From b66f84de79680aa71c4da98c05dc596055301481 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20M=C3=BCller-Seydlitz?= Date: Tue, 30 Jul 2024 21:09:58 +0200 Subject: [PATCH 001/476] Conformance with URLSessionDelegate, URLSessionTaskDelegate migrate to async/await --- NotificationService/NotificationService.swift | 48 ++++++++-------- .../Sources/OpenHABCore/Util/HTTPClient.swift | 56 ++++++++++++------- 2 files changed, 59 insertions(+), 45 deletions(-) diff --git a/NotificationService/NotificationService.swift b/NotificationService/NotificationService.swift index 038aacdc9..a030ec68f 100644 --- a/NotificationService/NotificationService.swift +++ b/NotificationService/NotificationService.swift @@ -18,23 +18,23 @@ import UserNotifications class NotificationService: UNNotificationServiceExtension { var contentHandler: ((UNNotificationContent) -> Void)? var bestAttemptContent: UNMutableNotificationContent? - + override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { self.contentHandler = contentHandler bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent) if let bestAttemptContent { var notificationActions: [UNNotificationAction] = [] let userInfo = bestAttemptContent.userInfo - + os_log("didReceive userInfo %{PUBLIC}@", log: .default, type: .info, userInfo) - + if let title = userInfo["title"] as? String { bestAttemptContent.title = title } if let message = userInfo["message"] as? String { bestAttemptContent.body = message } - + // Check if the user has defined custom actions in the payload if let actionsArray = parseActions(userInfo), let category = parseCategory(userInfo) { for actionDict in actionsArray { @@ -56,12 +56,12 @@ class NotificationService: UNNotificationServiceExtension { if !notificationActions.isEmpty { os_log("didReceive registering %{PUBLIC}@ for category %{PUBLIC}@", log: .default, type: .info, notificationActions, category) let notificationCategory = - UNNotificationCategory( - identifier: category, - actions: notificationActions, - intentIdentifiers: [], - options: .customDismissAction - ) + UNNotificationCategory( + identifier: category, + actions: notificationActions, + intentIdentifiers: [], + options: .customDismissAction + ) UNUserNotificationCenter.current().getNotificationCategories { existingCategories in var updatedCategories = existingCategories os_log("handleNotification adding category %{PUBLIC}@", log: .default, type: .info, category) @@ -70,13 +70,13 @@ class NotificationService: UNNotificationServiceExtension { } } } - + // check if there is an attachment to put on the notification // this should be last as we need to wait for media // TODO: we should support relative paths and try the user's openHAB (local,remote) for content if let attachmentURLString = userInfo["media-attachment-url"] as? String { let isItem = attachmentURLString.starts(with: "item:") - + let downloadCompletionHandler: @Sendable (UNNotificationAttachment?) -> Void = { attachment in if let attachment { os_log("handleNotification attaching %{PUBLIC}@", log: .default, type: .info, attachmentURLString) @@ -86,7 +86,7 @@ class NotificationService: UNNotificationServiceExtension { } contentHandler(bestAttemptContent) } - + if isItem { downloadAndAttachItemImage(itemURI: attachmentURLString, completion: downloadCompletionHandler) } else { @@ -97,7 +97,7 @@ class NotificationService: UNNotificationServiceExtension { } } } - + override func serviceExtensionTimeWillExpire() { // Called just before the extension will be terminated by the system. // Use this as an opportunity to deliver your "best attempt" at modified content, otherwise the original push payload will be used. @@ -106,7 +106,7 @@ class NotificationService: UNNotificationServiceExtension { contentHandler(bestAttemptContent) } } - + private func parseActions(_ userInfo: [AnyHashable: Any]) -> [[String: String]]? { // Extract actions and convert it from JSON string to an array of dictionaries if let actionsString = userInfo["actions"] as? String, let actionsData = actionsString.data(using: .utf8) { @@ -120,7 +120,7 @@ class NotificationService: UNNotificationServiceExtension { } return nil } - + private func parseCategory(_ userInfo: [AnyHashable: Any]) -> String? { // Extract category from aps dictionary if let aps = userInfo["aps"] as? [String: Any], @@ -129,10 +129,10 @@ class NotificationService: UNNotificationServiceExtension { } return nil } - + private func downloadAndAttachMedia(url: String, completion: @escaping (UNNotificationAttachment?) -> Void) { let client = HTTPClient(username: Preferences.username, password: Preferences.username, alwaysSendBasicAuth: Preferences.alwaysSendCreds) - + let downloadCompletionHandler: @Sendable (URL?, URLResponse?, Error?) -> Void = { (localURL, response, error) in guard let localURL else { os_log("Error downloading media %{PUBLIC}@", log: .default, type: .error, error?.localizedDescription ?? "Unknown error") @@ -147,16 +147,16 @@ class NotificationService: UNNotificationServiceExtension { client.downloadFile(url: url, completionHandler: downloadCompletionHandler) } } - + func downloadAndAttachItemImage(itemURI: String, completion: @escaping (UNNotificationAttachment?) -> Void) { guard let itemURI = URL(string: itemURI), let scheme = itemURI.scheme else { os_log("Could not find scheme %{PUBLIC}@", log: .default, type: .info) completion(nil) return } - + let itemName = String(itemURI.absoluteString.dropFirst(scheme.count + 1)) - + let client = HTTPClient(username: Preferences.username, password: Preferences.password, alwaysSendBasicAuth: Preferences.alwaysSendCreds) client.getItem(baseURLs: [Preferences.localUrl, Preferences.remoteUrl], itemName: itemName) { item, error in guard let item else { @@ -199,16 +199,16 @@ class NotificationService: UNNotificationServiceExtension { completion(nil) } } - + func attachFile(localURL: URL, mimeType: String?, completion: @escaping (UNNotificationAttachment?) -> Void) { do { let fileManager = FileManager.default let tempDirectory = NSTemporaryDirectory() let tempFile = URL(fileURLWithPath: tempDirectory).appendingPathComponent(UUID().uuidString) - + try fileManager.moveItem(at: localURL, to: tempFile) let attachment: UNNotificationAttachment? - + if let mimeType, let utType = UTType(mimeType: mimeType), utType.conforms(to: .data) { diff --git a/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift b/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift index 9bd824037..e7a1cb0c4 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift @@ -12,7 +12,7 @@ import Foundation import os.log -public class HTTPClient: NSObject, URLSessionDelegate, URLSessionTaskDelegate { +public class HTTPClient: NSObject { // MARK: - Properties private var session: URLSession! @@ -242,7 +242,6 @@ public class HTTPClient: NSObject, URLSessionDelegate, URLSessionTaskDelegate { request.httpBody = body.data(using: .utf8) request.setValue("text/plain", forHTTPHeaderField: "Content-Type") } - performRequest(request: request, download: download) { result, response, error in if let error { os_log("Error with URL %{public}@ : %{public}@", log: .networking, type: .error, url.absoluteString, error.localizedDescription) @@ -281,58 +280,73 @@ public class HTTPClient: NSObject, URLSessionDelegate, URLSessionTaskDelegate { task.resume() } + @available(watchOS 8.0, *) + @available(iOS 15.0, *) + private func performRequest(request: URLRequest, download: Bool) async throws -> (Any?, URLResponse?) { + var request = request + if alwaysSendBasicAuth { + request.setValue(basicAuthHeader(), forHTTPHeaderField: "Authorization") + } + if download { + return try await session.download(for: request) + } else { + return try await session.data(for: request) + } + } +} + +extension HTTPClient: URLSessionDelegate, URLSessionTaskDelegate { // MARK: - URLSessionDelegate for Client Certificates and Basic Auth - public func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { - urlSessionInternal(session, task: nil, didReceive: challenge, completionHandler: completionHandler) + public func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { + await urlSessionInternal(session, task: nil, didReceive: challenge) } - public func urlSession(_ session: URLSession, task: URLSessionTask, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { - urlSessionInternal(session, task: task, didReceive: challenge, completionHandler: completionHandler) + public func urlSession(_ session: URLSession, task: URLSessionTask, didReceive challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { + await urlSessionInternal(session, task: task, didReceive: challenge) } - private func urlSessionInternal(_ session: URLSession, task: URLSessionTask?, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { + private func urlSessionInternal(_ session: URLSession, task: URLSessionTask?, didReceive challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { os_log("URLAuthenticationChallenge: %{public}@", log: .networking, type: .info, challenge.protectionSpace.authenticationMethod) let authenticationMethod = challenge.protectionSpace.authenticationMethod switch authenticationMethod { case NSURLAuthenticationMethodServerTrust: - handleServerTrust(challenge: challenge, completionHandler: completionHandler) + return await handleServerTrust(challenge: challenge) case NSURLAuthenticationMethodDefault, NSURLAuthenticationMethodHTTPBasic: if let task { task.authAttemptCount += 1 if task.authAttemptCount > 1 { - completionHandler(.cancelAuthenticationChallenge, nil) + return (.cancelAuthenticationChallenge, nil) } else { - handleBasicAuth(challenge: challenge, completionHandler: completionHandler) + return await handleBasicAuth(challenge: challenge) } } else { - handleBasicAuth(challenge: challenge, completionHandler: completionHandler) + return await handleBasicAuth(challenge: challenge) } case NSURLAuthenticationMethodClientCertificate: - handleClientCertificateAuth(challenge: challenge, completionHandler: completionHandler) + return await handleClientCertificateAuth(challenge: challenge) default: - completionHandler(.performDefaultHandling, nil) + return (.performDefaultHandling, nil) } } - private func handleServerTrust(challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { + private func handleServerTrust(challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { guard let serverTrust = challenge.protectionSpace.serverTrust else { - completionHandler(.performDefaultHandling, nil) - return + return (.performDefaultHandling, nil) } let credential = URLCredential(trust: serverTrust) - completionHandler(.useCredential, credential) + return (.useCredential, credential) } - private func handleBasicAuth(challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { + private func handleBasicAuth(challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { let credential = URLCredential(user: username, password: password, persistence: .forSession) - completionHandler(.useCredential, credential) + return (.useCredential, credential) } - private func handleClientCertificateAuth(challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { + private func handleClientCertificateAuth(challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { let certificateManager = ClientCertificateManager() let (disposition, credential) = certificateManager.evaluateTrust(with: challenge) - completionHandler(disposition, credential) + return (disposition, credential) } } From 237940ec56707b196354debbcf7db40ff58f5fa6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20M=C3=BCller-Seydlitz?= Date: Wed, 7 Aug 2024 21:24:24 +0200 Subject: [PATCH 002/476] Got the swift-openapi-generator working on openHAB iOS app: -Properly gets data every 30s -Able to send commands -Making use of structured concurrency, ie async/await, actors -Still a lot to do Renamed OpenHABSitemapPage into OpenHABPage to avoid confusion Reworked OpenHABSitemap to properly handle embedded OpenHABPage Created convenience initializers for OpenHAB models to map from openAPI generated models Properly decoding widgets within a widget Manually modifying the OpenHAB's openAPI schema Manually adding X-Atmosphere-Transport in header parameters for pollDataPage Transferred code to package - requires workaround to invoke the CLI manually: https://swiftpackageindex.com/apple/swift-openapi-generator/1.2.1/documentation/swift-openapi-generator/manually-invoking-the-generator-cli : - clone the generator package locally - run locally swift run swift-openapi-generator generate --config ../Sources/OpenHABCore/openapi/openapi-generator-config.yml --output-directory ../GeneratedSources/openapi ../Sources/OpenHABCore/openapi/openapi.json Exclude the package and the generated code from swiftlint Async update for actor APIActor and initialiser with URL about:blank Using APIActor throughout the app Upgrade target to iOS 16 Helper function openHABpollPage(sitemapname: String, longPolling: Bool) for access without Making use internal accesModifier to properly isolate the internals in OpenHABCore Using openAPI generated interface to send command Support for basic authorization Making use of os logger --- .gitignore | 3 + BuildTools/.swiftlint.yml | 3 +- NotificationService/NotificationService.swift | 48 +- OpenHABCore/Package.swift | 14 +- .../Sources/OpenHABCore/Model/APIActor.swift | 340 + .../Model/OpenHABCommandDescription.swift | 2 +- .../Model/OpenHABCommandOptions.swift | 5 + .../OpenHABCore/Model/OpenHABItem.swift | 3 + .../OpenHABCore/Model/OpenHABOptions.swift | 5 + ...HABSitemapPage.swift => OpenHABPage.swift} | 16 +- .../OpenHABCore/Model/OpenHABSitemap.swift | 55 +- .../Model/OpenHABStateDescription.swift | 2 +- .../OpenHABCore/Model/OpenHABWidget.swift | 10 +- .../Model/OpenHABWidgetMapping.swift | 9 +- .../Sources/OpenHABCore/Util/Future.swift | 121 - .../OpenHABCore/Util/StringExtension.swift | 2 +- .../openapi/openapi-generator-config.yml | 9 + .../Sources/OpenHABCore/openapi/openapi.json | 9770 +++++++++++++++++ .../OpenHABCoreTests/JSONParserTests.swift | 16 +- openHAB.xcodeproj/project.pbxproj | 49 +- .../xcshareddata/swiftpm/Package.resolved | 476 +- .../OpenHABDrawerTableViewController.swift | 67 +- openHAB/OpenHABSitemapViewController.swift | 131 +- .../openapi/openapitest/openapiCorrected.json | 9756 ++++++++++++++++ .../Model/ObservableOpenHABWidget.swift | 6 +- .../openHABWatch Extension/UserData.swift | 61 - 26 files changed, 20350 insertions(+), 629 deletions(-) create mode 100644 OpenHABCore/Sources/OpenHABCore/Model/APIActor.swift rename OpenHABCore/Sources/OpenHABCore/Model/{OpenHABSitemapPage.swift => OpenHABPage.swift} (83%) delete mode 100644 OpenHABCore/Sources/OpenHABCore/Util/Future.swift create mode 100644 OpenHABCore/Sources/OpenHABCore/openapi/openapi-generator-config.yml create mode 100644 OpenHABCore/Sources/OpenHABCore/openapi/openapi.json create mode 100644 openHAB/openapi/openapitest/openapiCorrected.json diff --git a/.gitignore b/.gitignore index e716fd90b..92c6fa790 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,6 @@ openHAB.ipa build/ BuildTools/.build OpenHABCore/Package.resolved + +OpenHABCore/Sources/OpenHABCore/GeneratedSources +OpenHABCore/swift-openapi-generator diff --git a/BuildTools/.swiftlint.yml b/BuildTools/.swiftlint.yml index c6622e780..5646cc17b 100644 --- a/BuildTools/.swiftlint.yml +++ b/BuildTools/.swiftlint.yml @@ -29,7 +29,8 @@ excluded: - ../fastlane - ../OpenHABCore/.build - .build - + - ../OpenHABCore/Sources/OpenHABCore/GeneratedSources/* + - ../OpenHABCore/swift-openapi-generator nesting: type_level: 2 diff --git a/NotificationService/NotificationService.swift b/NotificationService/NotificationService.swift index 038aacdc9..a030ec68f 100644 --- a/NotificationService/NotificationService.swift +++ b/NotificationService/NotificationService.swift @@ -18,23 +18,23 @@ import UserNotifications class NotificationService: UNNotificationServiceExtension { var contentHandler: ((UNNotificationContent) -> Void)? var bestAttemptContent: UNMutableNotificationContent? - + override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { self.contentHandler = contentHandler bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent) if let bestAttemptContent { var notificationActions: [UNNotificationAction] = [] let userInfo = bestAttemptContent.userInfo - + os_log("didReceive userInfo %{PUBLIC}@", log: .default, type: .info, userInfo) - + if let title = userInfo["title"] as? String { bestAttemptContent.title = title } if let message = userInfo["message"] as? String { bestAttemptContent.body = message } - + // Check if the user has defined custom actions in the payload if let actionsArray = parseActions(userInfo), let category = parseCategory(userInfo) { for actionDict in actionsArray { @@ -56,12 +56,12 @@ class NotificationService: UNNotificationServiceExtension { if !notificationActions.isEmpty { os_log("didReceive registering %{PUBLIC}@ for category %{PUBLIC}@", log: .default, type: .info, notificationActions, category) let notificationCategory = - UNNotificationCategory( - identifier: category, - actions: notificationActions, - intentIdentifiers: [], - options: .customDismissAction - ) + UNNotificationCategory( + identifier: category, + actions: notificationActions, + intentIdentifiers: [], + options: .customDismissAction + ) UNUserNotificationCenter.current().getNotificationCategories { existingCategories in var updatedCategories = existingCategories os_log("handleNotification adding category %{PUBLIC}@", log: .default, type: .info, category) @@ -70,13 +70,13 @@ class NotificationService: UNNotificationServiceExtension { } } } - + // check if there is an attachment to put on the notification // this should be last as we need to wait for media // TODO: we should support relative paths and try the user's openHAB (local,remote) for content if let attachmentURLString = userInfo["media-attachment-url"] as? String { let isItem = attachmentURLString.starts(with: "item:") - + let downloadCompletionHandler: @Sendable (UNNotificationAttachment?) -> Void = { attachment in if let attachment { os_log("handleNotification attaching %{PUBLIC}@", log: .default, type: .info, attachmentURLString) @@ -86,7 +86,7 @@ class NotificationService: UNNotificationServiceExtension { } contentHandler(bestAttemptContent) } - + if isItem { downloadAndAttachItemImage(itemURI: attachmentURLString, completion: downloadCompletionHandler) } else { @@ -97,7 +97,7 @@ class NotificationService: UNNotificationServiceExtension { } } } - + override func serviceExtensionTimeWillExpire() { // Called just before the extension will be terminated by the system. // Use this as an opportunity to deliver your "best attempt" at modified content, otherwise the original push payload will be used. @@ -106,7 +106,7 @@ class NotificationService: UNNotificationServiceExtension { contentHandler(bestAttemptContent) } } - + private func parseActions(_ userInfo: [AnyHashable: Any]) -> [[String: String]]? { // Extract actions and convert it from JSON string to an array of dictionaries if let actionsString = userInfo["actions"] as? String, let actionsData = actionsString.data(using: .utf8) { @@ -120,7 +120,7 @@ class NotificationService: UNNotificationServiceExtension { } return nil } - + private func parseCategory(_ userInfo: [AnyHashable: Any]) -> String? { // Extract category from aps dictionary if let aps = userInfo["aps"] as? [String: Any], @@ -129,10 +129,10 @@ class NotificationService: UNNotificationServiceExtension { } return nil } - + private func downloadAndAttachMedia(url: String, completion: @escaping (UNNotificationAttachment?) -> Void) { let client = HTTPClient(username: Preferences.username, password: Preferences.username, alwaysSendBasicAuth: Preferences.alwaysSendCreds) - + let downloadCompletionHandler: @Sendable (URL?, URLResponse?, Error?) -> Void = { (localURL, response, error) in guard let localURL else { os_log("Error downloading media %{PUBLIC}@", log: .default, type: .error, error?.localizedDescription ?? "Unknown error") @@ -147,16 +147,16 @@ class NotificationService: UNNotificationServiceExtension { client.downloadFile(url: url, completionHandler: downloadCompletionHandler) } } - + func downloadAndAttachItemImage(itemURI: String, completion: @escaping (UNNotificationAttachment?) -> Void) { guard let itemURI = URL(string: itemURI), let scheme = itemURI.scheme else { os_log("Could not find scheme %{PUBLIC}@", log: .default, type: .info) completion(nil) return } - + let itemName = String(itemURI.absoluteString.dropFirst(scheme.count + 1)) - + let client = HTTPClient(username: Preferences.username, password: Preferences.password, alwaysSendBasicAuth: Preferences.alwaysSendCreds) client.getItem(baseURLs: [Preferences.localUrl, Preferences.remoteUrl], itemName: itemName) { item, error in guard let item else { @@ -199,16 +199,16 @@ class NotificationService: UNNotificationServiceExtension { completion(nil) } } - + func attachFile(localURL: URL, mimeType: String?, completion: @escaping (UNNotificationAttachment?) -> Void) { do { let fileManager = FileManager.default let tempDirectory = NSTemporaryDirectory() let tempFile = URL(fileURLWithPath: tempDirectory).appendingPathComponent(UUID().uuidString) - + try fileManager.moveItem(at: localURL, to: tempFile) let attachment: UNNotificationAttachment? - + if let mimeType, let utType = UTType(mimeType: mimeType), utType.conforms(to: .data) { diff --git a/OpenHABCore/Package.swift b/OpenHABCore/Package.swift index 8363eda5b..ffe19e384 100644 --- a/OpenHABCore/Package.swift +++ b/OpenHABCore/Package.swift @@ -1,11 +1,11 @@ -// swift-tools-version:5.5 +// swift-tools-version:5.9 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription let package = Package( name: "OpenHABCore", - platforms: [.iOS(.v12), .watchOS(.v6)], + platforms: [.iOS(.v16), .watchOS(.v10), .macOS(.v14)], products: [ // Products define the executables and libraries a package produces, and make them visible to other packages. .library( @@ -15,8 +15,10 @@ let package = Package( ], dependencies: [ // Dependencies declare other packages that this package depends on. - .package(name: "Alamofire", url: "https://github.com/Alamofire/Alamofire.git", from: "5.0.0"), - .package(name: "Kingfisher", url: "https://github.com/onevcat/Kingfisher.git", from: "7.0.0") + .package(url: "https://github.com/Alamofire/Alamofire.git", from: "5.0.0"), + .package(url: "https://github.com/onevcat/Kingfisher.git", from: "7.0.0"), + .package(url: "https://github.com/apple/swift-openapi-runtime", from: "1.0.0"), + .package(url: "https://github.com/apple/swift-openapi-urlsession", from: "1.0.0") ], targets: [ // Targets are the basic building blocks of a package. A target can define a module or a test suite. @@ -25,7 +27,9 @@ let package = Package( name: "OpenHABCore", dependencies: [ .product(name: "Alamofire", package: "Alamofire", condition: .when(platforms: [.iOS, .watchOS])), - .product(name: "Kingfisher", package: "Kingfisher", condition: .when(platforms: [.iOS, .watchOS])) + .product(name: "Kingfisher", package: "Kingfisher", condition: .when(platforms: [.iOS, .watchOS])), + .product(name: "OpenAPIRuntime", package: "swift-openapi-runtime"), + .product(name: "OpenAPIURLSession", package: "swift-openapi-urlsession") ] ), .testTarget( diff --git a/OpenHABCore/Sources/OpenHABCore/Model/APIActor.swift b/OpenHABCore/Sources/OpenHABCore/Model/APIActor.swift new file mode 100644 index 000000000..b0b708595 --- /dev/null +++ b/OpenHABCore/Sources/OpenHABCore/Model/APIActor.swift @@ -0,0 +1,340 @@ +// 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 + +// +// File.swift +// +// +// Created by Tim on 10.08.24. +// +import Foundation +import HTTPTypes +import OpenAPIRuntime +import OpenAPIURLSession +import os + +let logger = Logger(subsystem: "org.openhab.app", category: "apiactor") + +public protocol OpenHABSitemapsService { + func openHABSitemaps() async throws -> [OpenHABSitemap] +} + +public protocol OpenHABUiTileService { + func openHABTiles() async throws -> [OpenHABUiTile] +} + +// swiftlint:disable file_types_order + +public actor APIActor { + var api: APIProtocol + var url: URL? + var longPolling = false + var alwaysSendBasicAuth = false + var username: String + var password: String + + public init(username: String = "", password: String = "", alwaysSendBasicAuth: Bool = true) { + let url = "about:blank" + // TODO: Make use of prepareURLSessionConfiguration + let config = URLSessionConfiguration.default +// config.timeoutIntervalForRequest = if longPolling { 35.0 } else { 20.0 } +// config.timeoutIntervalForResource = config.timeoutIntervalForRequest + 25 + let session = URLSession(configuration: config) + self.username = username + self.password = password + self.alwaysSendBasicAuth = alwaysSendBasicAuth + + api = Client( + serverURL: URL(string: url)!, + transport: URLSessionTransport(configuration: .init(session: session)), + middlewares: [AuthorisationMiddleware(username: username, password: password, alwaysSendBasicAuth: alwaysSendBasicAuth)] + ) + } + + private func prepareURLSessionConfiguration(longPolling: Bool) -> URLSessionConfiguration { + let config = URLSessionConfiguration.default +// config.timeoutIntervalForRequest = if longPolling { 35.0 } else { 20.0 } +// config.timeoutIntervalForResource = config.timeoutIntervalForRequest + 25 + return config + } + + public func updateBaseURL(with newURL: URL) async { + if newURL != url { + let config = prepareURLSessionConfiguration(longPolling: longPolling) + let session = URLSession(configuration: config) + url = newURL + api = Client( + serverURL: newURL.appending(path: "/rest"), + transport: URLSessionTransport(configuration: .init(session: session)), + middlewares: [AuthorisationMiddleware(username: username, password: password)] + ) + } + } + + // timeoutIntervalForRequest/timeoutIntervalForResource need to be passed through URLSessionConfiguration when URLSession is created. Therefore create a new APIClient to change values. + public func updateForLongPolling(_ newlongPolling: Bool) async { + if newlongPolling != longPolling { + let config = prepareURLSessionConfiguration(longPolling: longPolling) + let session = URLSession(configuration: config) + longPolling = newlongPolling + api = Client( + serverURL: url!.appending(path: "/rest"), + transport: URLSessionTransport(configuration: .init(session: session)), + middlewares: [AuthorisationMiddleware(username: username, password: password)] + ) + } + } +} + +extension APIActor: OpenHABSitemapsService { + public func openHABSitemaps() async throws -> [OpenHABSitemap] { + try await api.getSitemaps(.init()) + .ok.body.json + .map(OpenHABSitemap.init) + } +} + +extension APIActor: OpenHABUiTileService { + public func openHABTiles() async throws -> [OpenHABUiTile] { + try await api.getUITiles(.init()) + .ok.body.json + .map(OpenHABUiTile.init) + } +} + +extension APIActor { + func openHABSitemap(path: Operations.getSitemapByName.Input.Path) async throws -> OpenHABSitemap? { + let result = try await api.getSitemapByName(path: path) + .ok.body.json + return OpenHABSitemap(result) + } +} + +extension APIActor { + // Internal function for pollPage + func openHABpollPage(path: Operations.pollDataForPage.Input.Path, + headers: Operations.pollDataForPage.Input.Headers) async throws -> OpenHABPage? { + let result = try await api.pollDataForPage(path: path, headers: headers) + .ok.body.json + return OpenHABPage(result) + } + + /// Poll page data on sitemap + /// - Parameters: + /// - sitemapname: name of sitemap + /// - longPolling: set to true for long-polling + public func openHABpollPage(sitemapname: String, longPolling: Bool) async throws -> OpenHABPage? { + var headers = Operations.pollDataForPage.Input.Headers() + + if longPolling { + logger.info("Long-polling, setting X-Atmosphere-Transport") + headers.X_hyphen_Atmosphere_hyphen_Transport = "long-polling" + } else { + headers.X_hyphen_Atmosphere_hyphen_Transport = nil + } + let path = Operations.pollDataForPage.Input.Path(sitemapname: sitemapname, pageid: sitemapname) + await updateForLongPolling(longPolling) + return try await openHABpollPage(path: path, headers: headers) + } +} + +extension APIActor { + func openHABSitemap(path: Operations.getSitemapByName.Input.Path, + headers: Operations.getSitemapByName.Input.Headers) async throws -> OpenHABSitemap? { + let result = try await api.getSitemapByName(path: path, headers: headers) + .ok.body.json + return OpenHABSitemap(result) + } +} + +// MARK: State changes and commands + +public extension APIActor { + func openHABUpdateItemState(itemname: String, with state: String) async throws { + let path = Operations.updateItemState.Input.Path(itemname: itemname) + let body = Operations.updateItemState.Input.Body.plainText(.init(state)) + let response = try await api.updateItemState(path: path, body: body) + _ = try response.accepted + } + + func openHABSendItemCommand(itemname: String, command: String) async throws { + let path = Operations.sendItemCommand.Input.Path(itemname: itemname) + let body = Operations.sendItemCommand.Input.Body.plainText(.init(command)) + let response = try await api.sendItemCommand(path: path, body: body) + _ = try response.ok + } +} + +public struct AuthorisationMiddleware { + private let username: String + private let password: String + private let alwaysSendBasicAuth: Bool + + public init(username: String, password: String, alwaysSendBasicAuth: Bool = false) { + self.username = username + self.password = password + self.alwaysSendBasicAuth = alwaysSendBasicAuth + } +} + +extension AuthorisationMiddleware: ClientMiddleware { + private func basicAuthHeader() -> String { + let credential = Data("\(username):\(password)".utf8).base64EncodedString() + return "Basic \(credential)" + } + + public func intercept(_ request: HTTPRequest, + body: HTTPBody?, + baseURL: URL, + operationID: String, + next: @Sendable (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?)) async throws -> (HTTPResponse, HTTPBody?) { + // Use a mutable copy of request + var request = request + + if ((baseURL.host?.hasSuffix("myopenhab.org")) == nil), alwaysSendBasicAuth, !username.isEmpty, !password.isEmpty { + request.headerFields[.authorization] = basicAuthHeader() + } + logger.info("Outgoing request: \(request.headerFields.debugDescription, privacy: .public)") + let (response, body) = try await next(request, body, baseURL) + logger.debug("Incoming response \(response.headerFields.debugDescription)") + return (response, body) + } +} + +extension OpenHABUiTile { + convenience init(_ tile: Components.Schemas.TileDTO) { + self.init(name: tile.name.orEmpty, url: tile.url.orEmpty, imageUrl: tile.imageUrl.orEmpty) + } +} + +extension OpenHABSitemap { + convenience init(_ sitemap: Components.Schemas.SitemapDTO) { + self.init( + name: sitemap.name.orEmpty, + icon: sitemap.icon.orEmpty, + label: sitemap.label.orEmpty, + link: sitemap.link.orEmpty, + page: OpenHABPage(sitemap.homepage) + ) + } +} + +extension OpenHABPage { + convenience init?(_ page: Components.Schemas.PageDTO?) { + if let page { + self.init( + pageId: page.id.orEmpty, + title: page.title.orEmpty, + link: page.link.orEmpty, + leaf: page.leaf ?? false, + widgets: page.widgets?.compactMap { OpenHABWidget($0) } ?? [], + icon: page.icon.orEmpty + ) + } else { + return nil + } + } +} + +extension OpenHABWidgetMapping { + convenience init(_ mapping: Components.Schemas.MappingDTO) { + self.init(command: mapping.command, label: mapping.label) + } +} + +extension OpenHABCommandOptions { + convenience init?(_ options: Components.Schemas.CommandOption?) { + if let options { + self.init(command: options.command.orEmpty, label: options.label.orEmpty) + } else { + return nil + } + } +} + +extension OpenHABOptions { + convenience init?(_ options: Components.Schemas.StateOption?) { + if let options { + self.init(value: options.value.orEmpty, label: options.label.orEmpty) + } else { + return nil + } + } +} + +extension OpenHABStateDescription { + convenience init?(_ state: Components.Schemas.StateDescription?) { + if let state { + self.init(minimum: state.minimum, maximum: state.maximum, step: state.step, readOnly: state.readOnly, options: state.options?.compactMap { OpenHABOptions($0) }, pattern: state.pattern) + } else { + return nil + } + } +} + +extension OpenHABCommandDescription { + convenience init?(_ commands: Components.Schemas.CommandDescription?) { + if let commands { + self.init(commandOptions: commands.commandOptions?.compactMap { OpenHABCommandOptions($0) }) + } else { + return nil + } + } +} + +// swiftlint:disable line_length +extension OpenHABItem { + convenience init?(_ item: Components.Schemas.EnrichedItemDTO?) { + if let item { + self.init(name: item.name.orEmpty, type: item._type.orEmpty, state: item.state.orEmpty, link: item.link.orEmpty, label: item.label.orEmpty, groupType: nil, stateDescription: OpenHABStateDescription(item.stateDescription), commandDescription: OpenHABCommandDescription(item.commandDescription), members: [], category: item.category, options: []) + } else { + return nil + } + } +} + +// swiftlint:enable line_length + +extension OpenHABWidget { + convenience init(_ widget: Components.Schemas.WidgetDTO) { + self.init( + widgetId: widget.widgetId.orEmpty, + label: widget.label.orEmpty, + icon: widget.icon.orEmpty, + type: OpenHABWidget.WidgetType(rawValue: widget._type!), + url: widget.url, + period: widget.period, + minValue: widget.minValue, + maxValue: widget.maxValue, + step: widget.step, + refresh: widget.refresh.map(Int.init), + height: 50, // TODO: + isLeaf: true, + iconColor: widget.iconcolor, + labelColor: widget.labelcolor, + valueColor: widget.valuecolor, + service: widget.service, + state: widget.state, + text: "", + legend: widget.legend, + encoding: widget.encoding, + item: OpenHABItem(widget.item), + linkedPage: OpenHABPage(widget.linkedPage), + mappings: widget.mappings?.compactMap(OpenHABWidgetMapping.init) ?? [], + widgets: widget.widgets?.compactMap { OpenHABWidget($0) } ?? [], + visibility: widget.visibility, + switchSupport: widget.switchSupport, + forceAsItem: widget.forceAsItem + ) + } +} + +// swiftlint:enable file_types_order diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABCommandDescription.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABCommandDescription.swift index 9cf6d3972..ce8eff968 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABCommandDescription.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABCommandDescription.swift @@ -14,7 +14,7 @@ import Foundation public class OpenHABCommandDescription { public var commandOptions: [OpenHABCommandOptions] = [] - init(commandOptions: [OpenHABCommandOptions]?) { + public init(commandOptions: [OpenHABCommandOptions]?) { self.commandOptions = commandOptions ?? [] } } diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABCommandOptions.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABCommandOptions.swift index 5484d2149..8010dc583 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABCommandOptions.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABCommandOptions.swift @@ -14,4 +14,9 @@ import Foundation public class OpenHABCommandOptions: Decodable { public var command = "" public var label = "" + + public init(command: String = "", label: String = "") { + self.command = command + self.label = label + } } diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABItem.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABItem.swift index f759251bb..9c2e97ad1 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABItem.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABItem.swift @@ -139,6 +139,7 @@ public extension OpenHABItem { } } +// swiftlint:disable line_length public extension OpenHABItem.CodingData { var openHABItem: OpenHABItem { let mappedMembers = members?.map(\.openHABItem) ?? [] @@ -147,6 +148,8 @@ public extension OpenHABItem.CodingData { } } +// swiftlint:enable line_length + extension CGFloat { init(state string: String, divisor: Float) { let numberFormatter = NumberFormatter() diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABOptions.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABOptions.swift index c98f5e1b4..d9a9983e9 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABOptions.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABOptions.swift @@ -14,4 +14,9 @@ import Foundation public class OpenHABOptions: Decodable { public var value = "" public var label = "" + + public init(value: String = "", label: String = "") { + self.value = value + self.label = label + } } diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABSitemapPage.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABPage.swift similarity index 83% rename from OpenHABCore/Sources/OpenHABCore/Model/OpenHABSitemapPage.swift rename to OpenHABCore/Sources/OpenHABCore/Model/OpenHABPage.swift index bc16248ad..0c9632dfc 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABSitemapPage.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABPage.swift @@ -12,7 +12,7 @@ import Foundation import os.log -public class OpenHABSitemapPage: NSObject { +public class OpenHABPage: NSObject { public var sendCommand: ((_ item: OpenHABItem, _ command: String?) -> Void)? public var widgets: [OpenHABWidget] = [] public var pageId = "" @@ -46,9 +46,9 @@ public class OpenHABSitemapPage: NSObject { } } -public extension OpenHABSitemapPage { - func filter(_ isIncluded: (OpenHABWidget) throws -> Bool) rethrows -> OpenHABSitemapPage { - let filteredOpenHABSitemapPage = try OpenHABSitemapPage( +public extension OpenHABPage { + func filter(_ isIncluded: (OpenHABWidget) throws -> Bool) rethrows -> OpenHABPage { + let filteredOpenHABSitemapPage = try OpenHABPage( pageId: pageId, title: title, link: link, @@ -60,7 +60,7 @@ public extension OpenHABSitemapPage { } } -public extension OpenHABSitemapPage { +public extension OpenHABPage { struct CodingData: Decodable { let pageId: String? let title: String? @@ -80,9 +80,9 @@ public extension OpenHABSitemapPage { } } -public extension OpenHABSitemapPage.CodingData { - var openHABSitemapPage: OpenHABSitemapPage { +public extension OpenHABPage.CodingData { + var openHABSitemapPage: OpenHABPage { let mappedWidgets = widgets?.map(\.openHABWidget) ?? [] - return OpenHABSitemapPage(pageId: pageId.orEmpty, title: title.orEmpty, link: link.orEmpty, leaf: leaf ?? false, widgets: mappedWidgets, icon: icon.orEmpty) + return OpenHABPage(pageId: pageId.orEmpty, title: title.orEmpty, link: link.orEmpty, leaf: leaf ?? false, widgets: mappedWidgets, icon: icon.orEmpty) } } diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABSitemap.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABSitemap.swift index 1eaeda6b6..00a03e867 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABSitemap.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABSitemap.swift @@ -32,16 +32,22 @@ public final class OpenHABSitemap: NSObject { public var icon = "" public var label = "" public var link = "" - public var leaf: Bool? - public var homepageLink = "" + public var page: OpenHABPage? - public init(name: String, icon: String, label: String, link: String, leaf: Bool, homepageLink: String) { + public var leaf: Bool? { + page?.leaf + } + + public var homepageLink: String { + page?.link ?? "" + } + + public init(name: String, icon: String, label: String, link: String, page: OpenHABPage?) { self.name = name self.icon = icon self.label = label self.link = link - self.leaf = leaf - self.homepageLink = homepageLink + self.page = page } } @@ -49,7 +55,7 @@ public extension OpenHABSitemap { struct CodingData: Decodable { public let name: String public let label: String - public let page: HomePage + public let page: OpenHABPage.CodingData? public let link: String public let icon: String? @@ -71,40 +77,6 @@ public extension OpenHABSitemap { icon = try container.decodeIfPresent(forKey: .icon) } } - - internal enum PageType: Decodable { - case homepage(HomePage) - case linkedPage(HomePage) - - private enum CodingKeys: String, CodingKey { - case homepage - case linkedPage - } - - enum PostTypeCodingError: Error { - case decoding(String) - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - if let homePageValue = try? container.decode(HomePage.self, forKey: .homepage) { - self = .homepage(homePageValue) - return - } - if let linkedPageValue = try? container.decode(HomePage.self, forKey: .linkedPage) { - self = .linkedPage(linkedPageValue) - return - } - throw PostTypeCodingError.decoding("Whoops! \(dump(container))") - } - } - - struct HomePage: Decodable { - public let link: String - public let leaf: Bool - public let timeout: ValueOrFalse? - public let widgets: [OpenHABWidget.CodingData]? - } } public extension OpenHABSitemap.CodingData { @@ -114,8 +86,7 @@ public extension OpenHABSitemap.CodingData { icon: icon.orEmpty, label: label, link: link, - leaf: page.leaf, - homepageLink: page.link + page: page?.openHABSitemapPage ) } } diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABStateDescription.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABStateDescription.swift index 035119729..65bebcee0 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABStateDescription.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABStateDescription.swift @@ -21,7 +21,7 @@ public class OpenHABStateDescription { public var numberPattern: String? - init(minimum: Double?, maximum: Double?, step: Double?, readOnly: Bool?, options: [OpenHABOptions]?, pattern: String?) { + public init(minimum: Double?, maximum: Double?, step: Double?, readOnly: Bool?, options: [OpenHABOptions]?, pattern: String?) { self.minimum = minimum ?? 0.0 self.maximum = maximum ?? 100.0 self.step = step ?? 1.0 diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift index 44773b735..e6b894a27 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift @@ -40,7 +40,7 @@ protocol Widget: AnyObject { var legend: Bool { get set } var encoding: String { get set } var item: OpenHABItem? { get set } - var linkedPage: OpenHABSitemapPage? { get set } + var linkedPage: OpenHABPage? { get set } var mappings: [OpenHABWidgetMapping] { get set } var image: UIImage? { get set } var widgets: [ChildWidget] { get set } @@ -92,7 +92,7 @@ public class OpenHABWidget: NSObject, MKAnnotation, Identifiable { public var encoding = "" public var forceAsItem: Bool? public var item: OpenHABItem? - public var linkedPage: OpenHABSitemapPage? + public var linkedPage: OpenHABPage? public var mappings: [OpenHABWidgetMapping] = [] public var image: UIImage? public var widgets: [OpenHABWidget] = [] @@ -212,9 +212,9 @@ extension OpenHABWidget.WidgetType: UnknownCaseRepresentable { static var unknownCase: OpenHABWidget.WidgetType = .unknown } -extension OpenHABWidget { +public extension OpenHABWidget { // This is an ugly initializer - convenience init(widgetId: String, label: String, icon: String, type: WidgetType, url: String?, period: String?, minValue: Double?, maxValue: Double?, step: Double?, refresh: Int?, height: Double?, isLeaf: Bool?, iconColor: String?, labelColor: String?, valueColor: String?, service: String?, state: String?, text: String?, legend: Bool?, encoding: String?, item: OpenHABItem?, linkedPage: OpenHABSitemapPage?, mappings: [OpenHABWidgetMapping], widgets: [OpenHABWidget], visibility: Bool?, switchSupport: Bool?, forceAsItem: Bool?) { + convenience init(widgetId: String, label: String, icon: String, type: WidgetType, url: String?, period: String?, minValue: Double?, maxValue: Double?, step: Double?, refresh: Int?, height: Double?, isLeaf: Bool?, iconColor: String?, labelColor: String?, valueColor: String?, service: String?, state: String?, text: String?, legend: Bool?, encoding: String?, item: OpenHABItem?, linkedPage: OpenHABPage?, mappings: [OpenHABWidgetMapping], widgets: [OpenHABWidget], visibility: Bool?, switchSupport: Bool?, forceAsItem: Bool?) { self.init() id = widgetId self.widgetId = widgetId @@ -281,7 +281,7 @@ public extension OpenHABWidget { let encoding: String? let groupType: String? let item: OpenHABItem.CodingData? - let linkedPage: OpenHABSitemapPage.CodingData? + let linkedPage: OpenHABPage.CodingData? let mappings: [OpenHABWidgetMapping] let widgets: [OpenHABWidget.CodingData] let visibility: Bool? diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidgetMapping.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidgetMapping.swift index a899bc90a..6d2d1499f 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidgetMapping.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidgetMapping.swift @@ -14,12 +14,9 @@ import Foundation public class OpenHABWidgetMapping: NSObject, Decodable { public var command = "" public var label = "" -} -public extension OpenHABWidgetMapping { - convenience init(command: String, label: String) { - self.init() - self.command = command - self.label = label + public init(command: String?, label: String?) { + self.command = command.orEmpty + self.label = label.orEmpty } } diff --git a/OpenHABCore/Sources/OpenHABCore/Util/Future.swift b/OpenHABCore/Sources/OpenHABCore/Util/Future.swift deleted file mode 100644 index a2cc029d8..000000000 --- a/OpenHABCore/Sources/OpenHABCore/Util/Future.swift +++ /dev/null @@ -1,121 +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 Foundation - -// swiftlint:disable:next file_types_order -public class Future { - public typealias Result = Swift.Result - - fileprivate var result: Result? { - // Observe whenever a result is assigned, and report it: - didSet { result.map(report) } - } - - private var callbacks = [(Result) -> Void]() - - public func observe(using callback: @escaping (Result) -> Void) { - // If a result has already been set, call the callback directly: - if let result { - return callback(result) - } - - callbacks.append(callback) - } - - private func report(result: Result) { - callbacks.forEach { $0(result) } - callbacks = [] - } -} - -public class Promise: Future { - public init(value: Value? = nil) { - super.init() - - // If the value was already known at the time the promise - // was constructed, we can report it directly: - result = value.map(Result.success) - } - - public func resolve(with value: Value) { - result = .success(value) - } - - public func reject(with error: Error) { - result = .failure(error) - } -} - -public enum NetworkingError: Error { - case invalidURL -} - -public typealias Networking = (Endpoint) -> Future - -extension Future { - func chained(using closure: @escaping (Value) throws -> Future) -> Future { - // We'll start by constructing a "wrapper" promise that will be - // returned from this method: - let promise = Promise() - - // Observe the current future: - observe { result in - switch result { - case let .success(value): - do { - // Attempt to construct a new future using the value - // returned from the first one: - let future = try closure(value) - - // Observe the "nested" future, and once it - // completes, resolve/reject the "wrapper" future: - future.observe { result in - switch result { - case let .success(value): - promise.resolve(with: value) - case let .failure(error): - promise.reject(with: error) - } - } - } catch { - promise.reject(with: error) - } - case let .failure(error): - promise.reject(with: error) - } - } - - return promise - } -} - -public extension Future { - func transformed(with closure: @escaping (Value) throws -> T) -> Future { - chained { value in - try Promise(value: closure(value)) - } - } -} - -// extension Future where Value == Data { -// func decoded() -> Future { -// decoded(as: T.self, using: JSONDecoder()) -// } -// } - -public extension Future where Value == Data { - func decoded(as type: T.Type = T.self, using decoder: JSONDecoder = .init()) -> Future { - transformed { data in - try decoder.decode(T.self, from: data) - } - } -} diff --git a/OpenHABCore/Sources/OpenHABCore/Util/StringExtension.swift b/OpenHABCore/Sources/OpenHABCore/Util/StringExtension.swift index 7ff82430e..dc725c777 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/StringExtension.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/StringExtension.swift @@ -136,7 +136,7 @@ public extension String { } } -extension String? { +public extension String? { var orEmpty: String { switch self { case let .some(value): diff --git a/OpenHABCore/Sources/OpenHABCore/openapi/openapi-generator-config.yml b/OpenHABCore/Sources/OpenHABCore/openapi/openapi-generator-config.yml new file mode 100644 index 000000000..3a3188b22 --- /dev/null +++ b/OpenHABCore/Sources/OpenHABCore/openapi/openapi-generator-config.yml @@ -0,0 +1,9 @@ +generate: + - types + - client +accessModifier: internal +filter: + tags: + - sitemaps + - ui + - items diff --git a/OpenHABCore/Sources/OpenHABCore/openapi/openapi.json b/OpenHABCore/Sources/OpenHABCore/openapi/openapi.json new file mode 100644 index 000000000..facdf003f --- /dev/null +++ b/OpenHABCore/Sources/OpenHABCore/openapi/openapi.json @@ -0,0 +1,9770 @@ +{ + "openapi": "3.0.1", + "info": { + "title": "openHAB REST API", + "contact": { + "name": "openHAB", + "url": "https://www.openhab.org/docs/" + }, + "version": "8" + }, + "servers": [ + { + "url": "/rest" + } + ], + "paths": { + "/module-types": { + "get": { + "tags": [ + "module-types" + ], + "summary": "Get all available module types.", + "operationId": "getModuleTypes", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + }, + { + "name": "tags", + "in": "query", + "description": "tags for filtering", + "schema": { + "type": "string" + } + }, + { + "name": "type", + "in": "query", + "description": "filtering by action, condition or trigger", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ModuleTypeDTO" + } + } + } + } + } + } + } + }, + "/module-types/{moduleTypeUID}": { + "get": { + "tags": [ + "module-types" + ], + "summary": "Gets a module type corresponding to the given UID.", + "operationId": "getModuleTypeById", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + }, + { + "name": "moduleTypeUID", + "in": "path", + "description": "moduleTypeUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModuleTypeDTO" + } + } + } + }, + "404": { + "description": "Module Type corresponding to the given UID does not found." + } + } + } + }, + "/rules": { + "get": { + "tags": [ + "rules" + ], + "summary": "Get available rules, optionally filtered by tags and/or prefix.", + "operationId": "getRules", + "parameters": [ + { + "name": "prefix", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "tags", + "in": "query", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "name": "summary", + "in": "query", + "description": "summary fields only", + "schema": { + "type": "boolean" + } + }, + { + "name": "staticDataOnly", + "in": "query", + "description": "provides a cacheable list of values not expected to change regularly and honors the If-Modified-Since header, all other parameters are ignored", + "schema": { + "type": "boolean", + "default": false + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EnrichedRuleDTO" + } + } + } + } + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + }, + "post": { + "tags": [ + "rules" + ], + "summary": "Creates a rule.", + "operationId": "createRule", + "requestBody": { + "description": "rule data", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RuleDTO" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Created", + "headers": { + "Location": { + "description": "Newly created Rule", + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "Creation of the rule is refused. Missing required parameter." + }, + "409": { + "description": "Creation of the rule is refused. Rule with the same UID already exists." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/rules/{ruleUID}/enable": { + "post": { + "tags": [ + "rules" + ], + "summary": "Sets the rule enabled status.", + "operationId": "enableRule", + "parameters": [ + { + "name": "ruleUID", + "in": "path", + "description": "ruleUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "enable", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "Rule corresponding to the given UID does not found." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/rules/{ruleUID}/actions": { + "get": { + "tags": [ + "rules" + ], + "summary": "Gets the rule actions.", + "operationId": "getRuleActions", + "parameters": [ + { + "name": "ruleUID", + "in": "path", + "description": "ruleUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ActionDTO" + } + } + } + } + }, + "404": { + "description": "Rule corresponding to the given UID does not found." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/rules/{ruleUID}": { + "get": { + "tags": [ + "rules" + ], + "summary": "Gets the rule corresponding to the given UID.", + "operationId": "getRuleById", + "parameters": [ + { + "name": "ruleUID", + "in": "path", + "description": "ruleUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EnrichedRuleDTO" + } + } + } + }, + "404": { + "description": "Rule not found" + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + }, + "put": { + "tags": [ + "rules" + ], + "summary": "Updates an existing rule corresponding to the given UID.", + "operationId": "updateRule", + "parameters": [ + { + "name": "ruleUID", + "in": "path", + "description": "ruleUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "rule data", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RuleDTO" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "Rule corresponding to the given UID does not found." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + }, + "delete": { + "tags": [ + "rules" + ], + "summary": "Removes an existing rule corresponding to the given UID.", + "operationId": "deleteRule", + "parameters": [ + { + "name": "ruleUID", + "in": "path", + "description": "ruleUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "Rule corresponding to the given UID does not found." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/rules/{ruleUID}/conditions": { + "get": { + "tags": [ + "rules" + ], + "summary": "Gets the rule conditions.", + "operationId": "getRuleConditions", + "parameters": [ + { + "name": "ruleUID", + "in": "path", + "description": "ruleUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ConditionDTO" + } + } + } + } + }, + "404": { + "description": "Rule corresponding to the given UID does not found." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/rules/{ruleUID}/config": { + "get": { + "tags": [ + "rules" + ], + "summary": "Gets the rule configuration values.", + "operationId": "getRuleConfiguration", + "parameters": [ + { + "name": "ruleUID", + "in": "path", + "description": "ruleUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + }, + "404": { + "description": "Rule corresponding to the given UID does not found." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + }, + "put": { + "tags": [ + "rules" + ], + "summary": "Sets the rule configuration values.", + "operationId": "updateRuleConfiguration", + "parameters": [ + { + "name": "ruleUID", + "in": "path", + "description": "ruleUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "config", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "type": "object" + } + } + } + } + }, + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "Rule corresponding to the given UID does not found." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/rules/{ruleUID}/{moduleCategory}/{id}": { + "get": { + "tags": [ + "rules" + ], + "summary": "Gets the rule's module corresponding to the given Category and ID.", + "operationId": "getRuleModuleById", + "parameters": [ + { + "name": "ruleUID", + "in": "path", + "description": "ruleUID", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "moduleCategory", + "in": "path", + "description": "moduleCategory", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "id", + "in": "path", + "description": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModuleDTO" + } + } + } + }, + "404": { + "description": "Rule corresponding to the given UID does not found or does not have a module with such Category and ID." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/rules/{ruleUID}/{moduleCategory}/{id}/config": { + "get": { + "tags": [ + "rules" + ], + "summary": "Gets the module's configuration.", + "operationId": "getRuleModuleConfig", + "parameters": [ + { + "name": "ruleUID", + "in": "path", + "description": "ruleUID", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "moduleCategory", + "in": "path", + "description": "moduleCategory", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "id", + "in": "path", + "description": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + }, + "404": { + "description": "Rule corresponding to the given UID does not found or does not have a module with such Category and ID." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/rules/{ruleUID}/{moduleCategory}/{id}/config/{param}": { + "get": { + "tags": [ + "rules" + ], + "summary": "Gets the module's configuration parameter.", + "operationId": "getRuleModuleConfigParameter", + "parameters": [ + { + "name": "ruleUID", + "in": "path", + "description": "ruleUID", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "moduleCategory", + "in": "path", + "description": "moduleCategory", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "id", + "in": "path", + "description": "id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "param", + "in": "path", + "description": "param", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "404": { + "description": "Rule corresponding to the given UID does not found or does not have a module with such Category and ID." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + }, + "put": { + "tags": [ + "rules" + ], + "summary": "Sets the module's configuration parameter value.", + "operationId": "setRuleModuleConfigParameter", + "parameters": [ + { + "name": "ruleUID", + "in": "path", + "description": "ruleUID", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "moduleCategory", + "in": "path", + "description": "moduleCategory", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "id", + "in": "path", + "description": "id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "param", + "in": "path", + "description": "param", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "value", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "Rule corresponding to the given UID does not found or does not have a module with such Category and ID." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/rules/{ruleUID}/triggers": { + "get": { + "tags": [ + "rules" + ], + "summary": "Gets the rule triggers.", + "operationId": "getRuleTriggers", + "parameters": [ + { + "name": "ruleUID", + "in": "path", + "description": "ruleUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TriggerDTO" + } + } + } + } + }, + "404": { + "description": "Rule corresponding to the given UID does not found." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/rules/{ruleUID}/runnow": { + "post": { + "tags": [ + "rules" + ], + "summary": "Executes actions of the rule.", + "operationId": "runRuleNow_1", + "parameters": [ + { + "name": "ruleUID", + "in": "path", + "description": "ruleUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "the context for running this rule", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "type": "object" + } + } + } + } + }, + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "Rule corresponding to the given UID does not found." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/rules/schedule/simulations": { + "get": { + "tags": [ + "rules" + ], + "summary": "Simulates the executions of rules filtered by tag 'Schedule' within the given times.", + "operationId": "getScheduleRuleSimulations", + "parameters": [ + { + "name": "from", + "in": "query", + "description": "Start time of the simulated rule executions. Will default to the current time. [yyyy-MM-dd'T'HH:mm:ss.SSSZ]", + "schema": { + "type": "string" + } + }, + { + "name": "until", + "in": "query", + "description": "End time of the simulated rule executions. Will default to 30 days after the start time. Must be less than 180 days after the given start time. [yyyy-MM-dd'T'HH:mm:ss.SSSZ]", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RuleExecution" + } + } + } + } + }, + "400": { + "description": "The max. simulation duration of 180 days is exceeded." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/templates": { + "get": { + "tags": [ + "templates" + ], + "summary": "Get all available templates.", + "operationId": "getTemplates", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Template" + } + } + } + } + } + } + } + }, + "/templates/{templateUID}": { + "get": { + "tags": [ + "templates" + ], + "summary": "Gets a template corresponding to the given UID.", + "operationId": "getTemplateById", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + }, + { + "name": "templateUID", + "in": "path", + "description": "templateUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Template" + } + } + } + }, + "404": { + "description": "Template corresponding to the given UID does not found." + } + } + } + }, + "/actions/{thingUID}/{actionUid}": { + "post": { + "tags": [ + "actions" + ], + "summary": "Executes a thing action.", + "operationId": "executeThingAction", + "parameters": [ + { + "name": "thingUID", + "in": "path", + "description": "thingUID", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "actionUid", + "in": "path", + "description": "action type UID (including scope, separated by '.')", + "required": true, + "schema": { + "pattern": "[a-zA-Z0-9]+\\.[a-zA-Z0-9]+", + "type": "string" + } + }, + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "action inputs as map (parameter name as key / argument as value)", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "type": "object" + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + }, + "404": { + "description": "Action not found" + }, + "500": { + "description": "Creation of action handler or execution failed" + } + } + } + }, + "/actions/{thingUID}": { + "get": { + "tags": [ + "actions" + ], + "summary": "Get all available actions for provided thing UID", + "operationId": "getAvailableActionsForThing", + "parameters": [ + { + "name": "thingUID", + "in": "path", + "description": "thingUID", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "uniqueItems": true, + "type": "array", + "items": { + "$ref": "#/components/schemas/ThingActionDTO" + } + } + } + } + }, + "204": { + "description": "No actions found." + } + } + } + }, + "/uuid": { + "get": { + "tags": [ + "uuid" + ], + "summary": "A unified unique id.", + "operationId": "getUUID", + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + "/audio/defaultsink": { + "get": { + "tags": [ + "audio" + ], + "summary": "Get the default sink if defined or the first available sink.", + "operationId": "getAudioDefaultSink", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AudioSinkDTO" + } + } + } + }, + "404": { + "description": "Sink not found" + } + } + } + }, + "/audio/defaultsource": { + "get": { + "tags": [ + "audio" + ], + "summary": "Get the default source if defined or the first available source.", + "operationId": "getAudioDefaultSource", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AudioSourceDTO" + } + } + } + }, + "404": { + "description": "Source not found" + } + } + } + }, + "/audio/sinks": { + "get": { + "tags": [ + "audio" + ], + "summary": "Get the list of all sinks.", + "operationId": "getAudioSinks", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AudioSinkDTO" + } + } + } + } + } + } + } + }, + "/audio/sources": { + "get": { + "tags": [ + "audio" + ], + "summary": "Get the list of all sources.", + "operationId": "getAudioSources", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AudioSourceDTO" + } + } + } + } + } + } + } + }, + "/auth/logout": { + "post": { + "tags": [ + "auth" + ], + "summary": "Delete the session associated with a refresh token.", + "operationId": "deleteSession", + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "type": "object", + "properties": { + "refresh_token": { + "type": "string" + }, + "id": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK" + }, + "401": { + "description": "User is not authenticated" + }, + "404": { + "description": "User or refresh token not found" + } + } + } + }, + "/auth/apitokens": { + "get": { + "tags": [ + "auth" + ], + "summary": "List the API tokens associated to the authenticated user.", + "operationId": "getApiTokens", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserApiTokenDTO" + } + } + } + } + }, + "401": { + "description": "User is not authenticated" + }, + "404": { + "description": "User not found" + } + } + } + }, + "/auth/sessions": { + "get": { + "tags": [ + "auth" + ], + "summary": "List the sessions associated to the authenticated user.", + "operationId": "getSessionsForCurrentUser", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserSessionDTO" + } + } + } + } + }, + "401": { + "description": "User is not authenticated" + }, + "404": { + "description": "User not found" + } + } + } + }, + "/auth/token": { + "post": { + "tags": [ + "auth" + ], + "summary": "Get access and refresh tokens.", + "operationId": "getOAuthToken", + "parameters": [ + { + "name": "useCookie", + "in": "query", + "schema": { + "type": "boolean" + } + } + ], + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "type": "object", + "properties": { + "grant_type": { + "type": "string" + }, + "code": { + "type": "string" + }, + "redirect_uri": { + "type": "string" + }, + "client_id": { + "type": "string" + }, + "refresh_token": { + "type": "string" + }, + "code_verifier": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TokenResponseDTO" + } + } + } + }, + "400": { + "description": "Invalid request parameters" + } + } + } + }, + "/auth/apitokens/{name}": { + "delete": { + "tags": [ + "auth" + ], + "summary": "Revoke a specified API token associated to the authenticated user.", + "operationId": "removeApiToken", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "401": { + "description": "User is not authenticated" + }, + "404": { + "description": "User or API token not found" + } + } + } + }, + "/addons": { + "get": { + "tags": [ + "addons" + ], + "summary": "Get all add-ons.", + "operationId": "getAddons", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + }, + { + "name": "serviceId", + "in": "query", + "description": "service ID", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Addon" + } + } + } + } + }, + "404": { + "description": "Service not found" + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/addons/{addonId}": { + "get": { + "tags": [ + "addons" + ], + "summary": "Get add-on with given ID.", + "operationId": "getAddonById", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + }, + { + "name": "addonId", + "in": "path", + "description": "addon ID", + "required": true, + "schema": { + "pattern": "[a-zA-Z_0-9-:]+", + "type": "string" + } + }, + { + "name": "serviceId", + "in": "query", + "description": "service ID", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Addon" + } + } + } + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/addons/{addonId}/config": { + "get": { + "tags": [ + "addons" + ], + "summary": "Get add-on configuration for given add-on ID.", + "operationId": "getAddonConfiguration", + "parameters": [ + { + "name": "addonId", + "in": "path", + "description": "addon ID", + "required": true, + "schema": { + "pattern": "[a-zA-Z_0-9-:]+", + "type": "string" + } + }, + { + "name": "serviceId", + "in": "query", + "description": "service ID", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + }, + "404": { + "description": "Add-on does not exist" + }, + "500": { + "description": "Configuration can not be read due to internal error" + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + }, + "put": { + "tags": [ + "addons" + ], + "summary": "Updates an add-on configuration for given ID and returns the old configuration.", + "operationId": "updateAddonConfiguration", + "parameters": [ + { + "name": "addonId", + "in": "path", + "description": "Add-on id", + "required": true, + "schema": { + "pattern": "[a-zA-Z_0-9-:]+", + "type": "string" + } + }, + { + "name": "serviceId", + "in": "query", + "description": "service ID", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "type": "object" + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + }, + "204": { + "description": "No old configuration" + }, + "404": { + "description": "Add-on does not exist" + }, + "500": { + "description": "Configuration can not be updated due to internal error" + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/addons/services": { + "get": { + "tags": [ + "addons" + ], + "summary": "Get all add-on types.", + "operationId": "getAddonTypes", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AddonType" + } + } + } + } + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/addons/suggestions": { + "get": { + "tags": [ + "addons" + ], + "summary": "Get suggested add-ons to be installed.", + "operationId": "getSuggestedAddons", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Addon" + } + } + } + } + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/addons/types": { + "get": { + "tags": [ + "addons" + ], + "summary": "Get add-on services.", + "operationId": "getAddonServices", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + }, + { + "name": "serviceId", + "in": "query", + "description": "service ID", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AddonType" + } + } + } + } + }, + "404": { + "description": "Service not found" + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/addons/{addonId}/install": { + "post": { + "tags": [ + "addons" + ], + "summary": "Installs the add-on with the given ID.", + "operationId": "installAddonById", + "parameters": [ + { + "name": "addonId", + "in": "path", + "description": "addon ID", + "required": true, + "schema": { + "pattern": "[a-zA-Z_0-9-:]+", + "type": "string" + } + }, + { + "name": "serviceId", + "in": "query", + "description": "service ID", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/addons/url/{url}/install": { + "post": { + "tags": [ + "addons" + ], + "summary": "Installs the add-on from the given URL.", + "operationId": "installAddonFromURL", + "parameters": [ + { + "name": "url", + "in": "path", + "description": "addon install URL", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "The given URL is malformed or not valid." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/addons/{addonId}/uninstall": { + "post": { + "tags": [ + "addons" + ], + "summary": "Uninstalls the add-on with the given ID.", + "operationId": "uninstallAddon", + "parameters": [ + { + "name": "addonId", + "in": "path", + "description": "addon ID", + "required": true, + "schema": { + "pattern": "[a-zA-Z_0-9-:]+", + "type": "string" + } + }, + { + "name": "serviceId", + "in": "query", + "description": "service ID", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/channel-types": { + "get": { + "tags": [ + "channel-types" + ], + "summary": "Gets all available channel types.", + "operationId": "getChannelTypes", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + }, + { + "name": "prefixes", + "in": "query", + "description": "filter UIDs by prefix (multiple comma-separated prefixes allowed, for example: 'system,mqtt')", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "uniqueItems": true, + "type": "array", + "items": { + "$ref": "#/components/schemas/ChannelTypeDTO" + } + } + } + } + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/channel-types/{channelTypeUID}": { + "get": { + "tags": [ + "channel-types" + ], + "summary": "Gets channel type by UID.", + "operationId": "getChannelTypeByUID", + "parameters": [ + { + "name": "channelTypeUID", + "in": "path", + "description": "channelTypeUID", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Channel type with provided channelTypeUID does not exist.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChannelTypeDTO" + } + } + } + }, + "404": { + "description": "No content" + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/channel-types/{channelTypeUID}/linkableItemTypes": { + "get": { + "tags": [ + "channel-types" + ], + "summary": "Gets the item types the given trigger channel type UID can be linked to.", + "operationId": "getLinkableItemTypesByChannelTypeUID", + "parameters": [ + { + "name": "channelTypeUID", + "in": "path", + "description": "channelTypeUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "uniqueItems": true, + "type": "array", + "items": { + "type": "string" + } + } + } + } + }, + "204": { + "description": "No content: channel type has no linkable items or is no trigger channel." + }, + "404": { + "description": "Given channel type UID not found." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/config-descriptions": { + "get": { + "tags": [ + "config-descriptions" + ], + "summary": "Gets all available config descriptions.", + "operationId": "getConfigDescriptions", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + }, + { + "name": "scheme", + "in": "query", + "description": "scheme filter", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ConfigDescriptionDTO" + } + } + } + } + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/config-descriptions/{uri}": { + "get": { + "tags": [ + "config-descriptions" + ], + "summary": "Gets a config description by URI.", + "operationId": "getConfigDescriptionByURI", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + }, + { + "name": "uri", + "in": "path", + "description": "uri", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConfigDescriptionDTO" + } + } + } + }, + "400": { + "description": "Invalid URI syntax" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/discovery": { + "get": { + "tags": [ + "discovery" + ], + "summary": "Gets all bindings that support discovery.", + "operationId": "getBindingsWithDiscoverySupport", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "uniqueItems": true, + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/discovery/bindings/{bindingId}/scan": { + "post": { + "tags": [ + "discovery" + ], + "summary": "Starts asynchronous discovery process for a binding and returns the timeout in seconds of the discovery operation.", + "operationId": "scan", + "parameters": [ + { + "name": "bindingId", + "in": "path", + "description": "bindingId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "integer", + "format": "int32" + } + } + } + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/inbox/{thingUID}/approve": { + "post": { + "tags": [ + "inbox" + ], + "summary": "Approves the discovery result by adding the thing to the registry.", + "operationId": "approveInboxItemById", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + }, + { + "name": "thingUID", + "in": "path", + "description": "thingUID", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "newThingId", + "in": "query", + "description": "new thing ID", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "thing label", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Invalid new thing ID." + }, + "404": { + "description": "Thing unable to be approved." + }, + "409": { + "description": "No binding found that supports this thing." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/inbox/{thingUID}": { + "delete": { + "tags": [ + "inbox" + ], + "summary": "Removes the discovery result from the inbox.", + "operationId": "removeItemFromInbox", + "parameters": [ + { + "name": "thingUID", + "in": "path", + "description": "thingUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "Discovery result not found in the inbox." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/inbox": { + "get": { + "tags": [ + "inbox" + ], + "summary": "Get all discovered things.", + "operationId": "getDiscoveredInboxItems", + "parameters": [ + { + "name": "includeIgnored", + "in": "query", + "description": "If true, include ignored inbox entries. Defaults to true", + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DiscoveryResultDTO" + } + } + } + } + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/inbox/{thingUID}/ignore": { + "post": { + "tags": [ + "inbox" + ], + "summary": "Flags a discovery result as ignored for further processing.", + "operationId": "flagInboxItemAsIgnored", + "parameters": [ + { + "name": "thingUID", + "in": "path", + "description": "thingUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/inbox/{thingUID}/unignore": { + "post": { + "tags": [ + "inbox" + ], + "summary": "Removes ignore flag from a discovery result.", + "operationId": "removeIgnoreFlagOnInboxItem", + "parameters": [ + { + "name": "thingUID", + "in": "path", + "description": "thingUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/items/{itemName}/members/{memberItemName}": { + "put": { + "tags": [ + "items" + ], + "summary": "Adds a new member to a group item.", + "operationId": "addMemberToGroupItem", + "parameters": [ + { + "name": "itemName", + "in": "path", + "description": "item name", + "required": true, + "schema": { + "pattern": "[a-zA-Z_0-9]+", + "type": "string" + } + }, + { + "name": "memberItemName", + "in": "path", + "description": "member item name", + "required": true, + "schema": { + "pattern": "[a-zA-Z_0-9]+", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "Item or member item not found or item is not of type group item." + }, + "405": { + "description": "Member item is not editable." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + }, + "delete": { + "tags": [ + "items" + ], + "summary": "Removes an existing member from a group item.", + "operationId": "removeMemberFromGroupItem", + "parameters": [ + { + "name": "itemName", + "in": "path", + "description": "item name", + "required": true, + "schema": { + "pattern": "[a-zA-Z_0-9]+", + "type": "string" + } + }, + { + "name": "memberItemName", + "in": "path", + "description": "member item name", + "required": true, + "schema": { + "pattern": "[a-zA-Z_0-9]+", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "Item or member item not found or item is not of type group item." + }, + "405": { + "description": "Member item is not editable." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/items/{itemname}/metadata/{namespace}": { + "put": { + "tags": [ + "items" + ], + "summary": "Adds metadata to an item.", + "operationId": "addMetadataToItem", + "parameters": [ + { + "name": "itemname", + "in": "path", + "description": "item name", + "required": true, + "schema": { + "pattern": "[a-zA-Z_0-9]+", + "type": "string" + } + }, + { + "name": "namespace", + "in": "path", + "description": "namespace", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "metadata", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MetadataDTO" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + }, + "201": { + "description": "Created" + }, + "400": { + "description": "Metadata value empty." + }, + "404": { + "description": "Item not found." + }, + "405": { + "description": "Metadata not editable." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + }, + "delete": { + "tags": [ + "items" + ], + "summary": "Removes metadata from an item.", + "operationId": "removeMetadataFromItem", + "parameters": [ + { + "name": "itemname", + "in": "path", + "description": "item name", + "required": true, + "schema": { + "pattern": "[a-zA-Z_0-9]+", + "type": "string" + } + }, + { + "name": "namespace", + "in": "path", + "description": "namespace", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "Item not found." + }, + "405": { + "description": "Meta data not editable." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/items/{itemname}/tags/{tag}": { + "put": { + "tags": [ + "items" + ], + "summary": "Adds a tag to an item.", + "operationId": "addTagToItem", + "parameters": [ + { + "name": "itemname", + "in": "path", + "description": "item name", + "required": true, + "schema": { + "pattern": "[a-zA-Z_0-9]+", + "type": "string" + } + }, + { + "name": "tag", + "in": "path", + "description": "tag", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "Item not found." + }, + "405": { + "description": "Item not editable." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + }, + "delete": { + "tags": [ + "items" + ], + "summary": "Removes a tag from an item.", + "operationId": "removeTagFromItem", + "parameters": [ + { + "name": "itemname", + "in": "path", + "description": "item name", + "required": true, + "schema": { + "pattern": "[a-zA-Z_0-9]+", + "type": "string" + } + }, + { + "name": "tag", + "in": "path", + "description": "tag", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "Item not found." + }, + "405": { + "description": "Item not editable." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/items/{itemname}": { + "get": { + "tags": [ + "items" + ], + "summary": "Gets a single item.", + "operationId": "getItemByName", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + }, + { + "name": "metadata", + "in": "query", + "description": "metadata selector - a comma separated list or a regular expression (returns all if no value given)", + "schema": { + "type": "string", + "default": ".*" + } + }, + { + "name": "recursive", + "in": "query", + "description": "get member items if the item is a group item", + "schema": { + "type": "boolean", + "default": true + } + }, + { + "name": "itemname", + "in": "path", + "description": "item name", + "required": true, + "schema": { + "pattern": "[a-zA-Z_0-9]+", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EnrichedItemDTO" + } + } + } + }, + "404": { + "description": "Item not found" + } + } + }, + "put": { + "tags": [ + "items" + ], + "summary": "Adds a new item to the registry or updates the existing item.", + "operationId": "addOrUpdateItemInRegistry", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + }, + { + "name": "itemname", + "in": "path", + "description": "item name", + "required": true, + "schema": { + "pattern": "[a-zA-Z_0-9]+", + "type": "string" + } + } + ], + "requestBody": { + "description": "item data", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GroupItemDTO" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/EnrichedItemDTO" + } + } + } + }, + "201": { + "description": "Item created." + }, + "400": { + "description": "Payload invalid." + }, + "404": { + "description": "Item not found or name in path invalid." + }, + "405": { + "description": "Item not editable." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + }, + "post": { + "tags": [ + "items" + ], + "summary": "Sends a command to an item.", + "operationId": "sendItemCommand", + "parameters": [ + { + "name": "itemname", + "in": "path", + "description": "item name", + "required": true, + "schema": { + "pattern": "[a-zA-Z_0-9]+", + "type": "string" + } + } + ], + "requestBody": { + "description": "valid item command (e.g. ON, OFF, UP, DOWN, REFRESH)", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Item command null" + }, + "404": { + "description": "Item not found" + } + } + }, + "delete": { + "tags": [ + "items" + ], + "summary": "Removes an item from the registry.", + "operationId": "removeItemFromRegistry", + "parameters": [ + { + "name": "itemname", + "in": "path", + "description": "item name", + "required": true, + "schema": { + "pattern": "[a-zA-Z_0-9]+", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "Item not found or item is not editable." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/items": { + "get": { + "tags": [ + "items" + ], + "summary": "Get all available items.", + "operationId": "getItems", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + }, + { + "name": "type", + "in": "query", + "description": "item type filter", + "schema": { + "type": "string" + } + }, + { + "name": "tags", + "in": "query", + "description": "item tag filter", + "schema": { + "type": "string" + } + }, + { + "name": "metadata", + "in": "query", + "description": "metadata selector - a comma separated list or a regular expression (returns all if no value given)", + "schema": { + "type": "string", + "default": ".*" + } + }, + { + "name": "recursive", + "in": "query", + "description": "get member items recursively", + "schema": { + "type": "boolean", + "default": false + } + }, + { + "name": "fields", + "in": "query", + "description": "limit output to the given fields (comma separated)", + "schema": { + "type": "string" + } + }, + { + "name": "staticDataOnly", + "in": "query", + "description": "provides a cacheable list of values not expected to change regularly and checks the If-Modified-Since header, all other parameters are ignored except \"metadata\"", + "schema": { + "type": "boolean", + "default": false + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EnrichedItemDTO" + } + } + } + } + } + } + }, + "put": { + "tags": [ + "items" + ], + "summary": "Adds a list of items to the registry or updates the existing items.", + "operationId": "addOrUpdateItemsInRegistry", + "requestBody": { + "description": "array of item data", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GroupItemDTO" + } + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "Payload is invalid." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/items/{itemname}/state": { + "get": { + "tags": [ + "items" + ], + "summary": "Gets the state of an item.", + "operationId": "getItemState_1", + "parameters": [ + { + "name": "itemname", + "in": "path", + "description": "item name", + "required": true, + "schema": { + "pattern": "[a-zA-Z_0-9]+", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "404": { + "description": "Item not found" + } + } + }, + "put": { + "tags": [ + "items" + ], + "summary": "Updates the state of an item.", + "operationId": "updateItemState", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + }, + { + "name": "itemname", + "in": "path", + "description": "item name", + "required": true, + "schema": { + "pattern": "[a-zA-Z_0-9]+", + "type": "string" + } + } + ], + "requestBody": { + "description": "valid item state (e.g. ON, OFF)", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "required": true + }, + "responses": { + "202": { + "description": "Accepted" + }, + "400": { + "description": "Item state null" + }, + "404": { + "description": "Item not found" + } + } + } + }, + "/items/{itemname}/metadata/namespaces": { + "get": { + "tags": [ + "items" + ], + "summary": "Gets the namespace of an item.", + "operationId": "getItemNamespaces", + "parameters": [ + { + "name": "itemname", + "in": "path", + "description": "item name", + "required": true, + "schema": { + "pattern": "[a-zA-Z_0-9]+", + "type": "string" + } + }, + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + }, + "404": { + "description": "Item not found" + } + } + } + }, + "/items/{itemName}/semantic/{semanticClass}": { + "get": { + "tags": [ + "items" + ], + "summary": "Gets the item which defines the requested semantics of an item.", + "operationId": "getSemanticItem", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + }, + { + "name": "itemName", + "in": "path", + "description": "item name", + "required": true, + "schema": { + "pattern": "\\w+", + "type": "string" + } + }, + { + "name": "semanticClass", + "in": "path", + "description": "semantic class", + "required": true, + "schema": { + "pattern": "\\w+", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "Item not found" + } + } + } + }, + "/items/metadata/purge": { + "post": { + "tags": [ + "items" + ], + "summary": "Remove unused/orphaned metadata.", + "operationId": "purgeDatabase", + "responses": { + "200": { + "description": "OK" + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/links": { + "get": { + "tags": [ + "links" + ], + "summary": "Gets all available links.", + "operationId": "getItemLinks", + "parameters": [ + { + "name": "channelUID", + "in": "query", + "description": "filter by channel UID", + "schema": { + "type": "string" + } + }, + { + "name": "itemName", + "in": "query", + "description": "filter by item name", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EnrichedItemChannelLinkDTO" + } + } + } + } + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/links/{itemName}/{channelUID}": { + "get": { + "tags": [ + "links" + ], + "summary": "Retrieves an individual link.", + "operationId": "getItemLink", + "parameters": [ + { + "name": "itemName", + "in": "path", + "description": "item name", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "channelUID", + "in": "path", + "description": "channel UID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EnrichedItemChannelLinkDTO" + } + } + } + }, + "404": { + "description": "Content does not match the path" + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + }, + "put": { + "tags": [ + "links" + ], + "summary": "Links an item to a channel.", + "operationId": "linkItemToChannel", + "parameters": [ + { + "name": "itemName", + "in": "path", + "description": "itemName", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "channelUID", + "in": "path", + "description": "channelUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "link data", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ItemChannelLinkDTO" + } + } + } + }, + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Content does not match the path" + }, + "405": { + "description": "Link is not editable" + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + }, + "delete": { + "tags": [ + "links" + ], + "summary": "Unlinks an item from a channel.", + "operationId": "unlinkItemFromChannel", + "parameters": [ + { + "name": "itemName", + "in": "path", + "description": "itemName", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "channelUID", + "in": "path", + "description": "channelUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "Link not found." + }, + "405": { + "description": "Link not editable." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/links/orphans": { + "get": { + "tags": [ + "links" + ], + "summary": "Get orphan links between items and broken/non-existent thing channels", + "operationId": "getOrphanLinks", + "responses": { + "200": { + "description": "List of broken links" + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/links/purge": { + "post": { + "tags": [ + "links" + ], + "summary": "Remove unused/orphaned links.", + "operationId": "purgeDatabase_1", + "responses": { + "200": { + "description": "OK" + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/links/{object}": { + "delete": { + "tags": [ + "links" + ], + "summary": "Delete all links that refer to an item or thing.", + "operationId": "removeAllLinksForObject", + "parameters": [ + { + "name": "object", + "in": "path", + "description": "item name or thing UID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/persistence/{serviceId}": { + "get": { + "tags": [ + "persistence" + ], + "summary": "Gets a persistence service configuration.", + "operationId": "getPersistenceServiceConfiguration", + "parameters": [ + { + "name": "serviceId", + "in": "path", + "description": "Id of the persistence service.", + "required": true, + "schema": { + "pattern": "[a-zA-Z0-9]+", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PersistenceServiceConfigurationDTO" + } + } + } + }, + "404": { + "description": "Service configuration not found." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + }, + "put": { + "tags": [ + "persistence" + ], + "summary": "Sets a persistence service configuration.", + "operationId": "putPersistenceServiceConfiguration", + "parameters": [ + { + "name": "serviceId", + "in": "path", + "description": "Id of the persistence service.", + "required": true, + "schema": { + "pattern": "[a-zA-Z0-9]+", + "type": "string" + } + } + ], + "requestBody": { + "description": "service configuration", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PersistenceServiceConfigurationDTO" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PersistenceServiceConfigurationDTO" + } + } + } + }, + "201": { + "description": "PersistenceServiceConfiguration created." + }, + "400": { + "description": "Payload invalid." + }, + "405": { + "description": "PersistenceServiceConfiguration not editable." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + }, + "delete": { + "tags": [ + "persistence" + ], + "summary": "Deletes a persistence service configuration.", + "operationId": "deletePersistenceServiceConfiguration", + "parameters": [ + { + "name": "serviceId", + "in": "path", + "description": "Id of the persistence service.", + "required": true, + "schema": { + "pattern": "[a-zA-Z0-9]+", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "Persistence service configuration not found." + }, + "405": { + "description": "Persistence service configuration not editable." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/persistence/items/{itemname}": { + "get": { + "tags": [ + "persistence" + ], + "summary": "Gets item persistence data from the persistence service.", + "operationId": "getItemDataFromPersistenceService", + "parameters": [ + { + "name": "serviceId", + "in": "query", + "description": "Id of the persistence service. If not provided the default service will be used", + "schema": { + "type": "string" + } + }, + { + "name": "itemname", + "in": "path", + "description": "The item name", + "required": true, + "schema": { + "pattern": "[a-zA-Z_0-9]+", + "type": "string" + } + }, + { + "name": "starttime", + "in": "query", + "description": "Start time of the data to return. Will default to 1 day before endtime. [yyyy-MM-dd'T'HH:mm:ss.SSSZ]", + "schema": { + "type": "string" + } + }, + { + "name": "endtime", + "in": "query", + "description": "End time of the data to return. Will default to current time. [yyyy-MM-dd'T'HH:mm:ss.SSSZ]", + "schema": { + "type": "string" + } + }, + { + "name": "page", + "in": "query", + "description": "Page number of data to return. This parameter will enable paging.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "pagelength", + "in": "query", + "description": "The length of each page.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "boundary", + "in": "query", + "description": "Gets one value before and after the requested period.", + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ItemHistoryDTO" + } + } + } + }, + "404": { + "description": "Unknown Item or persistence service" + } + } + }, + "put": { + "tags": [ + "persistence" + ], + "summary": "Stores item persistence data into the persistence service.", + "operationId": "storeItemDataInPersistenceService", + "parameters": [ + { + "name": "serviceId", + "in": "query", + "description": "Id of the persistence service. If not provided the default service will be used", + "schema": { + "type": "string" + } + }, + { + "name": "itemname", + "in": "path", + "description": "The item name.", + "required": true, + "schema": { + "pattern": "[a-zA-Z_0-9]+", + "type": "string" + } + }, + { + "name": "time", + "in": "query", + "description": "Time of the data to be stored. Will default to current time. [yyyy-MM-dd'T'HH:mm:ss.SSSZ]", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "state", + "in": "query", + "description": "The state to store.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "Unknown Item or persistence service" + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + }, + "delete": { + "tags": [ + "persistence" + ], + "summary": "Deletes item persistence data from a specific persistence service in a given time range.", + "operationId": "deleteItemFromPersistenceService", + "parameters": [ + { + "name": "serviceId", + "in": "query", + "description": "Id of the persistence service.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "itemname", + "in": "path", + "description": "The item name.", + "required": true, + "schema": { + "pattern": "[a-zA-Z_0-9]+", + "type": "string" + } + }, + { + "name": "starttime", + "in": "query", + "description": "Start of the time range to be deleted. [yyyy-MM-dd'T'HH:mm:ss.SSSZ]", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "endtime", + "in": "query", + "description": "End of the time range to be deleted. [yyyy-MM-dd'T'HH:mm:ss.SSSZ]", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + }, + "400": { + "description": "Invalid filter parameters" + }, + "404": { + "description": "Unknown persistence service" + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/persistence/items": { + "get": { + "tags": [ + "persistence" + ], + "summary": "Gets a list of items available via a specific persistence service.", + "operationId": "getItemsForPersistenceService", + "parameters": [ + { + "name": "serviceId", + "in": "query", + "description": "Id of the persistence service. If not provided the default service will be used", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "uniqueItems": true, + "type": "array", + "items": { + "$ref": "#/components/schemas/PersistenceItemInfo" + } + } + } + } + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/persistence": { + "get": { + "tags": [ + "persistence" + ], + "summary": "Gets a list of persistence services.", + "operationId": "getPersistenceServices", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PersistenceServiceDTO" + } + } + } + } + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/profile-types": { + "get": { + "tags": [ + "profile-types" + ], + "summary": "Gets all available profile types.", + "operationId": "getProfileTypes", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + }, + { + "name": "channelTypeUID", + "in": "query", + "description": "channel type filter", + "schema": { + "type": "string" + } + }, + { + "name": "itemType", + "in": "query", + "description": "item type filter", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "uniqueItems": true, + "type": "array", + "items": { + "$ref": "#/components/schemas/ProfileTypeDTO" + } + } + } + } + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/services/{serviceId}/config": { + "get": { + "tags": [ + "services" + ], + "summary": "Get service configuration for given service ID.", + "operationId": "getServiceConfig", + "parameters": [ + { + "name": "serviceId", + "in": "path", + "description": "service ID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + }, + "500": { + "description": "Configuration can not be read due to internal error" + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + }, + "put": { + "tags": [ + "services" + ], + "summary": "Updates a service configuration for given service ID and returns the old configuration.", + "operationId": "updateServiceConfig", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + }, + { + "name": "serviceId", + "in": "path", + "description": "service ID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "type": "object" + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + }, + "204": { + "description": "No old configuration" + }, + "500": { + "description": "Configuration can not be updated due to internal error" + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + }, + "delete": { + "tags": [ + "services" + ], + "summary": "Deletes a service configuration for given service ID and returns the old configuration.", + "operationId": "deleteServiceConfig", + "parameters": [ + { + "name": "serviceId", + "in": "path", + "description": "service ID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + }, + "204": { + "description": "No old configuration" + }, + "500": { + "description": "Configuration can not be deleted due to internal error" + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/services": { + "get": { + "tags": [ + "services" + ], + "summary": "Get all configurable services.", + "operationId": "getServices", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ConfigurableServiceDTO" + } + } + } + } + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/services/{serviceId}": { + "get": { + "tags": [ + "services" + ], + "summary": "Get configurable service for given service ID.", + "operationId": "getServicesById", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + }, + { + "name": "serviceId", + "in": "path", + "description": "service ID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConfigurableServiceDTO" + } + } + } + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/services/{serviceId}/contexts": { + "get": { + "tags": [ + "services" + ], + "summary": "Get existing multiple context service configurations for the given factory PID.", + "operationId": "getServiceContext", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + }, + { + "name": "serviceId", + "in": "path", + "description": "service ID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ConfigurableServiceDTO" + } + } + } + } + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/tags": { + "get": { + "tags": [ + "tags" + ], + "summary": "Get all available semantic tags.", + "operationId": "getSemanticTags", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EnrichedSemanticTagDTO" + } + } + } + } + } + } + }, + "post": { + "tags": [ + "tags" + ], + "summary": "Creates a new semantic tag and adds it to the registry.", + "operationId": "createSemanticTag", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "tag data", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EnrichedSemanticTagDTO" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Created", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/EnrichedSemanticTagDTO" + } + } + } + }, + "400": { + "description": "The tag identifier is invalid or the tag label is missing." + }, + "409": { + "description": "A tag with the same identifier already exists." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/tags/{tagId}": { + "get": { + "tags": [ + "tags" + ], + "summary": "Gets a semantic tag and its sub tags.", + "operationId": "getSemanticTagAndSubTags", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + }, + { + "name": "tagId", + "in": "path", + "description": "tag id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EnrichedSemanticTagDTO" + } + } + } + } + }, + "404": { + "description": "Semantic tag not found." + } + } + }, + "put": { + "tags": [ + "tags" + ], + "summary": "Updates a semantic tag.", + "operationId": "updateSemanticTag", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + }, + { + "name": "tagId", + "in": "path", + "description": "tag id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "tag data", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EnrichedSemanticTagDTO" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/EnrichedSemanticTagDTO" + } + } + } + }, + "404": { + "description": "Semantic tag not found." + }, + "405": { + "description": "Semantic tag not editable." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + }, + "delete": { + "tags": [ + "tags" + ], + "summary": "Removes a semantic tag and its sub tags from the registry.", + "operationId": "removeSemanticTag", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + }, + { + "name": "tagId", + "in": "path", + "description": "tag id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK, was deleted." + }, + "404": { + "description": "Semantic tag not found." + }, + "405": { + "description": "Semantic tag not removable." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/things": { + "get": { + "tags": [ + "things" + ], + "summary": "Get all available things.", + "operationId": "getThings", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + }, + { + "name": "summary", + "in": "query", + "description": "summary fields only", + "schema": { + "type": "boolean" + } + }, + { + "name": "staticDataOnly", + "in": "query", + "description": "provides a cacheable list of values not expected to change regularly and checks the If-Modified-Since header", + "schema": { + "type": "boolean", + "default": false + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "uniqueItems": true, + "type": "array", + "items": { + "$ref": "#/components/schemas/EnrichedThingDTO" + } + } + } + } + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + }, + "post": { + "tags": [ + "things" + ], + "summary": "Creates a new thing and adds it to the registry.", + "operationId": "createThingInRegistry", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "thing data", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ThingDTO" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Created", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/EnrichedThingDTO" + } + } + } + }, + "400": { + "description": "A uid must be provided, if no binding can create a thing of this type." + }, + "409": { + "description": "A thing with the same uid already exists." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/things/{thingUID}": { + "get": { + "tags": [ + "things" + ], + "summary": "Gets thing by UID.", + "operationId": "getThingById", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + }, + { + "name": "thingUID", + "in": "path", + "description": "thingUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EnrichedThingDTO" + } + } + } + }, + "404": { + "description": "Thing not found." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + }, + "put": { + "tags": [ + "things" + ], + "summary": "Updates a thing.", + "operationId": "updateThing", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + }, + { + "name": "thingUID", + "in": "path", + "description": "thingUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "thing", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ThingDTO" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/EnrichedThingDTO" + } + } + } + }, + "404": { + "description": "Thing not found." + }, + "409": { + "description": "Thing could not be updated as it is not editable." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + }, + "delete": { + "tags": [ + "things" + ], + "summary": "Removes a thing from the registry. Set 'force' to __true__ if you want the thing to be removed immediately.", + "operationId": "removeThingById", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + }, + { + "name": "thingUID", + "in": "path", + "description": "thingUID", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "force", + "in": "query", + "description": "force", + "schema": { + "type": "boolean", + "default": false + } + } + ], + "responses": { + "200": { + "description": "OK, was deleted." + }, + "202": { + "description": "ACCEPTED for asynchronous deletion." + }, + "404": { + "description": "Thing not found." + }, + "409": { + "description": "Thing could not be deleted because it's not editable." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/things/{thingUID}/config/status": { + "get": { + "tags": [ + "things" + ], + "summary": "Gets thing config status.", + "operationId": "getThingConfigStatus", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + }, + { + "name": "thingUID", + "in": "path", + "description": "thing", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ConfigStatusMessage" + } + } + } + } + }, + "404": { + "description": "Thing not found." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/things/{thingUID}/firmware/status": { + "get": { + "tags": [ + "things" + ], + "summary": "Gets thing's firmware status.", + "operationId": "getThingFirmwareStatus", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + }, + { + "name": "thingUID", + "in": "path", + "description": "thing", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/FirmwareStatusDTO" + } + } + } + }, + "204": { + "description": "No firmware status provided by this Thing." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/things/{thingUID}/firmwares": { + "get": { + "tags": [ + "things" + ], + "summary": "Get all available firmwares for provided thing UID", + "operationId": "getAvailableFirmwaresForThing", + "parameters": [ + { + "name": "thingUID", + "in": "path", + "description": "thingUID", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "uniqueItems": true, + "type": "array", + "items": { + "$ref": "#/components/schemas/FirmwareDTO" + } + } + } + } + }, + "204": { + "description": "No firmwares found." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/things/{thingUID}/status": { + "get": { + "tags": [ + "things" + ], + "summary": "Gets thing status.", + "operationId": "getThingStatus", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + }, + { + "name": "thingUID", + "in": "path", + "description": "thing", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ThingStatusInfo" + } + } + } + }, + "404": { + "description": "Thing not found." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/things/{thingUID}/enable": { + "put": { + "tags": [ + "things" + ], + "summary": "Sets the thing enabled status.", + "operationId": "enableThing", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + }, + { + "name": "thingUID", + "in": "path", + "description": "thing", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "enabled", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/EnrichedThingDTO" + } + } + } + }, + "404": { + "description": "Thing not found." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/things/{thingUID}/config": { + "put": { + "tags": [ + "things" + ], + "summary": "Updates thing's configuration.", + "operationId": "updateThingConfig", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + }, + { + "name": "thingUID", + "in": "path", + "description": "thing", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "configuration parameters", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "type": "object" + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/EnrichedThingDTO" + } + } + } + }, + "400": { + "description": "Configuration of the thing is not valid." + }, + "404": { + "description": "Thing not found" + }, + "409": { + "description": "Thing could not be updated as it is not editable." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/things/{thingUID}/firmware/{firmwareVersion}": { + "put": { + "tags": [ + "things" + ], + "summary": "Update thing firmware.", + "operationId": "updateThingFirmware", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + }, + { + "name": "thingUID", + "in": "path", + "description": "thing", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "firmwareVersion", + "in": "path", + "description": "version", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Firmware update preconditions not satisfied." + }, + "404": { + "description": "Thing not found." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/thing-types": { + "get": { + "tags": [ + "thing-types" + ], + "summary": "Gets all available thing types without config description, channels and properties.", + "operationId": "getThingTypes", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + }, + { + "name": "bindingId", + "in": "query", + "description": "filter by binding Id", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "uniqueItems": true, + "type": "array", + "items": { + "$ref": "#/components/schemas/StrippedThingTypeDTO" + } + } + } + } + } + } + } + }, + "/thing-types/{thingTypeUID}": { + "get": { + "tags": [ + "thing-types" + ], + "summary": "Gets thing type by UID.", + "operationId": "getThingTypeById", + "parameters": [ + { + "name": "thingTypeUID", + "in": "path", + "description": "thingTypeUID", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Thing type with provided thingTypeUID does not exist.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ThingTypeDTO" + } + } + } + }, + "404": { + "description": "No content" + } + } + } + }, + "/": { + "get": { + "tags": [ + "root" + ], + "summary": "Gets information about the runtime, the API version and links to resources.", + "operationId": "getRoot", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RootBean" + } + } + } + } + } + } + }, + "/systeminfo": { + "get": { + "tags": [ + "systeminfo" + ], + "summary": "Gets information about the system.", + "operationId": "getSystemInformation", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SystemInfoBean" + } + } + } + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/systeminfo/uom": { + "get": { + "tags": [ + "systeminfo" + ], + "summary": "Get all supported dimensions and their system units.", + "operationId": "getUoMInformation", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UoMInfoBean" + } + } + } + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/sitemaps/events/subscribe": { + "post": { + "tags": [ + "sitemaps" + ], + "summary": "Creates a sitemap event subscription.", + "operationId": "createSitemapEventSubscription", + "responses": { + "201": { + "description": "Subscription created." + }, + "503": { + "description": "Subscriptions limit reached." + } + } + } + }, + "/sitemaps/{sitemapname}/{pageid}": { + "get": { + "tags": [ + "sitemaps" + ], + "summary": "Polls the data for one page of a sitemap.", + "operationId": "pollDataForPage", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + }, + { + "name": "X-Atmosphere-Transport", + "in": "header", + "description": "X-Atmosphere-Transport for long polling", + "schema": { + "type": "string" + } + }, + { + "name": "sitemapname", + "in": "path", + "description": "sitemap name", + "required": true, + "schema": { + "pattern": "[a-zA-Z_0-9]+", + "type": "string" + } + }, + { + "name": "pageid", + "in": "path", + "description": "page id", + "required": true, + "schema": { + "pattern": "[a-zA-Z_0-9]+", + "type": "string" + } + }, + { + "name": "subscriptionid", + "in": "query", + "description": "subscriptionid", + "schema": { + "type": "string" + } + }, + { + "name": "includeHidden", + "in": "query", + "description": "include hidden widgets", + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PageDTO" + } + } + } + }, + "400": { + "description": "Invalid subscription id has been provided." + }, + "404": { + "description": "Sitemap with requested name does not exist or page does not exist, or page refers to a non-linkable widget" + } + } + } + }, + "/sitemaps/{sitemapname}/*": { + "get": { + "tags": [ + "sitemaps" + ], + "summary": "Polls the data for a whole sitemap. Not recommended due to potentially high traffic.", + "operationId": "pollDataForSitemap", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + }, + { + "name": "sitemapname", + "in": "path", + "description": "sitemap name", + "required": true, + "schema": { + "pattern": "[a-zA-Z_0-9]+", + "type": "string" + } + }, + { + "name": "subscriptionid", + "in": "query", + "description": "subscriptionid", + "schema": { + "type": "string" + } + }, + { + "name": "includeHidden", + "in": "query", + "description": "include hidden widgets", + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SitemapDTO" + } + } + } + }, + "400": { + "description": "Invalid subscription id has been provided." + }, + "404": { + "description": "Sitemap with requested name does not exist" + } + } + } + }, + "/sitemaps/{sitemapname}": { + "get": { + "tags": [ + "sitemaps" + ], + "summary": "Get sitemap by name.", + "operationId": "getSitemapByName", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + }, + { + "name": "sitemapname", + "in": "path", + "description": "sitemap name", + "required": true, + "schema": { + "pattern": "[a-zA-Z_0-9]+", + "type": "string" + } + }, + { + "name": "type", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "jsoncallback", + "in": "query", + "schema": { + "type": "string", + "default": "callback" + } + }, + { + "name": "includeHidden", + "in": "query", + "description": "include hidden widgets", + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SitemapDTO" + } + } + } + } + } + } + }, + "/sitemaps/events/{subscriptionid}/*": { + "get": { + "tags": [ + "sitemaps" + ], + "summary": "Get sitemap events for a whole sitemap. Not recommended due to potentially high traffic.", + "operationId": "getSitemapEvents", + "parameters": [ + { + "name": "subscriptionid", + "in": "path", + "description": "subscription id", + "required": true, + "schema": { + "pattern": "[a-zA-Z_0-9-]+", + "type": "string" + } + }, + { + "name": "sitemap", + "in": "query", + "description": "sitemap name", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Missing sitemap parameter, or sitemap not linked successfully to the subscription." + }, + "404": { + "description": "Subscription not found." + } + } + } + }, + "/sitemaps/events/{subscriptionid}": { + "get": { + "tags": [ + "sitemaps" + ], + "summary": "Get sitemap events.", + "operationId": "getSitemapEvents_1", + "parameters": [ + { + "name": "subscriptionid", + "in": "path", + "description": "subscription id", + "required": true, + "schema": { + "pattern": "[a-zA-Z_0-9-]+", + "type": "string" + } + }, + { + "name": "sitemap", + "in": "query", + "description": "sitemap name", + "schema": { + "type": "string" + } + }, + { + "name": "pageid", + "in": "query", + "description": "page id", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Missing sitemap or page parameter, or page not linked successfully to the subscription." + }, + "404": { + "description": "Subscription not found." + } + } + } + }, + "/sitemaps": { + "get": { + "tags": [ + "sitemaps" + ], + "summary": "Get all available sitemaps.", + "operationId": "getSitemaps", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SitemapDTO" + } + } + } + } + } + } + } + }, + "/events/states": { + "get": { + "tags": [ + "events" + ], + "summary": "Initiates a new item state tracker connection", + "operationId": "initNewStateTacker", + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/events": { + "get": { + "tags": [ + "events" + ], + "summary": "Get all events.", + "operationId": "getEvents", + "parameters": [ + { + "name": "topics", + "in": "query", + "description": "topics", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Topic is empty or contains invalid characters" + } + } + } + }, + "/events/states/{connectionId}": { + "post": { + "tags": [ + "events" + ], + "summary": "Changes the list of items a SSE connection will receive state updates to.", + "operationId": "updateItemListForStateUpdates", + "parameters": [ + { + "name": "connectionId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "items", + "content": { + "*/*": { + "schema": { + "uniqueItems": true, + "type": "array", + "items": { + "type": "string" + } + } + } + } + }, + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "Unknown connectionId" + } + } + } + }, + "/transformations/{uid}": { + "get": { + "tags": [ + "transformations" + ], + "summary": "Get a single transformation", + "operationId": "getTransformation", + "parameters": [ + { + "name": "uid", + "in": "path", + "description": "Transformation UID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Transformation" + } + } + } + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + }, + "put": { + "tags": [ + "transformations" + ], + "summary": "Put a single transformation", + "operationId": "putTransformation", + "parameters": [ + { + "name": "uid", + "in": "path", + "description": "Transformation UID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "transformation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TransformationDTO" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request (content missing or invalid)" + }, + "405": { + "description": "Transformation not editable" + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + }, + "delete": { + "tags": [ + "transformations" + ], + "summary": "Get a single transformation", + "operationId": "deleteTransformation", + "parameters": [ + { + "name": "uid", + "in": "path", + "description": "Transformation UID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "UID not found" + }, + "405": { + "description": "Transformation not editable" + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/transformations/services": { + "get": { + "tags": [ + "transformations" + ], + "summary": "Get all transformation services", + "operationId": "getTransformationServices", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/transformations": { + "get": { + "tags": [ + "transformations" + ], + "summary": "Get a list of all transformations", + "operationId": "getTransformations", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TransformationDTO" + } + } + } + } + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/ui/components/{namespace}": { + "get": { + "tags": [ + "ui" + ], + "summary": "Get all registered UI components in the specified namespace.", + "operationId": "getRegisteredUIComponentsInNamespace", + "parameters": [ + { + "name": "namespace", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "summary", + "in": "query", + "description": "summary fields only", + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RootUIComponent" + } + } + } + } + } + } + }, + "post": { + "tags": [ + "ui" + ], + "summary": "Add a UI component in the specified namespace.", + "operationId": "addUIComponentToNamespace", + "parameters": [ + { + "name": "namespace", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RootUIComponent" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RootUIComponent" + } + } + } + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/ui/components/{namespace}/{componentUID}": { + "get": { + "tags": [ + "ui" + ], + "summary": "Get a specific UI component in the specified namespace.", + "operationId": "getUIComponentInNamespace", + "parameters": [ + { + "name": "namespace", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "componentUID", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RootUIComponent" + } + } + } + }, + "404": { + "description": "Component not found" + } + } + }, + "put": { + "tags": [ + "ui" + ], + "summary": "Update a specific UI component in the specified namespace.", + "operationId": "updateUIComponentInNamespace", + "parameters": [ + { + "name": "namespace", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "componentUID", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RootUIComponent" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RootUIComponent" + } + } + } + }, + "404": { + "description": "Component not found" + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + }, + "delete": { + "tags": [ + "ui" + ], + "summary": "Remove a specific UI component in the specified namespace.", + "operationId": "removeUIComponentFromNamespace", + "parameters": [ + { + "name": "namespace", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "componentUID", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "Component not found" + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/ui/tiles": { + "get": { + "tags": [ + "ui" + ], + "summary": "Get all registered UI tiles.", + "operationId": "getUITiles", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TileDTO" + } + } + } + } + } + } + } + }, + "/voice/defaultvoice": { + "get": { + "tags": [ + "voice" + ], + "summary": "Gets the default voice.", + "operationId": "getDefaultVoice", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VoiceDTO" + } + } + } + }, + "404": { + "description": "No default voice was found." + } + } + } + }, + "/voice/interpreters/{id}": { + "get": { + "tags": [ + "voice" + ], + "summary": "Gets a single interpreter.", + "operationId": "getVoiceInterpreterByUID", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + }, + { + "name": "id", + "in": "path", + "description": "interpreter id", + "required": true, + "schema": { + "pattern": "[a-zA-Z_0-9]+", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/HumanLanguageInterpreterDTO" + } + } + } + } + }, + "404": { + "description": "Interpreter not found" + } + } + } + }, + "/voice/interpreters": { + "get": { + "tags": [ + "voice" + ], + "summary": "Get the list of all interpreters.", + "operationId": "getVoiceInterpreters", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/HumanLanguageInterpreterDTO" + } + } + } + } + } + } + }, + "post": { + "tags": [ + "voice" + ], + "summary": "Sends a text to the default human language interpreter.", + "operationId": "interpretTextByDefaultInterpreter", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "text to interpret", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "interpretation exception occurs" + }, + "404": { + "description": "No human language interpreter was found." + } + } + } + }, + "/voice/voices": { + "get": { + "tags": [ + "voice" + ], + "summary": "Get the list of all voices.", + "operationId": "getVoices", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/VoiceDTO" + } + } + } + } + } + } + } + }, + "/voice/interpreters/{ids}": { + "post": { + "tags": [ + "voice" + ], + "summary": "Sends a text to a given human language interpreter(s).", + "operationId": "interpretText", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + }, + { + "name": "ids", + "in": "path", + "description": "comma separated list of interpreter ids", + "required": true, + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + ], + "requestBody": { + "description": "text to interpret", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "interpretation exception occurs" + }, + "404": { + "description": "No human language interpreter was found." + } + } + } + }, + "/voice/listenandanswer": { + "post": { + "tags": [ + "voice" + ], + "summary": "Executes a simple dialog sequence without keyword spotting for a given audio source.", + "operationId": "listenAndAnswer", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + }, + { + "name": "sourceId", + "in": "query", + "description": "source ID", + "schema": { + "type": "string" + } + }, + { + "name": "sttId", + "in": "query", + "description": "Speech-to-Text ID", + "schema": { + "type": "string" + } + }, + { + "name": "ttsId", + "in": "query", + "description": "Text-to-Speech ID", + "schema": { + "type": "string" + } + }, + { + "name": "voiceId", + "in": "query", + "description": "voice ID", + "schema": { + "type": "string" + } + }, + { + "name": "hliIds", + "in": "query", + "description": "interpreter IDs", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "name": "sinkId", + "in": "query", + "description": "audio sink ID", + "schema": { + "type": "string" + } + }, + { + "name": "listeningItem", + "in": "query", + "description": "listening item", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Services are missing or language is not supported by services or dialog processing is already started for the audio source." + }, + "404": { + "description": "One of the given ids is wrong." + } + } + } + }, + "/voice/say": { + "post": { + "tags": [ + "voice" + ], + "summary": "Speaks a given text with a given voice through the given audio sink.", + "operationId": "textToSpeech", + "parameters": [ + { + "name": "voiceid", + "in": "query", + "description": "voice id", + "schema": { + "type": "string" + } + }, + { + "name": "sinkid", + "in": "query", + "description": "audio sink id", + "schema": { + "type": "string" + } + }, + { + "name": "volume", + "in": "query", + "description": "volume level", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "text to speak", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/voice/dialog/start": { + "post": { + "tags": [ + "voice" + ], + "summary": "Start dialog processing for a given audio source.", + "operationId": "startDialog", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + }, + { + "name": "sourceId", + "in": "query", + "description": "source ID", + "schema": { + "type": "string" + } + }, + { + "name": "ksId", + "in": "query", + "description": "keywork spotter ID", + "schema": { + "type": "string" + } + }, + { + "name": "sttId", + "in": "query", + "description": "Speech-to-Text ID", + "schema": { + "type": "string" + } + }, + { + "name": "ttsId", + "in": "query", + "description": "Text-to-Speech ID", + "schema": { + "type": "string" + } + }, + { + "name": "voiceId", + "in": "query", + "description": "voice ID", + "schema": { + "type": "string" + } + }, + { + "name": "hliIds", + "in": "query", + "description": "comma separated list of interpreter IDs", + "schema": { + "type": "string" + } + }, + { + "name": "sinkId", + "in": "query", + "description": "audio sink ID", + "schema": { + "type": "string" + } + }, + { + "name": "keyword", + "in": "query", + "description": "keyword", + "schema": { + "type": "string" + } + }, + { + "name": "listeningItem", + "in": "query", + "description": "listening item", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Services are missing or language is not supported by services or dialog processing is already started for the audio source." + }, + "404": { + "description": "One of the given ids is wrong." + } + } + } + }, + "/voice/dialog/stop": { + "post": { + "tags": [ + "voice" + ], + "summary": "Stop dialog processing for a given audio source.", + "operationId": "stopDialog", + "parameters": [ + { + "name": "sourceId", + "in": "query", + "description": "source ID", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "No dialog processing is started for the audio source." + }, + "404": { + "description": "No audio source was found." + } + } + } + }, + "/logging/{loggerName}": { + "get": { + "tags": [ + "logging" + ], + "summary": "Get a single logger.", + "operationId": "getLogger", + "parameters": [ + { + "name": "loggerName", + "in": "path", + "description": "logger name", + "required": true, + "schema": { + "pattern": "[a-zA-Z0-9.]+", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoggerInfo" + } + } + } + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + }, + "put": { + "tags": [ + "logging" + ], + "summary": "Modify or add logger", + "operationId": "putLogger", + "parameters": [ + { + "name": "loggerName", + "in": "path", + "description": "logger name", + "required": true, + "schema": { + "pattern": "[a-zA-Z0-9.]+", + "type": "string" + } + } + ], + "requestBody": { + "description": "logger", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoggerInfo" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Payload is invalid." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + }, + "delete": { + "tags": [ + "logging" + ], + "summary": "Remove a single logger.", + "operationId": "removeLogger", + "parameters": [ + { + "name": "loggerName", + "in": "path", + "description": "logger name", + "required": true, + "schema": { + "pattern": "[a-zA-Z0-9.]+", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/logging": { + "get": { + "tags": [ + "logging" + ], + "summary": "Get all loggers", + "operationId": "getLogger_1", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoggerBean" + } + } + } + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/iconsets": { + "get": { + "tags": [ + "iconsets" + ], + "summary": "Gets all icon sets.", + "operationId": "getIconSets", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/IconSet" + } + } + } + } + } + } + } + }, + "/habpanel/gallery/{galleryName}/widgets": { + "get": { + "tags": [ + "habpanel" + ], + "summary": "Gets the list of widget gallery items.", + "operationId": "getGalleryWidgetList", + "parameters": [ + { + "name": "galleryName", + "in": "path", + "description": "gallery name e.g. 'community'", + "required": true, + "schema": { + "pattern": "[a-zA-Z_0-9]*", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GalleryWidgetsListItem" + } + } + } + } + }, + "404": { + "description": "Unknown gallery" + } + } + } + }, + "/habpanel/gallery/{galleryName}/widgets/{id}": { + "get": { + "tags": [ + "habpanel" + ], + "summary": "Gets the details about a widget gallery item.", + "operationId": "getGalleryWidgetsItem", + "parameters": [ + { + "name": "galleryName", + "in": "path", + "description": "gallery name e.g. 'community'", + "required": true, + "schema": { + "pattern": "[a-zA-Z_0-9]*", + "type": "string" + } + }, + { + "name": "id", + "in": "path", + "description": "id within the gallery", + "required": true, + "schema": { + "pattern": "[a-zA-Z_0-9]*", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GalleryItem" + } + } + } + }, + "404": { + "description": "Unknown gallery or gallery item not found" + } + } + } + } + }, + "components": { + "schemas": { + "ConfigDescriptionParameterDTO": { + "type": "object", + "properties": { + "context": { + "type": "string" + }, + "defaultValue": { + "type": "string" + }, + "description": { + "type": "string" + }, + "label": { + "type": "string" + }, + "name": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "type": { + "type": "string", + "enum": [ + "TEXT", + "INTEGER", + "DECIMAL", + "BOOLEAN" + ] + }, + "min": { + "type": "number" + }, + "max": { + "type": "number" + }, + "stepsize": { + "type": "number" + }, + "pattern": { + "type": "string" + }, + "readOnly": { + "type": "boolean" + }, + "multiple": { + "type": "boolean" + }, + "multipleLimit": { + "type": "integer", + "format": "int32" + }, + "groupName": { + "type": "string" + }, + "advanced": { + "type": "boolean" + }, + "verify": { + "type": "boolean" + }, + "limitToOptions": { + "type": "boolean" + }, + "unit": { + "type": "string" + }, + "unitLabel": { + "type": "string" + }, + "options": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ParameterOptionDTO" + } + }, + "filterCriteria": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FilterCriteriaDTO" + } + } + } + }, + "FilterCriteriaDTO": { + "type": "object", + "properties": { + "value": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "ModuleTypeDTO": { + "type": "object", + "properties": { + "uid": { + "type": "string" + }, + "visibility": { + "type": "string", + "enum": [ + "VISIBLE", + "HIDDEN", + "EXPERT" + ] + }, + "tags": { + "uniqueItems": true, + "type": "array", + "items": { + "type": "string" + } + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "configDescriptions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ConfigDescriptionParameterDTO" + } + } + } + }, + "ParameterOptionDTO": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "value": { + "type": "string" + } + } + }, + "ActionDTO": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "configuration": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "type": { + "type": "string" + }, + "inputs": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + }, + "ConditionDTO": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "configuration": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "type": { + "type": "string" + }, + "inputs": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + }, + "RuleDTO": { + "type": "object", + "properties": { + "triggers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TriggerDTO" + } + }, + "conditions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ConditionDTO" + } + }, + "actions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ActionDTO" + } + }, + "configuration": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "configDescriptions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ConfigDescriptionParameterDTO" + } + }, + "templateUID": { + "type": "string" + }, + "uid": { + "type": "string" + }, + "name": { + "type": "string" + }, + "tags": { + "uniqueItems": true, + "type": "array", + "items": { + "type": "string" + } + }, + "visibility": { + "type": "string", + "enum": [ + "VISIBLE", + "HIDDEN", + "EXPERT" + ] + }, + "description": { + "type": "string" + } + } + }, + "TriggerDTO": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "configuration": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "type": { + "type": "string" + } + } + }, + "EnrichedRuleDTO": { + "type": "object", + "properties": { + "triggers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TriggerDTO" + } + }, + "conditions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ConditionDTO" + } + }, + "actions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ActionDTO" + } + }, + "configuration": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "configDescriptions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ConfigDescriptionParameterDTO" + } + }, + "templateUID": { + "type": "string" + }, + "uid": { + "type": "string" + }, + "name": { + "type": "string" + }, + "tags": { + "uniqueItems": true, + "type": "array", + "items": { + "type": "string" + } + }, + "visibility": { + "type": "string", + "enum": [ + "VISIBLE", + "HIDDEN", + "EXPERT" + ] + }, + "description": { + "type": "string" + }, + "status": { + "$ref": "#/components/schemas/RuleStatusInfo" + }, + "editable": { + "type": "boolean" + } + } + }, + "RuleStatusInfo": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "UNINITIALIZED", + "INITIALIZING", + "IDLE", + "RUNNING" + ] + }, + "statusDetail": { + "type": "string", + "enum": [ + "NONE", + "HANDLER_MISSING_ERROR", + "HANDLER_INITIALIZING_ERROR", + "CONFIGURATION_ERROR", + "TEMPLATE_MISSING_ERROR", + "INVALID_RULE", + "DISABLED" + ] + }, + "description": { + "type": "string" + } + } + }, + "ModuleDTO": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "configuration": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "type": { + "type": "string" + } + } + }, + "Action": { + "type": "object", + "properties": { + "inputs": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "description": { + "type": "string" + }, + "label": { + "type": "string" + }, + "typeUID": { + "type": "string" + }, + "configuration": { + "$ref": "#/components/schemas/Configuration" + }, + "id": { + "type": "string" + } + } + }, + "Condition": { + "type": "object", + "properties": { + "inputs": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "description": { + "type": "string" + }, + "label": { + "type": "string" + }, + "typeUID": { + "type": "string" + }, + "configuration": { + "$ref": "#/components/schemas/Configuration" + }, + "id": { + "type": "string" + } + } + }, + "ConfigDescriptionParameter": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "TEXT", + "INTEGER", + "DECIMAL", + "BOOLEAN" + ] + }, + "groupName": { + "type": "string" + }, + "pattern": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "readOnly": { + "type": "boolean" + }, + "multiple": { + "type": "boolean" + }, + "multipleLimit": { + "type": "integer", + "format": "int32" + }, + "unit": { + "type": "string" + }, + "unitLabel": { + "type": "string" + }, + "context": { + "type": "string" + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "options": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ParameterOption" + } + }, + "filterCriteria": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FilterCriteria" + } + }, + "limitToOptions": { + "type": "boolean" + }, + "advanced": { + "type": "boolean" + }, + "minimum": { + "type": "number" + }, + "maximum": { + "type": "number" + }, + "stepSize": { + "type": "number" + }, + "verifyable": { + "type": "boolean" + }, + "default": { + "type": "string" + } + } + }, + "Configuration": { + "type": "object", + "properties": { + "properties": { + "type": "object", + "additionalProperties": { + "type": "object" + } + } + } + }, + "FilterCriteria": { + "type": "object", + "properties": { + "value": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "Module": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "label": { + "type": "string" + }, + "typeUID": { + "type": "string" + }, + "configuration": { + "$ref": "#/components/schemas/Configuration" + }, + "id": { + "type": "string" + } + } + }, + "ParameterOption": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "value": { + "type": "string" + } + } + }, + "Rule": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "visibility": { + "type": "string", + "enum": [ + "VISIBLE", + "HIDDEN", + "EXPERT" + ] + }, + "configurationDescriptions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ConfigDescriptionParameter" + } + }, + "templateUID": { + "type": "string" + }, + "triggers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Trigger" + } + }, + "uid": { + "type": "string" + }, + "tags": { + "uniqueItems": true, + "type": "array", + "items": { + "type": "string" + } + }, + "configuration": { + "$ref": "#/components/schemas/Configuration" + }, + "modules": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Module" + } + }, + "conditions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Condition" + } + }, + "name": { + "type": "string" + }, + "actions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Action" + } + } + } + }, + "RuleExecution": { + "type": "object", + "properties": { + "date": { + "type": "string", + "format": "date-time" + }, + "rule": { + "$ref": "#/components/schemas/Rule" + } + } + }, + "Trigger": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "label": { + "type": "string" + }, + "typeUID": { + "type": "string" + }, + "configuration": { + "$ref": "#/components/schemas/Configuration" + }, + "id": { + "type": "string" + } + } + }, + "Template": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "label": { + "type": "string" + }, + "visibility": { + "type": "string", + "enum": [ + "VISIBLE", + "HIDDEN", + "EXPERT" + ] + }, + "uid": { + "type": "string" + }, + "tags": { + "uniqueItems": true, + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "Input": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "type": { + "type": "string" + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "tags": { + "uniqueItems": true, + "type": "array", + "items": { + "type": "string" + } + }, + "reference": { + "type": "string" + }, + "defaultValue": { + "type": "string" + } + } + }, + "Output": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "type": { + "type": "string" + }, + "tags": { + "uniqueItems": true, + "type": "array", + "items": { + "type": "string" + } + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "reference": { + "type": "string" + }, + "defaultValue": { + "type": "string" + } + } + }, + "ThingActionDTO": { + "type": "object", + "properties": { + "actionUid": { + "type": "string" + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "inputs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Input" + } + }, + "outputs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Output" + } + } + } + }, + "AudioSinkDTO": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "label": { + "type": "string" + } + } + }, + "AudioSourceDTO": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "label": { + "type": "string" + } + } + }, + "UserApiTokenDTO": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "createdTime": { + "type": "string", + "format": "date-time" + }, + "scope": { + "type": "string" + } + } + }, + "UserSessionDTO": { + "type": "object", + "properties": { + "sessionId": { + "type": "string" + }, + "createdTime": { + "type": "string", + "format": "date-time" + }, + "lastRefreshTime": { + "type": "string", + "format": "date-time" + }, + "clientId": { + "type": "string" + }, + "scope": { + "type": "string" + } + } + }, + "TokenResponseDTO": { + "type": "object", + "properties": { + "access_token": { + "type": "string" + }, + "token_type": { + "type": "string" + }, + "expires_in": { + "type": "integer", + "format": "int32" + }, + "refresh_token": { + "type": "string" + }, + "scope": { + "type": "string" + }, + "user": { + "$ref": "#/components/schemas/UserDTO" + } + } + }, + "UserDTO": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "roles": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "Addon": { + "type": "object", + "properties": { + "uid": { + "type": "string" + }, + "id": { + "type": "string" + }, + "label": { + "type": "string" + }, + "version": { + "type": "string" + }, + "maturity": { + "type": "string" + }, + "compatible": { + "type": "boolean" + }, + "contentType": { + "type": "string" + }, + "link": { + "type": "string" + }, + "author": { + "type": "string" + }, + "verifiedAuthor": { + "type": "boolean" + }, + "installed": { + "type": "boolean" + }, + "type": { + "type": "string" + }, + "description": { + "type": "string" + }, + "detailedDescription": { + "type": "string" + }, + "configDescriptionURI": { + "type": "string" + }, + "keywords": { + "type": "string" + }, + "countries": { + "type": "array", + "items": { + "type": "string" + } + }, + "license": { + "type": "string" + }, + "connection": { + "type": "string" + }, + "backgroundColor": { + "type": "string" + }, + "imageLink": { + "type": "string" + }, + "properties": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "loggerPackages": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "AddonType": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "label": { + "type": "string" + } + } + }, + "ChannelTypeDTO": { + "type": "object", + "properties": { + "parameters": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ConfigDescriptionParameterDTO" + } + }, + "parameterGroups": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ConfigDescriptionParameterGroupDTO" + } + }, + "description": { + "type": "string" + }, + "label": { + "type": "string" + }, + "category": { + "type": "string" + }, + "itemType": { + "type": "string" + }, + "unitHint": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "stateDescription": { + "$ref": "#/components/schemas/StateDescription" + }, + "tags": { + "uniqueItems": true, + "type": "array", + "items": { + "type": "string" + } + }, + "UID": { + "type": "string" + }, + "advanced": { + "type": "boolean" + }, + "commandDescription": { + "$ref": "#/components/schemas/CommandDescription" + } + } + }, + "CommandDescription": { + "type": "object", + "properties": { + "commandOptions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CommandOption" + } + } + } + }, + "CommandOption": { + "type": "object", + "properties": { + "command": { + "type": "string" + }, + "label": { + "type": "string" + } + } + }, + "ConfigDescriptionParameterGroupDTO": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "context": { + "type": "string" + }, + "advanced": { + "type": "boolean" + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + } + } + }, + "StateDescription": { + "type": "object", + "properties": { + "minimum": { + "type": "number" + }, + "maximum": { + "type": "number" + }, + "step": { + "type": "number" + }, + "pattern": { + "type": "string" + }, + "readOnly": { + "type": "boolean" + }, + "options": { + "type": "array", + "items": { + "$ref": "#/components/schemas/StateOption" + } + } + } + }, + "StateOption": { + "type": "object", + "properties": { + "value": { + "type": "string" + }, + "label": { + "type": "string" + } + } + }, + "ConfigDescriptionDTO": { + "type": "object", + "properties": { + "uri": { + "type": "string" + }, + "parameters": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ConfigDescriptionParameterDTO" + } + }, + "parameterGroups": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ConfigDescriptionParameterGroupDTO" + } + } + } + }, + "DiscoveryResultDTO": { + "type": "object", + "properties": { + "bridgeUID": { + "type": "string" + }, + "flag": { + "type": "string", + "enum": [ + "NEW", + "IGNORED" + ] + }, + "label": { + "type": "string" + }, + "properties": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "representationProperty": { + "type": "string" + }, + "thingUID": { + "type": "string" + }, + "thingTypeUID": { + "type": "string" + } + } + }, + "MetadataDTO": { + "type": "object", + "properties": { + "value": { + "type": "string" + }, + "config": { + "type": "object", + "additionalProperties": { + "type": "object" + } + } + } + }, + "EnrichedItemDTO": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "name": { + "type": "string" + }, + "label": { + "type": "string" + }, + "category": { + "type": "string" + }, + "tags": { + "uniqueItems": true, + "type": "array", + "items": { + "type": "string" + } + }, + "groupNames": { + "type": "array", + "items": { + "type": "string" + } + }, + "link": { + "type": "string" + }, + "state": { + "type": "string" + }, + "transformedState": { + "type": "string" + }, + "stateDescription": { + "$ref": "#/components/schemas/StateDescription" + }, + "unitSymbol": { + "type": "string" + }, + "commandDescription": { + "$ref": "#/components/schemas/CommandDescription" + }, + "metadata": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "editable": { + "type": "boolean" + } + } + }, + "GroupFunctionDTO": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "params": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "GroupItemDTO": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "name": { + "type": "string" + }, + "label": { + "type": "string" + }, + "category": { + "type": "string" + }, + "tags": { + "uniqueItems": true, + "type": "array", + "items": { + "type": "string" + } + }, + "groupNames": { + "type": "array", + "items": { + "type": "string" + } + }, + "groupType": { + "type": "string" + }, + "function": { + "$ref": "#/components/schemas/GroupFunctionDTO" + } + } + }, + "EnrichedItemChannelLinkDTO": { + "type": "object", + "properties": { + "itemName": { + "type": "string" + }, + "channelUID": { + "type": "string" + }, + "configuration": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "editable": { + "type": "boolean" + } + } + }, + "ItemChannelLinkDTO": { + "type": "object", + "properties": { + "itemName": { + "type": "string" + }, + "channelUID": { + "type": "string" + }, + "configuration": { + "type": "object", + "additionalProperties": { + "type": "object" + } + } + } + }, + "HistoryDataBean": { + "type": "object", + "properties": { + "time": { + "type": "integer", + "format": "int64" + }, + "state": { + "type": "string" + } + } + }, + "ItemHistoryDTO": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "totalrecords": { + "type": "string" + }, + "datapoints": { + "type": "string" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/HistoryDataBean" + } + } + } + }, + "PersistenceCronStrategyDTO": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "cronExpression": { + "type": "string" + } + } + }, + "PersistenceFilterDTO": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "value": { + "type": "number" + }, + "relative": { + "type": "boolean" + }, + "unit": { + "type": "string" + }, + "lower": { + "type": "number" + }, + "upper": { + "type": "number" + }, + "values": { + "type": "array", + "items": { + "type": "string" + } + }, + "inverted": { + "type": "boolean" + } + } + }, + "PersistenceItemConfigurationDTO": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "string" + } + }, + "strategies": { + "type": "array", + "items": { + "type": "string" + } + }, + "filters": { + "type": "array", + "items": { + "type": "string" + } + }, + "alias": { + "type": "string" + } + } + }, + "PersistenceServiceConfigurationDTO": { + "type": "object", + "properties": { + "serviceId": { + "type": "string" + }, + "configs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PersistenceItemConfigurationDTO" + } + }, + "defaults": { + "type": "array", + "items": { + "type": "string" + } + }, + "cronStrategies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PersistenceCronStrategyDTO" + } + }, + "thresholdFilters": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PersistenceFilterDTO" + } + }, + "timeFilters": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PersistenceFilterDTO" + } + }, + "equalsFilters": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PersistenceFilterDTO" + } + }, + "includeFilters": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PersistenceFilterDTO" + } + }, + "editable": { + "type": "boolean" + } + } + }, + "PersistenceItemInfo": { + "type": "object", + "properties": { + "earliest": { + "type": "string", + "format": "date-time" + }, + "latest": { + "type": "string", + "format": "date-time" + }, + "name": { + "type": "string" + }, + "count": { + "type": "integer", + "format": "int32" + } + } + }, + "PersistenceServiceDTO": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "label": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, + "ProfileTypeDTO": { + "type": "object", + "properties": { + "uid": { + "type": "string" + }, + "label": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "supportedItemTypes": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "ConfigurableServiceDTO": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "label": { + "type": "string" + }, + "category": { + "type": "string" + }, + "configDescriptionURI": { + "type": "string" + }, + "multiple": { + "type": "boolean" + } + } + }, + "EnrichedSemanticTagDTO": { + "type": "object" + }, + "EnrichedChannelDTO": { + "type": "object", + "properties": { + "uid": { + "type": "string" + }, + "id": { + "type": "string" + }, + "channelTypeUID": { + "type": "string" + }, + "itemType": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "defaultTags": { + "uniqueItems": true, + "type": "array", + "items": { + "type": "string" + } + }, + "properties": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "configuration": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "autoUpdatePolicy": { + "type": "string" + }, + "linkedItems": { + "uniqueItems": true, + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "EnrichedThingDTO": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "bridgeUID": { + "type": "string" + }, + "configuration": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "properties": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "UID": { + "type": "string" + }, + "thingTypeUID": { + "type": "string" + }, + "location": { + "type": "string" + }, + "channels": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EnrichedChannelDTO" + } + }, + "statusInfo": { + "$ref": "#/components/schemas/ThingStatusInfo" + }, + "firmwareStatus": { + "$ref": "#/components/schemas/FirmwareStatusDTO" + }, + "editable": { + "type": "boolean" + } + } + }, + "FirmwareStatusDTO": { + "type": "object", + "properties": { + "status": { + "type": "string" + }, + "updatableVersion": { + "type": "string" + } + } + }, + "ThingStatusInfo": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "UNINITIALIZED", + "INITIALIZING", + "UNKNOWN", + "ONLINE", + "OFFLINE", + "REMOVING", + "REMOVED" + ] + }, + "statusDetail": { + "type": "string", + "enum": [ + "NONE", + "NOT_YET_READY", + "HANDLER_MISSING_ERROR", + "HANDLER_REGISTERING_ERROR", + "HANDLER_INITIALIZING_ERROR", + "HANDLER_CONFIGURATION_PENDING", + "CONFIGURATION_PENDING", + "COMMUNICATION_ERROR", + "CONFIGURATION_ERROR", + "BRIDGE_OFFLINE", + "FIRMWARE_UPDATING", + "DUTY_CYCLE", + "BRIDGE_UNINITIALIZED", + "GONE", + "DISABLED" + ] + }, + "description": { + "type": "string" + } + } + }, + "ChannelDTO": { + "type": "object", + "properties": { + "uid": { + "type": "string" + }, + "id": { + "type": "string" + }, + "channelTypeUID": { + "type": "string" + }, + "itemType": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "defaultTags": { + "uniqueItems": true, + "type": "array", + "items": { + "type": "string" + } + }, + "properties": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "configuration": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "autoUpdatePolicy": { + "type": "string" + } + } + }, + "ThingDTO": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "bridgeUID": { + "type": "string" + }, + "configuration": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "properties": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "UID": { + "type": "string" + }, + "thingTypeUID": { + "type": "string" + }, + "location": { + "type": "string" + }, + "channels": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ChannelDTO" + } + } + } + }, + "ConfigStatusMessage": { + "type": "object", + "properties": { + "parameterName": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "INFORMATION", + "WARNING", + "ERROR", + "PENDING" + ] + }, + "message": { + "type": "string" + }, + "statusCode": { + "type": "integer", + "format": "int32" + } + } + }, + "FirmwareDTO": { + "type": "object", + "properties": { + "thingTypeUID": { + "type": "string" + }, + "vendor": { + "type": "string" + }, + "model": { + "type": "string" + }, + "modelRestricted": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "version": { + "type": "string" + }, + "changelog": { + "type": "string" + }, + "prerequisiteVersion": { + "type": "string" + } + } + }, + "StrippedThingTypeDTO": { + "type": "object", + "properties": { + "UID": { + "type": "string" + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "category": { + "type": "string" + }, + "listed": { + "type": "boolean" + }, + "supportedBridgeTypeUIDs": { + "type": "array", + "items": { + "type": "string" + } + }, + "bridge": { + "type": "boolean" + } + } + }, + "ChannelDefinitionDTO": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "label": { + "type": "string" + }, + "tags": { + "uniqueItems": true, + "type": "array", + "items": { + "type": "string" + } + }, + "properties": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "category": { + "type": "string" + }, + "stateDescription": { + "$ref": "#/components/schemas/StateDescription" + }, + "advanced": { + "type": "boolean" + }, + "typeUID": { + "type": "string" + } + } + }, + "ChannelGroupDefinitionDTO": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "description": { + "type": "string" + }, + "label": { + "type": "string" + }, + "channels": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ChannelDefinitionDTO" + } + } + } + }, + "ThingTypeDTO": { + "type": "object", + "properties": { + "UID": { + "type": "string" + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "category": { + "type": "string" + }, + "listed": { + "type": "boolean" + }, + "supportedBridgeTypeUIDs": { + "type": "array", + "items": { + "type": "string" + } + }, + "bridge": { + "type": "boolean" + }, + "channels": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ChannelDefinitionDTO" + } + }, + "channelGroups": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ChannelGroupDefinitionDTO" + } + }, + "configParameters": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ConfigDescriptionParameterDTO" + } + }, + "parameterGroups": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ConfigDescriptionParameterGroupDTO" + } + }, + "properties": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "extensibleChannelTypeIds": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "Links": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "url": { + "type": "string" + } + } + }, + "RootBean": { + "type": "object", + "properties": { + "version": { + "type": "string" + }, + "locale": { + "type": "string" + }, + "measurementSystem": { + "type": "string" + }, + "runtimeInfo": { + "$ref": "#/components/schemas/RuntimeInfo" + }, + "links": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Links" + } + } + } + }, + "RuntimeInfo": { + "type": "object", + "properties": { + "version": { + "type": "string" + }, + "buildString": { + "type": "string" + } + } + }, + "SystemInfo": { + "type": "object", + "properties": { + "configFolder": { + "type": "string" + }, + "userdataFolder": { + "type": "string" + }, + "logFolder": { + "type": "string" + }, + "javaVersion": { + "type": "string" + }, + "javaVendor": { + "type": "string" + }, + "javaVendorVersion": { + "type": "string" + }, + "osName": { + "type": "string" + }, + "osVersion": { + "type": "string" + }, + "osArchitecture": { + "type": "string" + }, + "availableProcessors": { + "type": "integer", + "format": "int32" + }, + "freeMemory": { + "type": "integer", + "format": "int64" + }, + "totalMemory": { + "type": "integer", + "format": "int64" + }, + "uptime": { + "type": "integer", + "format": "int64" + }, + "startLevel": { + "type": "integer", + "format": "int32" + } + } + }, + "SystemInfoBean": { + "type": "object", + "properties": { + "systemInfo": { + "$ref": "#/components/schemas/SystemInfo" + } + } + }, + "DimensionInfo": { + "type": "object", + "properties": { + "dimension": { + "type": "string" + }, + "systemUnit": { + "type": "string" + } + } + }, + "UoMInfo": { + "type": "object", + "properties": { + "dimensions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DimensionInfo" + } + } + } + }, + "UoMInfoBean": { + "type": "object", + "properties": { + "uomInfo": { + "$ref": "#/components/schemas/UoMInfo" + } + } + }, + "MappingDTO": { + "type": "object", + "properties": { + "row": { + "type": "integer", + "format": "int32" + }, + "column": { + "type": "integer", + "format": "int32" + }, + "command": { + "type": "string" + }, + "releaseCommand": { + "type": "string" + }, + "label": { + "type": "string" + }, + "icon": { + "type": "string" + } + } + }, + "PageDTO": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "title": { + "type": "string" + }, + "icon": { + "type": "string" + }, + "link": { + "type": "string" + }, + "parent": { + "$ref": "#/components/schemas/PageDTO" + }, + "leaf": { + "type": "boolean" + }, + "timeout": { + "type": "boolean" + }, + "widgets": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WidgetDTO" + } + } + } + }, + "WidgetDTO": { + "type": "object", + "properties": { + "widgetId": { + "type": "string" + }, + "type": { + "type": "string" + }, + "name": { + "type": "string" + }, + "visibility": { + "type": "boolean" + }, + "label": { + "type": "string" + }, + "labelSource": { + "type": "string" + }, + "icon": { + "type": "string" + }, + "staticIcon": { + "type": "boolean" + }, + "labelcolor": { + "type": "string" + }, + "valuecolor": { + "type": "string" + }, + "iconcolor": { + "type": "string" + }, + "pattern": { + "type": "string" + }, + "unit": { + "type": "string" + }, + "mappings": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MappingDTO" + } + }, + "switchSupport": { + "type": "boolean" + }, + "releaseOnly": { + "type": "boolean" + }, + "sendFrequency": { + "type": "integer", + "format": "int32" + }, + "refresh": { + "type": "integer", + "format": "int32" + }, + "height": { + "type": "integer", + "format": "int32" + }, + "minValue": { + "type": "number" + }, + "maxValue": { + "type": "number" + }, + "step": { + "type": "number" + }, + "inputHint": { + "type": "string" + }, + "url": { + "type": "string" + }, + "encoding": { + "type": "string" + }, + "service": { + "type": "string" + }, + "period": { + "type": "string" + }, + "yAxisDecimalPattern": { + "type": "string" + }, + "legend": { + "type": "boolean" + }, + "forceAsItem": { + "type": "boolean" + }, + "row": { + "type": "integer", + "format": "int32" + }, + "column": { + "type": "integer", + "format": "int32" + }, + "command": { + "type": "string" + }, + "releaseCommand": { + "type": "string" + }, + "stateless": { + "type": "boolean" + }, + "state": { + "type": "string" + }, + "item": { + "$ref": "#/components/schemas/EnrichedItemDTO" + }, + "linkedPage": { + "$ref": "#/components/schemas/PageDTO" + }, + "widgets": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WidgetDTO" + } + } + } + }, + "SitemapDTO": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "icon": { + "type": "string" + }, + "label": { + "type": "string" + }, + "link": { + "type": "string" + }, + "homepage": { + "$ref": "#/components/schemas/PageDTO" + } + } + }, + "Transformation": { + "type": "object", + "properties": { + "uid": { + "type": "string" + }, + "label": { + "type": "string" + }, + "type": { + "type": "string" + }, + "configuration": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + }, + "TransformationDTO": { + "type": "object", + "properties": { + "uid": { + "type": "string" + }, + "label": { + "type": "string" + }, + "type": { + "type": "string" + }, + "configuration": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "editable": { + "type": "boolean" + } + } + }, + "RootUIComponent": { + "type": "object", + "properties": { + "component": { + "type": "string" + }, + "config": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "slots": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UIComponent" + } + } + }, + "uid": { + "type": "string" + }, + "tags": { + "uniqueItems": true, + "type": "array", + "items": { + "type": "string" + } + }, + "props": { + "$ref": "#/components/schemas/ConfigDescriptionDTO" + }, + "timestamp": { + "type": "string", + "format": "date-time" + }, + "type": { + "type": "string" + } + } + }, + "UIComponent": { + "type": "object", + "properties": { + "component": { + "type": "string" + }, + "config": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "type": { + "type": "string" + } + } + }, + "TileDTO": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "url": { + "type": "string" + }, + "overlay": { + "type": "string" + }, + "imageUrl": { + "type": "string" + } + } + }, + "VoiceDTO": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "label": { + "type": "string" + }, + "locale": { + "type": "string" + } + } + }, + "HumanLanguageInterpreterDTO": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "label": { + "type": "string" + }, + "locales": { + "uniqueItems": true, + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "LoggerInfo": { + "type": "object", + "properties": { + "loggerName": { + "type": "string" + }, + "level": { + "type": "string" + } + } + }, + "LoggerBean": { + "type": "object", + "properties": { + "loggers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LoggerInfo" + } + } + } + }, + "IconSet": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "formats": { + "uniqueItems": true, + "type": "array", + "items": { + "type": "string", + "enum": [ + "PNG", + "SVG" + ] + } + } + } + }, + "GalleryWidgetsListItem": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "likes": { + "type": "integer", + "format": "int32" + }, + "views": { + "type": "integer", + "format": "int32" + }, + "posts": { + "type": "integer", + "format": "int32" + }, + "imageUrl": { + "type": "string" + }, + "createdDate": { + "type": "string", + "format": "date-time" + } + } + }, + "GalleryItem": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "likes": { + "type": "integer", + "format": "int32" + }, + "views": { + "type": "integer", + "format": "int32" + }, + "posts": { + "type": "integer", + "format": "int32" + }, + "imageUrl": { + "type": "string" + }, + "author": { + "type": "string" + }, + "authorName": { + "type": "string" + }, + "authorAvatarUrl": { + "type": "string" + }, + "createdDate": { + "type": "string", + "format": "date-time" + }, + "updatedDate": { + "type": "string", + "format": "date-time" + }, + "readme": { + "type": "string" + } + } + } + }, + "securitySchemes": { + "oauth2": { + "type": "oauth2", + "flows": { + "authorizationCode": { + "authorizationUrl": "/auth/authorize", + "tokenUrl": "/rest/auth/token", + "scopes": { + "admin": "Administration operations" + } + } + } + } + } + } +} diff --git a/OpenHABCore/Tests/OpenHABCoreTests/JSONParserTests.swift b/OpenHABCore/Tests/OpenHABCoreTests/JSONParserTests.swift index 29a423fb8..dca4e4b72 100644 --- a/OpenHABCore/Tests/OpenHABCoreTests/JSONParserTests.swift +++ b/OpenHABCore/Tests/OpenHABCoreTests/JSONParserTests.swift @@ -146,7 +146,7 @@ final class JSONParserTests: XCTestCase { """ let data = Data(json.utf8) do { - let codingData = try decoder.decode(OpenHABSitemapPage.CodingData.self, from: data) + let codingData = try decoder.decode(OpenHABPage.CodingData.self, from: data) let decoded = codingData.openHABSitemapPage XCTAssertEqual(decoded.pageId, "1302", "LinkedPage properly parsed") } catch { @@ -255,7 +255,7 @@ final class JSONParserTests: XCTestCase { } """.data(using: .utf8)! do { - let sitemapPageCodingData = try decoder.decode(OpenHABSitemapPage.CodingData.self, from: json) + let sitemapPageCodingData = try decoder.decode(OpenHABPage.CodingData.self, from: json) let sitemapPage = sitemapPageCodingData.openHABSitemapPage XCTAssertEqual(sitemapPage.pageId, "1304", "OpenHABLinkedPage properly parsed") } catch { @@ -357,7 +357,7 @@ final class JSONParserTests: XCTestCase { func testJSONSitemapPage() { do { - let codingData = try decoder.decode(OpenHABSitemapPage.CodingData.self, from: jsonSitemap) + let codingData = try decoder.decode(OpenHABPage.CodingData.self, from: jsonSitemap) XCTAssertEqual(codingData.leaf, false, "OpenHABSitemapPage properly parsed") XCTAssertEqual(codingData.widgets?[0].widgetId, "00", "widget properly parsed") } catch { @@ -367,7 +367,7 @@ final class JSONParserTests: XCTestCase { func testJSONSitemapPage2() { do { - let codingData = try decoder.decode(OpenHABSitemapPage.CodingData.self, from: jsonSitemap2) + let codingData = try decoder.decode(OpenHABPage.CodingData.self, from: jsonSitemap2) XCTAssertEqual(codingData.leaf, false, "OpenHABSitemapPage properly parsed") XCTAssertEqual(codingData.widgets?[0].widgetId, "00", "widget properly parsed") XCTAssertEqual(codingData.widgets?[4].widgets[3].item?.stateDescription?.options?[0].label, "New moon", "State description properly parsed") @@ -385,7 +385,7 @@ final class JSONParserTests: XCTestCase { """.data(using: .utf8)! do { let codingData = try decoder.decode(OpenHABSitemap.CodingData.self, from: json) - XCTAssertEqual(codingData.page.link, "https://192.168.2.15:8444/rest/sitemaps/watch/watch", "OpenHABSitemapPage properly parsed") + XCTAssertEqual(codingData.page?.link, "https://192.168.2.15:8444/rest/sitemaps/watch/watch", "OpenHABSitemapPage properly parsed") // XCTAssert(codingData.openHABSitemapPage. widgets[0].type == "Frame", "") // XCTAssert(.widgets[0].linkedPage?.pageId == "0000", "widget properly parsed") } catch { @@ -433,7 +433,7 @@ final class JSONParserTests: XCTestCase { """ let data = Data(jsonInputForGroup.utf8) do { - let codingData = try decoder.decode(OpenHABSitemapPage.CodingData.self, from: data) + let codingData = try decoder.decode(OpenHABPage.CodingData.self, from: data) let widget = codingData.widgets?[0] XCTAssert(widget?.item?.type == "Group" && widget?.item?.groupType == "Rollershutter", "") XCTAssertEqual(codingData.widgets?[0].item?.groupType, "Rollershutter") @@ -490,11 +490,11 @@ final class JSONParserTests: XCTestCase { "End" ) - let widgets: [OpenHABWidget.CodingData] = try XCTUnwrap(codingData.page.widgets) + let widgets: [OpenHABWidget.CodingData] = try XCTUnwrap(codingData.page?.widgets) let widget = widgets[0] XCTAssertEqual(widget.label, "Flat Scenes") XCTAssertEqual(widget.widgets[0].label, "Scenes") - XCTAssertEqual(codingData.page.link, "https://192.168.0.9:8443/rest/sitemaps/default/default") + XCTAssertEqual(codingData.page?.link, "https://192.168.0.9:8443/rest/sitemaps/default/default") let widget2 = widgets[10] XCTAssertEqual(widget2.widgets[0].label, "Admin Items") } diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index 4cb5a0945..19974b6d5 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -119,6 +119,8 @@ DAC9AF4924F966FA006DAE93 /* LazyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC9AF4824F966FA006DAE93 /* LazyView.swift */; }; DACB636227D3FC6500041931 /* error.png in Resources */ = {isa = PBXBuildFile; fileRef = DACB636127D3FC6500041931 /* error.png */; }; DACB636327D3FC6500041931 /* error.png in Resources */ = {isa = PBXBuildFile; fileRef = DACB636127D3FC6500041931 /* error.png */; }; + DACE664A2C63B0760069E514 /* OpenAPIURLSession in Frameworks */ = {isa = PBXBuildFile; productRef = DACE66492C63B0760069E514 /* OpenAPIURLSession */; }; + DACE664D2C63B0840069E514 /* OpenAPIRuntime in Frameworks */ = {isa = PBXBuildFile; productRef = DACE664C2C63B0840069E514 /* OpenAPIRuntime */; }; DAEAA89B21E2611000267EA3 /* OpenHABNotificationsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAEAA89A21E2611000267EA3 /* OpenHABNotificationsViewController.swift */; }; DAEAA89D21E6B06400267EA3 /* ReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAEAA89C21E6B06300267EA3 /* ReusableView.swift */; }; DAEAA89F21E6B16600267EA3 /* UITableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAEAA89E21E6B16600267EA3 /* UITableView.swift */; }; @@ -435,6 +437,7 @@ DAF4581723DC4A050018B495 /* ImageRow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageRow.swift; sourceTree = ""; }; DAF4581D23DC60020018B495 /* ImageRawRow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageRawRow.swift; sourceTree = ""; }; DAF4F6BF222734D200C24876 /* NewImageUITableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewImageUITableViewCell.swift; sourceTree = ""; }; + DAF6F4112C67E83B0083883E /* openapiCorrected.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = openapiCorrected.json; sourceTree = ""; }; DF05EF111D00696200DD646D /* DrawerUITableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DrawerUITableViewCell.swift; sourceTree = ""; }; DF05FF1F18965B5400FF2F9B /* RollershutterUITableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RollershutterUITableViewCell.swift; sourceTree = ""; }; DF05FF221896BD2D00FF2F9B /* SelectionUITableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SelectionUITableViewCell.swift; sourceTree = ""; }; @@ -525,10 +528,12 @@ DFB2622B18830A3600D3244D /* Foundation.framework in Frameworks */, 937E4485270B379900A98C26 /* DeviceKit in Frameworks */, DFB2622F18830A3600D3244D /* UIKit.framework in Frameworks */, + DACE664A2C63B0760069E514 /* OpenAPIURLSession in Frameworks */, 93F8064A27AE7A2E0035A6B0 /* FlexColorPicker in Frameworks */, 93F8063227AE6B940035A6B0 /* AlamofireNetworkActivityIndicator in Frameworks */, 93F8065327AE7B580035A6B0 /* SVGKit in Frameworks */, DA28C362225241DE00AB409C /* WebKit.framework in Frameworks */, + DACE664D2C63B0840069E514 /* OpenAPIRuntime in Frameworks */, 93F8061B27AE615D0035A6B0 /* Alamofire in Frameworks */, 93F8065027AE7A830035A6B0 /* SideMenu in Frameworks */, DFE10414197415F900D94943 /* Security.framework in Frameworks */, @@ -772,6 +777,15 @@ path = Views; sourceTree = ""; }; + DACE66522C63B2070069E514 /* openapitest */ = { + isa = PBXGroup; + children = ( + DAF6F4112C67E83B0083883E /* openapiCorrected.json */, + ); + name = openapitest; + path = openapi/openapitest; + sourceTree = ""; + }; DAE238252806E5C800196467 /* Recovered References */ = { isa = PBXGroup; children = ( @@ -952,6 +966,7 @@ DFB2623018830A3600D3244D /* openHAB */ = { isa = PBXGroup; children = ( + DACE66522C63B2070069E514 /* openapitest */, 938BF9C324EFCB9F00E6B52F /* Main.storyboard */, A3F4C3A41A49A5940019A09F /* MainLaunchScreen.xib */, DFB2623A18830A3600D3244D /* AppDelegate.swift */, @@ -1150,6 +1165,8 @@ 93F8064F27AE7A830035A6B0 /* SideMenu */, 93F8065227AE7B580035A6B0 /* SVGKit */, 6557AF912C039D140094D0C8 /* FirebaseMessaging */, + DACE66492C63B0760069E514 /* OpenAPIURLSession */, + DACE664C2C63B0840069E514 /* OpenAPIRuntime */, ); productName = openHAB; productReference = DFB2622718830A3600D3244D /* openHAB.app */; @@ -1236,6 +1253,8 @@ 93F8064B27AE7A4D0035A6B0 /* XCRemoteSwiftPackageReference "DynamicButton" */, 93F8064E27AE7A820035A6B0 /* XCRemoteSwiftPackageReference "SideMenu" */, 93F8065127AE7B580035A6B0 /* XCRemoteSwiftPackageReference "SVGKit" */, + DACE66482C63B0760069E514 /* XCRemoteSwiftPackageReference "swift-openapi-urlsession" */, + DACE664B2C63B0840069E514 /* XCRemoteSwiftPackageReference "swift-openapi-runtime" */, ); productRefGroup = DFB2622818830A3600D3244D /* Products */; projectDirPath = ""; @@ -1909,7 +1928,7 @@ SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 4; VERSIONING_SYSTEM = "apple-generic"; - WATCHOS_DEPLOYMENT_TARGET = 7.0; + WATCHOS_DEPLOYMENT_TARGET = 10.0; }; name = Debug; }; @@ -1956,7 +1975,7 @@ SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 4; VERSIONING_SYSTEM = "apple-generic"; - WATCHOS_DEPLOYMENT_TARGET = 7.0; + WATCHOS_DEPLOYMENT_TARGET = 10.0; }; name = Release; }; @@ -2404,6 +2423,22 @@ kind = branch; }; }; + DACE66482C63B0760069E514 /* XCRemoteSwiftPackageReference "swift-openapi-urlsession" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/apple/swift-openapi-urlsession"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.0.2; + }; + }; + DACE664B2C63B0840069E514 /* XCRemoteSwiftPackageReference "swift-openapi-runtime" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/apple/swift-openapi-runtime"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.5.0; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -2517,6 +2552,16 @@ package = 93F8065127AE7B580035A6B0 /* XCRemoteSwiftPackageReference "SVGKit" */; productName = SVGKit; }; + DACE66492C63B0760069E514 /* OpenAPIURLSession */ = { + isa = XCSwiftPackageProductDependency; + package = DACE66482C63B0760069E514 /* XCRemoteSwiftPackageReference "swift-openapi-urlsession" */; + productName = OpenAPIURLSession; + }; + DACE664C2C63B0840069E514 /* OpenAPIRuntime */ = { + isa = XCSwiftPackageProductDependency; + package = DACE664B2C63B0840069E514 /* XCRemoteSwiftPackageReference "swift-openapi-runtime" */; + productName = OpenAPIRuntime; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = DFB2621F18830A3600D3244D /* Project object */; diff --git a/openHAB.xcworkspace/xcshareddata/swiftpm/Package.resolved b/openHAB.xcworkspace/xcshareddata/swiftpm/Package.resolved index 08b6dad7d..9636f6eb0 100644 --- a/openHAB.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/openHAB.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,223 +1,257 @@ { - "object": { - "pins": [ - { - "package": "abseil", - "repositoryURL": "https://github.com/google/abseil-cpp-binary.git", - "state": { - "branch": null, - "revision": "748c7837511d0e6a507737353af268484e1745e2", - "version": "1.2024011601.1" - } - }, - { - "package": "Alamofire", - "repositoryURL": "https://github.com/Alamofire/Alamofire.git", - "state": { - "branch": null, - "revision": "f455c2975872ccd2d9c81594c658af65716e9b9a", - "version": "5.9.1" - } - }, - { - "package": "AlamofireNetworkActivityIndicator", - "repositoryURL": "https://github.com/Alamofire/AlamofireNetworkActivityIndicator.git", - "state": { - "branch": null, - "revision": "392bed083e8d193aca16bfa684ee24e4bcff0510", - "version": "3.1.0" - } - }, - { - "package": "AppCheck", - "repositoryURL": "https://github.com/google/app-check.git", - "state": { - "branch": null, - "revision": "076b241a625e25eac22f8849be256dfb960fcdfe", - "version": "10.19.1" - } - }, - { - "package": "CocoaLumberjack", - "repositoryURL": "https://github.com/CocoaLumberjack/CocoaLumberjack.git", - "state": { - "branch": null, - "revision": "4b8714a7fb84d42393314ce897127b3939885ec3", - "version": "3.8.5" - } - }, - { - "package": "DeviceKit", - "repositoryURL": "https://github.com/devicekit/DeviceKit.git", - "state": { - "branch": null, - "revision": "d37e70cb2646666dcf276d7d3d4a9760a41ff8a6", - "version": "4.9.0" - } - }, - { - "package": "DynamicButton", - "repositoryURL": "https://github.com/yannickl/DynamicButton.git", - "state": { - "branch": null, - "revision": "4fbd60e46a548e77fd118483bbb4e58d3c11c5ed", - "version": "6.2.1" - } - }, - { - "package": "Firebase", - "repositoryURL": "https://github.com/firebase/firebase-ios-sdk.git", - "state": { - "branch": null, - "revision": "9d17b500cd98d9a7009751ad62f802e152e97021", - "version": "10.26.0" - } - }, - { - "package": "FlexColorPicker", - "repositoryURL": "https://github.com/RastislavMirek/FlexColorPicker.git", - "state": { - "branch": null, - "revision": "72a5c2c5e28074e6c5f13efe3c98eb780ae2f906", - "version": "1.4.4" - } - }, - { - "package": "GoogleAppMeasurement", - "repositoryURL": "https://github.com/google/GoogleAppMeasurement.git", - "state": { - "branch": null, - "revision": "16244d177c4e989f87b25e9db1012b382cfedc55", - "version": "10.25.0" - } - }, - { - "package": "GoogleDataTransport", - "repositoryURL": "https://github.com/google/GoogleDataTransport.git", - "state": { - "branch": null, - "revision": "a637d318ae7ae246b02d7305121275bc75ed5565", - "version": "9.4.0" - } - }, - { - "package": "GoogleUtilities", - "repositoryURL": "https://github.com/google/GoogleUtilities.git", - "state": { - "branch": null, - "revision": "57a1d307f42df690fdef2637f3e5b776da02aad6", - "version": "7.13.3" - } - }, - { - "package": "gRPC", - "repositoryURL": "https://github.com/google/grpc-binary.git", - "state": { - "branch": null, - "revision": "e9fad491d0673bdda7063a0341fb6b47a30c5359", - "version": "1.62.2" - } - }, - { - "package": "GTMSessionFetcher", - "repositoryURL": "https://github.com/google/gtm-session-fetcher.git", - "state": { - "branch": null, - "revision": "0382ca27f22fb3494cf657d8dc356dc282cd1193", - "version": "3.4.1" - } - }, - { - "package": "InteropForGoogle", - "repositoryURL": "https://github.com/google/interop-ios-for-google-sdks.git", - "state": { - "branch": null, - "revision": "2d12673670417654f08f5f90fdd62926dc3a2648", - "version": "100.0.0" - } - }, - { - "package": "Kingfisher", - "repositoryURL": "https://github.com/onevcat/Kingfisher.git", - "state": { - "branch": null, - "revision": "5b92f029fab2cce44386d28588098b5be0824ef5", - "version": "7.11.0" - } - }, - { - "package": "leveldb", - "repositoryURL": "https://github.com/firebase/leveldb.git", - "state": { - "branch": null, - "revision": "a0bc79961d7be727d258d33d5a6b2f1023270ba1", - "version": "1.22.5" - } - }, - { - "package": "nanopb", - "repositoryURL": "https://github.com/firebase/nanopb.git", - "state": { - "branch": null, - "revision": "b7e1104502eca3a213b46303391ca4d3bc8ddec1", - "version": "2.30910.0" - } - }, - { - "package": "Promises", - "repositoryURL": "https://github.com/google/promises.git", - "state": { - "branch": null, - "revision": "540318ecedd63d883069ae7f1ed811a2df00b6ac", - "version": "2.4.0" - } - }, - { - "package": "SideMenu", - "repositoryURL": "https://github.com/jonkykong/SideMenu.git", - "state": { - "branch": null, - "revision": "8bd4fd128923cf5494fa726839af8afe12908ad9", - "version": "6.5.0" - } - }, - { - "package": "SVGKit", - "repositoryURL": "https://github.com/SVGKit/SVGKit.git", - "state": { - "branch": "3.x", - "revision": "02421928cab787faaffb2403d47c39392936fbc7", - "version": null - } - }, - { - "package": "swift-log", - "repositoryURL": "https://github.com/apple/swift-log", - "state": { - "branch": null, - "revision": "e97a6fcb1ab07462881ac165fdbb37f067e205d5", - "version": "1.5.4" - } - }, - { - "package": "SwiftProtobuf", - "repositoryURL": "https://github.com/apple/swift-protobuf.git", - "state": { - "branch": null, - "revision": "9f0c76544701845ad98716f3f6a774a892152bcb", - "version": "1.26.0" - } - }, - { - "package": "SwiftMessages", - "repositoryURL": "https://github.com/SwiftKickMobile/SwiftMessages.git", - "state": { - "branch": null, - "revision": "62e12e138fc3eedf88c7553dd5d98712aa119f40", - "version": "9.0.9" - } - } - ] - }, - "version": 1 + "pins" : [ + { + "identity" : "abseil-cpp-binary", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/abseil-cpp-binary.git", + "state" : { + "revision" : "748c7837511d0e6a507737353af268484e1745e2", + "version" : "1.2024011601.1" + } + }, + { + "identity" : "alamofire", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Alamofire/Alamofire.git", + "state" : { + "revision" : "f455c2975872ccd2d9c81594c658af65716e9b9a", + "version" : "5.9.1" + } + }, + { + "identity" : "alamofirenetworkactivityindicator", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Alamofire/AlamofireNetworkActivityIndicator.git", + "state" : { + "revision" : "392bed083e8d193aca16bfa684ee24e4bcff0510", + "version" : "3.1.0" + } + }, + { + "identity" : "app-check", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/app-check.git", + "state" : { + "revision" : "076b241a625e25eac22f8849be256dfb960fcdfe", + "version" : "10.19.1" + } + }, + { + "identity" : "cocoalumberjack", + "kind" : "remoteSourceControl", + "location" : "https://github.com/CocoaLumberjack/CocoaLumberjack.git", + "state" : { + "revision" : "4b8714a7fb84d42393314ce897127b3939885ec3", + "version" : "3.8.5" + } + }, + { + "identity" : "devicekit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/devicekit/DeviceKit.git", + "state" : { + "revision" : "d37e70cb2646666dcf276d7d3d4a9760a41ff8a6", + "version" : "4.9.0" + } + }, + { + "identity" : "dynamicbutton", + "kind" : "remoteSourceControl", + "location" : "https://github.com/yannickl/DynamicButton.git", + "state" : { + "revision" : "4fbd60e46a548e77fd118483bbb4e58d3c11c5ed", + "version" : "6.2.1" + } + }, + { + "identity" : "firebase-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/firebase-ios-sdk.git", + "state" : { + "revision" : "9d17b500cd98d9a7009751ad62f802e152e97021", + "version" : "10.26.0" + } + }, + { + "identity" : "flexcolorpicker", + "kind" : "remoteSourceControl", + "location" : "https://github.com/RastislavMirek/FlexColorPicker.git", + "state" : { + "revision" : "72a5c2c5e28074e6c5f13efe3c98eb780ae2f906", + "version" : "1.4.4" + } + }, + { + "identity" : "googleappmeasurement", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleAppMeasurement.git", + "state" : { + "revision" : "16244d177c4e989f87b25e9db1012b382cfedc55", + "version" : "10.25.0" + } + }, + { + "identity" : "googledatatransport", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleDataTransport.git", + "state" : { + "revision" : "a637d318ae7ae246b02d7305121275bc75ed5565", + "version" : "9.4.0" + } + }, + { + "identity" : "googleutilities", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleUtilities.git", + "state" : { + "revision" : "57a1d307f42df690fdef2637f3e5b776da02aad6", + "version" : "7.13.3" + } + }, + { + "identity" : "grpc-binary", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/grpc-binary.git", + "state" : { + "revision" : "e9fad491d0673bdda7063a0341fb6b47a30c5359", + "version" : "1.62.2" + } + }, + { + "identity" : "gtm-session-fetcher", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/gtm-session-fetcher.git", + "state" : { + "revision" : "0382ca27f22fb3494cf657d8dc356dc282cd1193", + "version" : "3.4.1" + } + }, + { + "identity" : "interop-ios-for-google-sdks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/interop-ios-for-google-sdks.git", + "state" : { + "revision" : "2d12673670417654f08f5f90fdd62926dc3a2648", + "version" : "100.0.0" + } + }, + { + "identity" : "kingfisher", + "kind" : "remoteSourceControl", + "location" : "https://github.com/onevcat/Kingfisher.git", + "state" : { + "revision" : "5b92f029fab2cce44386d28588098b5be0824ef5", + "version" : "7.11.0" + } + }, + { + "identity" : "leveldb", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/leveldb.git", + "state" : { + "revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1", + "version" : "1.22.5" + } + }, + { + "identity" : "nanopb", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/nanopb.git", + "state" : { + "revision" : "b7e1104502eca3a213b46303391ca4d3bc8ddec1", + "version" : "2.30910.0" + } + }, + { + "identity" : "promises", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/promises.git", + "state" : { + "revision" : "540318ecedd63d883069ae7f1ed811a2df00b6ac", + "version" : "2.4.0" + } + }, + { + "identity" : "sidemenu", + "kind" : "remoteSourceControl", + "location" : "https://github.com/jonkykong/SideMenu.git", + "state" : { + "revision" : "8bd4fd128923cf5494fa726839af8afe12908ad9", + "version" : "6.5.0" + } + }, + { + "identity" : "svgkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SVGKit/SVGKit.git", + "state" : { + "branch" : "3.x", + "revision" : "02421928cab787faaffb2403d47c39392936fbc7" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections", + "state" : { + "revision" : "3d2dc41a01f9e49d84f0a3925fb858bed64f702d", + "version" : "1.1.2" + } + }, + { + "identity" : "swift-http-types", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-types", + "state" : { + "revision" : "ae67c8178eb46944fd85e4dc6dd970e1f3ed6ccd", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log", + "state" : { + "revision" : "e97a6fcb1ab07462881ac165fdbb37f067e205d5", + "version" : "1.5.4" + } + }, + { + "identity" : "swift-openapi-runtime", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-openapi-runtime", + "state" : { + "revision" : "26e8ae3515d1ff3607e924ac96fc0094775f55e8", + "version" : "1.5.0" + } + }, + { + "identity" : "swift-openapi-urlsession", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-openapi-urlsession", + "state" : { + "revision" : "9bf4c712ad7989d6a91dbe68748b8829a50837e4", + "version" : "1.0.2" + } + }, + { + "identity" : "swift-protobuf", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-protobuf.git", + "state" : { + "revision" : "9f0c76544701845ad98716f3f6a774a892152bcb", + "version" : "1.26.0" + } + }, + { + "identity" : "swiftmessages", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SwiftKickMobile/SwiftMessages.git", + "state" : { + "revision" : "62e12e138fc3eedf88c7553dd5d98712aa119f40", + "version" : "9.0.9" + } + } + ], + "version" : 2 } diff --git a/openHAB/OpenHABDrawerTableViewController.swift b/openHAB/OpenHABDrawerTableViewController.swift index 9449d28cf..cbe48ed76 100644 --- a/openHAB/OpenHABDrawerTableViewController.swift +++ b/openHAB/OpenHABDrawerTableViewController.swift @@ -10,30 +10,12 @@ // SPDX-License-Identifier: EPL-2.0 import DynamicButton +import OpenAPIURLSession import OpenHABCore import os.log import SafariServices import UIKit -func deriveSitemaps(_ response: Data?) -> [OpenHABSitemap] { - var sitemaps = [OpenHABSitemap]() - - if let response { - do { - os_log("Response will be decoded by JSON", log: .remoteAccess, type: .info) - let sitemapsCodingData = try response.decoded(as: [OpenHABSitemap.CodingData].self) - for sitemapCodingDatum in sitemapsCodingData { - os_log("Sitemap %{PUBLIC}@", log: .remoteAccess, type: .info, sitemapCodingDatum.label) - sitemaps.append(sitemapCodingDatum.openHABSitemap) - } - } catch { - os_log("Should not throw %{PUBLIC}@", log: .notifications, type: .error, error.localizedDescription) - } - } - - return sitemaps -} - struct UiTile: Decodable { var name: String var url: String @@ -55,11 +37,15 @@ class OpenHABDrawerTableViewController: UITableViewController { AppDelegate.appDelegate.appData } + private let apiactor: APIActor + init() { + apiactor = APIActor() super.init(nibName: nil, bundle: nil) } required init?(coder aDecoder: NSCoder) { + apiactor = APIActor() super.init(coder: aDecoder) } @@ -77,27 +63,24 @@ class OpenHABDrawerTableViewController: UITableViewController { super.viewWillAppear(animated) os_log("OpenHABDrawerTableViewController viewWillAppear", log: .viewCycle, type: .info) - NetworkConnection.sitemaps(openHABRootUrl: appData?.openHABRootUrl ?? "") { response in - switch response.result { - case let .success(data): - os_log("Sitemap response", log: .viewCycle, type: .info) - - self.sitemaps = deriveSitemaps(data) + Task { + do { + await apiactor.updateBaseURL(with: URL(string: appData?.openHABRootUrl ?? "")!) - if self.sitemaps.last?.name == "_default", self.sitemaps.count > 1 { - self.sitemaps = Array(self.sitemaps.dropLast()) + sitemaps = try await apiactor.openHABSitemaps() + if sitemaps.last?.name == "_default", sitemaps.count > 1 { + sitemaps = Array(sitemaps.dropLast()) } - // Sort the sitemaps according to Settings selection. switch SortSitemapsOrder(rawValue: Preferences.sortSitemapsby) ?? .label { - case .label: self.sitemaps.sort { $0.label < $1.label } - case .name: self.sitemaps.sort { $0.name < $1.name } + case .label: sitemaps.sort { $0.label < $1.label } + case .name: sitemaps.sort { $0.name < $1.name } } self.drawerItems.removeAll() self.setStandardDrawerItems() self.tableView.reloadData() - case let .failure(error): + } catch { os_log("%{PUBLIC}@", log: .default, type: .error, error.localizedDescription) self.drawerItems.removeAll() self.setStandardDrawerItems() @@ -105,23 +88,13 @@ class OpenHABDrawerTableViewController: UITableViewController { } } - NetworkConnection.uiTiles(openHABRootUrl: appData?.openHABRootUrl ?? "") { response in - switch response.result { - case .success: - UIApplication.shared.isNetworkActivityIndicatorVisible = false + Task { + do { + await apiactor.updateBaseURL(with: URL(string: appData?.openHABRootUrl ?? "")!) + uiTiles = try await apiactor.openHABTiles() os_log("ui tiles response", log: .viewCycle, type: .info) - guard let responseData = response.data else { - os_log("Error: did not receive data", log: OSLog.remoteAccess, type: .info) - return - } - do { - self.uiTiles = try JSONDecoder().decode([OpenHABUiTile].self, from: responseData) - self.tableView.reloadData() - } catch { - os_log("Error: did not receive data %{PUBLIC}@", log: OSLog.remoteAccess, type: .info, error.localizedDescription) - } - case let .failure(error): - UIApplication.shared.isNetworkActivityIndicatorVisible = false + self.tableView.reloadData() + } catch { os_log("%{PUBLIC}@", log: .default, type: .error, error.localizedDescription) } } diff --git a/openHAB/OpenHABSitemapViewController.swift b/openHAB/OpenHABSitemapViewController.swift index 53e7cf0c6..9b01fb651 100644 --- a/openHAB/OpenHABSitemapViewController.swift +++ b/openHAB/OpenHABSitemapViewController.swift @@ -13,6 +13,8 @@ import Alamofire import AVFoundation import AVKit import Kingfisher +import OpenAPIRuntime +import OpenAPIURLSession import OpenHABCore import os.log import SafariServices @@ -71,20 +73,21 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel private var defaultSitemap = "" private var idleOff = false private var sitemaps: [OpenHABSitemap] = [] - private var currentPage: OpenHABSitemapPage? + private var currentPage: OpenHABPage? private var selectionPicker: UIPickerView? private var pageNetworkStatus: NetworkReachabilityManager.NetworkReachabilityStatus? private var pageNetworkStatusAvailable = false private var toggle: Int = 0 private var refreshControl: UIRefreshControl? - private var filteredPage: OpenHABSitemapPage? + private var filteredPage: OpenHABPage? private var serverProperties: OpenHABServerProperties? private let search = UISearchController(searchResultsController: nil) private var webViewController: OpenHABWebViewController? private var isUserInteracting = false private var isWaitingToReload = false + private var asyncOperation: Task? - var relevantPage: OpenHABSitemapPage? { + var relevantPage: OpenHABPage? { if isFiltering { filteredPage } else { @@ -109,6 +112,8 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel search.isActive && !searchBarIsEmpty } + private let apiactor = APIActor() + @IBOutlet private var widgetTableView: UITableView! // Here goes everything about view loading, appearing, disappearing, entering background and becoming active @@ -177,6 +182,7 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel OpenHABTracker.shared.multicastDelegate.add(self) OpenHABTracker.shared.restart() } else { + Task { await apiactor.updateBaseURL(with: URL(string: appData!.openHABRootUrl)!) } if !pageNetworkStatusChanged() { os_log("OpenHABSitemapViewController pageUrl = %{PUBLIC}@", log: .notifications, type: .info, pageUrl) loadPage(false) @@ -307,6 +313,11 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel currentPageOperation = nil } + // if asyncOperation != nil { + // asyncOperation?.cancel() + // asyncOperation = nil + // } + if pageUrl == "" { return } @@ -315,37 +326,14 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel // If this is the first request to the page make a bulk call to pageNetworkStatusChanged // to save current reachability status. if !longPolling { - _ = pageNetworkStatusChanged() + pageNetworkStatusChanged() } + asyncOperation = Task { + do { + await apiactor.updateBaseURL(with: URL(string: appData?.openHABRootUrl ?? "")!) - currentPageOperation = NetworkConnection.page( - pageUrl: pageUrl, - longPolling: longPolling - ) { [weak self] response in - guard let self else { return } + currentPage = try await apiactor.openHABpollPage(sitemapname: defaultSitemap, longPolling: longPolling) - switch response.result { - case let .success(data): - os_log("Page loaded with success", log: OSLog.remoteAccess, type: .info) - let headers = response.response?.allHeaderFields - - NetworkConnection.atmosphereTrackingId = headers?["X-Atmosphere-tracking-id"] as? String ?? "" - if !NetworkConnection.atmosphereTrackingId.isEmpty { - os_log("Found X-Atmosphere-tracking-id: %{PUBLIC}@", log: .remoteAccess, type: .info, NetworkConnection.atmosphereTrackingId) - } - var openHABSitemapPage: OpenHABSitemapPage? - do { - // Self-executing closure - // Inspired by https://www.swiftbysundell.com/posts/inline-types-and-functions-in-swift - openHABSitemapPage = try { - let sitemapPageCodingData = try data.decoded(as: OpenHABSitemapPage.CodingData.self) - return sitemapPageCodingData.openHABSitemapPage - }() - } catch { - os_log("Should not throw %{PUBLIC}@", log: OSLog.remoteAccess, type: .error, error.localizedDescription) - } - - currentPage = openHABSitemapPage if isFiltering { filterContentForSearchText(search.searchBar.text) } @@ -363,65 +351,64 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel parent?.navigationItem.title = currentPage?.title.components(separatedBy: "[")[0] loadPage(true) - case let .failure(error): - os_log("On LoadPage \"%{PUBLIC}@\" code: %d ", log: .remoteAccess, type: .error, error.localizedDescription, response.response?.statusCode ?? 0) - + } catch let error as DecodingError { + os_log("DecodingError %{PUBLIC}@", log: .default, type: .error, error.localizedDescription) + // } catch let error as NSError where error.code == -1001 { + // os_log("Timeout, restarting requests", log: OSLog.remoteAccess, type: .error) + // loadPage(false) + + } catch { + os_log("On LoadPage \"%{PUBLIC}@\" code: %d ", log: .remoteAccess, type: .error, error.localizedDescription) NetworkConnection.atmosphereTrackingId = "" - if (error as NSError?)?.code == -1001, longPolling { - os_log("Timeout, restarting requests", log: OSLog.remoteAccess, type: .error) - loadPage(false) - } else if error.isExplicitlyCancelledError { - os_log("Request was cancelled", log: OSLog.remoteAccess, type: .error) - } else { - // Error - DispatchQueue.main.async { - if (error as NSError?)?.code == -1012 { - self.showPopupMessage(seconds: 5, title: NSLocalizedString("error", comment: ""), message: NSLocalizedString("ssl_certificate_error", comment: ""), theme: .error) - } else { - self.showPopupMessage(seconds: 5, title: NSLocalizedString("error", comment: ""), message: error.localizedDescription, theme: .error) - } + // Error + DispatchQueue.main.async { + if (error as NSError?)?.code == -1012 { + self.showPopupMessage(seconds: 5, title: NSLocalizedString("error", comment: ""), message: NSLocalizedString("ssl_certificate_error", comment: ""), theme: .error) + } else { + self.showPopupMessage(seconds: 5, title: NSLocalizedString("error", comment: ""), message: error.localizedDescription, theme: .error) } } } + return 0 } - currentPageOperation?.resume() - os_log("OpenHABSitemapViewController request sent", log: .remoteAccess, type: .error) } // Select sitemap func selectSitemap() { - NetworkConnection.sitemaps(openHABRootUrl: openHABRootUrl) { response in - switch response.result { - case let .success(data): - self.sitemaps = deriveSitemaps(data) - switch self.sitemaps.count { + Task { + do { + await apiactor.updateBaseURL(with: URL(string: appData?.openHABRootUrl ?? "")!) + + sitemaps = try await apiactor.openHABSitemaps() + + switch sitemaps.count { case 2...: if !self.defaultSitemap.isEmpty { - if let sitemapToOpen = self.sitemap(byName: self.defaultSitemap) { + if let sitemapToOpen = sitemap(byName: self.defaultSitemap) { if self.currentPage?.pageId != sitemapToOpen.name { self.currentPage?.widgets.removeAll() // NOTE: remove all widgets to ensure cells get invalidated } - self.pageUrl = sitemapToOpen.homepageLink - self.loadPage(false) + pageUrl = sitemapToOpen.homepageLink + loadPage(false) } else { - self.showSideMenu() + showSideMenu() } } else { - self.showSideMenu() + showSideMenu() } case 1: - self.pageUrl = self.sitemaps[0].homepageLink - self.loadPage(false) + pageUrl = sitemaps[0].homepageLink + loadPage(false) case ...0: - self.showPopupMessage(seconds: 5, title: NSLocalizedString("warning", comment: ""), message: NSLocalizedString("empty_sitemap", comment: ""), theme: .warning) - self.showSideMenu() + showPopupMessage(seconds: 5, title: NSLocalizedString("warning", comment: ""), message: NSLocalizedString("empty_sitemap", comment: ""), theme: .warning) + showSideMenu() default: break } - self.widgetTableView.reloadData() - case let .failure(error): - os_log("%{PUBLIC}@ %d", log: .default, type: .error, error.localizedDescription, response.response?.statusCode ?? 0) + widgetTableView.reloadData() + } catch { + os_log("%{PUBLIC}@", log: .default, type: .error, error.localizedDescription) DispatchQueue.main.async { // Error if (error as NSError?)?.code == -1012 { @@ -464,6 +451,7 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel return nil } + @discardableResult func pageNetworkStatusChanged() -> Bool { os_log("OpenHABSitemapViewController pageNetworkStatusChange", log: .remoteAccess, type: .info) if !pageUrl.isEmpty { @@ -497,16 +485,15 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel } func sendCommand(_ item: OpenHABItem?, commandToSend command: String?) { - if commandOperation != nil { - commandOperation?.cancel() - commandOperation = nil - } if let item, let command { - commandOperation = NetworkConnection.sendCommand(item: item, commandToSend: command) - commandOperation?.resume() + sendCommand(itemname: item.name, command: command) } } + func sendCommand(itemname: String, command: String) { + Task { try await apiactor.openHABSendItemCommand(itemname: itemname, command: command) } + } + override func reloadView() { defaultSitemap = Preferences.defaultSitemap selectSitemap() diff --git a/openHAB/openapi/openapitest/openapiCorrected.json b/openHAB/openapi/openapitest/openapiCorrected.json new file mode 100644 index 000000000..7a183faec --- /dev/null +++ b/openHAB/openapi/openapitest/openapiCorrected.json @@ -0,0 +1,9756 @@ +{ + "openapi": "3.0.1", + "info": { + "title": "openHAB REST API", + "contact": { + "name": "openHAB", + "url": "https://www.openhab.org/docs/" + }, + "version": "8" + }, + "servers": [ + { + "url": "/rest" + } + ], + "paths": { + "/module-types": { + "get": { + "tags": [ + "module-types" + ], + "summary": "Get all available module types.", + "operationId": "getModuleTypes", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + }, + { + "name": "tags", + "in": "query", + "description": "tags for filtering", + "schema": { + "type": "string" + } + }, + { + "name": "type", + "in": "query", + "description": "filtering by action, condition or trigger", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ModuleTypeDTO" + } + } + } + } + } + } + } + }, + "/module-types/{moduleTypeUID}": { + "get": { + "tags": [ + "module-types" + ], + "summary": "Gets a module type corresponding to the given UID.", + "operationId": "getModuleTypeById", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + }, + { + "name": "moduleTypeUID", + "in": "path", + "description": "moduleTypeUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModuleTypeDTO" + } + } + } + }, + "404": { + "description": "Module Type corresponding to the given UID does not found." + } + } + } + }, + "/rules": { + "get": { + "tags": [ + "rules" + ], + "summary": "Get available rules, optionally filtered by tags and/or prefix.", + "operationId": "getRules", + "parameters": [ + { + "name": "prefix", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "tags", + "in": "query", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "name": "summary", + "in": "query", + "description": "summary fields only", + "schema": { + "type": "boolean" + } + }, + { + "name": "staticDataOnly", + "in": "query", + "description": "provides a cacheable list of values not expected to change regularly and honors the If-Modified-Since header, all other parameters are ignored", + "schema": { + "type": "boolean", + "default": false + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EnrichedRuleDTO" + } + } + } + } + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + }, + "post": { + "tags": [ + "rules" + ], + "summary": "Creates a rule.", + "operationId": "createRule", + "requestBody": { + "description": "rule data", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RuleDTO" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Created", + "headers": { + "Location": { + "description": "Newly created Rule", + "schema": { + "type": "string" + } + } + } + }, + "409": { + "description": "Creation of the rule is refused. Rule with the same UID already exists." + }, + "400": { + "description": "Creation of the rule is refused. Missing required parameter." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/rules/{ruleUID}/enable": { + "post": { + "tags": [ + "rules" + ], + "summary": "Sets the rule enabled status.", + "operationId": "enableRule", + "parameters": [ + { + "name": "ruleUID", + "in": "path", + "description": "ruleUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "enable", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "Rule corresponding to the given UID does not found." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/rules/{ruleUID}/actions": { + "get": { + "tags": [ + "rules" + ], + "summary": "Gets the rule actions.", + "operationId": "getRuleActions", + "parameters": [ + { + "name": "ruleUID", + "in": "path", + "description": "ruleUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ActionDTO" + } + } + } + } + }, + "404": { + "description": "Rule corresponding to the given UID does not found." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/rules/{ruleUID}": { + "get": { + "tags": [ + "rules" + ], + "summary": "Gets the rule corresponding to the given UID.", + "operationId": "getRuleById", + "parameters": [ + { + "name": "ruleUID", + "in": "path", + "description": "ruleUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EnrichedRuleDTO" + } + } + } + }, + "404": { + "description": "Rule not found" + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + }, + "put": { + "tags": [ + "rules" + ], + "summary": "Updates an existing rule corresponding to the given UID.", + "operationId": "updateRule", + "parameters": [ + { + "name": "ruleUID", + "in": "path", + "description": "ruleUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "rule data", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RuleDTO" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "Rule corresponding to the given UID does not found." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + }, + "delete": { + "tags": [ + "rules" + ], + "summary": "Removes an existing rule corresponding to the given UID.", + "operationId": "deleteRule", + "parameters": [ + { + "name": "ruleUID", + "in": "path", + "description": "ruleUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "Rule corresponding to the given UID does not found." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/rules/{ruleUID}/conditions": { + "get": { + "tags": [ + "rules" + ], + "summary": "Gets the rule conditions.", + "operationId": "getRuleConditions", + "parameters": [ + { + "name": "ruleUID", + "in": "path", + "description": "ruleUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ConditionDTO" + } + } + } + } + }, + "404": { + "description": "Rule corresponding to the given UID does not found." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/rules/{ruleUID}/config": { + "get": { + "tags": [ + "rules" + ], + "summary": "Gets the rule configuration values.", + "operationId": "getRuleConfiguration", + "parameters": [ + { + "name": "ruleUID", + "in": "path", + "description": "ruleUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + }, + "404": { + "description": "Rule corresponding to the given UID does not found." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + }, + "put": { + "tags": [ + "rules" + ], + "summary": "Sets the rule configuration values.", + "operationId": "updateRuleConfiguration", + "parameters": [ + { + "name": "ruleUID", + "in": "path", + "description": "ruleUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "config", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "type": "object" + } + } + } + } + }, + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "Rule corresponding to the given UID does not found." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/rules/{ruleUID}/{moduleCategory}/{id}": { + "get": { + "tags": [ + "rules" + ], + "summary": "Gets the rule\u0027s module corresponding to the given Category and ID.", + "operationId": "getRuleModuleById", + "parameters": [ + { + "name": "ruleUID", + "in": "path", + "description": "ruleUID", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "moduleCategory", + "in": "path", + "description": "moduleCategory", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "id", + "in": "path", + "description": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModuleDTO" + } + } + } + }, + "404": { + "description": "Rule corresponding to the given UID does not found or does not have a module with such Category and ID." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/rules/{ruleUID}/{moduleCategory}/{id}/config": { + "get": { + "tags": [ + "rules" + ], + "summary": "Gets the module\u0027s configuration.", + "operationId": "getRuleModuleConfig", + "parameters": [ + { + "name": "ruleUID", + "in": "path", + "description": "ruleUID", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "moduleCategory", + "in": "path", + "description": "moduleCategory", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "id", + "in": "path", + "description": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + }, + "404": { + "description": "Rule corresponding to the given UID does not found or does not have a module with such Category and ID." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/rules/{ruleUID}/{moduleCategory}/{id}/config/{param}": { + "get": { + "tags": [ + "rules" + ], + "summary": "Gets the module\u0027s configuration parameter.", + "operationId": "getRuleModuleConfigParameter", + "parameters": [ + { + "name": "ruleUID", + "in": "path", + "description": "ruleUID", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "moduleCategory", + "in": "path", + "description": "moduleCategory", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "id", + "in": "path", + "description": "id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "param", + "in": "path", + "description": "param", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "404": { + "description": "Rule corresponding to the given UID does not found or does not have a module with such Category and ID." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + }, + "put": { + "tags": [ + "rules" + ], + "summary": "Sets the module\u0027s configuration parameter value.", + "operationId": "setRuleModuleConfigParameter", + "parameters": [ + { + "name": "ruleUID", + "in": "path", + "description": "ruleUID", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "moduleCategory", + "in": "path", + "description": "moduleCategory", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "id", + "in": "path", + "description": "id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "param", + "in": "path", + "description": "param", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "value", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "Rule corresponding to the given UID does not found or does not have a module with such Category and ID." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/rules/{ruleUID}/triggers": { + "get": { + "tags": [ + "rules" + ], + "summary": "Gets the rule triggers.", + "operationId": "getRuleTriggers", + "parameters": [ + { + "name": "ruleUID", + "in": "path", + "description": "ruleUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TriggerDTO" + } + } + } + } + }, + "404": { + "description": "Rule corresponding to the given UID does not found." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/rules/{ruleUID}/runnow": { + "post": { + "tags": [ + "rules" + ], + "summary": "Executes actions of the rule.", + "operationId": "runRuleNow_1", + "parameters": [ + { + "name": "ruleUID", + "in": "path", + "description": "ruleUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "the context for running this rule", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "type": "object" + } + } + } + } + }, + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "Rule corresponding to the given UID does not found." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/rules/schedule/simulations": { + "get": { + "tags": [ + "rules" + ], + "summary": "Simulates the executions of rules filtered by tag \u0027Schedule\u0027 within the given times.", + "operationId": "getScheduleRuleSimulations", + "parameters": [ + { + "name": "from", + "in": "query", + "description": "Start time of the simulated rule executions. Will default to the current time. [yyyy-MM-dd\u0027T\u0027HH:mm:ss.SSSZ]", + "schema": { + "type": "string" + } + }, + { + "name": "until", + "in": "query", + "description": "End time of the simulated rule executions. Will default to 30 days after the start time. Must be less than 180 days after the given start time. [yyyy-MM-dd\u0027T\u0027HH:mm:ss.SSSZ]", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RuleExecution" + } + } + } + } + }, + "400": { + "description": "The max. simulation duration of 180 days is exceeded." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/templates": { + "get": { + "tags": [ + "templates" + ], + "summary": "Get all available templates.", + "operationId": "getTemplates", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Template" + } + } + } + } + } + } + } + }, + "/templates/{templateUID}": { + "get": { + "tags": [ + "templates" + ], + "summary": "Gets a template corresponding to the given UID.", + "operationId": "getTemplateById", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + }, + { + "name": "templateUID", + "in": "path", + "description": "templateUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Template" + } + } + } + }, + "404": { + "description": "Template corresponding to the given UID does not found." + } + } + } + }, + "/actions/{thingUID}/{actionUid}": { + "post": { + "tags": [ + "actions" + ], + "summary": "Executes a thing action.", + "operationId": "executeThingAction", + "parameters": [ + { + "name": "thingUID", + "in": "path", + "description": "thingUID", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "actionUid", + "in": "path", + "description": "action type UID (including scope, separated by \u0027.\u0027)", + "required": true, + "schema": { + "pattern": "[a-zA-Z0-9]+\\.[a-zA-Z0-9]+", + "type": "string" + } + }, + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "action inputs as map (parameter name as key / argument as value)", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "type": "object" + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + }, + "404": { + "description": "Action not found" + }, + "500": { + "description": "Creation of action handler or execution failed" + } + } + } + }, + "/actions/{thingUID}": { + "get": { + "tags": [ + "actions" + ], + "summary": "Get all available actions for provided thing UID", + "operationId": "getAvailableActionsForThing", + "parameters": [ + { + "name": "thingUID", + "in": "path", + "description": "thingUID", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "uniqueItems": true, + "type": "array", + "items": { + "$ref": "#/components/schemas/ThingActionDTO" + } + } + } + } + }, + "204": { + "description": "No actions found." + } + } + } + }, + "/uuid": { + "get": { + "tags": [ + "uuid" + ], + "summary": "A unified unique id.", + "operationId": "getUUID", + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + "/audio/defaultsink": { + "get": { + "tags": [ + "audio" + ], + "summary": "Get the default sink if defined or the first available sink.", + "operationId": "getAudioDefaultSink", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AudioSinkDTO" + } + } + } + }, + "404": { + "description": "Sink not found" + } + } + } + }, + "/audio/defaultsource": { + "get": { + "tags": [ + "audio" + ], + "summary": "Get the default source if defined or the first available source.", + "operationId": "getAudioDefaultSource", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AudioSourceDTO" + } + } + } + }, + "404": { + "description": "Source not found" + } + } + } + }, + "/audio/sinks": { + "get": { + "tags": [ + "audio" + ], + "summary": "Get the list of all sinks.", + "operationId": "getAudioSinks", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AudioSinkDTO" + } + } + } + } + } + } + } + }, + "/audio/sources": { + "get": { + "tags": [ + "audio" + ], + "summary": "Get the list of all sources.", + "operationId": "getAudioSources", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AudioSourceDTO" + } + } + } + } + } + } + } + }, + "/auth/logout": { + "post": { + "tags": [ + "auth" + ], + "summary": "Delete the session associated with a refresh token.", + "operationId": "deleteSession", + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "type": "object", + "properties": { + "refresh_token": { + "type": "string" + }, + "id": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK" + }, + "401": { + "description": "User is not authenticated" + }, + "404": { + "description": "User or refresh token not found" + } + } + } + }, + "/auth/apitokens": { + "get": { + "tags": [ + "auth" + ], + "summary": "List the API tokens associated to the authenticated user.", + "operationId": "getApiTokens", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserApiTokenDTO" + } + } + } + } + }, + "401": { + "description": "User is not authenticated" + }, + "404": { + "description": "User not found" + } + } + } + }, + "/auth/sessions": { + "get": { + "tags": [ + "auth" + ], + "summary": "List the sessions associated to the authenticated user.", + "operationId": "getSessionsForCurrentUser", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserSessionDTO" + } + } + } + } + }, + "401": { + "description": "User is not authenticated" + }, + "404": { + "description": "User not found" + } + } + } + }, + "/auth/token": { + "post": { + "tags": [ + "auth" + ], + "summary": "Get access and refresh tokens.", + "operationId": "getOAuthToken", + "parameters": [ + { + "name": "useCookie", + "in": "query", + "schema": { + "type": "boolean" + } + } + ], + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "type": "object", + "properties": { + "grant_type": { + "type": "string" + }, + "code": { + "type": "string" + }, + "redirect_uri": { + "type": "string" + }, + "client_id": { + "type": "string" + }, + "refresh_token": { + "type": "string" + }, + "code_verifier": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TokenResponseDTO" + } + } + } + }, + "400": { + "description": "Invalid request parameters" + } + } + } + }, + "/auth/apitokens/{name}": { + "delete": { + "tags": [ + "auth" + ], + "summary": "Revoke a specified API token associated to the authenticated user.", + "operationId": "removeApiToken", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "401": { + "description": "User is not authenticated" + }, + "404": { + "description": "User or API token not found" + } + } + } + }, + "/addons": { + "get": { + "tags": [ + "addons" + ], + "summary": "Get all add-ons.", + "operationId": "getAddons", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + }, + { + "name": "serviceId", + "in": "query", + "description": "service ID", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Addon" + } + } + } + } + }, + "404": { + "description": "Service not found" + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/addons/{addonId}": { + "get": { + "tags": [ + "addons" + ], + "summary": "Get add-on with given ID.", + "operationId": "getAddonById", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + }, + { + "name": "addonId", + "in": "path", + "description": "addon ID", + "required": true, + "schema": { + "pattern": "[a-zA-Z_0-9-:]+", + "type": "string" + } + }, + { + "name": "serviceId", + "in": "query", + "description": "service ID", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Addon" + } + } + } + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/addons/{addonId}/config": { + "get": { + "tags": [ + "addons" + ], + "summary": "Get add-on configuration for given add-on ID.", + "operationId": "getAddonConfiguration", + "parameters": [ + { + "name": "addonId", + "in": "path", + "description": "addon ID", + "required": true, + "schema": { + "pattern": "[a-zA-Z_0-9-:]+", + "type": "string" + } + }, + { + "name": "serviceId", + "in": "query", + "description": "service ID", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + }, + "404": { + "description": "Add-on does not exist" + }, + "500": { + "description": "Configuration can not be read due to internal error" + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + }, + "put": { + "tags": [ + "addons" + ], + "summary": "Updates an add-on configuration for given ID and returns the old configuration.", + "operationId": "updateAddonConfiguration", + "parameters": [ + { + "name": "addonId", + "in": "path", + "description": "Add-on id", + "required": true, + "schema": { + "pattern": "[a-zA-Z_0-9-:]+", + "type": "string" + } + }, + { + "name": "serviceId", + "in": "query", + "description": "service ID", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "type": "object" + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + }, + "204": { + "description": "No old configuration" + }, + "404": { + "description": "Add-on does not exist" + }, + "500": { + "description": "Configuration can not be updated due to internal error" + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/addons/services": { + "get": { + "tags": [ + "addons" + ], + "summary": "Get all add-on types.", + "operationId": "getAddonTypes", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AddonType" + } + } + } + } + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/addons/suggestions": { + "get": { + "tags": [ + "addons" + ], + "summary": "Get suggested add-ons to be installed.", + "operationId": "getSuggestedAddons", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Addon" + } + } + } + } + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/addons/types": { + "get": { + "tags": [ + "addons" + ], + "summary": "Get add-on services.", + "operationId": "getAddonServices", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + }, + { + "name": "serviceId", + "in": "query", + "description": "service ID", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AddonType" + } + } + } + } + }, + "404": { + "description": "Service not found" + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/addons/{addonId}/install": { + "post": { + "tags": [ + "addons" + ], + "summary": "Installs the add-on with the given ID.", + "operationId": "installAddonById", + "parameters": [ + { + "name": "addonId", + "in": "path", + "description": "addon ID", + "required": true, + "schema": { + "pattern": "[a-zA-Z_0-9-:]+", + "type": "string" + } + }, + { + "name": "serviceId", + "in": "query", + "description": "service ID", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/addons/url/{url}/install": { + "post": { + "tags": [ + "addons" + ], + "summary": "Installs the add-on from the given URL.", + "operationId": "installAddonFromURL", + "parameters": [ + { + "name": "url", + "in": "path", + "description": "addon install URL", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "The given URL is malformed or not valid." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/addons/{addonId}/uninstall": { + "post": { + "tags": [ + "addons" + ], + "summary": "Uninstalls the add-on with the given ID.", + "operationId": "uninstallAddon", + "parameters": [ + { + "name": "addonId", + "in": "path", + "description": "addon ID", + "required": true, + "schema": { + "pattern": "[a-zA-Z_0-9-:]+", + "type": "string" + } + }, + { + "name": "serviceId", + "in": "query", + "description": "service ID", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/channel-types": { + "get": { + "tags": [ + "channel-types" + ], + "summary": "Gets all available channel types.", + "operationId": "getChannelTypes", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + }, + { + "name": "prefixes", + "in": "query", + "description": "filter UIDs by prefix (multiple comma-separated prefixes allowed, for example: \u0027system,mqtt\u0027)", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "uniqueItems": true, + "type": "array", + "items": { + "$ref": "#/components/schemas/ChannelTypeDTO" + } + } + } + } + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/channel-types/{channelTypeUID}": { + "get": { + "tags": [ + "channel-types" + ], + "summary": "Gets channel type by UID.", + "operationId": "getChannelTypeByUID", + "parameters": [ + { + "name": "channelTypeUID", + "in": "path", + "description": "channelTypeUID", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Channel type with provided channelTypeUID does not exist.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChannelTypeDTO" + } + } + } + }, + "404": { + "description": "No content" + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/channel-types/{channelTypeUID}/linkableItemTypes": { + "get": { + "tags": [ + "channel-types" + ], + "summary": "Gets the item types the given trigger channel type UID can be linked to.", + "operationId": "getLinkableItemTypesByChannelTypeUID", + "parameters": [ + { + "name": "channelTypeUID", + "in": "path", + "description": "channelTypeUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "uniqueItems": true, + "type": "array", + "items": { + "type": "string" + } + } + } + } + }, + "204": { + "description": "No content: channel type has no linkable items or is no trigger channel." + }, + "404": { + "description": "Given channel type UID not found." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/config-descriptions": { + "get": { + "tags": [ + "config-descriptions" + ], + "summary": "Gets all available config descriptions.", + "operationId": "getConfigDescriptions", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + }, + { + "name": "scheme", + "in": "query", + "description": "scheme filter", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ConfigDescriptionDTO" + } + } + } + } + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/config-descriptions/{uri}": { + "get": { + "tags": [ + "config-descriptions" + ], + "summary": "Gets a config description by URI.", + "operationId": "getConfigDescriptionByURI", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + }, + { + "name": "uri", + "in": "path", + "description": "uri", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConfigDescriptionDTO" + } + } + } + }, + "400": { + "description": "Invalid URI syntax" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/discovery": { + "get": { + "tags": [ + "discovery" + ], + "summary": "Gets all bindings that support discovery.", + "operationId": "getBindingsWithDiscoverySupport", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "uniqueItems": true, + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/discovery/bindings/{bindingId}/scan": { + "post": { + "tags": [ + "discovery" + ], + "summary": "Starts asynchronous discovery process for a binding and returns the timeout in seconds of the discovery operation.", + "operationId": "scan", + "parameters": [ + { + "name": "bindingId", + "in": "path", + "description": "bindingId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "integer", + "format": "int32" + } + } + } + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/inbox/{thingUID}/approve": { + "post": { + "tags": [ + "inbox" + ], + "summary": "Approves the discovery result by adding the thing to the registry.", + "operationId": "approveInboxItemById", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + }, + { + "name": "thingUID", + "in": "path", + "description": "thingUID", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "newThingId", + "in": "query", + "description": "new thing ID", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "thing label", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Invalid new thing ID." + }, + "404": { + "description": "Thing unable to be approved." + }, + "409": { + "description": "No binding found that supports this thing." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/inbox/{thingUID}": { + "delete": { + "tags": [ + "inbox" + ], + "summary": "Removes the discovery result from the inbox.", + "operationId": "removeItemFromInbox", + "parameters": [ + { + "name": "thingUID", + "in": "path", + "description": "thingUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "Discovery result not found in the inbox." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/inbox": { + "get": { + "tags": [ + "inbox" + ], + "summary": "Get all discovered things.", + "operationId": "getDiscoveredInboxItems", + "parameters": [ + { + "name": "includeIgnored", + "in": "query", + "description": "If true, include ignored inbox entries. Defaults to true", + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DiscoveryResultDTO" + } + } + } + } + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/inbox/{thingUID}/ignore": { + "post": { + "tags": [ + "inbox" + ], + "summary": "Flags a discovery result as ignored for further processing.", + "operationId": "flagInboxItemAsIgnored", + "parameters": [ + { + "name": "thingUID", + "in": "path", + "description": "thingUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/inbox/{thingUID}/unignore": { + "post": { + "tags": [ + "inbox" + ], + "summary": "Removes ignore flag from a discovery result.", + "operationId": "removeIgnoreFlagOnInboxItem", + "parameters": [ + { + "name": "thingUID", + "in": "path", + "description": "thingUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/items/{itemName}/members/{memberItemName}": { + "put": { + "tags": [ + "items" + ], + "summary": "Adds a new member to a group item.", + "operationId": "addMemberToGroupItem", + "parameters": [ + { + "name": "itemName", + "in": "path", + "description": "item name", + "required": true, + "schema": { + "pattern": "[a-zA-Z_0-9]+", + "type": "string" + } + }, + { + "name": "memberItemName", + "in": "path", + "description": "member item name", + "required": true, + "schema": { + "pattern": "[a-zA-Z_0-9]+", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "Item or member item not found or item is not of type group item." + }, + "405": { + "description": "Member item is not editable." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + }, + "delete": { + "tags": [ + "items" + ], + "summary": "Removes an existing member from a group item.", + "operationId": "removeMemberFromGroupItem", + "parameters": [ + { + "name": "itemName", + "in": "path", + "description": "item name", + "required": true, + "schema": { + "pattern": "[a-zA-Z_0-9]+", + "type": "string" + } + }, + { + "name": "memberItemName", + "in": "path", + "description": "member item name", + "required": true, + "schema": { + "pattern": "[a-zA-Z_0-9]+", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "Item or member item not found or item is not of type group item." + }, + "405": { + "description": "Member item is not editable." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/items/{itemname}/metadata/{namespace}": { + "put": { + "tags": [ + "items" + ], + "summary": "Adds metadata to an item.", + "operationId": "addMetadataToItem", + "parameters": [ + { + "name": "itemname", + "in": "path", + "description": "item name", + "required": true, + "schema": { + "pattern": "[a-zA-Z_0-9]+", + "type": "string" + } + }, + { + "name": "namespace", + "in": "path", + "description": "namespace", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "metadata", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MetadataDTO" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + }, + "201": { + "description": "Created" + }, + "400": { + "description": "Metadata value empty." + }, + "404": { + "description": "Item not found." + }, + "405": { + "description": "Metadata not editable." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + }, + "delete": { + "tags": [ + "items" + ], + "summary": "Removes metadata from an item.", + "operationId": "removeMetadataFromItem", + "parameters": [ + { + "name": "itemname", + "in": "path", + "description": "item name", + "required": true, + "schema": { + "pattern": "[a-zA-Z_0-9]+", + "type": "string" + } + }, + { + "name": "namespace", + "in": "path", + "description": "namespace", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "Item not found." + }, + "405": { + "description": "Meta data not editable." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/items/{itemname}/tags/{tag}": { + "put": { + "tags": [ + "items" + ], + "summary": "Adds a tag to an item.", + "operationId": "addTagToItem", + "parameters": [ + { + "name": "itemname", + "in": "path", + "description": "item name", + "required": true, + "schema": { + "pattern": "[a-zA-Z_0-9]+", + "type": "string" + } + }, + { + "name": "tag", + "in": "path", + "description": "tag", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "Item not found." + }, + "405": { + "description": "Item not editable." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + }, + "delete": { + "tags": [ + "items" + ], + "summary": "Removes a tag from an item.", + "operationId": "removeTagFromItem", + "parameters": [ + { + "name": "itemname", + "in": "path", + "description": "item name", + "required": true, + "schema": { + "pattern": "[a-zA-Z_0-9]+", + "type": "string" + } + }, + { + "name": "tag", + "in": "path", + "description": "tag", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "Item not found." + }, + "405": { + "description": "Item not editable." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/items/{itemname}": { + "get": { + "tags": [ + "items" + ], + "summary": "Gets a single item.", + "operationId": "getItemByName", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + }, + { + "name": "metadata", + "in": "query", + "description": "metadata selector - a comma separated list or a regular expression (returns all if no value given)", + "schema": { + "type": "string", + "default": ".*" + } + }, + { + "name": "recursive", + "in": "query", + "description": "get member items if the item is a group item", + "schema": { + "type": "boolean", + "default": true + } + }, + { + "name": "itemname", + "in": "path", + "description": "item name", + "required": true, + "schema": { + "pattern": "[a-zA-Z_0-9]+", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EnrichedItemDTO" + } + } + } + }, + "404": { + "description": "Item not found" + } + } + }, + "put": { + "tags": [ + "items" + ], + "summary": "Adds a new item to the registry or updates the existing item.", + "operationId": "addOrUpdateItemInRegistry", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + }, + { + "name": "itemname", + "in": "path", + "description": "item name", + "required": true, + "schema": { + "pattern": "[a-zA-Z_0-9]+", + "type": "string" + } + } + ], + "requestBody": { + "description": "item data", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GroupItemDTO" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/EnrichedItemDTO" + } + } + } + }, + "201": { + "description": "Item created." + }, + "400": { + "description": "Payload invalid." + }, + "404": { + "description": "Item not found or name in path invalid." + }, + "405": { + "description": "Item not editable." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + }, + "post": { + "tags": [ + "items" + ], + "summary": "Sends a command to an item.", + "operationId": "sendItemCommand", + "parameters": [ + { + "name": "itemname", + "in": "path", + "description": "item name", + "required": true, + "schema": { + "pattern": "[a-zA-Z_0-9]+", + "type": "string" + } + } + ], + "requestBody": { + "description": "valid item command (e.g. ON, OFF, UP, DOWN, REFRESH)", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "Item not found" + }, + "400": { + "description": "Item command null" + } + } + }, + "delete": { + "tags": [ + "items" + ], + "summary": "Removes an item from the registry.", + "operationId": "removeItemFromRegistry", + "parameters": [ + { + "name": "itemname", + "in": "path", + "description": "item name", + "required": true, + "schema": { + "pattern": "[a-zA-Z_0-9]+", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "Item not found or item is not editable." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/items": { + "get": { + "tags": [ + "items" + ], + "summary": "Get all available items.", + "operationId": "getItems", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + }, + { + "name": "type", + "in": "query", + "description": "item type filter", + "schema": { + "type": "string" + } + }, + { + "name": "tags", + "in": "query", + "description": "item tag filter", + "schema": { + "type": "string" + } + }, + { + "name": "metadata", + "in": "query", + "description": "metadata selector - a comma separated list or a regular expression (returns all if no value given)", + "schema": { + "type": "string", + "default": ".*" + } + }, + { + "name": "recursive", + "in": "query", + "description": "get member items recursively", + "schema": { + "type": "boolean", + "default": false + } + }, + { + "name": "fields", + "in": "query", + "description": "limit output to the given fields (comma separated)", + "schema": { + "type": "string" + } + }, + { + "name": "staticDataOnly", + "in": "query", + "description": "provides a cacheable list of values not expected to change regularly and checks the If-Modified-Since header, all other parameters are ignored except \"metadata\"", + "schema": { + "type": "boolean", + "default": false + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EnrichedItemDTO" + } + } + } + } + } + } + }, + "put": { + "tags": [ + "items" + ], + "summary": "Adds a list of items to the registry or updates the existing items.", + "operationId": "addOrUpdateItemsInRegistry", + "requestBody": { + "description": "array of item data", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GroupItemDTO" + } + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "Payload is invalid." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/items/{itemname}/state": { + "get": { + "tags": [ + "items" + ], + "summary": "Gets the state of an item.", + "operationId": "getItemState_1", + "parameters": [ + { + "name": "itemname", + "in": "path", + "description": "item name", + "required": true, + "schema": { + "pattern": "[a-zA-Z_0-9]+", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "404": { + "description": "Item not found" + } + } + }, + "put": { + "tags": [ + "items" + ], + "summary": "Updates the state of an item.", + "operationId": "updateItemState", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + }, + { + "name": "itemname", + "in": "path", + "description": "item name", + "required": true, + "schema": { + "pattern": "[a-zA-Z_0-9]+", + "type": "string" + } + } + ], + "requestBody": { + "description": "valid item state (e.g. ON, OFF)", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "required": true + }, + "responses": { + "202": { + "description": "Accepted" + }, + "404": { + "description": "Item not found" + }, + "400": { + "description": "Item state null" + } + } + } + }, + "/items/{itemname}/metadata/namespaces": { + "get": { + "tags": [ + "items" + ], + "summary": "Gets the namespace of an item.", + "operationId": "getItemNamespaces", + "parameters": [ + { + "name": "itemname", + "in": "path", + "description": "item name", + "required": true, + "schema": { + "pattern": "[a-zA-Z_0-9]+", + "type": "string" + } + }, + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + }, + "404": { + "description": "Item not found" + } + } + } + }, + "/items/{itemName}/semantic/{semanticClass}": { + "get": { + "tags": [ + "items" + ], + "summary": "Gets the item which defines the requested semantics of an item.", + "operationId": "getSemanticItem", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + }, + { + "name": "itemName", + "in": "path", + "description": "item name", + "required": true, + "schema": { + "pattern": "\\w+", + "type": "string" + } + }, + { + "name": "semanticClass", + "in": "path", + "description": "semantic class", + "required": true, + "schema": { + "pattern": "\\w+", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "Item not found" + } + } + } + }, + "/items/metadata/purge": { + "post": { + "tags": [ + "items" + ], + "summary": "Remove unused/orphaned metadata.", + "operationId": "purgeDatabase", + "responses": { + "200": { + "description": "OK" + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/links": { + "get": { + "tags": [ + "links" + ], + "summary": "Gets all available links.", + "operationId": "getItemLinks", + "parameters": [ + { + "name": "channelUID", + "in": "query", + "description": "filter by channel UID", + "schema": { + "type": "string" + } + }, + { + "name": "itemName", + "in": "query", + "description": "filter by item name", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EnrichedItemChannelLinkDTO" + } + } + } + } + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/links/{itemName}/{channelUID}": { + "get": { + "tags": [ + "links" + ], + "summary": "Retrieves an individual link.", + "operationId": "getItemLink", + "parameters": [ + { + "name": "itemName", + "in": "path", + "description": "item name", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "channelUID", + "in": "path", + "description": "channel UID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EnrichedItemChannelLinkDTO" + } + } + } + }, + "404": { + "description": "Content does not match the path" + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + }, + "put": { + "tags": [ + "links" + ], + "summary": "Links an item to a channel.", + "operationId": "linkItemToChannel", + "parameters": [ + { + "name": "itemName", + "in": "path", + "description": "itemName", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "channelUID", + "in": "path", + "description": "channelUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "link data", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ItemChannelLinkDTO" + } + } + } + }, + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Content does not match the path" + }, + "405": { + "description": "Link is not editable" + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + }, + "delete": { + "tags": [ + "links" + ], + "summary": "Unlinks an item from a channel.", + "operationId": "unlinkItemFromChannel", + "parameters": [ + { + "name": "itemName", + "in": "path", + "description": "itemName", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "channelUID", + "in": "path", + "description": "channelUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "Link not found." + }, + "405": { + "description": "Link not editable." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/links/orphans": { + "get": { + "tags": [ + "links" + ], + "summary": "Get orphan links between items and broken/non-existent thing channels", + "operationId": "getOrphanLinks", + "responses": { + "200": { + "description": "List of broken links" + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/links/purge": { + "post": { + "tags": [ + "links" + ], + "summary": "Remove unused/orphaned links.", + "operationId": "purgeDatabase_1", + "responses": { + "200": { + "description": "OK" + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/links/{object}": { + "delete": { + "tags": [ + "links" + ], + "summary": "Delete all links that refer to an item or thing.", + "operationId": "removeAllLinksForObject", + "parameters": [ + { + "name": "object", + "in": "path", + "description": "item name or thing UID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/persistence/{serviceId}": { + "get": { + "tags": [ + "persistence" + ], + "summary": "Gets a persistence service configuration.", + "operationId": "getPersistenceServiceConfiguration", + "parameters": [ + { + "name": "serviceId", + "in": "path", + "description": "Id of the persistence service.", + "required": true, + "schema": { + "pattern": "[a-zA-Z0-9]+", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PersistenceServiceConfigurationDTO" + } + } + } + }, + "404": { + "description": "Service configuration not found." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + }, + "put": { + "tags": [ + "persistence" + ], + "summary": "Sets a persistence service configuration.", + "operationId": "putPersistenceServiceConfiguration", + "parameters": [ + { + "name": "serviceId", + "in": "path", + "description": "Id of the persistence service.", + "required": true, + "schema": { + "pattern": "[a-zA-Z0-9]+", + "type": "string" + } + } + ], + "requestBody": { + "description": "service configuration", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PersistenceServiceConfigurationDTO" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PersistenceServiceConfigurationDTO" + } + } + } + }, + "201": { + "description": "PersistenceServiceConfiguration created." + }, + "400": { + "description": "Payload invalid." + }, + "405": { + "description": "PersistenceServiceConfiguration not editable." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + }, + "delete": { + "tags": [ + "persistence" + ], + "summary": "Deletes a persistence service configuration.", + "operationId": "deletePersistenceServiceConfiguration", + "parameters": [ + { + "name": "serviceId", + "in": "path", + "description": "Id of the persistence service.", + "required": true, + "schema": { + "pattern": "[a-zA-Z0-9]+", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "Persistence service configuration not found." + }, + "405": { + "description": "Persistence service configuration not editable." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/persistence/items/{itemname}": { + "get": { + "tags": [ + "persistence" + ], + "summary": "Gets item persistence data from the persistence service.", + "operationId": "getItemDataFromPersistenceService", + "parameters": [ + { + "name": "serviceId", + "in": "query", + "description": "Id of the persistence service. If not provided the default service will be used", + "schema": { + "type": "string" + } + }, + { + "name": "itemname", + "in": "path", + "description": "The item name", + "required": true, + "schema": { + "pattern": "[a-zA-Z_0-9]+", + "type": "string" + } + }, + { + "name": "starttime", + "in": "query", + "description": "Start time of the data to return. Will default to 1 day before endtime. [yyyy-MM-dd\u0027T\u0027HH:mm:ss.SSSZ]", + "schema": { + "type": "string" + } + }, + { + "name": "endtime", + "in": "query", + "description": "End time of the data to return. Will default to current time. [yyyy-MM-dd\u0027T\u0027HH:mm:ss.SSSZ]", + "schema": { + "type": "string" + } + }, + { + "name": "page", + "in": "query", + "description": "Page number of data to return. This parameter will enable paging.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "pagelength", + "in": "query", + "description": "The length of each page.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "boundary", + "in": "query", + "description": "Gets one value before and after the requested period.", + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ItemHistoryDTO" + } + } + } + }, + "404": { + "description": "Unknown Item or persistence service" + } + } + }, + "put": { + "tags": [ + "persistence" + ], + "summary": "Stores item persistence data into the persistence service.", + "operationId": "storeItemDataInPersistenceService", + "parameters": [ + { + "name": "serviceId", + "in": "query", + "description": "Id of the persistence service. If not provided the default service will be used", + "schema": { + "type": "string" + } + }, + { + "name": "itemname", + "in": "path", + "description": "The item name.", + "required": true, + "schema": { + "pattern": "[a-zA-Z_0-9]+", + "type": "string" + } + }, + { + "name": "time", + "in": "query", + "description": "Time of the data to be stored. Will default to current time. [yyyy-MM-dd\u0027T\u0027HH:mm:ss.SSSZ]", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "state", + "in": "query", + "description": "The state to store.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "Unknown Item or persistence service" + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + }, + "delete": { + "tags": [ + "persistence" + ], + "summary": "Deletes item persistence data from a specific persistence service in a given time range.", + "operationId": "deleteItemFromPersistenceService", + "parameters": [ + { + "name": "serviceId", + "in": "query", + "description": "Id of the persistence service.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "itemname", + "in": "path", + "description": "The item name.", + "required": true, + "schema": { + "pattern": "[a-zA-Z_0-9]+", + "type": "string" + } + }, + { + "name": "starttime", + "in": "query", + "description": "Start of the time range to be deleted. [yyyy-MM-dd\u0027T\u0027HH:mm:ss.SSSZ]", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "endtime", + "in": "query", + "description": "End of the time range to be deleted. [yyyy-MM-dd\u0027T\u0027HH:mm:ss.SSSZ]", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + }, + "400": { + "description": "Invalid filter parameters" + }, + "404": { + "description": "Unknown persistence service" + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/persistence/items": { + "get": { + "tags": [ + "persistence" + ], + "summary": "Gets a list of items available via a specific persistence service.", + "operationId": "getItemsForPersistenceService", + "parameters": [ + { + "name": "serviceId", + "in": "query", + "description": "Id of the persistence service. If not provided the default service will be used", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "uniqueItems": true, + "type": "array", + "items": { + "$ref": "#/components/schemas/PersistenceItemInfo" + } + } + } + } + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/persistence": { + "get": { + "tags": [ + "persistence" + ], + "summary": "Gets a list of persistence services.", + "operationId": "getPersistenceServices", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PersistenceServiceDTO" + } + } + } + } + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/profile-types": { + "get": { + "tags": [ + "profile-types" + ], + "summary": "Gets all available profile types.", + "operationId": "getProfileTypes", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + }, + { + "name": "channelTypeUID", + "in": "query", + "description": "channel type filter", + "schema": { + "type": "string" + } + }, + { + "name": "itemType", + "in": "query", + "description": "item type filter", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "uniqueItems": true, + "type": "array", + "items": { + "$ref": "#/components/schemas/ProfileTypeDTO" + } + } + } + } + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/services/{serviceId}/config": { + "get": { + "tags": [ + "services" + ], + "summary": "Get service configuration for given service ID.", + "operationId": "getServiceConfig", + "parameters": [ + { + "name": "serviceId", + "in": "path", + "description": "service ID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + }, + "500": { + "description": "Configuration can not be read due to internal error" + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + }, + "put": { + "tags": [ + "services" + ], + "summary": "Updates a service configuration for given service ID and returns the old configuration.", + "operationId": "updateServiceConfig", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + }, + { + "name": "serviceId", + "in": "path", + "description": "service ID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "type": "object" + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + }, + "204": { + "description": "No old configuration" + }, + "500": { + "description": "Configuration can not be updated due to internal error" + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + }, + "delete": { + "tags": [ + "services" + ], + "summary": "Deletes a service configuration for given service ID and returns the old configuration.", + "operationId": "deleteServiceConfig", + "parameters": [ + { + "name": "serviceId", + "in": "path", + "description": "service ID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + }, + "204": { + "description": "No old configuration" + }, + "500": { + "description": "Configuration can not be deleted due to internal error" + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/services": { + "get": { + "tags": [ + "services" + ], + "summary": "Get all configurable services.", + "operationId": "getServices", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ConfigurableServiceDTO" + } + } + } + } + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/services/{serviceId}": { + "get": { + "tags": [ + "services" + ], + "summary": "Get configurable service for given service ID.", + "operationId": "getServicesById", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + }, + { + "name": "serviceId", + "in": "path", + "description": "service ID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConfigurableServiceDTO" + } + } + } + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/services/{serviceId}/contexts": { + "get": { + "tags": [ + "services" + ], + "summary": "Get existing multiple context service configurations for the given factory PID.", + "operationId": "getServiceContext", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + }, + { + "name": "serviceId", + "in": "path", + "description": "service ID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ConfigurableServiceDTO" + } + } + } + } + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/tags": { + "get": { + "tags": [ + "tags" + ], + "summary": "Get all available semantic tags.", + "operationId": "getSemanticTags", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EnrichedSemanticTagDTO" + } + } + } + } + } + } + }, + "post": { + "tags": [ + "tags" + ], + "summary": "Creates a new semantic tag and adds it to the registry.", + "operationId": "createSemanticTag", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "tag data", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EnrichedSemanticTagDTO" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Created", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/EnrichedSemanticTagDTO" + } + } + } + }, + "400": { + "description": "The tag identifier is invalid or the tag label is missing." + }, + "409": { + "description": "A tag with the same identifier already exists." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/tags/{tagId}": { + "get": { + "tags": [ + "tags" + ], + "summary": "Gets a semantic tag and its sub tags.", + "operationId": "getSemanticTagAndSubTags", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + }, + { + "name": "tagId", + "in": "path", + "description": "tag id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EnrichedSemanticTagDTO" + } + } + } + } + }, + "404": { + "description": "Semantic tag not found." + } + } + }, + "put": { + "tags": [ + "tags" + ], + "summary": "Updates a semantic tag.", + "operationId": "updateSemanticTag", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + }, + { + "name": "tagId", + "in": "path", + "description": "tag id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "tag data", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EnrichedSemanticTagDTO" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/EnrichedSemanticTagDTO" + } + } + } + }, + "404": { + "description": "Semantic tag not found." + }, + "405": { + "description": "Semantic tag not editable." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + }, + "delete": { + "tags": [ + "tags" + ], + "summary": "Removes a semantic tag and its sub tags from the registry.", + "operationId": "removeSemanticTag", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + }, + { + "name": "tagId", + "in": "path", + "description": "tag id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK, was deleted." + }, + "404": { + "description": "Semantic tag not found." + }, + "405": { + "description": "Semantic tag not removable." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/things": { + "get": { + "tags": [ + "things" + ], + "summary": "Get all available things.", + "operationId": "getThings", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + }, + { + "name": "summary", + "in": "query", + "description": "summary fields only", + "schema": { + "type": "boolean" + } + }, + { + "name": "staticDataOnly", + "in": "query", + "description": "provides a cacheable list of values not expected to change regularly and checks the If-Modified-Since header", + "schema": { + "type": "boolean", + "default": false + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "uniqueItems": true, + "type": "array", + "items": { + "$ref": "#/components/schemas/EnrichedThingDTO" + } + } + } + } + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + }, + "post": { + "tags": [ + "things" + ], + "summary": "Creates a new thing and adds it to the registry.", + "operationId": "createThingInRegistry", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "thing data", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ThingDTO" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Created", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/EnrichedThingDTO" + } + } + } + }, + "400": { + "description": "A uid must be provided, if no binding can create a thing of this type." + }, + "409": { + "description": "A thing with the same uid already exists." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/things/{thingUID}": { + "get": { + "tags": [ + "things" + ], + "summary": "Gets thing by UID.", + "operationId": "getThingById", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + }, + { + "name": "thingUID", + "in": "path", + "description": "thingUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EnrichedThingDTO" + } + } + } + }, + "404": { + "description": "Thing not found." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + }, + "put": { + "tags": [ + "things" + ], + "summary": "Updates a thing.", + "operationId": "updateThing", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + }, + { + "name": "thingUID", + "in": "path", + "description": "thingUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "thing", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ThingDTO" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/EnrichedThingDTO" + } + } + } + }, + "404": { + "description": "Thing not found." + }, + "409": { + "description": "Thing could not be updated as it is not editable." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + }, + "delete": { + "tags": [ + "things" + ], + "summary": "Removes a thing from the registry. Set \u0027force\u0027 to __true__ if you want the thing to be removed immediately.", + "operationId": "removeThingById", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + }, + { + "name": "thingUID", + "in": "path", + "description": "thingUID", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "force", + "in": "query", + "description": "force", + "schema": { + "type": "boolean", + "default": false + } + } + ], + "responses": { + "200": { + "description": "OK, was deleted." + }, + "202": { + "description": "ACCEPTED for asynchronous deletion." + }, + "404": { + "description": "Thing not found." + }, + "409": { + "description": "Thing could not be deleted because it\u0027s not editable." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/things/{thingUID}/config/status": { + "get": { + "tags": [ + "things" + ], + "summary": "Gets thing config status.", + "operationId": "getThingConfigStatus", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + }, + { + "name": "thingUID", + "in": "path", + "description": "thing", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ConfigStatusMessage" + } + } + } + } + }, + "404": { + "description": "Thing not found." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/things/{thingUID}/firmware/status": { + "get": { + "tags": [ + "things" + ], + "summary": "Gets thing\u0027s firmware status.", + "operationId": "getThingFirmwareStatus", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + }, + { + "name": "thingUID", + "in": "path", + "description": "thing", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/FirmwareStatusDTO" + } + } + } + }, + "204": { + "description": "No firmware status provided by this Thing." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/things/{thingUID}/firmwares": { + "get": { + "tags": [ + "things" + ], + "summary": "Get all available firmwares for provided thing UID", + "operationId": "getAvailableFirmwaresForThing", + "parameters": [ + { + "name": "thingUID", + "in": "path", + "description": "thingUID", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "uniqueItems": true, + "type": "array", + "items": { + "$ref": "#/components/schemas/FirmwareDTO" + } + } + } + } + }, + "204": { + "description": "No firmwares found." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/things/{thingUID}/status": { + "get": { + "tags": [ + "things" + ], + "summary": "Gets thing status.", + "operationId": "getThingStatus", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + }, + { + "name": "thingUID", + "in": "path", + "description": "thing", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ThingStatusInfo" + } + } + } + }, + "404": { + "description": "Thing not found." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/things/{thingUID}/enable": { + "put": { + "tags": [ + "things" + ], + "summary": "Sets the thing enabled status.", + "operationId": "enableThing", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + }, + { + "name": "thingUID", + "in": "path", + "description": "thing", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "enabled", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/EnrichedThingDTO" + } + } + } + }, + "404": { + "description": "Thing not found." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/things/{thingUID}/config": { + "put": { + "tags": [ + "things" + ], + "summary": "Updates thing\u0027s configuration.", + "operationId": "updateThingConfig", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + }, + { + "name": "thingUID", + "in": "path", + "description": "thing", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "configuration parameters", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "type": "object" + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/EnrichedThingDTO" + } + } + } + }, + "400": { + "description": "Configuration of the thing is not valid." + }, + "404": { + "description": "Thing not found" + }, + "409": { + "description": "Thing could not be updated as it is not editable." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/things/{thingUID}/firmware/{firmwareVersion}": { + "put": { + "tags": [ + "things" + ], + "summary": "Update thing firmware.", + "operationId": "updateThingFirmware", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + }, + { + "name": "thingUID", + "in": "path", + "description": "thing", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "firmwareVersion", + "in": "path", + "description": "version", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Firmware update preconditions not satisfied." + }, + "404": { + "description": "Thing not found." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/thing-types": { + "get": { + "tags": [ + "thing-types" + ], + "summary": "Gets all available thing types without config description, channels and properties.", + "operationId": "getThingTypes", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + }, + { + "name": "bindingId", + "in": "query", + "description": "filter by binding Id", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "uniqueItems": true, + "type": "array", + "items": { + "$ref": "#/components/schemas/StrippedThingTypeDTO" + } + } + } + } + } + } + } + }, + "/thing-types/{thingTypeUID}": { + "get": { + "tags": [ + "thing-types" + ], + "summary": "Gets thing type by UID.", + "operationId": "getThingTypeById", + "parameters": [ + { + "name": "thingTypeUID", + "in": "path", + "description": "thingTypeUID", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Thing type with provided thingTypeUID does not exist.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ThingTypeDTO" + } + } + } + }, + "404": { + "description": "No content" + } + } + } + }, + "/": { + "get": { + "tags": [ + "root" + ], + "summary": "Gets information about the runtime, the API version and links to resources.", + "operationId": "getRoot", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RootBean" + } + } + } + } + } + } + }, + "/systeminfo": { + "get": { + "tags": [ + "systeminfo" + ], + "summary": "Gets information about the system.", + "operationId": "getSystemInformation", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SystemInfoBean" + } + } + } + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/systeminfo/uom": { + "get": { + "tags": [ + "systeminfo" + ], + "summary": "Get all supported dimensions and their system units.", + "operationId": "getUoMInformation", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UoMInfoBean" + } + } + } + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/sitemaps/events/subscribe": { + "post": { + "tags": [ + "sitemaps" + ], + "summary": "Creates a sitemap event subscription.", + "operationId": "createSitemapEventSubscription", + "responses": { + "201": { + "description": "Subscription created." + }, + "503": { + "description": "Subscriptions limit reached." + } + } + } + }, + "/sitemaps/{sitemapname}/{pageid}": { + "get": { + "tags": [ + "sitemaps" + ], + "summary": "Polls the data for one page of a sitemap.", + "operationId": "pollDataForPage", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + }, + { + "name": "sitemapname", + "in": "path", + "description": "sitemap name", + "required": true, + "schema": { + "pattern": "[a-zA-Z_0-9]+", + "type": "string" + } + }, + { + "name": "pageid", + "in": "path", + "description": "page id", + "required": true, + "schema": { + "pattern": "[a-zA-Z_0-9]+", + "type": "string" + } + }, + { + "name": "subscriptionid", + "in": "query", + "description": "subscriptionid", + "schema": { + "type": "string" + } + }, + { + "name": "includeHidden", + "in": "query", + "description": "include hidden widgets", + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PageDTO" + } + } + } + }, + "404": { + "description": "Sitemap with requested name does not exist or page does not exist, or page refers to a non-linkable widget" + }, + "400": { + "description": "Invalid subscription id has been provided." + } + } + } + }, + "/sitemaps/{sitemapname}/*": { + "get": { + "tags": [ + "sitemaps" + ], + "summary": "Polls the data for a whole sitemap. Not recommended due to potentially high traffic.", + "operationId": "pollDataForSitemap", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + }, + { + "name": "sitemapname", + "in": "path", + "description": "sitemap name", + "required": true, + "schema": { + "pattern": "[a-zA-Z_0-9]+", + "type": "string" + } + }, + { + "name": "subscriptionid", + "in": "query", + "description": "subscriptionid", + "schema": { + "type": "string" + } + }, + { + "name": "includeHidden", + "in": "query", + "description": "include hidden widgets", + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SitemapDTO" + } + } + } + }, + "404": { + "description": "Sitemap with requested name does not exist" + }, + "400": { + "description": "Invalid subscription id has been provided." + } + } + } + }, + "/sitemaps/{sitemapname}": { + "get": { + "tags": [ + "sitemaps" + ], + "summary": "Get sitemap by name.", + "operationId": "getSitemapByName", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + }, + { + "name": "sitemapname", + "in": "path", + "description": "sitemap name", + "required": true, + "schema": { + "pattern": "[a-zA-Z_0-9]+", + "type": "string" + } + }, + { + "name": "type", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "jsoncallback", + "in": "query", + "schema": { + "type": "string", + "default": "callback" + } + }, + { + "name": "includeHidden", + "in": "query", + "description": "include hidden widgets", + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SitemapDTO" + } + } + } + } + } + } + }, + "/sitemaps/events/{subscriptionid}/*": { + "get": { + "tags": [ + "sitemaps" + ], + "summary": "Get sitemap events for a whole sitemap. Not recommended due to potentially high traffic.", + "operationId": "getSitemapEvents", + "parameters": [ + { + "name": "subscriptionid", + "in": "path", + "description": "subscription id", + "required": true, + "schema": { + "pattern": "[a-zA-Z_0-9-]+", + "type": "string" + } + }, + { + "name": "sitemap", + "in": "query", + "description": "sitemap name", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Missing sitemap parameter, or sitemap not linked successfully to the subscription." + }, + "404": { + "description": "Subscription not found." + } + } + } + }, + "/sitemaps/events/{subscriptionid}": { + "get": { + "tags": [ + "sitemaps" + ], + "summary": "Get sitemap events.", + "operationId": "getSitemapEvents_1", + "parameters": [ + { + "name": "subscriptionid", + "in": "path", + "description": "subscription id", + "required": true, + "schema": { + "pattern": "[a-zA-Z_0-9-]+", + "type": "string" + } + }, + { + "name": "sitemap", + "in": "query", + "description": "sitemap name", + "schema": { + "type": "string" + } + }, + { + "name": "pageid", + "in": "query", + "description": "page id", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Missing sitemap or page parameter, or page not linked successfully to the subscription." + }, + "404": { + "description": "Subscription not found." + } + } + } + }, + "/sitemaps": { + "get": { + "tags": [ + "sitemaps" + ], + "summary": "Get all available sitemaps.", + "operationId": "getSitemaps", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SitemapDTO" + } + } + } + } + } + } + } + }, + "/events/states": { + "get": { + "tags": [ + "events" + ], + "summary": "Initiates a new item state tracker connection", + "operationId": "initNewStateTacker", + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/events": { + "get": { + "tags": [ + "events" + ], + "summary": "Get all events.", + "operationId": "getEvents", + "parameters": [ + { + "name": "topics", + "in": "query", + "description": "topics", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Topic is empty or contains invalid characters" + } + } + } + }, + "/events/states/{connectionId}": { + "post": { + "tags": [ + "events" + ], + "summary": "Changes the list of items a SSE connection will receive state updates to.", + "operationId": "updateItemListForStateUpdates", + "parameters": [ + { + "name": "connectionId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "items", + "content": { + "*/*": { + "schema": { + "uniqueItems": true, + "type": "array", + "items": { + "type": "string" + } + } + } + } + }, + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "Unknown connectionId" + } + } + } + }, + "/transformations/{uid}": { + "get": { + "tags": [ + "transformations" + ], + "summary": "Get a single transformation", + "operationId": "getTransformation", + "parameters": [ + { + "name": "uid", + "in": "path", + "description": "Transformation UID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Transformation" + } + } + } + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + }, + "put": { + "tags": [ + "transformations" + ], + "summary": "Put a single transformation", + "operationId": "putTransformation", + "parameters": [ + { + "name": "uid", + "in": "path", + "description": "Transformation UID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "transformation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TransformationDTO" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request (content missing or invalid)" + }, + "405": { + "description": "Transformation not editable" + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + }, + "delete": { + "tags": [ + "transformations" + ], + "summary": "Get a single transformation", + "operationId": "deleteTransformation", + "parameters": [ + { + "name": "uid", + "in": "path", + "description": "Transformation UID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "UID not found" + }, + "405": { + "description": "Transformation not editable" + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/transformations/services": { + "get": { + "tags": [ + "transformations" + ], + "summary": "Get all transformation services", + "operationId": "getTransformationServices", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/transformations": { + "get": { + "tags": [ + "transformations" + ], + "summary": "Get a list of all transformations", + "operationId": "getTransformations", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TransformationDTO" + } + } + } + } + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/ui/components/{namespace}": { + "get": { + "tags": [ + "ui" + ], + "summary": "Get all registered UI components in the specified namespace.", + "operationId": "getRegisteredUIComponentsInNamespace", + "parameters": [ + { + "name": "namespace", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "summary", + "in": "query", + "description": "summary fields only", + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RootUIComponent" + } + } + } + } + } + } + }, + "post": { + "tags": [ + "ui" + ], + "summary": "Add a UI component in the specified namespace.", + "operationId": "addUIComponentToNamespace", + "parameters": [ + { + "name": "namespace", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RootUIComponent" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RootUIComponent" + } + } + } + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/ui/components/{namespace}/{componentUID}": { + "get": { + "tags": [ + "ui" + ], + "summary": "Get a specific UI component in the specified namespace.", + "operationId": "getUIComponentInNamespace", + "parameters": [ + { + "name": "namespace", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "componentUID", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RootUIComponent" + } + } + } + }, + "404": { + "description": "Component not found" + } + } + }, + "put": { + "tags": [ + "ui" + ], + "summary": "Update a specific UI component in the specified namespace.", + "operationId": "updateUIComponentInNamespace", + "parameters": [ + { + "name": "namespace", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "componentUID", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RootUIComponent" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RootUIComponent" + } + } + } + }, + "404": { + "description": "Component not found" + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + }, + "delete": { + "tags": [ + "ui" + ], + "summary": "Remove a specific UI component in the specified namespace.", + "operationId": "removeUIComponentFromNamespace", + "parameters": [ + { + "name": "namespace", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "componentUID", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "Component not found" + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/ui/tiles": { + "get": { + "tags": [ + "ui" + ], + "summary": "Get all registered UI tiles.", + "operationId": "getUITiles", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TileDTO" + } + } + } + } + } + } + } + }, + "/voice/defaultvoice": { + "get": { + "tags": [ + "voice" + ], + "summary": "Gets the default voice.", + "operationId": "getDefaultVoice", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VoiceDTO" + } + } + } + }, + "404": { + "description": "No default voice was found." + } + } + } + }, + "/voice/interpreters/{id}": { + "get": { + "tags": [ + "voice" + ], + "summary": "Gets a single interpreter.", + "operationId": "getVoiceInterpreterByUID", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + }, + { + "name": "id", + "in": "path", + "description": "interpreter id", + "required": true, + "schema": { + "pattern": "[a-zA-Z_0-9]+", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/HumanLanguageInterpreterDTO" + } + } + } + } + }, + "404": { + "description": "Interpreter not found" + } + } + } + }, + "/voice/interpreters": { + "get": { + "tags": [ + "voice" + ], + "summary": "Get the list of all interpreters.", + "operationId": "getVoiceInterpreters", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/HumanLanguageInterpreterDTO" + } + } + } + } + } + } + }, + "post": { + "tags": [ + "voice" + ], + "summary": "Sends a text to the default human language interpreter.", + "operationId": "interpretTextByDefaultInterpreter", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "text to interpret", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "No human language interpreter was found." + }, + "400": { + "description": "interpretation exception occurs" + } + } + } + }, + "/voice/voices": { + "get": { + "tags": [ + "voice" + ], + "summary": "Get the list of all voices.", + "operationId": "getVoices", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/VoiceDTO" + } + } + } + } + } + } + } + }, + "/voice/interpreters/{ids}": { + "post": { + "tags": [ + "voice" + ], + "summary": "Sends a text to a given human language interpreter(s).", + "operationId": "interpretText", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + }, + { + "name": "ids", + "in": "path", + "description": "comma separated list of interpreter ids", + "required": true, + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + ], + "requestBody": { + "description": "text to interpret", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "No human language interpreter was found." + }, + "400": { + "description": "interpretation exception occurs" + } + } + } + }, + "/voice/listenandanswer": { + "post": { + "tags": [ + "voice" + ], + "summary": "Executes a simple dialog sequence without keyword spotting for a given audio source.", + "operationId": "listenAndAnswer", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + }, + { + "name": "sourceId", + "in": "query", + "description": "source ID", + "schema": { + "type": "string" + } + }, + { + "name": "sttId", + "in": "query", + "description": "Speech-to-Text ID", + "schema": { + "type": "string" + } + }, + { + "name": "ttsId", + "in": "query", + "description": "Text-to-Speech ID", + "schema": { + "type": "string" + } + }, + { + "name": "voiceId", + "in": "query", + "description": "voice ID", + "schema": { + "type": "string" + } + }, + { + "name": "hliIds", + "in": "query", + "description": "interpreter IDs", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "name": "sinkId", + "in": "query", + "description": "audio sink ID", + "schema": { + "type": "string" + } + }, + { + "name": "listeningItem", + "in": "query", + "description": "listening item", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "One of the given ids is wrong." + }, + "400": { + "description": "Services are missing or language is not supported by services or dialog processing is already started for the audio source." + } + } + } + }, + "/voice/say": { + "post": { + "tags": [ + "voice" + ], + "summary": "Speaks a given text with a given voice through the given audio sink.", + "operationId": "textToSpeech", + "parameters": [ + { + "name": "voiceid", + "in": "query", + "description": "voice id", + "schema": { + "type": "string" + } + }, + { + "name": "sinkid", + "in": "query", + "description": "audio sink id", + "schema": { + "type": "string" + } + }, + { + "name": "volume", + "in": "query", + "description": "volume level", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "text to speak", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/voice/dialog/start": { + "post": { + "tags": [ + "voice" + ], + "summary": "Start dialog processing for a given audio source.", + "operationId": "startDialog", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + }, + { + "name": "sourceId", + "in": "query", + "description": "source ID", + "schema": { + "type": "string" + } + }, + { + "name": "ksId", + "in": "query", + "description": "keywork spotter ID", + "schema": { + "type": "string" + } + }, + { + "name": "sttId", + "in": "query", + "description": "Speech-to-Text ID", + "schema": { + "type": "string" + } + }, + { + "name": "ttsId", + "in": "query", + "description": "Text-to-Speech ID", + "schema": { + "type": "string" + } + }, + { + "name": "voiceId", + "in": "query", + "description": "voice ID", + "schema": { + "type": "string" + } + }, + { + "name": "hliIds", + "in": "query", + "description": "comma separated list of interpreter IDs", + "schema": { + "type": "string" + } + }, + { + "name": "sinkId", + "in": "query", + "description": "audio sink ID", + "schema": { + "type": "string" + } + }, + { + "name": "keyword", + "in": "query", + "description": "keyword", + "schema": { + "type": "string" + } + }, + { + "name": "listeningItem", + "in": "query", + "description": "listening item", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "One of the given ids is wrong." + }, + "400": { + "description": "Services are missing or language is not supported by services or dialog processing is already started for the audio source." + } + } + } + }, + "/voice/dialog/stop": { + "post": { + "tags": [ + "voice" + ], + "summary": "Stop dialog processing for a given audio source.", + "operationId": "stopDialog", + "parameters": [ + { + "name": "sourceId", + "in": "query", + "description": "source ID", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "No audio source was found." + }, + "400": { + "description": "No dialog processing is started for the audio source." + } + } + } + }, + "/logging/{loggerName}": { + "get": { + "tags": [ + "logging" + ], + "summary": "Get a single logger.", + "operationId": "getLogger", + "parameters": [ + { + "name": "loggerName", + "in": "path", + "description": "logger name", + "required": true, + "schema": { + "pattern": "[a-zA-Z0-9.]+", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoggerInfo" + } + } + } + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + }, + "put": { + "tags": [ + "logging" + ], + "summary": "Modify or add logger", + "operationId": "putLogger", + "parameters": [ + { + "name": "loggerName", + "in": "path", + "description": "logger name", + "required": true, + "schema": { + "pattern": "[a-zA-Z0-9.]+", + "type": "string" + } + } + ], + "requestBody": { + "description": "logger", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoggerInfo" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Payload is invalid." + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + }, + "delete": { + "tags": [ + "logging" + ], + "summary": "Remove a single logger.", + "operationId": "removeLogger", + "parameters": [ + { + "name": "loggerName", + "in": "path", + "description": "logger name", + "required": true, + "schema": { + "pattern": "[a-zA-Z0-9.]+", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/logging": { + "get": { + "tags": [ + "logging" + ], + "summary": "Get all loggers", + "operationId": "getLogger_1", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoggerBean" + } + } + } + } + }, + "security": [ + { + "oauth2": [ + "admin" + ] + } + ] + } + }, + "/iconsets": { + "get": { + "tags": [ + "iconsets" + ], + "summary": "Gets all icon sets.", + "operationId": "getIconSets", + "parameters": [ + { + "name": "Accept-Language", + "in": "header", + "description": "language", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/IconSet" + } + } + } + } + } + } + } + }, + "/habpanel/gallery/{galleryName}/widgets": { + "get": { + "tags": [ + "habpanel" + ], + "summary": "Gets the list of widget gallery items.", + "operationId": "getGalleryWidgetList", + "parameters": [ + { + "name": "galleryName", + "in": "path", + "description": "gallery name e.g. \u0027community\u0027", + "required": true, + "schema": { + "pattern": "[a-zA-Z_0-9]*", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GalleryWidgetsListItem" + } + } + } + } + }, + "404": { + "description": "Unknown gallery" + } + } + } + }, + "/habpanel/gallery/{galleryName}/widgets/{id}": { + "get": { + "tags": [ + "habpanel" + ], + "summary": "Gets the details about a widget gallery item.", + "operationId": "getGalleryWidgetsItem", + "parameters": [ + { + "name": "galleryName", + "in": "path", + "description": "gallery name e.g. \u0027community\u0027", + "required": true, + "schema": { + "pattern": "[a-zA-Z_0-9]*", + "type": "string" + } + }, + { + "name": "id", + "in": "path", + "description": "id within the gallery", + "required": true, + "schema": { + "pattern": "[a-zA-Z_0-9]*", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GalleryItem" + } + } + } + }, + "404": { + "description": "Unknown gallery or gallery item not found" + } + } + } + } + }, + "components": { + "schemas": { + "ConfigDescriptionParameterDTO": { + "type": "object", + "properties": { + "context": { + "type": "string" + }, + "defaultValue": { + "type": "string" + }, + "description": { + "type": "string" + }, + "label": { + "type": "string" + }, + "name": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "type": { + "type": "string", + "enum": [ + "TEXT", + "INTEGER", + "DECIMAL", + "BOOLEAN" + ] + }, + "min": { + "type": "number" + }, + "max": { + "type": "number" + }, + "stepsize": { + "type": "number" + }, + "pattern": { + "type": "string" + }, + "readOnly": { + "type": "boolean" + }, + "multiple": { + "type": "boolean" + }, + "multipleLimit": { + "type": "integer", + "format": "int32" + }, + "groupName": { + "type": "string" + }, + "advanced": { + "type": "boolean" + }, + "verify": { + "type": "boolean" + }, + "limitToOptions": { + "type": "boolean" + }, + "unit": { + "type": "string" + }, + "unitLabel": { + "type": "string" + }, + "options": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ParameterOptionDTO" + } + }, + "filterCriteria": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FilterCriteriaDTO" + } + } + } + }, + "FilterCriteriaDTO": { + "type": "object", + "properties": { + "value": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "ModuleTypeDTO": { + "type": "object", + "properties": { + "uid": { + "type": "string" + }, + "visibility": { + "type": "string", + "enum": [ + "VISIBLE", + "HIDDEN", + "EXPERT" + ] + }, + "tags": { + "uniqueItems": true, + "type": "array", + "items": { + "type": "string" + } + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "configDescriptions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ConfigDescriptionParameterDTO" + } + } + } + }, + "ParameterOptionDTO": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "value": { + "type": "string" + } + } + }, + "ActionDTO": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "configuration": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "type": { + "type": "string" + }, + "inputs": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + }, + "ConditionDTO": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "configuration": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "type": { + "type": "string" + }, + "inputs": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + }, + "RuleDTO": { + "type": "object", + "properties": { + "triggers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TriggerDTO" + } + }, + "conditions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ConditionDTO" + } + }, + "actions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ActionDTO" + } + }, + "configuration": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "configDescriptions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ConfigDescriptionParameterDTO" + } + }, + "templateUID": { + "type": "string" + }, + "uid": { + "type": "string" + }, + "name": { + "type": "string" + }, + "tags": { + "uniqueItems": true, + "type": "array", + "items": { + "type": "string" + } + }, + "visibility": { + "type": "string", + "enum": [ + "VISIBLE", + "HIDDEN", + "EXPERT" + ] + }, + "description": { + "type": "string" + } + } + }, + "TriggerDTO": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "configuration": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "type": { + "type": "string" + } + } + }, + "EnrichedRuleDTO": { + "type": "object", + "properties": { + "triggers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TriggerDTO" + } + }, + "conditions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ConditionDTO" + } + }, + "actions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ActionDTO" + } + }, + "configuration": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "configDescriptions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ConfigDescriptionParameterDTO" + } + }, + "templateUID": { + "type": "string" + }, + "uid": { + "type": "string" + }, + "name": { + "type": "string" + }, + "tags": { + "uniqueItems": true, + "type": "array", + "items": { + "type": "string" + } + }, + "visibility": { + "type": "string", + "enum": [ + "VISIBLE", + "HIDDEN", + "EXPERT" + ] + }, + "description": { + "type": "string" + }, + "status": { + "$ref": "#/components/schemas/RuleStatusInfo" + }, + "editable": { + "type": "boolean" + } + } + }, + "RuleStatusInfo": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "UNINITIALIZED", + "INITIALIZING", + "IDLE", + "RUNNING" + ] + }, + "statusDetail": { + "type": "string", + "enum": [ + "NONE", + "HANDLER_MISSING_ERROR", + "HANDLER_INITIALIZING_ERROR", + "CONFIGURATION_ERROR", + "TEMPLATE_MISSING_ERROR", + "INVALID_RULE", + "DISABLED" + ] + }, + "description": { + "type": "string" + } + } + }, + "ModuleDTO": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "configuration": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "type": { + "type": "string" + } + } + }, + "Action": { + "type": "object", + "properties": { + "inputs": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "description": { + "type": "string" + }, + "label": { + "type": "string" + }, + "typeUID": { + "type": "string" + }, + "configuration": { + "$ref": "#/components/schemas/Configuration" + }, + "id": { + "type": "string" + } + } + }, + "Condition": { + "type": "object", + "properties": { + "inputs": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "description": { + "type": "string" + }, + "label": { + "type": "string" + }, + "typeUID": { + "type": "string" + }, + "configuration": { + "$ref": "#/components/schemas/Configuration" + }, + "id": { + "type": "string" + } + } + }, + "ConfigDescriptionParameter": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "TEXT", + "INTEGER", + "DECIMAL", + "BOOLEAN" + ] + }, + "groupName": { + "type": "string" + }, + "pattern": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "readOnly": { + "type": "boolean" + }, + "multiple": { + "type": "boolean" + }, + "multipleLimit": { + "type": "integer", + "format": "int32" + }, + "unit": { + "type": "string" + }, + "unitLabel": { + "type": "string" + }, + "context": { + "type": "string" + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "options": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ParameterOption" + } + }, + "filterCriteria": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FilterCriteria" + } + }, + "limitToOptions": { + "type": "boolean" + }, + "advanced": { + "type": "boolean" + }, + "minimum": { + "type": "number" + }, + "maximum": { + "type": "number" + }, + "stepSize": { + "type": "number" + }, + "verifyable": { + "type": "boolean" + }, + "default": { + "type": "string" + } + } + }, + "Configuration": { + "type": "object", + "properties": { + "properties": { + "type": "object", + "additionalProperties": { + "type": "object" + } + } + } + }, + "FilterCriteria": { + "type": "object", + "properties": { + "value": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "Module": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "label": { + "type": "string" + }, + "typeUID": { + "type": "string" + }, + "configuration": { + "$ref": "#/components/schemas/Configuration" + }, + "id": { + "type": "string" + } + } + }, + "ParameterOption": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "value": { + "type": "string" + } + } + }, + "Rule": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "visibility": { + "type": "string", + "enum": [ + "VISIBLE", + "HIDDEN", + "EXPERT" + ] + }, + "configurationDescriptions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ConfigDescriptionParameter" + } + }, + "templateUID": { + "type": "string" + }, + "triggers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Trigger" + } + }, + "uid": { + "type": "string" + }, + "tags": { + "uniqueItems": true, + "type": "array", + "items": { + "type": "string" + } + }, + "configuration": { + "$ref": "#/components/schemas/Configuration" + }, + "modules": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Module" + } + }, + "conditions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Condition" + } + }, + "name": { + "type": "string" + }, + "actions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Action" + } + } + } + }, + "RuleExecution": { + "type": "object", + "properties": { + "date": { + "type": "string", + "format": "date-time" + }, + "rule": { + "$ref": "#/components/schemas/Rule" + } + } + }, + "Trigger": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "label": { + "type": "string" + }, + "typeUID": { + "type": "string" + }, + "configuration": { + "$ref": "#/components/schemas/Configuration" + }, + "id": { + "type": "string" + } + } + }, + "Template": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "label": { + "type": "string" + }, + "visibility": { + "type": "string", + "enum": [ + "VISIBLE", + "HIDDEN", + "EXPERT" + ] + }, + "uid": { + "type": "string" + }, + "tags": { + "uniqueItems": true, + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "Input": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "type": { + "type": "string" + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "tags": { + "uniqueItems": true, + "type": "array", + "items": { + "type": "string" + } + }, + "reference": { + "type": "string" + }, + "defaultValue": { + "type": "string" + } + } + }, + "Output": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "type": { + "type": "string" + }, + "tags": { + "uniqueItems": true, + "type": "array", + "items": { + "type": "string" + } + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "reference": { + "type": "string" + }, + "defaultValue": { + "type": "string" + } + } + }, + "ThingActionDTO": { + "type": "object", + "properties": { + "actionUid": { + "type": "string" + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "inputs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Input" + } + }, + "outputs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Output" + } + } + } + }, + "AudioSinkDTO": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "label": { + "type": "string" + } + } + }, + "AudioSourceDTO": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "label": { + "type": "string" + } + } + }, + "UserApiTokenDTO": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "createdTime": { + "type": "string", + "format": "date-time" + }, + "scope": { + "type": "string" + } + } + }, + "UserSessionDTO": { + "type": "object", + "properties": { + "sessionId": { + "type": "string" + }, + "createdTime": { + "type": "string", + "format": "date-time" + }, + "lastRefreshTime": { + "type": "string", + "format": "date-time" + }, + "clientId": { + "type": "string" + }, + "scope": { + "type": "string" + } + } + }, + "TokenResponseDTO": { + "type": "object", + "properties": { + "access_token": { + "type": "string" + }, + "token_type": { + "type": "string" + }, + "expires_in": { + "type": "integer", + "format": "int32" + }, + "refresh_token": { + "type": "string" + }, + "scope": { + "type": "string" + }, + "user": { + "$ref": "#/components/schemas/UserDTO" + } + } + }, + "UserDTO": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "roles": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "Addon": { + "type": "object", + "properties": { + "uid": { + "type": "string" + }, + "id": { + "type": "string" + }, + "label": { + "type": "string" + }, + "version": { + "type": "string" + }, + "maturity": { + "type": "string" + }, + "compatible": { + "type": "boolean" + }, + "contentType": { + "type": "string" + }, + "link": { + "type": "string" + }, + "author": { + "type": "string" + }, + "verifiedAuthor": { + "type": "boolean" + }, + "installed": { + "type": "boolean" + }, + "type": { + "type": "string" + }, + "description": { + "type": "string" + }, + "detailedDescription": { + "type": "string" + }, + "configDescriptionURI": { + "type": "string" + }, + "keywords": { + "type": "string" + }, + "countries": { + "type": "array", + "items": { + "type": "string" + } + }, + "license": { + "type": "string" + }, + "connection": { + "type": "string" + }, + "backgroundColor": { + "type": "string" + }, + "imageLink": { + "type": "string" + }, + "properties": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "loggerPackages": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "AddonType": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "label": { + "type": "string" + } + } + }, + "ChannelTypeDTO": { + "type": "object", + "properties": { + "parameters": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ConfigDescriptionParameterDTO" + } + }, + "parameterGroups": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ConfigDescriptionParameterGroupDTO" + } + }, + "description": { + "type": "string" + }, + "label": { + "type": "string" + }, + "category": { + "type": "string" + }, + "itemType": { + "type": "string" + }, + "unitHint": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "stateDescription": { + "$ref": "#/components/schemas/StateDescription" + }, + "tags": { + "uniqueItems": true, + "type": "array", + "items": { + "type": "string" + } + }, + "UID": { + "type": "string" + }, + "advanced": { + "type": "boolean" + }, + "commandDescription": { + "$ref": "#/components/schemas/CommandDescription" + } + } + }, + "CommandDescription": { + "type": "object", + "properties": { + "commandOptions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CommandOption" + } + } + } + }, + "CommandOption": { + "type": "object", + "properties": { + "command": { + "type": "string" + }, + "label": { + "type": "string" + } + } + }, + "ConfigDescriptionParameterGroupDTO": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "context": { + "type": "string" + }, + "advanced": { + "type": "boolean" + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + } + } + }, + "StateDescription": { + "type": "object", + "properties": { + "minimum": { + "type": "number" + }, + "maximum": { + "type": "number" + }, + "step": { + "type": "number" + }, + "pattern": { + "type": "string" + }, + "readOnly": { + "type": "boolean" + }, + "options": { + "type": "array", + "items": { + "$ref": "#/components/schemas/StateOption" + } + } + } + }, + "StateOption": { + "type": "object", + "properties": { + "value": { + "type": "string" + }, + "label": { + "type": "string" + } + } + }, + "ConfigDescriptionDTO": { + "type": "object", + "properties": { + "uri": { + "type": "string" + }, + "parameters": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ConfigDescriptionParameterDTO" + } + }, + "parameterGroups": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ConfigDescriptionParameterGroupDTO" + } + } + } + }, + "DiscoveryResultDTO": { + "type": "object", + "properties": { + "bridgeUID": { + "type": "string" + }, + "flag": { + "type": "string", + "enum": [ + "NEW", + "IGNORED" + ] + }, + "label": { + "type": "string" + }, + "properties": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "representationProperty": { + "type": "string" + }, + "thingUID": { + "type": "string" + }, + "thingTypeUID": { + "type": "string" + } + } + }, + "MetadataDTO": { + "type": "object", + "properties": { + "value": { + "type": "string" + }, + "config": { + "type": "object", + "additionalProperties": { + "type": "object" + } + } + } + }, + "EnrichedItemDTO": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "name": { + "type": "string" + }, + "label": { + "type": "string" + }, + "category": { + "type": "string" + }, + "tags": { + "uniqueItems": true, + "type": "array", + "items": { + "type": "string" + } + }, + "groupNames": { + "type": "array", + "items": { + "type": "string" + } + }, + "link": { + "type": "string" + }, + "state": { + "type": "string" + }, + "transformedState": { + "type": "string" + }, + "stateDescription": { + "$ref": "#/components/schemas/StateDescription" + }, + "unitSymbol": { + "type": "string" + }, + "commandDescription": { + "$ref": "#/components/schemas/CommandDescription" + }, + "metadata": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "editable": { + "type": "boolean" + } + } + }, + "GroupFunctionDTO": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "params": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "GroupItemDTO": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "name": { + "type": "string" + }, + "label": { + "type": "string" + }, + "category": { + "type": "string" + }, + "tags": { + "uniqueItems": true, + "type": "array", + "items": { + "type": "string" + } + }, + "groupNames": { + "type": "array", + "items": { + "type": "string" + } + }, + "groupType": { + "type": "string" + }, + "function": { + "$ref": "#/components/schemas/GroupFunctionDTO" + } + } + }, + "EnrichedItemChannelLinkDTO": { + "type": "object", + "properties": { + "itemName": { + "type": "string" + }, + "channelUID": { + "type": "string" + }, + "configuration": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "editable": { + "type": "boolean" + } + } + }, + "ItemChannelLinkDTO": { + "type": "object", + "properties": { + "itemName": { + "type": "string" + }, + "channelUID": { + "type": "string" + }, + "configuration": { + "type": "object", + "additionalProperties": { + "type": "object" + } + } + } + }, + "HistoryDataBean": { + "type": "object", + "properties": { + "time": { + "type": "integer", + "format": "int64" + }, + "state": { + "type": "string" + } + } + }, + "ItemHistoryDTO": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "totalrecords": { + "type": "string" + }, + "datapoints": { + "type": "string" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/HistoryDataBean" + } + } + } + }, + "PersistenceCronStrategyDTO": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "cronExpression": { + "type": "string" + } + } + }, + "PersistenceFilterDTO": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "value": { + "type": "number" + }, + "relative": { + "type": "boolean" + }, + "unit": { + "type": "string" + }, + "lower": { + "type": "number" + }, + "upper": { + "type": "number" + }, + "values": { + "type": "array", + "items": { + "type": "string" + } + }, + "inverted": { + "type": "boolean" + } + } + }, + "PersistenceItemConfigurationDTO": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "string" + } + }, + "strategies": { + "type": "array", + "items": { + "type": "string" + } + }, + "filters": { + "type": "array", + "items": { + "type": "string" + } + }, + "alias": { + "type": "string" + } + } + }, + "PersistenceServiceConfigurationDTO": { + "type": "object", + "properties": { + "serviceId": { + "type": "string" + }, + "configs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PersistenceItemConfigurationDTO" + } + }, + "defaults": { + "type": "array", + "items": { + "type": "string" + } + }, + "cronStrategies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PersistenceCronStrategyDTO" + } + }, + "thresholdFilters": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PersistenceFilterDTO" + } + }, + "timeFilters": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PersistenceFilterDTO" + } + }, + "equalsFilters": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PersistenceFilterDTO" + } + }, + "includeFilters": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PersistenceFilterDTO" + } + }, + "editable": { + "type": "boolean" + } + } + }, + "PersistenceItemInfo": { + "type": "object", + "properties": { + "earliest": { + "type": "string", + "format": "date-time" + }, + "latest": { + "type": "string", + "format": "date-time" + }, + "name": { + "type": "string" + }, + "count": { + "type": "integer", + "format": "int32" + } + } + }, + "PersistenceServiceDTO": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "label": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, + "ProfileTypeDTO": { + "type": "object", + "properties": { + "uid": { + "type": "string" + }, + "label": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "supportedItemTypes": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "ConfigurableServiceDTO": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "label": { + "type": "string" + }, + "category": { + "type": "string" + }, + "configDescriptionURI": { + "type": "string" + }, + "multiple": { + "type": "boolean" + } + } + }, + "EnrichedSemanticTagDTO": { + "type": "object" + }, + "EnrichedChannelDTO": { + "type": "object", + "properties": { + "uid": { + "type": "string" + }, + "id": { + "type": "string" + }, + "channelTypeUID": { + "type": "string" + }, + "itemType": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "defaultTags": { + "uniqueItems": true, + "type": "array", + "items": { + "type": "string" + } + }, + "properties": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "configuration": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "autoUpdatePolicy": { + "type": "string" + }, + "linkedItems": { + "uniqueItems": true, + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "EnrichedThingDTO": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "bridgeUID": { + "type": "string" + }, + "configuration": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "properties": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "UID": { + "type": "string" + }, + "thingTypeUID": { + "type": "string" + }, + "location": { + "type": "string" + }, + "channels": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EnrichedChannelDTO" + } + }, + "statusInfo": { + "$ref": "#/components/schemas/ThingStatusInfo" + }, + "firmwareStatus": { + "$ref": "#/components/schemas/FirmwareStatusDTO" + }, + "editable": { + "type": "boolean" + } + } + }, + "FirmwareStatusDTO": { + "type": "object", + "properties": { + "status": { + "type": "string" + }, + "updatableVersion": { + "type": "string" + } + } + }, + "ThingStatusInfo": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "UNINITIALIZED", + "INITIALIZING", + "UNKNOWN", + "ONLINE", + "OFFLINE", + "REMOVING", + "REMOVED" + ] + }, + "statusDetail": { + "type": "string", + "enum": [ + "NONE", + "NOT_YET_READY", + "HANDLER_MISSING_ERROR", + "HANDLER_REGISTERING_ERROR", + "HANDLER_INITIALIZING_ERROR", + "HANDLER_CONFIGURATION_PENDING", + "CONFIGURATION_PENDING", + "COMMUNICATION_ERROR", + "CONFIGURATION_ERROR", + "BRIDGE_OFFLINE", + "FIRMWARE_UPDATING", + "DUTY_CYCLE", + "BRIDGE_UNINITIALIZED", + "GONE", + "DISABLED" + ] + }, + "description": { + "type": "string" + } + } + }, + "ChannelDTO": { + "type": "object", + "properties": { + "uid": { + "type": "string" + }, + "id": { + "type": "string" + }, + "channelTypeUID": { + "type": "string" + }, + "itemType": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "defaultTags": { + "uniqueItems": true, + "type": "array", + "items": { + "type": "string" + } + }, + "properties": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "configuration": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "autoUpdatePolicy": { + "type": "string" + } + } + }, + "ThingDTO": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "bridgeUID": { + "type": "string" + }, + "configuration": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "properties": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "UID": { + "type": "string" + }, + "thingTypeUID": { + "type": "string" + }, + "location": { + "type": "string" + }, + "channels": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ChannelDTO" + } + } + } + }, + "ConfigStatusMessage": { + "type": "object", + "properties": { + "parameterName": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "INFORMATION", + "WARNING", + "ERROR", + "PENDING" + ] + }, + "message": { + "type": "string" + }, + "statusCode": { + "type": "integer", + "format": "int32" + } + } + }, + "FirmwareDTO": { + "type": "object", + "properties": { + "thingTypeUID": { + "type": "string" + }, + "vendor": { + "type": "string" + }, + "model": { + "type": "string" + }, + "modelRestricted": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "version": { + "type": "string" + }, + "changelog": { + "type": "string" + }, + "prerequisiteVersion": { + "type": "string" + } + } + }, + "StrippedThingTypeDTO": { + "type": "object", + "properties": { + "UID": { + "type": "string" + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "category": { + "type": "string" + }, + "listed": { + "type": "boolean" + }, + "supportedBridgeTypeUIDs": { + "type": "array", + "items": { + "type": "string" + } + }, + "bridge": { + "type": "boolean" + } + } + }, + "ChannelDefinitionDTO": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "label": { + "type": "string" + }, + "tags": { + "uniqueItems": true, + "type": "array", + "items": { + "type": "string" + } + }, + "properties": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "category": { + "type": "string" + }, + "stateDescription": { + "$ref": "#/components/schemas/StateDescription" + }, + "advanced": { + "type": "boolean" + }, + "typeUID": { + "type": "string" + } + } + }, + "ChannelGroupDefinitionDTO": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "description": { + "type": "string" + }, + "label": { + "type": "string" + }, + "channels": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ChannelDefinitionDTO" + } + } + } + }, + "ThingTypeDTO": { + "type": "object", + "properties": { + "UID": { + "type": "string" + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "category": { + "type": "string" + }, + "listed": { + "type": "boolean" + }, + "supportedBridgeTypeUIDs": { + "type": "array", + "items": { + "type": "string" + } + }, + "bridge": { + "type": "boolean" + }, + "channels": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ChannelDefinitionDTO" + } + }, + "channelGroups": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ChannelGroupDefinitionDTO" + } + }, + "configParameters": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ConfigDescriptionParameterDTO" + } + }, + "parameterGroups": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ConfigDescriptionParameterGroupDTO" + } + }, + "properties": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "extensibleChannelTypeIds": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "Links": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "url": { + "type": "string" + } + } + }, + "RootBean": { + "type": "object", + "properties": { + "version": { + "type": "string" + }, + "locale": { + "type": "string" + }, + "measurementSystem": { + "type": "string" + }, + "runtimeInfo": { + "$ref": "#/components/schemas/RuntimeInfo" + }, + "links": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Links" + } + } + } + }, + "RuntimeInfo": { + "type": "object", + "properties": { + "version": { + "type": "string" + }, + "buildString": { + "type": "string" + } + } + }, + "SystemInfo": { + "type": "object", + "properties": { + "configFolder": { + "type": "string" + }, + "userdataFolder": { + "type": "string" + }, + "logFolder": { + "type": "string" + }, + "javaVersion": { + "type": "string" + }, + "javaVendor": { + "type": "string" + }, + "javaVendorVersion": { + "type": "string" + }, + "osName": { + "type": "string" + }, + "osVersion": { + "type": "string" + }, + "osArchitecture": { + "type": "string" + }, + "availableProcessors": { + "type": "integer", + "format": "int32" + }, + "freeMemory": { + "type": "integer", + "format": "int64" + }, + "totalMemory": { + "type": "integer", + "format": "int64" + }, + "uptime": { + "type": "integer", + "format": "int64" + }, + "startLevel": { + "type": "integer", + "format": "int32" + } + } + }, + "SystemInfoBean": { + "type": "object", + "properties": { + "systemInfo": { + "$ref": "#/components/schemas/SystemInfo" + } + } + }, + "DimensionInfo": { + "type": "object", + "properties": { + "dimension": { + "type": "string" + }, + "systemUnit": { + "type": "string" + } + } + }, + "UoMInfo": { + "type": "object", + "properties": { + "dimensions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DimensionInfo" + } + } + } + }, + "UoMInfoBean": { + "type": "object", + "properties": { + "uomInfo": { + "$ref": "#/components/schemas/UoMInfo" + } + } + }, + "MappingDTO": { + "type": "object", + "properties": { + "row": { + "type": "integer", + "format": "int32" + }, + "column": { + "type": "integer", + "format": "int32" + }, + "command": { + "type": "string" + }, + "releaseCommand": { + "type": "string" + }, + "label": { + "type": "string" + }, + "icon": { + "type": "string" + } + } + }, + "PageDTO": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "title": { + "type": "string" + }, + "icon": { + "type": "string" + }, + "link": { + "type": "string" + }, + "parent": { + "$ref": "#/components/schemas/PageDTO" + }, + "leaf": { + "type": "boolean" + }, + "timeout": { + "type": "boolean" + }, + "widgets": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WidgetDTO" + } + } + } + }, + "WidgetDTO": { + "type": "object", + "properties": { + "widgetId": { + "type": "string" + }, + "type": { + "type": "string" + }, + "name": { + "type": "string" + }, + "visibility": { + "type": "boolean" + }, + "label": { + "type": "string" + }, + "labelSource": { + "type": "string" + }, + "icon": { + "type": "string" + }, + "staticIcon": { + "type": "boolean" + }, + "labelcolor": { + "type": "string" + }, + "valuecolor": { + "type": "string" + }, + "iconcolor": { + "type": "string" + }, + "pattern": { + "type": "string" + }, + "unit": { + "type": "string" + }, + "mappings": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MappingDTO" + } + }, + "switchSupport": { + "type": "boolean" + }, + "releaseOnly": { + "type": "boolean" + }, + "sendFrequency": { + "type": "integer", + "format": "int32" + }, + "refresh": { + "type": "integer", + "format": "int32" + }, + "height": { + "type": "integer", + "format": "int32" + }, + "minValue": { + "type": "number" + }, + "maxValue": { + "type": "number" + }, + "step": { + "type": "number" + }, + "inputHint": { + "type": "string" + }, + "url": { + "type": "string" + }, + "encoding": { + "type": "string" + }, + "service": { + "type": "string" + }, + "period": { + "type": "string" + }, + "yAxisDecimalPattern": { + "type": "string" + }, + "legend": { + "type": "boolean" + }, + "forceAsItem": { + "type": "boolean" + }, + "row": { + "type": "integer", + "format": "int32" + }, + "column": { + "type": "integer", + "format": "int32" + }, + "command": { + "type": "string" + }, + "releaseCommand": { + "type": "string" + }, + "stateless": { + "type": "boolean" + }, + "state": { + "type": "string" + }, + "item": { + "$ref": "#/components/schemas/EnrichedItemDTO" + }, + "linkedPage": { + "$ref": "#/components/schemas/PageDTO" + } + } + }, + "SitemapDTO": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "icon": { + "type": "string" + }, + "label": { + "type": "string" + }, + "link": { + "type": "string" + }, + "homepage": { + "$ref": "#/components/schemas/PageDTO" + } + } + }, + "Transformation": { + "type": "object", + "properties": { + "uid": { + "type": "string" + }, + "label": { + "type": "string" + }, + "type": { + "type": "string" + }, + "configuration": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + }, + "TransformationDTO": { + "type": "object", + "properties": { + "uid": { + "type": "string" + }, + "label": { + "type": "string" + }, + "type": { + "type": "string" + }, + "configuration": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "editable": { + "type": "boolean" + } + } + }, + "RootUIComponent": { + "type": "object", + "properties": { + "component": { + "type": "string" + }, + "config": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "slots": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UIComponent" + } + } + }, + "uid": { + "type": "string" + }, + "tags": { + "uniqueItems": true, + "type": "array", + "items": { + "type": "string" + } + }, + "props": { + "$ref": "#/components/schemas/ConfigDescriptionDTO" + }, + "timestamp": { + "type": "string", + "format": "date-time" + }, + "type": { + "type": "string" + } + } + }, + "UIComponent": { + "type": "object", + "properties": { + "component": { + "type": "string" + }, + "config": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "type": { + "type": "string" + } + } + }, + "TileDTO": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "url": { + "type": "string" + }, + "overlay": { + "type": "string" + }, + "imageUrl": { + "type": "string" + } + } + }, + "VoiceDTO": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "label": { + "type": "string" + }, + "locale": { + "type": "string" + } + } + }, + "HumanLanguageInterpreterDTO": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "label": { + "type": "string" + }, + "locales": { + "uniqueItems": true, + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "LoggerInfo": { + "type": "object", + "properties": { + "loggerName": { + "type": "string" + }, + "level": { + "type": "string" + } + } + }, + "LoggerBean": { + "type": "object", + "properties": { + "loggers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LoggerInfo" + } + } + } + }, + "IconSet": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "formats": { + "uniqueItems": true, + "type": "array", + "items": { + "type": "string", + "enum": [ + "PNG", + "SVG" + ] + } + } + } + }, + "GalleryWidgetsListItem": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "likes": { + "type": "integer", + "format": "int32" + }, + "views": { + "type": "integer", + "format": "int32" + }, + "posts": { + "type": "integer", + "format": "int32" + }, + "imageUrl": { + "type": "string" + }, + "createdDate": { + "type": "string", + "format": "date-time" + } + } + }, + "GalleryItem": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "likes": { + "type": "integer", + "format": "int32" + }, + "views": { + "type": "integer", + "format": "int32" + }, + "posts": { + "type": "integer", + "format": "int32" + }, + "imageUrl": { + "type": "string" + }, + "author": { + "type": "string" + }, + "authorName": { + "type": "string" + }, + "authorAvatarUrl": { + "type": "string" + }, + "createdDate": { + "type": "string", + "format": "date-time" + }, + "updatedDate": { + "type": "string", + "format": "date-time" + }, + "readme": { + "type": "string" + } + } + } + }, + "securitySchemes": { + "oauth2": { + "type": "oauth2", + "flows": { + "authorizationCode": { + "authorizationUrl": "/auth/authorize", + "tokenUrl": "/rest/auth/token", + "scopes": { + "admin": "Administration operations" + } + } + } + } + } + } +} \ No newline at end of file diff --git a/openHABWatch Extension/openHABWatch Extension/Model/ObservableOpenHABWidget.swift b/openHABWatch Extension/openHABWatch Extension/Model/ObservableOpenHABWidget.swift index 9241caa94..744e885e1 100644 --- a/openHABWatch Extension/openHABWatch Extension/Model/ObservableOpenHABWidget.swift +++ b/openHABWatch Extension/openHABWatch Extension/Model/ObservableOpenHABWidget.swift @@ -66,7 +66,7 @@ class ObservableOpenHABWidget: NSObject, MKAnnotation, Identifiable, ObservableO var legend: Bool? var encoding = "" @Published var item: OpenHABItem? - var linkedPage: OpenHABSitemapPage? + var linkedPage: OpenHABPage? var mappings: [OpenHABWidgetMapping] = [] var image: UIImage? var widgets: [ObservableOpenHABWidget] = [] @@ -212,7 +212,7 @@ class ObservableOpenHABWidget: NSObject, MKAnnotation, Identifiable, ObservableO extension ObservableOpenHABWidget { // This is an ugly initializer - convenience init(widgetId: String, label: String, icon: String, type: String, url: String?, period: String?, minValue: Double?, maxValue: Double?, step: Double?, refresh: Int?, height: Double?, isLeaf: Bool?, iconColor: String?, labelColor: String?, valueColor: String?, service: String?, state: String?, text: String?, legend: Bool?, encoding: String?, item: OpenHABItem?, linkedPage: OpenHABSitemapPage?, mappings: [OpenHABWidgetMapping], widgets: [ObservableOpenHABWidget], forceAsItem: Bool?) { + convenience init(widgetId: String, label: String, icon: String, type: String, url: String?, period: String?, minValue: Double?, maxValue: Double?, step: Double?, refresh: Int?, height: Double?, isLeaf: Bool?, iconColor: String?, labelColor: String?, valueColor: String?, service: String?, state: String?, text: String?, legend: Bool?, encoding: String?, item: OpenHABItem?, linkedPage: OpenHABPage?, mappings: [OpenHABWidgetMapping], widgets: [ObservableOpenHABWidget], forceAsItem: Bool?) { self.init() id = widgetId @@ -281,7 +281,7 @@ extension ObservableOpenHABWidget { let encoding: String? let groupType: String? let item: OpenHABItem.CodingData? - let linkedPage: OpenHABSitemapPage.CodingData? + let linkedPage: OpenHABPage.CodingData? let mappings: [OpenHABWidgetMapping] let widgets: [ObservableOpenHABWidget.CodingData] let forceAsItem: Bool? diff --git a/openHABWatch Extension/openHABWatch Extension/UserData.swift b/openHABWatch Extension/openHABWatch Extension/UserData.swift index adb06236f..6923ab3a3 100644 --- a/openHABWatch Extension/openHABWatch Extension/UserData.swift +++ b/openHABWatch Extension/openHABWatch Extension/UserData.swift @@ -16,15 +16,6 @@ import OpenHABCore import os.log import SwiftUI -// swiftlint:disable:next file_types_order -extension OpenHABCore.Future where Value == ObservableOpenHABSitemapPage.CodingData { - func trafo() -> OpenHABCore.Future { - transformed { data in - data.openHABSitemapPage - } - } -} - final class UserData: ObservableObject { @Published var widgets: [ObservableOpenHABWidget] = [] @Published var showAlert = false @@ -99,44 +90,6 @@ final class UserData: ObservableObject { refreshUrl() } - func request(_ endpoint: Endpoint) -> OpenHABCore.Future { - // Start by constructing a Promise, that will later be - // returned as a Future - let promise = Promise() - - // Immediately reject the promise in case the passed - // endpoint can't be converted into a valid URL - guard let url = endpoint.url else { - promise.reject(with: NetworkingError.invalidURL) - return promise - } - - if currentPageOperation != nil { - currentPageOperation?.cancel() - currentPageOperation = nil - } - - currentPageOperation = NetworkConnection.page( - url: url, - longPolling: true - ) { [weak self] response in - guard self != nil else { return } - - switch response.result { - case let .success(data): - os_log("openHAB 2", log: OSLog.remoteAccess, type: .info) - promise.resolve(with: data) - - case let .failure(error): - os_log("On LoadPage %{PUBLIC}@ code: %d ", log: .remoteAccess, type: .error, error.localizedDescription, response.response?.statusCode ?? 0) - promise.reject(with: error) - } - } - currentPageOperation?.resume() - - return promise - } - func loadPage(url: URL?, longPolling: Bool, refresh: Bool) { @@ -205,20 +158,6 @@ final class UserData: ObservableObject { tracker?.selectUrl() } } - - func loadPage(_ endpoint: Endpoint) { - request(endpoint) - .decoded(as: ObservableOpenHABSitemapPage.CodingData.self) - .trafo() - .observe { result in - switch result { - case let .failure(error): - os_log("On LoadPage %{PUBLIC}@", log: .remoteAccess, type: .error, error.localizedDescription) - case let .success(page): - self.openHABSitemapPage = page - } - } - } } extension UserData: OpenHABWatchTrackerDelegate { From c96ec85238722a097a96833d59465dbbeb9a9ec3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20M=C3=BCller-Seydlitz?= Date: Fri, 16 Aug 2024 21:15:05 +0200 Subject: [PATCH 003/476] Manually modifying openapi - JerseyResponseBuilderDTO for response to create event subscription, Allowing for X-Atmosphere-Transport for long-polling, SitemapWidgetEvent for server side events Experimenting with SSE consumption Include Client and Types to make it compile on github Shifted logging to dedicated ClientMiddleware Created class OpenHABSitemapWidgetEvent Modified openapi to include sitemapName and pageId in SitemapWidgetEvent Getting server sent events working - establishing a subscription and receiving events , not yet consuming / Commented out in OpenHABSitemapViewController --- .gitignore | 3 +- .../GeneratedSources/openapi/Client.swift | 1967 +++++ .../GeneratedSources/openapi/Types.swift | 7295 +++++++++++++++++ .../Sources/OpenHABCore/Model/APIActor.swift | 169 +- .../OpenHABCore/Model/LoggingMiddleware.swift | 115 + .../OpenHABCore/Model/OpenHABItem.swift | 2 +- .../Sources/OpenHABCore/openapi/openapi.json | 107 +- .../OpenHABDrawerTableViewController.swift | 25 +- openHAB/OpenHABSitemapViewController.swift | 25 +- openHAB/OpenHABWebViewController.swift | 1 - 10 files changed, 9647 insertions(+), 62 deletions(-) create mode 100644 OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Client.swift create mode 100644 OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Types.swift create mode 100644 OpenHABCore/Sources/OpenHABCore/Model/LoggingMiddleware.swift diff --git a/.gitignore b/.gitignore index 92c6fa790..5999bc488 100644 --- a/.gitignore +++ b/.gitignore @@ -18,5 +18,6 @@ build/ BuildTools/.build OpenHABCore/Package.resolved -OpenHABCore/Sources/OpenHABCore/GeneratedSources + +#OpenHABCore/Sources/OpenHABCore/GeneratedSources OpenHABCore/swift-openapi-generator diff --git a/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Client.swift b/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Client.swift new file mode 100644 index 000000000..9f0f22bf0 --- /dev/null +++ b/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Client.swift @@ -0,0 +1,1967 @@ +// 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 + +// Generated by swift-openapi-generator, do not modify. +@_spi(Generated) import OpenAPIRuntime +#if os(Linux) +@preconcurrency import struct Foundation.Data +@preconcurrency import struct Foundation.Date +@preconcurrency import struct Foundation.URL +#else +import struct Foundation.Data +import struct Foundation.Date +import struct Foundation.URL +#endif +import HTTPTypes + +struct Client: APIProtocol { + /// The underlying HTTP client. + private let client: UniversalClient + /// Creates a new client. + /// - Parameters: + /// - serverURL: The server URL that the client connects to. Any server + /// URLs defined in the OpenAPI document are available as static methods + /// on the ``Servers`` type. + /// - configuration: A set of configuration values for the client. + /// - transport: A transport that performs HTTP operations. + /// - middlewares: A list of middlewares to call before the transport. + init(serverURL: Foundation.URL, + configuration: Configuration = .init(), + transport: any ClientTransport, + middlewares: [any ClientMiddleware] = []) { + client = .init( + serverURL: serverURL, + configuration: configuration, + transport: transport, + middlewares: middlewares + ) + } + + private var converter: Converter { + client.converter + } + + /// Adds a new member to a group item. + /// + /// - Remark: HTTP `PUT /items/{itemName}/members/{memberItemName}`. + /// - Remark: Generated from `#/paths//items/{itemName}/members/{memberItemName}/put(addMemberToGroupItem)`. + func addMemberToGroupItem(_ input: Operations.addMemberToGroupItem.Input) async throws -> Operations.addMemberToGroupItem.Output { + try await client.send( + input: input, + forOperation: Operations.addMemberToGroupItem.id, + serializer: { input in + let path = try converter.renderedPath( + template: "/items/{}/members/{}", + parameters: [ + input.path.itemName, + input.path.memberItemName + ] + ) + var request: HTTPTypes.HTTPRequest = .init( + soar_path: path, + method: .put + ) + suppressMutabilityWarning(&request) + return (request, nil) + }, + deserializer: { response, responseBody in + switch response.status.code { + case 200: + .ok(.init()) + case 404: + .notFound(.init()) + case 405: + .methodNotAllowed(.init()) + default: + .undocumented( + statusCode: response.status.code, + .init( + headerFields: response.headerFields, + body: responseBody + ) + ) + } + } + ) + } + + /// Removes an existing member from a group item. + /// + /// - Remark: HTTP `DELETE /items/{itemName}/members/{memberItemName}`. + /// - Remark: Generated from `#/paths//items/{itemName}/members/{memberItemName}/delete(removeMemberFromGroupItem)`. + func removeMemberFromGroupItem(_ input: Operations.removeMemberFromGroupItem.Input) async throws -> Operations.removeMemberFromGroupItem.Output { + try await client.send( + input: input, + forOperation: Operations.removeMemberFromGroupItem.id, + serializer: { input in + let path = try converter.renderedPath( + template: "/items/{}/members/{}", + parameters: [ + input.path.itemName, + input.path.memberItemName + ] + ) + var request: HTTPTypes.HTTPRequest = .init( + soar_path: path, + method: .delete + ) + suppressMutabilityWarning(&request) + return (request, nil) + }, + deserializer: { response, responseBody in + switch response.status.code { + case 200: + .ok(.init()) + case 404: + .notFound(.init()) + case 405: + .methodNotAllowed(.init()) + default: + .undocumented( + statusCode: response.status.code, + .init( + headerFields: response.headerFields, + body: responseBody + ) + ) + } + } + ) + } + + /// Adds metadata to an item. + /// + /// - Remark: HTTP `PUT /items/{itemname}/metadata/{namespace}`. + /// - Remark: Generated from `#/paths//items/{itemname}/metadata/{namespace}/put(addMetadataToItem)`. + func addMetadataToItem(_ input: Operations.addMetadataToItem.Input) async throws -> Operations.addMetadataToItem.Output { + try await client.send( + input: input, + forOperation: Operations.addMetadataToItem.id, + serializer: { input in + let path = try converter.renderedPath( + template: "/items/{}/metadata/{}", + parameters: [ + input.path.itemname, + input.path.namespace + ] + ) + var request: HTTPTypes.HTTPRequest = .init( + soar_path: path, + method: .put + ) + suppressMutabilityWarning(&request) + let body: OpenAPIRuntime.HTTPBody? = switch input.body { + case let .json(value): + try converter.setRequiredRequestBodyAsJSON( + value, + headerFields: &request.headerFields, + contentType: "application/json; charset=utf-8" + ) + } + return (request, body) + }, + deserializer: { response, responseBody in + switch response.status.code { + case 200: + .ok(.init()) + case 201: + .created(.init()) + case 400: + .badRequest(.init()) + case 404: + .notFound(.init()) + case 405: + .methodNotAllowed(.init()) + default: + .undocumented( + statusCode: response.status.code, + .init( + headerFields: response.headerFields, + body: responseBody + ) + ) + } + } + ) + } + + /// Removes metadata from an item. + /// + /// - Remark: HTTP `DELETE /items/{itemname}/metadata/{namespace}`. + /// - Remark: Generated from `#/paths//items/{itemname}/metadata/{namespace}/delete(removeMetadataFromItem)`. + func removeMetadataFromItem(_ input: Operations.removeMetadataFromItem.Input) async throws -> Operations.removeMetadataFromItem.Output { + try await client.send( + input: input, + forOperation: Operations.removeMetadataFromItem.id, + serializer: { input in + let path = try converter.renderedPath( + template: "/items/{}/metadata/{}", + parameters: [ + input.path.itemname, + input.path.namespace + ] + ) + var request: HTTPTypes.HTTPRequest = .init( + soar_path: path, + method: .delete + ) + suppressMutabilityWarning(&request) + return (request, nil) + }, + deserializer: { response, responseBody in + switch response.status.code { + case 200: + .ok(.init()) + case 404: + .notFound(.init()) + case 405: + .methodNotAllowed(.init()) + default: + .undocumented( + statusCode: response.status.code, + .init( + headerFields: response.headerFields, + body: responseBody + ) + ) + } + } + ) + } + + /// Adds a tag to an item. + /// + /// - Remark: HTTP `PUT /items/{itemname}/tags/{tag}`. + /// - Remark: Generated from `#/paths//items/{itemname}/tags/{tag}/put(addTagToItem)`. + func addTagToItem(_ input: Operations.addTagToItem.Input) async throws -> Operations.addTagToItem.Output { + try await client.send( + input: input, + forOperation: Operations.addTagToItem.id, + serializer: { input in + let path = try converter.renderedPath( + template: "/items/{}/tags/{}", + parameters: [ + input.path.itemname, + input.path.tag + ] + ) + var request: HTTPTypes.HTTPRequest = .init( + soar_path: path, + method: .put + ) + suppressMutabilityWarning(&request) + return (request, nil) + }, + deserializer: { response, responseBody in + switch response.status.code { + case 200: + .ok(.init()) + case 404: + .notFound(.init()) + case 405: + .methodNotAllowed(.init()) + default: + .undocumented( + statusCode: response.status.code, + .init( + headerFields: response.headerFields, + body: responseBody + ) + ) + } + } + ) + } + + /// Removes a tag from an item. + /// + /// - Remark: HTTP `DELETE /items/{itemname}/tags/{tag}`. + /// - Remark: Generated from `#/paths//items/{itemname}/tags/{tag}/delete(removeTagFromItem)`. + func removeTagFromItem(_ input: Operations.removeTagFromItem.Input) async throws -> Operations.removeTagFromItem.Output { + try await client.send( + input: input, + forOperation: Operations.removeTagFromItem.id, + serializer: { input in + let path = try converter.renderedPath( + template: "/items/{}/tags/{}", + parameters: [ + input.path.itemname, + input.path.tag + ] + ) + var request: HTTPTypes.HTTPRequest = .init( + soar_path: path, + method: .delete + ) + suppressMutabilityWarning(&request) + return (request, nil) + }, + deserializer: { response, responseBody in + switch response.status.code { + case 200: + .ok(.init()) + case 404: + .notFound(.init()) + case 405: + .methodNotAllowed(.init()) + default: + .undocumented( + statusCode: response.status.code, + .init( + headerFields: response.headerFields, + body: responseBody + ) + ) + } + } + ) + } + + /// Gets a single item. + /// + /// - Remark: HTTP `GET /items/{itemname}`. + /// - Remark: Generated from `#/paths//items/{itemname}/get(getItemByName)`. + func getItemByName(_ input: Operations.getItemByName.Input) async throws -> Operations.getItemByName.Output { + try await client.send( + input: input, + forOperation: Operations.getItemByName.id, + serializer: { input in + let path = try converter.renderedPath( + template: "/items/{}", + parameters: [ + input.path.itemname + ] + ) + var request: HTTPTypes.HTTPRequest = .init( + soar_path: path, + method: .get + ) + suppressMutabilityWarning(&request) + try converter.setHeaderFieldAsURI( + in: &request.headerFields, + name: "Accept-Language", + value: input.headers.Accept_hyphen_Language + ) + try converter.setQueryItemAsURI( + in: &request, + style: .form, + explode: true, + name: "metadata", + value: input.query.metadata + ) + try converter.setQueryItemAsURI( + in: &request, + style: .form, + explode: true, + name: "recursive", + value: input.query.recursive + ) + converter.setAcceptHeader( + in: &request.headerFields, + contentTypes: input.headers.accept + ) + return (request, nil) + }, + deserializer: { response, responseBody in + switch response.status.code { + case 200: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Operations.getItemByName.Output.Ok.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Components.Schemas.EnrichedItemDTO.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .ok(.init(body: body)) + case 404: + return .notFound(.init()) + default: + return .undocumented( + statusCode: response.status.code, + .init( + headerFields: response.headerFields, + body: responseBody + ) + ) + } + } + ) + } + + /// Sends a command to an item. + /// + /// - Remark: HTTP `POST /items/{itemname}`. + /// - Remark: Generated from `#/paths//items/{itemname}/post(sendItemCommand)`. + func sendItemCommand(_ input: Operations.sendItemCommand.Input) async throws -> Operations.sendItemCommand.Output { + try await client.send( + input: input, + forOperation: Operations.sendItemCommand.id, + serializer: { input in + let path = try converter.renderedPath( + template: "/items/{}", + parameters: [ + input.path.itemname + ] + ) + var request: HTTPTypes.HTTPRequest = .init( + soar_path: path, + method: .post + ) + suppressMutabilityWarning(&request) + let body: OpenAPIRuntime.HTTPBody? = switch input.body { + case let .plainText(value): + try converter.setRequiredRequestBodyAsBinary( + value, + headerFields: &request.headerFields, + contentType: "text/plain" + ) + } + return (request, body) + }, + deserializer: { response, responseBody in + switch response.status.code { + case 200: + .ok(.init()) + case 400: + .badRequest(.init()) + case 404: + .notFound(.init()) + default: + .undocumented( + statusCode: response.status.code, + .init( + headerFields: response.headerFields, + body: responseBody + ) + ) + } + } + ) + } + + /// Adds a new item to the registry or updates the existing item. + /// + /// - Remark: HTTP `PUT /items/{itemname}`. + /// - Remark: Generated from `#/paths//items/{itemname}/put(addOrUpdateItemInRegistry)`. + func addOrUpdateItemInRegistry(_ input: Operations.addOrUpdateItemInRegistry.Input) async throws -> Operations.addOrUpdateItemInRegistry.Output { + try await client.send( + input: input, + forOperation: Operations.addOrUpdateItemInRegistry.id, + serializer: { input in + let path = try converter.renderedPath( + template: "/items/{}", + parameters: [ + input.path.itemname + ] + ) + var request: HTTPTypes.HTTPRequest = .init( + soar_path: path, + method: .put + ) + suppressMutabilityWarning(&request) + try converter.setHeaderFieldAsURI( + in: &request.headerFields, + name: "Accept-Language", + value: input.headers.Accept_hyphen_Language + ) + converter.setAcceptHeader( + in: &request.headerFields, + contentTypes: input.headers.accept + ) + let body: OpenAPIRuntime.HTTPBody? = switch input.body { + case let .json(value): + try converter.setRequiredRequestBodyAsJSON( + value, + headerFields: &request.headerFields, + contentType: "application/json; charset=utf-8" + ) + } + return (request, body) + }, + deserializer: { response, responseBody in + switch response.status.code { + case 200: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Operations.addOrUpdateItemInRegistry.Output.Ok.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "*/*" + ] + ) + switch chosenContentType { + case "*/*": + body = try converter.getResponseBodyAsBinary( + OpenAPIRuntime.HTTPBody.self, + from: responseBody, + transforming: { value in + .any(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .ok(.init(body: body)) + case 201: + return .created(.init()) + case 400: + return .badRequest(.init()) + case 404: + return .notFound(.init()) + case 405: + return .methodNotAllowed(.init()) + default: + return .undocumented( + statusCode: response.status.code, + .init( + headerFields: response.headerFields, + body: responseBody + ) + ) + } + } + ) + } + + /// Removes an item from the registry. + /// + /// - Remark: HTTP `DELETE /items/{itemname}`. + /// - Remark: Generated from `#/paths//items/{itemname}/delete(removeItemFromRegistry)`. + func removeItemFromRegistry(_ input: Operations.removeItemFromRegistry.Input) async throws -> Operations.removeItemFromRegistry.Output { + try await client.send( + input: input, + forOperation: Operations.removeItemFromRegistry.id, + serializer: { input in + let path = try converter.renderedPath( + template: "/items/{}", + parameters: [ + input.path.itemname + ] + ) + var request: HTTPTypes.HTTPRequest = .init( + soar_path: path, + method: .delete + ) + suppressMutabilityWarning(&request) + return (request, nil) + }, + deserializer: { response, responseBody in + switch response.status.code { + case 200: + .ok(.init()) + case 404: + .notFound(.init()) + default: + .undocumented( + statusCode: response.status.code, + .init( + headerFields: response.headerFields, + body: responseBody + ) + ) + } + } + ) + } + + /// Get all available items. + /// + /// - Remark: HTTP `GET /items`. + /// - Remark: Generated from `#/paths//items/get(getItems)`. + func getItems(_ input: Operations.getItems.Input) async throws -> Operations.getItems.Output { + try await client.send( + input: input, + forOperation: Operations.getItems.id, + serializer: { input in + let path = try converter.renderedPath( + template: "/items", + parameters: [] + ) + var request: HTTPTypes.HTTPRequest = .init( + soar_path: path, + method: .get + ) + suppressMutabilityWarning(&request) + try converter.setHeaderFieldAsURI( + in: &request.headerFields, + name: "Accept-Language", + value: input.headers.Accept_hyphen_Language + ) + try converter.setQueryItemAsURI( + in: &request, + style: .form, + explode: true, + name: "type", + value: input.query._type + ) + try converter.setQueryItemAsURI( + in: &request, + style: .form, + explode: true, + name: "tags", + value: input.query.tags + ) + try converter.setQueryItemAsURI( + in: &request, + style: .form, + explode: true, + name: "metadata", + value: input.query.metadata + ) + try converter.setQueryItemAsURI( + in: &request, + style: .form, + explode: true, + name: "recursive", + value: input.query.recursive + ) + try converter.setQueryItemAsURI( + in: &request, + style: .form, + explode: true, + name: "fields", + value: input.query.fields + ) + try converter.setQueryItemAsURI( + in: &request, + style: .form, + explode: true, + name: "staticDataOnly", + value: input.query.staticDataOnly + ) + converter.setAcceptHeader( + in: &request.headerFields, + contentTypes: input.headers.accept + ) + return (request, nil) + }, + deserializer: { response, responseBody in + switch response.status.code { + case 200: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Operations.getItems.Output.Ok.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + [Components.Schemas.EnrichedItemDTO].self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .ok(.init(body: body)) + default: + return .undocumented( + statusCode: response.status.code, + .init( + headerFields: response.headerFields, + body: responseBody + ) + ) + } + } + ) + } + + /// Adds a list of items to the registry or updates the existing items. + /// + /// - Remark: HTTP `PUT /items`. + /// - Remark: Generated from `#/paths//items/put(addOrUpdateItemsInRegistry)`. + func addOrUpdateItemsInRegistry(_ input: Operations.addOrUpdateItemsInRegistry.Input) async throws -> Operations.addOrUpdateItemsInRegistry.Output { + try await client.send( + input: input, + forOperation: Operations.addOrUpdateItemsInRegistry.id, + serializer: { input in + let path = try converter.renderedPath( + template: "/items", + parameters: [] + ) + var request: HTTPTypes.HTTPRequest = .init( + soar_path: path, + method: .put + ) + suppressMutabilityWarning(&request) + converter.setAcceptHeader( + in: &request.headerFields, + contentTypes: input.headers.accept + ) + let body: OpenAPIRuntime.HTTPBody? = switch input.body { + case let .json(value): + try converter.setRequiredRequestBodyAsJSON( + value, + headerFields: &request.headerFields, + contentType: "application/json; charset=utf-8" + ) + } + return (request, body) + }, + deserializer: { response, responseBody in + switch response.status.code { + case 200: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Operations.addOrUpdateItemsInRegistry.Output.Ok.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "*/*" + ] + ) + switch chosenContentType { + case "*/*": + body = try converter.getResponseBodyAsBinary( + OpenAPIRuntime.HTTPBody.self, + from: responseBody, + transforming: { value in + .any(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .ok(.init(body: body)) + case 400: + return .badRequest(.init()) + default: + return .undocumented( + statusCode: response.status.code, + .init( + headerFields: response.headerFields, + body: responseBody + ) + ) + } + } + ) + } + + /// Gets the state of an item. + /// + /// - Remark: HTTP `GET /items/{itemname}/state`. + /// - Remark: Generated from `#/paths//items/{itemname}/state/get(getItemState_1)`. + func getItemState_1(_ input: Operations.getItemState_1.Input) async throws -> Operations.getItemState_1.Output { + try await client.send( + input: input, + forOperation: Operations.getItemState_1.id, + serializer: { input in + let path = try converter.renderedPath( + template: "/items/{}/state", + parameters: [ + input.path.itemname + ] + ) + var request: HTTPTypes.HTTPRequest = .init( + soar_path: path, + method: .get + ) + suppressMutabilityWarning(&request) + converter.setAcceptHeader( + in: &request.headerFields, + contentTypes: input.headers.accept + ) + return (request, nil) + }, + deserializer: { response, responseBody in + switch response.status.code { + case 200: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Operations.getItemState_1.Output.Ok.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "text/plain" + ] + ) + switch chosenContentType { + case "text/plain": + body = try converter.getResponseBodyAsBinary( + OpenAPIRuntime.HTTPBody.self, + from: responseBody, + transforming: { value in + .plainText(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .ok(.init(body: body)) + case 404: + return .notFound(.init()) + default: + return .undocumented( + statusCode: response.status.code, + .init( + headerFields: response.headerFields, + body: responseBody + ) + ) + } + } + ) + } + + /// Updates the state of an item. + /// + /// - Remark: HTTP `PUT /items/{itemname}/state`. + /// - Remark: Generated from `#/paths//items/{itemname}/state/put(updateItemState)`. + func updateItemState(_ input: Operations.updateItemState.Input) async throws -> Operations.updateItemState.Output { + try await client.send( + input: input, + forOperation: Operations.updateItemState.id, + serializer: { input in + let path = try converter.renderedPath( + template: "/items/{}/state", + parameters: [ + input.path.itemname + ] + ) + var request: HTTPTypes.HTTPRequest = .init( + soar_path: path, + method: .put + ) + suppressMutabilityWarning(&request) + try converter.setHeaderFieldAsURI( + in: &request.headerFields, + name: "Accept-Language", + value: input.headers.Accept_hyphen_Language + ) + let body: OpenAPIRuntime.HTTPBody? = switch input.body { + case let .plainText(value): + try converter.setRequiredRequestBodyAsBinary( + value, + headerFields: &request.headerFields, + contentType: "text/plain" + ) + } + return (request, body) + }, + deserializer: { response, responseBody in + switch response.status.code { + case 202: + .accepted(.init()) + case 400: + .badRequest(.init()) + case 404: + .notFound(.init()) + default: + .undocumented( + statusCode: response.status.code, + .init( + headerFields: response.headerFields, + body: responseBody + ) + ) + } + } + ) + } + + /// Gets the namespace of an item. + /// + /// - Remark: HTTP `GET /items/{itemname}/metadata/namespaces`. + /// - Remark: Generated from `#/paths//items/{itemname}/metadata/namespaces/get(getItemNamespaces)`. + func getItemNamespaces(_ input: Operations.getItemNamespaces.Input) async throws -> Operations.getItemNamespaces.Output { + try await client.send( + input: input, + forOperation: Operations.getItemNamespaces.id, + serializer: { input in + let path = try converter.renderedPath( + template: "/items/{}/metadata/namespaces", + parameters: [ + input.path.itemname + ] + ) + var request: HTTPTypes.HTTPRequest = .init( + soar_path: path, + method: .get + ) + suppressMutabilityWarning(&request) + try converter.setHeaderFieldAsURI( + in: &request.headerFields, + name: "Accept-Language", + value: input.headers.Accept_hyphen_Language + ) + converter.setAcceptHeader( + in: &request.headerFields, + contentTypes: input.headers.accept + ) + return (request, nil) + }, + deserializer: { response, responseBody in + switch response.status.code { + case 200: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Operations.getItemNamespaces.Output.Ok.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Swift.String.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .ok(.init(body: body)) + case 404: + return .notFound(.init()) + default: + return .undocumented( + statusCode: response.status.code, + .init( + headerFields: response.headerFields, + body: responseBody + ) + ) + } + } + ) + } + + /// Gets the item which defines the requested semantics of an item. + /// + /// - Remark: HTTP `GET /items/{itemName}/semantic/{semanticClass}`. + /// - Remark: Generated from `#/paths//items/{itemName}/semantic/{semanticClass}/get(getSemanticItem)`. + func getSemanticItem(_ input: Operations.getSemanticItem.Input) async throws -> Operations.getSemanticItem.Output { + try await client.send( + input: input, + forOperation: Operations.getSemanticItem.id, + serializer: { input in + let path = try converter.renderedPath( + template: "/items/{}/semantic/{}", + parameters: [ + input.path.itemName, + input.path.semanticClass + ] + ) + var request: HTTPTypes.HTTPRequest = .init( + soar_path: path, + method: .get + ) + suppressMutabilityWarning(&request) + try converter.setHeaderFieldAsURI( + in: &request.headerFields, + name: "Accept-Language", + value: input.headers.Accept_hyphen_Language + ) + return (request, nil) + }, + deserializer: { response, responseBody in + switch response.status.code { + case 200: + .ok(.init()) + case 404: + .notFound(.init()) + default: + .undocumented( + statusCode: response.status.code, + .init( + headerFields: response.headerFields, + body: responseBody + ) + ) + } + } + ) + } + + /// Remove unused/orphaned metadata. + /// + /// - Remark: HTTP `POST /items/metadata/purge`. + /// - Remark: Generated from `#/paths//items/metadata/purge/post(purgeDatabase)`. + func purgeDatabase(_ input: Operations.purgeDatabase.Input) async throws -> Operations.purgeDatabase.Output { + try await client.send( + input: input, + forOperation: Operations.purgeDatabase.id, + serializer: { _ in + let path = try converter.renderedPath( + template: "/items/metadata/purge", + parameters: [] + ) + var request: HTTPTypes.HTTPRequest = .init( + soar_path: path, + method: .post + ) + suppressMutabilityWarning(&request) + return (request, nil) + }, + deserializer: { response, responseBody in + switch response.status.code { + case 200: + .ok(.init()) + default: + .undocumented( + statusCode: response.status.code, + .init( + headerFields: response.headerFields, + body: responseBody + ) + ) + } + } + ) + } + + /// Creates a sitemap event subscription. + /// + /// - Remark: HTTP `POST /sitemaps/events/subscribe`. + /// - Remark: Generated from `#/paths//sitemaps/events/subscribe/post(createSitemapEventSubscription)`. + func createSitemapEventSubscription(_ input: Operations.createSitemapEventSubscription.Input) async throws -> Operations.createSitemapEventSubscription.Output { + try await client.send( + input: input, + forOperation: Operations.createSitemapEventSubscription.id, + serializer: { input in + let path = try converter.renderedPath( + template: "/sitemaps/events/subscribe", + parameters: [] + ) + var request: HTTPTypes.HTTPRequest = .init( + soar_path: path, + method: .post + ) + suppressMutabilityWarning(&request) + converter.setAcceptHeader( + in: &request.headerFields, + contentTypes: input.headers.accept + ) + return (request, nil) + }, + deserializer: { response, responseBody in + switch response.status.code { + case 201: + return .created(.init()) + case 200: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Operations.createSitemapEventSubscription.Output.Ok.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Components.Schemas.JerseyResponseBuilderDTO.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .ok(.init(body: body)) + case 503: + return .serviceUnavailable(.init()) + default: + return .undocumented( + statusCode: response.status.code, + .init( + headerFields: response.headerFields, + body: responseBody + ) + ) + } + } + ) + } + + /// Polls the data for one page of a sitemap. + /// + /// - Remark: HTTP `GET /sitemaps/{sitemapname}/{pageid}`. + /// - Remark: Generated from `#/paths//sitemaps/{sitemapname}/{pageid}/get(pollDataForPage)`. + func pollDataForPage(_ input: Operations.pollDataForPage.Input) async throws -> Operations.pollDataForPage.Output { + try await client.send( + input: input, + forOperation: Operations.pollDataForPage.id, + serializer: { input in + let path = try converter.renderedPath( + template: "/sitemaps/{}/{}", + parameters: [ + input.path.sitemapname, + input.path.pageid + ] + ) + var request: HTTPTypes.HTTPRequest = .init( + soar_path: path, + method: .get + ) + suppressMutabilityWarning(&request) + try converter.setHeaderFieldAsURI( + in: &request.headerFields, + name: "Accept-Language", + value: input.headers.Accept_hyphen_Language + ) + try converter.setHeaderFieldAsURI( + in: &request.headerFields, + name: "X-Atmosphere-Transport", + value: input.headers.X_hyphen_Atmosphere_hyphen_Transport + ) + try converter.setQueryItemAsURI( + in: &request, + style: .form, + explode: true, + name: "subscriptionid", + value: input.query.subscriptionid + ) + try converter.setQueryItemAsURI( + in: &request, + style: .form, + explode: true, + name: "includeHidden", + value: input.query.includeHidden + ) + converter.setAcceptHeader( + in: &request.headerFields, + contentTypes: input.headers.accept + ) + return (request, nil) + }, + deserializer: { response, responseBody in + switch response.status.code { + case 200: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Operations.pollDataForPage.Output.Ok.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Components.Schemas.PageDTO.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .ok(.init(body: body)) + case 400: + return .badRequest(.init()) + case 404: + return .notFound(.init()) + default: + return .undocumented( + statusCode: response.status.code, + .init( + headerFields: response.headerFields, + body: responseBody + ) + ) + } + } + ) + } + + /// Polls the data for a whole sitemap. Not recommended due to potentially high traffic. + /// + /// - Remark: HTTP `GET /sitemaps/{sitemapname}/*`. + /// - Remark: Generated from `#/paths//sitemaps/{sitemapname}/*/get(pollDataForSitemap)`. + func pollDataForSitemap(_ input: Operations.pollDataForSitemap.Input) async throws -> Operations.pollDataForSitemap.Output { + try await client.send( + input: input, + forOperation: Operations.pollDataForSitemap.id, + serializer: { input in + let path = try converter.renderedPath( + template: "/sitemaps/{}/*", + parameters: [ + input.path.sitemapname + ] + ) + var request: HTTPTypes.HTTPRequest = .init( + soar_path: path, + method: .get + ) + suppressMutabilityWarning(&request) + try converter.setHeaderFieldAsURI( + in: &request.headerFields, + name: "Accept-Language", + value: input.headers.Accept_hyphen_Language + ) + try converter.setHeaderFieldAsURI( + in: &request.headerFields, + name: "X-Atmosphere-Transport", + value: input.headers.X_hyphen_Atmosphere_hyphen_Transport + ) + try converter.setQueryItemAsURI( + in: &request, + style: .form, + explode: true, + name: "subscriptionid", + value: input.query.subscriptionid + ) + try converter.setQueryItemAsURI( + in: &request, + style: .form, + explode: true, + name: "includeHidden", + value: input.query.includeHidden + ) + converter.setAcceptHeader( + in: &request.headerFields, + contentTypes: input.headers.accept + ) + return (request, nil) + }, + deserializer: { response, responseBody in + switch response.status.code { + case 200: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Operations.pollDataForSitemap.Output.Ok.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Components.Schemas.SitemapDTO.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .ok(.init(body: body)) + case 400: + return .badRequest(.init()) + case 404: + return .notFound(.init()) + default: + return .undocumented( + statusCode: response.status.code, + .init( + headerFields: response.headerFields, + body: responseBody + ) + ) + } + } + ) + } + + /// Get sitemap by name. + /// + /// - Remark: HTTP `GET /sitemaps/{sitemapname}`. + /// - Remark: Generated from `#/paths//sitemaps/{sitemapname}/get(getSitemapByName)`. + func getSitemapByName(_ input: Operations.getSitemapByName.Input) async throws -> Operations.getSitemapByName.Output { + try await client.send( + input: input, + forOperation: Operations.getSitemapByName.id, + serializer: { input in + let path = try converter.renderedPath( + template: "/sitemaps/{}", + parameters: [ + input.path.sitemapname + ] + ) + var request: HTTPTypes.HTTPRequest = .init( + soar_path: path, + method: .get + ) + suppressMutabilityWarning(&request) + try converter.setHeaderFieldAsURI( + in: &request.headerFields, + name: "Accept-Language", + value: input.headers.Accept_hyphen_Language + ) + try converter.setQueryItemAsURI( + in: &request, + style: .form, + explode: true, + name: "type", + value: input.query._type + ) + try converter.setQueryItemAsURI( + in: &request, + style: .form, + explode: true, + name: "jsoncallback", + value: input.query.jsoncallback + ) + try converter.setQueryItemAsURI( + in: &request, + style: .form, + explode: true, + name: "includeHidden", + value: input.query.includeHidden + ) + converter.setAcceptHeader( + in: &request.headerFields, + contentTypes: input.headers.accept + ) + return (request, nil) + }, + deserializer: { response, responseBody in + switch response.status.code { + case 200: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Operations.getSitemapByName.Output.Ok.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Components.Schemas.SitemapDTO.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .ok(.init(body: body)) + default: + return .undocumented( + statusCode: response.status.code, + .init( + headerFields: response.headerFields, + body: responseBody + ) + ) + } + } + ) + } + + /// Get sitemap events for a whole sitemap. Not recommended due to potentially high traffic. + /// + /// - Remark: HTTP `GET /sitemaps/events/{subscriptionid}/*`. + /// - Remark: Generated from `#/paths//sitemaps/events/{subscriptionid}/*/get(getSitemapEvents)`. + func getSitemapEvents(_ input: Operations.getSitemapEvents.Input) async throws -> Operations.getSitemapEvents.Output { + try await client.send( + input: input, + forOperation: Operations.getSitemapEvents.id, + serializer: { input in + let path = try converter.renderedPath( + template: "/sitemaps/events/{}/*", + parameters: [ + input.path.subscriptionid + ] + ) + var request: HTTPTypes.HTTPRequest = .init( + soar_path: path, + method: .get + ) + suppressMutabilityWarning(&request) + try converter.setQueryItemAsURI( + in: &request, + style: .form, + explode: true, + name: "sitemap", + value: input.query.sitemap + ) + return (request, nil) + }, + deserializer: { response, responseBody in + switch response.status.code { + case 200: + .ok(.init()) + case 400: + .badRequest(.init()) + case 404: + .notFound(.init()) + default: + .undocumented( + statusCode: response.status.code, + .init( + headerFields: response.headerFields, + body: responseBody + ) + ) + } + } + ) + } + + /// Get sitemap events. + /// + /// - Remark: HTTP `GET /sitemaps/events/{subscriptionid}`. + /// - Remark: Generated from `#/paths//sitemaps/events/{subscriptionid}/get(getSitemapEvents_1)`. + func getSitemapEvents_1(_ input: Operations.getSitemapEvents_1.Input) async throws -> Operations.getSitemapEvents_1.Output { + try await client.send( + input: input, + forOperation: Operations.getSitemapEvents_1.id, + serializer: { input in + let path = try converter.renderedPath( + template: "/sitemaps/events/{}", + parameters: [ + input.path.subscriptionid + ] + ) + var request: HTTPTypes.HTTPRequest = .init( + soar_path: path, + method: .get + ) + suppressMutabilityWarning(&request) + try converter.setQueryItemAsURI( + in: &request, + style: .form, + explode: true, + name: "sitemap", + value: input.query.sitemap + ) + try converter.setQueryItemAsURI( + in: &request, + style: .form, + explode: true, + name: "pageid", + value: input.query.pageid + ) + converter.setAcceptHeader( + in: &request.headerFields, + contentTypes: input.headers.accept + ) + return (request, nil) + }, + deserializer: { response, responseBody in + switch response.status.code { + case 200: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Operations.getSitemapEvents_1.Output.Ok.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "text/event-stream", + "application/json" + ] + ) + switch chosenContentType { + case "text/event-stream": + body = try converter.getResponseBodyAsBinary( + OpenAPIRuntime.HTTPBody.self, + from: responseBody, + transforming: { value in + .text_event_hyphen_stream(value) + } + ) + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Components.Schemas.SitemapWidgetEvent.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .ok(.init(body: body)) + case 400: + return .badRequest(.init()) + case 404: + return .notFound(.init()) + default: + return .undocumented( + statusCode: response.status.code, + .init( + headerFields: response.headerFields, + body: responseBody + ) + ) + } + } + ) + } + + /// Get all available sitemaps. + /// + /// - Remark: HTTP `GET /sitemaps`. + /// - Remark: Generated from `#/paths//sitemaps/get(getSitemaps)`. + func getSitemaps(_ input: Operations.getSitemaps.Input) async throws -> Operations.getSitemaps.Output { + try await client.send( + input: input, + forOperation: Operations.getSitemaps.id, + serializer: { input in + let path = try converter.renderedPath( + template: "/sitemaps", + parameters: [] + ) + var request: HTTPTypes.HTTPRequest = .init( + soar_path: path, + method: .get + ) + suppressMutabilityWarning(&request) + converter.setAcceptHeader( + in: &request.headerFields, + contentTypes: input.headers.accept + ) + return (request, nil) + }, + deserializer: { response, responseBody in + switch response.status.code { + case 200: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Operations.getSitemaps.Output.Ok.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + [Components.Schemas.SitemapDTO].self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .ok(.init(body: body)) + default: + return .undocumented( + statusCode: response.status.code, + .init( + headerFields: response.headerFields, + body: responseBody + ) + ) + } + } + ) + } + + /// Get all registered UI components in the specified namespace. + /// + /// - Remark: HTTP `GET /ui/components/{namespace}`. + /// - Remark: Generated from `#/paths//ui/components/{namespace}/get(getRegisteredUIComponentsInNamespace)`. + func getRegisteredUIComponentsInNamespace(_ input: Operations.getRegisteredUIComponentsInNamespace.Input) async throws -> Operations.getRegisteredUIComponentsInNamespace.Output { + try await client.send( + input: input, + forOperation: Operations.getRegisteredUIComponentsInNamespace.id, + serializer: { input in + let path = try converter.renderedPath( + template: "/ui/components/{}", + parameters: [ + input.path.namespace + ] + ) + var request: HTTPTypes.HTTPRequest = .init( + soar_path: path, + method: .get + ) + suppressMutabilityWarning(&request) + try converter.setQueryItemAsURI( + in: &request, + style: .form, + explode: true, + name: "summary", + value: input.query.summary + ) + converter.setAcceptHeader( + in: &request.headerFields, + contentTypes: input.headers.accept + ) + return (request, nil) + }, + deserializer: { response, responseBody in + switch response.status.code { + case 200: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Operations.getRegisteredUIComponentsInNamespace.Output.Ok.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + [Components.Schemas.RootUIComponent].self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .ok(.init(body: body)) + default: + return .undocumented( + statusCode: response.status.code, + .init( + headerFields: response.headerFields, + body: responseBody + ) + ) + } + } + ) + } + + /// Add a UI component in the specified namespace. + /// + /// - Remark: HTTP `POST /ui/components/{namespace}`. + /// - Remark: Generated from `#/paths//ui/components/{namespace}/post(addUIComponentToNamespace)`. + func addUIComponentToNamespace(_ input: Operations.addUIComponentToNamespace.Input) async throws -> Operations.addUIComponentToNamespace.Output { + try await client.send( + input: input, + forOperation: Operations.addUIComponentToNamespace.id, + serializer: { input in + let path = try converter.renderedPath( + template: "/ui/components/{}", + parameters: [ + input.path.namespace + ] + ) + var request: HTTPTypes.HTTPRequest = .init( + soar_path: path, + method: .post + ) + suppressMutabilityWarning(&request) + converter.setAcceptHeader( + in: &request.headerFields, + contentTypes: input.headers.accept + ) + let body: OpenAPIRuntime.HTTPBody? = switch input.body { + case .none: + nil + case let .json(value): + try converter.setOptionalRequestBodyAsJSON( + value, + headerFields: &request.headerFields, + contentType: "application/json; charset=utf-8" + ) + } + return (request, body) + }, + deserializer: { response, responseBody in + switch response.status.code { + case 200: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Operations.addUIComponentToNamespace.Output.Ok.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Components.Schemas.RootUIComponent.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .ok(.init(body: body)) + default: + return .undocumented( + statusCode: response.status.code, + .init( + headerFields: response.headerFields, + body: responseBody + ) + ) + } + } + ) + } + + /// Get a specific UI component in the specified namespace. + /// + /// - Remark: HTTP `GET /ui/components/{namespace}/{componentUID}`. + /// - Remark: Generated from `#/paths//ui/components/{namespace}/{componentUID}/get(getUIComponentInNamespace)`. + func getUIComponentInNamespace(_ input: Operations.getUIComponentInNamespace.Input) async throws -> Operations.getUIComponentInNamespace.Output { + try await client.send( + input: input, + forOperation: Operations.getUIComponentInNamespace.id, + serializer: { input in + let path = try converter.renderedPath( + template: "/ui/components/{}/{}", + parameters: [ + input.path.namespace, + input.path.componentUID + ] + ) + var request: HTTPTypes.HTTPRequest = .init( + soar_path: path, + method: .get + ) + suppressMutabilityWarning(&request) + converter.setAcceptHeader( + in: &request.headerFields, + contentTypes: input.headers.accept + ) + return (request, nil) + }, + deserializer: { response, responseBody in + switch response.status.code { + case 200: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Operations.getUIComponentInNamespace.Output.Ok.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Components.Schemas.RootUIComponent.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .ok(.init(body: body)) + case 404: + return .notFound(.init()) + default: + return .undocumented( + statusCode: response.status.code, + .init( + headerFields: response.headerFields, + body: responseBody + ) + ) + } + } + ) + } + + /// Update a specific UI component in the specified namespace. + /// + /// - Remark: HTTP `PUT /ui/components/{namespace}/{componentUID}`. + /// - Remark: Generated from `#/paths//ui/components/{namespace}/{componentUID}/put(updateUIComponentInNamespace)`. + func updateUIComponentInNamespace(_ input: Operations.updateUIComponentInNamespace.Input) async throws -> Operations.updateUIComponentInNamespace.Output { + try await client.send( + input: input, + forOperation: Operations.updateUIComponentInNamespace.id, + serializer: { input in + let path = try converter.renderedPath( + template: "/ui/components/{}/{}", + parameters: [ + input.path.namespace, + input.path.componentUID + ] + ) + var request: HTTPTypes.HTTPRequest = .init( + soar_path: path, + method: .put + ) + suppressMutabilityWarning(&request) + converter.setAcceptHeader( + in: &request.headerFields, + contentTypes: input.headers.accept + ) + let body: OpenAPIRuntime.HTTPBody? = switch input.body { + case .none: + nil + case let .json(value): + try converter.setOptionalRequestBodyAsJSON( + value, + headerFields: &request.headerFields, + contentType: "application/json; charset=utf-8" + ) + } + return (request, body) + }, + deserializer: { response, responseBody in + switch response.status.code { + case 200: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Operations.updateUIComponentInNamespace.Output.Ok.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Components.Schemas.RootUIComponent.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .ok(.init(body: body)) + case 404: + return .notFound(.init()) + default: + return .undocumented( + statusCode: response.status.code, + .init( + headerFields: response.headerFields, + body: responseBody + ) + ) + } + } + ) + } + + /// Remove a specific UI component in the specified namespace. + /// + /// - Remark: HTTP `DELETE /ui/components/{namespace}/{componentUID}`. + /// - Remark: Generated from `#/paths//ui/components/{namespace}/{componentUID}/delete(removeUIComponentFromNamespace)`. + func removeUIComponentFromNamespace(_ input: Operations.removeUIComponentFromNamespace.Input) async throws -> Operations.removeUIComponentFromNamespace.Output { + try await client.send( + input: input, + forOperation: Operations.removeUIComponentFromNamespace.id, + serializer: { input in + let path = try converter.renderedPath( + template: "/ui/components/{}/{}", + parameters: [ + input.path.namespace, + input.path.componentUID + ] + ) + var request: HTTPTypes.HTTPRequest = .init( + soar_path: path, + method: .delete + ) + suppressMutabilityWarning(&request) + return (request, nil) + }, + deserializer: { response, responseBody in + switch response.status.code { + case 200: + .ok(.init()) + case 404: + .notFound(.init()) + default: + .undocumented( + statusCode: response.status.code, + .init( + headerFields: response.headerFields, + body: responseBody + ) + ) + } + } + ) + } + + /// Get all registered UI tiles. + /// + /// - Remark: HTTP `GET /ui/tiles`. + /// - Remark: Generated from `#/paths//ui/tiles/get(getUITiles)`. + func getUITiles(_ input: Operations.getUITiles.Input) async throws -> Operations.getUITiles.Output { + try await client.send( + input: input, + forOperation: Operations.getUITiles.id, + serializer: { input in + let path = try converter.renderedPath( + template: "/ui/tiles", + parameters: [] + ) + var request: HTTPTypes.HTTPRequest = .init( + soar_path: path, + method: .get + ) + suppressMutabilityWarning(&request) + converter.setAcceptHeader( + in: &request.headerFields, + contentTypes: input.headers.accept + ) + return (request, nil) + }, + deserializer: { response, responseBody in + switch response.status.code { + case 200: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Operations.getUITiles.Output.Ok.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + [Components.Schemas.TileDTO].self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .ok(.init(body: body)) + default: + return .undocumented( + statusCode: response.status.code, + .init( + headerFields: response.headerFields, + body: responseBody + ) + ) + } + } + ) + } +} diff --git a/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Types.swift b/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Types.swift new file mode 100644 index 000000000..23afee90e --- /dev/null +++ b/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Types.swift @@ -0,0 +1,7295 @@ +// 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 + +// Generated by swift-openapi-generator, do not modify. +@_spi(Generated) import OpenAPIRuntime +#if os(Linux) +@preconcurrency import struct Foundation.Data +@preconcurrency import struct Foundation.Date +@preconcurrency import struct Foundation.URL +#else +import struct Foundation.Data +import struct Foundation.Date +import struct Foundation.URL +#endif +/// A type that performs HTTP operations defined by the OpenAPI document. +protocol APIProtocol: Sendable { + /// Adds a new member to a group item. + /// + /// - Remark: HTTP `PUT /items/{itemName}/members/{memberItemName}`. + /// - Remark: Generated from `#/paths//items/{itemName}/members/{memberItemName}/put(addMemberToGroupItem)`. + func addMemberToGroupItem(_ input: Operations.addMemberToGroupItem.Input) async throws -> Operations.addMemberToGroupItem.Output + /// Removes an existing member from a group item. + /// + /// - Remark: HTTP `DELETE /items/{itemName}/members/{memberItemName}`. + /// - Remark: Generated from `#/paths//items/{itemName}/members/{memberItemName}/delete(removeMemberFromGroupItem)`. + func removeMemberFromGroupItem(_ input: Operations.removeMemberFromGroupItem.Input) async throws -> Operations.removeMemberFromGroupItem.Output + /// Adds metadata to an item. + /// + /// - Remark: HTTP `PUT /items/{itemname}/metadata/{namespace}`. + /// - Remark: Generated from `#/paths//items/{itemname}/metadata/{namespace}/put(addMetadataToItem)`. + func addMetadataToItem(_ input: Operations.addMetadataToItem.Input) async throws -> Operations.addMetadataToItem.Output + /// Removes metadata from an item. + /// + /// - Remark: HTTP `DELETE /items/{itemname}/metadata/{namespace}`. + /// - Remark: Generated from `#/paths//items/{itemname}/metadata/{namespace}/delete(removeMetadataFromItem)`. + func removeMetadataFromItem(_ input: Operations.removeMetadataFromItem.Input) async throws -> Operations.removeMetadataFromItem.Output + /// Adds a tag to an item. + /// + /// - Remark: HTTP `PUT /items/{itemname}/tags/{tag}`. + /// - Remark: Generated from `#/paths//items/{itemname}/tags/{tag}/put(addTagToItem)`. + func addTagToItem(_ input: Operations.addTagToItem.Input) async throws -> Operations.addTagToItem.Output + /// Removes a tag from an item. + /// + /// - Remark: HTTP `DELETE /items/{itemname}/tags/{tag}`. + /// - Remark: Generated from `#/paths//items/{itemname}/tags/{tag}/delete(removeTagFromItem)`. + func removeTagFromItem(_ input: Operations.removeTagFromItem.Input) async throws -> Operations.removeTagFromItem.Output + /// Gets a single item. + /// + /// - Remark: HTTP `GET /items/{itemname}`. + /// - Remark: Generated from `#/paths//items/{itemname}/get(getItemByName)`. + func getItemByName(_ input: Operations.getItemByName.Input) async throws -> Operations.getItemByName.Output + /// Sends a command to an item. + /// + /// - Remark: HTTP `POST /items/{itemname}`. + /// - Remark: Generated from `#/paths//items/{itemname}/post(sendItemCommand)`. + func sendItemCommand(_ input: Operations.sendItemCommand.Input) async throws -> Operations.sendItemCommand.Output + /// Adds a new item to the registry or updates the existing item. + /// + /// - Remark: HTTP `PUT /items/{itemname}`. + /// - Remark: Generated from `#/paths//items/{itemname}/put(addOrUpdateItemInRegistry)`. + func addOrUpdateItemInRegistry(_ input: Operations.addOrUpdateItemInRegistry.Input) async throws -> Operations.addOrUpdateItemInRegistry.Output + /// Removes an item from the registry. + /// + /// - Remark: HTTP `DELETE /items/{itemname}`. + /// - Remark: Generated from `#/paths//items/{itemname}/delete(removeItemFromRegistry)`. + func removeItemFromRegistry(_ input: Operations.removeItemFromRegistry.Input) async throws -> Operations.removeItemFromRegistry.Output + /// Get all available items. + /// + /// - Remark: HTTP `GET /items`. + /// - Remark: Generated from `#/paths//items/get(getItems)`. + func getItems(_ input: Operations.getItems.Input) async throws -> Operations.getItems.Output + /// Adds a list of items to the registry or updates the existing items. + /// + /// - Remark: HTTP `PUT /items`. + /// - Remark: Generated from `#/paths//items/put(addOrUpdateItemsInRegistry)`. + func addOrUpdateItemsInRegistry(_ input: Operations.addOrUpdateItemsInRegistry.Input) async throws -> Operations.addOrUpdateItemsInRegistry.Output + /// Gets the state of an item. + /// + /// - Remark: HTTP `GET /items/{itemname}/state`. + /// - Remark: Generated from `#/paths//items/{itemname}/state/get(getItemState_1)`. + func getItemState_1(_ input: Operations.getItemState_1.Input) async throws -> Operations.getItemState_1.Output + /// Updates the state of an item. + /// + /// - Remark: HTTP `PUT /items/{itemname}/state`. + /// - Remark: Generated from `#/paths//items/{itemname}/state/put(updateItemState)`. + func updateItemState(_ input: Operations.updateItemState.Input) async throws -> Operations.updateItemState.Output + /// Gets the namespace of an item. + /// + /// - Remark: HTTP `GET /items/{itemname}/metadata/namespaces`. + /// - Remark: Generated from `#/paths//items/{itemname}/metadata/namespaces/get(getItemNamespaces)`. + func getItemNamespaces(_ input: Operations.getItemNamespaces.Input) async throws -> Operations.getItemNamespaces.Output + /// Gets the item which defines the requested semantics of an item. + /// + /// - Remark: HTTP `GET /items/{itemName}/semantic/{semanticClass}`. + /// - Remark: Generated from `#/paths//items/{itemName}/semantic/{semanticClass}/get(getSemanticItem)`. + func getSemanticItem(_ input: Operations.getSemanticItem.Input) async throws -> Operations.getSemanticItem.Output + /// Remove unused/orphaned metadata. + /// + /// - Remark: HTTP `POST /items/metadata/purge`. + /// - Remark: Generated from `#/paths//items/metadata/purge/post(purgeDatabase)`. + func purgeDatabase(_ input: Operations.purgeDatabase.Input) async throws -> Operations.purgeDatabase.Output + /// Creates a sitemap event subscription. + /// + /// - Remark: HTTP `POST /sitemaps/events/subscribe`. + /// - Remark: Generated from `#/paths//sitemaps/events/subscribe/post(createSitemapEventSubscription)`. + func createSitemapEventSubscription(_ input: Operations.createSitemapEventSubscription.Input) async throws -> Operations.createSitemapEventSubscription.Output + /// Polls the data for one page of a sitemap. + /// + /// - Remark: HTTP `GET /sitemaps/{sitemapname}/{pageid}`. + /// - Remark: Generated from `#/paths//sitemaps/{sitemapname}/{pageid}/get(pollDataForPage)`. + func pollDataForPage(_ input: Operations.pollDataForPage.Input) async throws -> Operations.pollDataForPage.Output + /// Polls the data for a whole sitemap. Not recommended due to potentially high traffic. + /// + /// - Remark: HTTP `GET /sitemaps/{sitemapname}/*`. + /// - Remark: Generated from `#/paths//sitemaps/{sitemapname}/*/get(pollDataForSitemap)`. + func pollDataForSitemap(_ input: Operations.pollDataForSitemap.Input) async throws -> Operations.pollDataForSitemap.Output + /// Get sitemap by name. + /// + /// - Remark: HTTP `GET /sitemaps/{sitemapname}`. + /// - Remark: Generated from `#/paths//sitemaps/{sitemapname}/get(getSitemapByName)`. + func getSitemapByName(_ input: Operations.getSitemapByName.Input) async throws -> Operations.getSitemapByName.Output + /// Get sitemap events for a whole sitemap. Not recommended due to potentially high traffic. + /// + /// - Remark: HTTP `GET /sitemaps/events/{subscriptionid}/*`. + /// - Remark: Generated from `#/paths//sitemaps/events/{subscriptionid}/*/get(getSitemapEvents)`. + func getSitemapEvents(_ input: Operations.getSitemapEvents.Input) async throws -> Operations.getSitemapEvents.Output + /// Get sitemap events. + /// + /// - Remark: HTTP `GET /sitemaps/events/{subscriptionid}`. + /// - Remark: Generated from `#/paths//sitemaps/events/{subscriptionid}/get(getSitemapEvents_1)`. + func getSitemapEvents_1(_ input: Operations.getSitemapEvents_1.Input) async throws -> Operations.getSitemapEvents_1.Output + /// Get all available sitemaps. + /// + /// - Remark: HTTP `GET /sitemaps`. + /// - Remark: Generated from `#/paths//sitemaps/get(getSitemaps)`. + func getSitemaps(_ input: Operations.getSitemaps.Input) async throws -> Operations.getSitemaps.Output + /// Get all registered UI components in the specified namespace. + /// + /// - Remark: HTTP `GET /ui/components/{namespace}`. + /// - Remark: Generated from `#/paths//ui/components/{namespace}/get(getRegisteredUIComponentsInNamespace)`. + func getRegisteredUIComponentsInNamespace(_ input: Operations.getRegisteredUIComponentsInNamespace.Input) async throws -> Operations.getRegisteredUIComponentsInNamespace.Output + /// Add a UI component in the specified namespace. + /// + /// - Remark: HTTP `POST /ui/components/{namespace}`. + /// - Remark: Generated from `#/paths//ui/components/{namespace}/post(addUIComponentToNamespace)`. + func addUIComponentToNamespace(_ input: Operations.addUIComponentToNamespace.Input) async throws -> Operations.addUIComponentToNamespace.Output + /// Get a specific UI component in the specified namespace. + /// + /// - Remark: HTTP `GET /ui/components/{namespace}/{componentUID}`. + /// - Remark: Generated from `#/paths//ui/components/{namespace}/{componentUID}/get(getUIComponentInNamespace)`. + func getUIComponentInNamespace(_ input: Operations.getUIComponentInNamespace.Input) async throws -> Operations.getUIComponentInNamespace.Output + /// Update a specific UI component in the specified namespace. + /// + /// - Remark: HTTP `PUT /ui/components/{namespace}/{componentUID}`. + /// - Remark: Generated from `#/paths//ui/components/{namespace}/{componentUID}/put(updateUIComponentInNamespace)`. + func updateUIComponentInNamespace(_ input: Operations.updateUIComponentInNamespace.Input) async throws -> Operations.updateUIComponentInNamespace.Output + /// Remove a specific UI component in the specified namespace. + /// + /// - Remark: HTTP `DELETE /ui/components/{namespace}/{componentUID}`. + /// - Remark: Generated from `#/paths//ui/components/{namespace}/{componentUID}/delete(removeUIComponentFromNamespace)`. + func removeUIComponentFromNamespace(_ input: Operations.removeUIComponentFromNamespace.Input) async throws -> Operations.removeUIComponentFromNamespace.Output + /// Get all registered UI tiles. + /// + /// - Remark: HTTP `GET /ui/tiles`. + /// - Remark: Generated from `#/paths//ui/tiles/get(getUITiles)`. + func getUITiles(_ input: Operations.getUITiles.Input) async throws -> Operations.getUITiles.Output +} + +/// Convenience overloads for operation inputs. +extension APIProtocol { + /// Adds a new member to a group item. + /// + /// - Remark: HTTP `PUT /items/{itemName}/members/{memberItemName}`. + /// - Remark: Generated from `#/paths//items/{itemName}/members/{memberItemName}/put(addMemberToGroupItem)`. + func addMemberToGroupItem(path: Operations.addMemberToGroupItem.Input.Path) async throws -> Operations.addMemberToGroupItem.Output { + try await addMemberToGroupItem(Operations.addMemberToGroupItem.Input(path: path)) + } + + /// Removes an existing member from a group item. + /// + /// - Remark: HTTP `DELETE /items/{itemName}/members/{memberItemName}`. + /// - Remark: Generated from `#/paths//items/{itemName}/members/{memberItemName}/delete(removeMemberFromGroupItem)`. + func removeMemberFromGroupItem(path: Operations.removeMemberFromGroupItem.Input.Path) async throws -> Operations.removeMemberFromGroupItem.Output { + try await removeMemberFromGroupItem(Operations.removeMemberFromGroupItem.Input(path: path)) + } + + /// Adds metadata to an item. + /// + /// - Remark: HTTP `PUT /items/{itemname}/metadata/{namespace}`. + /// - Remark: Generated from `#/paths//items/{itemname}/metadata/{namespace}/put(addMetadataToItem)`. + func addMetadataToItem(path: Operations.addMetadataToItem.Input.Path, + body: Operations.addMetadataToItem.Input.Body) async throws -> Operations.addMetadataToItem.Output { + try await addMetadataToItem(Operations.addMetadataToItem.Input( + path: path, + body: body + )) + } + + /// Removes metadata from an item. + /// + /// - Remark: HTTP `DELETE /items/{itemname}/metadata/{namespace}`. + /// - Remark: Generated from `#/paths//items/{itemname}/metadata/{namespace}/delete(removeMetadataFromItem)`. + func removeMetadataFromItem(path: Operations.removeMetadataFromItem.Input.Path) async throws -> Operations.removeMetadataFromItem.Output { + try await removeMetadataFromItem(Operations.removeMetadataFromItem.Input(path: path)) + } + + /// Adds a tag to an item. + /// + /// - Remark: HTTP `PUT /items/{itemname}/tags/{tag}`. + /// - Remark: Generated from `#/paths//items/{itemname}/tags/{tag}/put(addTagToItem)`. + func addTagToItem(path: Operations.addTagToItem.Input.Path) async throws -> Operations.addTagToItem.Output { + try await addTagToItem(Operations.addTagToItem.Input(path: path)) + } + + /// Removes a tag from an item. + /// + /// - Remark: HTTP `DELETE /items/{itemname}/tags/{tag}`. + /// - Remark: Generated from `#/paths//items/{itemname}/tags/{tag}/delete(removeTagFromItem)`. + func removeTagFromItem(path: Operations.removeTagFromItem.Input.Path) async throws -> Operations.removeTagFromItem.Output { + try await removeTagFromItem(Operations.removeTagFromItem.Input(path: path)) + } + + /// Gets a single item. + /// + /// - Remark: HTTP `GET /items/{itemname}`. + /// - Remark: Generated from `#/paths//items/{itemname}/get(getItemByName)`. + func getItemByName(path: Operations.getItemByName.Input.Path, + query: Operations.getItemByName.Input.Query = .init(), + headers: Operations.getItemByName.Input.Headers = .init()) async throws -> Operations.getItemByName.Output { + try await getItemByName(Operations.getItemByName.Input( + path: path, + query: query, + headers: headers + )) + } + + /// Sends a command to an item. + /// + /// - Remark: HTTP `POST /items/{itemname}`. + /// - Remark: Generated from `#/paths//items/{itemname}/post(sendItemCommand)`. + func sendItemCommand(path: Operations.sendItemCommand.Input.Path, + body: Operations.sendItemCommand.Input.Body) async throws -> Operations.sendItemCommand.Output { + try await sendItemCommand(Operations.sendItemCommand.Input( + path: path, + body: body + )) + } + + /// Adds a new item to the registry or updates the existing item. + /// + /// - Remark: HTTP `PUT /items/{itemname}`. + /// - Remark: Generated from `#/paths//items/{itemname}/put(addOrUpdateItemInRegistry)`. + func addOrUpdateItemInRegistry(path: Operations.addOrUpdateItemInRegistry.Input.Path, + headers: Operations.addOrUpdateItemInRegistry.Input.Headers = .init(), + body: Operations.addOrUpdateItemInRegistry.Input.Body) async throws -> Operations.addOrUpdateItemInRegistry.Output { + try await addOrUpdateItemInRegistry(Operations.addOrUpdateItemInRegistry.Input( + path: path, + headers: headers, + body: body + )) + } + + /// Removes an item from the registry. + /// + /// - Remark: HTTP `DELETE /items/{itemname}`. + /// - Remark: Generated from `#/paths//items/{itemname}/delete(removeItemFromRegistry)`. + func removeItemFromRegistry(path: Operations.removeItemFromRegistry.Input.Path) async throws -> Operations.removeItemFromRegistry.Output { + try await removeItemFromRegistry(Operations.removeItemFromRegistry.Input(path: path)) + } + + /// Get all available items. + /// + /// - Remark: HTTP `GET /items`. + /// - Remark: Generated from `#/paths//items/get(getItems)`. + func getItems(query: Operations.getItems.Input.Query = .init(), + headers: Operations.getItems.Input.Headers = .init()) async throws -> Operations.getItems.Output { + try await getItems(Operations.getItems.Input( + query: query, + headers: headers + )) + } + + /// Adds a list of items to the registry or updates the existing items. + /// + /// - Remark: HTTP `PUT /items`. + /// - Remark: Generated from `#/paths//items/put(addOrUpdateItemsInRegistry)`. + func addOrUpdateItemsInRegistry(headers: Operations.addOrUpdateItemsInRegistry.Input.Headers = .init(), + body: Operations.addOrUpdateItemsInRegistry.Input.Body) async throws -> Operations.addOrUpdateItemsInRegistry.Output { + try await addOrUpdateItemsInRegistry(Operations.addOrUpdateItemsInRegistry.Input( + headers: headers, + body: body + )) + } + + /// Gets the state of an item. + /// + /// - Remark: HTTP `GET /items/{itemname}/state`. + /// - Remark: Generated from `#/paths//items/{itemname}/state/get(getItemState_1)`. + func getItemState_1(path: Operations.getItemState_1.Input.Path, + headers: Operations.getItemState_1.Input.Headers = .init()) async throws -> Operations.getItemState_1.Output { + try await getItemState_1(Operations.getItemState_1.Input( + path: path, + headers: headers + )) + } + + /// Updates the state of an item. + /// + /// - Remark: HTTP `PUT /items/{itemname}/state`. + /// - Remark: Generated from `#/paths//items/{itemname}/state/put(updateItemState)`. + func updateItemState(path: Operations.updateItemState.Input.Path, + headers: Operations.updateItemState.Input.Headers = .init(), + body: Operations.updateItemState.Input.Body) async throws -> Operations.updateItemState.Output { + try await updateItemState(Operations.updateItemState.Input( + path: path, + headers: headers, + body: body + )) + } + + /// Gets the namespace of an item. + /// + /// - Remark: HTTP `GET /items/{itemname}/metadata/namespaces`. + /// - Remark: Generated from `#/paths//items/{itemname}/metadata/namespaces/get(getItemNamespaces)`. + func getItemNamespaces(path: Operations.getItemNamespaces.Input.Path, + headers: Operations.getItemNamespaces.Input.Headers = .init()) async throws -> Operations.getItemNamespaces.Output { + try await getItemNamespaces(Operations.getItemNamespaces.Input( + path: path, + headers: headers + )) + } + + /// Gets the item which defines the requested semantics of an item. + /// + /// - Remark: HTTP `GET /items/{itemName}/semantic/{semanticClass}`. + /// - Remark: Generated from `#/paths//items/{itemName}/semantic/{semanticClass}/get(getSemanticItem)`. + func getSemanticItem(path: Operations.getSemanticItem.Input.Path, + headers: Operations.getSemanticItem.Input.Headers = .init()) async throws -> Operations.getSemanticItem.Output { + try await getSemanticItem(Operations.getSemanticItem.Input( + path: path, + headers: headers + )) + } + + /// Remove unused/orphaned metadata. + /// + /// - Remark: HTTP `POST /items/metadata/purge`. + /// - Remark: Generated from `#/paths//items/metadata/purge/post(purgeDatabase)`. + func purgeDatabase() async throws -> Operations.purgeDatabase.Output { + try await purgeDatabase(Operations.purgeDatabase.Input()) + } + + /// Creates a sitemap event subscription. + /// + /// - Remark: HTTP `POST /sitemaps/events/subscribe`. + /// - Remark: Generated from `#/paths//sitemaps/events/subscribe/post(createSitemapEventSubscription)`. + func createSitemapEventSubscription(headers: Operations.createSitemapEventSubscription.Input.Headers = .init()) async throws -> Operations.createSitemapEventSubscription.Output { + try await createSitemapEventSubscription(Operations.createSitemapEventSubscription.Input(headers: headers)) + } + + /// Polls the data for one page of a sitemap. + /// + /// - Remark: HTTP `GET /sitemaps/{sitemapname}/{pageid}`. + /// - Remark: Generated from `#/paths//sitemaps/{sitemapname}/{pageid}/get(pollDataForPage)`. + func pollDataForPage(path: Operations.pollDataForPage.Input.Path, + query: Operations.pollDataForPage.Input.Query = .init(), + headers: Operations.pollDataForPage.Input.Headers = .init()) async throws -> Operations.pollDataForPage.Output { + try await pollDataForPage(Operations.pollDataForPage.Input( + path: path, + query: query, + headers: headers + )) + } + + /// Polls the data for a whole sitemap. Not recommended due to potentially high traffic. + /// + /// - Remark: HTTP `GET /sitemaps/{sitemapname}/*`. + /// - Remark: Generated from `#/paths//sitemaps/{sitemapname}/*/get(pollDataForSitemap)`. + func pollDataForSitemap(path: Operations.pollDataForSitemap.Input.Path, + query: Operations.pollDataForSitemap.Input.Query = .init(), + headers: Operations.pollDataForSitemap.Input.Headers = .init()) async throws -> Operations.pollDataForSitemap.Output { + try await pollDataForSitemap(Operations.pollDataForSitemap.Input( + path: path, + query: query, + headers: headers + )) + } + + /// Get sitemap by name. + /// + /// - Remark: HTTP `GET /sitemaps/{sitemapname}`. + /// - Remark: Generated from `#/paths//sitemaps/{sitemapname}/get(getSitemapByName)`. + func getSitemapByName(path: Operations.getSitemapByName.Input.Path, + query: Operations.getSitemapByName.Input.Query = .init(), + headers: Operations.getSitemapByName.Input.Headers = .init()) async throws -> Operations.getSitemapByName.Output { + try await getSitemapByName(Operations.getSitemapByName.Input( + path: path, + query: query, + headers: headers + )) + } + + /// Get sitemap events for a whole sitemap. Not recommended due to potentially high traffic. + /// + /// - Remark: HTTP `GET /sitemaps/events/{subscriptionid}/*`. + /// - Remark: Generated from `#/paths//sitemaps/events/{subscriptionid}/*/get(getSitemapEvents)`. + func getSitemapEvents(path: Operations.getSitemapEvents.Input.Path, + query: Operations.getSitemapEvents.Input.Query = .init()) async throws -> Operations.getSitemapEvents.Output { + try await getSitemapEvents(Operations.getSitemapEvents.Input( + path: path, + query: query + )) + } + + /// Get sitemap events. + /// + /// - Remark: HTTP `GET /sitemaps/events/{subscriptionid}`. + /// - Remark: Generated from `#/paths//sitemaps/events/{subscriptionid}/get(getSitemapEvents_1)`. + func getSitemapEvents_1(path: Operations.getSitemapEvents_1.Input.Path, + query: Operations.getSitemapEvents_1.Input.Query = .init(), + headers: Operations.getSitemapEvents_1.Input.Headers = .init()) async throws -> Operations.getSitemapEvents_1.Output { + try await getSitemapEvents_1(Operations.getSitemapEvents_1.Input( + path: path, + query: query, + headers: headers + )) + } + + /// Get all available sitemaps. + /// + /// - Remark: HTTP `GET /sitemaps`. + /// - Remark: Generated from `#/paths//sitemaps/get(getSitemaps)`. + func getSitemaps(headers: Operations.getSitemaps.Input.Headers = .init()) async throws -> Operations.getSitemaps.Output { + try await getSitemaps(Operations.getSitemaps.Input(headers: headers)) + } + + /// Get all registered UI components in the specified namespace. + /// + /// - Remark: HTTP `GET /ui/components/{namespace}`. + /// - Remark: Generated from `#/paths//ui/components/{namespace}/get(getRegisteredUIComponentsInNamespace)`. + func getRegisteredUIComponentsInNamespace(path: Operations.getRegisteredUIComponentsInNamespace.Input.Path, + query: Operations.getRegisteredUIComponentsInNamespace.Input.Query = .init(), + headers: Operations.getRegisteredUIComponentsInNamespace.Input.Headers = .init()) async throws -> Operations.getRegisteredUIComponentsInNamespace.Output { + try await getRegisteredUIComponentsInNamespace(Operations.getRegisteredUIComponentsInNamespace.Input( + path: path, + query: query, + headers: headers + )) + } + + /// Add a UI component in the specified namespace. + /// + /// - Remark: HTTP `POST /ui/components/{namespace}`. + /// - Remark: Generated from `#/paths//ui/components/{namespace}/post(addUIComponentToNamespace)`. + func addUIComponentToNamespace(path: Operations.addUIComponentToNamespace.Input.Path, + headers: Operations.addUIComponentToNamespace.Input.Headers = .init(), + body: Operations.addUIComponentToNamespace.Input.Body? = nil) async throws -> Operations.addUIComponentToNamespace.Output { + try await addUIComponentToNamespace(Operations.addUIComponentToNamespace.Input( + path: path, + headers: headers, + body: body + )) + } + + /// Get a specific UI component in the specified namespace. + /// + /// - Remark: HTTP `GET /ui/components/{namespace}/{componentUID}`. + /// - Remark: Generated from `#/paths//ui/components/{namespace}/{componentUID}/get(getUIComponentInNamespace)`. + func getUIComponentInNamespace(path: Operations.getUIComponentInNamespace.Input.Path, + headers: Operations.getUIComponentInNamespace.Input.Headers = .init()) async throws -> Operations.getUIComponentInNamespace.Output { + try await getUIComponentInNamespace(Operations.getUIComponentInNamespace.Input( + path: path, + headers: headers + )) + } + + /// Update a specific UI component in the specified namespace. + /// + /// - Remark: HTTP `PUT /ui/components/{namespace}/{componentUID}`. + /// - Remark: Generated from `#/paths//ui/components/{namespace}/{componentUID}/put(updateUIComponentInNamespace)`. + func updateUIComponentInNamespace(path: Operations.updateUIComponentInNamespace.Input.Path, + headers: Operations.updateUIComponentInNamespace.Input.Headers = .init(), + body: Operations.updateUIComponentInNamespace.Input.Body? = nil) async throws -> Operations.updateUIComponentInNamespace.Output { + try await updateUIComponentInNamespace(Operations.updateUIComponentInNamespace.Input( + path: path, + headers: headers, + body: body + )) + } + + /// Remove a specific UI component in the specified namespace. + /// + /// - Remark: HTTP `DELETE /ui/components/{namespace}/{componentUID}`. + /// - Remark: Generated from `#/paths//ui/components/{namespace}/{componentUID}/delete(removeUIComponentFromNamespace)`. + func removeUIComponentFromNamespace(path: Operations.removeUIComponentFromNamespace.Input.Path) async throws -> Operations.removeUIComponentFromNamespace.Output { + try await removeUIComponentFromNamespace(Operations.removeUIComponentFromNamespace.Input(path: path)) + } + + /// Get all registered UI tiles. + /// + /// - Remark: HTTP `GET /ui/tiles`. + /// - Remark: Generated from `#/paths//ui/tiles/get(getUITiles)`. + func getUITiles(headers: Operations.getUITiles.Input.Headers = .init()) async throws -> Operations.getUITiles.Output { + try await getUITiles(Operations.getUITiles.Input(headers: headers)) + } +} + +/// Server URLs defined in the OpenAPI document. +enum Servers { + static func server1() throws -> Foundation.URL { + try Foundation.URL( + validatingOpenAPIServerURL: "/rest", + variables: [] + ) + } +} + +/// Types generated from the components section of the OpenAPI document. +enum Components { + /// Types generated from the `#/components/schemas` section of the OpenAPI document. + enum Schemas { + /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterDTO`. + struct ConfigDescriptionParameterDTO: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterDTO/context`. + var context: Swift.String? + /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterDTO/defaultValue`. + var defaultValue: Swift.String? + /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterDTO/description`. + var description: Swift.String? + /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterDTO/label`. + var label: Swift.String? + /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterDTO/name`. + var name: Swift.String? + /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterDTO/required`. + var required: Swift.Bool? + /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterDTO/type`. + enum _typePayload: String, Codable, Hashable, Sendable, CaseIterable { + case TEXT + case INTEGER + case DECIMAL + case BOOLEAN + } + + /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterDTO/type`. + var _type: Components.Schemas.ConfigDescriptionParameterDTO._typePayload? + /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterDTO/min`. + var min: Swift.Double? + /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterDTO/max`. + var max: Swift.Double? + /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterDTO/stepsize`. + var stepsize: Swift.Double? + /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterDTO/pattern`. + var pattern: Swift.String? + /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterDTO/readOnly`. + var readOnly: Swift.Bool? + /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterDTO/multiple`. + var multiple: Swift.Bool? + /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterDTO/multipleLimit`. + var multipleLimit: Swift.Int32? + /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterDTO/groupName`. + var groupName: Swift.String? + /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterDTO/advanced`. + var advanced: Swift.Bool? + /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterDTO/verify`. + var verify: Swift.Bool? + /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterDTO/limitToOptions`. + var limitToOptions: Swift.Bool? + /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterDTO/unit`. + var unit: Swift.String? + /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterDTO/unitLabel`. + var unitLabel: Swift.String? + /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterDTO/options`. + var options: [Components.Schemas.ParameterOptionDTO]? + /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterDTO/filterCriteria`. + var filterCriteria: [Components.Schemas.FilterCriteriaDTO]? + /// Creates a new `ConfigDescriptionParameterDTO`. + /// + /// - Parameters: + /// - context: + /// - defaultValue: + /// - description: + /// - label: + /// - name: + /// - required: + /// - _type: + /// - min: + /// - max: + /// - stepsize: + /// - pattern: + /// - readOnly: + /// - multiple: + /// - multipleLimit: + /// - groupName: + /// - advanced: + /// - verify: + /// - limitToOptions: + /// - unit: + /// - unitLabel: + /// - options: + /// - filterCriteria: + init(context: Swift.String? = nil, + defaultValue: Swift.String? = nil, + description: Swift.String? = nil, + label: Swift.String? = nil, + name: Swift.String? = nil, + required: Swift.Bool? = nil, + _type: Components.Schemas.ConfigDescriptionParameterDTO._typePayload? = nil, + min: Swift.Double? = nil, + max: Swift.Double? = nil, + stepsize: Swift.Double? = nil, + pattern: Swift.String? = nil, + readOnly: Swift.Bool? = nil, + multiple: Swift.Bool? = nil, + multipleLimit: Swift.Int32? = nil, + groupName: Swift.String? = nil, + advanced: Swift.Bool? = nil, + verify: Swift.Bool? = nil, + limitToOptions: Swift.Bool? = nil, + unit: Swift.String? = nil, + unitLabel: Swift.String? = nil, + options: [Components.Schemas.ParameterOptionDTO]? = nil, + filterCriteria: [Components.Schemas.FilterCriteriaDTO]? = nil) { + self.context = context + self.defaultValue = defaultValue + self.description = description + self.label = label + self.name = name + self.required = required + self._type = _type + self.min = min + self.max = max + self.stepsize = stepsize + self.pattern = pattern + self.readOnly = readOnly + self.multiple = multiple + self.multipleLimit = multipleLimit + self.groupName = groupName + self.advanced = advanced + self.verify = verify + self.limitToOptions = limitToOptions + self.unit = unit + self.unitLabel = unitLabel + self.options = options + self.filterCriteria = filterCriteria + } + + enum CodingKeys: String, CodingKey { + case context + case defaultValue + case description + case label + case name + case required + case _type = "type" + case min + case max + case stepsize + case pattern + case readOnly + case multiple + case multipleLimit + case groupName + case advanced + case verify + case limitToOptions + case unit + case unitLabel + case options + case filterCriteria + } + } + + /// - Remark: Generated from `#/components/schemas/FilterCriteriaDTO`. + struct FilterCriteriaDTO: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/FilterCriteriaDTO/value`. + var value: Swift.String? + /// - Remark: Generated from `#/components/schemas/FilterCriteriaDTO/name`. + var name: Swift.String? + /// Creates a new `FilterCriteriaDTO`. + /// + /// - Parameters: + /// - value: + /// - name: + init(value: Swift.String? = nil, + name: Swift.String? = nil) { + self.value = value + self.name = name + } + + enum CodingKeys: String, CodingKey { + case value + case name + } + } + + /// - Remark: Generated from `#/components/schemas/ParameterOptionDTO`. + struct ParameterOptionDTO: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/ParameterOptionDTO/label`. + var label: Swift.String? + /// - Remark: Generated from `#/components/schemas/ParameterOptionDTO/value`. + var value: Swift.String? + /// Creates a new `ParameterOptionDTO`. + /// + /// - Parameters: + /// - label: + /// - value: + init(label: Swift.String? = nil, + value: Swift.String? = nil) { + self.label = label + self.value = value + } + + enum CodingKeys: String, CodingKey { + case label + case value + } + } + + /// - Remark: Generated from `#/components/schemas/CommandDescription`. + struct CommandDescription: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/CommandDescription/commandOptions`. + var commandOptions: [Components.Schemas.CommandOption]? + /// Creates a new `CommandDescription`. + /// + /// - Parameters: + /// - commandOptions: + init(commandOptions: [Components.Schemas.CommandOption]? = nil) { + self.commandOptions = commandOptions + } + + enum CodingKeys: String, CodingKey { + case commandOptions + } + } + + /// - Remark: Generated from `#/components/schemas/CommandOption`. + struct CommandOption: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/CommandOption/command`. + var command: Swift.String? + /// - Remark: Generated from `#/components/schemas/CommandOption/label`. + var label: Swift.String? + /// Creates a new `CommandOption`. + /// + /// - Parameters: + /// - command: + /// - label: + init(command: Swift.String? = nil, + label: Swift.String? = nil) { + self.command = command + self.label = label + } + + enum CodingKeys: String, CodingKey { + case command + case label + } + } + + /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterGroupDTO`. + struct ConfigDescriptionParameterGroupDTO: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterGroupDTO/name`. + var name: Swift.String? + /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterGroupDTO/context`. + var context: Swift.String? + /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterGroupDTO/advanced`. + var advanced: Swift.Bool? + /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterGroupDTO/label`. + var label: Swift.String? + /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterGroupDTO/description`. + var description: Swift.String? + /// Creates a new `ConfigDescriptionParameterGroupDTO`. + /// + /// - Parameters: + /// - name: + /// - context: + /// - advanced: + /// - label: + /// - description: + init(name: Swift.String? = nil, + context: Swift.String? = nil, + advanced: Swift.Bool? = nil, + label: Swift.String? = nil, + description: Swift.String? = nil) { + self.name = name + self.context = context + self.advanced = advanced + self.label = label + self.description = description + } + + enum CodingKeys: String, CodingKey { + case name + case context + case advanced + case label + case description + } + } + + /// - Remark: Generated from `#/components/schemas/StateDescription`. + struct StateDescription: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/StateDescription/minimum`. + var minimum: Swift.Double? + /// - Remark: Generated from `#/components/schemas/StateDescription/maximum`. + var maximum: Swift.Double? + /// - Remark: Generated from `#/components/schemas/StateDescription/step`. + var step: Swift.Double? + /// - Remark: Generated from `#/components/schemas/StateDescription/pattern`. + var pattern: Swift.String? + /// - Remark: Generated from `#/components/schemas/StateDescription/readOnly`. + var readOnly: Swift.Bool? + /// - Remark: Generated from `#/components/schemas/StateDescription/options`. + var options: [Components.Schemas.StateOption]? + /// Creates a new `StateDescription`. + /// + /// - Parameters: + /// - minimum: + /// - maximum: + /// - step: + /// - pattern: + /// - readOnly: + /// - options: + init(minimum: Swift.Double? = nil, + maximum: Swift.Double? = nil, + step: Swift.Double? = nil, + pattern: Swift.String? = nil, + readOnly: Swift.Bool? = nil, + options: [Components.Schemas.StateOption]? = nil) { + self.minimum = minimum + self.maximum = maximum + self.step = step + self.pattern = pattern + self.readOnly = readOnly + self.options = options + } + + enum CodingKeys: String, CodingKey { + case minimum + case maximum + case step + case pattern + case readOnly + case options + } + } + + /// - Remark: Generated from `#/components/schemas/StateOption`. + struct StateOption: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/StateOption/value`. + var value: Swift.String? + /// - Remark: Generated from `#/components/schemas/StateOption/label`. + var label: Swift.String? + /// Creates a new `StateOption`. + /// + /// - Parameters: + /// - value: + /// - label: + init(value: Swift.String? = nil, + label: Swift.String? = nil) { + self.value = value + self.label = label + } + + enum CodingKeys: String, CodingKey { + case value + case label + } + } + + /// - Remark: Generated from `#/components/schemas/ConfigDescriptionDTO`. + struct ConfigDescriptionDTO: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/ConfigDescriptionDTO/uri`. + var uri: Swift.String? + /// - Remark: Generated from `#/components/schemas/ConfigDescriptionDTO/parameters`. + var parameters: [Components.Schemas.ConfigDescriptionParameterDTO]? + /// - Remark: Generated from `#/components/schemas/ConfigDescriptionDTO/parameterGroups`. + var parameterGroups: [Components.Schemas.ConfigDescriptionParameterGroupDTO]? + /// Creates a new `ConfigDescriptionDTO`. + /// + /// - Parameters: + /// - uri: + /// - parameters: + /// - parameterGroups: + init(uri: Swift.String? = nil, + parameters: [Components.Schemas.ConfigDescriptionParameterDTO]? = nil, + parameterGroups: [Components.Schemas.ConfigDescriptionParameterGroupDTO]? = nil) { + self.uri = uri + self.parameters = parameters + self.parameterGroups = parameterGroups + } + + enum CodingKeys: String, CodingKey { + case uri + case parameters + case parameterGroups + } + } + + /// - Remark: Generated from `#/components/schemas/MetadataDTO`. + struct MetadataDTO: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/MetadataDTO/value`. + var value: Swift.String? + /// - Remark: Generated from `#/components/schemas/MetadataDTO/config`. + struct configPayload: Codable, Hashable, Sendable { + /// A container of undocumented properties. + var additionalProperties: [String: OpenAPIRuntime.OpenAPIObjectContainer] + /// Creates a new `configPayload`. + /// + /// - Parameters: + /// - additionalProperties: A container of undocumented properties. + init(additionalProperties: [String: OpenAPIRuntime.OpenAPIObjectContainer] = .init()) { + self.additionalProperties = additionalProperties + } + + init(from decoder: any Decoder) throws { + additionalProperties = try decoder.decodeAdditionalProperties(knownKeys: []) + } + + func encode(to encoder: any Encoder) throws { + try encoder.encodeAdditionalProperties(additionalProperties) + } + } + + /// - Remark: Generated from `#/components/schemas/MetadataDTO/config`. + var config: Components.Schemas.MetadataDTO.configPayload? + /// Creates a new `MetadataDTO`. + /// + /// - Parameters: + /// - value: + /// - config: + init(value: Swift.String? = nil, + config: Components.Schemas.MetadataDTO.configPayload? = nil) { + self.value = value + self.config = config + } + + enum CodingKeys: String, CodingKey { + case value + case config + } + } + + /// - Remark: Generated from `#/components/schemas/EnrichedItemDTO`. + struct EnrichedItemDTO: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/EnrichedItemDTO/type`. + var _type: Swift.String? + /// - Remark: Generated from `#/components/schemas/EnrichedItemDTO/name`. + var name: Swift.String? + /// - Remark: Generated from `#/components/schemas/EnrichedItemDTO/label`. + var label: Swift.String? + /// - Remark: Generated from `#/components/schemas/EnrichedItemDTO/category`. + var category: Swift.String? + /// - Remark: Generated from `#/components/schemas/EnrichedItemDTO/tags`. + var tags: [Swift.String]? + /// - Remark: Generated from `#/components/schemas/EnrichedItemDTO/groupNames`. + var groupNames: [Swift.String]? + /// - Remark: Generated from `#/components/schemas/EnrichedItemDTO/link`. + var link: Swift.String? + /// - Remark: Generated from `#/components/schemas/EnrichedItemDTO/state`. + var state: Swift.String? + /// - Remark: Generated from `#/components/schemas/EnrichedItemDTO/transformedState`. + var transformedState: Swift.String? + /// - Remark: Generated from `#/components/schemas/EnrichedItemDTO/stateDescription`. + var stateDescription: Components.Schemas.StateDescription? + /// - Remark: Generated from `#/components/schemas/EnrichedItemDTO/unitSymbol`. + var unitSymbol: Swift.String? + /// - Remark: Generated from `#/components/schemas/EnrichedItemDTO/commandDescription`. + var commandDescription: Components.Schemas.CommandDescription? + /// - Remark: Generated from `#/components/schemas/EnrichedItemDTO/metadata`. + struct metadataPayload: Codable, Hashable, Sendable { + /// A container of undocumented properties. + var additionalProperties: [String: OpenAPIRuntime.OpenAPIObjectContainer] + /// Creates a new `metadataPayload`. + /// + /// - Parameters: + /// - additionalProperties: A container of undocumented properties. + init(additionalProperties: [String: OpenAPIRuntime.OpenAPIObjectContainer] = .init()) { + self.additionalProperties = additionalProperties + } + + init(from decoder: any Decoder) throws { + additionalProperties = try decoder.decodeAdditionalProperties(knownKeys: []) + } + + func encode(to encoder: any Encoder) throws { + try encoder.encodeAdditionalProperties(additionalProperties) + } + } + + /// - Remark: Generated from `#/components/schemas/EnrichedItemDTO/metadata`. + var metadata: Components.Schemas.EnrichedItemDTO.metadataPayload? + /// - Remark: Generated from `#/components/schemas/EnrichedItemDTO/editable`. + var editable: Swift.Bool? + /// Creates a new `EnrichedItemDTO`. + /// + /// - Parameters: + /// - _type: + /// - name: + /// - label: + /// - category: + /// - tags: + /// - groupNames: + /// - link: + /// - state: + /// - transformedState: + /// - stateDescription: + /// - unitSymbol: + /// - commandDescription: + /// - metadata: + /// - editable: + init(_type: Swift.String? = nil, + name: Swift.String? = nil, + label: Swift.String? = nil, + category: Swift.String? = nil, + tags: [Swift.String]? = nil, + groupNames: [Swift.String]? = nil, + link: Swift.String? = nil, + state: Swift.String? = nil, + transformedState: Swift.String? = nil, + stateDescription: Components.Schemas.StateDescription? = nil, + unitSymbol: Swift.String? = nil, + commandDescription: Components.Schemas.CommandDescription? = nil, + metadata: Components.Schemas.EnrichedItemDTO.metadataPayload? = nil, + editable: Swift.Bool? = nil) { + self._type = _type + self.name = name + self.label = label + self.category = category + self.tags = tags + self.groupNames = groupNames + self.link = link + self.state = state + self.transformedState = transformedState + self.stateDescription = stateDescription + self.unitSymbol = unitSymbol + self.commandDescription = commandDescription + self.metadata = metadata + self.editable = editable + } + + enum CodingKeys: String, CodingKey { + case _type = "type" + case name + case label + case category + case tags + case groupNames + case link + case state + case transformedState + case stateDescription + case unitSymbol + case commandDescription + case metadata + case editable + } + } + + /// - Remark: Generated from `#/components/schemas/GroupFunctionDTO`. + struct GroupFunctionDTO: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/GroupFunctionDTO/name`. + var name: Swift.String? + /// - Remark: Generated from `#/components/schemas/GroupFunctionDTO/params`. + var params: [Swift.String]? + /// Creates a new `GroupFunctionDTO`. + /// + /// - Parameters: + /// - name: + /// - params: + init(name: Swift.String? = nil, + params: [Swift.String]? = nil) { + self.name = name + self.params = params + } + + enum CodingKeys: String, CodingKey { + case name + case params + } + } + + /// - Remark: Generated from `#/components/schemas/GroupItemDTO`. + struct GroupItemDTO: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/GroupItemDTO/type`. + var _type: Swift.String? + /// - Remark: Generated from `#/components/schemas/GroupItemDTO/name`. + var name: Swift.String? + /// - Remark: Generated from `#/components/schemas/GroupItemDTO/label`. + var label: Swift.String? + /// - Remark: Generated from `#/components/schemas/GroupItemDTO/category`. + var category: Swift.String? + /// - Remark: Generated from `#/components/schemas/GroupItemDTO/tags`. + var tags: [Swift.String]? + /// - Remark: Generated from `#/components/schemas/GroupItemDTO/groupNames`. + var groupNames: [Swift.String]? + /// - Remark: Generated from `#/components/schemas/GroupItemDTO/groupType`. + var groupType: Swift.String? + /// - Remark: Generated from `#/components/schemas/GroupItemDTO/function`. + var function: Components.Schemas.GroupFunctionDTO? + /// Creates a new `GroupItemDTO`. + /// + /// - Parameters: + /// - _type: + /// - name: + /// - label: + /// - category: + /// - tags: + /// - groupNames: + /// - groupType: + /// - function: + init(_type: Swift.String? = nil, + name: Swift.String? = nil, + label: Swift.String? = nil, + category: Swift.String? = nil, + tags: [Swift.String]? = nil, + groupNames: [Swift.String]? = nil, + groupType: Swift.String? = nil, + function: Components.Schemas.GroupFunctionDTO? = nil) { + self._type = _type + self.name = name + self.label = label + self.category = category + self.tags = tags + self.groupNames = groupNames + self.groupType = groupType + self.function = function + } + + enum CodingKeys: String, CodingKey { + case _type = "type" + case name + case label + case category + case tags + case groupNames + case groupType + case function + } + } + + /// - Remark: Generated from `#/components/schemas/JerseyResponseBuilderDTO`. + struct JerseyResponseBuilderDTO: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/JerseyResponseBuilderDTO/status`. + var status: Swift.String? + /// - Remark: Generated from `#/components/schemas/JerseyResponseBuilderDTO/context`. + var context: Components.Schemas.ContextDTO? + /// Creates a new `JerseyResponseBuilderDTO`. + /// + /// - Parameters: + /// - status: + /// - context: + init(status: Swift.String? = nil, + context: Components.Schemas.ContextDTO? = nil) { + self.status = status + self.context = context + } + + enum CodingKeys: String, CodingKey { + case status + case context + } + } + + /// - Remark: Generated from `#/components/schemas/ContextDTO`. + struct ContextDTO: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/ContextDTO/headers`. + var headers: Components.Schemas.HeadersDTO? + /// Creates a new `ContextDTO`. + /// + /// - Parameters: + /// - headers: + init(headers: Components.Schemas.HeadersDTO? = nil) { + self.headers = headers + } + + enum CodingKeys: String, CodingKey { + case headers + } + } + + /// - Remark: Generated from `#/components/schemas/HeadersDTO`. + struct HeadersDTO: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/HeadersDTO/Location`. + var Location: [Swift.String]? + /// Creates a new `HeadersDTO`. + /// + /// - Parameters: + /// - Location: + init(Location: [Swift.String]? = nil) { + self.Location = Location + } + + enum CodingKeys: String, CodingKey { + case Location + } + } + + /// - Remark: Generated from `#/components/schemas/MappingDTO`. + struct MappingDTO: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/MappingDTO/row`. + var row: Swift.Int32? + /// - Remark: Generated from `#/components/schemas/MappingDTO/column`. + var column: Swift.Int32? + /// - Remark: Generated from `#/components/schemas/MappingDTO/command`. + var command: Swift.String? + /// - Remark: Generated from `#/components/schemas/MappingDTO/releaseCommand`. + var releaseCommand: Swift.String? + /// - Remark: Generated from `#/components/schemas/MappingDTO/label`. + var label: Swift.String? + /// - Remark: Generated from `#/components/schemas/MappingDTO/icon`. + var icon: Swift.String? + /// Creates a new `MappingDTO`. + /// + /// - Parameters: + /// - row: + /// - column: + /// - command: + /// - releaseCommand: + /// - label: + /// - icon: + init(row: Swift.Int32? = nil, + column: Swift.Int32? = nil, + command: Swift.String? = nil, + releaseCommand: Swift.String? = nil, + label: Swift.String? = nil, + icon: Swift.String? = nil) { + self.row = row + self.column = column + self.command = command + self.releaseCommand = releaseCommand + self.label = label + self.icon = icon + } + + enum CodingKeys: String, CodingKey { + case row + case column + case command + case releaseCommand + case label + case icon + } + } + + /// - Remark: Generated from `#/components/schemas/PageDTO`. + struct PageDTO: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/PageDTO/id`. + var id: Swift.String? { + get { + storage.value.id + } + _modify { + yield &storage.value.id + } + } + + /// - Remark: Generated from `#/components/schemas/PageDTO/title`. + var title: Swift.String? { + get { + storage.value.title + } + _modify { + yield &storage.value.title + } + } + + /// - Remark: Generated from `#/components/schemas/PageDTO/icon`. + var icon: Swift.String? { + get { + storage.value.icon + } + _modify { + yield &storage.value.icon + } + } + + /// - Remark: Generated from `#/components/schemas/PageDTO/link`. + var link: Swift.String? { + get { + storage.value.link + } + _modify { + yield &storage.value.link + } + } + + /// - Remark: Generated from `#/components/schemas/PageDTO/parent`. + var parent: Components.Schemas.PageDTO? { + get { + storage.value.parent + } + _modify { + yield &storage.value.parent + } + } + + /// - Remark: Generated from `#/components/schemas/PageDTO/leaf`. + var leaf: Swift.Bool? { + get { + storage.value.leaf + } + _modify { + yield &storage.value.leaf + } + } + + /// - Remark: Generated from `#/components/schemas/PageDTO/timeout`. + var timeout: Swift.Bool? { + get { + storage.value.timeout + } + _modify { + yield &storage.value.timeout + } + } + + /// - Remark: Generated from `#/components/schemas/PageDTO/widgets`. + var widgets: [Components.Schemas.WidgetDTO]? { + get { + storage.value.widgets + } + _modify { + yield &storage.value.widgets + } + } + + /// Creates a new `PageDTO`. + /// + /// - Parameters: + /// - id: + /// - title: + /// - icon: + /// - link: + /// - parent: + /// - leaf: + /// - timeout: + /// - widgets: + init(id: Swift.String? = nil, + title: Swift.String? = nil, + icon: Swift.String? = nil, + link: Swift.String? = nil, + parent: Components.Schemas.PageDTO? = nil, + leaf: Swift.Bool? = nil, + timeout: Swift.Bool? = nil, + widgets: [Components.Schemas.WidgetDTO]? = nil) { + storage = .init(value: .init( + id: id, + title: title, + icon: icon, + link: link, + parent: parent, + leaf: leaf, + timeout: timeout, + widgets: widgets + )) + } + + enum CodingKeys: String, CodingKey { + case id + case title + case icon + case link + case parent + case leaf + case timeout + case widgets + } + + init(from decoder: any Decoder) throws { + storage = try .init(from: decoder) + } + + func encode(to encoder: any Encoder) throws { + try storage.encode(to: encoder) + } + + /// Internal reference storage to allow type recursion. + private var storage: OpenAPIRuntime.CopyOnWriteBox + private struct Storage: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/PageDTO/id`. + var id: Swift.String? + /// - Remark: Generated from `#/components/schemas/PageDTO/title`. + var title: Swift.String? + /// - Remark: Generated from `#/components/schemas/PageDTO/icon`. + var icon: Swift.String? + /// - Remark: Generated from `#/components/schemas/PageDTO/link`. + var link: Swift.String? + /// - Remark: Generated from `#/components/schemas/PageDTO/parent`. + var parent: Components.Schemas.PageDTO? + /// - Remark: Generated from `#/components/schemas/PageDTO/leaf`. + var leaf: Swift.Bool? + /// - Remark: Generated from `#/components/schemas/PageDTO/timeout`. + var timeout: Swift.Bool? + /// - Remark: Generated from `#/components/schemas/PageDTO/widgets`. + var widgets: [Components.Schemas.WidgetDTO]? + init(id: Swift.String? = nil, + title: Swift.String? = nil, + icon: Swift.String? = nil, + link: Swift.String? = nil, + parent: Components.Schemas.PageDTO? = nil, + leaf: Swift.Bool? = nil, + timeout: Swift.Bool? = nil, + widgets: [Components.Schemas.WidgetDTO]? = nil) { + self.id = id + self.title = title + self.icon = icon + self.link = link + self.parent = parent + self.leaf = leaf + self.timeout = timeout + self.widgets = widgets + } + + typealias CodingKeys = Components.Schemas.PageDTO.CodingKeys + } + } + + /// - Remark: Generated from `#/components/schemas/WidgetDTO`. + struct WidgetDTO: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/WidgetDTO/widgetId`. + var widgetId: Swift.String? { + get { + storage.value.widgetId + } + _modify { + yield &storage.value.widgetId + } + } + + /// - Remark: Generated from `#/components/schemas/WidgetDTO/type`. + var _type: Swift.String? { + get { + storage.value._type + } + _modify { + yield &storage.value._type + } + } + + /// - Remark: Generated from `#/components/schemas/WidgetDTO/name`. + var name: Swift.String? { + get { + storage.value.name + } + _modify { + yield &storage.value.name + } + } + + /// - Remark: Generated from `#/components/schemas/WidgetDTO/visibility`. + var visibility: Swift.Bool? { + get { + storage.value.visibility + } + _modify { + yield &storage.value.visibility + } + } + + /// - Remark: Generated from `#/components/schemas/WidgetDTO/label`. + var label: Swift.String? { + get { + storage.value.label + } + _modify { + yield &storage.value.label + } + } + + /// - Remark: Generated from `#/components/schemas/WidgetDTO/labelSource`. + var labelSource: Swift.String? { + get { + storage.value.labelSource + } + _modify { + yield &storage.value.labelSource + } + } + + /// - Remark: Generated from `#/components/schemas/WidgetDTO/icon`. + var icon: Swift.String? { + get { + storage.value.icon + } + _modify { + yield &storage.value.icon + } + } + + /// - Remark: Generated from `#/components/schemas/WidgetDTO/staticIcon`. + var staticIcon: Swift.Bool? { + get { + storage.value.staticIcon + } + _modify { + yield &storage.value.staticIcon + } + } + + /// - Remark: Generated from `#/components/schemas/WidgetDTO/labelcolor`. + var labelcolor: Swift.String? { + get { + storage.value.labelcolor + } + _modify { + yield &storage.value.labelcolor + } + } + + /// - Remark: Generated from `#/components/schemas/WidgetDTO/valuecolor`. + var valuecolor: Swift.String? { + get { + storage.value.valuecolor + } + _modify { + yield &storage.value.valuecolor + } + } + + /// - Remark: Generated from `#/components/schemas/WidgetDTO/iconcolor`. + var iconcolor: Swift.String? { + get { + storage.value.iconcolor + } + _modify { + yield &storage.value.iconcolor + } + } + + /// - Remark: Generated from `#/components/schemas/WidgetDTO/pattern`. + var pattern: Swift.String? { + get { + storage.value.pattern + } + _modify { + yield &storage.value.pattern + } + } + + /// - Remark: Generated from `#/components/schemas/WidgetDTO/unit`. + var unit: Swift.String? { + get { + storage.value.unit + } + _modify { + yield &storage.value.unit + } + } + + /// - Remark: Generated from `#/components/schemas/WidgetDTO/mappings`. + var mappings: [Components.Schemas.MappingDTO]? { + get { + storage.value.mappings + } + _modify { + yield &storage.value.mappings + } + } + + /// - Remark: Generated from `#/components/schemas/WidgetDTO/switchSupport`. + var switchSupport: Swift.Bool? { + get { + storage.value.switchSupport + } + _modify { + yield &storage.value.switchSupport + } + } + + /// - Remark: Generated from `#/components/schemas/WidgetDTO/releaseOnly`. + var releaseOnly: Swift.Bool? { + get { + storage.value.releaseOnly + } + _modify { + yield &storage.value.releaseOnly + } + } + + /// - Remark: Generated from `#/components/schemas/WidgetDTO/sendFrequency`. + var sendFrequency: Swift.Int32? { + get { + storage.value.sendFrequency + } + _modify { + yield &storage.value.sendFrequency + } + } + + /// - Remark: Generated from `#/components/schemas/WidgetDTO/refresh`. + var refresh: Swift.Int32? { + get { + storage.value.refresh + } + _modify { + yield &storage.value.refresh + } + } + + /// - Remark: Generated from `#/components/schemas/WidgetDTO/height`. + var height: Swift.Int32? { + get { + storage.value.height + } + _modify { + yield &storage.value.height + } + } + + /// - Remark: Generated from `#/components/schemas/WidgetDTO/minValue`. + var minValue: Swift.Double? { + get { + storage.value.minValue + } + _modify { + yield &storage.value.minValue + } + } + + /// - Remark: Generated from `#/components/schemas/WidgetDTO/maxValue`. + var maxValue: Swift.Double? { + get { + storage.value.maxValue + } + _modify { + yield &storage.value.maxValue + } + } + + /// - Remark: Generated from `#/components/schemas/WidgetDTO/step`. + var step: Swift.Double? { + get { + storage.value.step + } + _modify { + yield &storage.value.step + } + } + + /// - Remark: Generated from `#/components/schemas/WidgetDTO/inputHint`. + var inputHint: Swift.String? { + get { + storage.value.inputHint + } + _modify { + yield &storage.value.inputHint + } + } + + /// - Remark: Generated from `#/components/schemas/WidgetDTO/url`. + var url: Swift.String? { + get { + storage.value.url + } + _modify { + yield &storage.value.url + } + } + + /// - Remark: Generated from `#/components/schemas/WidgetDTO/encoding`. + var encoding: Swift.String? { + get { + storage.value.encoding + } + _modify { + yield &storage.value.encoding + } + } + + /// - Remark: Generated from `#/components/schemas/WidgetDTO/service`. + var service: Swift.String? { + get { + storage.value.service + } + _modify { + yield &storage.value.service + } + } + + /// - Remark: Generated from `#/components/schemas/WidgetDTO/period`. + var period: Swift.String? { + get { + storage.value.period + } + _modify { + yield &storage.value.period + } + } + + /// - Remark: Generated from `#/components/schemas/WidgetDTO/yAxisDecimalPattern`. + var yAxisDecimalPattern: Swift.String? { + get { + storage.value.yAxisDecimalPattern + } + _modify { + yield &storage.value.yAxisDecimalPattern + } + } + + /// - Remark: Generated from `#/components/schemas/WidgetDTO/legend`. + var legend: Swift.Bool? { + get { + storage.value.legend + } + _modify { + yield &storage.value.legend + } + } + + /// - Remark: Generated from `#/components/schemas/WidgetDTO/forceAsItem`. + var forceAsItem: Swift.Bool? { + get { + storage.value.forceAsItem + } + _modify { + yield &storage.value.forceAsItem + } + } + + /// - Remark: Generated from `#/components/schemas/WidgetDTO/row`. + var row: Swift.Int32? { + get { + storage.value.row + } + _modify { + yield &storage.value.row + } + } + + /// - Remark: Generated from `#/components/schemas/WidgetDTO/column`. + var column: Swift.Int32? { + get { + storage.value.column + } + _modify { + yield &storage.value.column + } + } + + /// - Remark: Generated from `#/components/schemas/WidgetDTO/command`. + var command: Swift.String? { + get { + storage.value.command + } + _modify { + yield &storage.value.command + } + } + + /// - Remark: Generated from `#/components/schemas/WidgetDTO/releaseCommand`. + var releaseCommand: Swift.String? { + get { + storage.value.releaseCommand + } + _modify { + yield &storage.value.releaseCommand + } + } + + /// - Remark: Generated from `#/components/schemas/WidgetDTO/stateless`. + var stateless: Swift.Bool? { + get { + storage.value.stateless + } + _modify { + yield &storage.value.stateless + } + } + + /// - Remark: Generated from `#/components/schemas/WidgetDTO/state`. + var state: Swift.String? { + get { + storage.value.state + } + _modify { + yield &storage.value.state + } + } + + /// - Remark: Generated from `#/components/schemas/WidgetDTO/item`. + var item: Components.Schemas.EnrichedItemDTO? { + get { + storage.value.item + } + _modify { + yield &storage.value.item + } + } + + /// - Remark: Generated from `#/components/schemas/WidgetDTO/linkedPage`. + var linkedPage: Components.Schemas.PageDTO? { + get { + storage.value.linkedPage + } + _modify { + yield &storage.value.linkedPage + } + } + + /// - Remark: Generated from `#/components/schemas/WidgetDTO/widgets`. + var widgets: [Components.Schemas.WidgetDTO]? { + get { + storage.value.widgets + } + _modify { + yield &storage.value.widgets + } + } + + /// Creates a new `WidgetDTO`. + /// + /// - Parameters: + /// - widgetId: + /// - _type: + /// - name: + /// - visibility: + /// - label: + /// - labelSource: + /// - icon: + /// - staticIcon: + /// - labelcolor: + /// - valuecolor: + /// - iconcolor: + /// - pattern: + /// - unit: + /// - mappings: + /// - switchSupport: + /// - releaseOnly: + /// - sendFrequency: + /// - refresh: + /// - height: + /// - minValue: + /// - maxValue: + /// - step: + /// - inputHint: + /// - url: + /// - encoding: + /// - service: + /// - period: + /// - yAxisDecimalPattern: + /// - legend: + /// - forceAsItem: + /// - row: + /// - column: + /// - command: + /// - releaseCommand: + /// - stateless: + /// - state: + /// - item: + /// - linkedPage: + /// - widgets: + init(widgetId: Swift.String? = nil, + _type: Swift.String? = nil, + name: Swift.String? = nil, + visibility: Swift.Bool? = nil, + label: Swift.String? = nil, + labelSource: Swift.String? = nil, + icon: Swift.String? = nil, + staticIcon: Swift.Bool? = nil, + labelcolor: Swift.String? = nil, + valuecolor: Swift.String? = nil, + iconcolor: Swift.String? = nil, + pattern: Swift.String? = nil, + unit: Swift.String? = nil, + mappings: [Components.Schemas.MappingDTO]? = nil, + switchSupport: Swift.Bool? = nil, + releaseOnly: Swift.Bool? = nil, + sendFrequency: Swift.Int32? = nil, + refresh: Swift.Int32? = nil, + height: Swift.Int32? = nil, + minValue: Swift.Double? = nil, + maxValue: Swift.Double? = nil, + step: Swift.Double? = nil, + inputHint: Swift.String? = nil, + url: Swift.String? = nil, + encoding: Swift.String? = nil, + service: Swift.String? = nil, + period: Swift.String? = nil, + yAxisDecimalPattern: Swift.String? = nil, + legend: Swift.Bool? = nil, + forceAsItem: Swift.Bool? = nil, + row: Swift.Int32? = nil, + column: Swift.Int32? = nil, + command: Swift.String? = nil, + releaseCommand: Swift.String? = nil, + stateless: Swift.Bool? = nil, + state: Swift.String? = nil, + item: Components.Schemas.EnrichedItemDTO? = nil, + linkedPage: Components.Schemas.PageDTO? = nil, + widgets: [Components.Schemas.WidgetDTO]? = nil) { + storage = .init(value: .init( + widgetId: widgetId, + _type: _type, + name: name, + visibility: visibility, + label: label, + labelSource: labelSource, + icon: icon, + staticIcon: staticIcon, + labelcolor: labelcolor, + valuecolor: valuecolor, + iconcolor: iconcolor, + pattern: pattern, + unit: unit, + mappings: mappings, + switchSupport: switchSupport, + releaseOnly: releaseOnly, + sendFrequency: sendFrequency, + refresh: refresh, + height: height, + minValue: minValue, + maxValue: maxValue, + step: step, + inputHint: inputHint, + url: url, + encoding: encoding, + service: service, + period: period, + yAxisDecimalPattern: yAxisDecimalPattern, + legend: legend, + forceAsItem: forceAsItem, + row: row, + column: column, + command: command, + releaseCommand: releaseCommand, + stateless: stateless, + state: state, + item: item, + linkedPage: linkedPage, + widgets: widgets + )) + } + + enum CodingKeys: String, CodingKey { + case widgetId + case _type = "type" + case name + case visibility + case label + case labelSource + case icon + case staticIcon + case labelcolor + case valuecolor + case iconcolor + case pattern + case unit + case mappings + case switchSupport + case releaseOnly + case sendFrequency + case refresh + case height + case minValue + case maxValue + case step + case inputHint + case url + case encoding + case service + case period + case yAxisDecimalPattern + case legend + case forceAsItem + case row + case column + case command + case releaseCommand + case stateless + case state + case item + case linkedPage + case widgets + } + + init(from decoder: any Decoder) throws { + storage = try .init(from: decoder) + } + + func encode(to encoder: any Encoder) throws { + try storage.encode(to: encoder) + } + + /// Internal reference storage to allow type recursion. + private var storage: OpenAPIRuntime.CopyOnWriteBox + private struct Storage: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/WidgetDTO/widgetId`. + var widgetId: Swift.String? + /// - Remark: Generated from `#/components/schemas/WidgetDTO/type`. + var _type: Swift.String? + /// - Remark: Generated from `#/components/schemas/WidgetDTO/name`. + var name: Swift.String? + /// - Remark: Generated from `#/components/schemas/WidgetDTO/visibility`. + var visibility: Swift.Bool? + /// - Remark: Generated from `#/components/schemas/WidgetDTO/label`. + var label: Swift.String? + /// - Remark: Generated from `#/components/schemas/WidgetDTO/labelSource`. + var labelSource: Swift.String? + /// - Remark: Generated from `#/components/schemas/WidgetDTO/icon`. + var icon: Swift.String? + /// - Remark: Generated from `#/components/schemas/WidgetDTO/staticIcon`. + var staticIcon: Swift.Bool? + /// - Remark: Generated from `#/components/schemas/WidgetDTO/labelcolor`. + var labelcolor: Swift.String? + /// - Remark: Generated from `#/components/schemas/WidgetDTO/valuecolor`. + var valuecolor: Swift.String? + /// - Remark: Generated from `#/components/schemas/WidgetDTO/iconcolor`. + var iconcolor: Swift.String? + /// - Remark: Generated from `#/components/schemas/WidgetDTO/pattern`. + var pattern: Swift.String? + /// - Remark: Generated from `#/components/schemas/WidgetDTO/unit`. + var unit: Swift.String? + /// - Remark: Generated from `#/components/schemas/WidgetDTO/mappings`. + var mappings: [Components.Schemas.MappingDTO]? + /// - Remark: Generated from `#/components/schemas/WidgetDTO/switchSupport`. + var switchSupport: Swift.Bool? + /// - Remark: Generated from `#/components/schemas/WidgetDTO/releaseOnly`. + var releaseOnly: Swift.Bool? + /// - Remark: Generated from `#/components/schemas/WidgetDTO/sendFrequency`. + var sendFrequency: Swift.Int32? + /// - Remark: Generated from `#/components/schemas/WidgetDTO/refresh`. + var refresh: Swift.Int32? + /// - Remark: Generated from `#/components/schemas/WidgetDTO/height`. + var height: Swift.Int32? + /// - Remark: Generated from `#/components/schemas/WidgetDTO/minValue`. + var minValue: Swift.Double? + /// - Remark: Generated from `#/components/schemas/WidgetDTO/maxValue`. + var maxValue: Swift.Double? + /// - Remark: Generated from `#/components/schemas/WidgetDTO/step`. + var step: Swift.Double? + /// - Remark: Generated from `#/components/schemas/WidgetDTO/inputHint`. + var inputHint: Swift.String? + /// - Remark: Generated from `#/components/schemas/WidgetDTO/url`. + var url: Swift.String? + /// - Remark: Generated from `#/components/schemas/WidgetDTO/encoding`. + var encoding: Swift.String? + /// - Remark: Generated from `#/components/schemas/WidgetDTO/service`. + var service: Swift.String? + /// - Remark: Generated from `#/components/schemas/WidgetDTO/period`. + var period: Swift.String? + /// - Remark: Generated from `#/components/schemas/WidgetDTO/yAxisDecimalPattern`. + var yAxisDecimalPattern: Swift.String? + /// - Remark: Generated from `#/components/schemas/WidgetDTO/legend`. + var legend: Swift.Bool? + /// - Remark: Generated from `#/components/schemas/WidgetDTO/forceAsItem`. + var forceAsItem: Swift.Bool? + /// - Remark: Generated from `#/components/schemas/WidgetDTO/row`. + var row: Swift.Int32? + /// - Remark: Generated from `#/components/schemas/WidgetDTO/column`. + var column: Swift.Int32? + /// - Remark: Generated from `#/components/schemas/WidgetDTO/command`. + var command: Swift.String? + /// - Remark: Generated from `#/components/schemas/WidgetDTO/releaseCommand`. + var releaseCommand: Swift.String? + /// - Remark: Generated from `#/components/schemas/WidgetDTO/stateless`. + var stateless: Swift.Bool? + /// - Remark: Generated from `#/components/schemas/WidgetDTO/state`. + var state: Swift.String? + /// - Remark: Generated from `#/components/schemas/WidgetDTO/item`. + var item: Components.Schemas.EnrichedItemDTO? + /// - Remark: Generated from `#/components/schemas/WidgetDTO/linkedPage`. + var linkedPage: Components.Schemas.PageDTO? + /// - Remark: Generated from `#/components/schemas/WidgetDTO/widgets`. + var widgets: [Components.Schemas.WidgetDTO]? + init(widgetId: Swift.String? = nil, + _type: Swift.String? = nil, + name: Swift.String? = nil, + visibility: Swift.Bool? = nil, + label: Swift.String? = nil, + labelSource: Swift.String? = nil, + icon: Swift.String? = nil, + staticIcon: Swift.Bool? = nil, + labelcolor: Swift.String? = nil, + valuecolor: Swift.String? = nil, + iconcolor: Swift.String? = nil, + pattern: Swift.String? = nil, + unit: Swift.String? = nil, + mappings: [Components.Schemas.MappingDTO]? = nil, + switchSupport: Swift.Bool? = nil, + releaseOnly: Swift.Bool? = nil, + sendFrequency: Swift.Int32? = nil, + refresh: Swift.Int32? = nil, + height: Swift.Int32? = nil, + minValue: Swift.Double? = nil, + maxValue: Swift.Double? = nil, + step: Swift.Double? = nil, + inputHint: Swift.String? = nil, + url: Swift.String? = nil, + encoding: Swift.String? = nil, + service: Swift.String? = nil, + period: Swift.String? = nil, + yAxisDecimalPattern: Swift.String? = nil, + legend: Swift.Bool? = nil, + forceAsItem: Swift.Bool? = nil, + row: Swift.Int32? = nil, + column: Swift.Int32? = nil, + command: Swift.String? = nil, + releaseCommand: Swift.String? = nil, + stateless: Swift.Bool? = nil, + state: Swift.String? = nil, + item: Components.Schemas.EnrichedItemDTO? = nil, + linkedPage: Components.Schemas.PageDTO? = nil, + widgets: [Components.Schemas.WidgetDTO]? = nil) { + self.widgetId = widgetId + self._type = _type + self.name = name + self.visibility = visibility + self.label = label + self.labelSource = labelSource + self.icon = icon + self.staticIcon = staticIcon + self.labelcolor = labelcolor + self.valuecolor = valuecolor + self.iconcolor = iconcolor + self.pattern = pattern + self.unit = unit + self.mappings = mappings + self.switchSupport = switchSupport + self.releaseOnly = releaseOnly + self.sendFrequency = sendFrequency + self.refresh = refresh + self.height = height + self.minValue = minValue + self.maxValue = maxValue + self.step = step + self.inputHint = inputHint + self.url = url + self.encoding = encoding + self.service = service + self.period = period + self.yAxisDecimalPattern = yAxisDecimalPattern + self.legend = legend + self.forceAsItem = forceAsItem + self.row = row + self.column = column + self.command = command + self.releaseCommand = releaseCommand + self.stateless = stateless + self.state = state + self.item = item + self.linkedPage = linkedPage + self.widgets = widgets + } + + typealias CodingKeys = Components.Schemas.WidgetDTO.CodingKeys + } + } + + /// - Remark: Generated from `#/components/schemas/SitemapWidgetEvent`. + struct SitemapWidgetEvent: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/SitemapWidgetEvent/widgetId`. + var widgetId: Swift.String? + /// - Remark: Generated from `#/components/schemas/SitemapWidgetEvent/label`. + var label: Swift.String? + /// - Remark: Generated from `#/components/schemas/SitemapWidgetEvent/labelSource`. + var labelSource: Swift.String? + /// - Remark: Generated from `#/components/schemas/SitemapWidgetEvent/icon`. + var icon: Swift.String? + /// - Remark: Generated from `#/components/schemas/SitemapWidgetEvent/labelcolor`. + var labelcolor: Swift.String? + /// - Remark: Generated from `#/components/schemas/SitemapWidgetEvent/valuecolor`. + var valuecolor: Swift.String? + /// - Remark: Generated from `#/components/schemas/SitemapWidgetEvent/iconcolor`. + var iconcolor: Swift.String? + /// - Remark: Generated from `#/components/schemas/SitemapWidgetEvent/state`. + var state: Swift.String? + /// - Remark: Generated from `#/components/schemas/SitemapWidgetEvent/reloadIcon`. + var reloadIcon: Swift.Bool? + /// - Remark: Generated from `#/components/schemas/SitemapWidgetEvent/visibility`. + var visibility: Swift.Bool? + /// - Remark: Generated from `#/components/schemas/SitemapWidgetEvent/descriptionChanged`. + var descriptionChanged: Swift.Bool? + /// - Remark: Generated from `#/components/schemas/SitemapWidgetEvent/item`. + var item: Components.Schemas.EnrichedItemDTO? + /// - Remark: Generated from `#/components/schemas/SitemapWidgetEvent/sitemapName`. + var sitemapName: Swift.String? + /// - Remark: Generated from `#/components/schemas/SitemapWidgetEvent/pageId`. + var pageId: Swift.String? + /// Creates a new `SitemapWidgetEvent`. + /// + /// - Parameters: + /// - widgetId: + /// - label: + /// - labelSource: + /// - icon: + /// - labelcolor: + /// - valuecolor: + /// - iconcolor: + /// - state: + /// - reloadIcon: + /// - visibility: + /// - descriptionChanged: + /// - item: + /// - sitemapName: + /// - pageId: + init(widgetId: Swift.String? = nil, + label: Swift.String? = nil, + labelSource: Swift.String? = nil, + icon: Swift.String? = nil, + labelcolor: Swift.String? = nil, + valuecolor: Swift.String? = nil, + iconcolor: Swift.String? = nil, + state: Swift.String? = nil, + reloadIcon: Swift.Bool? = nil, + visibility: Swift.Bool? = nil, + descriptionChanged: Swift.Bool? = nil, + item: Components.Schemas.EnrichedItemDTO? = nil, + sitemapName: Swift.String? = nil, + pageId: Swift.String? = nil) { + self.widgetId = widgetId + self.label = label + self.labelSource = labelSource + self.icon = icon + self.labelcolor = labelcolor + self.valuecolor = valuecolor + self.iconcolor = iconcolor + self.state = state + self.reloadIcon = reloadIcon + self.visibility = visibility + self.descriptionChanged = descriptionChanged + self.item = item + self.sitemapName = sitemapName + self.pageId = pageId + } + + enum CodingKeys: String, CodingKey { + case widgetId + case label + case labelSource + case icon + case labelcolor + case valuecolor + case iconcolor + case state + case reloadIcon + case visibility + case descriptionChanged + case item + case sitemapName + case pageId + } + } + + /// - Remark: Generated from `#/components/schemas/SitemapDTO`. + struct SitemapDTO: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/SitemapDTO/name`. + var name: Swift.String? + /// - Remark: Generated from `#/components/schemas/SitemapDTO/icon`. + var icon: Swift.String? + /// - Remark: Generated from `#/components/schemas/SitemapDTO/label`. + var label: Swift.String? + /// - Remark: Generated from `#/components/schemas/SitemapDTO/link`. + var link: Swift.String? + /// - Remark: Generated from `#/components/schemas/SitemapDTO/homepage`. + var homepage: Components.Schemas.PageDTO? + /// Creates a new `SitemapDTO`. + /// + /// - Parameters: + /// - name: + /// - icon: + /// - label: + /// - link: + /// - homepage: + init(name: Swift.String? = nil, + icon: Swift.String? = nil, + label: Swift.String? = nil, + link: Swift.String? = nil, + homepage: Components.Schemas.PageDTO? = nil) { + self.name = name + self.icon = icon + self.label = label + self.link = link + self.homepage = homepage + } + + enum CodingKeys: String, CodingKey { + case name + case icon + case label + case link + case homepage + } + } + + /// - Remark: Generated from `#/components/schemas/RootUIComponent`. + struct RootUIComponent: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/RootUIComponent/component`. + var component: Swift.String? + /// - Remark: Generated from `#/components/schemas/RootUIComponent/config`. + struct configPayload: Codable, Hashable, Sendable { + /// A container of undocumented properties. + var additionalProperties: [String: OpenAPIRuntime.OpenAPIObjectContainer] + /// Creates a new `configPayload`. + /// + /// - Parameters: + /// - additionalProperties: A container of undocumented properties. + init(additionalProperties: [String: OpenAPIRuntime.OpenAPIObjectContainer] = .init()) { + self.additionalProperties = additionalProperties + } + + init(from decoder: any Decoder) throws { + additionalProperties = try decoder.decodeAdditionalProperties(knownKeys: []) + } + + func encode(to encoder: any Encoder) throws { + try encoder.encodeAdditionalProperties(additionalProperties) + } + } + + /// - Remark: Generated from `#/components/schemas/RootUIComponent/config`. + var config: Components.Schemas.RootUIComponent.configPayload? + /// - Remark: Generated from `#/components/schemas/RootUIComponent/slots`. + struct slotsPayload: Codable, Hashable, Sendable { + /// A container of undocumented properties. + var additionalProperties: [String: [Components.Schemas.UIComponent]] + /// Creates a new `slotsPayload`. + /// + /// - Parameters: + /// - additionalProperties: A container of undocumented properties. + init(additionalProperties: [String: [Components.Schemas.UIComponent]] = .init()) { + self.additionalProperties = additionalProperties + } + + init(from decoder: any Decoder) throws { + additionalProperties = try decoder.decodeAdditionalProperties(knownKeys: []) + } + + func encode(to encoder: any Encoder) throws { + try encoder.encodeAdditionalProperties(additionalProperties) + } + } + + /// - Remark: Generated from `#/components/schemas/RootUIComponent/slots`. + var slots: Components.Schemas.RootUIComponent.slotsPayload? + /// - Remark: Generated from `#/components/schemas/RootUIComponent/uid`. + var uid: Swift.String? + /// - Remark: Generated from `#/components/schemas/RootUIComponent/tags`. + var tags: [Swift.String]? + /// - Remark: Generated from `#/components/schemas/RootUIComponent/props`. + var props: Components.Schemas.ConfigDescriptionDTO? + /// - Remark: Generated from `#/components/schemas/RootUIComponent/timestamp`. + var timestamp: Foundation.Date? + /// - Remark: Generated from `#/components/schemas/RootUIComponent/type`. + var _type: Swift.String? + /// Creates a new `RootUIComponent`. + /// + /// - Parameters: + /// - component: + /// - config: + /// - slots: + /// - uid: + /// - tags: + /// - props: + /// - timestamp: + /// - _type: + init(component: Swift.String? = nil, + config: Components.Schemas.RootUIComponent.configPayload? = nil, + slots: Components.Schemas.RootUIComponent.slotsPayload? = nil, + uid: Swift.String? = nil, + tags: [Swift.String]? = nil, + props: Components.Schemas.ConfigDescriptionDTO? = nil, + timestamp: Foundation.Date? = nil, + _type: Swift.String? = nil) { + self.component = component + self.config = config + self.slots = slots + self.uid = uid + self.tags = tags + self.props = props + self.timestamp = timestamp + self._type = _type + } + + enum CodingKeys: String, CodingKey { + case component + case config + case slots + case uid + case tags + case props + case timestamp + case _type = "type" + } + } + + /// - Remark: Generated from `#/components/schemas/UIComponent`. + struct UIComponent: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/UIComponent/component`. + var component: Swift.String? + /// - Remark: Generated from `#/components/schemas/UIComponent/config`. + struct configPayload: Codable, Hashable, Sendable { + /// A container of undocumented properties. + var additionalProperties: [String: OpenAPIRuntime.OpenAPIObjectContainer] + /// Creates a new `configPayload`. + /// + /// - Parameters: + /// - additionalProperties: A container of undocumented properties. + init(additionalProperties: [String: OpenAPIRuntime.OpenAPIObjectContainer] = .init()) { + self.additionalProperties = additionalProperties + } + + init(from decoder: any Decoder) throws { + additionalProperties = try decoder.decodeAdditionalProperties(knownKeys: []) + } + + func encode(to encoder: any Encoder) throws { + try encoder.encodeAdditionalProperties(additionalProperties) + } + } + + /// - Remark: Generated from `#/components/schemas/UIComponent/config`. + var config: Components.Schemas.UIComponent.configPayload? + /// - Remark: Generated from `#/components/schemas/UIComponent/type`. + var _type: Swift.String? + /// Creates a new `UIComponent`. + /// + /// - Parameters: + /// - component: + /// - config: + /// - _type: + init(component: Swift.String? = nil, + config: Components.Schemas.UIComponent.configPayload? = nil, + _type: Swift.String? = nil) { + self.component = component + self.config = config + self._type = _type + } + + enum CodingKeys: String, CodingKey { + case component + case config + case _type = "type" + } + } + + /// - Remark: Generated from `#/components/schemas/TileDTO`. + struct TileDTO: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/TileDTO/name`. + var name: Swift.String? + /// - Remark: Generated from `#/components/schemas/TileDTO/url`. + var url: Swift.String? + /// - Remark: Generated from `#/components/schemas/TileDTO/overlay`. + var overlay: Swift.String? + /// - Remark: Generated from `#/components/schemas/TileDTO/imageUrl`. + var imageUrl: Swift.String? + /// Creates a new `TileDTO`. + /// + /// - Parameters: + /// - name: + /// - url: + /// - overlay: + /// - imageUrl: + init(name: Swift.String? = nil, + url: Swift.String? = nil, + overlay: Swift.String? = nil, + imageUrl: Swift.String? = nil) { + self.name = name + self.url = url + self.overlay = overlay + self.imageUrl = imageUrl + } + + enum CodingKeys: String, CodingKey { + case name + case url + case overlay + case imageUrl + } + } + } + + /// Types generated from the `#/components/parameters` section of the OpenAPI document. + enum Parameters {} + /// Types generated from the `#/components/requestBodies` section of the OpenAPI document. + enum RequestBodies {} + /// Types generated from the `#/components/responses` section of the OpenAPI document. + enum Responses {} + /// Types generated from the `#/components/headers` section of the OpenAPI document. + enum Headers {} +} + +/// API operations, with input and output types, generated from `#/paths` in the OpenAPI document. +enum Operations { + /// Adds a new member to a group item. + /// + /// - Remark: HTTP `PUT /items/{itemName}/members/{memberItemName}`. + /// - Remark: Generated from `#/paths//items/{itemName}/members/{memberItemName}/put(addMemberToGroupItem)`. + enum addMemberToGroupItem { + static let id: Swift.String = "addMemberToGroupItem" + struct Input: Sendable, Hashable { + /// - Remark: Generated from `#/paths/items/{itemName}/members/{memberItemName}/PUT/path`. + struct Path: Sendable, Hashable { + /// item name + /// + /// - Remark: Generated from `#/paths/items/{itemName}/members/{memberItemName}/PUT/path/itemName`. + var itemName: Swift.String + /// member item name + /// + /// - Remark: Generated from `#/paths/items/{itemName}/members/{memberItemName}/PUT/path/memberItemName`. + var memberItemName: Swift.String + /// Creates a new `Path`. + /// + /// - Parameters: + /// - itemName: item name + /// - memberItemName: member item name + init(itemName: Swift.String, + memberItemName: Swift.String) { + self.itemName = itemName + self.memberItemName = memberItemName + } + } + + var path: Operations.addMemberToGroupItem.Input.Path + /// Creates a new `Input`. + /// + /// - Parameters: + /// - path: + init(path: Operations.addMemberToGroupItem.Input.Path) { + self.path = path + } + } + + enum Output: Sendable, Hashable { + struct Ok: Sendable, Hashable { + /// Creates a new `Ok`. + init() {} + } + + /// OK + /// + /// - Remark: Generated from `#/paths//items/{itemName}/members/{memberItemName}/put(addMemberToGroupItem)/responses/200`. + /// + /// HTTP response code: `200 ok`. + case ok(Operations.addMemberToGroupItem.Output.Ok) + /// The associated value of the enum case if `self` is `.ok`. + /// + /// - Throws: An error if `self` is not `.ok`. + /// - SeeAlso: `.ok`. + var ok: Operations.addMemberToGroupItem.Output.Ok { + get throws { + switch self { + case let .ok(response): + response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "ok", + response: self + ) + } + } + } + + struct NotFound: Sendable, Hashable { + /// Creates a new `NotFound`. + init() {} + } + + /// Item or member item not found or item is not of type group item. + /// + /// - Remark: Generated from `#/paths//items/{itemName}/members/{memberItemName}/put(addMemberToGroupItem)/responses/404`. + /// + /// HTTP response code: `404 notFound`. + case notFound(Operations.addMemberToGroupItem.Output.NotFound) + /// The associated value of the enum case if `self` is `.notFound`. + /// + /// - Throws: An error if `self` is not `.notFound`. + /// - SeeAlso: `.notFound`. + var notFound: Operations.addMemberToGroupItem.Output.NotFound { + get throws { + switch self { + case let .notFound(response): + response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "notFound", + response: self + ) + } + } + } + + struct MethodNotAllowed: Sendable, Hashable { + /// Creates a new `MethodNotAllowed`. + init() {} + } + + /// Member item is not editable. + /// + /// - Remark: Generated from `#/paths//items/{itemName}/members/{memberItemName}/put(addMemberToGroupItem)/responses/405`. + /// + /// HTTP response code: `405 methodNotAllowed`. + case methodNotAllowed(Operations.addMemberToGroupItem.Output.MethodNotAllowed) + /// The associated value of the enum case if `self` is `.methodNotAllowed`. + /// + /// - Throws: An error if `self` is not `.methodNotAllowed`. + /// - SeeAlso: `.methodNotAllowed`. + var methodNotAllowed: Operations.addMemberToGroupItem.Output.MethodNotAllowed { + get throws { + switch self { + case let .methodNotAllowed(response): + response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "methodNotAllowed", + response: self + ) + } + } + } + + /// Undocumented response. + /// + /// A response with a code that is not documented in the OpenAPI document. + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + } + + /// Removes an existing member from a group item. + /// + /// - Remark: HTTP `DELETE /items/{itemName}/members/{memberItemName}`. + /// - Remark: Generated from `#/paths//items/{itemName}/members/{memberItemName}/delete(removeMemberFromGroupItem)`. + enum removeMemberFromGroupItem { + static let id: Swift.String = "removeMemberFromGroupItem" + struct Input: Sendable, Hashable { + /// - Remark: Generated from `#/paths/items/{itemName}/members/{memberItemName}/DELETE/path`. + struct Path: Sendable, Hashable { + /// item name + /// + /// - Remark: Generated from `#/paths/items/{itemName}/members/{memberItemName}/DELETE/path/itemName`. + var itemName: Swift.String + /// member item name + /// + /// - Remark: Generated from `#/paths/items/{itemName}/members/{memberItemName}/DELETE/path/memberItemName`. + var memberItemName: Swift.String + /// Creates a new `Path`. + /// + /// - Parameters: + /// - itemName: item name + /// - memberItemName: member item name + init(itemName: Swift.String, + memberItemName: Swift.String) { + self.itemName = itemName + self.memberItemName = memberItemName + } + } + + var path: Operations.removeMemberFromGroupItem.Input.Path + /// Creates a new `Input`. + /// + /// - Parameters: + /// - path: + init(path: Operations.removeMemberFromGroupItem.Input.Path) { + self.path = path + } + } + + enum Output: Sendable, Hashable { + struct Ok: Sendable, Hashable { + /// Creates a new `Ok`. + init() {} + } + + /// OK + /// + /// - Remark: Generated from `#/paths//items/{itemName}/members/{memberItemName}/delete(removeMemberFromGroupItem)/responses/200`. + /// + /// HTTP response code: `200 ok`. + case ok(Operations.removeMemberFromGroupItem.Output.Ok) + /// The associated value of the enum case if `self` is `.ok`. + /// + /// - Throws: An error if `self` is not `.ok`. + /// - SeeAlso: `.ok`. + var ok: Operations.removeMemberFromGroupItem.Output.Ok { + get throws { + switch self { + case let .ok(response): + response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "ok", + response: self + ) + } + } + } + + struct NotFound: Sendable, Hashable { + /// Creates a new `NotFound`. + init() {} + } + + /// Item or member item not found or item is not of type group item. + /// + /// - Remark: Generated from `#/paths//items/{itemName}/members/{memberItemName}/delete(removeMemberFromGroupItem)/responses/404`. + /// + /// HTTP response code: `404 notFound`. + case notFound(Operations.removeMemberFromGroupItem.Output.NotFound) + /// The associated value of the enum case if `self` is `.notFound`. + /// + /// - Throws: An error if `self` is not `.notFound`. + /// - SeeAlso: `.notFound`. + var notFound: Operations.removeMemberFromGroupItem.Output.NotFound { + get throws { + switch self { + case let .notFound(response): + response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "notFound", + response: self + ) + } + } + } + + struct MethodNotAllowed: Sendable, Hashable { + /// Creates a new `MethodNotAllowed`. + init() {} + } + + /// Member item is not editable. + /// + /// - Remark: Generated from `#/paths//items/{itemName}/members/{memberItemName}/delete(removeMemberFromGroupItem)/responses/405`. + /// + /// HTTP response code: `405 methodNotAllowed`. + case methodNotAllowed(Operations.removeMemberFromGroupItem.Output.MethodNotAllowed) + /// The associated value of the enum case if `self` is `.methodNotAllowed`. + /// + /// - Throws: An error if `self` is not `.methodNotAllowed`. + /// - SeeAlso: `.methodNotAllowed`. + var methodNotAllowed: Operations.removeMemberFromGroupItem.Output.MethodNotAllowed { + get throws { + switch self { + case let .methodNotAllowed(response): + response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "methodNotAllowed", + response: self + ) + } + } + } + + /// Undocumented response. + /// + /// A response with a code that is not documented in the OpenAPI document. + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + } + + /// Adds metadata to an item. + /// + /// - Remark: HTTP `PUT /items/{itemname}/metadata/{namespace}`. + /// - Remark: Generated from `#/paths//items/{itemname}/metadata/{namespace}/put(addMetadataToItem)`. + enum addMetadataToItem { + static let id: Swift.String = "addMetadataToItem" + struct Input: Sendable, Hashable { + /// - Remark: Generated from `#/paths/items/{itemname}/metadata/{namespace}/PUT/path`. + struct Path: Sendable, Hashable { + /// item name + /// + /// - Remark: Generated from `#/paths/items/{itemname}/metadata/{namespace}/PUT/path/itemname`. + var itemname: Swift.String + /// namespace + /// + /// - Remark: Generated from `#/paths/items/{itemname}/metadata/{namespace}/PUT/path/namespace`. + var namespace: Swift.String + /// Creates a new `Path`. + /// + /// - Parameters: + /// - itemname: item name + /// - namespace: namespace + init(itemname: Swift.String, + namespace: Swift.String) { + self.itemname = itemname + self.namespace = namespace + } + } + + var path: Operations.addMetadataToItem.Input.Path + /// - Remark: Generated from `#/paths/items/{itemname}/metadata/{namespace}/PUT/requestBody`. + enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/items/{itemname}/metadata/{namespace}/PUT/requestBody/content/application\/json`. + case json(Components.Schemas.MetadataDTO) + } + + var body: Operations.addMetadataToItem.Input.Body + /// Creates a new `Input`. + /// + /// - Parameters: + /// - path: + /// - body: + init(path: Operations.addMetadataToItem.Input.Path, + body: Operations.addMetadataToItem.Input.Body) { + self.path = path + self.body = body + } + } + + enum Output: Sendable, Hashable { + struct Ok: Sendable, Hashable { + /// Creates a new `Ok`. + init() {} + } + + /// OK + /// + /// - Remark: Generated from `#/paths//items/{itemname}/metadata/{namespace}/put(addMetadataToItem)/responses/200`. + /// + /// HTTP response code: `200 ok`. + case ok(Operations.addMetadataToItem.Output.Ok) + /// The associated value of the enum case if `self` is `.ok`. + /// + /// - Throws: An error if `self` is not `.ok`. + /// - SeeAlso: `.ok`. + var ok: Operations.addMetadataToItem.Output.Ok { + get throws { + switch self { + case let .ok(response): + response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "ok", + response: self + ) + } + } + } + + struct Created: Sendable, Hashable { + /// Creates a new `Created`. + init() {} + } + + /// Created + /// + /// - Remark: Generated from `#/paths//items/{itemname}/metadata/{namespace}/put(addMetadataToItem)/responses/201`. + /// + /// HTTP response code: `201 created`. + case created(Operations.addMetadataToItem.Output.Created) + /// The associated value of the enum case if `self` is `.created`. + /// + /// - Throws: An error if `self` is not `.created`. + /// - SeeAlso: `.created`. + var created: Operations.addMetadataToItem.Output.Created { + get throws { + switch self { + case let .created(response): + response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "created", + response: self + ) + } + } + } + + struct BadRequest: Sendable, Hashable { + /// Creates a new `BadRequest`. + init() {} + } + + /// Metadata value empty. + /// + /// - Remark: Generated from `#/paths//items/{itemname}/metadata/{namespace}/put(addMetadataToItem)/responses/400`. + /// + /// HTTP response code: `400 badRequest`. + case badRequest(Operations.addMetadataToItem.Output.BadRequest) + /// The associated value of the enum case if `self` is `.badRequest`. + /// + /// - Throws: An error if `self` is not `.badRequest`. + /// - SeeAlso: `.badRequest`. + var badRequest: Operations.addMetadataToItem.Output.BadRequest { + get throws { + switch self { + case let .badRequest(response): + response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "badRequest", + response: self + ) + } + } + } + + struct NotFound: Sendable, Hashable { + /// Creates a new `NotFound`. + init() {} + } + + /// Item not found. + /// + /// - Remark: Generated from `#/paths//items/{itemname}/metadata/{namespace}/put(addMetadataToItem)/responses/404`. + /// + /// HTTP response code: `404 notFound`. + case notFound(Operations.addMetadataToItem.Output.NotFound) + /// The associated value of the enum case if `self` is `.notFound`. + /// + /// - Throws: An error if `self` is not `.notFound`. + /// - SeeAlso: `.notFound`. + var notFound: Operations.addMetadataToItem.Output.NotFound { + get throws { + switch self { + case let .notFound(response): + response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "notFound", + response: self + ) + } + } + } + + struct MethodNotAllowed: Sendable, Hashable { + /// Creates a new `MethodNotAllowed`. + init() {} + } + + /// Metadata not editable. + /// + /// - Remark: Generated from `#/paths//items/{itemname}/metadata/{namespace}/put(addMetadataToItem)/responses/405`. + /// + /// HTTP response code: `405 methodNotAllowed`. + case methodNotAllowed(Operations.addMetadataToItem.Output.MethodNotAllowed) + /// The associated value of the enum case if `self` is `.methodNotAllowed`. + /// + /// - Throws: An error if `self` is not `.methodNotAllowed`. + /// - SeeAlso: `.methodNotAllowed`. + var methodNotAllowed: Operations.addMetadataToItem.Output.MethodNotAllowed { + get throws { + switch self { + case let .methodNotAllowed(response): + response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "methodNotAllowed", + response: self + ) + } + } + } + + /// Undocumented response. + /// + /// A response with a code that is not documented in the OpenAPI document. + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + } + + /// Removes metadata from an item. + /// + /// - Remark: HTTP `DELETE /items/{itemname}/metadata/{namespace}`. + /// - Remark: Generated from `#/paths//items/{itemname}/metadata/{namespace}/delete(removeMetadataFromItem)`. + enum removeMetadataFromItem { + static let id: Swift.String = "removeMetadataFromItem" + struct Input: Sendable, Hashable { + /// - Remark: Generated from `#/paths/items/{itemname}/metadata/{namespace}/DELETE/path`. + struct Path: Sendable, Hashable { + /// item name + /// + /// - Remark: Generated from `#/paths/items/{itemname}/metadata/{namespace}/DELETE/path/itemname`. + var itemname: Swift.String + /// namespace + /// + /// - Remark: Generated from `#/paths/items/{itemname}/metadata/{namespace}/DELETE/path/namespace`. + var namespace: Swift.String + /// Creates a new `Path`. + /// + /// - Parameters: + /// - itemname: item name + /// - namespace: namespace + init(itemname: Swift.String, + namespace: Swift.String) { + self.itemname = itemname + self.namespace = namespace + } + } + + var path: Operations.removeMetadataFromItem.Input.Path + /// Creates a new `Input`. + /// + /// - Parameters: + /// - path: + init(path: Operations.removeMetadataFromItem.Input.Path) { + self.path = path + } + } + + enum Output: Sendable, Hashable { + struct Ok: Sendable, Hashable { + /// Creates a new `Ok`. + init() {} + } + + /// OK + /// + /// - Remark: Generated from `#/paths//items/{itemname}/metadata/{namespace}/delete(removeMetadataFromItem)/responses/200`. + /// + /// HTTP response code: `200 ok`. + case ok(Operations.removeMetadataFromItem.Output.Ok) + /// The associated value of the enum case if `self` is `.ok`. + /// + /// - Throws: An error if `self` is not `.ok`. + /// - SeeAlso: `.ok`. + var ok: Operations.removeMetadataFromItem.Output.Ok { + get throws { + switch self { + case let .ok(response): + response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "ok", + response: self + ) + } + } + } + + struct NotFound: Sendable, Hashable { + /// Creates a new `NotFound`. + init() {} + } + + /// Item not found. + /// + /// - Remark: Generated from `#/paths//items/{itemname}/metadata/{namespace}/delete(removeMetadataFromItem)/responses/404`. + /// + /// HTTP response code: `404 notFound`. + case notFound(Operations.removeMetadataFromItem.Output.NotFound) + /// The associated value of the enum case if `self` is `.notFound`. + /// + /// - Throws: An error if `self` is not `.notFound`. + /// - SeeAlso: `.notFound`. + var notFound: Operations.removeMetadataFromItem.Output.NotFound { + get throws { + switch self { + case let .notFound(response): + response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "notFound", + response: self + ) + } + } + } + + struct MethodNotAllowed: Sendable, Hashable { + /// Creates a new `MethodNotAllowed`. + init() {} + } + + /// Meta data not editable. + /// + /// - Remark: Generated from `#/paths//items/{itemname}/metadata/{namespace}/delete(removeMetadataFromItem)/responses/405`. + /// + /// HTTP response code: `405 methodNotAllowed`. + case methodNotAllowed(Operations.removeMetadataFromItem.Output.MethodNotAllowed) + /// The associated value of the enum case if `self` is `.methodNotAllowed`. + /// + /// - Throws: An error if `self` is not `.methodNotAllowed`. + /// - SeeAlso: `.methodNotAllowed`. + var methodNotAllowed: Operations.removeMetadataFromItem.Output.MethodNotAllowed { + get throws { + switch self { + case let .methodNotAllowed(response): + response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "methodNotAllowed", + response: self + ) + } + } + } + + /// Undocumented response. + /// + /// A response with a code that is not documented in the OpenAPI document. + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + } + + /// Adds a tag to an item. + /// + /// - Remark: HTTP `PUT /items/{itemname}/tags/{tag}`. + /// - Remark: Generated from `#/paths//items/{itemname}/tags/{tag}/put(addTagToItem)`. + enum addTagToItem { + static let id: Swift.String = "addTagToItem" + struct Input: Sendable, Hashable { + /// - Remark: Generated from `#/paths/items/{itemname}/tags/{tag}/PUT/path`. + struct Path: Sendable, Hashable { + /// item name + /// + /// - Remark: Generated from `#/paths/items/{itemname}/tags/{tag}/PUT/path/itemname`. + var itemname: Swift.String + /// tag + /// + /// - Remark: Generated from `#/paths/items/{itemname}/tags/{tag}/PUT/path/tag`. + var tag: Swift.String + /// Creates a new `Path`. + /// + /// - Parameters: + /// - itemname: item name + /// - tag: tag + init(itemname: Swift.String, + tag: Swift.String) { + self.itemname = itemname + self.tag = tag + } + } + + var path: Operations.addTagToItem.Input.Path + /// Creates a new `Input`. + /// + /// - Parameters: + /// - path: + init(path: Operations.addTagToItem.Input.Path) { + self.path = path + } + } + + enum Output: Sendable, Hashable { + struct Ok: Sendable, Hashable { + /// Creates a new `Ok`. + init() {} + } + + /// OK + /// + /// - Remark: Generated from `#/paths//items/{itemname}/tags/{tag}/put(addTagToItem)/responses/200`. + /// + /// HTTP response code: `200 ok`. + case ok(Operations.addTagToItem.Output.Ok) + /// The associated value of the enum case if `self` is `.ok`. + /// + /// - Throws: An error if `self` is not `.ok`. + /// - SeeAlso: `.ok`. + var ok: Operations.addTagToItem.Output.Ok { + get throws { + switch self { + case let .ok(response): + response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "ok", + response: self + ) + } + } + } + + struct NotFound: Sendable, Hashable { + /// Creates a new `NotFound`. + init() {} + } + + /// Item not found. + /// + /// - Remark: Generated from `#/paths//items/{itemname}/tags/{tag}/put(addTagToItem)/responses/404`. + /// + /// HTTP response code: `404 notFound`. + case notFound(Operations.addTagToItem.Output.NotFound) + /// The associated value of the enum case if `self` is `.notFound`. + /// + /// - Throws: An error if `self` is not `.notFound`. + /// - SeeAlso: `.notFound`. + var notFound: Operations.addTagToItem.Output.NotFound { + get throws { + switch self { + case let .notFound(response): + response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "notFound", + response: self + ) + } + } + } + + struct MethodNotAllowed: Sendable, Hashable { + /// Creates a new `MethodNotAllowed`. + init() {} + } + + /// Item not editable. + /// + /// - Remark: Generated from `#/paths//items/{itemname}/tags/{tag}/put(addTagToItem)/responses/405`. + /// + /// HTTP response code: `405 methodNotAllowed`. + case methodNotAllowed(Operations.addTagToItem.Output.MethodNotAllowed) + /// The associated value of the enum case if `self` is `.methodNotAllowed`. + /// + /// - Throws: An error if `self` is not `.methodNotAllowed`. + /// - SeeAlso: `.methodNotAllowed`. + var methodNotAllowed: Operations.addTagToItem.Output.MethodNotAllowed { + get throws { + switch self { + case let .methodNotAllowed(response): + response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "methodNotAllowed", + response: self + ) + } + } + } + + /// Undocumented response. + /// + /// A response with a code that is not documented in the OpenAPI document. + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + } + + /// Removes a tag from an item. + /// + /// - Remark: HTTP `DELETE /items/{itemname}/tags/{tag}`. + /// - Remark: Generated from `#/paths//items/{itemname}/tags/{tag}/delete(removeTagFromItem)`. + enum removeTagFromItem { + static let id: Swift.String = "removeTagFromItem" + struct Input: Sendable, Hashable { + /// - Remark: Generated from `#/paths/items/{itemname}/tags/{tag}/DELETE/path`. + struct Path: Sendable, Hashable { + /// item name + /// + /// - Remark: Generated from `#/paths/items/{itemname}/tags/{tag}/DELETE/path/itemname`. + var itemname: Swift.String + /// tag + /// + /// - Remark: Generated from `#/paths/items/{itemname}/tags/{tag}/DELETE/path/tag`. + var tag: Swift.String + /// Creates a new `Path`. + /// + /// - Parameters: + /// - itemname: item name + /// - tag: tag + init(itemname: Swift.String, + tag: Swift.String) { + self.itemname = itemname + self.tag = tag + } + } + + var path: Operations.removeTagFromItem.Input.Path + /// Creates a new `Input`. + /// + /// - Parameters: + /// - path: + init(path: Operations.removeTagFromItem.Input.Path) { + self.path = path + } + } + + enum Output: Sendable, Hashable { + struct Ok: Sendable, Hashable { + /// Creates a new `Ok`. + init() {} + } + + /// OK + /// + /// - Remark: Generated from `#/paths//items/{itemname}/tags/{tag}/delete(removeTagFromItem)/responses/200`. + /// + /// HTTP response code: `200 ok`. + case ok(Operations.removeTagFromItem.Output.Ok) + /// The associated value of the enum case if `self` is `.ok`. + /// + /// - Throws: An error if `self` is not `.ok`. + /// - SeeAlso: `.ok`. + var ok: Operations.removeTagFromItem.Output.Ok { + get throws { + switch self { + case let .ok(response): + response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "ok", + response: self + ) + } + } + } + + struct NotFound: Sendable, Hashable { + /// Creates a new `NotFound`. + init() {} + } + + /// Item not found. + /// + /// - Remark: Generated from `#/paths//items/{itemname}/tags/{tag}/delete(removeTagFromItem)/responses/404`. + /// + /// HTTP response code: `404 notFound`. + case notFound(Operations.removeTagFromItem.Output.NotFound) + /// The associated value of the enum case if `self` is `.notFound`. + /// + /// - Throws: An error if `self` is not `.notFound`. + /// - SeeAlso: `.notFound`. + var notFound: Operations.removeTagFromItem.Output.NotFound { + get throws { + switch self { + case let .notFound(response): + response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "notFound", + response: self + ) + } + } + } + + struct MethodNotAllowed: Sendable, Hashable { + /// Creates a new `MethodNotAllowed`. + init() {} + } + + /// Item not editable. + /// + /// - Remark: Generated from `#/paths//items/{itemname}/tags/{tag}/delete(removeTagFromItem)/responses/405`. + /// + /// HTTP response code: `405 methodNotAllowed`. + case methodNotAllowed(Operations.removeTagFromItem.Output.MethodNotAllowed) + /// The associated value of the enum case if `self` is `.methodNotAllowed`. + /// + /// - Throws: An error if `self` is not `.methodNotAllowed`. + /// - SeeAlso: `.methodNotAllowed`. + var methodNotAllowed: Operations.removeTagFromItem.Output.MethodNotAllowed { + get throws { + switch self { + case let .methodNotAllowed(response): + response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "methodNotAllowed", + response: self + ) + } + } + } + + /// Undocumented response. + /// + /// A response with a code that is not documented in the OpenAPI document. + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + } + + /// Gets a single item. + /// + /// - Remark: HTTP `GET /items/{itemname}`. + /// - Remark: Generated from `#/paths//items/{itemname}/get(getItemByName)`. + enum getItemByName { + static let id: Swift.String = "getItemByName" + struct Input: Sendable, Hashable { + /// - Remark: Generated from `#/paths/items/{itemname}/GET/path`. + struct Path: Sendable, Hashable { + /// item name + /// + /// - Remark: Generated from `#/paths/items/{itemname}/GET/path/itemname`. + var itemname: Swift.String + /// Creates a new `Path`. + /// + /// - Parameters: + /// - itemname: item name + init(itemname: Swift.String) { + self.itemname = itemname + } + } + + var path: Operations.getItemByName.Input.Path + /// - Remark: Generated from `#/paths/items/{itemname}/GET/query`. + struct Query: Sendable, Hashable { + /// metadata selector - a comma separated list or a regular expression (returns all if no value given) + /// + /// - Remark: Generated from `#/paths/items/{itemname}/GET/query/metadata`. + var metadata: Swift.String? + /// get member items if the item is a group item + /// + /// - Remark: Generated from `#/paths/items/{itemname}/GET/query/recursive`. + var recursive: Swift.Bool? + /// Creates a new `Query`. + /// + /// - Parameters: + /// - metadata: metadata selector - a comma separated list or a regular expression (returns all if no value given) + /// - recursive: get member items if the item is a group item + init(metadata: Swift.String? = nil, + recursive: Swift.Bool? = nil) { + self.metadata = metadata + self.recursive = recursive + } + } + + var query: Operations.getItemByName.Input.Query + /// - Remark: Generated from `#/paths/items/{itemname}/GET/header`. + struct Headers: Sendable, Hashable { + /// language + /// + /// - Remark: Generated from `#/paths/items/{itemname}/GET/header/Accept-Language`. + var Accept_hyphen_Language: Swift.String? + var accept: [OpenAPIRuntime.AcceptHeaderContentType] + /// Creates a new `Headers`. + /// + /// - Parameters: + /// - Accept_hyphen_Language: language + /// - accept: + init(Accept_hyphen_Language: Swift.String? = nil, + accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + self.Accept_hyphen_Language = Accept_hyphen_Language + self.accept = accept + } + } + + var headers: Operations.getItemByName.Input.Headers + /// Creates a new `Input`. + /// + /// - Parameters: + /// - path: + /// - query: + /// - headers: + init(path: Operations.getItemByName.Input.Path, + query: Operations.getItemByName.Input.Query = .init(), + headers: Operations.getItemByName.Input.Headers = .init()) { + self.path = path + self.query = query + self.headers = headers + } + } + + enum Output: Sendable, Hashable { + struct Ok: Sendable, Hashable { + /// - Remark: Generated from `#/paths/items/{itemname}/GET/responses/200/content`. + enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/items/{itemname}/GET/responses/200/content/application\/json`. + case json(Components.Schemas.EnrichedItemDTO) + /// The associated value of the enum case if `self` is `.json`. + /// + /// - Throws: An error if `self` is not `.json`. + /// - SeeAlso: `.json`. + var json: Components.Schemas.EnrichedItemDTO { + get throws { + switch self { + case let .json(body): + body + } + } + } + } + + /// Received HTTP response body + var body: Operations.getItemByName.Output.Ok.Body + /// Creates a new `Ok`. + /// + /// - Parameters: + /// - body: Received HTTP response body + init(body: Operations.getItemByName.Output.Ok.Body) { + self.body = body + } + } + + /// OK + /// + /// - Remark: Generated from `#/paths//items/{itemname}/get(getItemByName)/responses/200`. + /// + /// HTTP response code: `200 ok`. + case ok(Operations.getItemByName.Output.Ok) + /// The associated value of the enum case if `self` is `.ok`. + /// + /// - Throws: An error if `self` is not `.ok`. + /// - SeeAlso: `.ok`. + var ok: Operations.getItemByName.Output.Ok { + get throws { + switch self { + case let .ok(response): + response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "ok", + response: self + ) + } + } + } + + struct NotFound: Sendable, Hashable { + /// Creates a new `NotFound`. + init() {} + } + + /// Item not found + /// + /// - Remark: Generated from `#/paths//items/{itemname}/get(getItemByName)/responses/404`. + /// + /// HTTP response code: `404 notFound`. + case notFound(Operations.getItemByName.Output.NotFound) + /// The associated value of the enum case if `self` is `.notFound`. + /// + /// - Throws: An error if `self` is not `.notFound`. + /// - SeeAlso: `.notFound`. + var notFound: Operations.getItemByName.Output.NotFound { + get throws { + switch self { + case let .notFound(response): + response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "notFound", + response: self + ) + } + } + } + + /// Undocumented response. + /// + /// A response with a code that is not documented in the OpenAPI document. + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + + enum AcceptableContentType: AcceptableProtocol { + case json + case other(Swift.String) + init?(rawValue: Swift.String) { + switch rawValue.lowercased() { + case "application/json": + self = .json + default: + self = .other(rawValue) + } + } + + var rawValue: Swift.String { + switch self { + case let .other(string): + string + case .json: + "application/json" + } + } + + static var allCases: [Self] { + [ + .json + ] + } + } + } + + /// Sends a command to an item. + /// + /// - Remark: HTTP `POST /items/{itemname}`. + /// - Remark: Generated from `#/paths//items/{itemname}/post(sendItemCommand)`. + enum sendItemCommand { + static let id: Swift.String = "sendItemCommand" + struct Input: Sendable, Hashable { + /// - Remark: Generated from `#/paths/items/{itemname}/POST/path`. + struct Path: Sendable, Hashable { + /// item name + /// + /// - Remark: Generated from `#/paths/items/{itemname}/POST/path/itemname`. + var itemname: Swift.String + /// Creates a new `Path`. + /// + /// - Parameters: + /// - itemname: item name + init(itemname: Swift.String) { + self.itemname = itemname + } + } + + var path: Operations.sendItemCommand.Input.Path + /// - Remark: Generated from `#/paths/items/{itemname}/POST/requestBody`. + enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/items/{itemname}/POST/requestBody/content/text\/plain`. + case plainText(OpenAPIRuntime.HTTPBody) + } + + var body: Operations.sendItemCommand.Input.Body + /// Creates a new `Input`. + /// + /// - Parameters: + /// - path: + /// - body: + init(path: Operations.sendItemCommand.Input.Path, + body: Operations.sendItemCommand.Input.Body) { + self.path = path + self.body = body + } + } + + enum Output: Sendable, Hashable { + struct Ok: Sendable, Hashable { + /// Creates a new `Ok`. + init() {} + } + + /// OK + /// + /// - Remark: Generated from `#/paths//items/{itemname}/post(sendItemCommand)/responses/200`. + /// + /// HTTP response code: `200 ok`. + case ok(Operations.sendItemCommand.Output.Ok) + /// The associated value of the enum case if `self` is `.ok`. + /// + /// - Throws: An error if `self` is not `.ok`. + /// - SeeAlso: `.ok`. + var ok: Operations.sendItemCommand.Output.Ok { + get throws { + switch self { + case let .ok(response): + response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "ok", + response: self + ) + } + } + } + + struct BadRequest: Sendable, Hashable { + /// Creates a new `BadRequest`. + init() {} + } + + /// Item command null + /// + /// - Remark: Generated from `#/paths//items/{itemname}/post(sendItemCommand)/responses/400`. + /// + /// HTTP response code: `400 badRequest`. + case badRequest(Operations.sendItemCommand.Output.BadRequest) + /// The associated value of the enum case if `self` is `.badRequest`. + /// + /// - Throws: An error if `self` is not `.badRequest`. + /// - SeeAlso: `.badRequest`. + var badRequest: Operations.sendItemCommand.Output.BadRequest { + get throws { + switch self { + case let .badRequest(response): + response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "badRequest", + response: self + ) + } + } + } + + struct NotFound: Sendable, Hashable { + /// Creates a new `NotFound`. + init() {} + } + + /// Item not found + /// + /// - Remark: Generated from `#/paths//items/{itemname}/post(sendItemCommand)/responses/404`. + /// + /// HTTP response code: `404 notFound`. + case notFound(Operations.sendItemCommand.Output.NotFound) + /// The associated value of the enum case if `self` is `.notFound`. + /// + /// - Throws: An error if `self` is not `.notFound`. + /// - SeeAlso: `.notFound`. + var notFound: Operations.sendItemCommand.Output.NotFound { + get throws { + switch self { + case let .notFound(response): + response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "notFound", + response: self + ) + } + } + } + + /// Undocumented response. + /// + /// A response with a code that is not documented in the OpenAPI document. + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + } + + /// Adds a new item to the registry or updates the existing item. + /// + /// - Remark: HTTP `PUT /items/{itemname}`. + /// - Remark: Generated from `#/paths//items/{itemname}/put(addOrUpdateItemInRegistry)`. + enum addOrUpdateItemInRegistry { + static let id: Swift.String = "addOrUpdateItemInRegistry" + struct Input: Sendable, Hashable { + /// - Remark: Generated from `#/paths/items/{itemname}/PUT/path`. + struct Path: Sendable, Hashable { + /// item name + /// + /// - Remark: Generated from `#/paths/items/{itemname}/PUT/path/itemname`. + var itemname: Swift.String + /// Creates a new `Path`. + /// + /// - Parameters: + /// - itemname: item name + init(itemname: Swift.String) { + self.itemname = itemname + } + } + + var path: Operations.addOrUpdateItemInRegistry.Input.Path + /// - Remark: Generated from `#/paths/items/{itemname}/PUT/header`. + struct Headers: Sendable, Hashable { + /// language + /// + /// - Remark: Generated from `#/paths/items/{itemname}/PUT/header/Accept-Language`. + var Accept_hyphen_Language: Swift.String? + var accept: [OpenAPIRuntime.AcceptHeaderContentType] + /// Creates a new `Headers`. + /// + /// - Parameters: + /// - Accept_hyphen_Language: language + /// - accept: + init(Accept_hyphen_Language: Swift.String? = nil, + accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + self.Accept_hyphen_Language = Accept_hyphen_Language + self.accept = accept + } + } + + var headers: Operations.addOrUpdateItemInRegistry.Input.Headers + /// - Remark: Generated from `#/paths/items/{itemname}/PUT/requestBody`. + enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/items/{itemname}/PUT/requestBody/content/application\/json`. + case json(Components.Schemas.GroupItemDTO) + } + + var body: Operations.addOrUpdateItemInRegistry.Input.Body + /// Creates a new `Input`. + /// + /// - Parameters: + /// - path: + /// - headers: + /// - body: + init(path: Operations.addOrUpdateItemInRegistry.Input.Path, + headers: Operations.addOrUpdateItemInRegistry.Input.Headers = .init(), + body: Operations.addOrUpdateItemInRegistry.Input.Body) { + self.path = path + self.headers = headers + self.body = body + } + } + + enum Output: Sendable, Hashable { + struct Ok: Sendable, Hashable { + /// - Remark: Generated from `#/paths/items/{itemname}/PUT/responses/200/content`. + enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/items/{itemname}/PUT/responses/200/content/*\/*`. + case any(OpenAPIRuntime.HTTPBody) + /// The associated value of the enum case if `self` is `.any`. + /// + /// - Throws: An error if `self` is not `.any`. + /// - SeeAlso: `.any`. + var any: OpenAPIRuntime.HTTPBody { + get throws { + switch self { + case let .any(body): + body + } + } + } + } + + /// Received HTTP response body + var body: Operations.addOrUpdateItemInRegistry.Output.Ok.Body + /// Creates a new `Ok`. + /// + /// - Parameters: + /// - body: Received HTTP response body + init(body: Operations.addOrUpdateItemInRegistry.Output.Ok.Body) { + self.body = body + } + } + + /// OK + /// + /// - Remark: Generated from `#/paths//items/{itemname}/put(addOrUpdateItemInRegistry)/responses/200`. + /// + /// HTTP response code: `200 ok`. + case ok(Operations.addOrUpdateItemInRegistry.Output.Ok) + /// The associated value of the enum case if `self` is `.ok`. + /// + /// - Throws: An error if `self` is not `.ok`. + /// - SeeAlso: `.ok`. + var ok: Operations.addOrUpdateItemInRegistry.Output.Ok { + get throws { + switch self { + case let .ok(response): + response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "ok", + response: self + ) + } + } + } + + struct Created: Sendable, Hashable { + /// Creates a new `Created`. + init() {} + } + + /// Item created. + /// + /// - Remark: Generated from `#/paths//items/{itemname}/put(addOrUpdateItemInRegistry)/responses/201`. + /// + /// HTTP response code: `201 created`. + case created(Operations.addOrUpdateItemInRegistry.Output.Created) + /// The associated value of the enum case if `self` is `.created`. + /// + /// - Throws: An error if `self` is not `.created`. + /// - SeeAlso: `.created`. + var created: Operations.addOrUpdateItemInRegistry.Output.Created { + get throws { + switch self { + case let .created(response): + response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "created", + response: self + ) + } + } + } + + struct BadRequest: Sendable, Hashable { + /// Creates a new `BadRequest`. + init() {} + } + + /// Payload invalid. + /// + /// - Remark: Generated from `#/paths//items/{itemname}/put(addOrUpdateItemInRegistry)/responses/400`. + /// + /// HTTP response code: `400 badRequest`. + case badRequest(Operations.addOrUpdateItemInRegistry.Output.BadRequest) + /// The associated value of the enum case if `self` is `.badRequest`. + /// + /// - Throws: An error if `self` is not `.badRequest`. + /// - SeeAlso: `.badRequest`. + var badRequest: Operations.addOrUpdateItemInRegistry.Output.BadRequest { + get throws { + switch self { + case let .badRequest(response): + response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "badRequest", + response: self + ) + } + } + } + + struct NotFound: Sendable, Hashable { + /// Creates a new `NotFound`. + init() {} + } + + /// Item not found or name in path invalid. + /// + /// - Remark: Generated from `#/paths//items/{itemname}/put(addOrUpdateItemInRegistry)/responses/404`. + /// + /// HTTP response code: `404 notFound`. + case notFound(Operations.addOrUpdateItemInRegistry.Output.NotFound) + /// The associated value of the enum case if `self` is `.notFound`. + /// + /// - Throws: An error if `self` is not `.notFound`. + /// - SeeAlso: `.notFound`. + var notFound: Operations.addOrUpdateItemInRegistry.Output.NotFound { + get throws { + switch self { + case let .notFound(response): + response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "notFound", + response: self + ) + } + } + } + + struct MethodNotAllowed: Sendable, Hashable { + /// Creates a new `MethodNotAllowed`. + init() {} + } + + /// Item not editable. + /// + /// - Remark: Generated from `#/paths//items/{itemname}/put(addOrUpdateItemInRegistry)/responses/405`. + /// + /// HTTP response code: `405 methodNotAllowed`. + case methodNotAllowed(Operations.addOrUpdateItemInRegistry.Output.MethodNotAllowed) + /// The associated value of the enum case if `self` is `.methodNotAllowed`. + /// + /// - Throws: An error if `self` is not `.methodNotAllowed`. + /// - SeeAlso: `.methodNotAllowed`. + var methodNotAllowed: Operations.addOrUpdateItemInRegistry.Output.MethodNotAllowed { + get throws { + switch self { + case let .methodNotAllowed(response): + response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "methodNotAllowed", + response: self + ) + } + } + } + + /// Undocumented response. + /// + /// A response with a code that is not documented in the OpenAPI document. + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + + enum AcceptableContentType: AcceptableProtocol { + case any + case other(Swift.String) + init?(rawValue: Swift.String) { + switch rawValue.lowercased() { + case "*/*": + self = .any + default: + self = .other(rawValue) + } + } + + var rawValue: Swift.String { + switch self { + case let .other(string): + string + case .any: + "*/*" + } + } + + static var allCases: [Self] { + [ + .any + ] + } + } + } + + /// Removes an item from the registry. + /// + /// - Remark: HTTP `DELETE /items/{itemname}`. + /// - Remark: Generated from `#/paths//items/{itemname}/delete(removeItemFromRegistry)`. + enum removeItemFromRegistry { + static let id: Swift.String = "removeItemFromRegistry" + struct Input: Sendable, Hashable { + /// - Remark: Generated from `#/paths/items/{itemname}/DELETE/path`. + struct Path: Sendable, Hashable { + /// item name + /// + /// - Remark: Generated from `#/paths/items/{itemname}/DELETE/path/itemname`. + var itemname: Swift.String + /// Creates a new `Path`. + /// + /// - Parameters: + /// - itemname: item name + init(itemname: Swift.String) { + self.itemname = itemname + } + } + + var path: Operations.removeItemFromRegistry.Input.Path + /// Creates a new `Input`. + /// + /// - Parameters: + /// - path: + init(path: Operations.removeItemFromRegistry.Input.Path) { + self.path = path + } + } + + enum Output: Sendable, Hashable { + struct Ok: Sendable, Hashable { + /// Creates a new `Ok`. + init() {} + } + + /// OK + /// + /// - Remark: Generated from `#/paths//items/{itemname}/delete(removeItemFromRegistry)/responses/200`. + /// + /// HTTP response code: `200 ok`. + case ok(Operations.removeItemFromRegistry.Output.Ok) + /// The associated value of the enum case if `self` is `.ok`. + /// + /// - Throws: An error if `self` is not `.ok`. + /// - SeeAlso: `.ok`. + var ok: Operations.removeItemFromRegistry.Output.Ok { + get throws { + switch self { + case let .ok(response): + response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "ok", + response: self + ) + } + } + } + + struct NotFound: Sendable, Hashable { + /// Creates a new `NotFound`. + init() {} + } + + /// Item not found or item is not editable. + /// + /// - Remark: Generated from `#/paths//items/{itemname}/delete(removeItemFromRegistry)/responses/404`. + /// + /// HTTP response code: `404 notFound`. + case notFound(Operations.removeItemFromRegistry.Output.NotFound) + /// The associated value of the enum case if `self` is `.notFound`. + /// + /// - Throws: An error if `self` is not `.notFound`. + /// - SeeAlso: `.notFound`. + var notFound: Operations.removeItemFromRegistry.Output.NotFound { + get throws { + switch self { + case let .notFound(response): + response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "notFound", + response: self + ) + } + } + } + + /// Undocumented response. + /// + /// A response with a code that is not documented in the OpenAPI document. + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + } + + /// Get all available items. + /// + /// - Remark: HTTP `GET /items`. + /// - Remark: Generated from `#/paths//items/get(getItems)`. + enum getItems { + static let id: Swift.String = "getItems" + struct Input: Sendable, Hashable { + /// - Remark: Generated from `#/paths/items/GET/query`. + struct Query: Sendable, Hashable { + /// item type filter + /// + /// - Remark: Generated from `#/paths/items/GET/query/type`. + var _type: Swift.String? + /// item tag filter + /// + /// - Remark: Generated from `#/paths/items/GET/query/tags`. + var tags: Swift.String? + /// metadata selector - a comma separated list or a regular expression (returns all if no value given) + /// + /// - Remark: Generated from `#/paths/items/GET/query/metadata`. + var metadata: Swift.String? + /// get member items recursively + /// + /// - Remark: Generated from `#/paths/items/GET/query/recursive`. + var recursive: Swift.Bool? + /// limit output to the given fields (comma separated) + /// + /// - Remark: Generated from `#/paths/items/GET/query/fields`. + var fields: Swift.String? + /// provides a cacheable list of values not expected to change regularly and checks the If-Modified-Since header, all other parameters are ignored except "metadata" + /// + /// - Remark: Generated from `#/paths/items/GET/query/staticDataOnly`. + var staticDataOnly: Swift.Bool? + /// Creates a new `Query`. + /// + /// - Parameters: + /// - _type: item type filter + /// - tags: item tag filter + /// - metadata: metadata selector - a comma separated list or a regular expression (returns all if no value given) + /// - recursive: get member items recursively + /// - fields: limit output to the given fields (comma separated) + /// - staticDataOnly: provides a cacheable list of values not expected to change regularly and checks the If-Modified-Since header, all other parameters are ignored except "metadata" + init(_type: Swift.String? = nil, + tags: Swift.String? = nil, + metadata: Swift.String? = nil, + recursive: Swift.Bool? = nil, + fields: Swift.String? = nil, + staticDataOnly: Swift.Bool? = nil) { + self._type = _type + self.tags = tags + self.metadata = metadata + self.recursive = recursive + self.fields = fields + self.staticDataOnly = staticDataOnly + } + } + + var query: Operations.getItems.Input.Query + /// - Remark: Generated from `#/paths/items/GET/header`. + struct Headers: Sendable, Hashable { + /// language + /// + /// - Remark: Generated from `#/paths/items/GET/header/Accept-Language`. + var Accept_hyphen_Language: Swift.String? + var accept: [OpenAPIRuntime.AcceptHeaderContentType] + /// Creates a new `Headers`. + /// + /// - Parameters: + /// - Accept_hyphen_Language: language + /// - accept: + init(Accept_hyphen_Language: Swift.String? = nil, + accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + self.Accept_hyphen_Language = Accept_hyphen_Language + self.accept = accept + } + } + + var headers: Operations.getItems.Input.Headers + /// Creates a new `Input`. + /// + /// - Parameters: + /// - query: + /// - headers: + init(query: Operations.getItems.Input.Query = .init(), + headers: Operations.getItems.Input.Headers = .init()) { + self.query = query + self.headers = headers + } + } + + enum Output: Sendable, Hashable { + struct Ok: Sendable, Hashable { + /// - Remark: Generated from `#/paths/items/GET/responses/200/content`. + enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/items/GET/responses/200/content/application\/json`. + case json([Components.Schemas.EnrichedItemDTO]) + /// The associated value of the enum case if `self` is `.json`. + /// + /// - Throws: An error if `self` is not `.json`. + /// - SeeAlso: `.json`. + var json: [Components.Schemas.EnrichedItemDTO] { + get throws { + switch self { + case let .json(body): + body + } + } + } + } + + /// Received HTTP response body + var body: Operations.getItems.Output.Ok.Body + /// Creates a new `Ok`. + /// + /// - Parameters: + /// - body: Received HTTP response body + init(body: Operations.getItems.Output.Ok.Body) { + self.body = body + } + } + + /// OK + /// + /// - Remark: Generated from `#/paths//items/get(getItems)/responses/200`. + /// + /// HTTP response code: `200 ok`. + case ok(Operations.getItems.Output.Ok) + /// The associated value of the enum case if `self` is `.ok`. + /// + /// - Throws: An error if `self` is not `.ok`. + /// - SeeAlso: `.ok`. + var ok: Operations.getItems.Output.Ok { + get throws { + switch self { + case let .ok(response): + response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "ok", + response: self + ) + } + } + } + + /// Undocumented response. + /// + /// A response with a code that is not documented in the OpenAPI document. + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + + enum AcceptableContentType: AcceptableProtocol { + case json + case other(Swift.String) + init?(rawValue: Swift.String) { + switch rawValue.lowercased() { + case "application/json": + self = .json + default: + self = .other(rawValue) + } + } + + var rawValue: Swift.String { + switch self { + case let .other(string): + string + case .json: + "application/json" + } + } + + static var allCases: [Self] { + [ + .json + ] + } + } + } + + /// Adds a list of items to the registry or updates the existing items. + /// + /// - Remark: HTTP `PUT /items`. + /// - Remark: Generated from `#/paths//items/put(addOrUpdateItemsInRegistry)`. + enum addOrUpdateItemsInRegistry { + static let id: Swift.String = "addOrUpdateItemsInRegistry" + struct Input: Sendable, Hashable { + /// - Remark: Generated from `#/paths/items/PUT/header`. + struct Headers: Sendable, Hashable { + var accept: [OpenAPIRuntime.AcceptHeaderContentType] + /// Creates a new `Headers`. + /// + /// - Parameters: + /// - accept: + init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + self.accept = accept + } + } + + var headers: Operations.addOrUpdateItemsInRegistry.Input.Headers + /// - Remark: Generated from `#/paths/items/PUT/requestBody`. + enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/items/PUT/requestBody/content/application\/json`. + case json([Components.Schemas.GroupItemDTO]) + } + + var body: Operations.addOrUpdateItemsInRegistry.Input.Body + /// Creates a new `Input`. + /// + /// - Parameters: + /// - headers: + /// - body: + init(headers: Operations.addOrUpdateItemsInRegistry.Input.Headers = .init(), + body: Operations.addOrUpdateItemsInRegistry.Input.Body) { + self.headers = headers + self.body = body + } + } + + enum Output: Sendable, Hashable { + struct Ok: Sendable, Hashable { + /// - Remark: Generated from `#/paths/items/PUT/responses/200/content`. + enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/items/PUT/responses/200/content/*\/*`. + case any(OpenAPIRuntime.HTTPBody) + /// The associated value of the enum case if `self` is `.any`. + /// + /// - Throws: An error if `self` is not `.any`. + /// - SeeAlso: `.any`. + var any: OpenAPIRuntime.HTTPBody { + get throws { + switch self { + case let .any(body): + body + } + } + } + } + + /// Received HTTP response body + var body: Operations.addOrUpdateItemsInRegistry.Output.Ok.Body + /// Creates a new `Ok`. + /// + /// - Parameters: + /// - body: Received HTTP response body + init(body: Operations.addOrUpdateItemsInRegistry.Output.Ok.Body) { + self.body = body + } + } + + /// OK + /// + /// - Remark: Generated from `#/paths//items/put(addOrUpdateItemsInRegistry)/responses/200`. + /// + /// HTTP response code: `200 ok`. + case ok(Operations.addOrUpdateItemsInRegistry.Output.Ok) + /// The associated value of the enum case if `self` is `.ok`. + /// + /// - Throws: An error if `self` is not `.ok`. + /// - SeeAlso: `.ok`. + var ok: Operations.addOrUpdateItemsInRegistry.Output.Ok { + get throws { + switch self { + case let .ok(response): + response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "ok", + response: self + ) + } + } + } + + struct BadRequest: Sendable, Hashable { + /// Creates a new `BadRequest`. + init() {} + } + + /// Payload is invalid. + /// + /// - Remark: Generated from `#/paths//items/put(addOrUpdateItemsInRegistry)/responses/400`. + /// + /// HTTP response code: `400 badRequest`. + case badRequest(Operations.addOrUpdateItemsInRegistry.Output.BadRequest) + /// The associated value of the enum case if `self` is `.badRequest`. + /// + /// - Throws: An error if `self` is not `.badRequest`. + /// - SeeAlso: `.badRequest`. + var badRequest: Operations.addOrUpdateItemsInRegistry.Output.BadRequest { + get throws { + switch self { + case let .badRequest(response): + response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "badRequest", + response: self + ) + } + } + } + + /// Undocumented response. + /// + /// A response with a code that is not documented in the OpenAPI document. + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + + enum AcceptableContentType: AcceptableProtocol { + case any + case other(Swift.String) + init?(rawValue: Swift.String) { + switch rawValue.lowercased() { + case "*/*": + self = .any + default: + self = .other(rawValue) + } + } + + var rawValue: Swift.String { + switch self { + case let .other(string): + string + case .any: + "*/*" + } + } + + static var allCases: [Self] { + [ + .any + ] + } + } + } + + /// Gets the state of an item. + /// + /// - Remark: HTTP `GET /items/{itemname}/state`. + /// - Remark: Generated from `#/paths//items/{itemname}/state/get(getItemState_1)`. + enum getItemState_1 { + static let id: Swift.String = "getItemState_1" + struct Input: Sendable, Hashable { + /// - Remark: Generated from `#/paths/items/{itemname}/state/GET/path`. + struct Path: Sendable, Hashable { + /// item name + /// + /// - Remark: Generated from `#/paths/items/{itemname}/state/GET/path/itemname`. + var itemname: Swift.String + /// Creates a new `Path`. + /// + /// - Parameters: + /// - itemname: item name + init(itemname: Swift.String) { + self.itemname = itemname + } + } + + var path: Operations.getItemState_1.Input.Path + /// - Remark: Generated from `#/paths/items/{itemname}/state/GET/header`. + struct Headers: Sendable, Hashable { + var accept: [OpenAPIRuntime.AcceptHeaderContentType] + /// Creates a new `Headers`. + /// + /// - Parameters: + /// - accept: + init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + self.accept = accept + } + } + + var headers: Operations.getItemState_1.Input.Headers + /// Creates a new `Input`. + /// + /// - Parameters: + /// - path: + /// - headers: + init(path: Operations.getItemState_1.Input.Path, + headers: Operations.getItemState_1.Input.Headers = .init()) { + self.path = path + self.headers = headers + } + } + + enum Output: Sendable, Hashable { + struct Ok: Sendable, Hashable { + /// - Remark: Generated from `#/paths/items/{itemname}/state/GET/responses/200/content`. + enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/items/{itemname}/state/GET/responses/200/content/text\/plain`. + case plainText(OpenAPIRuntime.HTTPBody) + /// The associated value of the enum case if `self` is `.plainText`. + /// + /// - Throws: An error if `self` is not `.plainText`. + /// - SeeAlso: `.plainText`. + var plainText: OpenAPIRuntime.HTTPBody { + get throws { + switch self { + case let .plainText(body): + body + } + } + } + } + + /// Received HTTP response body + var body: Operations.getItemState_1.Output.Ok.Body + /// Creates a new `Ok`. + /// + /// - Parameters: + /// - body: Received HTTP response body + init(body: Operations.getItemState_1.Output.Ok.Body) { + self.body = body + } + } + + /// OK + /// + /// - Remark: Generated from `#/paths//items/{itemname}/state/get(getItemState_1)/responses/200`. + /// + /// HTTP response code: `200 ok`. + case ok(Operations.getItemState_1.Output.Ok) + /// The associated value of the enum case if `self` is `.ok`. + /// + /// - Throws: An error if `self` is not `.ok`. + /// - SeeAlso: `.ok`. + var ok: Operations.getItemState_1.Output.Ok { + get throws { + switch self { + case let .ok(response): + response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "ok", + response: self + ) + } + } + } + + struct NotFound: Sendable, Hashable { + /// Creates a new `NotFound`. + init() {} + } + + /// Item not found + /// + /// - Remark: Generated from `#/paths//items/{itemname}/state/get(getItemState_1)/responses/404`. + /// + /// HTTP response code: `404 notFound`. + case notFound(Operations.getItemState_1.Output.NotFound) + /// The associated value of the enum case if `self` is `.notFound`. + /// + /// - Throws: An error if `self` is not `.notFound`. + /// - SeeAlso: `.notFound`. + var notFound: Operations.getItemState_1.Output.NotFound { + get throws { + switch self { + case let .notFound(response): + response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "notFound", + response: self + ) + } + } + } + + /// Undocumented response. + /// + /// A response with a code that is not documented in the OpenAPI document. + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + + enum AcceptableContentType: AcceptableProtocol { + case plainText + case other(Swift.String) + init?(rawValue: Swift.String) { + switch rawValue.lowercased() { + case "text/plain": + self = .plainText + default: + self = .other(rawValue) + } + } + + var rawValue: Swift.String { + switch self { + case let .other(string): + string + case .plainText: + "text/plain" + } + } + + static var allCases: [Self] { + [ + .plainText + ] + } + } + } + + /// Updates the state of an item. + /// + /// - Remark: HTTP `PUT /items/{itemname}/state`. + /// - Remark: Generated from `#/paths//items/{itemname}/state/put(updateItemState)`. + enum updateItemState { + static let id: Swift.String = "updateItemState" + struct Input: Sendable, Hashable { + /// - Remark: Generated from `#/paths/items/{itemname}/state/PUT/path`. + struct Path: Sendable, Hashable { + /// item name + /// + /// - Remark: Generated from `#/paths/items/{itemname}/state/PUT/path/itemname`. + var itemname: Swift.String + /// Creates a new `Path`. + /// + /// - Parameters: + /// - itemname: item name + init(itemname: Swift.String) { + self.itemname = itemname + } + } + + var path: Operations.updateItemState.Input.Path + /// - Remark: Generated from `#/paths/items/{itemname}/state/PUT/header`. + struct Headers: Sendable, Hashable { + /// language + /// + /// - Remark: Generated from `#/paths/items/{itemname}/state/PUT/header/Accept-Language`. + var Accept_hyphen_Language: Swift.String? + /// Creates a new `Headers`. + /// + /// - Parameters: + /// - Accept_hyphen_Language: language + init(Accept_hyphen_Language: Swift.String? = nil) { + self.Accept_hyphen_Language = Accept_hyphen_Language + } + } + + var headers: Operations.updateItemState.Input.Headers + /// - Remark: Generated from `#/paths/items/{itemname}/state/PUT/requestBody`. + enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/items/{itemname}/state/PUT/requestBody/content/text\/plain`. + case plainText(OpenAPIRuntime.HTTPBody) + } + + var body: Operations.updateItemState.Input.Body + /// Creates a new `Input`. + /// + /// - Parameters: + /// - path: + /// - headers: + /// - body: + init(path: Operations.updateItemState.Input.Path, + headers: Operations.updateItemState.Input.Headers = .init(), + body: Operations.updateItemState.Input.Body) { + self.path = path + self.headers = headers + self.body = body + } + } + + enum Output: Sendable, Hashable { + struct Accepted: Sendable, Hashable { + /// Creates a new `Accepted`. + init() {} + } + + /// Accepted + /// + /// - Remark: Generated from `#/paths//items/{itemname}/state/put(updateItemState)/responses/202`. + /// + /// HTTP response code: `202 accepted`. + case accepted(Operations.updateItemState.Output.Accepted) + /// The associated value of the enum case if `self` is `.accepted`. + /// + /// - Throws: An error if `self` is not `.accepted`. + /// - SeeAlso: `.accepted`. + var accepted: Operations.updateItemState.Output.Accepted { + get throws { + switch self { + case let .accepted(response): + response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "accepted", + response: self + ) + } + } + } + + struct BadRequest: Sendable, Hashable { + /// Creates a new `BadRequest`. + init() {} + } + + /// Item state null + /// + /// - Remark: Generated from `#/paths//items/{itemname}/state/put(updateItemState)/responses/400`. + /// + /// HTTP response code: `400 badRequest`. + case badRequest(Operations.updateItemState.Output.BadRequest) + /// The associated value of the enum case if `self` is `.badRequest`. + /// + /// - Throws: An error if `self` is not `.badRequest`. + /// - SeeAlso: `.badRequest`. + var badRequest: Operations.updateItemState.Output.BadRequest { + get throws { + switch self { + case let .badRequest(response): + response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "badRequest", + response: self + ) + } + } + } + + struct NotFound: Sendable, Hashable { + /// Creates a new `NotFound`. + init() {} + } + + /// Item not found + /// + /// - Remark: Generated from `#/paths//items/{itemname}/state/put(updateItemState)/responses/404`. + /// + /// HTTP response code: `404 notFound`. + case notFound(Operations.updateItemState.Output.NotFound) + /// The associated value of the enum case if `self` is `.notFound`. + /// + /// - Throws: An error if `self` is not `.notFound`. + /// - SeeAlso: `.notFound`. + var notFound: Operations.updateItemState.Output.NotFound { + get throws { + switch self { + case let .notFound(response): + response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "notFound", + response: self + ) + } + } + } + + /// Undocumented response. + /// + /// A response with a code that is not documented in the OpenAPI document. + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + } + + /// Gets the namespace of an item. + /// + /// - Remark: HTTP `GET /items/{itemname}/metadata/namespaces`. + /// - Remark: Generated from `#/paths//items/{itemname}/metadata/namespaces/get(getItemNamespaces)`. + enum getItemNamespaces { + static let id: Swift.String = "getItemNamespaces" + struct Input: Sendable, Hashable { + /// - Remark: Generated from `#/paths/items/{itemname}/metadata/namespaces/GET/path`. + struct Path: Sendable, Hashable { + /// item name + /// + /// - Remark: Generated from `#/paths/items/{itemname}/metadata/namespaces/GET/path/itemname`. + var itemname: Swift.String + /// Creates a new `Path`. + /// + /// - Parameters: + /// - itemname: item name + init(itemname: Swift.String) { + self.itemname = itemname + } + } + + var path: Operations.getItemNamespaces.Input.Path + /// - Remark: Generated from `#/paths/items/{itemname}/metadata/namespaces/GET/header`. + struct Headers: Sendable, Hashable { + /// language + /// + /// - Remark: Generated from `#/paths/items/{itemname}/metadata/namespaces/GET/header/Accept-Language`. + var Accept_hyphen_Language: Swift.String? + var accept: [OpenAPIRuntime.AcceptHeaderContentType] + /// Creates a new `Headers`. + /// + /// - Parameters: + /// - Accept_hyphen_Language: language + /// - accept: + init(Accept_hyphen_Language: Swift.String? = nil, + accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + self.Accept_hyphen_Language = Accept_hyphen_Language + self.accept = accept + } + } + + var headers: Operations.getItemNamespaces.Input.Headers + /// Creates a new `Input`. + /// + /// - Parameters: + /// - path: + /// - headers: + init(path: Operations.getItemNamespaces.Input.Path, + headers: Operations.getItemNamespaces.Input.Headers = .init()) { + self.path = path + self.headers = headers + } + } + + enum Output: Sendable, Hashable { + struct Ok: Sendable, Hashable { + /// - Remark: Generated from `#/paths/items/{itemname}/metadata/namespaces/GET/responses/200/content`. + enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/items/{itemname}/metadata/namespaces/GET/responses/200/content/application\/json`. + case json(Swift.String) + /// The associated value of the enum case if `self` is `.json`. + /// + /// - Throws: An error if `self` is not `.json`. + /// - SeeAlso: `.json`. + var json: Swift.String { + get throws { + switch self { + case let .json(body): + body + } + } + } + } + + /// Received HTTP response body + var body: Operations.getItemNamespaces.Output.Ok.Body + /// Creates a new `Ok`. + /// + /// - Parameters: + /// - body: Received HTTP response body + init(body: Operations.getItemNamespaces.Output.Ok.Body) { + self.body = body + } + } + + /// OK + /// + /// - Remark: Generated from `#/paths//items/{itemname}/metadata/namespaces/get(getItemNamespaces)/responses/200`. + /// + /// HTTP response code: `200 ok`. + case ok(Operations.getItemNamespaces.Output.Ok) + /// The associated value of the enum case if `self` is `.ok`. + /// + /// - Throws: An error if `self` is not `.ok`. + /// - SeeAlso: `.ok`. + var ok: Operations.getItemNamespaces.Output.Ok { + get throws { + switch self { + case let .ok(response): + response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "ok", + response: self + ) + } + } + } + + struct NotFound: Sendable, Hashable { + /// Creates a new `NotFound`. + init() {} + } + + /// Item not found + /// + /// - Remark: Generated from `#/paths//items/{itemname}/metadata/namespaces/get(getItemNamespaces)/responses/404`. + /// + /// HTTP response code: `404 notFound`. + case notFound(Operations.getItemNamespaces.Output.NotFound) + /// The associated value of the enum case if `self` is `.notFound`. + /// + /// - Throws: An error if `self` is not `.notFound`. + /// - SeeAlso: `.notFound`. + var notFound: Operations.getItemNamespaces.Output.NotFound { + get throws { + switch self { + case let .notFound(response): + response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "notFound", + response: self + ) + } + } + } + + /// Undocumented response. + /// + /// A response with a code that is not documented in the OpenAPI document. + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + + enum AcceptableContentType: AcceptableProtocol { + case json + case other(Swift.String) + init?(rawValue: Swift.String) { + switch rawValue.lowercased() { + case "application/json": + self = .json + default: + self = .other(rawValue) + } + } + + var rawValue: Swift.String { + switch self { + case let .other(string): + string + case .json: + "application/json" + } + } + + static var allCases: [Self] { + [ + .json + ] + } + } + } + + /// Gets the item which defines the requested semantics of an item. + /// + /// - Remark: HTTP `GET /items/{itemName}/semantic/{semanticClass}`. + /// - Remark: Generated from `#/paths//items/{itemName}/semantic/{semanticClass}/get(getSemanticItem)`. + enum getSemanticItem { + static let id: Swift.String = "getSemanticItem" + struct Input: Sendable, Hashable { + /// - Remark: Generated from `#/paths/items/{itemName}/semantic/{semanticClass}/GET/path`. + struct Path: Sendable, Hashable { + /// item name + /// + /// - Remark: Generated from `#/paths/items/{itemName}/semantic/{semanticClass}/GET/path/itemName`. + var itemName: Swift.String + /// semantic class + /// + /// - Remark: Generated from `#/paths/items/{itemName}/semantic/{semanticClass}/GET/path/semanticClass`. + var semanticClass: Swift.String + /// Creates a new `Path`. + /// + /// - Parameters: + /// - itemName: item name + /// - semanticClass: semantic class + init(itemName: Swift.String, + semanticClass: Swift.String) { + self.itemName = itemName + self.semanticClass = semanticClass + } + } + + var path: Operations.getSemanticItem.Input.Path + /// - Remark: Generated from `#/paths/items/{itemName}/semantic/{semanticClass}/GET/header`. + struct Headers: Sendable, Hashable { + /// language + /// + /// - Remark: Generated from `#/paths/items/{itemName}/semantic/{semanticClass}/GET/header/Accept-Language`. + var Accept_hyphen_Language: Swift.String? + /// Creates a new `Headers`. + /// + /// - Parameters: + /// - Accept_hyphen_Language: language + init(Accept_hyphen_Language: Swift.String? = nil) { + self.Accept_hyphen_Language = Accept_hyphen_Language + } + } + + var headers: Operations.getSemanticItem.Input.Headers + /// Creates a new `Input`. + /// + /// - Parameters: + /// - path: + /// - headers: + init(path: Operations.getSemanticItem.Input.Path, + headers: Operations.getSemanticItem.Input.Headers = .init()) { + self.path = path + self.headers = headers + } + } + + enum Output: Sendable, Hashable { + struct Ok: Sendable, Hashable { + /// Creates a new `Ok`. + init() {} + } + + /// OK + /// + /// - Remark: Generated from `#/paths//items/{itemName}/semantic/{semanticClass}/get(getSemanticItem)/responses/200`. + /// + /// HTTP response code: `200 ok`. + case ok(Operations.getSemanticItem.Output.Ok) + /// The associated value of the enum case if `self` is `.ok`. + /// + /// - Throws: An error if `self` is not `.ok`. + /// - SeeAlso: `.ok`. + var ok: Operations.getSemanticItem.Output.Ok { + get throws { + switch self { + case let .ok(response): + response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "ok", + response: self + ) + } + } + } + + struct NotFound: Sendable, Hashable { + /// Creates a new `NotFound`. + init() {} + } + + /// Item not found + /// + /// - Remark: Generated from `#/paths//items/{itemName}/semantic/{semanticClass}/get(getSemanticItem)/responses/404`. + /// + /// HTTP response code: `404 notFound`. + case notFound(Operations.getSemanticItem.Output.NotFound) + /// The associated value of the enum case if `self` is `.notFound`. + /// + /// - Throws: An error if `self` is not `.notFound`. + /// - SeeAlso: `.notFound`. + var notFound: Operations.getSemanticItem.Output.NotFound { + get throws { + switch self { + case let .notFound(response): + response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "notFound", + response: self + ) + } + } + } + + /// Undocumented response. + /// + /// A response with a code that is not documented in the OpenAPI document. + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + } + + /// Remove unused/orphaned metadata. + /// + /// - Remark: HTTP `POST /items/metadata/purge`. + /// - Remark: Generated from `#/paths//items/metadata/purge/post(purgeDatabase)`. + enum purgeDatabase { + static let id: Swift.String = "purgeDatabase" + struct Input: Sendable, Hashable { + /// Creates a new `Input`. + init() {} + } + + enum Output: Sendable, Hashable { + struct Ok: Sendable, Hashable { + /// Creates a new `Ok`. + init() {} + } + + /// OK + /// + /// - Remark: Generated from `#/paths//items/metadata/purge/post(purgeDatabase)/responses/200`. + /// + /// HTTP response code: `200 ok`. + case ok(Operations.purgeDatabase.Output.Ok) + /// The associated value of the enum case if `self` is `.ok`. + /// + /// - Throws: An error if `self` is not `.ok`. + /// - SeeAlso: `.ok`. + var ok: Operations.purgeDatabase.Output.Ok { + get throws { + switch self { + case let .ok(response): + response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "ok", + response: self + ) + } + } + } + + /// Undocumented response. + /// + /// A response with a code that is not documented in the OpenAPI document. + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + } + + /// Creates a sitemap event subscription. + /// + /// - Remark: HTTP `POST /sitemaps/events/subscribe`. + /// - Remark: Generated from `#/paths//sitemaps/events/subscribe/post(createSitemapEventSubscription)`. + enum createSitemapEventSubscription { + static let id: Swift.String = "createSitemapEventSubscription" + struct Input: Sendable, Hashable { + /// - Remark: Generated from `#/paths/sitemaps/events/subscribe/POST/header`. + struct Headers: Sendable, Hashable { + var accept: [OpenAPIRuntime.AcceptHeaderContentType] + /// Creates a new `Headers`. + /// + /// - Parameters: + /// - accept: + init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + self.accept = accept + } + } + + var headers: Operations.createSitemapEventSubscription.Input.Headers + /// Creates a new `Input`. + /// + /// - Parameters: + /// - headers: + init(headers: Operations.createSitemapEventSubscription.Input.Headers = .init()) { + self.headers = headers + } + } + + enum Output: Sendable, Hashable { + struct Created: Sendable, Hashable { + /// Creates a new `Created`. + init() {} + } + + /// Subscription created. + /// + /// - Remark: Generated from `#/paths//sitemaps/events/subscribe/post(createSitemapEventSubscription)/responses/201`. + /// + /// HTTP response code: `201 created`. + case created(Operations.createSitemapEventSubscription.Output.Created) + /// The associated value of the enum case if `self` is `.created`. + /// + /// - Throws: An error if `self` is not `.created`. + /// - SeeAlso: `.created`. + var created: Operations.createSitemapEventSubscription.Output.Created { + get throws { + switch self { + case let .created(response): + response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "created", + response: self + ) + } + } + } + + struct Ok: Sendable, Hashable { + /// - Remark: Generated from `#/paths/sitemaps/events/subscribe/POST/responses/200/content`. + enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/sitemaps/events/subscribe/POST/responses/200/content/application\/json`. + case json(Components.Schemas.JerseyResponseBuilderDTO) + /// The associated value of the enum case if `self` is `.json`. + /// + /// - Throws: An error if `self` is not `.json`. + /// - SeeAlso: `.json`. + var json: Components.Schemas.JerseyResponseBuilderDTO { + get throws { + switch self { + case let .json(body): + body + } + } + } + } + + /// Received HTTP response body + var body: Operations.createSitemapEventSubscription.Output.Ok.Body + /// Creates a new `Ok`. + /// + /// - Parameters: + /// - body: Received HTTP response body + init(body: Operations.createSitemapEventSubscription.Output.Ok.Body) { + self.body = body + } + } + + /// OK + /// + /// - Remark: Generated from `#/paths//sitemaps/events/subscribe/post(createSitemapEventSubscription)/responses/200`. + /// + /// HTTP response code: `200 ok`. + case ok(Operations.createSitemapEventSubscription.Output.Ok) + /// The associated value of the enum case if `self` is `.ok`. + /// + /// - Throws: An error if `self` is not `.ok`. + /// - SeeAlso: `.ok`. + var ok: Operations.createSitemapEventSubscription.Output.Ok { + get throws { + switch self { + case let .ok(response): + response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "ok", + response: self + ) + } + } + } + + struct ServiceUnavailable: Sendable, Hashable { + /// Creates a new `ServiceUnavailable`. + init() {} + } + + /// Subscriptions limit reached. + /// + /// - Remark: Generated from `#/paths//sitemaps/events/subscribe/post(createSitemapEventSubscription)/responses/503`. + /// + /// HTTP response code: `503 serviceUnavailable`. + case serviceUnavailable(Operations.createSitemapEventSubscription.Output.ServiceUnavailable) + /// The associated value of the enum case if `self` is `.serviceUnavailable`. + /// + /// - Throws: An error if `self` is not `.serviceUnavailable`. + /// - SeeAlso: `.serviceUnavailable`. + var serviceUnavailable: Operations.createSitemapEventSubscription.Output.ServiceUnavailable { + get throws { + switch self { + case let .serviceUnavailable(response): + response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "serviceUnavailable", + response: self + ) + } + } + } + + /// Undocumented response. + /// + /// A response with a code that is not documented in the OpenAPI document. + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + + enum AcceptableContentType: AcceptableProtocol { + case json + case other(Swift.String) + init?(rawValue: Swift.String) { + switch rawValue.lowercased() { + case "application/json": + self = .json + default: + self = .other(rawValue) + } + } + + var rawValue: Swift.String { + switch self { + case let .other(string): + string + case .json: + "application/json" + } + } + + static var allCases: [Self] { + [ + .json + ] + } + } + } + + /// Polls the data for one page of a sitemap. + /// + /// - Remark: HTTP `GET /sitemaps/{sitemapname}/{pageid}`. + /// - Remark: Generated from `#/paths//sitemaps/{sitemapname}/{pageid}/get(pollDataForPage)`. + enum pollDataForPage { + static let id: Swift.String = "pollDataForPage" + struct Input: Sendable, Hashable { + /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/{pageid}/GET/path`. + struct Path: Sendable, Hashable { + /// sitemap name + /// + /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/{pageid}/GET/path/sitemapname`. + var sitemapname: Swift.String + /// page id + /// + /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/{pageid}/GET/path/pageid`. + var pageid: Swift.String + /// Creates a new `Path`. + /// + /// - Parameters: + /// - sitemapname: sitemap name + /// - pageid: page id + init(sitemapname: Swift.String, + pageid: Swift.String) { + self.sitemapname = sitemapname + self.pageid = pageid + } + } + + var path: Operations.pollDataForPage.Input.Path + /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/{pageid}/GET/query`. + struct Query: Sendable, Hashable { + /// subscriptionid + /// + /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/{pageid}/GET/query/subscriptionid`. + var subscriptionid: Swift.String? + /// include hidden widgets + /// + /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/{pageid}/GET/query/includeHidden`. + var includeHidden: Swift.Bool? + /// Creates a new `Query`. + /// + /// - Parameters: + /// - subscriptionid: subscriptionid + /// - includeHidden: include hidden widgets + init(subscriptionid: Swift.String? = nil, + includeHidden: Swift.Bool? = nil) { + self.subscriptionid = subscriptionid + self.includeHidden = includeHidden + } + } + + var query: Operations.pollDataForPage.Input.Query + /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/{pageid}/GET/header`. + struct Headers: Sendable, Hashable { + /// language + /// + /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/{pageid}/GET/header/Accept-Language`. + var Accept_hyphen_Language: Swift.String? + /// X-Atmosphere-Transport for long polling + /// + /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/{pageid}/GET/header/X-Atmosphere-Transport`. + var X_hyphen_Atmosphere_hyphen_Transport: Swift.String? + var accept: [OpenAPIRuntime.AcceptHeaderContentType] + /// Creates a new `Headers`. + /// + /// - Parameters: + /// - Accept_hyphen_Language: language + /// - X_hyphen_Atmosphere_hyphen_Transport: X-Atmosphere-Transport for long polling + /// - accept: + init(Accept_hyphen_Language: Swift.String? = nil, + X_hyphen_Atmosphere_hyphen_Transport: Swift.String? = nil, + accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + self.Accept_hyphen_Language = Accept_hyphen_Language + self.X_hyphen_Atmosphere_hyphen_Transport = X_hyphen_Atmosphere_hyphen_Transport + self.accept = accept + } + } + + var headers: Operations.pollDataForPage.Input.Headers + /// Creates a new `Input`. + /// + /// - Parameters: + /// - path: + /// - query: + /// - headers: + init(path: Operations.pollDataForPage.Input.Path, + query: Operations.pollDataForPage.Input.Query = .init(), + headers: Operations.pollDataForPage.Input.Headers = .init()) { + self.path = path + self.query = query + self.headers = headers + } + } + + enum Output: Sendable, Hashable { + struct Ok: Sendable, Hashable { + /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/{pageid}/GET/responses/200/content`. + enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/{pageid}/GET/responses/200/content/application\/json`. + case json(Components.Schemas.PageDTO) + /// The associated value of the enum case if `self` is `.json`. + /// + /// - Throws: An error if `self` is not `.json`. + /// - SeeAlso: `.json`. + var json: Components.Schemas.PageDTO { + get throws { + switch self { + case let .json(body): + body + } + } + } + } + + /// Received HTTP response body + var body: Operations.pollDataForPage.Output.Ok.Body + /// Creates a new `Ok`. + /// + /// - Parameters: + /// - body: Received HTTP response body + init(body: Operations.pollDataForPage.Output.Ok.Body) { + self.body = body + } + } + + /// OK + /// + /// - Remark: Generated from `#/paths//sitemaps/{sitemapname}/{pageid}/get(pollDataForPage)/responses/200`. + /// + /// HTTP response code: `200 ok`. + case ok(Operations.pollDataForPage.Output.Ok) + /// The associated value of the enum case if `self` is `.ok`. + /// + /// - Throws: An error if `self` is not `.ok`. + /// - SeeAlso: `.ok`. + var ok: Operations.pollDataForPage.Output.Ok { + get throws { + switch self { + case let .ok(response): + response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "ok", + response: self + ) + } + } + } + + struct BadRequest: Sendable, Hashable { + /// Creates a new `BadRequest`. + init() {} + } + + /// Invalid subscription id has been provided. + /// + /// - Remark: Generated from `#/paths//sitemaps/{sitemapname}/{pageid}/get(pollDataForPage)/responses/400`. + /// + /// HTTP response code: `400 badRequest`. + case badRequest(Operations.pollDataForPage.Output.BadRequest) + /// The associated value of the enum case if `self` is `.badRequest`. + /// + /// - Throws: An error if `self` is not `.badRequest`. + /// - SeeAlso: `.badRequest`. + var badRequest: Operations.pollDataForPage.Output.BadRequest { + get throws { + switch self { + case let .badRequest(response): + response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "badRequest", + response: self + ) + } + } + } + + struct NotFound: Sendable, Hashable { + /// Creates a new `NotFound`. + init() {} + } + + /// Sitemap with requested name does not exist or page does not exist, or page refers to a non-linkable widget + /// + /// - Remark: Generated from `#/paths//sitemaps/{sitemapname}/{pageid}/get(pollDataForPage)/responses/404`. + /// + /// HTTP response code: `404 notFound`. + case notFound(Operations.pollDataForPage.Output.NotFound) + /// The associated value of the enum case if `self` is `.notFound`. + /// + /// - Throws: An error if `self` is not `.notFound`. + /// - SeeAlso: `.notFound`. + var notFound: Operations.pollDataForPage.Output.NotFound { + get throws { + switch self { + case let .notFound(response): + response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "notFound", + response: self + ) + } + } + } + + /// Undocumented response. + /// + /// A response with a code that is not documented in the OpenAPI document. + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + + enum AcceptableContentType: AcceptableProtocol { + case json + case other(Swift.String) + init?(rawValue: Swift.String) { + switch rawValue.lowercased() { + case "application/json": + self = .json + default: + self = .other(rawValue) + } + } + + var rawValue: Swift.String { + switch self { + case let .other(string): + string + case .json: + "application/json" + } + } + + static var allCases: [Self] { + [ + .json + ] + } + } + } + + /// Polls the data for a whole sitemap. Not recommended due to potentially high traffic. + /// + /// - Remark: HTTP `GET /sitemaps/{sitemapname}/*`. + /// - Remark: Generated from `#/paths//sitemaps/{sitemapname}/*/get(pollDataForSitemap)`. + enum pollDataForSitemap { + static let id: Swift.String = "pollDataForSitemap" + struct Input: Sendable, Hashable { + /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/*/GET/path`. + struct Path: Sendable, Hashable { + /// sitemap name + /// + /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/*/GET/path/sitemapname`. + var sitemapname: Swift.String + /// Creates a new `Path`. + /// + /// - Parameters: + /// - sitemapname: sitemap name + init(sitemapname: Swift.String) { + self.sitemapname = sitemapname + } + } + + var path: Operations.pollDataForSitemap.Input.Path + /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/*/GET/query`. + struct Query: Sendable, Hashable { + /// subscriptionid + /// + /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/*/GET/query/subscriptionid`. + var subscriptionid: Swift.String? + /// include hidden widgets + /// + /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/*/GET/query/includeHidden`. + var includeHidden: Swift.Bool? + /// Creates a new `Query`. + /// + /// - Parameters: + /// - subscriptionid: subscriptionid + /// - includeHidden: include hidden widgets + init(subscriptionid: Swift.String? = nil, + includeHidden: Swift.Bool? = nil) { + self.subscriptionid = subscriptionid + self.includeHidden = includeHidden + } + } + + var query: Operations.pollDataForSitemap.Input.Query + /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/*/GET/header`. + struct Headers: Sendable, Hashable { + /// language + /// + /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/*/GET/header/Accept-Language`. + var Accept_hyphen_Language: Swift.String? + /// X-Atmosphere-Transport for long polling + /// + /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/*/GET/header/X-Atmosphere-Transport`. + var X_hyphen_Atmosphere_hyphen_Transport: Swift.String? + var accept: [OpenAPIRuntime.AcceptHeaderContentType] + /// Creates a new `Headers`. + /// + /// - Parameters: + /// - Accept_hyphen_Language: language + /// - X_hyphen_Atmosphere_hyphen_Transport: X-Atmosphere-Transport for long polling + /// - accept: + init(Accept_hyphen_Language: Swift.String? = nil, + X_hyphen_Atmosphere_hyphen_Transport: Swift.String? = nil, + accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + self.Accept_hyphen_Language = Accept_hyphen_Language + self.X_hyphen_Atmosphere_hyphen_Transport = X_hyphen_Atmosphere_hyphen_Transport + self.accept = accept + } + } + + var headers: Operations.pollDataForSitemap.Input.Headers + /// Creates a new `Input`. + /// + /// - Parameters: + /// - path: + /// - query: + /// - headers: + init(path: Operations.pollDataForSitemap.Input.Path, + query: Operations.pollDataForSitemap.Input.Query = .init(), + headers: Operations.pollDataForSitemap.Input.Headers = .init()) { + self.path = path + self.query = query + self.headers = headers + } + } + + enum Output: Sendable, Hashable { + struct Ok: Sendable, Hashable { + /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/*/GET/responses/200/content`. + enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/*/GET/responses/200/content/application\/json`. + case json(Components.Schemas.SitemapDTO) + /// The associated value of the enum case if `self` is `.json`. + /// + /// - Throws: An error if `self` is not `.json`. + /// - SeeAlso: `.json`. + var json: Components.Schemas.SitemapDTO { + get throws { + switch self { + case let .json(body): + body + } + } + } + } + + /// Received HTTP response body + var body: Operations.pollDataForSitemap.Output.Ok.Body + /// Creates a new `Ok`. + /// + /// - Parameters: + /// - body: Received HTTP response body + init(body: Operations.pollDataForSitemap.Output.Ok.Body) { + self.body = body + } + } + + /// OK + /// + /// - Remark: Generated from `#/paths//sitemaps/{sitemapname}/*/get(pollDataForSitemap)/responses/200`. + /// + /// HTTP response code: `200 ok`. + case ok(Operations.pollDataForSitemap.Output.Ok) + /// The associated value of the enum case if `self` is `.ok`. + /// + /// - Throws: An error if `self` is not `.ok`. + /// - SeeAlso: `.ok`. + var ok: Operations.pollDataForSitemap.Output.Ok { + get throws { + switch self { + case let .ok(response): + response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "ok", + response: self + ) + } + } + } + + struct BadRequest: Sendable, Hashable { + /// Creates a new `BadRequest`. + init() {} + } + + /// Invalid subscription id has been provided. + /// + /// - Remark: Generated from `#/paths//sitemaps/{sitemapname}/*/get(pollDataForSitemap)/responses/400`. + /// + /// HTTP response code: `400 badRequest`. + case badRequest(Operations.pollDataForSitemap.Output.BadRequest) + /// The associated value of the enum case if `self` is `.badRequest`. + /// + /// - Throws: An error if `self` is not `.badRequest`. + /// - SeeAlso: `.badRequest`. + var badRequest: Operations.pollDataForSitemap.Output.BadRequest { + get throws { + switch self { + case let .badRequest(response): + response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "badRequest", + response: self + ) + } + } + } + + struct NotFound: Sendable, Hashable { + /// Creates a new `NotFound`. + init() {} + } + + /// Sitemap with requested name does not exist + /// + /// - Remark: Generated from `#/paths//sitemaps/{sitemapname}/*/get(pollDataForSitemap)/responses/404`. + /// + /// HTTP response code: `404 notFound`. + case notFound(Operations.pollDataForSitemap.Output.NotFound) + /// The associated value of the enum case if `self` is `.notFound`. + /// + /// - Throws: An error if `self` is not `.notFound`. + /// - SeeAlso: `.notFound`. + var notFound: Operations.pollDataForSitemap.Output.NotFound { + get throws { + switch self { + case let .notFound(response): + response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "notFound", + response: self + ) + } + } + } + + /// Undocumented response. + /// + /// A response with a code that is not documented in the OpenAPI document. + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + + enum AcceptableContentType: AcceptableProtocol { + case json + case other(Swift.String) + init?(rawValue: Swift.String) { + switch rawValue.lowercased() { + case "application/json": + self = .json + default: + self = .other(rawValue) + } + } + + var rawValue: Swift.String { + switch self { + case let .other(string): + string + case .json: + "application/json" + } + } + + static var allCases: [Self] { + [ + .json + ] + } + } + } + + /// Get sitemap by name. + /// + /// - Remark: HTTP `GET /sitemaps/{sitemapname}`. + /// - Remark: Generated from `#/paths//sitemaps/{sitemapname}/get(getSitemapByName)`. + enum getSitemapByName { + static let id: Swift.String = "getSitemapByName" + struct Input: Sendable, Hashable { + /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/GET/path`. + struct Path: Sendable, Hashable { + /// sitemap name + /// + /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/GET/path/sitemapname`. + var sitemapname: Swift.String + /// Creates a new `Path`. + /// + /// - Parameters: + /// - sitemapname: sitemap name + init(sitemapname: Swift.String) { + self.sitemapname = sitemapname + } + } + + var path: Operations.getSitemapByName.Input.Path + /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/GET/query`. + struct Query: Sendable, Hashable { + /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/GET/query/type`. + var _type: Swift.String? + /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/GET/query/jsoncallback`. + var jsoncallback: Swift.String? + /// include hidden widgets + /// + /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/GET/query/includeHidden`. + var includeHidden: Swift.Bool? + /// Creates a new `Query`. + /// + /// - Parameters: + /// - _type: + /// - jsoncallback: + /// - includeHidden: include hidden widgets + init(_type: Swift.String? = nil, + jsoncallback: Swift.String? = nil, + includeHidden: Swift.Bool? = nil) { + self._type = _type + self.jsoncallback = jsoncallback + self.includeHidden = includeHidden + } + } + + var query: Operations.getSitemapByName.Input.Query + /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/GET/header`. + struct Headers: Sendable, Hashable { + /// language + /// + /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/GET/header/Accept-Language`. + var Accept_hyphen_Language: Swift.String? + var accept: [OpenAPIRuntime.AcceptHeaderContentType] + /// Creates a new `Headers`. + /// + /// - Parameters: + /// - Accept_hyphen_Language: language + /// - accept: + init(Accept_hyphen_Language: Swift.String? = nil, + accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + self.Accept_hyphen_Language = Accept_hyphen_Language + self.accept = accept + } + } + + var headers: Operations.getSitemapByName.Input.Headers + /// Creates a new `Input`. + /// + /// - Parameters: + /// - path: + /// - query: + /// - headers: + init(path: Operations.getSitemapByName.Input.Path, + query: Operations.getSitemapByName.Input.Query = .init(), + headers: Operations.getSitemapByName.Input.Headers = .init()) { + self.path = path + self.query = query + self.headers = headers + } + } + + enum Output: Sendable, Hashable { + struct Ok: Sendable, Hashable { + /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/GET/responses/200/content`. + enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/GET/responses/200/content/application\/json`. + case json(Components.Schemas.SitemapDTO) + /// The associated value of the enum case if `self` is `.json`. + /// + /// - Throws: An error if `self` is not `.json`. + /// - SeeAlso: `.json`. + var json: Components.Schemas.SitemapDTO { + get throws { + switch self { + case let .json(body): + body + } + } + } + } + + /// Received HTTP response body + var body: Operations.getSitemapByName.Output.Ok.Body + /// Creates a new `Ok`. + /// + /// - Parameters: + /// - body: Received HTTP response body + init(body: Operations.getSitemapByName.Output.Ok.Body) { + self.body = body + } + } + + /// OK + /// + /// - Remark: Generated from `#/paths//sitemaps/{sitemapname}/get(getSitemapByName)/responses/200`. + /// + /// HTTP response code: `200 ok`. + case ok(Operations.getSitemapByName.Output.Ok) + /// The associated value of the enum case if `self` is `.ok`. + /// + /// - Throws: An error if `self` is not `.ok`. + /// - SeeAlso: `.ok`. + var ok: Operations.getSitemapByName.Output.Ok { + get throws { + switch self { + case let .ok(response): + response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "ok", + response: self + ) + } + } + } + + /// Undocumented response. + /// + /// A response with a code that is not documented in the OpenAPI document. + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + + enum AcceptableContentType: AcceptableProtocol { + case json + case other(Swift.String) + init?(rawValue: Swift.String) { + switch rawValue.lowercased() { + case "application/json": + self = .json + default: + self = .other(rawValue) + } + } + + var rawValue: Swift.String { + switch self { + case let .other(string): + string + case .json: + "application/json" + } + } + + static var allCases: [Self] { + [ + .json + ] + } + } + } + + /// Get sitemap events for a whole sitemap. Not recommended due to potentially high traffic. + /// + /// - Remark: HTTP `GET /sitemaps/events/{subscriptionid}/*`. + /// - Remark: Generated from `#/paths//sitemaps/events/{subscriptionid}/*/get(getSitemapEvents)`. + enum getSitemapEvents { + static let id: Swift.String = "getSitemapEvents" + struct Input: Sendable, Hashable { + /// - Remark: Generated from `#/paths/sitemaps/events/{subscriptionid}/*/GET/path`. + struct Path: Sendable, Hashable { + /// subscription id + /// + /// - Remark: Generated from `#/paths/sitemaps/events/{subscriptionid}/*/GET/path/subscriptionid`. + var subscriptionid: Swift.String + /// Creates a new `Path`. + /// + /// - Parameters: + /// - subscriptionid: subscription id + init(subscriptionid: Swift.String) { + self.subscriptionid = subscriptionid + } + } + + var path: Operations.getSitemapEvents.Input.Path + /// - Remark: Generated from `#/paths/sitemaps/events/{subscriptionid}/*/GET/query`. + struct Query: Sendable, Hashable { + /// sitemap name + /// + /// - Remark: Generated from `#/paths/sitemaps/events/{subscriptionid}/*/GET/query/sitemap`. + var sitemap: Swift.String? + /// Creates a new `Query`. + /// + /// - Parameters: + /// - sitemap: sitemap name + init(sitemap: Swift.String? = nil) { + self.sitemap = sitemap + } + } + + var query: Operations.getSitemapEvents.Input.Query + /// Creates a new `Input`. + /// + /// - Parameters: + /// - path: + /// - query: + init(path: Operations.getSitemapEvents.Input.Path, + query: Operations.getSitemapEvents.Input.Query = .init()) { + self.path = path + self.query = query + } + } + + enum Output: Sendable, Hashable { + struct Ok: Sendable, Hashable { + /// Creates a new `Ok`. + init() {} + } + + /// OK + /// + /// - Remark: Generated from `#/paths//sitemaps/events/{subscriptionid}/*/get(getSitemapEvents)/responses/200`. + /// + /// HTTP response code: `200 ok`. + case ok(Operations.getSitemapEvents.Output.Ok) + /// The associated value of the enum case if `self` is `.ok`. + /// + /// - Throws: An error if `self` is not `.ok`. + /// - SeeAlso: `.ok`. + var ok: Operations.getSitemapEvents.Output.Ok { + get throws { + switch self { + case let .ok(response): + response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "ok", + response: self + ) + } + } + } + + struct BadRequest: Sendable, Hashable { + /// Creates a new `BadRequest`. + init() {} + } + + /// Missing sitemap parameter, or sitemap not linked successfully to the subscription. + /// + /// - Remark: Generated from `#/paths//sitemaps/events/{subscriptionid}/*/get(getSitemapEvents)/responses/400`. + /// + /// HTTP response code: `400 badRequest`. + case badRequest(Operations.getSitemapEvents.Output.BadRequest) + /// The associated value of the enum case if `self` is `.badRequest`. + /// + /// - Throws: An error if `self` is not `.badRequest`. + /// - SeeAlso: `.badRequest`. + var badRequest: Operations.getSitemapEvents.Output.BadRequest { + get throws { + switch self { + case let .badRequest(response): + response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "badRequest", + response: self + ) + } + } + } + + struct NotFound: Sendable, Hashable { + /// Creates a new `NotFound`. + init() {} + } + + /// Subscription not found. + /// + /// - Remark: Generated from `#/paths//sitemaps/events/{subscriptionid}/*/get(getSitemapEvents)/responses/404`. + /// + /// HTTP response code: `404 notFound`. + case notFound(Operations.getSitemapEvents.Output.NotFound) + /// The associated value of the enum case if `self` is `.notFound`. + /// + /// - Throws: An error if `self` is not `.notFound`. + /// - SeeAlso: `.notFound`. + var notFound: Operations.getSitemapEvents.Output.NotFound { + get throws { + switch self { + case let .notFound(response): + response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "notFound", + response: self + ) + } + } + } + + /// Undocumented response. + /// + /// A response with a code that is not documented in the OpenAPI document. + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + } + + /// Get sitemap events. + /// + /// - Remark: HTTP `GET /sitemaps/events/{subscriptionid}`. + /// - Remark: Generated from `#/paths//sitemaps/events/{subscriptionid}/get(getSitemapEvents_1)`. + enum getSitemapEvents_1 { + static let id: Swift.String = "getSitemapEvents_1" + struct Input: Sendable, Hashable { + /// - Remark: Generated from `#/paths/sitemaps/events/{subscriptionid}/GET/path`. + struct Path: Sendable, Hashable { + /// subscription id + /// + /// - Remark: Generated from `#/paths/sitemaps/events/{subscriptionid}/GET/path/subscriptionid`. + var subscriptionid: Swift.String + /// Creates a new `Path`. + /// + /// - Parameters: + /// - subscriptionid: subscription id + init(subscriptionid: Swift.String) { + self.subscriptionid = subscriptionid + } + } + + var path: Operations.getSitemapEvents_1.Input.Path + /// - Remark: Generated from `#/paths/sitemaps/events/{subscriptionid}/GET/query`. + struct Query: Sendable, Hashable { + /// sitemap name + /// + /// - Remark: Generated from `#/paths/sitemaps/events/{subscriptionid}/GET/query/sitemap`. + var sitemap: Swift.String? + /// page id + /// + /// - Remark: Generated from `#/paths/sitemaps/events/{subscriptionid}/GET/query/pageid`. + var pageid: Swift.String? + /// Creates a new `Query`. + /// + /// - Parameters: + /// - sitemap: sitemap name + /// - pageid: page id + init(sitemap: Swift.String? = nil, + pageid: Swift.String? = nil) { + self.sitemap = sitemap + self.pageid = pageid + } + } + + var query: Operations.getSitemapEvents_1.Input.Query + /// - Remark: Generated from `#/paths/sitemaps/events/{subscriptionid}/GET/header`. + struct Headers: Sendable, Hashable { + var accept: [OpenAPIRuntime.AcceptHeaderContentType] + /// Creates a new `Headers`. + /// + /// - Parameters: + /// - accept: + init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + self.accept = accept + } + } + + var headers: Operations.getSitemapEvents_1.Input.Headers + /// Creates a new `Input`. + /// + /// - Parameters: + /// - path: + /// - query: + /// - headers: + init(path: Operations.getSitemapEvents_1.Input.Path, + query: Operations.getSitemapEvents_1.Input.Query = .init(), + headers: Operations.getSitemapEvents_1.Input.Headers = .init()) { + self.path = path + self.query = query + self.headers = headers + } + } + + enum Output: Sendable, Hashable { + struct Ok: Sendable, Hashable { + /// - Remark: Generated from `#/paths/sitemaps/events/{subscriptionid}/GET/responses/200/content`. + enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/sitemaps/events/{subscriptionid}/GET/responses/200/content/text\/event-stream`. + case text_event_hyphen_stream(OpenAPIRuntime.HTTPBody) + /// The associated value of the enum case if `self` is `.text_event_hyphen_stream`. + /// + /// - Throws: An error if `self` is not `.text_event_hyphen_stream`. + /// - SeeAlso: `.text_event_hyphen_stream`. + var text_event_hyphen_stream: OpenAPIRuntime.HTTPBody { + get throws { + switch self { + case let .text_event_hyphen_stream(body): + body + default: + try throwUnexpectedResponseBody( + expectedContent: "text/event-stream", + body: self + ) + } + } + } + + /// - Remark: Generated from `#/paths/sitemaps/events/{subscriptionid}/GET/responses/200/content/application\/json`. + case json(Components.Schemas.SitemapWidgetEvent) + /// The associated value of the enum case if `self` is `.json`. + /// + /// - Throws: An error if `self` is not `.json`. + /// - SeeAlso: `.json`. + var json: Components.Schemas.SitemapWidgetEvent { + get throws { + switch self { + case let .json(body): + body + default: + try throwUnexpectedResponseBody( + expectedContent: "application/json", + body: self + ) + } + } + } + } + + /// Received HTTP response body + var body: Operations.getSitemapEvents_1.Output.Ok.Body + /// Creates a new `Ok`. + /// + /// - Parameters: + /// - body: Received HTTP response body + init(body: Operations.getSitemapEvents_1.Output.Ok.Body) { + self.body = body + } + } + + /// OK + /// + /// - Remark: Generated from `#/paths//sitemaps/events/{subscriptionid}/get(getSitemapEvents_1)/responses/200`. + /// + /// HTTP response code: `200 ok`. + case ok(Operations.getSitemapEvents_1.Output.Ok) + /// The associated value of the enum case if `self` is `.ok`. + /// + /// - Throws: An error if `self` is not `.ok`. + /// - SeeAlso: `.ok`. + var ok: Operations.getSitemapEvents_1.Output.Ok { + get throws { + switch self { + case let .ok(response): + response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "ok", + response: self + ) + } + } + } + + struct BadRequest: Sendable, Hashable { + /// Creates a new `BadRequest`. + init() {} + } + + /// Missing sitemap or page parameter, or page not linked successfully to the subscription. + /// + /// - Remark: Generated from `#/paths//sitemaps/events/{subscriptionid}/get(getSitemapEvents_1)/responses/400`. + /// + /// HTTP response code: `400 badRequest`. + case badRequest(Operations.getSitemapEvents_1.Output.BadRequest) + /// The associated value of the enum case if `self` is `.badRequest`. + /// + /// - Throws: An error if `self` is not `.badRequest`. + /// - SeeAlso: `.badRequest`. + var badRequest: Operations.getSitemapEvents_1.Output.BadRequest { + get throws { + switch self { + case let .badRequest(response): + response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "badRequest", + response: self + ) + } + } + } + + struct NotFound: Sendable, Hashable { + /// Creates a new `NotFound`. + init() {} + } + + /// Subscription not found. + /// + /// - Remark: Generated from `#/paths//sitemaps/events/{subscriptionid}/get(getSitemapEvents_1)/responses/404`. + /// + /// HTTP response code: `404 notFound`. + case notFound(Operations.getSitemapEvents_1.Output.NotFound) + /// The associated value of the enum case if `self` is `.notFound`. + /// + /// - Throws: An error if `self` is not `.notFound`. + /// - SeeAlso: `.notFound`. + var notFound: Operations.getSitemapEvents_1.Output.NotFound { + get throws { + switch self { + case let .notFound(response): + response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "notFound", + response: self + ) + } + } + } + + /// Undocumented response. + /// + /// A response with a code that is not documented in the OpenAPI document. + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + + enum AcceptableContentType: AcceptableProtocol { + case text_event_hyphen_stream + case json + case other(Swift.String) + init?(rawValue: Swift.String) { + switch rawValue.lowercased() { + case "text/event-stream": + self = .text_event_hyphen_stream + case "application/json": + self = .json + default: + self = .other(rawValue) + } + } + + var rawValue: Swift.String { + switch self { + case let .other(string): + string + case .text_event_hyphen_stream: + "text/event-stream" + case .json: + "application/json" + } + } + + static var allCases: [Self] { + [ + .text_event_hyphen_stream, + .json + ] + } + } + } + + /// Get all available sitemaps. + /// + /// - Remark: HTTP `GET /sitemaps`. + /// - Remark: Generated from `#/paths//sitemaps/get(getSitemaps)`. + enum getSitemaps { + static let id: Swift.String = "getSitemaps" + struct Input: Sendable, Hashable { + /// - Remark: Generated from `#/paths/sitemaps/GET/header`. + struct Headers: Sendable, Hashable { + var accept: [OpenAPIRuntime.AcceptHeaderContentType] + /// Creates a new `Headers`. + /// + /// - Parameters: + /// - accept: + init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + self.accept = accept + } + } + + var headers: Operations.getSitemaps.Input.Headers + /// Creates a new `Input`. + /// + /// - Parameters: + /// - headers: + init(headers: Operations.getSitemaps.Input.Headers = .init()) { + self.headers = headers + } + } + + enum Output: Sendable, Hashable { + struct Ok: Sendable, Hashable { + /// - Remark: Generated from `#/paths/sitemaps/GET/responses/200/content`. + enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/sitemaps/GET/responses/200/content/application\/json`. + case json([Components.Schemas.SitemapDTO]) + /// The associated value of the enum case if `self` is `.json`. + /// + /// - Throws: An error if `self` is not `.json`. + /// - SeeAlso: `.json`. + var json: [Components.Schemas.SitemapDTO] { + get throws { + switch self { + case let .json(body): + body + } + } + } + } + + /// Received HTTP response body + var body: Operations.getSitemaps.Output.Ok.Body + /// Creates a new `Ok`. + /// + /// - Parameters: + /// - body: Received HTTP response body + init(body: Operations.getSitemaps.Output.Ok.Body) { + self.body = body + } + } + + /// OK + /// + /// - Remark: Generated from `#/paths//sitemaps/get(getSitemaps)/responses/200`. + /// + /// HTTP response code: `200 ok`. + case ok(Operations.getSitemaps.Output.Ok) + /// The associated value of the enum case if `self` is `.ok`. + /// + /// - Throws: An error if `self` is not `.ok`. + /// - SeeAlso: `.ok`. + var ok: Operations.getSitemaps.Output.Ok { + get throws { + switch self { + case let .ok(response): + response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "ok", + response: self + ) + } + } + } + + /// Undocumented response. + /// + /// A response with a code that is not documented in the OpenAPI document. + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + + enum AcceptableContentType: AcceptableProtocol { + case json + case other(Swift.String) + init?(rawValue: Swift.String) { + switch rawValue.lowercased() { + case "application/json": + self = .json + default: + self = .other(rawValue) + } + } + + var rawValue: Swift.String { + switch self { + case let .other(string): + string + case .json: + "application/json" + } + } + + static var allCases: [Self] { + [ + .json + ] + } + } + } + + /// Get all registered UI components in the specified namespace. + /// + /// - Remark: HTTP `GET /ui/components/{namespace}`. + /// - Remark: Generated from `#/paths//ui/components/{namespace}/get(getRegisteredUIComponentsInNamespace)`. + enum getRegisteredUIComponentsInNamespace { + static let id: Swift.String = "getRegisteredUIComponentsInNamespace" + struct Input: Sendable, Hashable { + /// - Remark: Generated from `#/paths/ui/components/{namespace}/GET/path`. + struct Path: Sendable, Hashable { + /// - Remark: Generated from `#/paths/ui/components/{namespace}/GET/path/namespace`. + var namespace: Swift.String + /// Creates a new `Path`. + /// + /// - Parameters: + /// - namespace: + init(namespace: Swift.String) { + self.namespace = namespace + } + } + + var path: Operations.getRegisteredUIComponentsInNamespace.Input.Path + /// - Remark: Generated from `#/paths/ui/components/{namespace}/GET/query`. + struct Query: Sendable, Hashable { + /// summary fields only + /// + /// - Remark: Generated from `#/paths/ui/components/{namespace}/GET/query/summary`. + var summary: Swift.Bool? + /// Creates a new `Query`. + /// + /// - Parameters: + /// - summary: summary fields only + init(summary: Swift.Bool? = nil) { + self.summary = summary + } + } + + var query: Operations.getRegisteredUIComponentsInNamespace.Input.Query + /// - Remark: Generated from `#/paths/ui/components/{namespace}/GET/header`. + struct Headers: Sendable, Hashable { + var accept: [OpenAPIRuntime.AcceptHeaderContentType] + /// Creates a new `Headers`. + /// + /// - Parameters: + /// - accept: + init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + self.accept = accept + } + } + + var headers: Operations.getRegisteredUIComponentsInNamespace.Input.Headers + /// Creates a new `Input`. + /// + /// - Parameters: + /// - path: + /// - query: + /// - headers: + init(path: Operations.getRegisteredUIComponentsInNamespace.Input.Path, + query: Operations.getRegisteredUIComponentsInNamespace.Input.Query = .init(), + headers: Operations.getRegisteredUIComponentsInNamespace.Input.Headers = .init()) { + self.path = path + self.query = query + self.headers = headers + } + } + + enum Output: Sendable, Hashable { + struct Ok: Sendable, Hashable { + /// - Remark: Generated from `#/paths/ui/components/{namespace}/GET/responses/200/content`. + enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/ui/components/{namespace}/GET/responses/200/content/application\/json`. + case json([Components.Schemas.RootUIComponent]) + /// The associated value of the enum case if `self` is `.json`. + /// + /// - Throws: An error if `self` is not `.json`. + /// - SeeAlso: `.json`. + var json: [Components.Schemas.RootUIComponent] { + get throws { + switch self { + case let .json(body): + body + } + } + } + } + + /// Received HTTP response body + var body: Operations.getRegisteredUIComponentsInNamespace.Output.Ok.Body + /// Creates a new `Ok`. + /// + /// - Parameters: + /// - body: Received HTTP response body + init(body: Operations.getRegisteredUIComponentsInNamespace.Output.Ok.Body) { + self.body = body + } + } + + /// OK + /// + /// - Remark: Generated from `#/paths//ui/components/{namespace}/get(getRegisteredUIComponentsInNamespace)/responses/200`. + /// + /// HTTP response code: `200 ok`. + case ok(Operations.getRegisteredUIComponentsInNamespace.Output.Ok) + /// The associated value of the enum case if `self` is `.ok`. + /// + /// - Throws: An error if `self` is not `.ok`. + /// - SeeAlso: `.ok`. + var ok: Operations.getRegisteredUIComponentsInNamespace.Output.Ok { + get throws { + switch self { + case let .ok(response): + response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "ok", + response: self + ) + } + } + } + + /// Undocumented response. + /// + /// A response with a code that is not documented in the OpenAPI document. + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + + enum AcceptableContentType: AcceptableProtocol { + case json + case other(Swift.String) + init?(rawValue: Swift.String) { + switch rawValue.lowercased() { + case "application/json": + self = .json + default: + self = .other(rawValue) + } + } + + var rawValue: Swift.String { + switch self { + case let .other(string): + string + case .json: + "application/json" + } + } + + static var allCases: [Self] { + [ + .json + ] + } + } + } + + /// Add a UI component in the specified namespace. + /// + /// - Remark: HTTP `POST /ui/components/{namespace}`. + /// - Remark: Generated from `#/paths//ui/components/{namespace}/post(addUIComponentToNamespace)`. + enum addUIComponentToNamespace { + static let id: Swift.String = "addUIComponentToNamespace" + struct Input: Sendable, Hashable { + /// - Remark: Generated from `#/paths/ui/components/{namespace}/POST/path`. + struct Path: Sendable, Hashable { + /// - Remark: Generated from `#/paths/ui/components/{namespace}/POST/path/namespace`. + var namespace: Swift.String + /// Creates a new `Path`. + /// + /// - Parameters: + /// - namespace: + init(namespace: Swift.String) { + self.namespace = namespace + } + } + + var path: Operations.addUIComponentToNamespace.Input.Path + /// - Remark: Generated from `#/paths/ui/components/{namespace}/POST/header`. + struct Headers: Sendable, Hashable { + var accept: [OpenAPIRuntime.AcceptHeaderContentType] + /// Creates a new `Headers`. + /// + /// - Parameters: + /// - accept: + init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + self.accept = accept + } + } + + var headers: Operations.addUIComponentToNamespace.Input.Headers + /// - Remark: Generated from `#/paths/ui/components/{namespace}/POST/requestBody`. + enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/ui/components/{namespace}/POST/requestBody/content/application\/json`. + case json(Components.Schemas.RootUIComponent) + } + + var body: Operations.addUIComponentToNamespace.Input.Body? + /// Creates a new `Input`. + /// + /// - Parameters: + /// - path: + /// - headers: + /// - body: + init(path: Operations.addUIComponentToNamespace.Input.Path, + headers: Operations.addUIComponentToNamespace.Input.Headers = .init(), + body: Operations.addUIComponentToNamespace.Input.Body? = nil) { + self.path = path + self.headers = headers + self.body = body + } + } + + enum Output: Sendable, Hashable { + struct Ok: Sendable, Hashable { + /// - Remark: Generated from `#/paths/ui/components/{namespace}/POST/responses/200/content`. + enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/ui/components/{namespace}/POST/responses/200/content/application\/json`. + case json(Components.Schemas.RootUIComponent) + /// The associated value of the enum case if `self` is `.json`. + /// + /// - Throws: An error if `self` is not `.json`. + /// - SeeAlso: `.json`. + var json: Components.Schemas.RootUIComponent { + get throws { + switch self { + case let .json(body): + body + } + } + } + } + + /// Received HTTP response body + var body: Operations.addUIComponentToNamespace.Output.Ok.Body + /// Creates a new `Ok`. + /// + /// - Parameters: + /// - body: Received HTTP response body + init(body: Operations.addUIComponentToNamespace.Output.Ok.Body) { + self.body = body + } + } + + /// OK + /// + /// - Remark: Generated from `#/paths//ui/components/{namespace}/post(addUIComponentToNamespace)/responses/200`. + /// + /// HTTP response code: `200 ok`. + case ok(Operations.addUIComponentToNamespace.Output.Ok) + /// The associated value of the enum case if `self` is `.ok`. + /// + /// - Throws: An error if `self` is not `.ok`. + /// - SeeAlso: `.ok`. + var ok: Operations.addUIComponentToNamespace.Output.Ok { + get throws { + switch self { + case let .ok(response): + response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "ok", + response: self + ) + } + } + } + + /// Undocumented response. + /// + /// A response with a code that is not documented in the OpenAPI document. + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + + enum AcceptableContentType: AcceptableProtocol { + case json + case other(Swift.String) + init?(rawValue: Swift.String) { + switch rawValue.lowercased() { + case "application/json": + self = .json + default: + self = .other(rawValue) + } + } + + var rawValue: Swift.String { + switch self { + case let .other(string): + string + case .json: + "application/json" + } + } + + static var allCases: [Self] { + [ + .json + ] + } + } + } + + /// Get a specific UI component in the specified namespace. + /// + /// - Remark: HTTP `GET /ui/components/{namespace}/{componentUID}`. + /// - Remark: Generated from `#/paths//ui/components/{namespace}/{componentUID}/get(getUIComponentInNamespace)`. + enum getUIComponentInNamespace { + static let id: Swift.String = "getUIComponentInNamespace" + struct Input: Sendable, Hashable { + /// - Remark: Generated from `#/paths/ui/components/{namespace}/{componentUID}/GET/path`. + struct Path: Sendable, Hashable { + /// - Remark: Generated from `#/paths/ui/components/{namespace}/{componentUID}/GET/path/namespace`. + var namespace: Swift.String + /// - Remark: Generated from `#/paths/ui/components/{namespace}/{componentUID}/GET/path/componentUID`. + var componentUID: Swift.String + /// Creates a new `Path`. + /// + /// - Parameters: + /// - namespace: + /// - componentUID: + init(namespace: Swift.String, + componentUID: Swift.String) { + self.namespace = namespace + self.componentUID = componentUID + } + } + + var path: Operations.getUIComponentInNamespace.Input.Path + /// - Remark: Generated from `#/paths/ui/components/{namespace}/{componentUID}/GET/header`. + struct Headers: Sendable, Hashable { + var accept: [OpenAPIRuntime.AcceptHeaderContentType] + /// Creates a new `Headers`. + /// + /// - Parameters: + /// - accept: + init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + self.accept = accept + } + } + + var headers: Operations.getUIComponentInNamespace.Input.Headers + /// Creates a new `Input`. + /// + /// - Parameters: + /// - path: + /// - headers: + init(path: Operations.getUIComponentInNamespace.Input.Path, + headers: Operations.getUIComponentInNamespace.Input.Headers = .init()) { + self.path = path + self.headers = headers + } + } + + enum Output: Sendable, Hashable { + struct Ok: Sendable, Hashable { + /// - Remark: Generated from `#/paths/ui/components/{namespace}/{componentUID}/GET/responses/200/content`. + enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/ui/components/{namespace}/{componentUID}/GET/responses/200/content/application\/json`. + case json(Components.Schemas.RootUIComponent) + /// The associated value of the enum case if `self` is `.json`. + /// + /// - Throws: An error if `self` is not `.json`. + /// - SeeAlso: `.json`. + var json: Components.Schemas.RootUIComponent { + get throws { + switch self { + case let .json(body): + body + } + } + } + } + + /// Received HTTP response body + var body: Operations.getUIComponentInNamespace.Output.Ok.Body + /// Creates a new `Ok`. + /// + /// - Parameters: + /// - body: Received HTTP response body + init(body: Operations.getUIComponentInNamespace.Output.Ok.Body) { + self.body = body + } + } + + /// OK + /// + /// - Remark: Generated from `#/paths//ui/components/{namespace}/{componentUID}/get(getUIComponentInNamespace)/responses/200`. + /// + /// HTTP response code: `200 ok`. + case ok(Operations.getUIComponentInNamespace.Output.Ok) + /// The associated value of the enum case if `self` is `.ok`. + /// + /// - Throws: An error if `self` is not `.ok`. + /// - SeeAlso: `.ok`. + var ok: Operations.getUIComponentInNamespace.Output.Ok { + get throws { + switch self { + case let .ok(response): + response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "ok", + response: self + ) + } + } + } + + struct NotFound: Sendable, Hashable { + /// Creates a new `NotFound`. + init() {} + } + + /// Component not found + /// + /// - Remark: Generated from `#/paths//ui/components/{namespace}/{componentUID}/get(getUIComponentInNamespace)/responses/404`. + /// + /// HTTP response code: `404 notFound`. + case notFound(Operations.getUIComponentInNamespace.Output.NotFound) + /// The associated value of the enum case if `self` is `.notFound`. + /// + /// - Throws: An error if `self` is not `.notFound`. + /// - SeeAlso: `.notFound`. + var notFound: Operations.getUIComponentInNamespace.Output.NotFound { + get throws { + switch self { + case let .notFound(response): + response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "notFound", + response: self + ) + } + } + } + + /// Undocumented response. + /// + /// A response with a code that is not documented in the OpenAPI document. + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + + enum AcceptableContentType: AcceptableProtocol { + case json + case other(Swift.String) + init?(rawValue: Swift.String) { + switch rawValue.lowercased() { + case "application/json": + self = .json + default: + self = .other(rawValue) + } + } + + var rawValue: Swift.String { + switch self { + case let .other(string): + string + case .json: + "application/json" + } + } + + static var allCases: [Self] { + [ + .json + ] + } + } + } + + /// Update a specific UI component in the specified namespace. + /// + /// - Remark: HTTP `PUT /ui/components/{namespace}/{componentUID}`. + /// - Remark: Generated from `#/paths//ui/components/{namespace}/{componentUID}/put(updateUIComponentInNamespace)`. + enum updateUIComponentInNamespace { + static let id: Swift.String = "updateUIComponentInNamespace" + struct Input: Sendable, Hashable { + /// - Remark: Generated from `#/paths/ui/components/{namespace}/{componentUID}/PUT/path`. + struct Path: Sendable, Hashable { + /// - Remark: Generated from `#/paths/ui/components/{namespace}/{componentUID}/PUT/path/namespace`. + var namespace: Swift.String + /// - Remark: Generated from `#/paths/ui/components/{namespace}/{componentUID}/PUT/path/componentUID`. + var componentUID: Swift.String + /// Creates a new `Path`. + /// + /// - Parameters: + /// - namespace: + /// - componentUID: + init(namespace: Swift.String, + componentUID: Swift.String) { + self.namespace = namespace + self.componentUID = componentUID + } + } + + var path: Operations.updateUIComponentInNamespace.Input.Path + /// - Remark: Generated from `#/paths/ui/components/{namespace}/{componentUID}/PUT/header`. + struct Headers: Sendable, Hashable { + var accept: [OpenAPIRuntime.AcceptHeaderContentType] + /// Creates a new `Headers`. + /// + /// - Parameters: + /// - accept: + init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + self.accept = accept + } + } + + var headers: Operations.updateUIComponentInNamespace.Input.Headers + /// - Remark: Generated from `#/paths/ui/components/{namespace}/{componentUID}/PUT/requestBody`. + enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/ui/components/{namespace}/{componentUID}/PUT/requestBody/content/application\/json`. + case json(Components.Schemas.RootUIComponent) + } + + var body: Operations.updateUIComponentInNamespace.Input.Body? + /// Creates a new `Input`. + /// + /// - Parameters: + /// - path: + /// - headers: + /// - body: + init(path: Operations.updateUIComponentInNamespace.Input.Path, + headers: Operations.updateUIComponentInNamespace.Input.Headers = .init(), + body: Operations.updateUIComponentInNamespace.Input.Body? = nil) { + self.path = path + self.headers = headers + self.body = body + } + } + + enum Output: Sendable, Hashable { + struct Ok: Sendable, Hashable { + /// - Remark: Generated from `#/paths/ui/components/{namespace}/{componentUID}/PUT/responses/200/content`. + enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/ui/components/{namespace}/{componentUID}/PUT/responses/200/content/application\/json`. + case json(Components.Schemas.RootUIComponent) + /// The associated value of the enum case if `self` is `.json`. + /// + /// - Throws: An error if `self` is not `.json`. + /// - SeeAlso: `.json`. + var json: Components.Schemas.RootUIComponent { + get throws { + switch self { + case let .json(body): + body + } + } + } + } + + /// Received HTTP response body + var body: Operations.updateUIComponentInNamespace.Output.Ok.Body + /// Creates a new `Ok`. + /// + /// - Parameters: + /// - body: Received HTTP response body + init(body: Operations.updateUIComponentInNamespace.Output.Ok.Body) { + self.body = body + } + } + + /// OK + /// + /// - Remark: Generated from `#/paths//ui/components/{namespace}/{componentUID}/put(updateUIComponentInNamespace)/responses/200`. + /// + /// HTTP response code: `200 ok`. + case ok(Operations.updateUIComponentInNamespace.Output.Ok) + /// The associated value of the enum case if `self` is `.ok`. + /// + /// - Throws: An error if `self` is not `.ok`. + /// - SeeAlso: `.ok`. + var ok: Operations.updateUIComponentInNamespace.Output.Ok { + get throws { + switch self { + case let .ok(response): + response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "ok", + response: self + ) + } + } + } + + struct NotFound: Sendable, Hashable { + /// Creates a new `NotFound`. + init() {} + } + + /// Component not found + /// + /// - Remark: Generated from `#/paths//ui/components/{namespace}/{componentUID}/put(updateUIComponentInNamespace)/responses/404`. + /// + /// HTTP response code: `404 notFound`. + case notFound(Operations.updateUIComponentInNamespace.Output.NotFound) + /// The associated value of the enum case if `self` is `.notFound`. + /// + /// - Throws: An error if `self` is not `.notFound`. + /// - SeeAlso: `.notFound`. + var notFound: Operations.updateUIComponentInNamespace.Output.NotFound { + get throws { + switch self { + case let .notFound(response): + response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "notFound", + response: self + ) + } + } + } + + /// Undocumented response. + /// + /// A response with a code that is not documented in the OpenAPI document. + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + + enum AcceptableContentType: AcceptableProtocol { + case json + case other(Swift.String) + init?(rawValue: Swift.String) { + switch rawValue.lowercased() { + case "application/json": + self = .json + default: + self = .other(rawValue) + } + } + + var rawValue: Swift.String { + switch self { + case let .other(string): + string + case .json: + "application/json" + } + } + + static var allCases: [Self] { + [ + .json + ] + } + } + } + + /// Remove a specific UI component in the specified namespace. + /// + /// - Remark: HTTP `DELETE /ui/components/{namespace}/{componentUID}`. + /// - Remark: Generated from `#/paths//ui/components/{namespace}/{componentUID}/delete(removeUIComponentFromNamespace)`. + enum removeUIComponentFromNamespace { + static let id: Swift.String = "removeUIComponentFromNamespace" + struct Input: Sendable, Hashable { + /// - Remark: Generated from `#/paths/ui/components/{namespace}/{componentUID}/DELETE/path`. + struct Path: Sendable, Hashable { + /// - Remark: Generated from `#/paths/ui/components/{namespace}/{componentUID}/DELETE/path/namespace`. + var namespace: Swift.String + /// - Remark: Generated from `#/paths/ui/components/{namespace}/{componentUID}/DELETE/path/componentUID`. + var componentUID: Swift.String + /// Creates a new `Path`. + /// + /// - Parameters: + /// - namespace: + /// - componentUID: + init(namespace: Swift.String, + componentUID: Swift.String) { + self.namespace = namespace + self.componentUID = componentUID + } + } + + var path: Operations.removeUIComponentFromNamespace.Input.Path + /// Creates a new `Input`. + /// + /// - Parameters: + /// - path: + init(path: Operations.removeUIComponentFromNamespace.Input.Path) { + self.path = path + } + } + + enum Output: Sendable, Hashable { + struct Ok: Sendable, Hashable { + /// Creates a new `Ok`. + init() {} + } + + /// OK + /// + /// - Remark: Generated from `#/paths//ui/components/{namespace}/{componentUID}/delete(removeUIComponentFromNamespace)/responses/200`. + /// + /// HTTP response code: `200 ok`. + case ok(Operations.removeUIComponentFromNamespace.Output.Ok) + /// The associated value of the enum case if `self` is `.ok`. + /// + /// - Throws: An error if `self` is not `.ok`. + /// - SeeAlso: `.ok`. + var ok: Operations.removeUIComponentFromNamespace.Output.Ok { + get throws { + switch self { + case let .ok(response): + response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "ok", + response: self + ) + } + } + } + + struct NotFound: Sendable, Hashable { + /// Creates a new `NotFound`. + init() {} + } + + /// Component not found + /// + /// - Remark: Generated from `#/paths//ui/components/{namespace}/{componentUID}/delete(removeUIComponentFromNamespace)/responses/404`. + /// + /// HTTP response code: `404 notFound`. + case notFound(Operations.removeUIComponentFromNamespace.Output.NotFound) + /// The associated value of the enum case if `self` is `.notFound`. + /// + /// - Throws: An error if `self` is not `.notFound`. + /// - SeeAlso: `.notFound`. + var notFound: Operations.removeUIComponentFromNamespace.Output.NotFound { + get throws { + switch self { + case let .notFound(response): + response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "notFound", + response: self + ) + } + } + } + + /// Undocumented response. + /// + /// A response with a code that is not documented in the OpenAPI document. + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + } + + /// Get all registered UI tiles. + /// + /// - Remark: HTTP `GET /ui/tiles`. + /// - Remark: Generated from `#/paths//ui/tiles/get(getUITiles)`. + enum getUITiles { + static let id: Swift.String = "getUITiles" + struct Input: Sendable, Hashable { + /// - Remark: Generated from `#/paths/ui/tiles/GET/header`. + struct Headers: Sendable, Hashable { + var accept: [OpenAPIRuntime.AcceptHeaderContentType] + /// Creates a new `Headers`. + /// + /// - Parameters: + /// - accept: + init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + self.accept = accept + } + } + + var headers: Operations.getUITiles.Input.Headers + /// Creates a new `Input`. + /// + /// - Parameters: + /// - headers: + init(headers: Operations.getUITiles.Input.Headers = .init()) { + self.headers = headers + } + } + + enum Output: Sendable, Hashable { + struct Ok: Sendable, Hashable { + /// - Remark: Generated from `#/paths/ui/tiles/GET/responses/200/content`. + enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/ui/tiles/GET/responses/200/content/application\/json`. + case json([Components.Schemas.TileDTO]) + /// The associated value of the enum case if `self` is `.json`. + /// + /// - Throws: An error if `self` is not `.json`. + /// - SeeAlso: `.json`. + var json: [Components.Schemas.TileDTO] { + get throws { + switch self { + case let .json(body): + body + } + } + } + } + + /// Received HTTP response body + var body: Operations.getUITiles.Output.Ok.Body + /// Creates a new `Ok`. + /// + /// - Parameters: + /// - body: Received HTTP response body + init(body: Operations.getUITiles.Output.Ok.Body) { + self.body = body + } + } + + /// OK + /// + /// - Remark: Generated from `#/paths//ui/tiles/get(getUITiles)/responses/200`. + /// + /// HTTP response code: `200 ok`. + case ok(Operations.getUITiles.Output.Ok) + /// The associated value of the enum case if `self` is `.ok`. + /// + /// - Throws: An error if `self` is not `.ok`. + /// - SeeAlso: `.ok`. + var ok: Operations.getUITiles.Output.Ok { + get throws { + switch self { + case let .ok(response): + response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "ok", + response: self + ) + } + } + } + + /// Undocumented response. + /// + /// A response with a code that is not documented in the OpenAPI document. + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + + enum AcceptableContentType: AcceptableProtocol { + case json + case other(Swift.String) + init?(rawValue: Swift.String) { + switch rawValue.lowercased() { + case "application/json": + self = .json + default: + self = .other(rawValue) + } + } + + var rawValue: Swift.String { + switch self { + case let .other(string): + string + case .json: + "application/json" + } + } + + static var allCases: [Self] { + [ + .json + ] + } + } + } +} diff --git a/OpenHABCore/Sources/OpenHABCore/Model/APIActor.swift b/OpenHABCore/Sources/OpenHABCore/Model/APIActor.swift index b0b708595..f1433b846 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/APIActor.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/APIActor.swift @@ -9,20 +9,12 @@ // // SPDX-License-Identifier: EPL-2.0 -// -// File.swift -// -// -// Created by Tim on 10.08.24. -// import Foundation import HTTPTypes import OpenAPIRuntime import OpenAPIURLSession import os -let logger = Logger(subsystem: "org.openhab.app", category: "apiactor") - public protocol OpenHABSitemapsService { func openHABSitemaps() async throws -> [OpenHABSitemap] } @@ -41,8 +33,9 @@ public actor APIActor { var username: String var password: String - public init(username: String = "", password: String = "", alwaysSendBasicAuth: Bool = true) { - let url = "about:blank" + private let logger = Logger(subsystem: "org.openhab.app", category: "apiactor") + + public init(username: String = "", password: String = "", alwaysSendBasicAuth: Bool = true, url: URL = URL(staticString: "about:blank")) async { // TODO: Make use of prepareURLSessionConfiguration let config = URLSessionConfiguration.default // config.timeoutIntervalForRequest = if longPolling { 35.0 } else { 20.0 } @@ -51,11 +44,15 @@ public actor APIActor { self.username = username self.password = password self.alwaysSendBasicAuth = alwaysSendBasicAuth + self.url = url api = Client( - serverURL: URL(string: url)!, + serverURL: url.appending(path: "/rest"), transport: URLSessionTransport(configuration: .init(session: session)), - middlewares: [AuthorisationMiddleware(username: username, password: password, alwaysSendBasicAuth: alwaysSendBasicAuth)] + middlewares: [ + AuthorisationMiddleware(username: username, password: password, alwaysSendBasicAuth: alwaysSendBasicAuth), + LoggingMiddleware() + ] ) } @@ -74,7 +71,10 @@ public actor APIActor { api = Client( serverURL: newURL.appending(path: "/rest"), transport: URLSessionTransport(configuration: .init(session: session)), - middlewares: [AuthorisationMiddleware(username: username, password: password)] + middlewares: [ + AuthorisationMiddleware(username: username, password: password), + LoggingMiddleware() + ] ) } } @@ -88,17 +88,27 @@ public actor APIActor { api = Client( serverURL: url!.appending(path: "/rest"), transport: URLSessionTransport(configuration: .init(session: session)), - middlewares: [AuthorisationMiddleware(username: username, password: password)] + middlewares: [ + AuthorisationMiddleware(username: username, password: password), + LoggingMiddleware() + ] ) } } } +public enum APIActorError: Error { + case undocumented +} + extension APIActor: OpenHABSitemapsService { public func openHABSitemaps() async throws -> [OpenHABSitemap] { - try await api.getSitemaps(.init()) - .ok.body.json - .map(OpenHABSitemap.init) + // swiftformat:disable:next redundantSelf + logger.log("Trying to getSitemaps for : \(self.url?.debugDescription ?? "No URL")") + switch try await api.getSitemaps(.init()) { + case let .ok(okresponse): return try okresponse.body.json.map(OpenHABSitemap.init) + case .undocumented: throw APIActorError.undocumented + } } } @@ -110,19 +120,65 @@ extension APIActor: OpenHABUiTileService { } } -extension APIActor { - func openHABSitemap(path: Operations.getSitemapByName.Input.Path) async throws -> OpenHABSitemap? { - let result = try await api.getSitemapByName(path: path) - .ok.body.json - return OpenHABSitemap(result) +public extension AsyncThrowingStream { +// func map(_ transform: @escaping (Self.Element) -> Transformed) -> AsyncThrowingStream { +// AsyncThrowingStream { continuation in +// Task { +// for try await element in self { +// continuation.yield(transform(element)) +// } +// continuation.finish() +// } +// } +// } + + func map2(transform: @escaping (Self.Element) -> T) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + let task = Task { + for try await element in self { + continuation.yield(transform(element)) + } + continuation.finish() + } + continuation.onTermination = { _ in task.cancel() } + } + } +} + +public extension APIActor { + func openHABcreateSubscription() async throws -> String? { + logger.info("Creating subscription") + let result = try await api.createSitemapEventSubscription() + guard let urlString = try result.ok.body.json.context?.headers?.Location?.first else { return nil } + return URL(string: urlString)?.lastPathComponent + } + + func openHABSitemapWidgetEvents(subscriptionid: String, sitemap: String) async throws -> String { +// AsyncThrowingStream { + let path = Operations.getSitemapEvents_1.Input.Path(subscriptionid: subscriptionid) + let query = Operations.getSitemapEvents_1.Input.Query(sitemap: sitemap, pageid: sitemap) + let stream = try await api.getSitemapEvents_1(path: path, query: query).ok.body.text_event_hyphen_stream.asDecodedServerSentEventsWithJSONData(of: Components.Schemas.SitemapWidgetEvent.self).compactMap { (value) -> OpenHABSitemapWidgetEvent? in + guard let data = value.data else { return nil } + return OpenHABSitemapWidgetEvent(data) + } +// return stream.map2 + + for try await line in stream { + print(line) + print("\n") + } + return "" + + logger.debug("subscription date received") } } extension APIActor { // Internal function for pollPage func openHABpollPage(path: Operations.pollDataForPage.Input.Path, + query: Operations.pollDataForPage.Input.Query = .init(), headers: Operations.pollDataForPage.Input.Headers) async throws -> OpenHABPage? { - let result = try await api.pollDataForPage(path: path, headers: headers) + let result = try await api.pollDataForPage(path: path, query: query, headers: headers) .ok.body.json return OpenHABPage(result) } @@ -133,7 +189,6 @@ extension APIActor { /// - longPolling: set to true for long-polling public func openHABpollPage(sitemapname: String, longPolling: Bool) async throws -> OpenHABPage? { var headers = Operations.pollDataForPage.Input.Headers() - if longPolling { logger.info("Long-polling, setting X-Atmosphere-Transport") headers.X_hyphen_Atmosphere_hyphen_Transport = "long-polling" @@ -144,15 +199,29 @@ extension APIActor { await updateForLongPolling(longPolling) return try await openHABpollPage(path: path, headers: headers) } -} -extension APIActor { - func openHABSitemap(path: Operations.getSitemapByName.Input.Path, - headers: Operations.getSitemapByName.Input.Headers) async throws -> OpenHABSitemap? { - let result = try await api.getSitemapByName(path: path, headers: headers) + // Internal function for pollSitemap + func openHABpollSitemap(path: Operations.pollDataForSitemap.Input.Path, + query: Operations.pollDataForSitemap.Input.Query = .init(), + headers: Operations.pollDataForSitemap.Input.Headers) async throws -> OpenHABSitemap? { + let result = try await api.pollDataForSitemap(path: path, query: query, headers: headers) .ok.body.json return OpenHABSitemap(result) } + + public func openHABpollSitemap(sitemapname: String, longPolling: Bool, subscriptionId: String? = nil) async throws -> OpenHABSitemap? { + var headers = Operations.pollDataForSitemap.Input.Headers() + if longPolling { + logger.info("Long-polling, setting X-Atmosphere-Transport") + headers.X_hyphen_Atmosphere_hyphen_Transport = "long-polling" + } else { + headers.X_hyphen_Atmosphere_hyphen_Transport = nil + } + let query = Operations.pollDataForSitemap.Input.Query(subscriptionid: subscriptionId) + let path = Operations.pollDataForSitemap.Input.Path(sitemapname: sitemapname) + await updateForLongPolling(longPolling) + return try await openHABpollSitemap(path: path, query: query, headers: headers) + } } // MARK: State changes and commands @@ -173,6 +242,44 @@ public extension APIActor { } } +class OpenHABSitemapWidgetEvent { + init(sitemapName: String? = nil, pageId: String? = nil, widgetId: String? = nil, label: String? = nil, labelSource: String? = nil, icon: String? = nil, reloadIcon: Bool? = nil, labelcolor: String? = nil, valuecolor: String? = nil, iconcolor: String? = nil, visibility: Bool? = nil, state: String? = nil, enrichedItem: OpenHABItem? = nil, descriptionChanged: Bool? = nil) { + self.sitemapName = sitemapName + self.pageId = pageId + self.widgetId = widgetId + self.label = label + self.labelSource = labelSource + self.icon = icon + self.reloadIcon = reloadIcon + self.labelcolor = labelcolor + self.valuecolor = valuecolor + self.iconcolor = iconcolor + self.visibility = visibility + self.state = state + self.enrichedItem = enrichedItem + self.descriptionChanged = descriptionChanged + } + + convenience init(_ event: Components.Schemas.SitemapWidgetEvent) { + self.init(sitemapName: event.sitemapName, pageId: event.pageId, widgetId: event.widgetId, label: event.label, labelSource: event.labelSource, icon: event.icon, reloadIcon: event.reloadIcon, labelcolor: event.labelcolor, valuecolor: event.valuecolor, iconcolor: event.iconcolor, visibility: event.visibility, state: event.state, enrichedItem: OpenHABItem(event.item), descriptionChanged: event.descriptionChanged) + } + + var sitemapName: String? + var pageId: String? + var widgetId: String? + var label: String? + var labelSource: String? + var icon: String? + var reloadIcon: Bool? + var labelcolor: String? + var valuecolor: String? + var iconcolor: String? + var visibility: Bool? + var state: String? + var enrichedItem: OpenHABItem? + var descriptionChanged: Bool? +} + public struct AuthorisationMiddleware { private let username: String private let password: String @@ -199,12 +306,10 @@ extension AuthorisationMiddleware: ClientMiddleware { // Use a mutable copy of request var request = request - if ((baseURL.host?.hasSuffix("myopenhab.org")) == nil), alwaysSendBasicAuth, !username.isEmpty, !password.isEmpty { + if baseURL.host?.hasSuffix("myopenhab.org") == nil, alwaysSendBasicAuth, !username.isEmpty, !password.isEmpty { request.headerFields[.authorization] = basicAuthHeader() } - logger.info("Outgoing request: \(request.headerFields.debugDescription, privacy: .public)") let (response, body) = try await next(request, body, baseURL) - logger.debug("Incoming response \(response.headerFields.debugDescription)") return (response, body) } } diff --git a/OpenHABCore/Sources/OpenHABCore/Model/LoggingMiddleware.swift b/OpenHABCore/Sources/OpenHABCore/Model/LoggingMiddleware.swift new file mode 100644 index 000000000..cc4f12e87 --- /dev/null +++ b/OpenHABCore/Sources/OpenHABCore/Model/LoggingMiddleware.swift @@ -0,0 +1,115 @@ +// 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 Foundation +import HTTPTypes +import OpenAPIRuntime +import os + +// swiftlint:disable file_types_order +package actor LoggingMiddleware { + private static var defaultLogger: Logger { + Logger(subsystem: "org.openhab.app", category: "logging-middleware") + } + + private let logger: Logger + package let bodyLoggingPolicy: BodyLoggingPolicy + + package init(logger: Logger = defaultLogger, bodyLoggingConfiguration: BodyLoggingPolicy = .never) { + self.logger = logger + bodyLoggingPolicy = bodyLoggingConfiguration + } +} + +extension LoggingMiddleware: ClientMiddleware { + package func intercept(_ request: HTTPRequest, + body: HTTPBody?, + baseURL: URL, + operationID: String, + next: @Sendable (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?)) async throws -> (HTTPResponse, HTTPBody?) { + let (requestBodyToLog, requestBodyForNext) = try await bodyLoggingPolicy.process(body) + log(request, requestBodyToLog) + do { + let (response, responseBody) = try await next(request, requestBodyForNext, baseURL) + let (responseBodyToLog, responseBodyForNext) = try await bodyLoggingPolicy.process(responseBody) + log(request, response, responseBodyToLog) + return (response, responseBodyForNext) + } catch { + log(request, failedWith: error) + throw error + } + } +} + +extension LoggingMiddleware { + func log(_ request: HTTPRequest, _ requestBody: BodyLoggingPolicy.BodyLog) { + logger.debug( + "Request: \(request.method, privacy: .public) \(request.path ?? "", privacy: .public) body: \(requestBody, privacy: .auto)" + ) + } + + func log(_ request: HTTPRequest, _ response: HTTPResponse, _ responseBody: BodyLoggingPolicy.BodyLog) { + logger.debug( + "Response: \(request.method, privacy: .public) \(request.path ?? "", privacy: .public) \(response.status, privacy: .public) body: \(responseBody, privacy: .auto)" + ) + } + + func log(_ request: HTTPRequest, failedWith error: any Error) { + logger.warning("Request failed. Error: \(error.localizedDescription)") + } +} + +// swiftlint:enable file_types_order + +package enum BodyLoggingPolicy { + /// Never log request or response bodies. + case never + /// Log request and response bodies that have a known length less than or equal to `maxBytes`. + case upTo(maxBytes: Int) + + enum BodyLog: Equatable, CustomStringConvertible { + /// There is no body to log. + case none + /// The policy forbids logging the body. + case redacted + /// The body was of unknown length. + case unknownLength + /// The body exceeds the maximum size for logging allowed by the policy. + case tooManyBytesToLog(Int64) + /// The body can be logged. + case complete(Data) + + var description: String { + switch self { + case .none: return "" + case .redacted: return "" + case .unknownLength: return "" + case let .tooManyBytesToLog(byteCount): return "<\(byteCount) bytes>" + case let .complete(data): + if let string = String(data: data, encoding: .utf8) { return string } + return String(describing: data) + } + } + } + + func process(_ body: HTTPBody?) async throws -> (bodyToLog: BodyLog, bodyForNext: HTTPBody?) { + switch (body?.length, self) { + case (.none, _): return (.none, body) + case (_, .never): return (.redacted, body) + case (.unknown, _): return (.unknownLength, body) + case let (.known(length), .upTo(maxBytesToLog)) where length > maxBytesToLog: + return (.tooManyBytesToLog(length), body) + case let (.known, .upTo(maxBytesToLog)): + let bodyData = try await Data(collecting: body!, upTo: maxBytesToLog) + return (.complete(bodyData), HTTPBody(bodyData)) + } + } +} diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABItem.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABItem.swift index 9c2e97ad1..aab719bf5 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABItem.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABItem.swift @@ -13,7 +13,7 @@ import CoreLocation import os.log import UIKit -public final class OpenHABItem: NSObject, CommItem { +public class OpenHABItem: NSObject, CommItem { public enum ItemType: String { case color = "Color" case contact = "Contact" diff --git a/OpenHABCore/Sources/OpenHABCore/openapi/openapi.json b/OpenHABCore/Sources/OpenHABCore/openapi/openapi.json index facdf003f..d34f33439 100644 --- a/OpenHABCore/Sources/OpenHABCore/openapi/openapi.json +++ b/OpenHABCore/Sources/OpenHABCore/openapi/openapi.json @@ -5501,7 +5501,17 @@ "operationId": "createSitemapEventSubscription", "responses": { "201": { - "description": "Subscription created." + "description": "Subscription created.", + }, + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JerseyResponseBuilderDTO" + } + } + } }, "503": { "description": "Subscriptions limit reached." @@ -5606,6 +5616,14 @@ "type": "string" } }, + { + "name": "X-Atmosphere-Transport", + "in": "header", + "description": "X-Atmosphere-Transport for long polling", + "schema": { + "type": "string" + } + }, { "name": "sitemapname", "in": "path", @@ -5794,7 +5812,15 @@ ], "responses": { "200": { - "description": "OK" + "description": "OK", + "content": { + "text/event-stream": {}, + "application/json": { + "schema": { + "$ref": "#/components/schemas/SitemapWidgetEvent" + } + } + } }, "400": { "description": "Missing sitemap or page parameter, or page not linked successfully to the subscription." @@ -9001,6 +9027,36 @@ } } }, + "JerseyResponseBuilderDTO": { + "type": "object", + "properties": { + "status": { + "type": "string" + }, + "context": { + "$ref": "#/components/schemas/ContextDTO" + } + } + }, + "ContextDTO": { + "type": "object", + "properties": { + "headers": { + "$ref": "#/components/schemas/HeadersDTO" + } + } + }, + "HeadersDTO": { + "type": "object", + "properties": { + "Location": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "ChannelDefinitionDTO": { "type": "object", "properties": { @@ -9450,6 +9506,53 @@ } } }, + "SitemapWidgetEvent": { + "type": "object", + "properties": { + "widgetId": { + "type": "string" + }, + "label": { + "type": "string" + }, + "labelSource": { + "type": "string" + }, + "icon": { + "type": "string" + }, + "labelcolor": { + "type": "string" + }, + "valuecolor": { + "type": "string" + }, + "iconcolor": { + "type": "string" + }, + "state": { + "type": "string" + }, + "reloadIcon": { + "type": "boolean" + }, + "visibility": { + "type": "boolean" + }, + "descriptionChanged": { + "type": "boolean" + }, + "item": { + "$ref": "#/components/schemas/EnrichedItemDTO" + }, + "sitemapName": { + "type": "string" + }, + "pageId": { + "type": "string" + } + } + }, "SitemapDTO": { "type": "object", "properties": { diff --git a/openHAB/OpenHABDrawerTableViewController.swift b/openHAB/OpenHABDrawerTableViewController.swift index cbe48ed76..ee009bf8c 100644 --- a/openHAB/OpenHABDrawerTableViewController.swift +++ b/openHAB/OpenHABDrawerTableViewController.swift @@ -16,6 +16,8 @@ import os.log import SafariServices import UIKit +let logger = Logger(subsystem: "org.openhab.app", category: "OpenHABDrawerTableViewController") + struct UiTile: Decodable { var name: String var url: String @@ -37,17 +39,7 @@ class OpenHABDrawerTableViewController: UITableViewController { AppDelegate.appDelegate.appData } - private let apiactor: APIActor - - init() { - apiactor = APIActor() - super.init(nibName: nil, bundle: nil) - } - - required init?(coder aDecoder: NSCoder) { - apiactor = APIActor() - super.init(coder: aDecoder) - } + private var apiactor: APIActor? override func viewDidLoad() { super.viewDidLoad() @@ -65,9 +57,8 @@ class OpenHABDrawerTableViewController: UITableViewController { Task { do { - await apiactor.updateBaseURL(with: URL(string: appData?.openHABRootUrl ?? "")!) - - sitemaps = try await apiactor.openHABSitemaps() + apiactor = await APIActor(username: appData!.openHABUsername, password: appData!.openHABPassword, alwaysSendBasicAuth: appData!.openHABAlwaysSendCreds, url: URL(string: appData?.openHABRootUrl ?? "")!) + sitemaps = try await apiactor?.openHABSitemaps() ?? [] if sitemaps.last?.name == "_default", sitemaps.count > 1 { sitemaps = Array(sitemaps.dropLast()) } @@ -81,7 +72,7 @@ class OpenHABDrawerTableViewController: UITableViewController { self.setStandardDrawerItems() self.tableView.reloadData() } catch { - os_log("%{PUBLIC}@", log: .default, type: .error, error.localizedDescription) + os_log("Error %{PUBLIC}@", log: .default, type: .error, error.localizedDescription) self.drawerItems.removeAll() self.setStandardDrawerItems() self.tableView.reloadData() @@ -90,8 +81,8 @@ class OpenHABDrawerTableViewController: UITableViewController { Task { do { - await apiactor.updateBaseURL(with: URL(string: appData?.openHABRootUrl ?? "")!) - uiTiles = try await apiactor.openHABTiles() + await apiactor = APIActor(username: appData!.openHABUsername, password: appData!.openHABPassword, alwaysSendBasicAuth: appData!.openHABAlwaysSendCreds, url: URL(string: appData?.openHABRootUrl ?? "")!) + uiTiles = try await apiactor?.openHABTiles() ?? [] os_log("ui tiles response", log: .viewCycle, type: .info) self.tableView.reloadData() } catch { diff --git a/openHAB/OpenHABSitemapViewController.swift b/openHAB/OpenHABSitemapViewController.swift index 9b01fb651..7ff07ff63 100644 --- a/openHAB/OpenHABSitemapViewController.swift +++ b/openHAB/OpenHABSitemapViewController.swift @@ -112,7 +112,7 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel search.isActive && !searchBarIsEmpty } - private let apiactor = APIActor() + private var apiactor: APIActor? @IBOutlet private var widgetTableView: UITableView! @@ -124,6 +124,7 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel pageNetworkStatus = nil sitemaps = [] widgetTableView.tableFooterView = UIView() + Task { await apiactor = APIActor(username: openHABUsername, password: openHABPassword, alwaysSendBasicAuth: openHABAlwaysSendCreds, url: URL(string: openHABRootUrl) ?? URL(staticString: "about:blank")) } registerTableViewCells() configureTableView() @@ -182,7 +183,7 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel OpenHABTracker.shared.multicastDelegate.add(self) OpenHABTracker.shared.restart() } else { - Task { await apiactor.updateBaseURL(with: URL(string: appData!.openHABRootUrl)!) } + Task { await apiactor?.updateBaseURL(with: URL(string: appData!.openHABRootUrl)!) } if !pageNetworkStatusChanged() { os_log("OpenHABSitemapViewController pageUrl = %{PUBLIC}@", log: .notifications, type: .info, pageUrl) loadPage(false) @@ -330,9 +331,14 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel } asyncOperation = Task { do { - await apiactor.updateBaseURL(with: URL(string: appData?.openHABRootUrl ?? "")!) + await apiactor?.updateBaseURL(with: URL(string: appData?.openHABRootUrl ?? "")!) - currentPage = try await apiactor.openHABpollPage(sitemapname: defaultSitemap, longPolling: longPolling) +// if let subscriptionid = try await apiactor?.openHABcreateSubscription() { +// let sitemap = try await apiactor?.openHABpollSitemap(sitemapname: defaultSitemap, longPolling: longPolling, subscriptionId: subscriptionid) +// try await apiactor?.openHABSitemapWidgetEvents(subscriptionid: subscriptionid, sitemap: defaultSitemap) +// } + + currentPage = try await apiactor?.openHABpollPage(sitemapname: defaultSitemap, longPolling: longPolling) if isFiltering { filterContentForSearchText(search.searchBar.text) @@ -379,9 +385,9 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel func selectSitemap() { Task { do { - await apiactor.updateBaseURL(with: URL(string: appData?.openHABRootUrl ?? "")!) - - sitemaps = try await apiactor.openHABSitemaps() + logger.debug("Running selectSitemap for URL: \(self.appData?.openHABRootUrl ?? "")") + apiactor = await APIActor(username: appData!.openHABUsername, password: appData!.openHABPassword, alwaysSendBasicAuth: appData!.openHABAlwaysSendCreds, url: URL(string: appData?.openHABRootUrl ?? "")!) + sitemaps = try await apiactor?.openHABSitemaps() ?? [] switch sitemaps.count { case 2...: @@ -407,6 +413,8 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel default: break } widgetTableView.reloadData() + } catch let error as APIActorError { + logger.debug("APIActorError on OpenHABSitemapViewController") } catch { os_log("%{PUBLIC}@", log: .default, type: .error, error.localizedDescription) DispatchQueue.main.async { @@ -491,11 +499,12 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel } func sendCommand(itemname: String, command: String) { - Task { try await apiactor.openHABSendItemCommand(itemname: itemname, command: command) } + Task { try await apiactor?.openHABSendItemCommand(itemname: itemname, command: command) } } override func reloadView() { defaultSitemap = Preferences.defaultSitemap + logger.debug("Reload view") selectSitemap() } diff --git a/openHAB/OpenHABWebViewController.swift b/openHAB/OpenHABWebViewController.swift index e0cbb2241..e68e06169 100644 --- a/openHAB/OpenHABWebViewController.swift +++ b/openHAB/OpenHABWebViewController.swift @@ -13,7 +13,6 @@ import OpenHABCore import os.log import SafariServices import SideMenu -import SwiftMessages import UIKit import WebKit From eecb27eac02f61dfb1672cb01fb78878a823aefa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20M=C3=BCller-Seydlitz?= Date: Sun, 18 Aug 2024 22:31:11 +0200 Subject: [PATCH 004/476] Created LoggingMiddleware to be separated from AuthorisationMiddleware update OpenHABWidget with OpenHABSitemapWidgetEvent Update to swift-tools-version 5.10 --- OpenHABCore/Package.swift | 2 +- .../{Model => Util}/APIActor.swift | 121 +++++++++++++----- .../Util/AuthorisationMiddleware.swift | 49 +++++++ .../{Model => Util}/LoggingMiddleware.swift | 0 .../xcshareddata/swiftpm/Package.resolved | 3 +- 5 files changed, 138 insertions(+), 37 deletions(-) rename OpenHABCore/Sources/OpenHABCore/{Model => Util}/APIActor.swift (81%) create mode 100644 OpenHABCore/Sources/OpenHABCore/Util/AuthorisationMiddleware.swift rename OpenHABCore/Sources/OpenHABCore/{Model => Util}/LoggingMiddleware.swift (100%) diff --git a/OpenHABCore/Package.swift b/OpenHABCore/Package.swift index ffe19e384..eab75daa4 100644 --- a/OpenHABCore/Package.swift +++ b/OpenHABCore/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.9 +// swift-tools-version:5.10 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription diff --git a/OpenHABCore/Sources/OpenHABCore/Model/APIActor.swift b/OpenHABCore/Sources/OpenHABCore/Util/APIActor.swift similarity index 81% rename from OpenHABCore/Sources/OpenHABCore/Model/APIActor.swift rename to OpenHABCore/Sources/OpenHABCore/Util/APIActor.swift index f1433b846..b2cd57f59 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/APIActor.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/APIActor.swift @@ -40,7 +40,9 @@ public actor APIActor { let config = URLSessionConfiguration.default // config.timeoutIntervalForRequest = if longPolling { 35.0 } else { 20.0 } // config.timeoutIntervalForResource = config.timeoutIntervalForRequest + 25 - let session = URLSession(configuration: config) + + let delegate = APIActorDelegate(username: username, password: password) + let session = URLSession(configuration: config, delegate: delegate, delegateQueue: nil) self.username = username self.password = password self.alwaysSendBasicAuth = alwaysSendBasicAuth @@ -242,6 +244,89 @@ public extension APIActor { } } +// MARK: - URLSessionDelegate for Client Certificates and Basic Auth + +class APIActorDelegate: NSObject, URLSessionDelegate, URLSessionTaskDelegate { + private let username: String + private let password: String + + init(username: String, password: String) { + self.username = username + self.password = password + } + + public func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { + await urlSessionInternal(session, task: nil, didReceive: challenge) + } + + public func urlSession(_ session: URLSession, task: URLSessionTask, didReceive challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { + await urlSessionInternal(session, task: task, didReceive: challenge) + } + + private func urlSessionInternal(_ session: URLSession, task: URLSessionTask?, didReceive challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { + os_log("URLAuthenticationChallenge: %{public}@", log: .networking, type: .info, challenge.protectionSpace.authenticationMethod) + let authenticationMethod = challenge.protectionSpace.authenticationMethod + switch authenticationMethod { + case NSURLAuthenticationMethodServerTrust: + return await handleServerTrust(challenge: challenge) + case NSURLAuthenticationMethodDefault, NSURLAuthenticationMethodHTTPBasic: + if let task { + task.authAttemptCount += 1 + if task.authAttemptCount > 1 { + return (.cancelAuthenticationChallenge, nil) + } else { + return await handleBasicAuth(challenge: challenge) + } + } else { + return await handleBasicAuth(challenge: challenge) + } + case NSURLAuthenticationMethodClientCertificate: + return await handleClientCertificateAuth(challenge: challenge) + default: + return (.performDefaultHandling, nil) + } + } + + private func handleServerTrust(challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { + guard let serverTrust = challenge.protectionSpace.serverTrust else { + return (.performDefaultHandling, nil) + } + let credential = URLCredential(trust: serverTrust) + return (.useCredential, credential) + } + + private func handleBasicAuth(challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { + let credential = URLCredential(user: username, password: password, persistence: .forSession) + return (.useCredential, credential) + } + + private func handleClientCertificateAuth(challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { + let certificateManager = ClientCertificateManager() + let (disposition, credential) = certificateManager.evaluateTrust(with: challenge) + return (disposition, credential) + } +} + +extension OpenHABWidget { + func update(with event: OpenHABSitemapWidgetEvent) { + + state = event.state ?? self.state + icon = event.icon ?? self.icon + label = event.label ?? self.label + iconColor = event.iconcolor ?? "" + labelcolor = event.labelcolor ?? "" + valuecolor = event.valuecolor ?? "" + visibility = event.visibility ?? self.visibility + + if let enrichedItem = event.enrichedItem { + if let link = self.item?.link { + enrichedItem.link = link + } + item = enrichedItem + } + } +} + class OpenHABSitemapWidgetEvent { init(sitemapName: String? = nil, pageId: String? = nil, widgetId: String? = nil, label: String? = nil, labelSource: String? = nil, icon: String? = nil, reloadIcon: Bool? = nil, labelcolor: String? = nil, valuecolor: String? = nil, iconcolor: String? = nil, visibility: Bool? = nil, state: String? = nil, enrichedItem: OpenHABItem? = nil, descriptionChanged: Bool? = nil) { self.sitemapName = sitemapName @@ -280,40 +365,6 @@ class OpenHABSitemapWidgetEvent { var descriptionChanged: Bool? } -public struct AuthorisationMiddleware { - private let username: String - private let password: String - private let alwaysSendBasicAuth: Bool - - public init(username: String, password: String, alwaysSendBasicAuth: Bool = false) { - self.username = username - self.password = password - self.alwaysSendBasicAuth = alwaysSendBasicAuth - } -} - -extension AuthorisationMiddleware: ClientMiddleware { - private func basicAuthHeader() -> String { - let credential = Data("\(username):\(password)".utf8).base64EncodedString() - return "Basic \(credential)" - } - - public func intercept(_ request: HTTPRequest, - body: HTTPBody?, - baseURL: URL, - operationID: String, - next: @Sendable (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?)) async throws -> (HTTPResponse, HTTPBody?) { - // Use a mutable copy of request - var request = request - - if baseURL.host?.hasSuffix("myopenhab.org") == nil, alwaysSendBasicAuth, !username.isEmpty, !password.isEmpty { - request.headerFields[.authorization] = basicAuthHeader() - } - let (response, body) = try await next(request, body, baseURL) - return (response, body) - } -} - extension OpenHABUiTile { convenience init(_ tile: Components.Schemas.TileDTO) { self.init(name: tile.name.orEmpty, url: tile.url.orEmpty, imageUrl: tile.imageUrl.orEmpty) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/AuthorisationMiddleware.swift b/OpenHABCore/Sources/OpenHABCore/Util/AuthorisationMiddleware.swift new file mode 100644 index 000000000..82ae3c676 --- /dev/null +++ b/OpenHABCore/Sources/OpenHABCore/Util/AuthorisationMiddleware.swift @@ -0,0 +1,49 @@ +// 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 Foundation +import HTTPTypes +import OpenAPIRuntime +import OpenAPIURLSession +import os + +public struct AuthorisationMiddleware { + private let username: String + private let password: String + private let alwaysSendBasicAuth: Bool + + public init(username: String, password: String, alwaysSendBasicAuth: Bool = false) { + self.username = username + self.password = password + self.alwaysSendBasicAuth = alwaysSendBasicAuth + } +} + +extension AuthorisationMiddleware: ClientMiddleware { + private func basicAuthHeader() -> String { + let credential = Data("\(username):\(password)".utf8).base64EncodedString() + return "Basic \(credential)" + } + + public func intercept(_ request: HTTPRequest, + body: HTTPBody?, + baseURL: URL, + operationID: String, + next: @Sendable (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?)) async throws -> (HTTPResponse, HTTPBody?) { + // Use a mutable copy of request + var request = request + if baseURL.host?.hasSuffix("myopenhab.org") == nil, alwaysSendBasicAuth, !username.isEmpty, !password.isEmpty { + request.headerFields[.authorization] = basicAuthHeader() + } + let (response, body) = try await next(request, body, baseURL) + return (response, body) + } +} diff --git a/OpenHABCore/Sources/OpenHABCore/Model/LoggingMiddleware.swift b/OpenHABCore/Sources/OpenHABCore/Util/LoggingMiddleware.swift similarity index 100% rename from OpenHABCore/Sources/OpenHABCore/Model/LoggingMiddleware.swift rename to OpenHABCore/Sources/OpenHABCore/Util/LoggingMiddleware.swift diff --git a/openHAB.xcworkspace/xcshareddata/swiftpm/Package.resolved b/openHAB.xcworkspace/xcshareddata/swiftpm/Package.resolved index 9636f6eb0..8f57eb615 100644 --- a/openHAB.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/openHAB.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,4 +1,5 @@ { + "originHash" : "3bc6a2e9afce7e9424388f65dacbd328261dab0cfc5ae52bc4c92551494b6d12", "pins" : [ { "identity" : "abseil-cpp-binary", @@ -253,5 +254,5 @@ } } ], - "version" : 2 + "version" : 3 } From 7a32f3927f54a4fab7ac526c68ad9e8ee328fb33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20M=C3=BCller-Seydlitz?= Date: Mon, 19 Aug 2024 21:48:54 +0200 Subject: [PATCH 005/476] Subcribe to events and map received events to OpenHABSitemapWidgetEvents In order not to expose decode OpenHABSitemapWidgetEvents manually. Some relaxations required on OpenHABItem --- .../OpenHABCore/Model/OpenHABItem.swift | 7 +- .../Sources/OpenHABCore/Util/APIActor.swift | 108 ++++++++++++------ openHAB/OpenHABSitemapViewController.swift | 17 ++- 3 files changed, 86 insertions(+), 46 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABItem.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABItem.swift index aab719bf5..ac4d7c17d 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABItem.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABItem.swift @@ -28,6 +28,7 @@ public class OpenHABItem: NSObject, CommItem { case rollershutter = "Rollershutter" case stringItem = "String" case switchItem = "Switch" + case undetermind = "" // Relevant only for SitemapWidgetEvent } public var type: ItemType? @@ -125,10 +126,10 @@ public extension OpenHABItem { public extension OpenHABItem { struct CodingData: Decodable { - let type: String + let type: String? let groupType: String? let name: String - let link: String + let link: String? let state: String? let label: String? let stateDescription: OpenHABStateDescription.CodingData? @@ -144,7 +145,7 @@ public extension OpenHABItem.CodingData { var openHABItem: OpenHABItem { let mappedMembers = members?.map(\.openHABItem) ?? [] - return OpenHABItem(name: name, type: type, state: state, link: link, label: label, groupType: groupType, stateDescription: stateDescription?.openHABStateDescription, commandDescription: commandDescription?.openHABCommandDescription, members: mappedMembers, category: category, options: options) + return OpenHABItem(name: name, type: type ?? "", state: state, link: link ?? "", label: label, groupType: groupType, stateDescription: stateDescription?.openHABStateDescription, commandDescription: commandDescription?.openHABCommandDescription, members: mappedMembers, category: category, options: options) } } diff --git a/OpenHABCore/Sources/OpenHABCore/Util/APIActor.swift b/OpenHABCore/Sources/OpenHABCore/Util/APIActor.swift index b2cd57f59..3793082de 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/APIActor.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/APIActor.swift @@ -155,23 +155,16 @@ public extension APIActor { return URL(string: urlString)?.lastPathComponent } - func openHABSitemapWidgetEvents(subscriptionid: String, sitemap: String) async throws -> String { -// AsyncThrowingStream { + func openHABSitemapWidgetEvents(subscriptionid: String, sitemap: String) async throws -> AsyncThrowingCompactMapSequence>, OpenHABSitemapWidgetEvent> { let path = Operations.getSitemapEvents_1.Input.Path(subscriptionid: subscriptionid) let query = Operations.getSitemapEvents_1.Input.Query(sitemap: sitemap, pageid: sitemap) - let stream = try await api.getSitemapEvents_1(path: path, query: query).ok.body.text_event_hyphen_stream.asDecodedServerSentEventsWithJSONData(of: Components.Schemas.SitemapWidgetEvent.self).compactMap { (value) -> OpenHABSitemapWidgetEvent? in - guard let data = value.data else { return nil } - return OpenHABSitemapWidgetEvent(data) + let decodedSequence = try await api.getSitemapEvents_1(path: path, query: query).ok.body.text_event_hyphen_stream.asDecodedServerSentEvents() + let opaqueSequence = decodedSequence.compactMap { (event) in + if let data = event.data { + try JSONDecoder().decode(OpenHABSitemapWidgetEvent.CodingData.self, from: Data(data.utf8)).openHABSitemapWidgetEvent + } else { nil } } -// return stream.map2 - - for try await line in stream { - print(line) - print("\n") - } - return "" - - logger.debug("subscription date received") + return opaqueSequence } } @@ -309,17 +302,16 @@ class APIActorDelegate: NSObject, URLSessionDelegate, URLSessionTaskDelegate { extension OpenHABWidget { func update(with event: OpenHABSitemapWidgetEvent) { - - state = event.state ?? self.state - icon = event.icon ?? self.icon - label = event.label ?? self.label + state = event.state ?? state + icon = event.icon ?? icon + label = event.label ?? label iconColor = event.iconcolor ?? "" labelcolor = event.labelcolor ?? "" valuecolor = event.valuecolor ?? "" - visibility = event.visibility ?? self.visibility - + visibility = event.visibility ?? visibility + if let enrichedItem = event.enrichedItem { - if let link = self.item?.link { + if let link = item?.link { enrichedItem.link = link } item = enrichedItem @@ -327,7 +319,22 @@ extension OpenHABWidget { } } -class OpenHABSitemapWidgetEvent { +public class OpenHABSitemapWidgetEvent { + var sitemapName: String? + var pageId: String? + var widgetId: String? + var label: String? + var labelSource: String? + var icon: String? + var reloadIcon: Bool? + var labelcolor: String? + var valuecolor: String? + var iconcolor: String? + var visibility: Bool? + var state: String? + var enrichedItem: OpenHABItem? + var descriptionChanged: Bool? + init(sitemapName: String? = nil, pageId: String? = nil, widgetId: String? = nil, label: String? = nil, labelSource: String? = nil, icon: String? = nil, reloadIcon: Bool? = nil, labelcolor: String? = nil, valuecolor: String? = nil, iconcolor: String? = nil, visibility: Bool? = nil, state: String? = nil, enrichedItem: OpenHABItem? = nil, descriptionChanged: Bool? = nil) { self.sitemapName = sitemapName self.pageId = pageId @@ -345,24 +352,51 @@ class OpenHABSitemapWidgetEvent { self.descriptionChanged = descriptionChanged } - convenience init(_ event: Components.Schemas.SitemapWidgetEvent) { + convenience init?(_ event: Components.Schemas.SitemapWidgetEvent?) { + guard let event else { return nil } self.init(sitemapName: event.sitemapName, pageId: event.pageId, widgetId: event.widgetId, label: event.label, labelSource: event.labelSource, icon: event.icon, reloadIcon: event.reloadIcon, labelcolor: event.labelcolor, valuecolor: event.valuecolor, iconcolor: event.iconcolor, visibility: event.visibility, state: event.state, enrichedItem: OpenHABItem(event.item), descriptionChanged: event.descriptionChanged) } +} - var sitemapName: String? - var pageId: String? - var widgetId: String? - var label: String? - var labelSource: String? - var icon: String? - var reloadIcon: Bool? - var labelcolor: String? - var valuecolor: String? - var iconcolor: String? - var visibility: Bool? - var state: String? - var enrichedItem: OpenHABItem? - var descriptionChanged: Bool? +extension OpenHABSitemapWidgetEvent: CustomStringConvertible { + public var description: String { + "\(widgetId) \(label) \(enrichedItem?.state)" + } +} + +public extension OpenHABSitemapWidgetEvent { + struct CodingData: Decodable, Hashable, Equatable { + public static func == (lhs: OpenHABSitemapWidgetEvent.CodingData, rhs: OpenHABSitemapWidgetEvent.CodingData) -> Bool { + lhs.widgetId == rhs.widgetId + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(widgetId) + } + + var sitemapName: String? + var pageId: String? + var widgetId: String? + var label: String? + var labelSource: String? + var icon: String? + var reloadIcon: Bool? + var labelcolor: String? + var valuecolor: String? + var iconcolor: String? + var visibility: Bool? +// var state: String? + var item: OpenHABItem.CodingData? + var descriptionChanged: Bool? + var link: String? + } +} + +extension OpenHABSitemapWidgetEvent.CodingData { + var openHABSitemapWidgetEvent: OpenHABSitemapWidgetEvent { + // swiftlint:disable:next line_length + OpenHABSitemapWidgetEvent(sitemapName: sitemapName, pageId: pageId, widgetId: widgetId, label: label, labelSource: labelSource, icon: icon, reloadIcon: reloadIcon, labelcolor: labelcolor, valuecolor: valuecolor, iconcolor: iconcolor, visibility: visibility, enrichedItem: item?.openHABItem, descriptionChanged: descriptionChanged) + } } extension OpenHABUiTile { diff --git a/openHAB/OpenHABSitemapViewController.swift b/openHAB/OpenHABSitemapViewController.swift index 7ff07ff63..d627c7437 100644 --- a/openHAB/OpenHABSitemapViewController.swift +++ b/openHAB/OpenHABSitemapViewController.swift @@ -331,12 +331,17 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel } asyncOperation = Task { do { - await apiactor?.updateBaseURL(with: URL(string: appData?.openHABRootUrl ?? "")!) - -// if let subscriptionid = try await apiactor?.openHABcreateSubscription() { -// let sitemap = try await apiactor?.openHABpollSitemap(sitemapname: defaultSitemap, longPolling: longPolling, subscriptionId: subscriptionid) -// try await apiactor?.openHABSitemapWidgetEvents(subscriptionid: subscriptionid, sitemap: defaultSitemap) -// } + if let apiactor { + await apiactor.updateBaseURL(with: URL(string: appData?.openHABRootUrl ?? "")!) + + if let subscriptionid = try await apiactor.openHABcreateSubscription() { + let sitemap = try await apiactor.openHABpollSitemap(sitemapname: defaultSitemap, longPolling: longPolling, subscriptionId: subscriptionid) + let events = try await apiactor.openHABSitemapWidgetEvents(subscriptionid: subscriptionid, sitemap: defaultSitemap) + for try await event in events { + print(event) + } + } + } currentPage = try await apiactor?.openHABpollPage(sitemapname: defaultSitemap, longPolling: longPolling) From aaf8b49e87132e8ef185854104eba5a7f32975fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20M=C3=BCller-Seydlitz?= Date: Tue, 20 Aug 2024 21:58:40 +0200 Subject: [PATCH 006/476] Using logger --- .../Sources/OpenHABCore/Util/APIActor.swift | 25 ------------------- openHAB/OpenHABSitemapViewController.swift | 4 ++- 2 files changed, 3 insertions(+), 26 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/APIActor.swift b/OpenHABCore/Sources/OpenHABCore/Util/APIActor.swift index 3793082de..0083c97b3 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/APIActor.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/APIActor.swift @@ -122,31 +122,6 @@ extension APIActor: OpenHABUiTileService { } } -public extension AsyncThrowingStream { -// func map(_ transform: @escaping (Self.Element) -> Transformed) -> AsyncThrowingStream { -// AsyncThrowingStream { continuation in -// Task { -// for try await element in self { -// continuation.yield(transform(element)) -// } -// continuation.finish() -// } -// } -// } - - func map2(transform: @escaping (Self.Element) -> T) -> AsyncThrowingStream { - AsyncThrowingStream { continuation in - let task = Task { - for try await element in self { - continuation.yield(transform(element)) - } - continuation.finish() - } - continuation.onTermination = { _ in task.cancel() } - } - } -} - public extension APIActor { func openHABcreateSubscription() async throws -> String? { logger.info("Creating subscription") diff --git a/openHAB/OpenHABSitemapViewController.swift b/openHAB/OpenHABSitemapViewController.swift index d627c7437..0056255a8 100644 --- a/openHAB/OpenHABSitemapViewController.swift +++ b/openHAB/OpenHABSitemapViewController.swift @@ -87,6 +87,8 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel private var isWaitingToReload = false private var asyncOperation: Task? + private let logger = Logger(subsystem: "org.openhab.app", category: "OpenHABSitemapViewController") + var relevantPage: OpenHABPage? { if isFiltering { filteredPage @@ -333,8 +335,8 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel do { if let apiactor { await apiactor.updateBaseURL(with: URL(string: appData?.openHABRootUrl ?? "")!) - if let subscriptionid = try await apiactor.openHABcreateSubscription() { + logger.log("Got subscriptionid: \(subscriptionid)") let sitemap = try await apiactor.openHABpollSitemap(sitemapname: defaultSitemap, longPolling: longPolling, subscriptionId: subscriptionid) let events = try await apiactor.openHABSitemapWidgetEvents(subscriptionid: subscriptionid, sitemap: defaultSitemap) for try await event in events { From ec0fc85553a28cfc7189c59299893a3f7fc6e090 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20M=C3=BCller-Seydlitz?= Date: Mon, 26 Aug 2024 19:45:47 +0200 Subject: [PATCH 007/476] Changed accessModifier to public - To be changed back to internal when switching to swift 6.0 --- .../GeneratedSources/openapi/Client.swift | 70 +- .../GeneratedSources/openapi/Types.swift | 2410 ++++++++--------- .../Sources/OpenHABCore/Util/APIActor.swift | 14 +- .../openapi/openapi-generator-config.yml | 2 +- openHAB/OpenHABSitemapViewController.swift | 1 + 5 files changed, 1248 insertions(+), 1249 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Client.swift b/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Client.swift index 9f0f22bf0..659256804 100644 --- a/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Client.swift +++ b/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Client.swift @@ -22,7 +22,7 @@ import struct Foundation.URL #endif import HTTPTypes -struct Client: APIProtocol { +public struct Client: APIProtocol { /// The underlying HTTP client. private let client: UniversalClient /// Creates a new client. @@ -33,10 +33,10 @@ struct Client: APIProtocol { /// - configuration: A set of configuration values for the client. /// - transport: A transport that performs HTTP operations. /// - middlewares: A list of middlewares to call before the transport. - init(serverURL: Foundation.URL, - configuration: Configuration = .init(), - transport: any ClientTransport, - middlewares: [any ClientMiddleware] = []) { + public init(serverURL: Foundation.URL, + configuration: Configuration = .init(), + transport: any ClientTransport, + middlewares: [any ClientMiddleware] = []) { client = .init( serverURL: serverURL, configuration: configuration, @@ -53,7 +53,7 @@ struct Client: APIProtocol { /// /// - Remark: HTTP `PUT /items/{itemName}/members/{memberItemName}`. /// - Remark: Generated from `#/paths//items/{itemName}/members/{memberItemName}/put(addMemberToGroupItem)`. - func addMemberToGroupItem(_ input: Operations.addMemberToGroupItem.Input) async throws -> Operations.addMemberToGroupItem.Output { + public func addMemberToGroupItem(_ input: Operations.addMemberToGroupItem.Input) async throws -> Operations.addMemberToGroupItem.Output { try await client.send( input: input, forOperation: Operations.addMemberToGroupItem.id, @@ -97,7 +97,7 @@ struct Client: APIProtocol { /// /// - Remark: HTTP `DELETE /items/{itemName}/members/{memberItemName}`. /// - Remark: Generated from `#/paths//items/{itemName}/members/{memberItemName}/delete(removeMemberFromGroupItem)`. - func removeMemberFromGroupItem(_ input: Operations.removeMemberFromGroupItem.Input) async throws -> Operations.removeMemberFromGroupItem.Output { + public func removeMemberFromGroupItem(_ input: Operations.removeMemberFromGroupItem.Input) async throws -> Operations.removeMemberFromGroupItem.Output { try await client.send( input: input, forOperation: Operations.removeMemberFromGroupItem.id, @@ -141,7 +141,7 @@ struct Client: APIProtocol { /// /// - Remark: HTTP `PUT /items/{itemname}/metadata/{namespace}`. /// - Remark: Generated from `#/paths//items/{itemname}/metadata/{namespace}/put(addMetadataToItem)`. - func addMetadataToItem(_ input: Operations.addMetadataToItem.Input) async throws -> Operations.addMetadataToItem.Output { + public func addMetadataToItem(_ input: Operations.addMetadataToItem.Input) async throws -> Operations.addMetadataToItem.Output { try await client.send( input: input, forOperation: Operations.addMetadataToItem.id, @@ -197,7 +197,7 @@ struct Client: APIProtocol { /// /// - Remark: HTTP `DELETE /items/{itemname}/metadata/{namespace}`. /// - Remark: Generated from `#/paths//items/{itemname}/metadata/{namespace}/delete(removeMetadataFromItem)`. - func removeMetadataFromItem(_ input: Operations.removeMetadataFromItem.Input) async throws -> Operations.removeMetadataFromItem.Output { + public func removeMetadataFromItem(_ input: Operations.removeMetadataFromItem.Input) async throws -> Operations.removeMetadataFromItem.Output { try await client.send( input: input, forOperation: Operations.removeMetadataFromItem.id, @@ -241,7 +241,7 @@ struct Client: APIProtocol { /// /// - Remark: HTTP `PUT /items/{itemname}/tags/{tag}`. /// - Remark: Generated from `#/paths//items/{itemname}/tags/{tag}/put(addTagToItem)`. - func addTagToItem(_ input: Operations.addTagToItem.Input) async throws -> Operations.addTagToItem.Output { + public func addTagToItem(_ input: Operations.addTagToItem.Input) async throws -> Operations.addTagToItem.Output { try await client.send( input: input, forOperation: Operations.addTagToItem.id, @@ -285,7 +285,7 @@ struct Client: APIProtocol { /// /// - Remark: HTTP `DELETE /items/{itemname}/tags/{tag}`. /// - Remark: Generated from `#/paths//items/{itemname}/tags/{tag}/delete(removeTagFromItem)`. - func removeTagFromItem(_ input: Operations.removeTagFromItem.Input) async throws -> Operations.removeTagFromItem.Output { + public func removeTagFromItem(_ input: Operations.removeTagFromItem.Input) async throws -> Operations.removeTagFromItem.Output { try await client.send( input: input, forOperation: Operations.removeTagFromItem.id, @@ -329,7 +329,7 @@ struct Client: APIProtocol { /// /// - Remark: HTTP `GET /items/{itemname}`. /// - Remark: Generated from `#/paths//items/{itemname}/get(getItemByName)`. - func getItemByName(_ input: Operations.getItemByName.Input) async throws -> Operations.getItemByName.Output { + public func getItemByName(_ input: Operations.getItemByName.Input) async throws -> Operations.getItemByName.Output { try await client.send( input: input, forOperation: Operations.getItemByName.id, @@ -413,7 +413,7 @@ struct Client: APIProtocol { /// /// - Remark: HTTP `POST /items/{itemname}`. /// - Remark: Generated from `#/paths//items/{itemname}/post(sendItemCommand)`. - func sendItemCommand(_ input: Operations.sendItemCommand.Input) async throws -> Operations.sendItemCommand.Output { + public func sendItemCommand(_ input: Operations.sendItemCommand.Input) async throws -> Operations.sendItemCommand.Output { try await client.send( input: input, forOperation: Operations.sendItemCommand.id, @@ -464,7 +464,7 @@ struct Client: APIProtocol { /// /// - Remark: HTTP `PUT /items/{itemname}`. /// - Remark: Generated from `#/paths//items/{itemname}/put(addOrUpdateItemInRegistry)`. - func addOrUpdateItemInRegistry(_ input: Operations.addOrUpdateItemInRegistry.Input) async throws -> Operations.addOrUpdateItemInRegistry.Output { + public func addOrUpdateItemInRegistry(_ input: Operations.addOrUpdateItemInRegistry.Input) async throws -> Operations.addOrUpdateItemInRegistry.Output { try await client.send( input: input, forOperation: Operations.addOrUpdateItemInRegistry.id, @@ -548,7 +548,7 @@ struct Client: APIProtocol { /// /// - Remark: HTTP `DELETE /items/{itemname}`. /// - Remark: Generated from `#/paths//items/{itemname}/delete(removeItemFromRegistry)`. - func removeItemFromRegistry(_ input: Operations.removeItemFromRegistry.Input) async throws -> Operations.removeItemFromRegistry.Output { + public func removeItemFromRegistry(_ input: Operations.removeItemFromRegistry.Input) async throws -> Operations.removeItemFromRegistry.Output { try await client.send( input: input, forOperation: Operations.removeItemFromRegistry.id, @@ -589,7 +589,7 @@ struct Client: APIProtocol { /// /// - Remark: HTTP `GET /items`. /// - Remark: Generated from `#/paths//items/get(getItems)`. - func getItems(_ input: Operations.getItems.Input) async throws -> Operations.getItems.Output { + public func getItems(_ input: Operations.getItems.Input) async throws -> Operations.getItems.Output { try await client.send( input: input, forOperation: Operations.getItems.id, @@ -697,7 +697,7 @@ struct Client: APIProtocol { /// /// - Remark: HTTP `PUT /items`. /// - Remark: Generated from `#/paths//items/put(addOrUpdateItemsInRegistry)`. - func addOrUpdateItemsInRegistry(_ input: Operations.addOrUpdateItemsInRegistry.Input) async throws -> Operations.addOrUpdateItemsInRegistry.Output { + public func addOrUpdateItemsInRegistry(_ input: Operations.addOrUpdateItemsInRegistry.Input) async throws -> Operations.addOrUpdateItemsInRegistry.Output { try await client.send( input: input, forOperation: Operations.addOrUpdateItemsInRegistry.id, @@ -768,7 +768,7 @@ struct Client: APIProtocol { /// /// - Remark: HTTP `GET /items/{itemname}/state`. /// - Remark: Generated from `#/paths//items/{itemname}/state/get(getItemState_1)`. - func getItemState_1(_ input: Operations.getItemState_1.Input) async throws -> Operations.getItemState_1.Output { + public func getItemState_1(_ input: Operations.getItemState_1.Input) async throws -> Operations.getItemState_1.Output { try await client.send( input: input, forOperation: Operations.getItemState_1.id, @@ -833,7 +833,7 @@ struct Client: APIProtocol { /// /// - Remark: HTTP `PUT /items/{itemname}/state`. /// - Remark: Generated from `#/paths//items/{itemname}/state/put(updateItemState)`. - func updateItemState(_ input: Operations.updateItemState.Input) async throws -> Operations.updateItemState.Output { + public func updateItemState(_ input: Operations.updateItemState.Input) async throws -> Operations.updateItemState.Output { try await client.send( input: input, forOperation: Operations.updateItemState.id, @@ -889,7 +889,7 @@ struct Client: APIProtocol { /// /// - Remark: HTTP `GET /items/{itemname}/metadata/namespaces`. /// - Remark: Generated from `#/paths//items/{itemname}/metadata/namespaces/get(getItemNamespaces)`. - func getItemNamespaces(_ input: Operations.getItemNamespaces.Input) async throws -> Operations.getItemNamespaces.Output { + public func getItemNamespaces(_ input: Operations.getItemNamespaces.Input) async throws -> Operations.getItemNamespaces.Output { try await client.send( input: input, forOperation: Operations.getItemNamespaces.id, @@ -959,7 +959,7 @@ struct Client: APIProtocol { /// /// - Remark: HTTP `GET /items/{itemName}/semantic/{semanticClass}`. /// - Remark: Generated from `#/paths//items/{itemName}/semantic/{semanticClass}/get(getSemanticItem)`. - func getSemanticItem(_ input: Operations.getSemanticItem.Input) async throws -> Operations.getSemanticItem.Output { + public func getSemanticItem(_ input: Operations.getSemanticItem.Input) async throws -> Operations.getSemanticItem.Output { try await client.send( input: input, forOperation: Operations.getSemanticItem.id, @@ -1006,7 +1006,7 @@ struct Client: APIProtocol { /// /// - Remark: HTTP `POST /items/metadata/purge`. /// - Remark: Generated from `#/paths//items/metadata/purge/post(purgeDatabase)`. - func purgeDatabase(_ input: Operations.purgeDatabase.Input) async throws -> Operations.purgeDatabase.Output { + public func purgeDatabase(_ input: Operations.purgeDatabase.Input) async throws -> Operations.purgeDatabase.Output { try await client.send( input: input, forOperation: Operations.purgeDatabase.id, @@ -1043,7 +1043,7 @@ struct Client: APIProtocol { /// /// - Remark: HTTP `POST /sitemaps/events/subscribe`. /// - Remark: Generated from `#/paths//sitemaps/events/subscribe/post(createSitemapEventSubscription)`. - func createSitemapEventSubscription(_ input: Operations.createSitemapEventSubscription.Input) async throws -> Operations.createSitemapEventSubscription.Output { + public func createSitemapEventSubscription(_ input: Operations.createSitemapEventSubscription.Input) async throws -> Operations.createSitemapEventSubscription.Output { try await client.send( input: input, forOperation: Operations.createSitemapEventSubscription.id, @@ -1108,7 +1108,7 @@ struct Client: APIProtocol { /// /// - Remark: HTTP `GET /sitemaps/{sitemapname}/{pageid}`. /// - Remark: Generated from `#/paths//sitemaps/{sitemapname}/{pageid}/get(pollDataForPage)`. - func pollDataForPage(_ input: Operations.pollDataForPage.Input) async throws -> Operations.pollDataForPage.Output { + public func pollDataForPage(_ input: Operations.pollDataForPage.Input) async throws -> Operations.pollDataForPage.Output { try await client.send( input: input, forOperation: Operations.pollDataForPage.id, @@ -1200,7 +1200,7 @@ struct Client: APIProtocol { /// /// - Remark: HTTP `GET /sitemaps/{sitemapname}/*`. /// - Remark: Generated from `#/paths//sitemaps/{sitemapname}/*/get(pollDataForSitemap)`. - func pollDataForSitemap(_ input: Operations.pollDataForSitemap.Input) async throws -> Operations.pollDataForSitemap.Output { + public func pollDataForSitemap(_ input: Operations.pollDataForSitemap.Input) async throws -> Operations.pollDataForSitemap.Output { try await client.send( input: input, forOperation: Operations.pollDataForSitemap.id, @@ -1291,7 +1291,7 @@ struct Client: APIProtocol { /// /// - Remark: HTTP `GET /sitemaps/{sitemapname}`. /// - Remark: Generated from `#/paths//sitemaps/{sitemapname}/get(getSitemapByName)`. - func getSitemapByName(_ input: Operations.getSitemapByName.Input) async throws -> Operations.getSitemapByName.Output { + public func getSitemapByName(_ input: Operations.getSitemapByName.Input) async throws -> Operations.getSitemapByName.Output { try await client.send( input: input, forOperation: Operations.getSitemapByName.id, @@ -1380,7 +1380,7 @@ struct Client: APIProtocol { /// /// - Remark: HTTP `GET /sitemaps/events/{subscriptionid}/*`. /// - Remark: Generated from `#/paths//sitemaps/events/{subscriptionid}/*/get(getSitemapEvents)`. - func getSitemapEvents(_ input: Operations.getSitemapEvents.Input) async throws -> Operations.getSitemapEvents.Output { + public func getSitemapEvents(_ input: Operations.getSitemapEvents.Input) async throws -> Operations.getSitemapEvents.Output { try await client.send( input: input, forOperation: Operations.getSitemapEvents.id, @@ -1430,7 +1430,7 @@ struct Client: APIProtocol { /// /// - Remark: HTTP `GET /sitemaps/events/{subscriptionid}`. /// - Remark: Generated from `#/paths//sitemaps/events/{subscriptionid}/get(getSitemapEvents_1)`. - func getSitemapEvents_1(_ input: Operations.getSitemapEvents_1.Input) async throws -> Operations.getSitemapEvents_1.Output { + public func getSitemapEvents_1(_ input: Operations.getSitemapEvents_1.Input) async throws -> Operations.getSitemapEvents_1.Output { try await client.send( input: input, forOperation: Operations.getSitemapEvents_1.id, @@ -1520,7 +1520,7 @@ struct Client: APIProtocol { /// /// - Remark: HTTP `GET /sitemaps`. /// - Remark: Generated from `#/paths//sitemaps/get(getSitemaps)`. - func getSitemaps(_ input: Operations.getSitemaps.Input) async throws -> Operations.getSitemaps.Output { + public func getSitemaps(_ input: Operations.getSitemaps.Input) async throws -> Operations.getSitemaps.Output { try await client.send( input: input, forOperation: Operations.getSitemaps.id, @@ -1581,7 +1581,7 @@ struct Client: APIProtocol { /// /// - Remark: HTTP `GET /ui/components/{namespace}`. /// - Remark: Generated from `#/paths//ui/components/{namespace}/get(getRegisteredUIComponentsInNamespace)`. - func getRegisteredUIComponentsInNamespace(_ input: Operations.getRegisteredUIComponentsInNamespace.Input) async throws -> Operations.getRegisteredUIComponentsInNamespace.Output { + public func getRegisteredUIComponentsInNamespace(_ input: Operations.getRegisteredUIComponentsInNamespace.Input) async throws -> Operations.getRegisteredUIComponentsInNamespace.Output { try await client.send( input: input, forOperation: Operations.getRegisteredUIComponentsInNamespace.id, @@ -1651,7 +1651,7 @@ struct Client: APIProtocol { /// /// - Remark: HTTP `POST /ui/components/{namespace}`. /// - Remark: Generated from `#/paths//ui/components/{namespace}/post(addUIComponentToNamespace)`. - func addUIComponentToNamespace(_ input: Operations.addUIComponentToNamespace.Input) async throws -> Operations.addUIComponentToNamespace.Output { + public func addUIComponentToNamespace(_ input: Operations.addUIComponentToNamespace.Input) async throws -> Operations.addUIComponentToNamespace.Output { try await client.send( input: input, forOperation: Operations.addUIComponentToNamespace.id, @@ -1724,7 +1724,7 @@ struct Client: APIProtocol { /// /// - Remark: HTTP `GET /ui/components/{namespace}/{componentUID}`. /// - Remark: Generated from `#/paths//ui/components/{namespace}/{componentUID}/get(getUIComponentInNamespace)`. - func getUIComponentInNamespace(_ input: Operations.getUIComponentInNamespace.Input) async throws -> Operations.getUIComponentInNamespace.Output { + public func getUIComponentInNamespace(_ input: Operations.getUIComponentInNamespace.Input) async throws -> Operations.getUIComponentInNamespace.Output { try await client.send( input: input, forOperation: Operations.getUIComponentInNamespace.id, @@ -1790,7 +1790,7 @@ struct Client: APIProtocol { /// /// - Remark: HTTP `PUT /ui/components/{namespace}/{componentUID}`. /// - Remark: Generated from `#/paths//ui/components/{namespace}/{componentUID}/put(updateUIComponentInNamespace)`. - func updateUIComponentInNamespace(_ input: Operations.updateUIComponentInNamespace.Input) async throws -> Operations.updateUIComponentInNamespace.Output { + public func updateUIComponentInNamespace(_ input: Operations.updateUIComponentInNamespace.Input) async throws -> Operations.updateUIComponentInNamespace.Output { try await client.send( input: input, forOperation: Operations.updateUIComponentInNamespace.id, @@ -1866,7 +1866,7 @@ struct Client: APIProtocol { /// /// - Remark: HTTP `DELETE /ui/components/{namespace}/{componentUID}`. /// - Remark: Generated from `#/paths//ui/components/{namespace}/{componentUID}/delete(removeUIComponentFromNamespace)`. - func removeUIComponentFromNamespace(_ input: Operations.removeUIComponentFromNamespace.Input) async throws -> Operations.removeUIComponentFromNamespace.Output { + public func removeUIComponentFromNamespace(_ input: Operations.removeUIComponentFromNamespace.Input) async throws -> Operations.removeUIComponentFromNamespace.Output { try await client.send( input: input, forOperation: Operations.removeUIComponentFromNamespace.id, @@ -1908,7 +1908,7 @@ struct Client: APIProtocol { /// /// - Remark: HTTP `GET /ui/tiles`. /// - Remark: Generated from `#/paths//ui/tiles/get(getUITiles)`. - func getUITiles(_ input: Operations.getUITiles.Input) async throws -> Operations.getUITiles.Output { + public func getUITiles(_ input: Operations.getUITiles.Input) async throws -> Operations.getUITiles.Output { try await client.send( input: input, forOperation: Operations.getUITiles.id, diff --git a/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Types.swift b/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Types.swift index 23afee90e..ed7400a1c 100644 --- a/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Types.swift +++ b/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Types.swift @@ -21,7 +21,7 @@ import struct Foundation.Date import struct Foundation.URL #endif /// A type that performs HTTP operations defined by the OpenAPI document. -protocol APIProtocol: Sendable { +public protocol APIProtocol: Sendable { /// Adds a new member to a group item. /// /// - Remark: HTTP `PUT /items/{itemName}/members/{memberItemName}`. @@ -175,7 +175,7 @@ protocol APIProtocol: Sendable { } /// Convenience overloads for operation inputs. -extension APIProtocol { +public extension APIProtocol { /// Adds a new member to a group item. /// /// - Remark: HTTP `PUT /items/{itemName}/members/{memberItemName}`. @@ -514,8 +514,8 @@ extension APIProtocol { } /// Server URLs defined in the OpenAPI document. -enum Servers { - static func server1() throws -> Foundation.URL { +public enum Servers { + public static func server1() throws -> Foundation.URL { try Foundation.URL( validatingOpenAPIServerURL: "/rest", variables: [] @@ -524,25 +524,25 @@ enum Servers { } /// Types generated from the components section of the OpenAPI document. -enum Components { +public enum Components { /// Types generated from the `#/components/schemas` section of the OpenAPI document. - enum Schemas { + public enum Schemas { /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterDTO`. - struct ConfigDescriptionParameterDTO: Codable, Hashable, Sendable { + public struct ConfigDescriptionParameterDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterDTO/context`. - var context: Swift.String? + public var context: Swift.String? /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterDTO/defaultValue`. - var defaultValue: Swift.String? + public var defaultValue: Swift.String? /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterDTO/description`. - var description: Swift.String? + public var description: Swift.String? /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterDTO/label`. - var label: Swift.String? + public var label: Swift.String? /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterDTO/name`. - var name: Swift.String? + public var name: Swift.String? /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterDTO/required`. - var required: Swift.Bool? + public var required: Swift.Bool? /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterDTO/type`. - enum _typePayload: String, Codable, Hashable, Sendable, CaseIterable { + @frozen public enum _typePayload: String, Codable, Hashable, Sendable, CaseIterable { case TEXT case INTEGER case DECIMAL @@ -550,37 +550,37 @@ enum Components { } /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterDTO/type`. - var _type: Components.Schemas.ConfigDescriptionParameterDTO._typePayload? + public var _type: Components.Schemas.ConfigDescriptionParameterDTO._typePayload? /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterDTO/min`. - var min: Swift.Double? + public var min: Swift.Double? /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterDTO/max`. - var max: Swift.Double? + public var max: Swift.Double? /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterDTO/stepsize`. - var stepsize: Swift.Double? + public var stepsize: Swift.Double? /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterDTO/pattern`. - var pattern: Swift.String? + public var pattern: Swift.String? /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterDTO/readOnly`. - var readOnly: Swift.Bool? + public var readOnly: Swift.Bool? /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterDTO/multiple`. - var multiple: Swift.Bool? + public var multiple: Swift.Bool? /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterDTO/multipleLimit`. - var multipleLimit: Swift.Int32? + public var multipleLimit: Swift.Int32? /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterDTO/groupName`. - var groupName: Swift.String? + public var groupName: Swift.String? /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterDTO/advanced`. - var advanced: Swift.Bool? + public var advanced: Swift.Bool? /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterDTO/verify`. - var verify: Swift.Bool? + public var verify: Swift.Bool? /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterDTO/limitToOptions`. - var limitToOptions: Swift.Bool? + public var limitToOptions: Swift.Bool? /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterDTO/unit`. - var unit: Swift.String? + public var unit: Swift.String? /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterDTO/unitLabel`. - var unitLabel: Swift.String? + public var unitLabel: Swift.String? /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterDTO/options`. - var options: [Components.Schemas.ParameterOptionDTO]? + public var options: [Components.Schemas.ParameterOptionDTO]? /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterDTO/filterCriteria`. - var filterCriteria: [Components.Schemas.FilterCriteriaDTO]? + public var filterCriteria: [Components.Schemas.FilterCriteriaDTO]? /// Creates a new `ConfigDescriptionParameterDTO`. /// /// - Parameters: @@ -606,28 +606,28 @@ enum Components { /// - unitLabel: /// - options: /// - filterCriteria: - init(context: Swift.String? = nil, - defaultValue: Swift.String? = nil, - description: Swift.String? = nil, - label: Swift.String? = nil, - name: Swift.String? = nil, - required: Swift.Bool? = nil, - _type: Components.Schemas.ConfigDescriptionParameterDTO._typePayload? = nil, - min: Swift.Double? = nil, - max: Swift.Double? = nil, - stepsize: Swift.Double? = nil, - pattern: Swift.String? = nil, - readOnly: Swift.Bool? = nil, - multiple: Swift.Bool? = nil, - multipleLimit: Swift.Int32? = nil, - groupName: Swift.String? = nil, - advanced: Swift.Bool? = nil, - verify: Swift.Bool? = nil, - limitToOptions: Swift.Bool? = nil, - unit: Swift.String? = nil, - unitLabel: Swift.String? = nil, - options: [Components.Schemas.ParameterOptionDTO]? = nil, - filterCriteria: [Components.Schemas.FilterCriteriaDTO]? = nil) { + public init(context: Swift.String? = nil, + defaultValue: Swift.String? = nil, + description: Swift.String? = nil, + label: Swift.String? = nil, + name: Swift.String? = nil, + required: Swift.Bool? = nil, + _type: Components.Schemas.ConfigDescriptionParameterDTO._typePayload? = nil, + min: Swift.Double? = nil, + max: Swift.Double? = nil, + stepsize: Swift.Double? = nil, + pattern: Swift.String? = nil, + readOnly: Swift.Bool? = nil, + multiple: Swift.Bool? = nil, + multipleLimit: Swift.Int32? = nil, + groupName: Swift.String? = nil, + advanced: Swift.Bool? = nil, + verify: Swift.Bool? = nil, + limitToOptions: Swift.Bool? = nil, + unit: Swift.String? = nil, + unitLabel: Swift.String? = nil, + options: [Components.Schemas.ParameterOptionDTO]? = nil, + filterCriteria: [Components.Schemas.FilterCriteriaDTO]? = nil) { self.context = context self.defaultValue = defaultValue self.description = description @@ -652,7 +652,7 @@ enum Components { self.filterCriteria = filterCriteria } - enum CodingKeys: String, CodingKey { + public enum CodingKeys: String, CodingKey { case context case defaultValue case description @@ -679,103 +679,103 @@ enum Components { } /// - Remark: Generated from `#/components/schemas/FilterCriteriaDTO`. - struct FilterCriteriaDTO: Codable, Hashable, Sendable { + public struct FilterCriteriaDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/FilterCriteriaDTO/value`. - var value: Swift.String? + public var value: Swift.String? /// - Remark: Generated from `#/components/schemas/FilterCriteriaDTO/name`. - var name: Swift.String? + public var name: Swift.String? /// Creates a new `FilterCriteriaDTO`. /// /// - Parameters: /// - value: /// - name: - init(value: Swift.String? = nil, - name: Swift.String? = nil) { + public init(value: Swift.String? = nil, + name: Swift.String? = nil) { self.value = value self.name = name } - enum CodingKeys: String, CodingKey { + public enum CodingKeys: String, CodingKey { case value case name } } /// - Remark: Generated from `#/components/schemas/ParameterOptionDTO`. - struct ParameterOptionDTO: Codable, Hashable, Sendable { + public struct ParameterOptionDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/ParameterOptionDTO/label`. - var label: Swift.String? + public var label: Swift.String? /// - Remark: Generated from `#/components/schemas/ParameterOptionDTO/value`. - var value: Swift.String? + public var value: Swift.String? /// Creates a new `ParameterOptionDTO`. /// /// - Parameters: /// - label: /// - value: - init(label: Swift.String? = nil, - value: Swift.String? = nil) { + public init(label: Swift.String? = nil, + value: Swift.String? = nil) { self.label = label self.value = value } - enum CodingKeys: String, CodingKey { + public enum CodingKeys: String, CodingKey { case label case value } } /// - Remark: Generated from `#/components/schemas/CommandDescription`. - struct CommandDescription: Codable, Hashable, Sendable { + public struct CommandDescription: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/CommandDescription/commandOptions`. - var commandOptions: [Components.Schemas.CommandOption]? + public var commandOptions: [Components.Schemas.CommandOption]? /// Creates a new `CommandDescription`. /// /// - Parameters: /// - commandOptions: - init(commandOptions: [Components.Schemas.CommandOption]? = nil) { + public init(commandOptions: [Components.Schemas.CommandOption]? = nil) { self.commandOptions = commandOptions } - enum CodingKeys: String, CodingKey { + public enum CodingKeys: String, CodingKey { case commandOptions } } /// - Remark: Generated from `#/components/schemas/CommandOption`. - struct CommandOption: Codable, Hashable, Sendable { + public struct CommandOption: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/CommandOption/command`. - var command: Swift.String? + public var command: Swift.String? /// - Remark: Generated from `#/components/schemas/CommandOption/label`. - var label: Swift.String? + public var label: Swift.String? /// Creates a new `CommandOption`. /// /// - Parameters: /// - command: /// - label: - init(command: Swift.String? = nil, - label: Swift.String? = nil) { + public init(command: Swift.String? = nil, + label: Swift.String? = nil) { self.command = command self.label = label } - enum CodingKeys: String, CodingKey { + public enum CodingKeys: String, CodingKey { case command case label } } /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterGroupDTO`. - struct ConfigDescriptionParameterGroupDTO: Codable, Hashable, Sendable { + public struct ConfigDescriptionParameterGroupDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterGroupDTO/name`. - var name: Swift.String? + public var name: Swift.String? /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterGroupDTO/context`. - var context: Swift.String? + public var context: Swift.String? /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterGroupDTO/advanced`. - var advanced: Swift.Bool? + public var advanced: Swift.Bool? /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterGroupDTO/label`. - var label: Swift.String? + public var label: Swift.String? /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterGroupDTO/description`. - var description: Swift.String? + public var description: Swift.String? /// Creates a new `ConfigDescriptionParameterGroupDTO`. /// /// - Parameters: @@ -784,11 +784,11 @@ enum Components { /// - advanced: /// - label: /// - description: - init(name: Swift.String? = nil, - context: Swift.String? = nil, - advanced: Swift.Bool? = nil, - label: Swift.String? = nil, - description: Swift.String? = nil) { + public init(name: Swift.String? = nil, + context: Swift.String? = nil, + advanced: Swift.Bool? = nil, + label: Swift.String? = nil, + description: Swift.String? = nil) { self.name = name self.context = context self.advanced = advanced @@ -796,7 +796,7 @@ enum Components { self.description = description } - enum CodingKeys: String, CodingKey { + public enum CodingKeys: String, CodingKey { case name case context case advanced @@ -806,19 +806,19 @@ enum Components { } /// - Remark: Generated from `#/components/schemas/StateDescription`. - struct StateDescription: Codable, Hashable, Sendable { + public struct StateDescription: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/StateDescription/minimum`. - var minimum: Swift.Double? + public var minimum: Swift.Double? /// - Remark: Generated from `#/components/schemas/StateDescription/maximum`. - var maximum: Swift.Double? + public var maximum: Swift.Double? /// - Remark: Generated from `#/components/schemas/StateDescription/step`. - var step: Swift.Double? + public var step: Swift.Double? /// - Remark: Generated from `#/components/schemas/StateDescription/pattern`. - var pattern: Swift.String? + public var pattern: Swift.String? /// - Remark: Generated from `#/components/schemas/StateDescription/readOnly`. - var readOnly: Swift.Bool? + public var readOnly: Swift.Bool? /// - Remark: Generated from `#/components/schemas/StateDescription/options`. - var options: [Components.Schemas.StateOption]? + public var options: [Components.Schemas.StateOption]? /// Creates a new `StateDescription`. /// /// - Parameters: @@ -828,12 +828,12 @@ enum Components { /// - pattern: /// - readOnly: /// - options: - init(minimum: Swift.Double? = nil, - maximum: Swift.Double? = nil, - step: Swift.Double? = nil, - pattern: Swift.String? = nil, - readOnly: Swift.Bool? = nil, - options: [Components.Schemas.StateOption]? = nil) { + public init(minimum: Swift.Double? = nil, + maximum: Swift.Double? = nil, + step: Swift.Double? = nil, + pattern: Swift.String? = nil, + readOnly: Swift.Bool? = nil, + options: [Components.Schemas.StateOption]? = nil) { self.minimum = minimum self.maximum = maximum self.step = step @@ -842,7 +842,7 @@ enum Components { self.options = options } - enum CodingKeys: String, CodingKey { + public enum CodingKeys: String, CodingKey { case minimum case maximum case step @@ -853,51 +853,51 @@ enum Components { } /// - Remark: Generated from `#/components/schemas/StateOption`. - struct StateOption: Codable, Hashable, Sendable { + public struct StateOption: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/StateOption/value`. - var value: Swift.String? + public var value: Swift.String? /// - Remark: Generated from `#/components/schemas/StateOption/label`. - var label: Swift.String? + public var label: Swift.String? /// Creates a new `StateOption`. /// /// - Parameters: /// - value: /// - label: - init(value: Swift.String? = nil, - label: Swift.String? = nil) { + public init(value: Swift.String? = nil, + label: Swift.String? = nil) { self.value = value self.label = label } - enum CodingKeys: String, CodingKey { + public enum CodingKeys: String, CodingKey { case value case label } } /// - Remark: Generated from `#/components/schemas/ConfigDescriptionDTO`. - struct ConfigDescriptionDTO: Codable, Hashable, Sendable { + public struct ConfigDescriptionDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/ConfigDescriptionDTO/uri`. - var uri: Swift.String? + public var uri: Swift.String? /// - Remark: Generated from `#/components/schemas/ConfigDescriptionDTO/parameters`. - var parameters: [Components.Schemas.ConfigDescriptionParameterDTO]? + public var parameters: [Components.Schemas.ConfigDescriptionParameterDTO]? /// - Remark: Generated from `#/components/schemas/ConfigDescriptionDTO/parameterGroups`. - var parameterGroups: [Components.Schemas.ConfigDescriptionParameterGroupDTO]? + public var parameterGroups: [Components.Schemas.ConfigDescriptionParameterGroupDTO]? /// Creates a new `ConfigDescriptionDTO`. /// /// - Parameters: /// - uri: /// - parameters: /// - parameterGroups: - init(uri: Swift.String? = nil, - parameters: [Components.Schemas.ConfigDescriptionParameterDTO]? = nil, - parameterGroups: [Components.Schemas.ConfigDescriptionParameterGroupDTO]? = nil) { + public init(uri: Swift.String? = nil, + parameters: [Components.Schemas.ConfigDescriptionParameterDTO]? = nil, + parameterGroups: [Components.Schemas.ConfigDescriptionParameterGroupDTO]? = nil) { self.uri = uri self.parameters = parameters self.parameterGroups = parameterGroups } - enum CodingKeys: String, CodingKey { + public enum CodingKeys: String, CodingKey { case uri case parameters case parameterGroups @@ -905,100 +905,100 @@ enum Components { } /// - Remark: Generated from `#/components/schemas/MetadataDTO`. - struct MetadataDTO: Codable, Hashable, Sendable { + public struct MetadataDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/MetadataDTO/value`. - var value: Swift.String? + public var value: Swift.String? /// - Remark: Generated from `#/components/schemas/MetadataDTO/config`. - struct configPayload: Codable, Hashable, Sendable { + public struct configPayload: Codable, Hashable, Sendable { /// A container of undocumented properties. - var additionalProperties: [String: OpenAPIRuntime.OpenAPIObjectContainer] + public var additionalProperties: [String: OpenAPIRuntime.OpenAPIObjectContainer] /// Creates a new `configPayload`. /// /// - Parameters: /// - additionalProperties: A container of undocumented properties. - init(additionalProperties: [String: OpenAPIRuntime.OpenAPIObjectContainer] = .init()) { + public init(additionalProperties: [String: OpenAPIRuntime.OpenAPIObjectContainer] = .init()) { self.additionalProperties = additionalProperties } - init(from decoder: any Decoder) throws { + public init(from decoder: any Decoder) throws { additionalProperties = try decoder.decodeAdditionalProperties(knownKeys: []) } - func encode(to encoder: any Encoder) throws { + public func encode(to encoder: any Encoder) throws { try encoder.encodeAdditionalProperties(additionalProperties) } } /// - Remark: Generated from `#/components/schemas/MetadataDTO/config`. - var config: Components.Schemas.MetadataDTO.configPayload? + public var config: Components.Schemas.MetadataDTO.configPayload? /// Creates a new `MetadataDTO`. /// /// - Parameters: /// - value: /// - config: - init(value: Swift.String? = nil, - config: Components.Schemas.MetadataDTO.configPayload? = nil) { + public init(value: Swift.String? = nil, + config: Components.Schemas.MetadataDTO.configPayload? = nil) { self.value = value self.config = config } - enum CodingKeys: String, CodingKey { + public enum CodingKeys: String, CodingKey { case value case config } } /// - Remark: Generated from `#/components/schemas/EnrichedItemDTO`. - struct EnrichedItemDTO: Codable, Hashable, Sendable { + public struct EnrichedItemDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/EnrichedItemDTO/type`. - var _type: Swift.String? + public var _type: Swift.String? /// - Remark: Generated from `#/components/schemas/EnrichedItemDTO/name`. - var name: Swift.String? + public var name: Swift.String? /// - Remark: Generated from `#/components/schemas/EnrichedItemDTO/label`. - var label: Swift.String? + public var label: Swift.String? /// - Remark: Generated from `#/components/schemas/EnrichedItemDTO/category`. - var category: Swift.String? + public var category: Swift.String? /// - Remark: Generated from `#/components/schemas/EnrichedItemDTO/tags`. - var tags: [Swift.String]? + public var tags: [Swift.String]? /// - Remark: Generated from `#/components/schemas/EnrichedItemDTO/groupNames`. - var groupNames: [Swift.String]? + public var groupNames: [Swift.String]? /// - Remark: Generated from `#/components/schemas/EnrichedItemDTO/link`. - var link: Swift.String? + public var link: Swift.String? /// - Remark: Generated from `#/components/schemas/EnrichedItemDTO/state`. - var state: Swift.String? + public var state: Swift.String? /// - Remark: Generated from `#/components/schemas/EnrichedItemDTO/transformedState`. - var transformedState: Swift.String? + public var transformedState: Swift.String? /// - Remark: Generated from `#/components/schemas/EnrichedItemDTO/stateDescription`. - var stateDescription: Components.Schemas.StateDescription? + public var stateDescription: Components.Schemas.StateDescription? /// - Remark: Generated from `#/components/schemas/EnrichedItemDTO/unitSymbol`. - var unitSymbol: Swift.String? + public var unitSymbol: Swift.String? /// - Remark: Generated from `#/components/schemas/EnrichedItemDTO/commandDescription`. - var commandDescription: Components.Schemas.CommandDescription? + public var commandDescription: Components.Schemas.CommandDescription? /// - Remark: Generated from `#/components/schemas/EnrichedItemDTO/metadata`. - struct metadataPayload: Codable, Hashable, Sendable { + public struct metadataPayload: Codable, Hashable, Sendable { /// A container of undocumented properties. - var additionalProperties: [String: OpenAPIRuntime.OpenAPIObjectContainer] + public var additionalProperties: [String: OpenAPIRuntime.OpenAPIObjectContainer] /// Creates a new `metadataPayload`. /// /// - Parameters: /// - additionalProperties: A container of undocumented properties. - init(additionalProperties: [String: OpenAPIRuntime.OpenAPIObjectContainer] = .init()) { + public init(additionalProperties: [String: OpenAPIRuntime.OpenAPIObjectContainer] = .init()) { self.additionalProperties = additionalProperties } - init(from decoder: any Decoder) throws { + public init(from decoder: any Decoder) throws { additionalProperties = try decoder.decodeAdditionalProperties(knownKeys: []) } - func encode(to encoder: any Encoder) throws { + public func encode(to encoder: any Encoder) throws { try encoder.encodeAdditionalProperties(additionalProperties) } } /// - Remark: Generated from `#/components/schemas/EnrichedItemDTO/metadata`. - var metadata: Components.Schemas.EnrichedItemDTO.metadataPayload? + public var metadata: Components.Schemas.EnrichedItemDTO.metadataPayload? /// - Remark: Generated from `#/components/schemas/EnrichedItemDTO/editable`. - var editable: Swift.Bool? + public var editable: Swift.Bool? /// Creates a new `EnrichedItemDTO`. /// /// - Parameters: @@ -1016,20 +1016,20 @@ enum Components { /// - commandDescription: /// - metadata: /// - editable: - init(_type: Swift.String? = nil, - name: Swift.String? = nil, - label: Swift.String? = nil, - category: Swift.String? = nil, - tags: [Swift.String]? = nil, - groupNames: [Swift.String]? = nil, - link: Swift.String? = nil, - state: Swift.String? = nil, - transformedState: Swift.String? = nil, - stateDescription: Components.Schemas.StateDescription? = nil, - unitSymbol: Swift.String? = nil, - commandDescription: Components.Schemas.CommandDescription? = nil, - metadata: Components.Schemas.EnrichedItemDTO.metadataPayload? = nil, - editable: Swift.Bool? = nil) { + public init(_type: Swift.String? = nil, + name: Swift.String? = nil, + label: Swift.String? = nil, + category: Swift.String? = nil, + tags: [Swift.String]? = nil, + groupNames: [Swift.String]? = nil, + link: Swift.String? = nil, + state: Swift.String? = nil, + transformedState: Swift.String? = nil, + stateDescription: Components.Schemas.StateDescription? = nil, + unitSymbol: Swift.String? = nil, + commandDescription: Components.Schemas.CommandDescription? = nil, + metadata: Components.Schemas.EnrichedItemDTO.metadataPayload? = nil, + editable: Swift.Bool? = nil) { self._type = _type self.name = name self.label = label @@ -1046,7 +1046,7 @@ enum Components { self.editable = editable } - enum CodingKeys: String, CodingKey { + public enum CodingKeys: String, CodingKey { case _type = "type" case name case label @@ -1065,46 +1065,46 @@ enum Components { } /// - Remark: Generated from `#/components/schemas/GroupFunctionDTO`. - struct GroupFunctionDTO: Codable, Hashable, Sendable { + public struct GroupFunctionDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/GroupFunctionDTO/name`. - var name: Swift.String? + public var name: Swift.String? /// - Remark: Generated from `#/components/schemas/GroupFunctionDTO/params`. - var params: [Swift.String]? + public var params: [Swift.String]? /// Creates a new `GroupFunctionDTO`. /// /// - Parameters: /// - name: /// - params: - init(name: Swift.String? = nil, - params: [Swift.String]? = nil) { + public init(name: Swift.String? = nil, + params: [Swift.String]? = nil) { self.name = name self.params = params } - enum CodingKeys: String, CodingKey { + public enum CodingKeys: String, CodingKey { case name case params } } /// - Remark: Generated from `#/components/schemas/GroupItemDTO`. - struct GroupItemDTO: Codable, Hashable, Sendable { + public struct GroupItemDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/GroupItemDTO/type`. - var _type: Swift.String? + public var _type: Swift.String? /// - Remark: Generated from `#/components/schemas/GroupItemDTO/name`. - var name: Swift.String? + public var name: Swift.String? /// - Remark: Generated from `#/components/schemas/GroupItemDTO/label`. - var label: Swift.String? + public var label: Swift.String? /// - Remark: Generated from `#/components/schemas/GroupItemDTO/category`. - var category: Swift.String? + public var category: Swift.String? /// - Remark: Generated from `#/components/schemas/GroupItemDTO/tags`. - var tags: [Swift.String]? + public var tags: [Swift.String]? /// - Remark: Generated from `#/components/schemas/GroupItemDTO/groupNames`. - var groupNames: [Swift.String]? + public var groupNames: [Swift.String]? /// - Remark: Generated from `#/components/schemas/GroupItemDTO/groupType`. - var groupType: Swift.String? + public var groupType: Swift.String? /// - Remark: Generated from `#/components/schemas/GroupItemDTO/function`. - var function: Components.Schemas.GroupFunctionDTO? + public var function: Components.Schemas.GroupFunctionDTO? /// Creates a new `GroupItemDTO`. /// /// - Parameters: @@ -1116,14 +1116,14 @@ enum Components { /// - groupNames: /// - groupType: /// - function: - init(_type: Swift.String? = nil, - name: Swift.String? = nil, - label: Swift.String? = nil, - category: Swift.String? = nil, - tags: [Swift.String]? = nil, - groupNames: [Swift.String]? = nil, - groupType: Swift.String? = nil, - function: Components.Schemas.GroupFunctionDTO? = nil) { + public init(_type: Swift.String? = nil, + name: Swift.String? = nil, + label: Swift.String? = nil, + category: Swift.String? = nil, + tags: [Swift.String]? = nil, + groupNames: [Swift.String]? = nil, + groupType: Swift.String? = nil, + function: Components.Schemas.GroupFunctionDTO? = nil) { self._type = _type self.name = name self.label = label @@ -1134,7 +1134,7 @@ enum Components { self.function = function } - enum CodingKeys: String, CodingKey { + public enum CodingKeys: String, CodingKey { case _type = "type" case name case label @@ -1147,76 +1147,76 @@ enum Components { } /// - Remark: Generated from `#/components/schemas/JerseyResponseBuilderDTO`. - struct JerseyResponseBuilderDTO: Codable, Hashable, Sendable { + public struct JerseyResponseBuilderDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/JerseyResponseBuilderDTO/status`. - var status: Swift.String? + public var status: Swift.String? /// - Remark: Generated from `#/components/schemas/JerseyResponseBuilderDTO/context`. - var context: Components.Schemas.ContextDTO? + public var context: Components.Schemas.ContextDTO? /// Creates a new `JerseyResponseBuilderDTO`. /// /// - Parameters: /// - status: /// - context: - init(status: Swift.String? = nil, - context: Components.Schemas.ContextDTO? = nil) { + public init(status: Swift.String? = nil, + context: Components.Schemas.ContextDTO? = nil) { self.status = status self.context = context } - enum CodingKeys: String, CodingKey { + public enum CodingKeys: String, CodingKey { case status case context } } /// - Remark: Generated from `#/components/schemas/ContextDTO`. - struct ContextDTO: Codable, Hashable, Sendable { + public struct ContextDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/ContextDTO/headers`. - var headers: Components.Schemas.HeadersDTO? + public var headers: Components.Schemas.HeadersDTO? /// Creates a new `ContextDTO`. /// /// - Parameters: /// - headers: - init(headers: Components.Schemas.HeadersDTO? = nil) { + public init(headers: Components.Schemas.HeadersDTO? = nil) { self.headers = headers } - enum CodingKeys: String, CodingKey { + public enum CodingKeys: String, CodingKey { case headers } } /// - Remark: Generated from `#/components/schemas/HeadersDTO`. - struct HeadersDTO: Codable, Hashable, Sendable { + public struct HeadersDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/HeadersDTO/Location`. - var Location: [Swift.String]? + public var Location: [Swift.String]? /// Creates a new `HeadersDTO`. /// /// - Parameters: /// - Location: - init(Location: [Swift.String]? = nil) { + public init(Location: [Swift.String]? = nil) { self.Location = Location } - enum CodingKeys: String, CodingKey { + public enum CodingKeys: String, CodingKey { case Location } } /// - Remark: Generated from `#/components/schemas/MappingDTO`. - struct MappingDTO: Codable, Hashable, Sendable { + public struct MappingDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/MappingDTO/row`. - var row: Swift.Int32? + public var row: Swift.Int32? /// - Remark: Generated from `#/components/schemas/MappingDTO/column`. - var column: Swift.Int32? + public var column: Swift.Int32? /// - Remark: Generated from `#/components/schemas/MappingDTO/command`. - var command: Swift.String? + public var command: Swift.String? /// - Remark: Generated from `#/components/schemas/MappingDTO/releaseCommand`. - var releaseCommand: Swift.String? + public var releaseCommand: Swift.String? /// - Remark: Generated from `#/components/schemas/MappingDTO/label`. - var label: Swift.String? + public var label: Swift.String? /// - Remark: Generated from `#/components/schemas/MappingDTO/icon`. - var icon: Swift.String? + public var icon: Swift.String? /// Creates a new `MappingDTO`. /// /// - Parameters: @@ -1226,12 +1226,12 @@ enum Components { /// - releaseCommand: /// - label: /// - icon: - init(row: Swift.Int32? = nil, - column: Swift.Int32? = nil, - command: Swift.String? = nil, - releaseCommand: Swift.String? = nil, - label: Swift.String? = nil, - icon: Swift.String? = nil) { + public init(row: Swift.Int32? = nil, + column: Swift.Int32? = nil, + command: Swift.String? = nil, + releaseCommand: Swift.String? = nil, + label: Swift.String? = nil, + icon: Swift.String? = nil) { self.row = row self.column = column self.command = command @@ -1240,7 +1240,7 @@ enum Components { self.icon = icon } - enum CodingKeys: String, CodingKey { + public enum CodingKeys: String, CodingKey { case row case column case command @@ -1251,9 +1251,9 @@ enum Components { } /// - Remark: Generated from `#/components/schemas/PageDTO`. - struct PageDTO: Codable, Hashable, Sendable { + public struct PageDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/PageDTO/id`. - var id: Swift.String? { + public var id: Swift.String? { get { storage.value.id } @@ -1263,7 +1263,7 @@ enum Components { } /// - Remark: Generated from `#/components/schemas/PageDTO/title`. - var title: Swift.String? { + public var title: Swift.String? { get { storage.value.title } @@ -1273,7 +1273,7 @@ enum Components { } /// - Remark: Generated from `#/components/schemas/PageDTO/icon`. - var icon: Swift.String? { + public var icon: Swift.String? { get { storage.value.icon } @@ -1283,7 +1283,7 @@ enum Components { } /// - Remark: Generated from `#/components/schemas/PageDTO/link`. - var link: Swift.String? { + public var link: Swift.String? { get { storage.value.link } @@ -1293,7 +1293,7 @@ enum Components { } /// - Remark: Generated from `#/components/schemas/PageDTO/parent`. - var parent: Components.Schemas.PageDTO? { + public var parent: Components.Schemas.PageDTO? { get { storage.value.parent } @@ -1303,7 +1303,7 @@ enum Components { } /// - Remark: Generated from `#/components/schemas/PageDTO/leaf`. - var leaf: Swift.Bool? { + public var leaf: Swift.Bool? { get { storage.value.leaf } @@ -1313,7 +1313,7 @@ enum Components { } /// - Remark: Generated from `#/components/schemas/PageDTO/timeout`. - var timeout: Swift.Bool? { + public var timeout: Swift.Bool? { get { storage.value.timeout } @@ -1323,7 +1323,7 @@ enum Components { } /// - Remark: Generated from `#/components/schemas/PageDTO/widgets`. - var widgets: [Components.Schemas.WidgetDTO]? { + public var widgets: [Components.Schemas.WidgetDTO]? { get { storage.value.widgets } @@ -1343,14 +1343,14 @@ enum Components { /// - leaf: /// - timeout: /// - widgets: - init(id: Swift.String? = nil, - title: Swift.String? = nil, - icon: Swift.String? = nil, - link: Swift.String? = nil, - parent: Components.Schemas.PageDTO? = nil, - leaf: Swift.Bool? = nil, - timeout: Swift.Bool? = nil, - widgets: [Components.Schemas.WidgetDTO]? = nil) { + public init(id: Swift.String? = nil, + title: Swift.String? = nil, + icon: Swift.String? = nil, + link: Swift.String? = nil, + parent: Components.Schemas.PageDTO? = nil, + leaf: Swift.Bool? = nil, + timeout: Swift.Bool? = nil, + widgets: [Components.Schemas.WidgetDTO]? = nil) { storage = .init(value: .init( id: id, title: title, @@ -1363,7 +1363,7 @@ enum Components { )) } - enum CodingKeys: String, CodingKey { + public enum CodingKeys: String, CodingKey { case id case title case icon @@ -1374,11 +1374,11 @@ enum Components { case widgets } - init(from decoder: any Decoder) throws { + public init(from decoder: any Decoder) throws { storage = try .init(from: decoder) } - func encode(to encoder: any Encoder) throws { + public func encode(to encoder: any Encoder) throws { try storage.encode(to: encoder) } @@ -1424,9 +1424,9 @@ enum Components { } /// - Remark: Generated from `#/components/schemas/WidgetDTO`. - struct WidgetDTO: Codable, Hashable, Sendable { + public struct WidgetDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/WidgetDTO/widgetId`. - var widgetId: Swift.String? { + public var widgetId: Swift.String? { get { storage.value.widgetId } @@ -1436,7 +1436,7 @@ enum Components { } /// - Remark: Generated from `#/components/schemas/WidgetDTO/type`. - var _type: Swift.String? { + public var _type: Swift.String? { get { storage.value._type } @@ -1446,7 +1446,7 @@ enum Components { } /// - Remark: Generated from `#/components/schemas/WidgetDTO/name`. - var name: Swift.String? { + public var name: Swift.String? { get { storage.value.name } @@ -1456,7 +1456,7 @@ enum Components { } /// - Remark: Generated from `#/components/schemas/WidgetDTO/visibility`. - var visibility: Swift.Bool? { + public var visibility: Swift.Bool? { get { storage.value.visibility } @@ -1466,7 +1466,7 @@ enum Components { } /// - Remark: Generated from `#/components/schemas/WidgetDTO/label`. - var label: Swift.String? { + public var label: Swift.String? { get { storage.value.label } @@ -1476,7 +1476,7 @@ enum Components { } /// - Remark: Generated from `#/components/schemas/WidgetDTO/labelSource`. - var labelSource: Swift.String? { + public var labelSource: Swift.String? { get { storage.value.labelSource } @@ -1486,7 +1486,7 @@ enum Components { } /// - Remark: Generated from `#/components/schemas/WidgetDTO/icon`. - var icon: Swift.String? { + public var icon: Swift.String? { get { storage.value.icon } @@ -1496,7 +1496,7 @@ enum Components { } /// - Remark: Generated from `#/components/schemas/WidgetDTO/staticIcon`. - var staticIcon: Swift.Bool? { + public var staticIcon: Swift.Bool? { get { storage.value.staticIcon } @@ -1506,7 +1506,7 @@ enum Components { } /// - Remark: Generated from `#/components/schemas/WidgetDTO/labelcolor`. - var labelcolor: Swift.String? { + public var labelcolor: Swift.String? { get { storage.value.labelcolor } @@ -1516,7 +1516,7 @@ enum Components { } /// - Remark: Generated from `#/components/schemas/WidgetDTO/valuecolor`. - var valuecolor: Swift.String? { + public var valuecolor: Swift.String? { get { storage.value.valuecolor } @@ -1526,7 +1526,7 @@ enum Components { } /// - Remark: Generated from `#/components/schemas/WidgetDTO/iconcolor`. - var iconcolor: Swift.String? { + public var iconcolor: Swift.String? { get { storage.value.iconcolor } @@ -1536,7 +1536,7 @@ enum Components { } /// - Remark: Generated from `#/components/schemas/WidgetDTO/pattern`. - var pattern: Swift.String? { + public var pattern: Swift.String? { get { storage.value.pattern } @@ -1546,7 +1546,7 @@ enum Components { } /// - Remark: Generated from `#/components/schemas/WidgetDTO/unit`. - var unit: Swift.String? { + public var unit: Swift.String? { get { storage.value.unit } @@ -1556,7 +1556,7 @@ enum Components { } /// - Remark: Generated from `#/components/schemas/WidgetDTO/mappings`. - var mappings: [Components.Schemas.MappingDTO]? { + public var mappings: [Components.Schemas.MappingDTO]? { get { storage.value.mappings } @@ -1566,7 +1566,7 @@ enum Components { } /// - Remark: Generated from `#/components/schemas/WidgetDTO/switchSupport`. - var switchSupport: Swift.Bool? { + public var switchSupport: Swift.Bool? { get { storage.value.switchSupport } @@ -1576,7 +1576,7 @@ enum Components { } /// - Remark: Generated from `#/components/schemas/WidgetDTO/releaseOnly`. - var releaseOnly: Swift.Bool? { + public var releaseOnly: Swift.Bool? { get { storage.value.releaseOnly } @@ -1586,7 +1586,7 @@ enum Components { } /// - Remark: Generated from `#/components/schemas/WidgetDTO/sendFrequency`. - var sendFrequency: Swift.Int32? { + public var sendFrequency: Swift.Int32? { get { storage.value.sendFrequency } @@ -1596,7 +1596,7 @@ enum Components { } /// - Remark: Generated from `#/components/schemas/WidgetDTO/refresh`. - var refresh: Swift.Int32? { + public var refresh: Swift.Int32? { get { storage.value.refresh } @@ -1606,7 +1606,7 @@ enum Components { } /// - Remark: Generated from `#/components/schemas/WidgetDTO/height`. - var height: Swift.Int32? { + public var height: Swift.Int32? { get { storage.value.height } @@ -1616,7 +1616,7 @@ enum Components { } /// - Remark: Generated from `#/components/schemas/WidgetDTO/minValue`. - var minValue: Swift.Double? { + public var minValue: Swift.Double? { get { storage.value.minValue } @@ -1626,7 +1626,7 @@ enum Components { } /// - Remark: Generated from `#/components/schemas/WidgetDTO/maxValue`. - var maxValue: Swift.Double? { + public var maxValue: Swift.Double? { get { storage.value.maxValue } @@ -1636,7 +1636,7 @@ enum Components { } /// - Remark: Generated from `#/components/schemas/WidgetDTO/step`. - var step: Swift.Double? { + public var step: Swift.Double? { get { storage.value.step } @@ -1646,7 +1646,7 @@ enum Components { } /// - Remark: Generated from `#/components/schemas/WidgetDTO/inputHint`. - var inputHint: Swift.String? { + public var inputHint: Swift.String? { get { storage.value.inputHint } @@ -1656,7 +1656,7 @@ enum Components { } /// - Remark: Generated from `#/components/schemas/WidgetDTO/url`. - var url: Swift.String? { + public var url: Swift.String? { get { storage.value.url } @@ -1666,7 +1666,7 @@ enum Components { } /// - Remark: Generated from `#/components/schemas/WidgetDTO/encoding`. - var encoding: Swift.String? { + public var encoding: Swift.String? { get { storage.value.encoding } @@ -1676,7 +1676,7 @@ enum Components { } /// - Remark: Generated from `#/components/schemas/WidgetDTO/service`. - var service: Swift.String? { + public var service: Swift.String? { get { storage.value.service } @@ -1686,7 +1686,7 @@ enum Components { } /// - Remark: Generated from `#/components/schemas/WidgetDTO/period`. - var period: Swift.String? { + public var period: Swift.String? { get { storage.value.period } @@ -1696,7 +1696,7 @@ enum Components { } /// - Remark: Generated from `#/components/schemas/WidgetDTO/yAxisDecimalPattern`. - var yAxisDecimalPattern: Swift.String? { + public var yAxisDecimalPattern: Swift.String? { get { storage.value.yAxisDecimalPattern } @@ -1706,7 +1706,7 @@ enum Components { } /// - Remark: Generated from `#/components/schemas/WidgetDTO/legend`. - var legend: Swift.Bool? { + public var legend: Swift.Bool? { get { storage.value.legend } @@ -1716,7 +1716,7 @@ enum Components { } /// - Remark: Generated from `#/components/schemas/WidgetDTO/forceAsItem`. - var forceAsItem: Swift.Bool? { + public var forceAsItem: Swift.Bool? { get { storage.value.forceAsItem } @@ -1726,7 +1726,7 @@ enum Components { } /// - Remark: Generated from `#/components/schemas/WidgetDTO/row`. - var row: Swift.Int32? { + public var row: Swift.Int32? { get { storage.value.row } @@ -1736,7 +1736,7 @@ enum Components { } /// - Remark: Generated from `#/components/schemas/WidgetDTO/column`. - var column: Swift.Int32? { + public var column: Swift.Int32? { get { storage.value.column } @@ -1746,7 +1746,7 @@ enum Components { } /// - Remark: Generated from `#/components/schemas/WidgetDTO/command`. - var command: Swift.String? { + public var command: Swift.String? { get { storage.value.command } @@ -1756,7 +1756,7 @@ enum Components { } /// - Remark: Generated from `#/components/schemas/WidgetDTO/releaseCommand`. - var releaseCommand: Swift.String? { + public var releaseCommand: Swift.String? { get { storage.value.releaseCommand } @@ -1766,7 +1766,7 @@ enum Components { } /// - Remark: Generated from `#/components/schemas/WidgetDTO/stateless`. - var stateless: Swift.Bool? { + public var stateless: Swift.Bool? { get { storage.value.stateless } @@ -1776,7 +1776,7 @@ enum Components { } /// - Remark: Generated from `#/components/schemas/WidgetDTO/state`. - var state: Swift.String? { + public var state: Swift.String? { get { storage.value.state } @@ -1786,7 +1786,7 @@ enum Components { } /// - Remark: Generated from `#/components/schemas/WidgetDTO/item`. - var item: Components.Schemas.EnrichedItemDTO? { + public var item: Components.Schemas.EnrichedItemDTO? { get { storage.value.item } @@ -1796,7 +1796,7 @@ enum Components { } /// - Remark: Generated from `#/components/schemas/WidgetDTO/linkedPage`. - var linkedPage: Components.Schemas.PageDTO? { + public var linkedPage: Components.Schemas.PageDTO? { get { storage.value.linkedPage } @@ -1806,7 +1806,7 @@ enum Components { } /// - Remark: Generated from `#/components/schemas/WidgetDTO/widgets`. - var widgets: [Components.Schemas.WidgetDTO]? { + public var widgets: [Components.Schemas.WidgetDTO]? { get { storage.value.widgets } @@ -1857,45 +1857,45 @@ enum Components { /// - item: /// - linkedPage: /// - widgets: - init(widgetId: Swift.String? = nil, - _type: Swift.String? = nil, - name: Swift.String? = nil, - visibility: Swift.Bool? = nil, - label: Swift.String? = nil, - labelSource: Swift.String? = nil, - icon: Swift.String? = nil, - staticIcon: Swift.Bool? = nil, - labelcolor: Swift.String? = nil, - valuecolor: Swift.String? = nil, - iconcolor: Swift.String? = nil, - pattern: Swift.String? = nil, - unit: Swift.String? = nil, - mappings: [Components.Schemas.MappingDTO]? = nil, - switchSupport: Swift.Bool? = nil, - releaseOnly: Swift.Bool? = nil, - sendFrequency: Swift.Int32? = nil, - refresh: Swift.Int32? = nil, - height: Swift.Int32? = nil, - minValue: Swift.Double? = nil, - maxValue: Swift.Double? = nil, - step: Swift.Double? = nil, - inputHint: Swift.String? = nil, - url: Swift.String? = nil, - encoding: Swift.String? = nil, - service: Swift.String? = nil, - period: Swift.String? = nil, - yAxisDecimalPattern: Swift.String? = nil, - legend: Swift.Bool? = nil, - forceAsItem: Swift.Bool? = nil, - row: Swift.Int32? = nil, - column: Swift.Int32? = nil, - command: Swift.String? = nil, - releaseCommand: Swift.String? = nil, - stateless: Swift.Bool? = nil, - state: Swift.String? = nil, - item: Components.Schemas.EnrichedItemDTO? = nil, - linkedPage: Components.Schemas.PageDTO? = nil, - widgets: [Components.Schemas.WidgetDTO]? = nil) { + public init(widgetId: Swift.String? = nil, + _type: Swift.String? = nil, + name: Swift.String? = nil, + visibility: Swift.Bool? = nil, + label: Swift.String? = nil, + labelSource: Swift.String? = nil, + icon: Swift.String? = nil, + staticIcon: Swift.Bool? = nil, + labelcolor: Swift.String? = nil, + valuecolor: Swift.String? = nil, + iconcolor: Swift.String? = nil, + pattern: Swift.String? = nil, + unit: Swift.String? = nil, + mappings: [Components.Schemas.MappingDTO]? = nil, + switchSupport: Swift.Bool? = nil, + releaseOnly: Swift.Bool? = nil, + sendFrequency: Swift.Int32? = nil, + refresh: Swift.Int32? = nil, + height: Swift.Int32? = nil, + minValue: Swift.Double? = nil, + maxValue: Swift.Double? = nil, + step: Swift.Double? = nil, + inputHint: Swift.String? = nil, + url: Swift.String? = nil, + encoding: Swift.String? = nil, + service: Swift.String? = nil, + period: Swift.String? = nil, + yAxisDecimalPattern: Swift.String? = nil, + legend: Swift.Bool? = nil, + forceAsItem: Swift.Bool? = nil, + row: Swift.Int32? = nil, + column: Swift.Int32? = nil, + command: Swift.String? = nil, + releaseCommand: Swift.String? = nil, + stateless: Swift.Bool? = nil, + state: Swift.String? = nil, + item: Components.Schemas.EnrichedItemDTO? = nil, + linkedPage: Components.Schemas.PageDTO? = nil, + widgets: [Components.Schemas.WidgetDTO]? = nil) { storage = .init(value: .init( widgetId: widgetId, _type: _type, @@ -1939,7 +1939,7 @@ enum Components { )) } - enum CodingKeys: String, CodingKey { + public enum CodingKeys: String, CodingKey { case widgetId case _type = "type" case name @@ -1981,11 +1981,11 @@ enum Components { case widgets } - init(from decoder: any Decoder) throws { + public init(from decoder: any Decoder) throws { storage = try .init(from: decoder) } - func encode(to encoder: any Encoder) throws { + public func encode(to encoder: any Encoder) throws { try storage.encode(to: encoder) } @@ -2155,35 +2155,35 @@ enum Components { } /// - Remark: Generated from `#/components/schemas/SitemapWidgetEvent`. - struct SitemapWidgetEvent: Codable, Hashable, Sendable { + public struct SitemapWidgetEvent: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/SitemapWidgetEvent/widgetId`. - var widgetId: Swift.String? + public var widgetId: Swift.String? /// - Remark: Generated from `#/components/schemas/SitemapWidgetEvent/label`. - var label: Swift.String? + public var label: Swift.String? /// - Remark: Generated from `#/components/schemas/SitemapWidgetEvent/labelSource`. - var labelSource: Swift.String? + public var labelSource: Swift.String? /// - Remark: Generated from `#/components/schemas/SitemapWidgetEvent/icon`. - var icon: Swift.String? + public var icon: Swift.String? /// - Remark: Generated from `#/components/schemas/SitemapWidgetEvent/labelcolor`. - var labelcolor: Swift.String? + public var labelcolor: Swift.String? /// - Remark: Generated from `#/components/schemas/SitemapWidgetEvent/valuecolor`. - var valuecolor: Swift.String? + public var valuecolor: Swift.String? /// - Remark: Generated from `#/components/schemas/SitemapWidgetEvent/iconcolor`. - var iconcolor: Swift.String? + public var iconcolor: Swift.String? /// - Remark: Generated from `#/components/schemas/SitemapWidgetEvent/state`. - var state: Swift.String? + public var state: Swift.String? /// - Remark: Generated from `#/components/schemas/SitemapWidgetEvent/reloadIcon`. - var reloadIcon: Swift.Bool? + public var reloadIcon: Swift.Bool? /// - Remark: Generated from `#/components/schemas/SitemapWidgetEvent/visibility`. - var visibility: Swift.Bool? + public var visibility: Swift.Bool? /// - Remark: Generated from `#/components/schemas/SitemapWidgetEvent/descriptionChanged`. - var descriptionChanged: Swift.Bool? + public var descriptionChanged: Swift.Bool? /// - Remark: Generated from `#/components/schemas/SitemapWidgetEvent/item`. - var item: Components.Schemas.EnrichedItemDTO? + public var item: Components.Schemas.EnrichedItemDTO? /// - Remark: Generated from `#/components/schemas/SitemapWidgetEvent/sitemapName`. - var sitemapName: Swift.String? + public var sitemapName: Swift.String? /// - Remark: Generated from `#/components/schemas/SitemapWidgetEvent/pageId`. - var pageId: Swift.String? + public var pageId: Swift.String? /// Creates a new `SitemapWidgetEvent`. /// /// - Parameters: @@ -2201,20 +2201,20 @@ enum Components { /// - item: /// - sitemapName: /// - pageId: - init(widgetId: Swift.String? = nil, - label: Swift.String? = nil, - labelSource: Swift.String? = nil, - icon: Swift.String? = nil, - labelcolor: Swift.String? = nil, - valuecolor: Swift.String? = nil, - iconcolor: Swift.String? = nil, - state: Swift.String? = nil, - reloadIcon: Swift.Bool? = nil, - visibility: Swift.Bool? = nil, - descriptionChanged: Swift.Bool? = nil, - item: Components.Schemas.EnrichedItemDTO? = nil, - sitemapName: Swift.String? = nil, - pageId: Swift.String? = nil) { + public init(widgetId: Swift.String? = nil, + label: Swift.String? = nil, + labelSource: Swift.String? = nil, + icon: Swift.String? = nil, + labelcolor: Swift.String? = nil, + valuecolor: Swift.String? = nil, + iconcolor: Swift.String? = nil, + state: Swift.String? = nil, + reloadIcon: Swift.Bool? = nil, + visibility: Swift.Bool? = nil, + descriptionChanged: Swift.Bool? = nil, + item: Components.Schemas.EnrichedItemDTO? = nil, + sitemapName: Swift.String? = nil, + pageId: Swift.String? = nil) { self.widgetId = widgetId self.label = label self.labelSource = labelSource @@ -2231,7 +2231,7 @@ enum Components { self.pageId = pageId } - enum CodingKeys: String, CodingKey { + public enum CodingKeys: String, CodingKey { case widgetId case label case labelSource @@ -2250,17 +2250,17 @@ enum Components { } /// - Remark: Generated from `#/components/schemas/SitemapDTO`. - struct SitemapDTO: Codable, Hashable, Sendable { + public struct SitemapDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/SitemapDTO/name`. - var name: Swift.String? + public var name: Swift.String? /// - Remark: Generated from `#/components/schemas/SitemapDTO/icon`. - var icon: Swift.String? + public var icon: Swift.String? /// - Remark: Generated from `#/components/schemas/SitemapDTO/label`. - var label: Swift.String? + public var label: Swift.String? /// - Remark: Generated from `#/components/schemas/SitemapDTO/link`. - var link: Swift.String? + public var link: Swift.String? /// - Remark: Generated from `#/components/schemas/SitemapDTO/homepage`. - var homepage: Components.Schemas.PageDTO? + public var homepage: Components.Schemas.PageDTO? /// Creates a new `SitemapDTO`. /// /// - Parameters: @@ -2269,11 +2269,11 @@ enum Components { /// - label: /// - link: /// - homepage: - init(name: Swift.String? = nil, - icon: Swift.String? = nil, - label: Swift.String? = nil, - link: Swift.String? = nil, - homepage: Components.Schemas.PageDTO? = nil) { + public init(name: Swift.String? = nil, + icon: Swift.String? = nil, + label: Swift.String? = nil, + link: Swift.String? = nil, + homepage: Components.Schemas.PageDTO? = nil) { self.name = name self.icon = icon self.label = label @@ -2281,7 +2281,7 @@ enum Components { self.homepage = homepage } - enum CodingKeys: String, CodingKey { + public enum CodingKeys: String, CodingKey { case name case icon case label @@ -2291,65 +2291,65 @@ enum Components { } /// - Remark: Generated from `#/components/schemas/RootUIComponent`. - struct RootUIComponent: Codable, Hashable, Sendable { + public struct RootUIComponent: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/RootUIComponent/component`. - var component: Swift.String? + public var component: Swift.String? /// - Remark: Generated from `#/components/schemas/RootUIComponent/config`. - struct configPayload: Codable, Hashable, Sendable { + public struct configPayload: Codable, Hashable, Sendable { /// A container of undocumented properties. - var additionalProperties: [String: OpenAPIRuntime.OpenAPIObjectContainer] + public var additionalProperties: [String: OpenAPIRuntime.OpenAPIObjectContainer] /// Creates a new `configPayload`. /// /// - Parameters: /// - additionalProperties: A container of undocumented properties. - init(additionalProperties: [String: OpenAPIRuntime.OpenAPIObjectContainer] = .init()) { + public init(additionalProperties: [String: OpenAPIRuntime.OpenAPIObjectContainer] = .init()) { self.additionalProperties = additionalProperties } - init(from decoder: any Decoder) throws { + public init(from decoder: any Decoder) throws { additionalProperties = try decoder.decodeAdditionalProperties(knownKeys: []) } - func encode(to encoder: any Encoder) throws { + public func encode(to encoder: any Encoder) throws { try encoder.encodeAdditionalProperties(additionalProperties) } } /// - Remark: Generated from `#/components/schemas/RootUIComponent/config`. - var config: Components.Schemas.RootUIComponent.configPayload? + public var config: Components.Schemas.RootUIComponent.configPayload? /// - Remark: Generated from `#/components/schemas/RootUIComponent/slots`. - struct slotsPayload: Codable, Hashable, Sendable { + public struct slotsPayload: Codable, Hashable, Sendable { /// A container of undocumented properties. - var additionalProperties: [String: [Components.Schemas.UIComponent]] + public var additionalProperties: [String: [Components.Schemas.UIComponent]] /// Creates a new `slotsPayload`. /// /// - Parameters: /// - additionalProperties: A container of undocumented properties. - init(additionalProperties: [String: [Components.Schemas.UIComponent]] = .init()) { + public init(additionalProperties: [String: [Components.Schemas.UIComponent]] = .init()) { self.additionalProperties = additionalProperties } - init(from decoder: any Decoder) throws { + public init(from decoder: any Decoder) throws { additionalProperties = try decoder.decodeAdditionalProperties(knownKeys: []) } - func encode(to encoder: any Encoder) throws { + public func encode(to encoder: any Encoder) throws { try encoder.encodeAdditionalProperties(additionalProperties) } } /// - Remark: Generated from `#/components/schemas/RootUIComponent/slots`. - var slots: Components.Schemas.RootUIComponent.slotsPayload? + public var slots: Components.Schemas.RootUIComponent.slotsPayload? /// - Remark: Generated from `#/components/schemas/RootUIComponent/uid`. - var uid: Swift.String? + public var uid: Swift.String? /// - Remark: Generated from `#/components/schemas/RootUIComponent/tags`. - var tags: [Swift.String]? + public var tags: [Swift.String]? /// - Remark: Generated from `#/components/schemas/RootUIComponent/props`. - var props: Components.Schemas.ConfigDescriptionDTO? + public var props: Components.Schemas.ConfigDescriptionDTO? /// - Remark: Generated from `#/components/schemas/RootUIComponent/timestamp`. - var timestamp: Foundation.Date? + public var timestamp: Foundation.Date? /// - Remark: Generated from `#/components/schemas/RootUIComponent/type`. - var _type: Swift.String? + public var _type: Swift.String? /// Creates a new `RootUIComponent`. /// /// - Parameters: @@ -2361,14 +2361,14 @@ enum Components { /// - props: /// - timestamp: /// - _type: - init(component: Swift.String? = nil, - config: Components.Schemas.RootUIComponent.configPayload? = nil, - slots: Components.Schemas.RootUIComponent.slotsPayload? = nil, - uid: Swift.String? = nil, - tags: [Swift.String]? = nil, - props: Components.Schemas.ConfigDescriptionDTO? = nil, - timestamp: Foundation.Date? = nil, - _type: Swift.String? = nil) { + public init(component: Swift.String? = nil, + config: Components.Schemas.RootUIComponent.configPayload? = nil, + slots: Components.Schemas.RootUIComponent.slotsPayload? = nil, + uid: Swift.String? = nil, + tags: [Swift.String]? = nil, + props: Components.Schemas.ConfigDescriptionDTO? = nil, + timestamp: Foundation.Date? = nil, + _type: Swift.String? = nil) { self.component = component self.config = config self.slots = slots @@ -2379,7 +2379,7 @@ enum Components { self._type = _type } - enum CodingKeys: String, CodingKey { + public enum CodingKeys: String, CodingKey { case component case config case slots @@ -2392,49 +2392,49 @@ enum Components { } /// - Remark: Generated from `#/components/schemas/UIComponent`. - struct UIComponent: Codable, Hashable, Sendable { + public struct UIComponent: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/UIComponent/component`. - var component: Swift.String? + public var component: Swift.String? /// - Remark: Generated from `#/components/schemas/UIComponent/config`. - struct configPayload: Codable, Hashable, Sendable { + public struct configPayload: Codable, Hashable, Sendable { /// A container of undocumented properties. - var additionalProperties: [String: OpenAPIRuntime.OpenAPIObjectContainer] + public var additionalProperties: [String: OpenAPIRuntime.OpenAPIObjectContainer] /// Creates a new `configPayload`. /// /// - Parameters: /// - additionalProperties: A container of undocumented properties. - init(additionalProperties: [String: OpenAPIRuntime.OpenAPIObjectContainer] = .init()) { + public init(additionalProperties: [String: OpenAPIRuntime.OpenAPIObjectContainer] = .init()) { self.additionalProperties = additionalProperties } - init(from decoder: any Decoder) throws { + public init(from decoder: any Decoder) throws { additionalProperties = try decoder.decodeAdditionalProperties(knownKeys: []) } - func encode(to encoder: any Encoder) throws { + public func encode(to encoder: any Encoder) throws { try encoder.encodeAdditionalProperties(additionalProperties) } } /// - Remark: Generated from `#/components/schemas/UIComponent/config`. - var config: Components.Schemas.UIComponent.configPayload? + public var config: Components.Schemas.UIComponent.configPayload? /// - Remark: Generated from `#/components/schemas/UIComponent/type`. - var _type: Swift.String? + public var _type: Swift.String? /// Creates a new `UIComponent`. /// /// - Parameters: /// - component: /// - config: /// - _type: - init(component: Swift.String? = nil, - config: Components.Schemas.UIComponent.configPayload? = nil, - _type: Swift.String? = nil) { + public init(component: Swift.String? = nil, + config: Components.Schemas.UIComponent.configPayload? = nil, + _type: Swift.String? = nil) { self.component = component self.config = config self._type = _type } - enum CodingKeys: String, CodingKey { + public enum CodingKeys: String, CodingKey { case component case config case _type = "type" @@ -2442,15 +2442,15 @@ enum Components { } /// - Remark: Generated from `#/components/schemas/TileDTO`. - struct TileDTO: Codable, Hashable, Sendable { + public struct TileDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/TileDTO/name`. - var name: Swift.String? + public var name: Swift.String? /// - Remark: Generated from `#/components/schemas/TileDTO/url`. - var url: Swift.String? + public var url: Swift.String? /// - Remark: Generated from `#/components/schemas/TileDTO/overlay`. - var overlay: Swift.String? + public var overlay: Swift.String? /// - Remark: Generated from `#/components/schemas/TileDTO/imageUrl`. - var imageUrl: Swift.String? + public var imageUrl: Swift.String? /// Creates a new `TileDTO`. /// /// - Parameters: @@ -2458,17 +2458,17 @@ enum Components { /// - url: /// - overlay: /// - imageUrl: - init(name: Swift.String? = nil, - url: Swift.String? = nil, - overlay: Swift.String? = nil, - imageUrl: Swift.String? = nil) { + public init(name: Swift.String? = nil, + url: Swift.String? = nil, + overlay: Swift.String? = nil, + imageUrl: Swift.String? = nil) { self.name = name self.url = url self.overlay = overlay self.imageUrl = imageUrl } - enum CodingKeys: String, CodingKey { + public enum CodingKeys: String, CodingKey { case name case url case overlay @@ -2478,60 +2478,60 @@ enum Components { } /// Types generated from the `#/components/parameters` section of the OpenAPI document. - enum Parameters {} + public enum Parameters {} /// Types generated from the `#/components/requestBodies` section of the OpenAPI document. - enum RequestBodies {} + public enum RequestBodies {} /// Types generated from the `#/components/responses` section of the OpenAPI document. - enum Responses {} + public enum Responses {} /// Types generated from the `#/components/headers` section of the OpenAPI document. - enum Headers {} + public enum Headers {} } /// API operations, with input and output types, generated from `#/paths` in the OpenAPI document. -enum Operations { +public enum Operations { /// Adds a new member to a group item. /// /// - Remark: HTTP `PUT /items/{itemName}/members/{memberItemName}`. /// - Remark: Generated from `#/paths//items/{itemName}/members/{memberItemName}/put(addMemberToGroupItem)`. - enum addMemberToGroupItem { - static let id: Swift.String = "addMemberToGroupItem" - struct Input: Sendable, Hashable { + public enum addMemberToGroupItem { + public static let id: Swift.String = "addMemberToGroupItem" + public struct Input: Sendable, Hashable { /// - Remark: Generated from `#/paths/items/{itemName}/members/{memberItemName}/PUT/path`. - struct Path: Sendable, Hashable { + public struct Path: Sendable, Hashable { /// item name /// /// - Remark: Generated from `#/paths/items/{itemName}/members/{memberItemName}/PUT/path/itemName`. - var itemName: Swift.String + public var itemName: Swift.String /// member item name /// /// - Remark: Generated from `#/paths/items/{itemName}/members/{memberItemName}/PUT/path/memberItemName`. - var memberItemName: Swift.String + public var memberItemName: Swift.String /// Creates a new `Path`. /// /// - Parameters: /// - itemName: item name /// - memberItemName: member item name - init(itemName: Swift.String, - memberItemName: Swift.String) { + public init(itemName: Swift.String, + memberItemName: Swift.String) { self.itemName = itemName self.memberItemName = memberItemName } } - var path: Operations.addMemberToGroupItem.Input.Path + public var path: Operations.addMemberToGroupItem.Input.Path /// Creates a new `Input`. /// /// - Parameters: /// - path: - init(path: Operations.addMemberToGroupItem.Input.Path) { + public init(path: Operations.addMemberToGroupItem.Input.Path) { self.path = path } } - enum Output: Sendable, Hashable { - struct Ok: Sendable, Hashable { + @frozen public enum Output: Sendable, Hashable { + public struct Ok: Sendable, Hashable { /// Creates a new `Ok`. - init() {} + public init() {} } /// OK @@ -2544,7 +2544,7 @@ enum Operations { /// /// - Throws: An error if `self` is not `.ok`. /// - SeeAlso: `.ok`. - var ok: Operations.addMemberToGroupItem.Output.Ok { + public var ok: Operations.addMemberToGroupItem.Output.Ok { get throws { switch self { case let .ok(response): @@ -2558,9 +2558,9 @@ enum Operations { } } - struct NotFound: Sendable, Hashable { + public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. - init() {} + public init() {} } /// Item or member item not found or item is not of type group item. @@ -2573,7 +2573,7 @@ enum Operations { /// /// - Throws: An error if `self` is not `.notFound`. /// - SeeAlso: `.notFound`. - var notFound: Operations.addMemberToGroupItem.Output.NotFound { + public var notFound: Operations.addMemberToGroupItem.Output.NotFound { get throws { switch self { case let .notFound(response): @@ -2587,9 +2587,9 @@ enum Operations { } } - struct MethodNotAllowed: Sendable, Hashable { + public struct MethodNotAllowed: Sendable, Hashable { /// Creates a new `MethodNotAllowed`. - init() {} + public init() {} } /// Member item is not editable. @@ -2602,7 +2602,7 @@ enum Operations { /// /// - Throws: An error if `self` is not `.methodNotAllowed`. /// - SeeAlso: `.methodNotAllowed`. - var methodNotAllowed: Operations.addMemberToGroupItem.Output.MethodNotAllowed { + public var methodNotAllowed: Operations.addMemberToGroupItem.Output.MethodNotAllowed { get throws { switch self { case let .methodNotAllowed(response): @@ -2627,45 +2627,45 @@ enum Operations { /// /// - Remark: HTTP `DELETE /items/{itemName}/members/{memberItemName}`. /// - Remark: Generated from `#/paths//items/{itemName}/members/{memberItemName}/delete(removeMemberFromGroupItem)`. - enum removeMemberFromGroupItem { - static let id: Swift.String = "removeMemberFromGroupItem" - struct Input: Sendable, Hashable { + public enum removeMemberFromGroupItem { + public static let id: Swift.String = "removeMemberFromGroupItem" + public struct Input: Sendable, Hashable { /// - Remark: Generated from `#/paths/items/{itemName}/members/{memberItemName}/DELETE/path`. - struct Path: Sendable, Hashable { + public struct Path: Sendable, Hashable { /// item name /// /// - Remark: Generated from `#/paths/items/{itemName}/members/{memberItemName}/DELETE/path/itemName`. - var itemName: Swift.String + public var itemName: Swift.String /// member item name /// /// - Remark: Generated from `#/paths/items/{itemName}/members/{memberItemName}/DELETE/path/memberItemName`. - var memberItemName: Swift.String + public var memberItemName: Swift.String /// Creates a new `Path`. /// /// - Parameters: /// - itemName: item name /// - memberItemName: member item name - init(itemName: Swift.String, - memberItemName: Swift.String) { + public init(itemName: Swift.String, + memberItemName: Swift.String) { self.itemName = itemName self.memberItemName = memberItemName } } - var path: Operations.removeMemberFromGroupItem.Input.Path + public var path: Operations.removeMemberFromGroupItem.Input.Path /// Creates a new `Input`. /// /// - Parameters: /// - path: - init(path: Operations.removeMemberFromGroupItem.Input.Path) { + public init(path: Operations.removeMemberFromGroupItem.Input.Path) { self.path = path } } - enum Output: Sendable, Hashable { - struct Ok: Sendable, Hashable { + @frozen public enum Output: Sendable, Hashable { + public struct Ok: Sendable, Hashable { /// Creates a new `Ok`. - init() {} + public init() {} } /// OK @@ -2678,7 +2678,7 @@ enum Operations { /// /// - Throws: An error if `self` is not `.ok`. /// - SeeAlso: `.ok`. - var ok: Operations.removeMemberFromGroupItem.Output.Ok { + public var ok: Operations.removeMemberFromGroupItem.Output.Ok { get throws { switch self { case let .ok(response): @@ -2692,9 +2692,9 @@ enum Operations { } } - struct NotFound: Sendable, Hashable { + public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. - init() {} + public init() {} } /// Item or member item not found or item is not of type group item. @@ -2707,7 +2707,7 @@ enum Operations { /// /// - Throws: An error if `self` is not `.notFound`. /// - SeeAlso: `.notFound`. - var notFound: Operations.removeMemberFromGroupItem.Output.NotFound { + public var notFound: Operations.removeMemberFromGroupItem.Output.NotFound { get throws { switch self { case let .notFound(response): @@ -2721,9 +2721,9 @@ enum Operations { } } - struct MethodNotAllowed: Sendable, Hashable { + public struct MethodNotAllowed: Sendable, Hashable { /// Creates a new `MethodNotAllowed`. - init() {} + public init() {} } /// Member item is not editable. @@ -2736,7 +2736,7 @@ enum Operations { /// /// - Throws: An error if `self` is not `.methodNotAllowed`. /// - SeeAlso: `.methodNotAllowed`. - var methodNotAllowed: Operations.removeMemberFromGroupItem.Output.MethodNotAllowed { + public var methodNotAllowed: Operations.removeMemberFromGroupItem.Output.MethodNotAllowed { get throws { switch self { case let .methodNotAllowed(response): @@ -2761,55 +2761,55 @@ enum Operations { /// /// - Remark: HTTP `PUT /items/{itemname}/metadata/{namespace}`. /// - Remark: Generated from `#/paths//items/{itemname}/metadata/{namespace}/put(addMetadataToItem)`. - enum addMetadataToItem { - static let id: Swift.String = "addMetadataToItem" - struct Input: Sendable, Hashable { + public enum addMetadataToItem { + public static let id: Swift.String = "addMetadataToItem" + public struct Input: Sendable, Hashable { /// - Remark: Generated from `#/paths/items/{itemname}/metadata/{namespace}/PUT/path`. - struct Path: Sendable, Hashable { + public struct Path: Sendable, Hashable { /// item name /// /// - Remark: Generated from `#/paths/items/{itemname}/metadata/{namespace}/PUT/path/itemname`. - var itemname: Swift.String + public var itemname: Swift.String /// namespace /// /// - Remark: Generated from `#/paths/items/{itemname}/metadata/{namespace}/PUT/path/namespace`. - var namespace: Swift.String + public var namespace: Swift.String /// Creates a new `Path`. /// /// - Parameters: /// - itemname: item name /// - namespace: namespace - init(itemname: Swift.String, - namespace: Swift.String) { + public init(itemname: Swift.String, + namespace: Swift.String) { self.itemname = itemname self.namespace = namespace } } - var path: Operations.addMetadataToItem.Input.Path + public var path: Operations.addMetadataToItem.Input.Path /// - Remark: Generated from `#/paths/items/{itemname}/metadata/{namespace}/PUT/requestBody`. - enum Body: Sendable, Hashable { + @frozen public enum Body: Sendable, Hashable { /// - Remark: Generated from `#/paths/items/{itemname}/metadata/{namespace}/PUT/requestBody/content/application\/json`. case json(Components.Schemas.MetadataDTO) } - var body: Operations.addMetadataToItem.Input.Body + public var body: Operations.addMetadataToItem.Input.Body /// Creates a new `Input`. /// /// - Parameters: /// - path: /// - body: - init(path: Operations.addMetadataToItem.Input.Path, - body: Operations.addMetadataToItem.Input.Body) { + public init(path: Operations.addMetadataToItem.Input.Path, + body: Operations.addMetadataToItem.Input.Body) { self.path = path self.body = body } } - enum Output: Sendable, Hashable { - struct Ok: Sendable, Hashable { + @frozen public enum Output: Sendable, Hashable { + public struct Ok: Sendable, Hashable { /// Creates a new `Ok`. - init() {} + public init() {} } /// OK @@ -2822,7 +2822,7 @@ enum Operations { /// /// - Throws: An error if `self` is not `.ok`. /// - SeeAlso: `.ok`. - var ok: Operations.addMetadataToItem.Output.Ok { + public var ok: Operations.addMetadataToItem.Output.Ok { get throws { switch self { case let .ok(response): @@ -2836,9 +2836,9 @@ enum Operations { } } - struct Created: Sendable, Hashable { + public struct Created: Sendable, Hashable { /// Creates a new `Created`. - init() {} + public init() {} } /// Created @@ -2851,7 +2851,7 @@ enum Operations { /// /// - Throws: An error if `self` is not `.created`. /// - SeeAlso: `.created`. - var created: Operations.addMetadataToItem.Output.Created { + public var created: Operations.addMetadataToItem.Output.Created { get throws { switch self { case let .created(response): @@ -2865,9 +2865,9 @@ enum Operations { } } - struct BadRequest: Sendable, Hashable { + public struct BadRequest: Sendable, Hashable { /// Creates a new `BadRequest`. - init() {} + public init() {} } /// Metadata value empty. @@ -2880,7 +2880,7 @@ enum Operations { /// /// - Throws: An error if `self` is not `.badRequest`. /// - SeeAlso: `.badRequest`. - var badRequest: Operations.addMetadataToItem.Output.BadRequest { + public var badRequest: Operations.addMetadataToItem.Output.BadRequest { get throws { switch self { case let .badRequest(response): @@ -2894,9 +2894,9 @@ enum Operations { } } - struct NotFound: Sendable, Hashable { + public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. - init() {} + public init() {} } /// Item not found. @@ -2909,7 +2909,7 @@ enum Operations { /// /// - Throws: An error if `self` is not `.notFound`. /// - SeeAlso: `.notFound`. - var notFound: Operations.addMetadataToItem.Output.NotFound { + public var notFound: Operations.addMetadataToItem.Output.NotFound { get throws { switch self { case let .notFound(response): @@ -2923,9 +2923,9 @@ enum Operations { } } - struct MethodNotAllowed: Sendable, Hashable { + public struct MethodNotAllowed: Sendable, Hashable { /// Creates a new `MethodNotAllowed`. - init() {} + public init() {} } /// Metadata not editable. @@ -2938,7 +2938,7 @@ enum Operations { /// /// - Throws: An error if `self` is not `.methodNotAllowed`. /// - SeeAlso: `.methodNotAllowed`. - var methodNotAllowed: Operations.addMetadataToItem.Output.MethodNotAllowed { + public var methodNotAllowed: Operations.addMetadataToItem.Output.MethodNotAllowed { get throws { switch self { case let .methodNotAllowed(response): @@ -2963,45 +2963,45 @@ enum Operations { /// /// - Remark: HTTP `DELETE /items/{itemname}/metadata/{namespace}`. /// - Remark: Generated from `#/paths//items/{itemname}/metadata/{namespace}/delete(removeMetadataFromItem)`. - enum removeMetadataFromItem { - static let id: Swift.String = "removeMetadataFromItem" - struct Input: Sendable, Hashable { + public enum removeMetadataFromItem { + public static let id: Swift.String = "removeMetadataFromItem" + public struct Input: Sendable, Hashable { /// - Remark: Generated from `#/paths/items/{itemname}/metadata/{namespace}/DELETE/path`. - struct Path: Sendable, Hashable { + public struct Path: Sendable, Hashable { /// item name /// /// - Remark: Generated from `#/paths/items/{itemname}/metadata/{namespace}/DELETE/path/itemname`. - var itemname: Swift.String + public var itemname: Swift.String /// namespace /// /// - Remark: Generated from `#/paths/items/{itemname}/metadata/{namespace}/DELETE/path/namespace`. - var namespace: Swift.String + public var namespace: Swift.String /// Creates a new `Path`. /// /// - Parameters: /// - itemname: item name /// - namespace: namespace - init(itemname: Swift.String, - namespace: Swift.String) { + public init(itemname: Swift.String, + namespace: Swift.String) { self.itemname = itemname self.namespace = namespace } } - var path: Operations.removeMetadataFromItem.Input.Path + public var path: Operations.removeMetadataFromItem.Input.Path /// Creates a new `Input`. /// /// - Parameters: /// - path: - init(path: Operations.removeMetadataFromItem.Input.Path) { + public init(path: Operations.removeMetadataFromItem.Input.Path) { self.path = path } } - enum Output: Sendable, Hashable { - struct Ok: Sendable, Hashable { + @frozen public enum Output: Sendable, Hashable { + public struct Ok: Sendable, Hashable { /// Creates a new `Ok`. - init() {} + public init() {} } /// OK @@ -3014,7 +3014,7 @@ enum Operations { /// /// - Throws: An error if `self` is not `.ok`. /// - SeeAlso: `.ok`. - var ok: Operations.removeMetadataFromItem.Output.Ok { + public var ok: Operations.removeMetadataFromItem.Output.Ok { get throws { switch self { case let .ok(response): @@ -3028,9 +3028,9 @@ enum Operations { } } - struct NotFound: Sendable, Hashable { + public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. - init() {} + public init() {} } /// Item not found. @@ -3043,7 +3043,7 @@ enum Operations { /// /// - Throws: An error if `self` is not `.notFound`. /// - SeeAlso: `.notFound`. - var notFound: Operations.removeMetadataFromItem.Output.NotFound { + public var notFound: Operations.removeMetadataFromItem.Output.NotFound { get throws { switch self { case let .notFound(response): @@ -3057,9 +3057,9 @@ enum Operations { } } - struct MethodNotAllowed: Sendable, Hashable { + public struct MethodNotAllowed: Sendable, Hashable { /// Creates a new `MethodNotAllowed`. - init() {} + public init() {} } /// Meta data not editable. @@ -3072,7 +3072,7 @@ enum Operations { /// /// - Throws: An error if `self` is not `.methodNotAllowed`. /// - SeeAlso: `.methodNotAllowed`. - var methodNotAllowed: Operations.removeMetadataFromItem.Output.MethodNotAllowed { + public var methodNotAllowed: Operations.removeMetadataFromItem.Output.MethodNotAllowed { get throws { switch self { case let .methodNotAllowed(response): @@ -3097,45 +3097,45 @@ enum Operations { /// /// - Remark: HTTP `PUT /items/{itemname}/tags/{tag}`. /// - Remark: Generated from `#/paths//items/{itemname}/tags/{tag}/put(addTagToItem)`. - enum addTagToItem { - static let id: Swift.String = "addTagToItem" - struct Input: Sendable, Hashable { + public enum addTagToItem { + public static let id: Swift.String = "addTagToItem" + public struct Input: Sendable, Hashable { /// - Remark: Generated from `#/paths/items/{itemname}/tags/{tag}/PUT/path`. - struct Path: Sendable, Hashable { + public struct Path: Sendable, Hashable { /// item name /// /// - Remark: Generated from `#/paths/items/{itemname}/tags/{tag}/PUT/path/itemname`. - var itemname: Swift.String + public var itemname: Swift.String /// tag /// /// - Remark: Generated from `#/paths/items/{itemname}/tags/{tag}/PUT/path/tag`. - var tag: Swift.String + public var tag: Swift.String /// Creates a new `Path`. /// /// - Parameters: /// - itemname: item name /// - tag: tag - init(itemname: Swift.String, - tag: Swift.String) { + public init(itemname: Swift.String, + tag: Swift.String) { self.itemname = itemname self.tag = tag } } - var path: Operations.addTagToItem.Input.Path + public var path: Operations.addTagToItem.Input.Path /// Creates a new `Input`. /// /// - Parameters: /// - path: - init(path: Operations.addTagToItem.Input.Path) { + public init(path: Operations.addTagToItem.Input.Path) { self.path = path } } - enum Output: Sendable, Hashable { - struct Ok: Sendable, Hashable { + @frozen public enum Output: Sendable, Hashable { + public struct Ok: Sendable, Hashable { /// Creates a new `Ok`. - init() {} + public init() {} } /// OK @@ -3148,7 +3148,7 @@ enum Operations { /// /// - Throws: An error if `self` is not `.ok`. /// - SeeAlso: `.ok`. - var ok: Operations.addTagToItem.Output.Ok { + public var ok: Operations.addTagToItem.Output.Ok { get throws { switch self { case let .ok(response): @@ -3162,9 +3162,9 @@ enum Operations { } } - struct NotFound: Sendable, Hashable { + public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. - init() {} + public init() {} } /// Item not found. @@ -3177,7 +3177,7 @@ enum Operations { /// /// - Throws: An error if `self` is not `.notFound`. /// - SeeAlso: `.notFound`. - var notFound: Operations.addTagToItem.Output.NotFound { + public var notFound: Operations.addTagToItem.Output.NotFound { get throws { switch self { case let .notFound(response): @@ -3191,9 +3191,9 @@ enum Operations { } } - struct MethodNotAllowed: Sendable, Hashable { + public struct MethodNotAllowed: Sendable, Hashable { /// Creates a new `MethodNotAllowed`. - init() {} + public init() {} } /// Item not editable. @@ -3206,7 +3206,7 @@ enum Operations { /// /// - Throws: An error if `self` is not `.methodNotAllowed`. /// - SeeAlso: `.methodNotAllowed`. - var methodNotAllowed: Operations.addTagToItem.Output.MethodNotAllowed { + public var methodNotAllowed: Operations.addTagToItem.Output.MethodNotAllowed { get throws { switch self { case let .methodNotAllowed(response): @@ -3231,45 +3231,45 @@ enum Operations { /// /// - Remark: HTTP `DELETE /items/{itemname}/tags/{tag}`. /// - Remark: Generated from `#/paths//items/{itemname}/tags/{tag}/delete(removeTagFromItem)`. - enum removeTagFromItem { - static let id: Swift.String = "removeTagFromItem" - struct Input: Sendable, Hashable { + public enum removeTagFromItem { + public static let id: Swift.String = "removeTagFromItem" + public struct Input: Sendable, Hashable { /// - Remark: Generated from `#/paths/items/{itemname}/tags/{tag}/DELETE/path`. - struct Path: Sendable, Hashable { + public struct Path: Sendable, Hashable { /// item name /// /// - Remark: Generated from `#/paths/items/{itemname}/tags/{tag}/DELETE/path/itemname`. - var itemname: Swift.String + public var itemname: Swift.String /// tag /// /// - Remark: Generated from `#/paths/items/{itemname}/tags/{tag}/DELETE/path/tag`. - var tag: Swift.String + public var tag: Swift.String /// Creates a new `Path`. /// /// - Parameters: /// - itemname: item name /// - tag: tag - init(itemname: Swift.String, - tag: Swift.String) { + public init(itemname: Swift.String, + tag: Swift.String) { self.itemname = itemname self.tag = tag } } - var path: Operations.removeTagFromItem.Input.Path + public var path: Operations.removeTagFromItem.Input.Path /// Creates a new `Input`. /// /// - Parameters: /// - path: - init(path: Operations.removeTagFromItem.Input.Path) { + public init(path: Operations.removeTagFromItem.Input.Path) { self.path = path } } - enum Output: Sendable, Hashable { - struct Ok: Sendable, Hashable { + @frozen public enum Output: Sendable, Hashable { + public struct Ok: Sendable, Hashable { /// Creates a new `Ok`. - init() {} + public init() {} } /// OK @@ -3282,7 +3282,7 @@ enum Operations { /// /// - Throws: An error if `self` is not `.ok`. /// - SeeAlso: `.ok`. - var ok: Operations.removeTagFromItem.Output.Ok { + public var ok: Operations.removeTagFromItem.Output.Ok { get throws { switch self { case let .ok(response): @@ -3296,9 +3296,9 @@ enum Operations { } } - struct NotFound: Sendable, Hashable { + public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. - init() {} + public init() {} } /// Item not found. @@ -3311,7 +3311,7 @@ enum Operations { /// /// - Throws: An error if `self` is not `.notFound`. /// - SeeAlso: `.notFound`. - var notFound: Operations.removeTagFromItem.Output.NotFound { + public var notFound: Operations.removeTagFromItem.Output.NotFound { get throws { switch self { case let .notFound(response): @@ -3325,9 +3325,9 @@ enum Operations { } } - struct MethodNotAllowed: Sendable, Hashable { + public struct MethodNotAllowed: Sendable, Hashable { /// Creates a new `MethodNotAllowed`. - init() {} + public init() {} } /// Item not editable. @@ -3340,7 +3340,7 @@ enum Operations { /// /// - Throws: An error if `self` is not `.methodNotAllowed`. /// - SeeAlso: `.methodNotAllowed`. - var methodNotAllowed: Operations.removeTagFromItem.Output.MethodNotAllowed { + public var methodNotAllowed: Operations.removeTagFromItem.Output.MethodNotAllowed { get throws { switch self { case let .methodNotAllowed(response): @@ -3365,94 +3365,94 @@ enum Operations { /// /// - Remark: HTTP `GET /items/{itemname}`. /// - Remark: Generated from `#/paths//items/{itemname}/get(getItemByName)`. - enum getItemByName { - static let id: Swift.String = "getItemByName" - struct Input: Sendable, Hashable { + public enum getItemByName { + public static let id: Swift.String = "getItemByName" + public struct Input: Sendable, Hashable { /// - Remark: Generated from `#/paths/items/{itemname}/GET/path`. - struct Path: Sendable, Hashable { + public struct Path: Sendable, Hashable { /// item name /// /// - Remark: Generated from `#/paths/items/{itemname}/GET/path/itemname`. - var itemname: Swift.String + public var itemname: Swift.String /// Creates a new `Path`. /// /// - Parameters: /// - itemname: item name - init(itemname: Swift.String) { + public init(itemname: Swift.String) { self.itemname = itemname } } - var path: Operations.getItemByName.Input.Path + public var path: Operations.getItemByName.Input.Path /// - Remark: Generated from `#/paths/items/{itemname}/GET/query`. - struct Query: Sendable, Hashable { + public struct Query: Sendable, Hashable { /// metadata selector - a comma separated list or a regular expression (returns all if no value given) /// /// - Remark: Generated from `#/paths/items/{itemname}/GET/query/metadata`. - var metadata: Swift.String? + public var metadata: Swift.String? /// get member items if the item is a group item /// /// - Remark: Generated from `#/paths/items/{itemname}/GET/query/recursive`. - var recursive: Swift.Bool? + public var recursive: Swift.Bool? /// Creates a new `Query`. /// /// - Parameters: /// - metadata: metadata selector - a comma separated list or a regular expression (returns all if no value given) /// - recursive: get member items if the item is a group item - init(metadata: Swift.String? = nil, - recursive: Swift.Bool? = nil) { + public init(metadata: Swift.String? = nil, + recursive: Swift.Bool? = nil) { self.metadata = metadata self.recursive = recursive } } - var query: Operations.getItemByName.Input.Query + public var query: Operations.getItemByName.Input.Query /// - Remark: Generated from `#/paths/items/{itemname}/GET/header`. - struct Headers: Sendable, Hashable { + public struct Headers: Sendable, Hashable { /// language /// /// - Remark: Generated from `#/paths/items/{itemname}/GET/header/Accept-Language`. - var Accept_hyphen_Language: Swift.String? - var accept: [OpenAPIRuntime.AcceptHeaderContentType] + public var Accept_hyphen_Language: Swift.String? + public var accept: [OpenAPIRuntime.AcceptHeaderContentType] /// Creates a new `Headers`. /// /// - Parameters: /// - Accept_hyphen_Language: language /// - accept: - init(Accept_hyphen_Language: Swift.String? = nil, - accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + public init(Accept_hyphen_Language: Swift.String? = nil, + accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { self.Accept_hyphen_Language = Accept_hyphen_Language self.accept = accept } } - var headers: Operations.getItemByName.Input.Headers + public var headers: Operations.getItemByName.Input.Headers /// Creates a new `Input`. /// /// - Parameters: /// - path: /// - query: /// - headers: - init(path: Operations.getItemByName.Input.Path, - query: Operations.getItemByName.Input.Query = .init(), - headers: Operations.getItemByName.Input.Headers = .init()) { + public init(path: Operations.getItemByName.Input.Path, + query: Operations.getItemByName.Input.Query = .init(), + headers: Operations.getItemByName.Input.Headers = .init()) { self.path = path self.query = query self.headers = headers } } - enum Output: Sendable, Hashable { - struct Ok: Sendable, Hashable { + @frozen public enum Output: Sendable, Hashable { + public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/items/{itemname}/GET/responses/200/content`. - enum Body: Sendable, Hashable { + @frozen public enum Body: Sendable, Hashable { /// - Remark: Generated from `#/paths/items/{itemname}/GET/responses/200/content/application\/json`. case json(Components.Schemas.EnrichedItemDTO) /// The associated value of the enum case if `self` is `.json`. /// /// - Throws: An error if `self` is not `.json`. /// - SeeAlso: `.json`. - var json: Components.Schemas.EnrichedItemDTO { + public var json: Components.Schemas.EnrichedItemDTO { get throws { switch self { case let .json(body): @@ -3463,12 +3463,12 @@ enum Operations { } /// Received HTTP response body - var body: Operations.getItemByName.Output.Ok.Body + public var body: Operations.getItemByName.Output.Ok.Body /// Creates a new `Ok`. /// /// - Parameters: /// - body: Received HTTP response body - init(body: Operations.getItemByName.Output.Ok.Body) { + public init(body: Operations.getItemByName.Output.Ok.Body) { self.body = body } } @@ -3483,7 +3483,7 @@ enum Operations { /// /// - Throws: An error if `self` is not `.ok`. /// - SeeAlso: `.ok`. - var ok: Operations.getItemByName.Output.Ok { + public var ok: Operations.getItemByName.Output.Ok { get throws { switch self { case let .ok(response): @@ -3497,9 +3497,9 @@ enum Operations { } } - struct NotFound: Sendable, Hashable { + public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. - init() {} + public init() {} } /// Item not found @@ -3512,7 +3512,7 @@ enum Operations { /// /// - Throws: An error if `self` is not `.notFound`. /// - SeeAlso: `.notFound`. - var notFound: Operations.getItemByName.Output.NotFound { + public var notFound: Operations.getItemByName.Output.NotFound { get throws { switch self { case let .notFound(response): @@ -3532,10 +3532,10 @@ enum Operations { case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } - enum AcceptableContentType: AcceptableProtocol { + @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) - init?(rawValue: Swift.String) { + public init?(rawValue: Swift.String) { switch rawValue.lowercased() { case "application/json": self = .json @@ -3544,7 +3544,7 @@ enum Operations { } } - var rawValue: Swift.String { + public var rawValue: Swift.String { switch self { case let .other(string): string @@ -3553,7 +3553,7 @@ enum Operations { } } - static var allCases: [Self] { + public static var allCases: [Self] { [ .json ] @@ -3565,48 +3565,48 @@ enum Operations { /// /// - Remark: HTTP `POST /items/{itemname}`. /// - Remark: Generated from `#/paths//items/{itemname}/post(sendItemCommand)`. - enum sendItemCommand { - static let id: Swift.String = "sendItemCommand" - struct Input: Sendable, Hashable { + public enum sendItemCommand { + public static let id: Swift.String = "sendItemCommand" + public struct Input: Sendable, Hashable { /// - Remark: Generated from `#/paths/items/{itemname}/POST/path`. - struct Path: Sendable, Hashable { + public struct Path: Sendable, Hashable { /// item name /// /// - Remark: Generated from `#/paths/items/{itemname}/POST/path/itemname`. - var itemname: Swift.String + public var itemname: Swift.String /// Creates a new `Path`. /// /// - Parameters: /// - itemname: item name - init(itemname: Swift.String) { + public init(itemname: Swift.String) { self.itemname = itemname } } - var path: Operations.sendItemCommand.Input.Path + public var path: Operations.sendItemCommand.Input.Path /// - Remark: Generated from `#/paths/items/{itemname}/POST/requestBody`. - enum Body: Sendable, Hashable { + @frozen public enum Body: Sendable, Hashable { /// - Remark: Generated from `#/paths/items/{itemname}/POST/requestBody/content/text\/plain`. case plainText(OpenAPIRuntime.HTTPBody) } - var body: Operations.sendItemCommand.Input.Body + public var body: Operations.sendItemCommand.Input.Body /// Creates a new `Input`. /// /// - Parameters: /// - path: /// - body: - init(path: Operations.sendItemCommand.Input.Path, - body: Operations.sendItemCommand.Input.Body) { + public init(path: Operations.sendItemCommand.Input.Path, + body: Operations.sendItemCommand.Input.Body) { self.path = path self.body = body } } - enum Output: Sendable, Hashable { - struct Ok: Sendable, Hashable { + @frozen public enum Output: Sendable, Hashable { + public struct Ok: Sendable, Hashable { /// Creates a new `Ok`. - init() {} + public init() {} } /// OK @@ -3619,7 +3619,7 @@ enum Operations { /// /// - Throws: An error if `self` is not `.ok`. /// - SeeAlso: `.ok`. - var ok: Operations.sendItemCommand.Output.Ok { + public var ok: Operations.sendItemCommand.Output.Ok { get throws { switch self { case let .ok(response): @@ -3633,9 +3633,9 @@ enum Operations { } } - struct BadRequest: Sendable, Hashable { + public struct BadRequest: Sendable, Hashable { /// Creates a new `BadRequest`. - init() {} + public init() {} } /// Item command null @@ -3648,7 +3648,7 @@ enum Operations { /// /// - Throws: An error if `self` is not `.badRequest`. /// - SeeAlso: `.badRequest`. - var badRequest: Operations.sendItemCommand.Output.BadRequest { + public var badRequest: Operations.sendItemCommand.Output.BadRequest { get throws { switch self { case let .badRequest(response): @@ -3662,9 +3662,9 @@ enum Operations { } } - struct NotFound: Sendable, Hashable { + public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. - init() {} + public init() {} } /// Item not found @@ -3677,7 +3677,7 @@ enum Operations { /// /// - Throws: An error if `self` is not `.notFound`. /// - SeeAlso: `.notFound`. - var notFound: Operations.sendItemCommand.Output.NotFound { + public var notFound: Operations.sendItemCommand.Output.NotFound { get throws { switch self { case let .notFound(response): @@ -3702,78 +3702,78 @@ enum Operations { /// /// - Remark: HTTP `PUT /items/{itemname}`. /// - Remark: Generated from `#/paths//items/{itemname}/put(addOrUpdateItemInRegistry)`. - enum addOrUpdateItemInRegistry { - static let id: Swift.String = "addOrUpdateItemInRegistry" - struct Input: Sendable, Hashable { + public enum addOrUpdateItemInRegistry { + public static let id: Swift.String = "addOrUpdateItemInRegistry" + public struct Input: Sendable, Hashable { /// - Remark: Generated from `#/paths/items/{itemname}/PUT/path`. - struct Path: Sendable, Hashable { + public struct Path: Sendable, Hashable { /// item name /// /// - Remark: Generated from `#/paths/items/{itemname}/PUT/path/itemname`. - var itemname: Swift.String + public var itemname: Swift.String /// Creates a new `Path`. /// /// - Parameters: /// - itemname: item name - init(itemname: Swift.String) { + public init(itemname: Swift.String) { self.itemname = itemname } } - var path: Operations.addOrUpdateItemInRegistry.Input.Path + public var path: Operations.addOrUpdateItemInRegistry.Input.Path /// - Remark: Generated from `#/paths/items/{itemname}/PUT/header`. - struct Headers: Sendable, Hashable { + public struct Headers: Sendable, Hashable { /// language /// /// - Remark: Generated from `#/paths/items/{itemname}/PUT/header/Accept-Language`. - var Accept_hyphen_Language: Swift.String? - var accept: [OpenAPIRuntime.AcceptHeaderContentType] + public var Accept_hyphen_Language: Swift.String? + public var accept: [OpenAPIRuntime.AcceptHeaderContentType] /// Creates a new `Headers`. /// /// - Parameters: /// - Accept_hyphen_Language: language /// - accept: - init(Accept_hyphen_Language: Swift.String? = nil, - accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + public init(Accept_hyphen_Language: Swift.String? = nil, + accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { self.Accept_hyphen_Language = Accept_hyphen_Language self.accept = accept } } - var headers: Operations.addOrUpdateItemInRegistry.Input.Headers + public var headers: Operations.addOrUpdateItemInRegistry.Input.Headers /// - Remark: Generated from `#/paths/items/{itemname}/PUT/requestBody`. - enum Body: Sendable, Hashable { + @frozen public enum Body: Sendable, Hashable { /// - Remark: Generated from `#/paths/items/{itemname}/PUT/requestBody/content/application\/json`. case json(Components.Schemas.GroupItemDTO) } - var body: Operations.addOrUpdateItemInRegistry.Input.Body + public var body: Operations.addOrUpdateItemInRegistry.Input.Body /// Creates a new `Input`. /// /// - Parameters: /// - path: /// - headers: /// - body: - init(path: Operations.addOrUpdateItemInRegistry.Input.Path, - headers: Operations.addOrUpdateItemInRegistry.Input.Headers = .init(), - body: Operations.addOrUpdateItemInRegistry.Input.Body) { + public init(path: Operations.addOrUpdateItemInRegistry.Input.Path, + headers: Operations.addOrUpdateItemInRegistry.Input.Headers = .init(), + body: Operations.addOrUpdateItemInRegistry.Input.Body) { self.path = path self.headers = headers self.body = body } } - enum Output: Sendable, Hashable { - struct Ok: Sendable, Hashable { + @frozen public enum Output: Sendable, Hashable { + public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/items/{itemname}/PUT/responses/200/content`. - enum Body: Sendable, Hashable { + @frozen public enum Body: Sendable, Hashable { /// - Remark: Generated from `#/paths/items/{itemname}/PUT/responses/200/content/*\/*`. case any(OpenAPIRuntime.HTTPBody) /// The associated value of the enum case if `self` is `.any`. /// /// - Throws: An error if `self` is not `.any`. /// - SeeAlso: `.any`. - var any: OpenAPIRuntime.HTTPBody { + public var any: OpenAPIRuntime.HTTPBody { get throws { switch self { case let .any(body): @@ -3784,12 +3784,12 @@ enum Operations { } /// Received HTTP response body - var body: Operations.addOrUpdateItemInRegistry.Output.Ok.Body + public var body: Operations.addOrUpdateItemInRegistry.Output.Ok.Body /// Creates a new `Ok`. /// /// - Parameters: /// - body: Received HTTP response body - init(body: Operations.addOrUpdateItemInRegistry.Output.Ok.Body) { + public init(body: Operations.addOrUpdateItemInRegistry.Output.Ok.Body) { self.body = body } } @@ -3804,7 +3804,7 @@ enum Operations { /// /// - Throws: An error if `self` is not `.ok`. /// - SeeAlso: `.ok`. - var ok: Operations.addOrUpdateItemInRegistry.Output.Ok { + public var ok: Operations.addOrUpdateItemInRegistry.Output.Ok { get throws { switch self { case let .ok(response): @@ -3818,9 +3818,9 @@ enum Operations { } } - struct Created: Sendable, Hashable { + public struct Created: Sendable, Hashable { /// Creates a new `Created`. - init() {} + public init() {} } /// Item created. @@ -3833,7 +3833,7 @@ enum Operations { /// /// - Throws: An error if `self` is not `.created`. /// - SeeAlso: `.created`. - var created: Operations.addOrUpdateItemInRegistry.Output.Created { + public var created: Operations.addOrUpdateItemInRegistry.Output.Created { get throws { switch self { case let .created(response): @@ -3847,9 +3847,9 @@ enum Operations { } } - struct BadRequest: Sendable, Hashable { + public struct BadRequest: Sendable, Hashable { /// Creates a new `BadRequest`. - init() {} + public init() {} } /// Payload invalid. @@ -3862,7 +3862,7 @@ enum Operations { /// /// - Throws: An error if `self` is not `.badRequest`. /// - SeeAlso: `.badRequest`. - var badRequest: Operations.addOrUpdateItemInRegistry.Output.BadRequest { + public var badRequest: Operations.addOrUpdateItemInRegistry.Output.BadRequest { get throws { switch self { case let .badRequest(response): @@ -3876,9 +3876,9 @@ enum Operations { } } - struct NotFound: Sendable, Hashable { + public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. - init() {} + public init() {} } /// Item not found or name in path invalid. @@ -3891,7 +3891,7 @@ enum Operations { /// /// - Throws: An error if `self` is not `.notFound`. /// - SeeAlso: `.notFound`. - var notFound: Operations.addOrUpdateItemInRegistry.Output.NotFound { + public var notFound: Operations.addOrUpdateItemInRegistry.Output.NotFound { get throws { switch self { case let .notFound(response): @@ -3905,9 +3905,9 @@ enum Operations { } } - struct MethodNotAllowed: Sendable, Hashable { + public struct MethodNotAllowed: Sendable, Hashable { /// Creates a new `MethodNotAllowed`. - init() {} + public init() {} } /// Item not editable. @@ -3920,7 +3920,7 @@ enum Operations { /// /// - Throws: An error if `self` is not `.methodNotAllowed`. /// - SeeAlso: `.methodNotAllowed`. - var methodNotAllowed: Operations.addOrUpdateItemInRegistry.Output.MethodNotAllowed { + public var methodNotAllowed: Operations.addOrUpdateItemInRegistry.Output.MethodNotAllowed { get throws { switch self { case let .methodNotAllowed(response): @@ -3940,10 +3940,10 @@ enum Operations { case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } - enum AcceptableContentType: AcceptableProtocol { + @frozen public enum AcceptableContentType: AcceptableProtocol { case any case other(Swift.String) - init?(rawValue: Swift.String) { + public init?(rawValue: Swift.String) { switch rawValue.lowercased() { case "*/*": self = .any @@ -3952,7 +3952,7 @@ enum Operations { } } - var rawValue: Swift.String { + public var rawValue: Swift.String { switch self { case let .other(string): string @@ -3961,7 +3961,7 @@ enum Operations { } } - static var allCases: [Self] { + public static var allCases: [Self] { [ .any ] @@ -3973,38 +3973,38 @@ enum Operations { /// /// - Remark: HTTP `DELETE /items/{itemname}`. /// - Remark: Generated from `#/paths//items/{itemname}/delete(removeItemFromRegistry)`. - enum removeItemFromRegistry { - static let id: Swift.String = "removeItemFromRegistry" - struct Input: Sendable, Hashable { + public enum removeItemFromRegistry { + public static let id: Swift.String = "removeItemFromRegistry" + public struct Input: Sendable, Hashable { /// - Remark: Generated from `#/paths/items/{itemname}/DELETE/path`. - struct Path: Sendable, Hashable { + public struct Path: Sendable, Hashable { /// item name /// /// - Remark: Generated from `#/paths/items/{itemname}/DELETE/path/itemname`. - var itemname: Swift.String + public var itemname: Swift.String /// Creates a new `Path`. /// /// - Parameters: /// - itemname: item name - init(itemname: Swift.String) { + public init(itemname: Swift.String) { self.itemname = itemname } } - var path: Operations.removeItemFromRegistry.Input.Path + public var path: Operations.removeItemFromRegistry.Input.Path /// Creates a new `Input`. /// /// - Parameters: /// - path: - init(path: Operations.removeItemFromRegistry.Input.Path) { + public init(path: Operations.removeItemFromRegistry.Input.Path) { self.path = path } } - enum Output: Sendable, Hashable { - struct Ok: Sendable, Hashable { + @frozen public enum Output: Sendable, Hashable { + public struct Ok: Sendable, Hashable { /// Creates a new `Ok`. - init() {} + public init() {} } /// OK @@ -4017,7 +4017,7 @@ enum Operations { /// /// - Throws: An error if `self` is not `.ok`. /// - SeeAlso: `.ok`. - var ok: Operations.removeItemFromRegistry.Output.Ok { + public var ok: Operations.removeItemFromRegistry.Output.Ok { get throws { switch self { case let .ok(response): @@ -4031,9 +4031,9 @@ enum Operations { } } - struct NotFound: Sendable, Hashable { + public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. - init() {} + public init() {} } /// Item not found or item is not editable. @@ -4046,7 +4046,7 @@ enum Operations { /// /// - Throws: An error if `self` is not `.notFound`. /// - SeeAlso: `.notFound`. - var notFound: Operations.removeItemFromRegistry.Output.NotFound { + public var notFound: Operations.removeItemFromRegistry.Output.NotFound { get throws { switch self { case let .notFound(response): @@ -4071,35 +4071,35 @@ enum Operations { /// /// - Remark: HTTP `GET /items`. /// - Remark: Generated from `#/paths//items/get(getItems)`. - enum getItems { - static let id: Swift.String = "getItems" - struct Input: Sendable, Hashable { + public enum getItems { + public static let id: Swift.String = "getItems" + public struct Input: Sendable, Hashable { /// - Remark: Generated from `#/paths/items/GET/query`. - struct Query: Sendable, Hashable { + public struct Query: Sendable, Hashable { /// item type filter /// /// - Remark: Generated from `#/paths/items/GET/query/type`. - var _type: Swift.String? + public var _type: Swift.String? /// item tag filter /// /// - Remark: Generated from `#/paths/items/GET/query/tags`. - var tags: Swift.String? + public var tags: Swift.String? /// metadata selector - a comma separated list or a regular expression (returns all if no value given) /// /// - Remark: Generated from `#/paths/items/GET/query/metadata`. - var metadata: Swift.String? + public var metadata: Swift.String? /// get member items recursively /// /// - Remark: Generated from `#/paths/items/GET/query/recursive`. - var recursive: Swift.Bool? + public var recursive: Swift.Bool? /// limit output to the given fields (comma separated) /// /// - Remark: Generated from `#/paths/items/GET/query/fields`. - var fields: Swift.String? + public var fields: Swift.String? /// provides a cacheable list of values not expected to change regularly and checks the If-Modified-Since header, all other parameters are ignored except "metadata" /// /// - Remark: Generated from `#/paths/items/GET/query/staticDataOnly`. - var staticDataOnly: Swift.Bool? + public var staticDataOnly: Swift.Bool? /// Creates a new `Query`. /// /// - Parameters: @@ -4109,12 +4109,12 @@ enum Operations { /// - recursive: get member items recursively /// - fields: limit output to the given fields (comma separated) /// - staticDataOnly: provides a cacheable list of values not expected to change regularly and checks the If-Modified-Since header, all other parameters are ignored except "metadata" - init(_type: Swift.String? = nil, - tags: Swift.String? = nil, - metadata: Swift.String? = nil, - recursive: Swift.Bool? = nil, - fields: Swift.String? = nil, - staticDataOnly: Swift.Bool? = nil) { + public init(_type: Swift.String? = nil, + tags: Swift.String? = nil, + metadata: Swift.String? = nil, + recursive: Swift.Bool? = nil, + fields: Swift.String? = nil, + staticDataOnly: Swift.Bool? = nil) { self._type = _type self.tags = tags self.metadata = metadata @@ -4124,50 +4124,50 @@ enum Operations { } } - var query: Operations.getItems.Input.Query + public var query: Operations.getItems.Input.Query /// - Remark: Generated from `#/paths/items/GET/header`. - struct Headers: Sendable, Hashable { + public struct Headers: Sendable, Hashable { /// language /// /// - Remark: Generated from `#/paths/items/GET/header/Accept-Language`. - var Accept_hyphen_Language: Swift.String? - var accept: [OpenAPIRuntime.AcceptHeaderContentType] + public var Accept_hyphen_Language: Swift.String? + public var accept: [OpenAPIRuntime.AcceptHeaderContentType] /// Creates a new `Headers`. /// /// - Parameters: /// - Accept_hyphen_Language: language /// - accept: - init(Accept_hyphen_Language: Swift.String? = nil, - accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + public init(Accept_hyphen_Language: Swift.String? = nil, + accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { self.Accept_hyphen_Language = Accept_hyphen_Language self.accept = accept } } - var headers: Operations.getItems.Input.Headers + public var headers: Operations.getItems.Input.Headers /// Creates a new `Input`. /// /// - Parameters: /// - query: /// - headers: - init(query: Operations.getItems.Input.Query = .init(), - headers: Operations.getItems.Input.Headers = .init()) { + public init(query: Operations.getItems.Input.Query = .init(), + headers: Operations.getItems.Input.Headers = .init()) { self.query = query self.headers = headers } } - enum Output: Sendable, Hashable { - struct Ok: Sendable, Hashable { + @frozen public enum Output: Sendable, Hashable { + public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/items/GET/responses/200/content`. - enum Body: Sendable, Hashable { + @frozen public enum Body: Sendable, Hashable { /// - Remark: Generated from `#/paths/items/GET/responses/200/content/application\/json`. case json([Components.Schemas.EnrichedItemDTO]) /// The associated value of the enum case if `self` is `.json`. /// /// - Throws: An error if `self` is not `.json`. /// - SeeAlso: `.json`. - var json: [Components.Schemas.EnrichedItemDTO] { + public var json: [Components.Schemas.EnrichedItemDTO] { get throws { switch self { case let .json(body): @@ -4178,12 +4178,12 @@ enum Operations { } /// Received HTTP response body - var body: Operations.getItems.Output.Ok.Body + public var body: Operations.getItems.Output.Ok.Body /// Creates a new `Ok`. /// /// - Parameters: /// - body: Received HTTP response body - init(body: Operations.getItems.Output.Ok.Body) { + public init(body: Operations.getItems.Output.Ok.Body) { self.body = body } } @@ -4198,7 +4198,7 @@ enum Operations { /// /// - Throws: An error if `self` is not `.ok`. /// - SeeAlso: `.ok`. - var ok: Operations.getItems.Output.Ok { + public var ok: Operations.getItems.Output.Ok { get throws { switch self { case let .ok(response): @@ -4218,10 +4218,10 @@ enum Operations { case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } - enum AcceptableContentType: AcceptableProtocol { + @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) - init?(rawValue: Swift.String) { + public init?(rawValue: Swift.String) { switch rawValue.lowercased() { case "application/json": self = .json @@ -4230,7 +4230,7 @@ enum Operations { } } - var rawValue: Swift.String { + public var rawValue: Swift.String { switch self { case let .other(string): string @@ -4239,7 +4239,7 @@ enum Operations { } } - static var allCases: [Self] { + public static var allCases: [Self] { [ .json ] @@ -4251,52 +4251,52 @@ enum Operations { /// /// - Remark: HTTP `PUT /items`. /// - Remark: Generated from `#/paths//items/put(addOrUpdateItemsInRegistry)`. - enum addOrUpdateItemsInRegistry { - static let id: Swift.String = "addOrUpdateItemsInRegistry" - struct Input: Sendable, Hashable { + public enum addOrUpdateItemsInRegistry { + public static let id: Swift.String = "addOrUpdateItemsInRegistry" + public struct Input: Sendable, Hashable { /// - Remark: Generated from `#/paths/items/PUT/header`. - struct Headers: Sendable, Hashable { - var accept: [OpenAPIRuntime.AcceptHeaderContentType] + public struct Headers: Sendable, Hashable { + public var accept: [OpenAPIRuntime.AcceptHeaderContentType] /// Creates a new `Headers`. /// /// - Parameters: /// - accept: - init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + public init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { self.accept = accept } } - var headers: Operations.addOrUpdateItemsInRegistry.Input.Headers + public var headers: Operations.addOrUpdateItemsInRegistry.Input.Headers /// - Remark: Generated from `#/paths/items/PUT/requestBody`. - enum Body: Sendable, Hashable { + @frozen public enum Body: Sendable, Hashable { /// - Remark: Generated from `#/paths/items/PUT/requestBody/content/application\/json`. case json([Components.Schemas.GroupItemDTO]) } - var body: Operations.addOrUpdateItemsInRegistry.Input.Body + public var body: Operations.addOrUpdateItemsInRegistry.Input.Body /// Creates a new `Input`. /// /// - Parameters: /// - headers: /// - body: - init(headers: Operations.addOrUpdateItemsInRegistry.Input.Headers = .init(), - body: Operations.addOrUpdateItemsInRegistry.Input.Body) { + public init(headers: Operations.addOrUpdateItemsInRegistry.Input.Headers = .init(), + body: Operations.addOrUpdateItemsInRegistry.Input.Body) { self.headers = headers self.body = body } } - enum Output: Sendable, Hashable { - struct Ok: Sendable, Hashable { + @frozen public enum Output: Sendable, Hashable { + public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/items/PUT/responses/200/content`. - enum Body: Sendable, Hashable { + @frozen public enum Body: Sendable, Hashable { /// - Remark: Generated from `#/paths/items/PUT/responses/200/content/*\/*`. case any(OpenAPIRuntime.HTTPBody) /// The associated value of the enum case if `self` is `.any`. /// /// - Throws: An error if `self` is not `.any`. /// - SeeAlso: `.any`. - var any: OpenAPIRuntime.HTTPBody { + public var any: OpenAPIRuntime.HTTPBody { get throws { switch self { case let .any(body): @@ -4307,12 +4307,12 @@ enum Operations { } /// Received HTTP response body - var body: Operations.addOrUpdateItemsInRegistry.Output.Ok.Body + public var body: Operations.addOrUpdateItemsInRegistry.Output.Ok.Body /// Creates a new `Ok`. /// /// - Parameters: /// - body: Received HTTP response body - init(body: Operations.addOrUpdateItemsInRegistry.Output.Ok.Body) { + public init(body: Operations.addOrUpdateItemsInRegistry.Output.Ok.Body) { self.body = body } } @@ -4327,7 +4327,7 @@ enum Operations { /// /// - Throws: An error if `self` is not `.ok`. /// - SeeAlso: `.ok`. - var ok: Operations.addOrUpdateItemsInRegistry.Output.Ok { + public var ok: Operations.addOrUpdateItemsInRegistry.Output.Ok { get throws { switch self { case let .ok(response): @@ -4341,9 +4341,9 @@ enum Operations { } } - struct BadRequest: Sendable, Hashable { + public struct BadRequest: Sendable, Hashable { /// Creates a new `BadRequest`. - init() {} + public init() {} } /// Payload is invalid. @@ -4356,7 +4356,7 @@ enum Operations { /// /// - Throws: An error if `self` is not `.badRequest`. /// - SeeAlso: `.badRequest`. - var badRequest: Operations.addOrUpdateItemsInRegistry.Output.BadRequest { + public var badRequest: Operations.addOrUpdateItemsInRegistry.Output.BadRequest { get throws { switch self { case let .badRequest(response): @@ -4376,10 +4376,10 @@ enum Operations { case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } - enum AcceptableContentType: AcceptableProtocol { + @frozen public enum AcceptableContentType: AcceptableProtocol { case any case other(Swift.String) - init?(rawValue: Swift.String) { + public init?(rawValue: Swift.String) { switch rawValue.lowercased() { case "*/*": self = .any @@ -4388,7 +4388,7 @@ enum Operations { } } - var rawValue: Swift.String { + public var rawValue: Swift.String { switch self { case let .other(string): string @@ -4397,7 +4397,7 @@ enum Operations { } } - static var allCases: [Self] { + public static var allCases: [Self] { [ .any ] @@ -4409,61 +4409,61 @@ enum Operations { /// /// - Remark: HTTP `GET /items/{itemname}/state`. /// - Remark: Generated from `#/paths//items/{itemname}/state/get(getItemState_1)`. - enum getItemState_1 { - static let id: Swift.String = "getItemState_1" - struct Input: Sendable, Hashable { + public enum getItemState_1 { + public static let id: Swift.String = "getItemState_1" + public struct Input: Sendable, Hashable { /// - Remark: Generated from `#/paths/items/{itemname}/state/GET/path`. - struct Path: Sendable, Hashable { + public struct Path: Sendable, Hashable { /// item name /// /// - Remark: Generated from `#/paths/items/{itemname}/state/GET/path/itemname`. - var itemname: Swift.String + public var itemname: Swift.String /// Creates a new `Path`. /// /// - Parameters: /// - itemname: item name - init(itemname: Swift.String) { + public init(itemname: Swift.String) { self.itemname = itemname } } - var path: Operations.getItemState_1.Input.Path + public var path: Operations.getItemState_1.Input.Path /// - Remark: Generated from `#/paths/items/{itemname}/state/GET/header`. - struct Headers: Sendable, Hashable { - var accept: [OpenAPIRuntime.AcceptHeaderContentType] + public struct Headers: Sendable, Hashable { + public var accept: [OpenAPIRuntime.AcceptHeaderContentType] /// Creates a new `Headers`. /// /// - Parameters: /// - accept: - init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + public init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { self.accept = accept } } - var headers: Operations.getItemState_1.Input.Headers + public var headers: Operations.getItemState_1.Input.Headers /// Creates a new `Input`. /// /// - Parameters: /// - path: /// - headers: - init(path: Operations.getItemState_1.Input.Path, - headers: Operations.getItemState_1.Input.Headers = .init()) { + public init(path: Operations.getItemState_1.Input.Path, + headers: Operations.getItemState_1.Input.Headers = .init()) { self.path = path self.headers = headers } } - enum Output: Sendable, Hashable { - struct Ok: Sendable, Hashable { + @frozen public enum Output: Sendable, Hashable { + public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/items/{itemname}/state/GET/responses/200/content`. - enum Body: Sendable, Hashable { + @frozen public enum Body: Sendable, Hashable { /// - Remark: Generated from `#/paths/items/{itemname}/state/GET/responses/200/content/text\/plain`. case plainText(OpenAPIRuntime.HTTPBody) /// The associated value of the enum case if `self` is `.plainText`. /// /// - Throws: An error if `self` is not `.plainText`. /// - SeeAlso: `.plainText`. - var plainText: OpenAPIRuntime.HTTPBody { + public var plainText: OpenAPIRuntime.HTTPBody { get throws { switch self { case let .plainText(body): @@ -4474,12 +4474,12 @@ enum Operations { } /// Received HTTP response body - var body: Operations.getItemState_1.Output.Ok.Body + public var body: Operations.getItemState_1.Output.Ok.Body /// Creates a new `Ok`. /// /// - Parameters: /// - body: Received HTTP response body - init(body: Operations.getItemState_1.Output.Ok.Body) { + public init(body: Operations.getItemState_1.Output.Ok.Body) { self.body = body } } @@ -4494,7 +4494,7 @@ enum Operations { /// /// - Throws: An error if `self` is not `.ok`. /// - SeeAlso: `.ok`. - var ok: Operations.getItemState_1.Output.Ok { + public var ok: Operations.getItemState_1.Output.Ok { get throws { switch self { case let .ok(response): @@ -4508,9 +4508,9 @@ enum Operations { } } - struct NotFound: Sendable, Hashable { + public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. - init() {} + public init() {} } /// Item not found @@ -4523,7 +4523,7 @@ enum Operations { /// /// - Throws: An error if `self` is not `.notFound`. /// - SeeAlso: `.notFound`. - var notFound: Operations.getItemState_1.Output.NotFound { + public var notFound: Operations.getItemState_1.Output.NotFound { get throws { switch self { case let .notFound(response): @@ -4543,10 +4543,10 @@ enum Operations { case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } - enum AcceptableContentType: AcceptableProtocol { + @frozen public enum AcceptableContentType: AcceptableProtocol { case plainText case other(Swift.String) - init?(rawValue: Swift.String) { + public init?(rawValue: Swift.String) { switch rawValue.lowercased() { case "text/plain": self = .plainText @@ -4555,7 +4555,7 @@ enum Operations { } } - var rawValue: Swift.String { + public var rawValue: Swift.String { switch self { case let .other(string): string @@ -4564,7 +4564,7 @@ enum Operations { } } - static var allCases: [Self] { + public static var allCases: [Self] { [ .plainText ] @@ -4576,67 +4576,67 @@ enum Operations { /// /// - Remark: HTTP `PUT /items/{itemname}/state`. /// - Remark: Generated from `#/paths//items/{itemname}/state/put(updateItemState)`. - enum updateItemState { - static let id: Swift.String = "updateItemState" - struct Input: Sendable, Hashable { + public enum updateItemState { + public static let id: Swift.String = "updateItemState" + public struct Input: Sendable, Hashable { /// - Remark: Generated from `#/paths/items/{itemname}/state/PUT/path`. - struct Path: Sendable, Hashable { + public struct Path: Sendable, Hashable { /// item name /// /// - Remark: Generated from `#/paths/items/{itemname}/state/PUT/path/itemname`. - var itemname: Swift.String + public var itemname: Swift.String /// Creates a new `Path`. /// /// - Parameters: /// - itemname: item name - init(itemname: Swift.String) { + public init(itemname: Swift.String) { self.itemname = itemname } } - var path: Operations.updateItemState.Input.Path + public var path: Operations.updateItemState.Input.Path /// - Remark: Generated from `#/paths/items/{itemname}/state/PUT/header`. - struct Headers: Sendable, Hashable { + public struct Headers: Sendable, Hashable { /// language /// /// - Remark: Generated from `#/paths/items/{itemname}/state/PUT/header/Accept-Language`. - var Accept_hyphen_Language: Swift.String? + public var Accept_hyphen_Language: Swift.String? /// Creates a new `Headers`. /// /// - Parameters: /// - Accept_hyphen_Language: language - init(Accept_hyphen_Language: Swift.String? = nil) { + public init(Accept_hyphen_Language: Swift.String? = nil) { self.Accept_hyphen_Language = Accept_hyphen_Language } } - var headers: Operations.updateItemState.Input.Headers + public var headers: Operations.updateItemState.Input.Headers /// - Remark: Generated from `#/paths/items/{itemname}/state/PUT/requestBody`. - enum Body: Sendable, Hashable { + @frozen public enum Body: Sendable, Hashable { /// - Remark: Generated from `#/paths/items/{itemname}/state/PUT/requestBody/content/text\/plain`. case plainText(OpenAPIRuntime.HTTPBody) } - var body: Operations.updateItemState.Input.Body + public var body: Operations.updateItemState.Input.Body /// Creates a new `Input`. /// /// - Parameters: /// - path: /// - headers: /// - body: - init(path: Operations.updateItemState.Input.Path, - headers: Operations.updateItemState.Input.Headers = .init(), - body: Operations.updateItemState.Input.Body) { + public init(path: Operations.updateItemState.Input.Path, + headers: Operations.updateItemState.Input.Headers = .init(), + body: Operations.updateItemState.Input.Body) { self.path = path self.headers = headers self.body = body } } - enum Output: Sendable, Hashable { - struct Accepted: Sendable, Hashable { + @frozen public enum Output: Sendable, Hashable { + public struct Accepted: Sendable, Hashable { /// Creates a new `Accepted`. - init() {} + public init() {} } /// Accepted @@ -4649,7 +4649,7 @@ enum Operations { /// /// - Throws: An error if `self` is not `.accepted`. /// - SeeAlso: `.accepted`. - var accepted: Operations.updateItemState.Output.Accepted { + public var accepted: Operations.updateItemState.Output.Accepted { get throws { switch self { case let .accepted(response): @@ -4663,9 +4663,9 @@ enum Operations { } } - struct BadRequest: Sendable, Hashable { + public struct BadRequest: Sendable, Hashable { /// Creates a new `BadRequest`. - init() {} + public init() {} } /// Item state null @@ -4678,7 +4678,7 @@ enum Operations { /// /// - Throws: An error if `self` is not `.badRequest`. /// - SeeAlso: `.badRequest`. - var badRequest: Operations.updateItemState.Output.BadRequest { + public var badRequest: Operations.updateItemState.Output.BadRequest { get throws { switch self { case let .badRequest(response): @@ -4692,9 +4692,9 @@ enum Operations { } } - struct NotFound: Sendable, Hashable { + public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. - init() {} + public init() {} } /// Item not found @@ -4707,7 +4707,7 @@ enum Operations { /// /// - Throws: An error if `self` is not `.notFound`. /// - SeeAlso: `.notFound`. - var notFound: Operations.updateItemState.Output.NotFound { + public var notFound: Operations.updateItemState.Output.NotFound { get throws { switch self { case let .notFound(response): @@ -4732,68 +4732,68 @@ enum Operations { /// /// - Remark: HTTP `GET /items/{itemname}/metadata/namespaces`. /// - Remark: Generated from `#/paths//items/{itemname}/metadata/namespaces/get(getItemNamespaces)`. - enum getItemNamespaces { - static let id: Swift.String = "getItemNamespaces" - struct Input: Sendable, Hashable { + public enum getItemNamespaces { + public static let id: Swift.String = "getItemNamespaces" + public struct Input: Sendable, Hashable { /// - Remark: Generated from `#/paths/items/{itemname}/metadata/namespaces/GET/path`. - struct Path: Sendable, Hashable { + public struct Path: Sendable, Hashable { /// item name /// /// - Remark: Generated from `#/paths/items/{itemname}/metadata/namespaces/GET/path/itemname`. - var itemname: Swift.String + public var itemname: Swift.String /// Creates a new `Path`. /// /// - Parameters: /// - itemname: item name - init(itemname: Swift.String) { + public init(itemname: Swift.String) { self.itemname = itemname } } - var path: Operations.getItemNamespaces.Input.Path + public var path: Operations.getItemNamespaces.Input.Path /// - Remark: Generated from `#/paths/items/{itemname}/metadata/namespaces/GET/header`. - struct Headers: Sendable, Hashable { + public struct Headers: Sendable, Hashable { /// language /// /// - Remark: Generated from `#/paths/items/{itemname}/metadata/namespaces/GET/header/Accept-Language`. - var Accept_hyphen_Language: Swift.String? - var accept: [OpenAPIRuntime.AcceptHeaderContentType] + public var Accept_hyphen_Language: Swift.String? + public var accept: [OpenAPIRuntime.AcceptHeaderContentType] /// Creates a new `Headers`. /// /// - Parameters: /// - Accept_hyphen_Language: language /// - accept: - init(Accept_hyphen_Language: Swift.String? = nil, - accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + public init(Accept_hyphen_Language: Swift.String? = nil, + accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { self.Accept_hyphen_Language = Accept_hyphen_Language self.accept = accept } } - var headers: Operations.getItemNamespaces.Input.Headers + public var headers: Operations.getItemNamespaces.Input.Headers /// Creates a new `Input`. /// /// - Parameters: /// - path: /// - headers: - init(path: Operations.getItemNamespaces.Input.Path, - headers: Operations.getItemNamespaces.Input.Headers = .init()) { + public init(path: Operations.getItemNamespaces.Input.Path, + headers: Operations.getItemNamespaces.Input.Headers = .init()) { self.path = path self.headers = headers } } - enum Output: Sendable, Hashable { - struct Ok: Sendable, Hashable { + @frozen public enum Output: Sendable, Hashable { + public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/items/{itemname}/metadata/namespaces/GET/responses/200/content`. - enum Body: Sendable, Hashable { + @frozen public enum Body: Sendable, Hashable { /// - Remark: Generated from `#/paths/items/{itemname}/metadata/namespaces/GET/responses/200/content/application\/json`. case json(Swift.String) /// The associated value of the enum case if `self` is `.json`. /// /// - Throws: An error if `self` is not `.json`. /// - SeeAlso: `.json`. - var json: Swift.String { + public var json: Swift.String { get throws { switch self { case let .json(body): @@ -4804,12 +4804,12 @@ enum Operations { } /// Received HTTP response body - var body: Operations.getItemNamespaces.Output.Ok.Body + public var body: Operations.getItemNamespaces.Output.Ok.Body /// Creates a new `Ok`. /// /// - Parameters: /// - body: Received HTTP response body - init(body: Operations.getItemNamespaces.Output.Ok.Body) { + public init(body: Operations.getItemNamespaces.Output.Ok.Body) { self.body = body } } @@ -4824,7 +4824,7 @@ enum Operations { /// /// - Throws: An error if `self` is not `.ok`. /// - SeeAlso: `.ok`. - var ok: Operations.getItemNamespaces.Output.Ok { + public var ok: Operations.getItemNamespaces.Output.Ok { get throws { switch self { case let .ok(response): @@ -4838,9 +4838,9 @@ enum Operations { } } - struct NotFound: Sendable, Hashable { + public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. - init() {} + public init() {} } /// Item not found @@ -4853,7 +4853,7 @@ enum Operations { /// /// - Throws: An error if `self` is not `.notFound`. /// - SeeAlso: `.notFound`. - var notFound: Operations.getItemNamespaces.Output.NotFound { + public var notFound: Operations.getItemNamespaces.Output.NotFound { get throws { switch self { case let .notFound(response): @@ -4873,10 +4873,10 @@ enum Operations { case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } - enum AcceptableContentType: AcceptableProtocol { + @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) - init?(rawValue: Swift.String) { + public init?(rawValue: Swift.String) { switch rawValue.lowercased() { case "application/json": self = .json @@ -4885,7 +4885,7 @@ enum Operations { } } - var rawValue: Swift.String { + public var rawValue: Swift.String { switch self { case let .other(string): string @@ -4894,7 +4894,7 @@ enum Operations { } } - static var allCases: [Self] { + public static var allCases: [Self] { [ .json ] @@ -4906,64 +4906,64 @@ enum Operations { /// /// - Remark: HTTP `GET /items/{itemName}/semantic/{semanticClass}`. /// - Remark: Generated from `#/paths//items/{itemName}/semantic/{semanticClass}/get(getSemanticItem)`. - enum getSemanticItem { - static let id: Swift.String = "getSemanticItem" - struct Input: Sendable, Hashable { + public enum getSemanticItem { + public static let id: Swift.String = "getSemanticItem" + public struct Input: Sendable, Hashable { /// - Remark: Generated from `#/paths/items/{itemName}/semantic/{semanticClass}/GET/path`. - struct Path: Sendable, Hashable { + public struct Path: Sendable, Hashable { /// item name /// /// - Remark: Generated from `#/paths/items/{itemName}/semantic/{semanticClass}/GET/path/itemName`. - var itemName: Swift.String + public var itemName: Swift.String /// semantic class /// /// - Remark: Generated from `#/paths/items/{itemName}/semantic/{semanticClass}/GET/path/semanticClass`. - var semanticClass: Swift.String + public var semanticClass: Swift.String /// Creates a new `Path`. /// /// - Parameters: /// - itemName: item name /// - semanticClass: semantic class - init(itemName: Swift.String, - semanticClass: Swift.String) { + public init(itemName: Swift.String, + semanticClass: Swift.String) { self.itemName = itemName self.semanticClass = semanticClass } } - var path: Operations.getSemanticItem.Input.Path + public var path: Operations.getSemanticItem.Input.Path /// - Remark: Generated from `#/paths/items/{itemName}/semantic/{semanticClass}/GET/header`. - struct Headers: Sendable, Hashable { + public struct Headers: Sendable, Hashable { /// language /// /// - Remark: Generated from `#/paths/items/{itemName}/semantic/{semanticClass}/GET/header/Accept-Language`. - var Accept_hyphen_Language: Swift.String? + public var Accept_hyphen_Language: Swift.String? /// Creates a new `Headers`. /// /// - Parameters: /// - Accept_hyphen_Language: language - init(Accept_hyphen_Language: Swift.String? = nil) { + public init(Accept_hyphen_Language: Swift.String? = nil) { self.Accept_hyphen_Language = Accept_hyphen_Language } } - var headers: Operations.getSemanticItem.Input.Headers + public var headers: Operations.getSemanticItem.Input.Headers /// Creates a new `Input`. /// /// - Parameters: /// - path: /// - headers: - init(path: Operations.getSemanticItem.Input.Path, - headers: Operations.getSemanticItem.Input.Headers = .init()) { + public init(path: Operations.getSemanticItem.Input.Path, + headers: Operations.getSemanticItem.Input.Headers = .init()) { self.path = path self.headers = headers } } - enum Output: Sendable, Hashable { - struct Ok: Sendable, Hashable { + @frozen public enum Output: Sendable, Hashable { + public struct Ok: Sendable, Hashable { /// Creates a new `Ok`. - init() {} + public init() {} } /// OK @@ -4976,7 +4976,7 @@ enum Operations { /// /// - Throws: An error if `self` is not `.ok`. /// - SeeAlso: `.ok`. - var ok: Operations.getSemanticItem.Output.Ok { + public var ok: Operations.getSemanticItem.Output.Ok { get throws { switch self { case let .ok(response): @@ -4990,9 +4990,9 @@ enum Operations { } } - struct NotFound: Sendable, Hashable { + public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. - init() {} + public init() {} } /// Item not found @@ -5005,7 +5005,7 @@ enum Operations { /// /// - Throws: An error if `self` is not `.notFound`. /// - SeeAlso: `.notFound`. - var notFound: Operations.getSemanticItem.Output.NotFound { + public var notFound: Operations.getSemanticItem.Output.NotFound { get throws { switch self { case let .notFound(response): @@ -5030,17 +5030,17 @@ enum Operations { /// /// - Remark: HTTP `POST /items/metadata/purge`. /// - Remark: Generated from `#/paths//items/metadata/purge/post(purgeDatabase)`. - enum purgeDatabase { - static let id: Swift.String = "purgeDatabase" - struct Input: Sendable, Hashable { + public enum purgeDatabase { + public static let id: Swift.String = "purgeDatabase" + public struct Input: Sendable, Hashable { /// Creates a new `Input`. - init() {} + public init() {} } - enum Output: Sendable, Hashable { - struct Ok: Sendable, Hashable { + @frozen public enum Output: Sendable, Hashable { + public struct Ok: Sendable, Hashable { /// Creates a new `Ok`. - init() {} + public init() {} } /// OK @@ -5053,7 +5053,7 @@ enum Operations { /// /// - Throws: An error if `self` is not `.ok`. /// - SeeAlso: `.ok`. - var ok: Operations.purgeDatabase.Output.Ok { + public var ok: Operations.purgeDatabase.Output.Ok { get throws { switch self { case let .ok(response): @@ -5078,35 +5078,35 @@ enum Operations { /// /// - Remark: HTTP `POST /sitemaps/events/subscribe`. /// - Remark: Generated from `#/paths//sitemaps/events/subscribe/post(createSitemapEventSubscription)`. - enum createSitemapEventSubscription { - static let id: Swift.String = "createSitemapEventSubscription" - struct Input: Sendable, Hashable { + public enum createSitemapEventSubscription { + public static let id: Swift.String = "createSitemapEventSubscription" + public struct Input: Sendable, Hashable { /// - Remark: Generated from `#/paths/sitemaps/events/subscribe/POST/header`. - struct Headers: Sendable, Hashable { - var accept: [OpenAPIRuntime.AcceptHeaderContentType] + public struct Headers: Sendable, Hashable { + public var accept: [OpenAPIRuntime.AcceptHeaderContentType] /// Creates a new `Headers`. /// /// - Parameters: /// - accept: - init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + public init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { self.accept = accept } } - var headers: Operations.createSitemapEventSubscription.Input.Headers + public var headers: Operations.createSitemapEventSubscription.Input.Headers /// Creates a new `Input`. /// /// - Parameters: /// - headers: - init(headers: Operations.createSitemapEventSubscription.Input.Headers = .init()) { + public init(headers: Operations.createSitemapEventSubscription.Input.Headers = .init()) { self.headers = headers } } - enum Output: Sendable, Hashable { - struct Created: Sendable, Hashable { + @frozen public enum Output: Sendable, Hashable { + public struct Created: Sendable, Hashable { /// Creates a new `Created`. - init() {} + public init() {} } /// Subscription created. @@ -5119,7 +5119,7 @@ enum Operations { /// /// - Throws: An error if `self` is not `.created`. /// - SeeAlso: `.created`. - var created: Operations.createSitemapEventSubscription.Output.Created { + public var created: Operations.createSitemapEventSubscription.Output.Created { get throws { switch self { case let .created(response): @@ -5133,16 +5133,16 @@ enum Operations { } } - struct Ok: Sendable, Hashable { + public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/sitemaps/events/subscribe/POST/responses/200/content`. - enum Body: Sendable, Hashable { + @frozen public enum Body: Sendable, Hashable { /// - Remark: Generated from `#/paths/sitemaps/events/subscribe/POST/responses/200/content/application\/json`. case json(Components.Schemas.JerseyResponseBuilderDTO) /// The associated value of the enum case if `self` is `.json`. /// /// - Throws: An error if `self` is not `.json`. /// - SeeAlso: `.json`. - var json: Components.Schemas.JerseyResponseBuilderDTO { + public var json: Components.Schemas.JerseyResponseBuilderDTO { get throws { switch self { case let .json(body): @@ -5153,12 +5153,12 @@ enum Operations { } /// Received HTTP response body - var body: Operations.createSitemapEventSubscription.Output.Ok.Body + public var body: Operations.createSitemapEventSubscription.Output.Ok.Body /// Creates a new `Ok`. /// /// - Parameters: /// - body: Received HTTP response body - init(body: Operations.createSitemapEventSubscription.Output.Ok.Body) { + public init(body: Operations.createSitemapEventSubscription.Output.Ok.Body) { self.body = body } } @@ -5173,7 +5173,7 @@ enum Operations { /// /// - Throws: An error if `self` is not `.ok`. /// - SeeAlso: `.ok`. - var ok: Operations.createSitemapEventSubscription.Output.Ok { + public var ok: Operations.createSitemapEventSubscription.Output.Ok { get throws { switch self { case let .ok(response): @@ -5187,9 +5187,9 @@ enum Operations { } } - struct ServiceUnavailable: Sendable, Hashable { + public struct ServiceUnavailable: Sendable, Hashable { /// Creates a new `ServiceUnavailable`. - init() {} + public init() {} } /// Subscriptions limit reached. @@ -5202,7 +5202,7 @@ enum Operations { /// /// - Throws: An error if `self` is not `.serviceUnavailable`. /// - SeeAlso: `.serviceUnavailable`. - var serviceUnavailable: Operations.createSitemapEventSubscription.Output.ServiceUnavailable { + public var serviceUnavailable: Operations.createSitemapEventSubscription.Output.ServiceUnavailable { get throws { switch self { case let .serviceUnavailable(response): @@ -5222,10 +5222,10 @@ enum Operations { case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } - enum AcceptableContentType: AcceptableProtocol { + @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) - init?(rawValue: Swift.String) { + public init?(rawValue: Swift.String) { switch rawValue.lowercased() { case "application/json": self = .json @@ -5234,7 +5234,7 @@ enum Operations { } } - var rawValue: Swift.String { + public var rawValue: Swift.String { switch self { case let .other(string): string @@ -5243,7 +5243,7 @@ enum Operations { } } - static var allCases: [Self] { + public static var allCases: [Self] { [ .json ] @@ -5255,108 +5255,108 @@ enum Operations { /// /// - Remark: HTTP `GET /sitemaps/{sitemapname}/{pageid}`. /// - Remark: Generated from `#/paths//sitemaps/{sitemapname}/{pageid}/get(pollDataForPage)`. - enum pollDataForPage { - static let id: Swift.String = "pollDataForPage" - struct Input: Sendable, Hashable { + public enum pollDataForPage { + public static let id: Swift.String = "pollDataForPage" + public struct Input: Sendable, Hashable { /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/{pageid}/GET/path`. - struct Path: Sendable, Hashable { + public struct Path: Sendable, Hashable { /// sitemap name /// /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/{pageid}/GET/path/sitemapname`. - var sitemapname: Swift.String + public var sitemapname: Swift.String /// page id /// /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/{pageid}/GET/path/pageid`. - var pageid: Swift.String + public var pageid: Swift.String /// Creates a new `Path`. /// /// - Parameters: /// - sitemapname: sitemap name /// - pageid: page id - init(sitemapname: Swift.String, - pageid: Swift.String) { + public init(sitemapname: Swift.String, + pageid: Swift.String) { self.sitemapname = sitemapname self.pageid = pageid } } - var path: Operations.pollDataForPage.Input.Path + public var path: Operations.pollDataForPage.Input.Path /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/{pageid}/GET/query`. - struct Query: Sendable, Hashable { + public struct Query: Sendable, Hashable { /// subscriptionid /// /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/{pageid}/GET/query/subscriptionid`. - var subscriptionid: Swift.String? + public var subscriptionid: Swift.String? /// include hidden widgets /// /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/{pageid}/GET/query/includeHidden`. - var includeHidden: Swift.Bool? + public var includeHidden: Swift.Bool? /// Creates a new `Query`. /// /// - Parameters: /// - subscriptionid: subscriptionid /// - includeHidden: include hidden widgets - init(subscriptionid: Swift.String? = nil, - includeHidden: Swift.Bool? = nil) { + public init(subscriptionid: Swift.String? = nil, + includeHidden: Swift.Bool? = nil) { self.subscriptionid = subscriptionid self.includeHidden = includeHidden } } - var query: Operations.pollDataForPage.Input.Query + public var query: Operations.pollDataForPage.Input.Query /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/{pageid}/GET/header`. - struct Headers: Sendable, Hashable { + public struct Headers: Sendable, Hashable { /// language /// /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/{pageid}/GET/header/Accept-Language`. - var Accept_hyphen_Language: Swift.String? + public var Accept_hyphen_Language: Swift.String? /// X-Atmosphere-Transport for long polling /// /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/{pageid}/GET/header/X-Atmosphere-Transport`. - var X_hyphen_Atmosphere_hyphen_Transport: Swift.String? - var accept: [OpenAPIRuntime.AcceptHeaderContentType] + public var X_hyphen_Atmosphere_hyphen_Transport: Swift.String? + public var accept: [OpenAPIRuntime.AcceptHeaderContentType] /// Creates a new `Headers`. /// /// - Parameters: /// - Accept_hyphen_Language: language /// - X_hyphen_Atmosphere_hyphen_Transport: X-Atmosphere-Transport for long polling /// - accept: - init(Accept_hyphen_Language: Swift.String? = nil, - X_hyphen_Atmosphere_hyphen_Transport: Swift.String? = nil, - accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + public init(Accept_hyphen_Language: Swift.String? = nil, + X_hyphen_Atmosphere_hyphen_Transport: Swift.String? = nil, + accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { self.Accept_hyphen_Language = Accept_hyphen_Language self.X_hyphen_Atmosphere_hyphen_Transport = X_hyphen_Atmosphere_hyphen_Transport self.accept = accept } } - var headers: Operations.pollDataForPage.Input.Headers + public var headers: Operations.pollDataForPage.Input.Headers /// Creates a new `Input`. /// /// - Parameters: /// - path: /// - query: /// - headers: - init(path: Operations.pollDataForPage.Input.Path, - query: Operations.pollDataForPage.Input.Query = .init(), - headers: Operations.pollDataForPage.Input.Headers = .init()) { + public init(path: Operations.pollDataForPage.Input.Path, + query: Operations.pollDataForPage.Input.Query = .init(), + headers: Operations.pollDataForPage.Input.Headers = .init()) { self.path = path self.query = query self.headers = headers } } - enum Output: Sendable, Hashable { - struct Ok: Sendable, Hashable { + @frozen public enum Output: Sendable, Hashable { + public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/{pageid}/GET/responses/200/content`. - enum Body: Sendable, Hashable { + @frozen public enum Body: Sendable, Hashable { /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/{pageid}/GET/responses/200/content/application\/json`. case json(Components.Schemas.PageDTO) /// The associated value of the enum case if `self` is `.json`. /// /// - Throws: An error if `self` is not `.json`. /// - SeeAlso: `.json`. - var json: Components.Schemas.PageDTO { + public var json: Components.Schemas.PageDTO { get throws { switch self { case let .json(body): @@ -5367,12 +5367,12 @@ enum Operations { } /// Received HTTP response body - var body: Operations.pollDataForPage.Output.Ok.Body + public var body: Operations.pollDataForPage.Output.Ok.Body /// Creates a new `Ok`. /// /// - Parameters: /// - body: Received HTTP response body - init(body: Operations.pollDataForPage.Output.Ok.Body) { + public init(body: Operations.pollDataForPage.Output.Ok.Body) { self.body = body } } @@ -5387,7 +5387,7 @@ enum Operations { /// /// - Throws: An error if `self` is not `.ok`. /// - SeeAlso: `.ok`. - var ok: Operations.pollDataForPage.Output.Ok { + public var ok: Operations.pollDataForPage.Output.Ok { get throws { switch self { case let .ok(response): @@ -5401,9 +5401,9 @@ enum Operations { } } - struct BadRequest: Sendable, Hashable { + public struct BadRequest: Sendable, Hashable { /// Creates a new `BadRequest`. - init() {} + public init() {} } /// Invalid subscription id has been provided. @@ -5416,7 +5416,7 @@ enum Operations { /// /// - Throws: An error if `self` is not `.badRequest`. /// - SeeAlso: `.badRequest`. - var badRequest: Operations.pollDataForPage.Output.BadRequest { + public var badRequest: Operations.pollDataForPage.Output.BadRequest { get throws { switch self { case let .badRequest(response): @@ -5430,9 +5430,9 @@ enum Operations { } } - struct NotFound: Sendable, Hashable { + public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. - init() {} + public init() {} } /// Sitemap with requested name does not exist or page does not exist, or page refers to a non-linkable widget @@ -5445,7 +5445,7 @@ enum Operations { /// /// - Throws: An error if `self` is not `.notFound`. /// - SeeAlso: `.notFound`. - var notFound: Operations.pollDataForPage.Output.NotFound { + public var notFound: Operations.pollDataForPage.Output.NotFound { get throws { switch self { case let .notFound(response): @@ -5465,10 +5465,10 @@ enum Operations { case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } - enum AcceptableContentType: AcceptableProtocol { + @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) - init?(rawValue: Swift.String) { + public init?(rawValue: Swift.String) { switch rawValue.lowercased() { case "application/json": self = .json @@ -5477,7 +5477,7 @@ enum Operations { } } - var rawValue: Swift.String { + public var rawValue: Swift.String { switch self { case let .other(string): string @@ -5486,7 +5486,7 @@ enum Operations { } } - static var allCases: [Self] { + public static var allCases: [Self] { [ .json ] @@ -5498,101 +5498,101 @@ enum Operations { /// /// - Remark: HTTP `GET /sitemaps/{sitemapname}/*`. /// - Remark: Generated from `#/paths//sitemaps/{sitemapname}/*/get(pollDataForSitemap)`. - enum pollDataForSitemap { - static let id: Swift.String = "pollDataForSitemap" - struct Input: Sendable, Hashable { + public enum pollDataForSitemap { + public static let id: Swift.String = "pollDataForSitemap" + public struct Input: Sendable, Hashable { /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/*/GET/path`. - struct Path: Sendable, Hashable { + public struct Path: Sendable, Hashable { /// sitemap name /// /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/*/GET/path/sitemapname`. - var sitemapname: Swift.String + public var sitemapname: Swift.String /// Creates a new `Path`. /// /// - Parameters: /// - sitemapname: sitemap name - init(sitemapname: Swift.String) { + public init(sitemapname: Swift.String) { self.sitemapname = sitemapname } } - var path: Operations.pollDataForSitemap.Input.Path + public var path: Operations.pollDataForSitemap.Input.Path /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/*/GET/query`. - struct Query: Sendable, Hashable { + public struct Query: Sendable, Hashable { /// subscriptionid /// /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/*/GET/query/subscriptionid`. - var subscriptionid: Swift.String? + public var subscriptionid: Swift.String? /// include hidden widgets /// /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/*/GET/query/includeHidden`. - var includeHidden: Swift.Bool? + public var includeHidden: Swift.Bool? /// Creates a new `Query`. /// /// - Parameters: /// - subscriptionid: subscriptionid /// - includeHidden: include hidden widgets - init(subscriptionid: Swift.String? = nil, - includeHidden: Swift.Bool? = nil) { + public init(subscriptionid: Swift.String? = nil, + includeHidden: Swift.Bool? = nil) { self.subscriptionid = subscriptionid self.includeHidden = includeHidden } } - var query: Operations.pollDataForSitemap.Input.Query + public var query: Operations.pollDataForSitemap.Input.Query /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/*/GET/header`. - struct Headers: Sendable, Hashable { + public struct Headers: Sendable, Hashable { /// language /// /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/*/GET/header/Accept-Language`. - var Accept_hyphen_Language: Swift.String? + public var Accept_hyphen_Language: Swift.String? /// X-Atmosphere-Transport for long polling /// /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/*/GET/header/X-Atmosphere-Transport`. - var X_hyphen_Atmosphere_hyphen_Transport: Swift.String? - var accept: [OpenAPIRuntime.AcceptHeaderContentType] + public var X_hyphen_Atmosphere_hyphen_Transport: Swift.String? + public var accept: [OpenAPIRuntime.AcceptHeaderContentType] /// Creates a new `Headers`. /// /// - Parameters: /// - Accept_hyphen_Language: language /// - X_hyphen_Atmosphere_hyphen_Transport: X-Atmosphere-Transport for long polling /// - accept: - init(Accept_hyphen_Language: Swift.String? = nil, - X_hyphen_Atmosphere_hyphen_Transport: Swift.String? = nil, - accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + public init(Accept_hyphen_Language: Swift.String? = nil, + X_hyphen_Atmosphere_hyphen_Transport: Swift.String? = nil, + accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { self.Accept_hyphen_Language = Accept_hyphen_Language self.X_hyphen_Atmosphere_hyphen_Transport = X_hyphen_Atmosphere_hyphen_Transport self.accept = accept } } - var headers: Operations.pollDataForSitemap.Input.Headers + public var headers: Operations.pollDataForSitemap.Input.Headers /// Creates a new `Input`. /// /// - Parameters: /// - path: /// - query: /// - headers: - init(path: Operations.pollDataForSitemap.Input.Path, - query: Operations.pollDataForSitemap.Input.Query = .init(), - headers: Operations.pollDataForSitemap.Input.Headers = .init()) { + public init(path: Operations.pollDataForSitemap.Input.Path, + query: Operations.pollDataForSitemap.Input.Query = .init(), + headers: Operations.pollDataForSitemap.Input.Headers = .init()) { self.path = path self.query = query self.headers = headers } } - enum Output: Sendable, Hashable { - struct Ok: Sendable, Hashable { + @frozen public enum Output: Sendable, Hashable { + public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/*/GET/responses/200/content`. - enum Body: Sendable, Hashable { + @frozen public enum Body: Sendable, Hashable { /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/*/GET/responses/200/content/application\/json`. case json(Components.Schemas.SitemapDTO) /// The associated value of the enum case if `self` is `.json`. /// /// - Throws: An error if `self` is not `.json`. /// - SeeAlso: `.json`. - var json: Components.Schemas.SitemapDTO { + public var json: Components.Schemas.SitemapDTO { get throws { switch self { case let .json(body): @@ -5603,12 +5603,12 @@ enum Operations { } /// Received HTTP response body - var body: Operations.pollDataForSitemap.Output.Ok.Body + public var body: Operations.pollDataForSitemap.Output.Ok.Body /// Creates a new `Ok`. /// /// - Parameters: /// - body: Received HTTP response body - init(body: Operations.pollDataForSitemap.Output.Ok.Body) { + public init(body: Operations.pollDataForSitemap.Output.Ok.Body) { self.body = body } } @@ -5623,7 +5623,7 @@ enum Operations { /// /// - Throws: An error if `self` is not `.ok`. /// - SeeAlso: `.ok`. - var ok: Operations.pollDataForSitemap.Output.Ok { + public var ok: Operations.pollDataForSitemap.Output.Ok { get throws { switch self { case let .ok(response): @@ -5637,9 +5637,9 @@ enum Operations { } } - struct BadRequest: Sendable, Hashable { + public struct BadRequest: Sendable, Hashable { /// Creates a new `BadRequest`. - init() {} + public init() {} } /// Invalid subscription id has been provided. @@ -5652,7 +5652,7 @@ enum Operations { /// /// - Throws: An error if `self` is not `.badRequest`. /// - SeeAlso: `.badRequest`. - var badRequest: Operations.pollDataForSitemap.Output.BadRequest { + public var badRequest: Operations.pollDataForSitemap.Output.BadRequest { get throws { switch self { case let .badRequest(response): @@ -5666,9 +5666,9 @@ enum Operations { } } - struct NotFound: Sendable, Hashable { + public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. - init() {} + public init() {} } /// Sitemap with requested name does not exist @@ -5681,7 +5681,7 @@ enum Operations { /// /// - Throws: An error if `self` is not `.notFound`. /// - SeeAlso: `.notFound`. - var notFound: Operations.pollDataForSitemap.Output.NotFound { + public var notFound: Operations.pollDataForSitemap.Output.NotFound { get throws { switch self { case let .notFound(response): @@ -5701,10 +5701,10 @@ enum Operations { case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } - enum AcceptableContentType: AcceptableProtocol { + @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) - init?(rawValue: Swift.String) { + public init?(rawValue: Swift.String) { switch rawValue.lowercased() { case "application/json": self = .json @@ -5713,7 +5713,7 @@ enum Operations { } } - var rawValue: Swift.String { + public var rawValue: Swift.String { switch self { case let .other(string): string @@ -5722,7 +5722,7 @@ enum Operations { } } - static var allCases: [Self] { + public static var allCases: [Self] { [ .json ] @@ -5734,97 +5734,97 @@ enum Operations { /// /// - Remark: HTTP `GET /sitemaps/{sitemapname}`. /// - Remark: Generated from `#/paths//sitemaps/{sitemapname}/get(getSitemapByName)`. - enum getSitemapByName { - static let id: Swift.String = "getSitemapByName" - struct Input: Sendable, Hashable { + public enum getSitemapByName { + public static let id: Swift.String = "getSitemapByName" + public struct Input: Sendable, Hashable { /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/GET/path`. - struct Path: Sendable, Hashable { + public struct Path: Sendable, Hashable { /// sitemap name /// /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/GET/path/sitemapname`. - var sitemapname: Swift.String + public var sitemapname: Swift.String /// Creates a new `Path`. /// /// - Parameters: /// - sitemapname: sitemap name - init(sitemapname: Swift.String) { + public init(sitemapname: Swift.String) { self.sitemapname = sitemapname } } - var path: Operations.getSitemapByName.Input.Path + public var path: Operations.getSitemapByName.Input.Path /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/GET/query`. - struct Query: Sendable, Hashable { + public struct Query: Sendable, Hashable { /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/GET/query/type`. - var _type: Swift.String? + public var _type: Swift.String? /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/GET/query/jsoncallback`. - var jsoncallback: Swift.String? + public var jsoncallback: Swift.String? /// include hidden widgets /// /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/GET/query/includeHidden`. - var includeHidden: Swift.Bool? + public var includeHidden: Swift.Bool? /// Creates a new `Query`. /// /// - Parameters: /// - _type: /// - jsoncallback: /// - includeHidden: include hidden widgets - init(_type: Swift.String? = nil, - jsoncallback: Swift.String? = nil, - includeHidden: Swift.Bool? = nil) { + public init(_type: Swift.String? = nil, + jsoncallback: Swift.String? = nil, + includeHidden: Swift.Bool? = nil) { self._type = _type self.jsoncallback = jsoncallback self.includeHidden = includeHidden } } - var query: Operations.getSitemapByName.Input.Query + public var query: Operations.getSitemapByName.Input.Query /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/GET/header`. - struct Headers: Sendable, Hashable { + public struct Headers: Sendable, Hashable { /// language /// /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/GET/header/Accept-Language`. - var Accept_hyphen_Language: Swift.String? - var accept: [OpenAPIRuntime.AcceptHeaderContentType] + public var Accept_hyphen_Language: Swift.String? + public var accept: [OpenAPIRuntime.AcceptHeaderContentType] /// Creates a new `Headers`. /// /// - Parameters: /// - Accept_hyphen_Language: language /// - accept: - init(Accept_hyphen_Language: Swift.String? = nil, - accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + public init(Accept_hyphen_Language: Swift.String? = nil, + accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { self.Accept_hyphen_Language = Accept_hyphen_Language self.accept = accept } } - var headers: Operations.getSitemapByName.Input.Headers + public var headers: Operations.getSitemapByName.Input.Headers /// Creates a new `Input`. /// /// - Parameters: /// - path: /// - query: /// - headers: - init(path: Operations.getSitemapByName.Input.Path, - query: Operations.getSitemapByName.Input.Query = .init(), - headers: Operations.getSitemapByName.Input.Headers = .init()) { + public init(path: Operations.getSitemapByName.Input.Path, + query: Operations.getSitemapByName.Input.Query = .init(), + headers: Operations.getSitemapByName.Input.Headers = .init()) { self.path = path self.query = query self.headers = headers } } - enum Output: Sendable, Hashable { - struct Ok: Sendable, Hashable { + @frozen public enum Output: Sendable, Hashable { + public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/GET/responses/200/content`. - enum Body: Sendable, Hashable { + @frozen public enum Body: Sendable, Hashable { /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/GET/responses/200/content/application\/json`. case json(Components.Schemas.SitemapDTO) /// The associated value of the enum case if `self` is `.json`. /// /// - Throws: An error if `self` is not `.json`. /// - SeeAlso: `.json`. - var json: Components.Schemas.SitemapDTO { + public var json: Components.Schemas.SitemapDTO { get throws { switch self { case let .json(body): @@ -5835,12 +5835,12 @@ enum Operations { } /// Received HTTP response body - var body: Operations.getSitemapByName.Output.Ok.Body + public var body: Operations.getSitemapByName.Output.Ok.Body /// Creates a new `Ok`. /// /// - Parameters: /// - body: Received HTTP response body - init(body: Operations.getSitemapByName.Output.Ok.Body) { + public init(body: Operations.getSitemapByName.Output.Ok.Body) { self.body = body } } @@ -5855,7 +5855,7 @@ enum Operations { /// /// - Throws: An error if `self` is not `.ok`. /// - SeeAlso: `.ok`. - var ok: Operations.getSitemapByName.Output.Ok { + public var ok: Operations.getSitemapByName.Output.Ok { get throws { switch self { case let .ok(response): @@ -5875,10 +5875,10 @@ enum Operations { case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } - enum AcceptableContentType: AcceptableProtocol { + @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) - init?(rawValue: Swift.String) { + public init?(rawValue: Swift.String) { switch rawValue.lowercased() { case "application/json": self = .json @@ -5887,7 +5887,7 @@ enum Operations { } } - var rawValue: Swift.String { + public var rawValue: Swift.String { switch self { case let .other(string): string @@ -5896,7 +5896,7 @@ enum Operations { } } - static var allCases: [Self] { + public static var allCases: [Self] { [ .json ] @@ -5908,57 +5908,57 @@ enum Operations { /// /// - Remark: HTTP `GET /sitemaps/events/{subscriptionid}/*`. /// - Remark: Generated from `#/paths//sitemaps/events/{subscriptionid}/*/get(getSitemapEvents)`. - enum getSitemapEvents { - static let id: Swift.String = "getSitemapEvents" - struct Input: Sendable, Hashable { + public enum getSitemapEvents { + public static let id: Swift.String = "getSitemapEvents" + public struct Input: Sendable, Hashable { /// - Remark: Generated from `#/paths/sitemaps/events/{subscriptionid}/*/GET/path`. - struct Path: Sendable, Hashable { + public struct Path: Sendable, Hashable { /// subscription id /// /// - Remark: Generated from `#/paths/sitemaps/events/{subscriptionid}/*/GET/path/subscriptionid`. - var subscriptionid: Swift.String + public var subscriptionid: Swift.String /// Creates a new `Path`. /// /// - Parameters: /// - subscriptionid: subscription id - init(subscriptionid: Swift.String) { + public init(subscriptionid: Swift.String) { self.subscriptionid = subscriptionid } } - var path: Operations.getSitemapEvents.Input.Path + public var path: Operations.getSitemapEvents.Input.Path /// - Remark: Generated from `#/paths/sitemaps/events/{subscriptionid}/*/GET/query`. - struct Query: Sendable, Hashable { + public struct Query: Sendable, Hashable { /// sitemap name /// /// - Remark: Generated from `#/paths/sitemaps/events/{subscriptionid}/*/GET/query/sitemap`. - var sitemap: Swift.String? + public var sitemap: Swift.String? /// Creates a new `Query`. /// /// - Parameters: /// - sitemap: sitemap name - init(sitemap: Swift.String? = nil) { + public init(sitemap: Swift.String? = nil) { self.sitemap = sitemap } } - var query: Operations.getSitemapEvents.Input.Query + public var query: Operations.getSitemapEvents.Input.Query /// Creates a new `Input`. /// /// - Parameters: /// - path: /// - query: - init(path: Operations.getSitemapEvents.Input.Path, - query: Operations.getSitemapEvents.Input.Query = .init()) { + public init(path: Operations.getSitemapEvents.Input.Path, + query: Operations.getSitemapEvents.Input.Query = .init()) { self.path = path self.query = query } } - enum Output: Sendable, Hashable { - struct Ok: Sendable, Hashable { + @frozen public enum Output: Sendable, Hashable { + public struct Ok: Sendable, Hashable { /// Creates a new `Ok`. - init() {} + public init() {} } /// OK @@ -5971,7 +5971,7 @@ enum Operations { /// /// - Throws: An error if `self` is not `.ok`. /// - SeeAlso: `.ok`. - var ok: Operations.getSitemapEvents.Output.Ok { + public var ok: Operations.getSitemapEvents.Output.Ok { get throws { switch self { case let .ok(response): @@ -5985,9 +5985,9 @@ enum Operations { } } - struct BadRequest: Sendable, Hashable { + public struct BadRequest: Sendable, Hashable { /// Creates a new `BadRequest`. - init() {} + public init() {} } /// Missing sitemap parameter, or sitemap not linked successfully to the subscription. @@ -6000,7 +6000,7 @@ enum Operations { /// /// - Throws: An error if `self` is not `.badRequest`. /// - SeeAlso: `.badRequest`. - var badRequest: Operations.getSitemapEvents.Output.BadRequest { + public var badRequest: Operations.getSitemapEvents.Output.BadRequest { get throws { switch self { case let .badRequest(response): @@ -6014,9 +6014,9 @@ enum Operations { } } - struct NotFound: Sendable, Hashable { + public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. - init() {} + public init() {} } /// Subscription not found. @@ -6029,7 +6029,7 @@ enum Operations { /// /// - Throws: An error if `self` is not `.notFound`. /// - SeeAlso: `.notFound`. - var notFound: Operations.getSitemapEvents.Output.NotFound { + public var notFound: Operations.getSitemapEvents.Output.NotFound { get throws { switch self { case let .notFound(response): @@ -6054,87 +6054,87 @@ enum Operations { /// /// - Remark: HTTP `GET /sitemaps/events/{subscriptionid}`. /// - Remark: Generated from `#/paths//sitemaps/events/{subscriptionid}/get(getSitemapEvents_1)`. - enum getSitemapEvents_1 { - static let id: Swift.String = "getSitemapEvents_1" - struct Input: Sendable, Hashable { + public enum getSitemapEvents_1 { + public static let id: Swift.String = "getSitemapEvents_1" + public struct Input: Sendable, Hashable { /// - Remark: Generated from `#/paths/sitemaps/events/{subscriptionid}/GET/path`. - struct Path: Sendable, Hashable { + public struct Path: Sendable, Hashable { /// subscription id /// /// - Remark: Generated from `#/paths/sitemaps/events/{subscriptionid}/GET/path/subscriptionid`. - var subscriptionid: Swift.String + public var subscriptionid: Swift.String /// Creates a new `Path`. /// /// - Parameters: /// - subscriptionid: subscription id - init(subscriptionid: Swift.String) { + public init(subscriptionid: Swift.String) { self.subscriptionid = subscriptionid } } - var path: Operations.getSitemapEvents_1.Input.Path + public var path: Operations.getSitemapEvents_1.Input.Path /// - Remark: Generated from `#/paths/sitemaps/events/{subscriptionid}/GET/query`. - struct Query: Sendable, Hashable { + public struct Query: Sendable, Hashable { /// sitemap name /// /// - Remark: Generated from `#/paths/sitemaps/events/{subscriptionid}/GET/query/sitemap`. - var sitemap: Swift.String? + public var sitemap: Swift.String? /// page id /// /// - Remark: Generated from `#/paths/sitemaps/events/{subscriptionid}/GET/query/pageid`. - var pageid: Swift.String? + public var pageid: Swift.String? /// Creates a new `Query`. /// /// - Parameters: /// - sitemap: sitemap name /// - pageid: page id - init(sitemap: Swift.String? = nil, - pageid: Swift.String? = nil) { + public init(sitemap: Swift.String? = nil, + pageid: Swift.String? = nil) { self.sitemap = sitemap self.pageid = pageid } } - var query: Operations.getSitemapEvents_1.Input.Query + public var query: Operations.getSitemapEvents_1.Input.Query /// - Remark: Generated from `#/paths/sitemaps/events/{subscriptionid}/GET/header`. - struct Headers: Sendable, Hashable { - var accept: [OpenAPIRuntime.AcceptHeaderContentType] + public struct Headers: Sendable, Hashable { + public var accept: [OpenAPIRuntime.AcceptHeaderContentType] /// Creates a new `Headers`. /// /// - Parameters: /// - accept: - init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + public init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { self.accept = accept } } - var headers: Operations.getSitemapEvents_1.Input.Headers + public var headers: Operations.getSitemapEvents_1.Input.Headers /// Creates a new `Input`. /// /// - Parameters: /// - path: /// - query: /// - headers: - init(path: Operations.getSitemapEvents_1.Input.Path, - query: Operations.getSitemapEvents_1.Input.Query = .init(), - headers: Operations.getSitemapEvents_1.Input.Headers = .init()) { + public init(path: Operations.getSitemapEvents_1.Input.Path, + query: Operations.getSitemapEvents_1.Input.Query = .init(), + headers: Operations.getSitemapEvents_1.Input.Headers = .init()) { self.path = path self.query = query self.headers = headers } } - enum Output: Sendable, Hashable { - struct Ok: Sendable, Hashable { + @frozen public enum Output: Sendable, Hashable { + public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/sitemaps/events/{subscriptionid}/GET/responses/200/content`. - enum Body: Sendable, Hashable { + @frozen public enum Body: Sendable, Hashable { /// - Remark: Generated from `#/paths/sitemaps/events/{subscriptionid}/GET/responses/200/content/text\/event-stream`. case text_event_hyphen_stream(OpenAPIRuntime.HTTPBody) /// The associated value of the enum case if `self` is `.text_event_hyphen_stream`. /// /// - Throws: An error if `self` is not `.text_event_hyphen_stream`. /// - SeeAlso: `.text_event_hyphen_stream`. - var text_event_hyphen_stream: OpenAPIRuntime.HTTPBody { + public var text_event_hyphen_stream: OpenAPIRuntime.HTTPBody { get throws { switch self { case let .text_event_hyphen_stream(body): @@ -6154,7 +6154,7 @@ enum Operations { /// /// - Throws: An error if `self` is not `.json`. /// - SeeAlso: `.json`. - var json: Components.Schemas.SitemapWidgetEvent { + public var json: Components.Schemas.SitemapWidgetEvent { get throws { switch self { case let .json(body): @@ -6170,12 +6170,12 @@ enum Operations { } /// Received HTTP response body - var body: Operations.getSitemapEvents_1.Output.Ok.Body + public var body: Operations.getSitemapEvents_1.Output.Ok.Body /// Creates a new `Ok`. /// /// - Parameters: /// - body: Received HTTP response body - init(body: Operations.getSitemapEvents_1.Output.Ok.Body) { + public init(body: Operations.getSitemapEvents_1.Output.Ok.Body) { self.body = body } } @@ -6190,7 +6190,7 @@ enum Operations { /// /// - Throws: An error if `self` is not `.ok`. /// - SeeAlso: `.ok`. - var ok: Operations.getSitemapEvents_1.Output.Ok { + public var ok: Operations.getSitemapEvents_1.Output.Ok { get throws { switch self { case let .ok(response): @@ -6204,9 +6204,9 @@ enum Operations { } } - struct BadRequest: Sendable, Hashable { + public struct BadRequest: Sendable, Hashable { /// Creates a new `BadRequest`. - init() {} + public init() {} } /// Missing sitemap or page parameter, or page not linked successfully to the subscription. @@ -6219,7 +6219,7 @@ enum Operations { /// /// - Throws: An error if `self` is not `.badRequest`. /// - SeeAlso: `.badRequest`. - var badRequest: Operations.getSitemapEvents_1.Output.BadRequest { + public var badRequest: Operations.getSitemapEvents_1.Output.BadRequest { get throws { switch self { case let .badRequest(response): @@ -6233,9 +6233,9 @@ enum Operations { } } - struct NotFound: Sendable, Hashable { + public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. - init() {} + public init() {} } /// Subscription not found. @@ -6248,7 +6248,7 @@ enum Operations { /// /// - Throws: An error if `self` is not `.notFound`. /// - SeeAlso: `.notFound`. - var notFound: Operations.getSitemapEvents_1.Output.NotFound { + public var notFound: Operations.getSitemapEvents_1.Output.NotFound { get throws { switch self { case let .notFound(response): @@ -6268,11 +6268,11 @@ enum Operations { case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } - enum AcceptableContentType: AcceptableProtocol { + @frozen public enum AcceptableContentType: AcceptableProtocol { case text_event_hyphen_stream case json case other(Swift.String) - init?(rawValue: Swift.String) { + public init?(rawValue: Swift.String) { switch rawValue.lowercased() { case "text/event-stream": self = .text_event_hyphen_stream @@ -6283,7 +6283,7 @@ enum Operations { } } - var rawValue: Swift.String { + public var rawValue: Swift.String { switch self { case let .other(string): string @@ -6294,7 +6294,7 @@ enum Operations { } } - static var allCases: [Self] { + public static var allCases: [Self] { [ .text_event_hyphen_stream, .json @@ -6307,42 +6307,42 @@ enum Operations { /// /// - Remark: HTTP `GET /sitemaps`. /// - Remark: Generated from `#/paths//sitemaps/get(getSitemaps)`. - enum getSitemaps { - static let id: Swift.String = "getSitemaps" - struct Input: Sendable, Hashable { + public enum getSitemaps { + public static let id: Swift.String = "getSitemaps" + public struct Input: Sendable, Hashable { /// - Remark: Generated from `#/paths/sitemaps/GET/header`. - struct Headers: Sendable, Hashable { - var accept: [OpenAPIRuntime.AcceptHeaderContentType] + public struct Headers: Sendable, Hashable { + public var accept: [OpenAPIRuntime.AcceptHeaderContentType] /// Creates a new `Headers`. /// /// - Parameters: /// - accept: - init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + public init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { self.accept = accept } } - var headers: Operations.getSitemaps.Input.Headers + public var headers: Operations.getSitemaps.Input.Headers /// Creates a new `Input`. /// /// - Parameters: /// - headers: - init(headers: Operations.getSitemaps.Input.Headers = .init()) { + public init(headers: Operations.getSitemaps.Input.Headers = .init()) { self.headers = headers } } - enum Output: Sendable, Hashable { - struct Ok: Sendable, Hashable { + @frozen public enum Output: Sendable, Hashable { + public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/sitemaps/GET/responses/200/content`. - enum Body: Sendable, Hashable { + @frozen public enum Body: Sendable, Hashable { /// - Remark: Generated from `#/paths/sitemaps/GET/responses/200/content/application\/json`. case json([Components.Schemas.SitemapDTO]) /// The associated value of the enum case if `self` is `.json`. /// /// - Throws: An error if `self` is not `.json`. /// - SeeAlso: `.json`. - var json: [Components.Schemas.SitemapDTO] { + public var json: [Components.Schemas.SitemapDTO] { get throws { switch self { case let .json(body): @@ -6353,12 +6353,12 @@ enum Operations { } /// Received HTTP response body - var body: Operations.getSitemaps.Output.Ok.Body + public var body: Operations.getSitemaps.Output.Ok.Body /// Creates a new `Ok`. /// /// - Parameters: /// - body: Received HTTP response body - init(body: Operations.getSitemaps.Output.Ok.Body) { + public init(body: Operations.getSitemaps.Output.Ok.Body) { self.body = body } } @@ -6373,7 +6373,7 @@ enum Operations { /// /// - Throws: An error if `self` is not `.ok`. /// - SeeAlso: `.ok`. - var ok: Operations.getSitemaps.Output.Ok { + public var ok: Operations.getSitemaps.Output.Ok { get throws { switch self { case let .ok(response): @@ -6393,10 +6393,10 @@ enum Operations { case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } - enum AcceptableContentType: AcceptableProtocol { + @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) - init?(rawValue: Swift.String) { + public init?(rawValue: Swift.String) { switch rawValue.lowercased() { case "application/json": self = .json @@ -6405,7 +6405,7 @@ enum Operations { } } - var rawValue: Swift.String { + public var rawValue: Swift.String { switch self { case let .other(string): string @@ -6414,7 +6414,7 @@ enum Operations { } } - static var allCases: [Self] { + public static var allCases: [Self] { [ .json ] @@ -6426,78 +6426,78 @@ enum Operations { /// /// - Remark: HTTP `GET /ui/components/{namespace}`. /// - Remark: Generated from `#/paths//ui/components/{namespace}/get(getRegisteredUIComponentsInNamespace)`. - enum getRegisteredUIComponentsInNamespace { - static let id: Swift.String = "getRegisteredUIComponentsInNamespace" - struct Input: Sendable, Hashable { + public enum getRegisteredUIComponentsInNamespace { + public static let id: Swift.String = "getRegisteredUIComponentsInNamespace" + public struct Input: Sendable, Hashable { /// - Remark: Generated from `#/paths/ui/components/{namespace}/GET/path`. - struct Path: Sendable, Hashable { + public struct Path: Sendable, Hashable { /// - Remark: Generated from `#/paths/ui/components/{namespace}/GET/path/namespace`. - var namespace: Swift.String + public var namespace: Swift.String /// Creates a new `Path`. /// /// - Parameters: /// - namespace: - init(namespace: Swift.String) { + public init(namespace: Swift.String) { self.namespace = namespace } } - var path: Operations.getRegisteredUIComponentsInNamespace.Input.Path + public var path: Operations.getRegisteredUIComponentsInNamespace.Input.Path /// - Remark: Generated from `#/paths/ui/components/{namespace}/GET/query`. - struct Query: Sendable, Hashable { + public struct Query: Sendable, Hashable { /// summary fields only /// /// - Remark: Generated from `#/paths/ui/components/{namespace}/GET/query/summary`. - var summary: Swift.Bool? + public var summary: Swift.Bool? /// Creates a new `Query`. /// /// - Parameters: /// - summary: summary fields only - init(summary: Swift.Bool? = nil) { + public init(summary: Swift.Bool? = nil) { self.summary = summary } } - var query: Operations.getRegisteredUIComponentsInNamespace.Input.Query + public var query: Operations.getRegisteredUIComponentsInNamespace.Input.Query /// - Remark: Generated from `#/paths/ui/components/{namespace}/GET/header`. - struct Headers: Sendable, Hashable { - var accept: [OpenAPIRuntime.AcceptHeaderContentType] + public struct Headers: Sendable, Hashable { + public var accept: [OpenAPIRuntime.AcceptHeaderContentType] /// Creates a new `Headers`. /// /// - Parameters: /// - accept: - init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + public init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { self.accept = accept } } - var headers: Operations.getRegisteredUIComponentsInNamespace.Input.Headers + public var headers: Operations.getRegisteredUIComponentsInNamespace.Input.Headers /// Creates a new `Input`. /// /// - Parameters: /// - path: /// - query: /// - headers: - init(path: Operations.getRegisteredUIComponentsInNamespace.Input.Path, - query: Operations.getRegisteredUIComponentsInNamespace.Input.Query = .init(), - headers: Operations.getRegisteredUIComponentsInNamespace.Input.Headers = .init()) { + public init(path: Operations.getRegisteredUIComponentsInNamespace.Input.Path, + query: Operations.getRegisteredUIComponentsInNamespace.Input.Query = .init(), + headers: Operations.getRegisteredUIComponentsInNamespace.Input.Headers = .init()) { self.path = path self.query = query self.headers = headers } } - enum Output: Sendable, Hashable { - struct Ok: Sendable, Hashable { + @frozen public enum Output: Sendable, Hashable { + public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/ui/components/{namespace}/GET/responses/200/content`. - enum Body: Sendable, Hashable { + @frozen public enum Body: Sendable, Hashable { /// - Remark: Generated from `#/paths/ui/components/{namespace}/GET/responses/200/content/application\/json`. case json([Components.Schemas.RootUIComponent]) /// The associated value of the enum case if `self` is `.json`. /// /// - Throws: An error if `self` is not `.json`. /// - SeeAlso: `.json`. - var json: [Components.Schemas.RootUIComponent] { + public var json: [Components.Schemas.RootUIComponent] { get throws { switch self { case let .json(body): @@ -6508,12 +6508,12 @@ enum Operations { } /// Received HTTP response body - var body: Operations.getRegisteredUIComponentsInNamespace.Output.Ok.Body + public var body: Operations.getRegisteredUIComponentsInNamespace.Output.Ok.Body /// Creates a new `Ok`. /// /// - Parameters: /// - body: Received HTTP response body - init(body: Operations.getRegisteredUIComponentsInNamespace.Output.Ok.Body) { + public init(body: Operations.getRegisteredUIComponentsInNamespace.Output.Ok.Body) { self.body = body } } @@ -6528,7 +6528,7 @@ enum Operations { /// /// - Throws: An error if `self` is not `.ok`. /// - SeeAlso: `.ok`. - var ok: Operations.getRegisteredUIComponentsInNamespace.Output.Ok { + public var ok: Operations.getRegisteredUIComponentsInNamespace.Output.Ok { get throws { switch self { case let .ok(response): @@ -6548,10 +6548,10 @@ enum Operations { case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } - enum AcceptableContentType: AcceptableProtocol { + @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) - init?(rawValue: Swift.String) { + public init?(rawValue: Swift.String) { switch rawValue.lowercased() { case "application/json": self = .json @@ -6560,7 +6560,7 @@ enum Operations { } } - var rawValue: Swift.String { + public var rawValue: Swift.String { switch self { case let .other(string): string @@ -6569,7 +6569,7 @@ enum Operations { } } - static var allCases: [Self] { + public static var allCases: [Self] { [ .json ] @@ -6581,69 +6581,69 @@ enum Operations { /// /// - Remark: HTTP `POST /ui/components/{namespace}`. /// - Remark: Generated from `#/paths//ui/components/{namespace}/post(addUIComponentToNamespace)`. - enum addUIComponentToNamespace { - static let id: Swift.String = "addUIComponentToNamespace" - struct Input: Sendable, Hashable { + public enum addUIComponentToNamespace { + public static let id: Swift.String = "addUIComponentToNamespace" + public struct Input: Sendable, Hashable { /// - Remark: Generated from `#/paths/ui/components/{namespace}/POST/path`. - struct Path: Sendable, Hashable { + public struct Path: Sendable, Hashable { /// - Remark: Generated from `#/paths/ui/components/{namespace}/POST/path/namespace`. - var namespace: Swift.String + public var namespace: Swift.String /// Creates a new `Path`. /// /// - Parameters: /// - namespace: - init(namespace: Swift.String) { + public init(namespace: Swift.String) { self.namespace = namespace } } - var path: Operations.addUIComponentToNamespace.Input.Path + public var path: Operations.addUIComponentToNamespace.Input.Path /// - Remark: Generated from `#/paths/ui/components/{namespace}/POST/header`. - struct Headers: Sendable, Hashable { - var accept: [OpenAPIRuntime.AcceptHeaderContentType] + public struct Headers: Sendable, Hashable { + public var accept: [OpenAPIRuntime.AcceptHeaderContentType] /// Creates a new `Headers`. /// /// - Parameters: /// - accept: - init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + public init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { self.accept = accept } } - var headers: Operations.addUIComponentToNamespace.Input.Headers + public var headers: Operations.addUIComponentToNamespace.Input.Headers /// - Remark: Generated from `#/paths/ui/components/{namespace}/POST/requestBody`. - enum Body: Sendable, Hashable { + @frozen public enum Body: Sendable, Hashable { /// - Remark: Generated from `#/paths/ui/components/{namespace}/POST/requestBody/content/application\/json`. case json(Components.Schemas.RootUIComponent) } - var body: Operations.addUIComponentToNamespace.Input.Body? + public var body: Operations.addUIComponentToNamespace.Input.Body? /// Creates a new `Input`. /// /// - Parameters: /// - path: /// - headers: /// - body: - init(path: Operations.addUIComponentToNamespace.Input.Path, - headers: Operations.addUIComponentToNamespace.Input.Headers = .init(), - body: Operations.addUIComponentToNamespace.Input.Body? = nil) { + public init(path: Operations.addUIComponentToNamespace.Input.Path, + headers: Operations.addUIComponentToNamespace.Input.Headers = .init(), + body: Operations.addUIComponentToNamespace.Input.Body? = nil) { self.path = path self.headers = headers self.body = body } } - enum Output: Sendable, Hashable { - struct Ok: Sendable, Hashable { + @frozen public enum Output: Sendable, Hashable { + public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/ui/components/{namespace}/POST/responses/200/content`. - enum Body: Sendable, Hashable { + @frozen public enum Body: Sendable, Hashable { /// - Remark: Generated from `#/paths/ui/components/{namespace}/POST/responses/200/content/application\/json`. case json(Components.Schemas.RootUIComponent) /// The associated value of the enum case if `self` is `.json`. /// /// - Throws: An error if `self` is not `.json`. /// - SeeAlso: `.json`. - var json: Components.Schemas.RootUIComponent { + public var json: Components.Schemas.RootUIComponent { get throws { switch self { case let .json(body): @@ -6654,12 +6654,12 @@ enum Operations { } /// Received HTTP response body - var body: Operations.addUIComponentToNamespace.Output.Ok.Body + public var body: Operations.addUIComponentToNamespace.Output.Ok.Body /// Creates a new `Ok`. /// /// - Parameters: /// - body: Received HTTP response body - init(body: Operations.addUIComponentToNamespace.Output.Ok.Body) { + public init(body: Operations.addUIComponentToNamespace.Output.Ok.Body) { self.body = body } } @@ -6674,7 +6674,7 @@ enum Operations { /// /// - Throws: An error if `self` is not `.ok`. /// - SeeAlso: `.ok`. - var ok: Operations.addUIComponentToNamespace.Output.Ok { + public var ok: Operations.addUIComponentToNamespace.Output.Ok { get throws { switch self { case let .ok(response): @@ -6694,10 +6694,10 @@ enum Operations { case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } - enum AcceptableContentType: AcceptableProtocol { + @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) - init?(rawValue: Swift.String) { + public init?(rawValue: Swift.String) { switch rawValue.lowercased() { case "application/json": self = .json @@ -6706,7 +6706,7 @@ enum Operations { } } - var rawValue: Swift.String { + public var rawValue: Swift.String { switch self { case let .other(string): string @@ -6715,7 +6715,7 @@ enum Operations { } } - static var allCases: [Self] { + public static var allCases: [Self] { [ .json ] @@ -6727,64 +6727,64 @@ enum Operations { /// /// - Remark: HTTP `GET /ui/components/{namespace}/{componentUID}`. /// - Remark: Generated from `#/paths//ui/components/{namespace}/{componentUID}/get(getUIComponentInNamespace)`. - enum getUIComponentInNamespace { - static let id: Swift.String = "getUIComponentInNamespace" - struct Input: Sendable, Hashable { + public enum getUIComponentInNamespace { + public static let id: Swift.String = "getUIComponentInNamespace" + public struct Input: Sendable, Hashable { /// - Remark: Generated from `#/paths/ui/components/{namespace}/{componentUID}/GET/path`. - struct Path: Sendable, Hashable { + public struct Path: Sendable, Hashable { /// - Remark: Generated from `#/paths/ui/components/{namespace}/{componentUID}/GET/path/namespace`. - var namespace: Swift.String + public var namespace: Swift.String /// - Remark: Generated from `#/paths/ui/components/{namespace}/{componentUID}/GET/path/componentUID`. - var componentUID: Swift.String + public var componentUID: Swift.String /// Creates a new `Path`. /// /// - Parameters: /// - namespace: /// - componentUID: - init(namespace: Swift.String, - componentUID: Swift.String) { + public init(namespace: Swift.String, + componentUID: Swift.String) { self.namespace = namespace self.componentUID = componentUID } } - var path: Operations.getUIComponentInNamespace.Input.Path + public var path: Operations.getUIComponentInNamespace.Input.Path /// - Remark: Generated from `#/paths/ui/components/{namespace}/{componentUID}/GET/header`. - struct Headers: Sendable, Hashable { - var accept: [OpenAPIRuntime.AcceptHeaderContentType] + public struct Headers: Sendable, Hashable { + public var accept: [OpenAPIRuntime.AcceptHeaderContentType] /// Creates a new `Headers`. /// /// - Parameters: /// - accept: - init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + public init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { self.accept = accept } } - var headers: Operations.getUIComponentInNamespace.Input.Headers + public var headers: Operations.getUIComponentInNamespace.Input.Headers /// Creates a new `Input`. /// /// - Parameters: /// - path: /// - headers: - init(path: Operations.getUIComponentInNamespace.Input.Path, - headers: Operations.getUIComponentInNamespace.Input.Headers = .init()) { + public init(path: Operations.getUIComponentInNamespace.Input.Path, + headers: Operations.getUIComponentInNamespace.Input.Headers = .init()) { self.path = path self.headers = headers } } - enum Output: Sendable, Hashable { - struct Ok: Sendable, Hashable { + @frozen public enum Output: Sendable, Hashable { + public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/ui/components/{namespace}/{componentUID}/GET/responses/200/content`. - enum Body: Sendable, Hashable { + @frozen public enum Body: Sendable, Hashable { /// - Remark: Generated from `#/paths/ui/components/{namespace}/{componentUID}/GET/responses/200/content/application\/json`. case json(Components.Schemas.RootUIComponent) /// The associated value of the enum case if `self` is `.json`. /// /// - Throws: An error if `self` is not `.json`. /// - SeeAlso: `.json`. - var json: Components.Schemas.RootUIComponent { + public var json: Components.Schemas.RootUIComponent { get throws { switch self { case let .json(body): @@ -6795,12 +6795,12 @@ enum Operations { } /// Received HTTP response body - var body: Operations.getUIComponentInNamespace.Output.Ok.Body + public var body: Operations.getUIComponentInNamespace.Output.Ok.Body /// Creates a new `Ok`. /// /// - Parameters: /// - body: Received HTTP response body - init(body: Operations.getUIComponentInNamespace.Output.Ok.Body) { + public init(body: Operations.getUIComponentInNamespace.Output.Ok.Body) { self.body = body } } @@ -6815,7 +6815,7 @@ enum Operations { /// /// - Throws: An error if `self` is not `.ok`. /// - SeeAlso: `.ok`. - var ok: Operations.getUIComponentInNamespace.Output.Ok { + public var ok: Operations.getUIComponentInNamespace.Output.Ok { get throws { switch self { case let .ok(response): @@ -6829,9 +6829,9 @@ enum Operations { } } - struct NotFound: Sendable, Hashable { + public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. - init() {} + public init() {} } /// Component not found @@ -6844,7 +6844,7 @@ enum Operations { /// /// - Throws: An error if `self` is not `.notFound`. /// - SeeAlso: `.notFound`. - var notFound: Operations.getUIComponentInNamespace.Output.NotFound { + public var notFound: Operations.getUIComponentInNamespace.Output.NotFound { get throws { switch self { case let .notFound(response): @@ -6864,10 +6864,10 @@ enum Operations { case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } - enum AcceptableContentType: AcceptableProtocol { + @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) - init?(rawValue: Swift.String) { + public init?(rawValue: Swift.String) { switch rawValue.lowercased() { case "application/json": self = .json @@ -6876,7 +6876,7 @@ enum Operations { } } - var rawValue: Swift.String { + public var rawValue: Swift.String { switch self { case let .other(string): string @@ -6885,7 +6885,7 @@ enum Operations { } } - static var allCases: [Self] { + public static var allCases: [Self] { [ .json ] @@ -6897,74 +6897,74 @@ enum Operations { /// /// - Remark: HTTP `PUT /ui/components/{namespace}/{componentUID}`. /// - Remark: Generated from `#/paths//ui/components/{namespace}/{componentUID}/put(updateUIComponentInNamespace)`. - enum updateUIComponentInNamespace { - static let id: Swift.String = "updateUIComponentInNamespace" - struct Input: Sendable, Hashable { + public enum updateUIComponentInNamespace { + public static let id: Swift.String = "updateUIComponentInNamespace" + public struct Input: Sendable, Hashable { /// - Remark: Generated from `#/paths/ui/components/{namespace}/{componentUID}/PUT/path`. - struct Path: Sendable, Hashable { + public struct Path: Sendable, Hashable { /// - Remark: Generated from `#/paths/ui/components/{namespace}/{componentUID}/PUT/path/namespace`. - var namespace: Swift.String + public var namespace: Swift.String /// - Remark: Generated from `#/paths/ui/components/{namespace}/{componentUID}/PUT/path/componentUID`. - var componentUID: Swift.String + public var componentUID: Swift.String /// Creates a new `Path`. /// /// - Parameters: /// - namespace: /// - componentUID: - init(namespace: Swift.String, - componentUID: Swift.String) { + public init(namespace: Swift.String, + componentUID: Swift.String) { self.namespace = namespace self.componentUID = componentUID } } - var path: Operations.updateUIComponentInNamespace.Input.Path + public var path: Operations.updateUIComponentInNamespace.Input.Path /// - Remark: Generated from `#/paths/ui/components/{namespace}/{componentUID}/PUT/header`. - struct Headers: Sendable, Hashable { - var accept: [OpenAPIRuntime.AcceptHeaderContentType] + public struct Headers: Sendable, Hashable { + public var accept: [OpenAPIRuntime.AcceptHeaderContentType] /// Creates a new `Headers`. /// /// - Parameters: /// - accept: - init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + public init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { self.accept = accept } } - var headers: Operations.updateUIComponentInNamespace.Input.Headers + public var headers: Operations.updateUIComponentInNamespace.Input.Headers /// - Remark: Generated from `#/paths/ui/components/{namespace}/{componentUID}/PUT/requestBody`. - enum Body: Sendable, Hashable { + @frozen public enum Body: Sendable, Hashable { /// - Remark: Generated from `#/paths/ui/components/{namespace}/{componentUID}/PUT/requestBody/content/application\/json`. case json(Components.Schemas.RootUIComponent) } - var body: Operations.updateUIComponentInNamespace.Input.Body? + public var body: Operations.updateUIComponentInNamespace.Input.Body? /// Creates a new `Input`. /// /// - Parameters: /// - path: /// - headers: /// - body: - init(path: Operations.updateUIComponentInNamespace.Input.Path, - headers: Operations.updateUIComponentInNamespace.Input.Headers = .init(), - body: Operations.updateUIComponentInNamespace.Input.Body? = nil) { + public init(path: Operations.updateUIComponentInNamespace.Input.Path, + headers: Operations.updateUIComponentInNamespace.Input.Headers = .init(), + body: Operations.updateUIComponentInNamespace.Input.Body? = nil) { self.path = path self.headers = headers self.body = body } } - enum Output: Sendable, Hashable { - struct Ok: Sendable, Hashable { + @frozen public enum Output: Sendable, Hashable { + public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/ui/components/{namespace}/{componentUID}/PUT/responses/200/content`. - enum Body: Sendable, Hashable { + @frozen public enum Body: Sendable, Hashable { /// - Remark: Generated from `#/paths/ui/components/{namespace}/{componentUID}/PUT/responses/200/content/application\/json`. case json(Components.Schemas.RootUIComponent) /// The associated value of the enum case if `self` is `.json`. /// /// - Throws: An error if `self` is not `.json`. /// - SeeAlso: `.json`. - var json: Components.Schemas.RootUIComponent { + public var json: Components.Schemas.RootUIComponent { get throws { switch self { case let .json(body): @@ -6975,12 +6975,12 @@ enum Operations { } /// Received HTTP response body - var body: Operations.updateUIComponentInNamespace.Output.Ok.Body + public var body: Operations.updateUIComponentInNamespace.Output.Ok.Body /// Creates a new `Ok`. /// /// - Parameters: /// - body: Received HTTP response body - init(body: Operations.updateUIComponentInNamespace.Output.Ok.Body) { + public init(body: Operations.updateUIComponentInNamespace.Output.Ok.Body) { self.body = body } } @@ -6995,7 +6995,7 @@ enum Operations { /// /// - Throws: An error if `self` is not `.ok`. /// - SeeAlso: `.ok`. - var ok: Operations.updateUIComponentInNamespace.Output.Ok { + public var ok: Operations.updateUIComponentInNamespace.Output.Ok { get throws { switch self { case let .ok(response): @@ -7009,9 +7009,9 @@ enum Operations { } } - struct NotFound: Sendable, Hashable { + public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. - init() {} + public init() {} } /// Component not found @@ -7024,7 +7024,7 @@ enum Operations { /// /// - Throws: An error if `self` is not `.notFound`. /// - SeeAlso: `.notFound`. - var notFound: Operations.updateUIComponentInNamespace.Output.NotFound { + public var notFound: Operations.updateUIComponentInNamespace.Output.NotFound { get throws { switch self { case let .notFound(response): @@ -7044,10 +7044,10 @@ enum Operations { case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } - enum AcceptableContentType: AcceptableProtocol { + @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) - init?(rawValue: Swift.String) { + public init?(rawValue: Swift.String) { switch rawValue.lowercased() { case "application/json": self = .json @@ -7056,7 +7056,7 @@ enum Operations { } } - var rawValue: Swift.String { + public var rawValue: Swift.String { switch self { case let .other(string): string @@ -7065,7 +7065,7 @@ enum Operations { } } - static var allCases: [Self] { + public static var allCases: [Self] { [ .json ] @@ -7077,41 +7077,41 @@ enum Operations { /// /// - Remark: HTTP `DELETE /ui/components/{namespace}/{componentUID}`. /// - Remark: Generated from `#/paths//ui/components/{namespace}/{componentUID}/delete(removeUIComponentFromNamespace)`. - enum removeUIComponentFromNamespace { - static let id: Swift.String = "removeUIComponentFromNamespace" - struct Input: Sendable, Hashable { + public enum removeUIComponentFromNamespace { + public static let id: Swift.String = "removeUIComponentFromNamespace" + public struct Input: Sendable, Hashable { /// - Remark: Generated from `#/paths/ui/components/{namespace}/{componentUID}/DELETE/path`. - struct Path: Sendable, Hashable { + public struct Path: Sendable, Hashable { /// - Remark: Generated from `#/paths/ui/components/{namespace}/{componentUID}/DELETE/path/namespace`. - var namespace: Swift.String + public var namespace: Swift.String /// - Remark: Generated from `#/paths/ui/components/{namespace}/{componentUID}/DELETE/path/componentUID`. - var componentUID: Swift.String + public var componentUID: Swift.String /// Creates a new `Path`. /// /// - Parameters: /// - namespace: /// - componentUID: - init(namespace: Swift.String, - componentUID: Swift.String) { + public init(namespace: Swift.String, + componentUID: Swift.String) { self.namespace = namespace self.componentUID = componentUID } } - var path: Operations.removeUIComponentFromNamespace.Input.Path + public var path: Operations.removeUIComponentFromNamespace.Input.Path /// Creates a new `Input`. /// /// - Parameters: /// - path: - init(path: Operations.removeUIComponentFromNamespace.Input.Path) { + public init(path: Operations.removeUIComponentFromNamespace.Input.Path) { self.path = path } } - enum Output: Sendable, Hashable { - struct Ok: Sendable, Hashable { + @frozen public enum Output: Sendable, Hashable { + public struct Ok: Sendable, Hashable { /// Creates a new `Ok`. - init() {} + public init() {} } /// OK @@ -7124,7 +7124,7 @@ enum Operations { /// /// - Throws: An error if `self` is not `.ok`. /// - SeeAlso: `.ok`. - var ok: Operations.removeUIComponentFromNamespace.Output.Ok { + public var ok: Operations.removeUIComponentFromNamespace.Output.Ok { get throws { switch self { case let .ok(response): @@ -7138,9 +7138,9 @@ enum Operations { } } - struct NotFound: Sendable, Hashable { + public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. - init() {} + public init() {} } /// Component not found @@ -7153,7 +7153,7 @@ enum Operations { /// /// - Throws: An error if `self` is not `.notFound`. /// - SeeAlso: `.notFound`. - var notFound: Operations.removeUIComponentFromNamespace.Output.NotFound { + public var notFound: Operations.removeUIComponentFromNamespace.Output.NotFound { get throws { switch self { case let .notFound(response): @@ -7178,42 +7178,42 @@ enum Operations { /// /// - Remark: HTTP `GET /ui/tiles`. /// - Remark: Generated from `#/paths//ui/tiles/get(getUITiles)`. - enum getUITiles { - static let id: Swift.String = "getUITiles" - struct Input: Sendable, Hashable { + public enum getUITiles { + public static let id: Swift.String = "getUITiles" + public struct Input: Sendable, Hashable { /// - Remark: Generated from `#/paths/ui/tiles/GET/header`. - struct Headers: Sendable, Hashable { - var accept: [OpenAPIRuntime.AcceptHeaderContentType] + public struct Headers: Sendable, Hashable { + public var accept: [OpenAPIRuntime.AcceptHeaderContentType] /// Creates a new `Headers`. /// /// - Parameters: /// - accept: - init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + public init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { self.accept = accept } } - var headers: Operations.getUITiles.Input.Headers + public var headers: Operations.getUITiles.Input.Headers /// Creates a new `Input`. /// /// - Parameters: /// - headers: - init(headers: Operations.getUITiles.Input.Headers = .init()) { + public init(headers: Operations.getUITiles.Input.Headers = .init()) { self.headers = headers } } - enum Output: Sendable, Hashable { - struct Ok: Sendable, Hashable { + @frozen public enum Output: Sendable, Hashable { + public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/ui/tiles/GET/responses/200/content`. - enum Body: Sendable, Hashable { + @frozen public enum Body: Sendable, Hashable { /// - Remark: Generated from `#/paths/ui/tiles/GET/responses/200/content/application\/json`. case json([Components.Schemas.TileDTO]) /// The associated value of the enum case if `self` is `.json`. /// /// - Throws: An error if `self` is not `.json`. /// - SeeAlso: `.json`. - var json: [Components.Schemas.TileDTO] { + public var json: [Components.Schemas.TileDTO] { get throws { switch self { case let .json(body): @@ -7224,12 +7224,12 @@ enum Operations { } /// Received HTTP response body - var body: Operations.getUITiles.Output.Ok.Body + public var body: Operations.getUITiles.Output.Ok.Body /// Creates a new `Ok`. /// /// - Parameters: /// - body: Received HTTP response body - init(body: Operations.getUITiles.Output.Ok.Body) { + public init(body: Operations.getUITiles.Output.Ok.Body) { self.body = body } } @@ -7244,7 +7244,7 @@ enum Operations { /// /// - Throws: An error if `self` is not `.ok`. /// - SeeAlso: `.ok`. - var ok: Operations.getUITiles.Output.Ok { + public var ok: Operations.getUITiles.Output.Ok { get throws { switch self { case let .ok(response): @@ -7264,10 +7264,10 @@ enum Operations { case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } - enum AcceptableContentType: AcceptableProtocol { + @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) - init?(rawValue: Swift.String) { + public init?(rawValue: Swift.String) { switch rawValue.lowercased() { case "application/json": self = .json @@ -7276,7 +7276,7 @@ enum Operations { } } - var rawValue: Swift.String { + public var rawValue: Swift.String { switch self { case let .other(string): string @@ -7285,7 +7285,7 @@ enum Operations { } } - static var allCases: [Self] { + public static var allCases: [Self] { [ .json ] diff --git a/OpenHABCore/Sources/OpenHABCore/Util/APIActor.swift b/OpenHABCore/Sources/OpenHABCore/Util/APIActor.swift index 0083c97b3..d292058a4 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/APIActor.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/APIActor.swift @@ -130,16 +130,14 @@ public extension APIActor { return URL(string: urlString)?.lastPathComponent } - func openHABSitemapWidgetEvents(subscriptionid: String, sitemap: String) async throws -> AsyncThrowingCompactMapSequence>, OpenHABSitemapWidgetEvent> { + // Will need swift 6.0 SE-0421 to return an opaque sequence + func openHABSitemapWidgetEvents(subscriptionid: String, sitemap: String) async throws -> AsyncCompactMapSequence>, ServerSentEventWithJSONData>, OpenHABSitemapWidgetEvent> { let path = Operations.getSitemapEvents_1.Input.Path(subscriptionid: subscriptionid) let query = Operations.getSitemapEvents_1.Input.Query(sitemap: sitemap, pageid: sitemap) - let decodedSequence = try await api.getSitemapEvents_1(path: path, query: query).ok.body.text_event_hyphen_stream.asDecodedServerSentEvents() - let opaqueSequence = decodedSequence.compactMap { (event) in - if let data = event.data { - try JSONDecoder().decode(OpenHABSitemapWidgetEvent.CodingData.self, from: Data(data.utf8)).openHABSitemapWidgetEvent - } else { nil } - } - return opaqueSequence + let decodedSequence = try await api.getSitemapEvents_1(path: path, query: query) + .ok.body.text_event_hyphen_stream + .asDecodedServerSentEventsWithJSONData(of: Components.Schemas.SitemapWidgetEvent.self) + return decodedSequence.compactMap { OpenHABSitemapWidgetEvent($0.data) } } } diff --git a/OpenHABCore/Sources/OpenHABCore/openapi/openapi-generator-config.yml b/OpenHABCore/Sources/OpenHABCore/openapi/openapi-generator-config.yml index 3a3188b22..4d0407ff7 100644 --- a/OpenHABCore/Sources/OpenHABCore/openapi/openapi-generator-config.yml +++ b/OpenHABCore/Sources/OpenHABCore/openapi/openapi-generator-config.yml @@ -1,7 +1,7 @@ generate: - types - client -accessModifier: internal +accessModifier: public filter: tags: - sitemaps diff --git a/openHAB/OpenHABSitemapViewController.swift b/openHAB/OpenHABSitemapViewController.swift index 0056255a8..10ad666c5 100644 --- a/openHAB/OpenHABSitemapViewController.swift +++ b/openHAB/OpenHABSitemapViewController.swift @@ -338,6 +338,7 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel if let subscriptionid = try await apiactor.openHABcreateSubscription() { logger.log("Got subscriptionid: \(subscriptionid)") let sitemap = try await apiactor.openHABpollSitemap(sitemapname: defaultSitemap, longPolling: longPolling, subscriptionId: subscriptionid) + currentPage = sitemap?.page let events = try await apiactor.openHABSitemapWidgetEvents(subscriptionid: subscriptionid, sitemap: defaultSitemap) for try await event in events { print(event) From c07b57e395f83d78661af6311dc5dcf6765bee4a Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Sun, 15 Sep 2024 15:53:08 +0200 Subject: [PATCH 008/476] Move extensions for models from APIActor.swift to respective files Port api load to DrawerView --- .../OpenHABCore/Model/CGFloatExtension.swift | 24 ++ .../Model/OpenHABCommandDescription.swift | 10 + .../Model/OpenHABCommandOptions.swift | 10 + .../OpenHABCore/Model/OpenHABItem.swift | 13 +- .../OpenHABCore/Model/OpenHABOptions.swift | 10 + .../OpenHABCore/Model/OpenHABPage.swift | 17 + .../OpenHABCore/Model/OpenHABSitemap.swift | 44 +-- .../Model/OpenHABSitemapWidgetEvent.swift | 93 ++++++ .../Model/OpenHABStateDescription.swift | 10 + .../OpenHABCore/Model/OpenHABUiTile.swift | 10 +- .../OpenHABCore/Model/OpenHABWidget.swift | 53 +++ .../Model/OpenHABWidgetMapping.swift | 6 + .../Sources/OpenHABCore/Util/APIActor.swift | 301 +----------------- .../OpenHABCore/Util/APIActorDelegate.swift | 76 +++++ openHAB/DrawerView.swift | 98 ++---- 15 files changed, 353 insertions(+), 422 deletions(-) create mode 100644 OpenHABCore/Sources/OpenHABCore/Model/CGFloatExtension.swift create mode 100644 OpenHABCore/Sources/OpenHABCore/Model/OpenHABSitemapWidgetEvent.swift create mode 100644 OpenHABCore/Sources/OpenHABCore/Util/APIActorDelegate.swift diff --git a/OpenHABCore/Sources/OpenHABCore/Model/CGFloatExtension.swift b/OpenHABCore/Sources/OpenHABCore/Model/CGFloatExtension.swift new file mode 100644 index 000000000..97d447875 --- /dev/null +++ b/OpenHABCore/Sources/OpenHABCore/Model/CGFloatExtension.swift @@ -0,0 +1,24 @@ +// 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 Foundation + +extension CGFloat { + init(state string: String, divisor: Float) { + let numberFormatter = NumberFormatter() + numberFormatter.locale = Locale(identifier: "US") + if let number = numberFormatter.number(from: string) { + self.init(number.floatValue / divisor) + } else { + self.init(0) + } + } +} diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABCommandDescription.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABCommandDescription.swift index ce8eff968..1b96cb5f0 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABCommandDescription.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABCommandDescription.swift @@ -30,3 +30,13 @@ extension OpenHABCommandDescription.CodingData { OpenHABCommandDescription(commandOptions: commandOptions) } } + +extension OpenHABCommandDescription { + convenience init?(_ commands: Components.Schemas.CommandDescription?) { + if let commands { + self.init(commandOptions: commands.commandOptions?.compactMap { OpenHABCommandOptions($0) }) + } else { + return nil + } + } +} diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABCommandOptions.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABCommandOptions.swift index 3ace621ab..383a1a75f 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABCommandOptions.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABCommandOptions.swift @@ -20,3 +20,13 @@ public class OpenHABCommandOptions: Decodable { self.label = label } } + +extension OpenHABCommandOptions { + convenience init?(_ options: Components.Schemas.CommandOption?) { + if let options { + self.init(command: options.command.orEmpty, label: options.label.orEmpty) + } else { + return nil + } + } +} diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABItem.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABItem.swift index 175f4ef69..7da0e102d 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABItem.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABItem.swift @@ -148,14 +148,13 @@ public extension OpenHABItem.CodingData { } } -extension CGFloat { - init(state string: String, divisor: Float) { - let numberFormatter = NumberFormatter() - numberFormatter.locale = Locale(identifier: "US") - if let number = numberFormatter.number(from: string) { - self.init(number.floatValue / divisor) +extension OpenHABItem { + convenience init?(_ item: Components.Schemas.EnrichedItemDTO?) { + if let item { + // swiftlint:disable:next line_length + self.init(name: item.name.orEmpty, type: item._type.orEmpty, state: item.state.orEmpty, link: item.link.orEmpty, label: item.label.orEmpty, groupType: nil, stateDescription: OpenHABStateDescription(item.stateDescription), commandDescription: OpenHABCommandDescription(item.commandDescription), members: [], category: item.category, options: []) } else { - self.init(0) + return nil } } } diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABOptions.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABOptions.swift index d9a9983e9..620a84581 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABOptions.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABOptions.swift @@ -20,3 +20,13 @@ public class OpenHABOptions: Decodable { self.label = label } } + +extension OpenHABOptions { + convenience init?(_ options: Components.Schemas.StateOption?) { + if let options { + self.init(value: options.value.orEmpty, label: options.label.orEmpty) + } else { + return nil + } + } +} diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABPage.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABPage.swift index 0c9632dfc..27b2c8fd1 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABPage.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABPage.swift @@ -86,3 +86,20 @@ public extension OpenHABPage.CodingData { return OpenHABPage(pageId: pageId.orEmpty, title: title.orEmpty, link: link.orEmpty, leaf: leaf ?? false, widgets: mappedWidgets, icon: icon.orEmpty) } } + +extension OpenHABPage { + convenience init?(_ page: Components.Schemas.PageDTO?) { + if let page { + self.init( + pageId: page.id.orEmpty, + title: page.title.orEmpty, + link: page.link.orEmpty, + leaf: page.leaf ?? false, + widgets: page.widgets?.compactMap { OpenHABWidget($0) } ?? [], + icon: page.icon.orEmpty + ) + } else { + return nil + } + } +} diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABSitemap.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABSitemap.swift index 00a03e867..8f654bcd2 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABSitemap.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABSitemap.swift @@ -51,42 +51,14 @@ public final class OpenHABSitemap: NSObject { } } -public extension OpenHABSitemap { - struct CodingData: Decodable { - public let name: String - public let label: String - public let page: OpenHABPage.CodingData? - public let link: String - public let icon: String? - - private enum CodingKeys: String, CodingKey { - case page = "homepage" - case name - case label - case link - case icon - } - - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - - name = try container.decode(forKey: .name) - label = try container.decode(forKey: .label, default: name) - page = try container.decode(forKey: .page) - link = try container.decode(forKey: .link) - icon = try container.decodeIfPresent(forKey: .icon) - } - } -} - -public extension OpenHABSitemap.CodingData { - var openHABSitemap: OpenHABSitemap { - OpenHABSitemap( - name: name, - icon: icon.orEmpty, - label: label, - link: link, - page: page?.openHABSitemapPage +extension OpenHABSitemap { + convenience init(_ sitemap: Components.Schemas.SitemapDTO) { + self.init( + name: sitemap.name.orEmpty, + icon: sitemap.icon.orEmpty, + label: sitemap.label.orEmpty, + link: sitemap.link.orEmpty, + page: OpenHABPage(sitemap.homepage) ) } } diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABSitemapWidgetEvent.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABSitemapWidgetEvent.swift new file mode 100644 index 000000000..224471036 --- /dev/null +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABSitemapWidgetEvent.swift @@ -0,0 +1,93 @@ +// 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 Foundation + +public class OpenHABSitemapWidgetEvent { + var sitemapName: String? + var pageId: String? + var widgetId: String? + var label: String? + var labelSource: String? + var icon: String? + var reloadIcon: Bool? + var labelcolor: String? + var valuecolor: String? + var iconcolor: String? + var visibility: Bool? + var state: String? + var enrichedItem: OpenHABItem? + var descriptionChanged: Bool? + + init(sitemapName: String? = nil, pageId: String? = nil, widgetId: String? = nil, label: String? = nil, labelSource: String? = nil, icon: String? = nil, reloadIcon: Bool? = nil, labelcolor: String? = nil, valuecolor: String? = nil, iconcolor: String? = nil, visibility: Bool? = nil, state: String? = nil, enrichedItem: OpenHABItem? = nil, descriptionChanged: Bool? = nil) { + self.sitemapName = sitemapName + self.pageId = pageId + self.widgetId = widgetId + self.label = label + self.labelSource = labelSource + self.icon = icon + self.reloadIcon = reloadIcon + self.labelcolor = labelcolor + self.valuecolor = valuecolor + self.iconcolor = iconcolor + self.visibility = visibility + self.state = state + self.enrichedItem = enrichedItem + self.descriptionChanged = descriptionChanged + } + + convenience init?(_ event: Components.Schemas.SitemapWidgetEvent?) { + guard let event else { return nil } + // swiftlint:disable:next line_length + self.init(sitemapName: event.sitemapName, pageId: event.pageId, widgetId: event.widgetId, label: event.label, labelSource: event.labelSource, icon: event.icon, reloadIcon: event.reloadIcon, labelcolor: event.labelcolor, valuecolor: event.valuecolor, iconcolor: event.iconcolor, visibility: event.visibility, state: event.state, enrichedItem: OpenHABItem(event.item), descriptionChanged: event.descriptionChanged) + } +} + +extension OpenHABSitemapWidgetEvent: CustomStringConvertible { + public var description: String { + "\(widgetId ?? "") \(label ?? "") \(enrichedItem?.state ?? "")" + } +} + +public extension OpenHABSitemapWidgetEvent { + struct CodingData: Decodable, Hashable, Equatable { + public static func == (lhs: OpenHABSitemapWidgetEvent.CodingData, rhs: OpenHABSitemapWidgetEvent.CodingData) -> Bool { + lhs.widgetId == rhs.widgetId + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(widgetId) + } + + var sitemapName: String? + var pageId: String? + var widgetId: String? + var label: String? + var labelSource: String? + var icon: String? + var reloadIcon: Bool? + var labelcolor: String? + var valuecolor: String? + var iconcolor: String? + var visibility: Bool? +// var state: String? + var item: OpenHABItem.CodingData? + var descriptionChanged: Bool? + var link: String? + } +} + +extension OpenHABSitemapWidgetEvent.CodingData { + var openHABSitemapWidgetEvent: OpenHABSitemapWidgetEvent { + // swiftlint:disable:next line_length + OpenHABSitemapWidgetEvent(sitemapName: sitemapName, pageId: pageId, widgetId: widgetId, label: label, labelSource: labelSource, icon: icon, reloadIcon: reloadIcon, labelcolor: labelcolor, valuecolor: valuecolor, iconcolor: iconcolor, visibility: visibility, enrichedItem: item?.openHABItem, descriptionChanged: descriptionChanged) + } +} diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABStateDescription.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABStateDescription.swift index 439812c84..9e17b3c97 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABStateDescription.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABStateDescription.swift @@ -59,3 +59,13 @@ extension OpenHABStateDescription.CodingData { OpenHABStateDescription(minimum: minimum, maximum: maximum, step: step, readOnly: readOnly, options: options, pattern: pattern) } } + +extension OpenHABStateDescription { + convenience init?(_ state: Components.Schemas.StateDescription?) { + if let state { + self.init(minimum: state.minimum, maximum: state.maximum, step: state.step, readOnly: state.readOnly, options: state.options?.compactMap { OpenHABOptions($0) }, pattern: state.pattern) + } else { + return nil + } + } +} diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABUiTile.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABUiTile.swift index ffac41eee..06705597f 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABUiTile.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABUiTile.swift @@ -11,7 +11,7 @@ import Foundation -public class OpenHABUiTile: Decodable { +public class OpenHABUiTile { public var name = "" public var url = "" public var imageUrl = "" @@ -23,10 +23,8 @@ public class OpenHABUiTile: Decodable { } } -public extension OpenHABUiTile { - struct CodingData: Decodable { - public let name: String - public let url: String - public let imageUrl: String +extension OpenHABUiTile { + convenience init(_ tile: Components.Schemas.TileDTO) { + self.init(name: tile.name.orEmpty, url: tile.url.orEmpty, imageUrl: tile.imageUrl.orEmpty) } } diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift index a7c4dc847..8e5afd055 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift @@ -305,3 +305,56 @@ extension [OpenHABWidget] { } } } + +extension OpenHABWidget { + convenience init(_ widget: Components.Schemas.WidgetDTO) { + self.init( + widgetId: widget.widgetId.orEmpty, + label: widget.label.orEmpty, + icon: widget.icon.orEmpty, + type: OpenHABWidget.WidgetType(rawValue: widget._type!), + url: widget.url, + period: widget.period, + minValue: widget.minValue, + maxValue: widget.maxValue, + step: widget.step, + refresh: widget.refresh.map(Int.init), + height: 50, // TODO: + isLeaf: true, + iconColor: widget.iconcolor, + labelColor: widget.labelcolor, + valueColor: widget.valuecolor, + service: widget.service, + state: widget.state, + text: "", + legend: widget.legend, + encoding: widget.encoding, + item: OpenHABItem(widget.item), + linkedPage: OpenHABPage(widget.linkedPage), + mappings: widget.mappings?.compactMap(OpenHABWidgetMapping.init) ?? [], + widgets: widget.widgets?.compactMap { OpenHABWidget($0) } ?? [], + visibility: widget.visibility, + switchSupport: widget.switchSupport, + forceAsItem: widget.forceAsItem + ) + } +} + +extension OpenHABWidget { + func update(with event: OpenHABSitemapWidgetEvent) { + state = event.state ?? state + icon = event.icon ?? icon + label = event.label ?? label + iconColor = event.iconcolor ?? "" + labelcolor = event.labelcolor ?? "" + valuecolor = event.valuecolor ?? "" + visibility = event.visibility ?? visibility + + if let enrichedItem = event.enrichedItem { + if let link = item?.link { + enrichedItem.link = link + } + item = enrichedItem + } + } +} diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidgetMapping.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidgetMapping.swift index 6d2d1499f..81775c25a 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidgetMapping.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidgetMapping.swift @@ -20,3 +20,9 @@ public class OpenHABWidgetMapping: NSObject, Decodable { self.label = label.orEmpty } } + +extension OpenHABWidgetMapping { + convenience init(_ mapping: Components.Schemas.MappingDTO) { + self.init(command: mapping.command, label: mapping.label) + } +} diff --git a/OpenHABCore/Sources/OpenHABCore/Util/APIActor.swift b/OpenHABCore/Sources/OpenHABCore/Util/APIActor.swift index 841341770..e2d1b4ea0 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/APIActor.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/APIActor.swift @@ -23,7 +23,9 @@ public protocol OpenHABUiTileService { func openHABTiles() async throws -> [OpenHABUiTile] } -// swiftlint:disable file_types_order +public enum APIActorError: Error { + case undocumented +} public actor APIActor { var api: APIProtocol @@ -99,10 +101,6 @@ public actor APIActor { } } -public enum APIActorError: Error { - case undocumented -} - extension APIActor: OpenHABSitemapsService { public func openHABSitemaps() async throws -> [OpenHABSitemap] { // swiftformat:disable:next redundantSelf @@ -209,296 +207,3 @@ public extension APIActor { _ = try response.ok } } - -// MARK: - URLSessionDelegate for Client Certificates and Basic Auth - -class APIActorDelegate: NSObject, URLSessionDelegate, URLSessionTaskDelegate { - private let username: String - private let password: String - - init(username: String, password: String) { - self.username = username - self.password = password - } - - public func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { - await urlSessionInternal(session, task: nil, didReceive: challenge) - } - - public func urlSession(_ session: URLSession, task: URLSessionTask, didReceive challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { - await urlSessionInternal(session, task: task, didReceive: challenge) - } - - private func urlSessionInternal(_ session: URLSession, task: URLSessionTask?, didReceive challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { - os_log("URLAuthenticationChallenge: %{public}@", log: .networking, type: .info, challenge.protectionSpace.authenticationMethod) - let authenticationMethod = challenge.protectionSpace.authenticationMethod - switch authenticationMethod { - case NSURLAuthenticationMethodServerTrust: - return await handleServerTrust(challenge: challenge) - case NSURLAuthenticationMethodDefault, NSURLAuthenticationMethodHTTPBasic: - if let task { - task.authAttemptCount += 1 - if task.authAttemptCount > 1 { - return (.cancelAuthenticationChallenge, nil) - } else { - return await handleBasicAuth(challenge: challenge) - } - } else { - return await handleBasicAuth(challenge: challenge) - } - case NSURLAuthenticationMethodClientCertificate: - return await handleClientCertificateAuth(challenge: challenge) - default: - return (.performDefaultHandling, nil) - } - } - - private func handleServerTrust(challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { - guard let serverTrust = challenge.protectionSpace.serverTrust else { - return (.performDefaultHandling, nil) - } - let credential = URLCredential(trust: serverTrust) - return (.useCredential, credential) - } - - private func handleBasicAuth(challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { - let credential = URLCredential(user: username, password: password, persistence: .forSession) - return (.useCredential, credential) - } - - private func handleClientCertificateAuth(challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { - let certificateManager = ClientCertificateManager() - let (disposition, credential) = certificateManager.evaluateTrust(with: challenge) - return (disposition, credential) - } -} - -extension OpenHABWidget { - func update(with event: OpenHABSitemapWidgetEvent) { - state = event.state ?? state - icon = event.icon ?? icon - label = event.label ?? label - iconColor = event.iconcolor ?? "" - labelcolor = event.labelcolor ?? "" - valuecolor = event.valuecolor ?? "" - visibility = event.visibility ?? visibility - - if let enrichedItem = event.enrichedItem { - if let link = item?.link { - enrichedItem.link = link - } - item = enrichedItem - } - } -} - -public class OpenHABSitemapWidgetEvent { - var sitemapName: String? - var pageId: String? - var widgetId: String? - var label: String? - var labelSource: String? - var icon: String? - var reloadIcon: Bool? - var labelcolor: String? - var valuecolor: String? - var iconcolor: String? - var visibility: Bool? - var state: String? - var enrichedItem: OpenHABItem? - var descriptionChanged: Bool? - - init(sitemapName: String? = nil, pageId: String? = nil, widgetId: String? = nil, label: String? = nil, labelSource: String? = nil, icon: String? = nil, reloadIcon: Bool? = nil, labelcolor: String? = nil, valuecolor: String? = nil, iconcolor: String? = nil, visibility: Bool? = nil, state: String? = nil, enrichedItem: OpenHABItem? = nil, descriptionChanged: Bool? = nil) { - self.sitemapName = sitemapName - self.pageId = pageId - self.widgetId = widgetId - self.label = label - self.labelSource = labelSource - self.icon = icon - self.reloadIcon = reloadIcon - self.labelcolor = labelcolor - self.valuecolor = valuecolor - self.iconcolor = iconcolor - self.visibility = visibility - self.state = state - self.enrichedItem = enrichedItem - self.descriptionChanged = descriptionChanged - } - - convenience init?(_ event: Components.Schemas.SitemapWidgetEvent?) { - guard let event else { return nil } - // swiftlint:disable:next line_length - self.init(sitemapName: event.sitemapName, pageId: event.pageId, widgetId: event.widgetId, label: event.label, labelSource: event.labelSource, icon: event.icon, reloadIcon: event.reloadIcon, labelcolor: event.labelcolor, valuecolor: event.valuecolor, iconcolor: event.iconcolor, visibility: event.visibility, state: event.state, enrichedItem: OpenHABItem(event.item), descriptionChanged: event.descriptionChanged) - } -} - -extension OpenHABSitemapWidgetEvent: CustomStringConvertible { - public var description: String { - "\(widgetId ?? "") \(label ?? "") \(enrichedItem?.state ?? "")" - } -} - -public extension OpenHABSitemapWidgetEvent { - struct CodingData: Decodable, Hashable, Equatable { - public static func == (lhs: OpenHABSitemapWidgetEvent.CodingData, rhs: OpenHABSitemapWidgetEvent.CodingData) -> Bool { - lhs.widgetId == rhs.widgetId - } - - public func hash(into hasher: inout Hasher) { - hasher.combine(widgetId) - } - - var sitemapName: String? - var pageId: String? - var widgetId: String? - var label: String? - var labelSource: String? - var icon: String? - var reloadIcon: Bool? - var labelcolor: String? - var valuecolor: String? - var iconcolor: String? - var visibility: Bool? -// var state: String? - var item: OpenHABItem.CodingData? - var descriptionChanged: Bool? - var link: String? - } -} - -extension OpenHABSitemapWidgetEvent.CodingData { - var openHABSitemapWidgetEvent: OpenHABSitemapWidgetEvent { - // swiftlint:disable:next line_length - OpenHABSitemapWidgetEvent(sitemapName: sitemapName, pageId: pageId, widgetId: widgetId, label: label, labelSource: labelSource, icon: icon, reloadIcon: reloadIcon, labelcolor: labelcolor, valuecolor: valuecolor, iconcolor: iconcolor, visibility: visibility, enrichedItem: item?.openHABItem, descriptionChanged: descriptionChanged) - } -} - -extension OpenHABUiTile { - convenience init(_ tile: Components.Schemas.TileDTO) { - self.init(name: tile.name.orEmpty, url: tile.url.orEmpty, imageUrl: tile.imageUrl.orEmpty) - } -} - -extension OpenHABSitemap { - convenience init(_ sitemap: Components.Schemas.SitemapDTO) { - self.init( - name: sitemap.name.orEmpty, - icon: sitemap.icon.orEmpty, - label: sitemap.label.orEmpty, - link: sitemap.link.orEmpty, - page: OpenHABPage(sitemap.homepage) - ) - } -} - -extension OpenHABPage { - convenience init?(_ page: Components.Schemas.PageDTO?) { - if let page { - self.init( - pageId: page.id.orEmpty, - title: page.title.orEmpty, - link: page.link.orEmpty, - leaf: page.leaf ?? false, - widgets: page.widgets?.compactMap { OpenHABWidget($0) } ?? [], - icon: page.icon.orEmpty - ) - } else { - return nil - } - } -} - -extension OpenHABWidgetMapping { - convenience init(_ mapping: Components.Schemas.MappingDTO) { - self.init(command: mapping.command, label: mapping.label) - } -} - -extension OpenHABCommandOptions { - convenience init?(_ options: Components.Schemas.CommandOption?) { - if let options { - self.init(command: options.command.orEmpty, label: options.label.orEmpty) - } else { - return nil - } - } -} - -extension OpenHABOptions { - convenience init?(_ options: Components.Schemas.StateOption?) { - if let options { - self.init(value: options.value.orEmpty, label: options.label.orEmpty) - } else { - return nil - } - } -} - -extension OpenHABStateDescription { - convenience init?(_ state: Components.Schemas.StateDescription?) { - if let state { - self.init(minimum: state.minimum, maximum: state.maximum, step: state.step, readOnly: state.readOnly, options: state.options?.compactMap { OpenHABOptions($0) }, pattern: state.pattern) - } else { - return nil - } - } -} - -extension OpenHABCommandDescription { - convenience init?(_ commands: Components.Schemas.CommandDescription?) { - if let commands { - self.init(commandOptions: commands.commandOptions?.compactMap { OpenHABCommandOptions($0) }) - } else { - return nil - } - } -} - -// swiftlint:disable line_length -extension OpenHABItem { - convenience init?(_ item: Components.Schemas.EnrichedItemDTO?) { - if let item { - self.init(name: item.name.orEmpty, type: item._type.orEmpty, state: item.state.orEmpty, link: item.link.orEmpty, label: item.label.orEmpty, groupType: nil, stateDescription: OpenHABStateDescription(item.stateDescription), commandDescription: OpenHABCommandDescription(item.commandDescription), members: [], category: item.category, options: []) - } else { - return nil - } - } -} - -// swiftlint:enable line_length - -extension OpenHABWidget { - convenience init(_ widget: Components.Schemas.WidgetDTO) { - self.init( - widgetId: widget.widgetId.orEmpty, - label: widget.label.orEmpty, - icon: widget.icon.orEmpty, - type: OpenHABWidget.WidgetType(rawValue: widget._type!), - url: widget.url, - period: widget.period, - minValue: widget.minValue, - maxValue: widget.maxValue, - step: widget.step, - refresh: widget.refresh.map(Int.init), - height: 50, // TODO: - isLeaf: true, - iconColor: widget.iconcolor, - labelColor: widget.labelcolor, - valueColor: widget.valuecolor, - service: widget.service, - state: widget.state, - text: "", - legend: widget.legend, - encoding: widget.encoding, - item: OpenHABItem(widget.item), - linkedPage: OpenHABPage(widget.linkedPage), - mappings: widget.mappings?.compactMap(OpenHABWidgetMapping.init) ?? [], - widgets: widget.widgets?.compactMap { OpenHABWidget($0) } ?? [], - visibility: widget.visibility, - switchSupport: widget.switchSupport, - forceAsItem: widget.forceAsItem - ) - } -} - -// swiftlint:enable file_types_order diff --git a/OpenHABCore/Sources/OpenHABCore/Util/APIActorDelegate.swift b/OpenHABCore/Sources/OpenHABCore/Util/APIActorDelegate.swift new file mode 100644 index 000000000..f56482259 --- /dev/null +++ b/OpenHABCore/Sources/OpenHABCore/Util/APIActorDelegate.swift @@ -0,0 +1,76 @@ +// 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 Foundation +import os + +// MARK: - URLSessionDelegate for Client Certificates and Basic Auth + +class APIActorDelegate: NSObject, URLSessionDelegate, URLSessionTaskDelegate { + private let username: String + private let password: String + + init(username: String, password: String) { + self.username = username + self.password = password + } + + public func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { + await urlSessionInternal(session, task: nil, didReceive: challenge) + } + + public func urlSession(_ session: URLSession, task: URLSessionTask, didReceive challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { + await urlSessionInternal(session, task: task, didReceive: challenge) + } + + private func urlSessionInternal(_ session: URLSession, task: URLSessionTask?, didReceive challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { + os_log("URLAuthenticationChallenge: %{public}@", log: .networking, type: .info, challenge.protectionSpace.authenticationMethod) + let authenticationMethod = challenge.protectionSpace.authenticationMethod + switch authenticationMethod { + case NSURLAuthenticationMethodServerTrust: + return await handleServerTrust(challenge: challenge) + case NSURLAuthenticationMethodDefault, NSURLAuthenticationMethodHTTPBasic: + if let task { + task.authAttemptCount += 1 + if task.authAttemptCount > 1 { + return (.cancelAuthenticationChallenge, nil) + } else { + return await handleBasicAuth(challenge: challenge) + } + } else { + return await handleBasicAuth(challenge: challenge) + } + case NSURLAuthenticationMethodClientCertificate: + return await handleClientCertificateAuth(challenge: challenge) + default: + return (.performDefaultHandling, nil) + } + } + + private func handleServerTrust(challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { + guard let serverTrust = challenge.protectionSpace.serverTrust else { + return (.performDefaultHandling, nil) + } + let credential = URLCredential(trust: serverTrust) + return (.useCredential, credential) + } + + private func handleBasicAuth(challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { + let credential = URLCredential(user: username, password: password, persistence: .forSession) + return (.useCredential, credential) + } + + private func handleClientCertificateAuth(challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { + let certificateManager = ClientCertificateManager() + let (disposition, credential) = certificateManager.evaluateTrust(with: challenge) + return (disposition, credential) + } +} diff --git a/openHAB/DrawerView.swift b/openHAB/DrawerView.swift index 291428a1d..2faf84407 100644 --- a/openHAB/DrawerView.swift +++ b/openHAB/DrawerView.swift @@ -16,31 +16,6 @@ import SafariServices import SFSafeSymbols import SwiftUI -func deriveSitemaps(_ response: Data?) -> [OpenHABSitemap] { - var sitemaps = [OpenHABSitemap]() - - if let response { - do { - os_log("Response will be decoded by JSON", log: .remoteAccess, type: .info) - let sitemapsCodingData = try response.decoded(as: [OpenHABSitemap.CodingData].self) - for sitemapCodingDatum in sitemapsCodingData { - os_log("Sitemap %{PUBLIC}@", log: .remoteAccess, type: .info, sitemapCodingDatum.label) - sitemaps.append(sitemapCodingDatum.openHABSitemap) - } - } catch { - os_log("Should not throw %{PUBLIC}@", log: .notifications, type: .error, error.localizedDescription) - } - } - - return sitemaps -} - -struct UiTile: Decodable { - var name: String - var url: String - var imageUrl: String -} - struct ImageView: View { let url: String @@ -167,67 +142,40 @@ struct DrawerView: View { } } .listStyle(.inset) - .onAppear(perform: loadData) - } - - private func loadData() { - // TODO: Replace network calls with appropriate @EnvironmentObject or other state management - loadSitemaps() - loadUiTiles() - } - - private func loadSitemaps() { - // Perform network call to load sitemaps and decode - // Update the sitemaps state - - NetworkConnection.sitemaps(openHABRootUrl: appData?.openHABRootUrl ?? "") { response in - switch response.result { - case let .success(data): - os_log("Sitemap response", log: .viewCycle, type: .info) - - sitemaps = deriveSitemaps(data) + .task { + let apiactor = await APIActor() + Task { + do { + await apiactor.updateBaseURL(with: URL(string: appData?.openHABRootUrl ?? "")!) - if sitemaps.last?.name == "_default", sitemaps.count > 1 { - sitemaps = Array(sitemaps.dropLast()) - } + sitemaps = try await apiactor.openHABSitemaps() + if sitemaps.last?.name == "_default", sitemaps.count > 1 { + sitemaps = Array(sitemaps.dropLast()) + } + // Sort the sitemaps according to Settings selection. + switch SortSitemapsOrder(rawValue: Preferences.sortSitemapsby) ?? .label { + case .label: sitemaps.sort { $0.label < $1.label } + case .name: sitemaps.sort { $0.name < $1.name } + } - // Sort the sitemaps according to Settings selection. - switch SortSitemapsOrder(rawValue: Preferences.sortSitemapsby) ?? .label { - case .label: sitemaps.sort { $0.label < $1.label } - case .name: sitemaps.sort { $0.name < $1.name } + } catch { + os_log("%{PUBLIC}@", log: .default, type: .error, error.localizedDescription) + sitemaps = [] } - case let .failure(error): - os_log("%{PUBLIC}@", log: .default, type: .error, error.localizedDescription) } - } - } - private func loadUiTiles() { - // Perform network call to load UI Tiles and decode - // Update the uiTiles state - NetworkConnection.uiTiles(openHABRootUrl: appData?.openHABRootUrl ?? "") { response in - switch response.result { - case .success: - os_log("ui tiles response", log: .viewCycle, type: .info) - guard let responseData = response.data else { - os_log("Error: did not receive data", log: OSLog.remoteAccess, type: .info) - return - } + Task { do { - uiTiles = try JSONDecoder().decode([OpenHABUiTile].self, from: responseData) + await apiactor.updateBaseURL(with: URL(string: appData?.openHABRootUrl ?? "")!) + uiTiles = try await apiactor.openHABTiles() + os_log("ui tiles response", log: .viewCycle, type: .info) } catch { - os_log("Error: did not receive data %{PUBLIC}@", log: OSLog.remoteAccess, type: .info, error.localizedDescription) + os_log("%{PUBLIC}@", log: .default, type: .error, error.localizedDescription) + uiTiles = [] } - case let .failure(error): - os_log("%{PUBLIC}@", log: .default, type: .error, error.localizedDescription) } } } - - mutating func loadSettings() { - openHABUsername = Preferences.username - openHABPassword = Preferences.password - } } #Preview { From 4ec2b7e0de5522c4746b70d55c2a1d3f8890e541 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Sun, 15 Sep 2024 20:38:37 +0200 Subject: [PATCH 009/476] Reusing OpenHABWidget for Watch. Allows to remove complexity. --- .../OpenHABCore/Model/OpenHABWidget.swift | 119 ++++++++++++------ openHAB/OpenHABSitemapViewController.swift | 2 +- .../Views/ContentView.swift | 2 +- .../Views/Rows/ColorPickerRow.swift | 3 +- .../Views/Rows/FrameRow.swift | 3 +- .../Views/Rows/GenericRow.swift | 3 +- .../Views/Rows/ImageRawRow.swift | 2 +- .../Views/Rows/MapViewRow.swift | 3 +- .../Views/Rows/RollershutterRow.swift | 3 +- .../Views/Rows/SegmentRow.swift | 2 +- .../Views/Rows/SetpointRow.swift | 3 +- .../Views/Rows/SliderRow.swift | 2 +- .../Rows/SliderWithSwitchSupportRow.swift | 2 +- .../Views/Rows/SwitchRow.swift | 2 +- .../Views/Utils/DetailTextLabelView.swift | 3 +- .../Views/Utils/IconView.swift | 2 +- .../Views/Utils/MapView.swift | 6 +- .../Views/Utils/TextLabelView.swift | 3 +- .../Model/ObservableOpenHABSitemapPage.swift | 12 +- .../Model/ObservableOpenHABWidget.swift | 26 +--- .../ObservableOpenHABWidgetExtension.swift | 3 +- .../openHABWatch Extension/UserData.swift | 2 +- 22 files changed, 120 insertions(+), 88 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift index 8e5afd055..b1ae4b5a2 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift @@ -14,41 +14,29 @@ import Foundation import MapKit import os.log -protocol Widget: AnyObject { - // Recursive constraints possible as of Swift 4.1 - associatedtype ChildWidget: Widget - - var sendCommand: ((_ item: OpenHABItem, _ command: String?) -> Void)? { get set } - var widgetId: String { get set } - var label: String { get set } - var icon: String { get set } - var type: String { get set } - var url: String { get set } - var period: String { get set } - var minValue: Double { get set } - var maxValue: Double { get set } - var step: Double { get set } - var refresh: Int { get set } - var height: Double { get set } - var isLeaf: Bool { get set } - var iconColor: String { get set } - var labelcolor: String { get set } - var valuecolor: String { get set } - var service: String { get set } - var state: String { get set } - var text: String { get set } - var legend: Bool { get set } - var encoding: String { get set } - var item: OpenHABItem? { get set } - var linkedPage: OpenHABPage? { get set } - var mappings: [OpenHABWidgetMapping] { get set } - var image: UIImage? { get set } - var widgets: [ChildWidget] { get set } - - func flatten(_: [ChildWidget]) +public enum WidgetTypeEnum { + case switcher(Bool) + case slider // + case segmented(Int) + case unassigned + case rollershutter + case frame + case setpoint + case selection + case colorpicker + case chart + case image + case video + case webview + case mapview + + public var boolState: Bool { + guard case let .switcher(value) = self else { return false } + return value + } } -public class OpenHABWidget: NSObject, MKAnnotation, Identifiable { +public class OpenHABWidget: NSObject, MKAnnotation, Identifiable, ObservableObject { public enum WidgetType: String { case chart = "Chart" case colorpicker = "Colorpicker" @@ -71,7 +59,7 @@ public class OpenHABWidget: NSObject, MKAnnotation, Identifiable { public var sendCommand: ((_ item: OpenHABItem, _ command: String?) -> Void)? public var widgetId = "" - public var label = "" + @Published public var label = "" public var icon = "" public var type: WidgetType? public var url = "" @@ -86,12 +74,12 @@ public class OpenHABWidget: NSObject, MKAnnotation, Identifiable { public var labelcolor = "" public var valuecolor = "" public var service = "" - public var state = "" + @Published public var state = "" public var text = "" public var legend: Bool? public var encoding = "" public var forceAsItem: Bool? - public var item: OpenHABItem? + @Published public var item: OpenHABItem? public var linkedPage: OpenHABPage? public var mappings: [OpenHABWidgetMapping] = [] public var image: UIImage? @@ -99,6 +87,8 @@ public class OpenHABWidget: NSObject, MKAnnotation, Identifiable { public var visibility = true public var switchSupport = false + @Published public var stateEnumBinding: WidgetTypeEnum = .unassigned + // Text prior to "[" public var labelText: String? { let array = label.components(separatedBy: "[") @@ -142,6 +132,54 @@ public class OpenHABWidget: NSObject, MKAnnotation, Identifiable { item?.state?.parseAsNumber(format: item?.stateDescription?.numberPattern) } + public var adjustedValue: Double { + if let item { + adj(item.stateAsDouble()) + } else { + minValue + } + } + + public var stateEnum: WidgetTypeEnum { + switch type { + case .frame: + .frame + case .switchWidget: + // Reflecting the discussion held in https://github.com/openhab/openhab-core/issues/952 + if !mappings.isEmpty { + .segmented(Int(mappingIndex(byCommand: item?.state) ?? -1)) + } else if item?.isOfTypeOrGroupType(.switchItem) ?? false { + .switcher(item?.state == "ON" ? true : false) + } else if item?.isOfTypeOrGroupType(.rollershutter) ?? false { + .rollershutter + } else if !mappingsOrItemOptions.isEmpty { + .segmented(Int(mappingIndex(byCommand: item?.state) ?? -1)) + } else { + .switcher(item?.state == "ON" ? true : false) + } + case .setpoint: + .setpoint + case .slider: + .slider + case .selection: + .selection + case .colorpicker: + .colorpicker + case .chart: + .chart + case .image: + .image + case .video: + .video + case .webview: + .webview + case .mapview: + .mapview + default: + .unassigned + } + } + public func sendItemUpdate(state: NumberState?) { guard let item, let state else { os_log("ItemUpdate for Item or State = nil", log: .default, type: .info) @@ -202,6 +240,12 @@ public class OpenHABWidget: NSObject, MKAnnotation, Identifiable { } return iconState } + + private func adj(_ raw: Double) -> Double { + var valueAdjustedToStep = floor((raw - minValue) / step) * step + valueAdjustedToStep += minValue + return valueAdjustedToStep.clamped(to: minValue ... maxValue) + } } extension OpenHABWidget.WidgetType: Decodable {} @@ -252,6 +296,7 @@ public extension OpenHABWidget { self.switchSupport = switchSupport ?? false self.forceAsItem = forceAsItem + stateEnumBinding = stateEnum } } @@ -288,7 +333,7 @@ public extension OpenHABWidget { } } -extension OpenHABWidget.CodingData { +public extension OpenHABWidget.CodingData { var openHABWidget: OpenHABWidget { let mappedWidgets = widgets.map(\.openHABWidget) // swiftlint:disable:next line_length diff --git a/openHAB/OpenHABSitemapViewController.swift b/openHAB/OpenHABSitemapViewController.swift index 6402917f9..0cb1a136c 100644 --- a/openHAB/OpenHABSitemapViewController.swift +++ b/openHAB/OpenHABSitemapViewController.swift @@ -419,7 +419,7 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel default: break } widgetTableView.reloadData() - } catch let error as APIActorError { + } catch _ as APIActorError { logger.debug("APIActorError on OpenHABSitemapViewController") } catch { os_log("%{PUBLIC}@", log: .default, type: .error, error.localizedDescription) diff --git a/openHABWatch Extension/Views/ContentView.swift b/openHABWatch Extension/Views/ContentView.swift index 893b78c88..984ba4ee7 100644 --- a/openHABWatch Extension/Views/ContentView.swift +++ b/openHABWatch Extension/Views/ContentView.swift @@ -58,7 +58,7 @@ struct ContentView: View { } // https://www.swiftbysundell.com/tips/adding-swiftui-viewbuilder-to-functions/ - @ViewBuilder func rowWidget(widget: ObservableOpenHABWidget) -> some View { + @ViewBuilder func rowWidget(widget: OpenHABWidget) -> some View { switch widget.stateEnum { case .switcher: SwitchRow(widget: widget) diff --git a/openHABWatch Extension/Views/Rows/ColorPickerRow.swift b/openHABWatch Extension/Views/Rows/ColorPickerRow.swift index 86ded1134..48cf9b73a 100644 --- a/openHABWatch Extension/Views/Rows/ColorPickerRow.swift +++ b/openHABWatch Extension/Views/Rows/ColorPickerRow.swift @@ -9,11 +9,12 @@ // // SPDX-License-Identifier: EPL-2.0 +import OpenHABCore import os.log import SwiftUI struct ColorPickerRow: View { - @ObservedObject var widget: ObservableOpenHABWidget + @ObservedObject var widget: OpenHABWidget @ObservedObject var settings = ObservableOpenHABDataObject.shared var body: some View { let uiColor = widget.item?.stateAsUIColor() diff --git a/openHABWatch Extension/Views/Rows/FrameRow.swift b/openHABWatch Extension/Views/Rows/FrameRow.swift index 1048b6c91..fe8d76113 100644 --- a/openHABWatch Extension/Views/Rows/FrameRow.swift +++ b/openHABWatch Extension/Views/Rows/FrameRow.swift @@ -9,10 +9,11 @@ // // SPDX-License-Identifier: EPL-2.0 +import OpenHABCore import SwiftUI struct FrameRow: View { - @ObservedObject var widget: ObservableOpenHABWidget + @ObservedObject var widget: OpenHABWidget @ObservedObject var settings = ObservableOpenHABDataObject.shared var body: some View { let gray = Color(UIColor.darkGray) diff --git a/openHABWatch Extension/Views/Rows/GenericRow.swift b/openHABWatch Extension/Views/Rows/GenericRow.swift index e8bf6c46a..46c667735 100644 --- a/openHABWatch Extension/Views/Rows/GenericRow.swift +++ b/openHABWatch Extension/Views/Rows/GenericRow.swift @@ -9,11 +9,12 @@ // // SPDX-License-Identifier: EPL-2.0 +import OpenHABCore import os.log import SwiftUI struct GenericRow: View { - @ObservedObject var widget: ObservableOpenHABWidget + @ObservedObject var widget: OpenHABWidget @ObservedObject var settings = ObservableOpenHABDataObject.shared var body: some View { diff --git a/openHABWatch Extension/Views/Rows/ImageRawRow.swift b/openHABWatch Extension/Views/Rows/ImageRawRow.swift index 77869abaa..43dd82c3e 100644 --- a/openHABWatch Extension/Views/Rows/ImageRawRow.swift +++ b/openHABWatch Extension/Views/Rows/ImageRawRow.swift @@ -15,7 +15,7 @@ import os.log import SwiftUI struct ImageRawRow: View { - @ObservedObject var widget: ObservableOpenHABWidget + @ObservedObject var widget: OpenHABWidget @ObservedObject var settings = ObservableOpenHABDataObject.shared var body: some View { diff --git a/openHABWatch Extension/Views/Rows/MapViewRow.swift b/openHABWatch Extension/Views/Rows/MapViewRow.swift index dfb178b83..ddbba15ac 100644 --- a/openHABWatch Extension/Views/Rows/MapViewRow.swift +++ b/openHABWatch Extension/Views/Rows/MapViewRow.swift @@ -9,10 +9,11 @@ // // SPDX-License-Identifier: EPL-2.0 +import OpenHABCore import SwiftUI struct MapViewRow: View { - @ObservedObject var widget: ObservableOpenHABWidget + @ObservedObject var widget: OpenHABWidget @ObservedObject var settings = ObservableOpenHABDataObject.shared var body: some View { diff --git a/openHABWatch Extension/Views/Rows/RollershutterRow.swift b/openHABWatch Extension/Views/Rows/RollershutterRow.swift index f68974e04..45f5e49a2 100644 --- a/openHABWatch Extension/Views/Rows/RollershutterRow.swift +++ b/openHABWatch Extension/Views/Rows/RollershutterRow.swift @@ -9,10 +9,11 @@ // // SPDX-License-Identifier: EPL-2.0 +import OpenHABCore import SwiftUI struct RollershutterRow: View { - @ObservedObject var widget: ObservableOpenHABWidget + @ObservedObject var widget: OpenHABWidget @ObservedObject var settings = ObservableOpenHABDataObject.shared var body: some View { diff --git a/openHABWatch Extension/Views/Rows/SegmentRow.swift b/openHABWatch Extension/Views/Rows/SegmentRow.swift index 3ec61e2a3..e38ded4c0 100644 --- a/openHABWatch Extension/Views/Rows/SegmentRow.swift +++ b/openHABWatch Extension/Views/Rows/SegmentRow.swift @@ -14,7 +14,7 @@ import os.log import SwiftUI struct SegmentRow: View { - @ObservedObject var widget: ObservableOpenHABWidget + @ObservedObject var widget: OpenHABWidget @ObservedObject var settings = ObservableOpenHABDataObject.shared @State private var favoriteColor = 0 diff --git a/openHABWatch Extension/Views/Rows/SetpointRow.swift b/openHABWatch Extension/Views/Rows/SetpointRow.swift index a63cda591..c2fd6fd33 100644 --- a/openHABWatch Extension/Views/Rows/SetpointRow.swift +++ b/openHABWatch Extension/Views/Rows/SetpointRow.swift @@ -9,11 +9,12 @@ // // SPDX-License-Identifier: EPL-2.0 +import OpenHABCore import os.log import SwiftUI struct SetpointRow: View { - @ObservedObject var widget: ObservableOpenHABWidget + @ObservedObject var widget: OpenHABWidget @ObservedObject var settings = ObservableOpenHABDataObject.shared private var isIntStep: Bool { diff --git a/openHABWatch Extension/Views/Rows/SliderRow.swift b/openHABWatch Extension/Views/Rows/SliderRow.swift index 44cb1ae17..261908c3b 100644 --- a/openHABWatch Extension/Views/Rows/SliderRow.swift +++ b/openHABWatch Extension/Views/Rows/SliderRow.swift @@ -14,7 +14,7 @@ import os.log import SwiftUI struct SliderRow: View { - @ObservedObject var widget: ObservableOpenHABWidget + @ObservedObject var widget: OpenHABWidget @ObservedObject var settings = ObservableOpenHABDataObject.shared var body: some View { diff --git a/openHABWatch Extension/Views/Rows/SliderWithSwitchSupportRow.swift b/openHABWatch Extension/Views/Rows/SliderWithSwitchSupportRow.swift index af0fea029..63b5abc02 100644 --- a/openHABWatch Extension/Views/Rows/SliderWithSwitchSupportRow.swift +++ b/openHABWatch Extension/Views/Rows/SliderWithSwitchSupportRow.swift @@ -14,7 +14,7 @@ import os.log import SwiftUI struct SliderWithSwitchSupportRow: View { - @ObservedObject var widget: ObservableOpenHABWidget + @ObservedObject var widget: OpenHABWidget @ObservedObject var settings = ObservableOpenHABDataObject.shared var body: some View { diff --git a/openHABWatch Extension/Views/Rows/SwitchRow.swift b/openHABWatch Extension/Views/Rows/SwitchRow.swift index a593a42e2..f3f393406 100644 --- a/openHABWatch Extension/Views/Rows/SwitchRow.swift +++ b/openHABWatch Extension/Views/Rows/SwitchRow.swift @@ -15,7 +15,7 @@ import os.log import SwiftUI struct SwitchRow: View { - @ObservedObject var widget: ObservableOpenHABWidget + @ObservedObject var widget: OpenHABWidget @ObservedObject var settings = ObservableOpenHABDataObject.shared var body: some View { diff --git a/openHABWatch Extension/Views/Utils/DetailTextLabelView.swift b/openHABWatch Extension/Views/Utils/DetailTextLabelView.swift index a6a0d84a2..65f10ce5c 100644 --- a/openHABWatch Extension/Views/Utils/DetailTextLabelView.swift +++ b/openHABWatch Extension/Views/Utils/DetailTextLabelView.swift @@ -9,10 +9,11 @@ // // SPDX-License-Identifier: EPL-2.0 +import OpenHABCore import SwiftUI struct DetailTextLabelView: View { - @ObservedObject var widget: ObservableOpenHABWidget + @ObservedObject var widget: OpenHABWidget var body: some View { Unwrap(widget.labelValue) { diff --git a/openHABWatch Extension/Views/Utils/IconView.swift b/openHABWatch Extension/Views/Utils/IconView.swift index 55dd1ca89..6b053d1a7 100644 --- a/openHABWatch Extension/Views/Utils/IconView.swift +++ b/openHABWatch Extension/Views/Utils/IconView.swift @@ -15,7 +15,7 @@ import os.log import SwiftUI struct IconView: View { - @ObservedObject var widget: ObservableOpenHABWidget + @ObservedObject var widget: OpenHABWidget @ObservedObject var settings = ObservableOpenHABDataObject.shared var iconURL: URL? { diff --git a/openHABWatch Extension/Views/Utils/MapView.swift b/openHABWatch Extension/Views/Utils/MapView.swift index a57e7c0aa..4d8966a78 100644 --- a/openHABWatch Extension/Views/Utils/MapView.swift +++ b/openHABWatch Extension/Views/Utils/MapView.swift @@ -14,7 +14,7 @@ import OpenHABCore import SwiftUI struct MapView: View { - @ObservedObject var widget: ObservableOpenHABWidget + @ObservedObject var widget: OpenHABWidget @State private var region = MKCoordinateRegion( center: CLLocationCoordinate2D( latitude: 40, @@ -38,10 +38,10 @@ struct MapView: View { } } -struct MapView_Previews: PreviewProvider { + struct MapView_Previews: PreviewProvider { static var previews: some View { let widget = UserData().widgets[9] return MapView(widget: widget) .previewDevice("Apple Watch Series 5 - 44mm") } -} + } diff --git a/openHABWatch Extension/Views/Utils/TextLabelView.swift b/openHABWatch Extension/Views/Utils/TextLabelView.swift index d4cdc2ae4..b4aef5b4a 100644 --- a/openHABWatch Extension/Views/Utils/TextLabelView.swift +++ b/openHABWatch Extension/Views/Utils/TextLabelView.swift @@ -9,10 +9,11 @@ // // SPDX-License-Identifier: EPL-2.0 +import OpenHABCore import SwiftUI struct TextLabelView: View { - @ObservedObject var widget: ObservableOpenHABWidget + @ObservedObject var widget: OpenHABWidget var body: some View { Text(widget.labelText ?? "") diff --git a/openHABWatch Extension/openHABWatch Extension/Model/ObservableOpenHABSitemapPage.swift b/openHABWatch Extension/openHABWatch Extension/Model/ObservableOpenHABSitemapPage.swift index 3d94657be..39c092677 100644 --- a/openHABWatch Extension/openHABWatch Extension/Model/ObservableOpenHABSitemapPage.swift +++ b/openHABWatch Extension/openHABWatch Extension/Model/ObservableOpenHABSitemapPage.swift @@ -15,19 +15,19 @@ import os.log class ObservableOpenHABSitemapPage: NSObject { var sendCommand: ((_ item: OpenHABItem, _ command: String?) -> Void)? - var widgets: [ObservableOpenHABWidget] = [] + var widgets: [OpenHABWidget] = [] var pageId = "" var title = "" var link = "" var leaf = false - init(pageId: String, title: String, link: String, leaf: Bool, widgets: [ObservableOpenHABWidget]) { + init(pageId: String, title: String, link: String, leaf: Bool, widgets: [OpenHABWidget]) { super.init() self.pageId = pageId self.title = title self.link = link self.leaf = leaf - var tempWidgets = [ObservableOpenHABWidget]() + var tempWidgets = [OpenHABWidget]() tempWidgets.flatten(widgets) self.widgets = tempWidgets for widget in self.widgets { @@ -37,7 +37,7 @@ class ObservableOpenHABSitemapPage: NSObject { } } - init(pageId: String, title: String, link: String, leaf: Bool, expandedWidgets: [ObservableOpenHABWidget]) { + init(pageId: String, title: String, link: String, leaf: Bool, expandedWidgets: [OpenHABWidget]) { super.init() self.pageId = pageId self.title = title @@ -65,7 +65,7 @@ extension ObservableOpenHABSitemapPage { let title: String? let link: String? let leaf: Bool? - let widgets: [ObservableOpenHABWidget.CodingData]? + let widgets: [OpenHABWidget.CodingData]? private enum CodingKeys: String, CodingKey { case pageId = "id" @@ -85,7 +85,7 @@ extension ObservableOpenHABSitemapPage.CodingData { } extension ObservableOpenHABSitemapPage { - func filter(_ isIncluded: (ObservableOpenHABWidget) throws -> Bool) rethrows -> ObservableOpenHABSitemapPage { + func filter(_ isIncluded: (OpenHABWidget) throws -> Bool) rethrows -> ObservableOpenHABSitemapPage { let filteredOpenHABSitemapPage = try ObservableOpenHABSitemapPage( pageId: pageId, title: title, diff --git a/openHABWatch Extension/openHABWatch Extension/Model/ObservableOpenHABWidget.swift b/openHABWatch Extension/openHABWatch Extension/Model/ObservableOpenHABWidget.swift index 6de6d960e..9adf56fe7 100644 --- a/openHABWatch Extension/openHABWatch Extension/Model/ObservableOpenHABWidget.swift +++ b/openHABWatch Extension/openHABWatch Extension/Model/ObservableOpenHABWidget.swift @@ -18,28 +18,6 @@ import MapKit import OpenHABCore import os.log -enum WidgetTypeEnum { - case switcher(Bool) - case slider // - case segmented(Int) - case unassigned - case rollershutter - case frame - case setpoint - case selection - case colorpicker - case chart - case image - case video - case webview - case mapview - - var boolState: Bool { - guard case let .switcher(value) = self else { return false } - return value - } -} - @available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *) class ObservableOpenHABWidget: NSObject, MKAnnotation, Identifiable, ObservableObject { var id: String = "" @@ -74,7 +52,7 @@ class ObservableOpenHABWidget: NSObject, MKAnnotation, Identifiable, ObservableO public var switchSupport = false public var forceAsItem: Bool? - @Published var stateEnumBinding: WidgetTypeEnum = .unassigned + @Published public var stateEnumBinding: WidgetTypeEnum = .unassigned // Text prior to "[" var labelText: String? { @@ -295,7 +273,7 @@ extension ObservableOpenHABWidget.CodingData { } // Recursive parsing of nested widget structure -extension [ObservableOpenHABWidget] { +extension [OpenHABWidget] { mutating func flatten(_ widgets: [Element]) { for widget in widgets { append(widget) diff --git a/openHABWatch Extension/openHABWatch Extension/Model/ObservableOpenHABWidgetExtension.swift b/openHABWatch Extension/openHABWatch Extension/Model/ObservableOpenHABWidgetExtension.swift index d9402387d..3ab2cff64 100644 --- a/openHABWatch Extension/openHABWatch Extension/Model/ObservableOpenHABWidgetExtension.swift +++ b/openHABWatch Extension/openHABWatch Extension/Model/ObservableOpenHABWidgetExtension.swift @@ -10,10 +10,11 @@ // SPDX-License-Identifier: EPL-2.0 import Foundation +import OpenHABCore import os.log import SwiftUI -extension ObservableOpenHABWidget { +extension OpenHABWidget { @ViewBuilder func makeView(settings: ObservableOpenHABDataObject) -> some View { if let linkedPage { let title = linkedPage.title.components(separatedBy: "[")[0] diff --git a/openHABWatch Extension/openHABWatch Extension/UserData.swift b/openHABWatch Extension/openHABWatch Extension/UserData.swift index 6923ab3a3..10a644304 100644 --- a/openHABWatch Extension/openHABWatch Extension/UserData.swift +++ b/openHABWatch Extension/openHABWatch Extension/UserData.swift @@ -17,7 +17,7 @@ import os.log import SwiftUI final class UserData: ObservableObject { - @Published var widgets: [ObservableOpenHABWidget] = [] + @Published var widgets: [OpenHABWidget] = [] @Published var showAlert = false @Published var errorDescription = "" @Published var showCertificateAlert = false From 52fb51d390eab409a136cfab1c4f4d63b6e12ee6 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Sun, 15 Sep 2024 20:47:09 +0200 Subject: [PATCH 010/476] Getting rid of ObservableOpenHABSitemapPage for watch --- openHAB.xcodeproj/project.pbxproj | 18 +- .../Views/Utils/MapView.swift | 4 +- .../Model/ObservableOpenHABSitemapPage.swift | 98 ------ .../Model/ObservableOpenHABWidget.swift | 283 ------------------ ...ion.swift => OpenHABWidgetExtension.swift} | 0 .../openHABWatch Extension/UserData.swift | 6 +- 6 files changed, 9 insertions(+), 400 deletions(-) delete mode 100644 openHABWatch Extension/openHABWatch Extension/Model/ObservableOpenHABSitemapPage.swift delete mode 100644 openHABWatch Extension/openHABWatch Extension/Model/ObservableOpenHABWidget.swift rename openHABWatch Extension/openHABWatch Extension/Model/{ObservableOpenHABWidgetExtension.swift => OpenHABWidgetExtension.swift} (100%) diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index 390b74231..bfe15fd37 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -27,8 +27,6 @@ 934E592728F16EBA00162004 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 934E592628F16EBA00162004 /* Kingfisher */; }; 934E592928F16EBA00162004 /* DeviceKit in Frameworks */ = {isa = PBXBuildFile; productRef = 934E592828F16EBA00162004 /* DeviceKit */; }; 934E592B28F16EBA00162004 /* Alamofire in Frameworks */ = {isa = PBXBuildFile; productRef = 934E592A28F16EBA00162004 /* Alamofire */; }; - 9350F17923814FAC00054BA8 /* ObservableOpenHABWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9350F17723814FAC00054BA8 /* ObservableOpenHABWidget.swift */; }; - 9350F17A23814FAC00054BA8 /* ObservableOpenHABSitemapPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9350F17823814FAC00054BA8 /* ObservableOpenHABSitemapPage.swift */; }; 935B484625342B8E00E44CF0 /* URL+Static.swift in Sources */ = {isa = PBXBuildFile; fileRef = 935B484525342B8E00E44CF0 /* URL+Static.swift */; }; 93685A7A2ADE755C0077A9A6 /* openHABTests.xctestplan in Resources */ = {isa = PBXBuildFile; fileRef = 93685A792ADE755C0077A9A6 /* openHABTests.xctestplan */; }; 937C8B0C2800A738009C055E /* Intents.intentdefinition in Sources */ = {isa = PBXBuildFile; fileRef = 935D340A257B7DC00020A404 /* Intents.intentdefinition */; }; @@ -121,13 +119,12 @@ DAC6608D236F771600F4501E /* PreferencesSwiftUIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC6608C236F771600F4501E /* PreferencesSwiftUIView.swift */; }; DAC6608F236F80BA00F4501E /* PreferencesRowUIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC6608E236F80BA00F4501E /* PreferencesRowUIView.swift */; }; DAC9395522B00E7600C5F423 /* XCTestCaseExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC9395422B00E7600C5F423 /* XCTestCaseExtension.swift */; }; - DAC9AF4724F9669F006DAE93 /* ObservableOpenHABWidgetExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC9AF4624F9669F006DAE93 /* ObservableOpenHABWidgetExtension.swift */; }; + DAC9AF4724F9669F006DAE93 /* OpenHABWidgetExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC9AF4624F9669F006DAE93 /* OpenHABWidgetExtension.swift */; }; DAC9AF4924F966FA006DAE93 /* LazyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC9AF4824F966FA006DAE93 /* LazyView.swift */; }; DACB636227D3FC6500041931 /* error.png in Resources */ = {isa = PBXBuildFile; fileRef = DACB636127D3FC6500041931 /* error.png */; }; DACB636327D3FC6500041931 /* error.png in Resources */ = {isa = PBXBuildFile; fileRef = DACB636127D3FC6500041931 /* error.png */; }; DACE664A2C63B0760069E514 /* OpenAPIURLSession in Frameworks */ = {isa = PBXBuildFile; productRef = DACE66492C63B0760069E514 /* OpenAPIURLSession */; }; DACE664D2C63B0840069E514 /* OpenAPIRuntime in Frameworks */ = {isa = PBXBuildFile; productRef = DACE664C2C63B0840069E514 /* OpenAPIRuntime */; }; - DAEAA89B21E2611000267EA3 /* OpenHABNotificationsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAEAA89A21E2611000267EA3 /* OpenHABNotificationsViewController.swift */; }; DAEAA89D21E6B06400267EA3 /* ReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAEAA89C21E6B06300267EA3 /* ReusableView.swift */; }; DAEAA89F21E6B16600267EA3 /* UITableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAEAA89E21E6B16600267EA3 /* UITableView.swift */; }; DAF0A28B2C56E3A300A14A6A /* RollershutterCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF0A28A2C56E3A300A14A6A /* RollershutterCell.swift */; }; @@ -295,8 +292,6 @@ 933D7F0822E7015100621A03 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 933D7F0E22E7030600621A03 /* SnapshotHelper.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = SnapshotHelper.swift; path = fastlane/SnapshotHelper.swift; sourceTree = SOURCE_ROOT; }; 934B610B2348D2F9009112D5 /* Color+Extension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Color+Extension.swift"; sourceTree = ""; }; - 9350F17723814FAC00054BA8 /* ObservableOpenHABWidget.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ObservableOpenHABWidget.swift; sourceTree = ""; }; - 9350F17823814FAC00054BA8 /* ObservableOpenHABSitemapPage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ObservableOpenHABSitemapPage.swift; sourceTree = ""; }; 935B484525342B8E00E44CF0 /* URL+Static.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+Static.swift"; sourceTree = ""; }; 935D3412257B7E2F0020A404 /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = Resources/nl.lproj/Intents.strings; sourceTree = ""; }; 935D3419257B7E820020A404 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.intentdefinition; name = Base; path = Resources/Base.lproj/Intents.intentdefinition; sourceTree = ""; }; @@ -415,7 +410,7 @@ DAC6608E236F80BA00F4501E /* PreferencesRowUIView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesRowUIView.swift; sourceTree = ""; }; DAC9394322AD4A7A00C5F423 /* OpenHABWatchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenHABWatchTests.swift; sourceTree = ""; }; DAC9395422B00E7600C5F423 /* XCTestCaseExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCTestCaseExtension.swift; sourceTree = ""; }; - DAC9AF4624F9669F006DAE93 /* ObservableOpenHABWidgetExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObservableOpenHABWidgetExtension.swift; sourceTree = ""; }; + DAC9AF4624F9669F006DAE93 /* OpenHABWidgetExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenHABWidgetExtension.swift; sourceTree = ""; }; DAC9AF4824F966FA006DAE93 /* LazyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LazyView.swift; sourceTree = ""; }; DACB636127D3FC6500041931 /* error.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = error.png; sourceTree = ""; }; DAD488B2287DDDFE00414693 /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = nb.lproj/Interface.strings; sourceTree = ""; }; @@ -444,7 +439,6 @@ DAF4581D23DC60020018B495 /* ImageRawRow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageRawRow.swift; sourceTree = ""; }; DAF4F6BF222734D200C24876 /* NewImageUITableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewImageUITableViewCell.swift; sourceTree = ""; }; DAF6F4112C67E83B0083883E /* openapiCorrected.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = openapiCorrected.json; sourceTree = ""; }; - DF05EF111D00696200DD646D /* DrawerUITableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DrawerUITableViewCell.swift; sourceTree = ""; }; DF05FF221896BD2D00FF2F9B /* SelectionUITableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SelectionUITableViewCell.swift; sourceTree = ""; }; DF06F1FB18FEC2020011E7B9 /* ColorPickerViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ColorPickerViewController.swift; sourceTree = ""; }; DF1B302C1CF5C667009C921C /* OpenHABNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OpenHABNotification.swift; sourceTree = ""; }; @@ -641,10 +635,8 @@ isa = PBXGroup; children = ( DA15BFBC23C6726400BD8ADA /* ObservableOpenHABDataObject.swift */, - 9350F17823814FAC00054BA8 /* ObservableOpenHABSitemapPage.swift */, - 9350F17723814FAC00054BA8 /* ObservableOpenHABWidget.swift */, DA9721C224E29A8F0092CCFD /* UserDefaultsBacked.swift */, - DAC9AF4624F9669F006DAE93 /* ObservableOpenHABWidgetExtension.swift */, + DAC9AF4624F9669F006DAE93 /* OpenHABWidgetExtension.swift */, DAC9AF4824F966FA006DAE93 /* LazyView.swift */, ); name = Model; @@ -1399,7 +1391,7 @@ buildActionMask = 2147483647; files = ( DA7649DE23FC81A20085CE46 /* Unwrap.swift in Sources */, - DAC9AF4724F9669F006DAE93 /* ObservableOpenHABWidgetExtension.swift in Sources */, + DAC9AF4724F9669F006DAE93 /* OpenHABWidgetExtension.swift in Sources */, DAF4581423DC1F5D0018B495 /* AppState.swift in Sources */, DA2E0B0E23DCC153009B0A99 /* MapView.swift in Sources */, DA2E0B1023DCC439009B0A99 /* MapViewRow.swift in Sources */, @@ -1422,7 +1414,6 @@ DA07752B2346705F0086C685 /* ExtensionDelegate.swift in Sources */, DAF4581623DC48400018B495 /* GenericRow.swift in Sources */, DAC6608F236F80BA00F4501E /* PreferencesRowUIView.swift in Sources */, - 9350F17A23814FAC00054BA8 /* ObservableOpenHABSitemapPage.swift in Sources */, DAF457A023DA3E1C0018B495 /* SegmentRow.swift in Sources */, DAF4578923D79AA50018B495 /* DetailTextLabelView.swift in Sources */, DA15BFBD23C6726400BD8ADA /* ObservableOpenHABDataObject.swift in Sources */, @@ -1433,7 +1424,6 @@ DA50C7BD2B0A51BD0009F716 /* SliderWithSwitchSupportRow.swift in Sources */, DAF457A623DB9CE00018B495 /* SetpointRow.swift in Sources */, DAF4581823DC4A050018B495 /* ImageRow.swift in Sources */, - 9350F17923814FAC00054BA8 /* ObservableOpenHABWidget.swift in Sources */, DA07752F2346705F0086C685 /* NotificationView.swift in Sources */, DA0775312346705F0086C685 /* ComplicationController.swift in Sources */, DAF4578523D7807A0018B495 /* Color+Extension.swift in Sources */, diff --git a/openHABWatch Extension/Views/Utils/MapView.swift b/openHABWatch Extension/Views/Utils/MapView.swift index 4d8966a78..12fba2629 100644 --- a/openHABWatch Extension/Views/Utils/MapView.swift +++ b/openHABWatch Extension/Views/Utils/MapView.swift @@ -38,10 +38,10 @@ struct MapView: View { } } - struct MapView_Previews: PreviewProvider { +struct MapView_Previews: PreviewProvider { static var previews: some View { let widget = UserData().widgets[9] return MapView(widget: widget) .previewDevice("Apple Watch Series 5 - 44mm") } - } +} diff --git a/openHABWatch Extension/openHABWatch Extension/Model/ObservableOpenHABSitemapPage.swift b/openHABWatch Extension/openHABWatch Extension/Model/ObservableOpenHABSitemapPage.swift deleted file mode 100644 index 39c092677..000000000 --- a/openHABWatch Extension/openHABWatch Extension/Model/ObservableOpenHABSitemapPage.swift +++ /dev/null @@ -1,98 +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 Foundation -import OpenHABCore -import os.log - -class ObservableOpenHABSitemapPage: NSObject { - var sendCommand: ((_ item: OpenHABItem, _ command: String?) -> Void)? - var widgets: [OpenHABWidget] = [] - var pageId = "" - var title = "" - var link = "" - var leaf = false - - init(pageId: String, title: String, link: String, leaf: Bool, widgets: [OpenHABWidget]) { - super.init() - self.pageId = pageId - self.title = title - self.link = link - self.leaf = leaf - var tempWidgets = [OpenHABWidget]() - tempWidgets.flatten(widgets) - self.widgets = tempWidgets - for widget in self.widgets { - widget.sendCommand = { [weak self] item, command in - self?.sendCommand(item, commandToSend: command) - } - } - } - - init(pageId: String, title: String, link: String, leaf: Bool, expandedWidgets: [OpenHABWidget]) { - super.init() - self.pageId = pageId - self.title = title - self.link = link - self.leaf = leaf - widgets = expandedWidgets - for widget in widgets { - widget.sendCommand = { [weak self] item, command in - self?.sendCommand(item, commandToSend: command) - } - } - } - - private func sendCommand(_ item: OpenHABItem?, commandToSend command: String?) { - guard let item else { return } - - os_log("SitemapPage sending command %{PUBLIC}@ to %{PUBLIC}@", log: OSLog.remoteAccess, type: .info, command ?? "", item.name) - sendCommand?(item, command) - } -} - -extension ObservableOpenHABSitemapPage { - struct CodingData: Decodable { - let pageId: String? - let title: String? - let link: String? - let leaf: Bool? - let widgets: [OpenHABWidget.CodingData]? - - private enum CodingKeys: String, CodingKey { - case pageId = "id" - case title - case link - case leaf - case widgets - } - } -} - -extension ObservableOpenHABSitemapPage.CodingData { - var openHABSitemapPage: ObservableOpenHABSitemapPage { - let mappedWidgets = widgets?.map(\.openHABWidget) ?? [] - return ObservableOpenHABSitemapPage(pageId: pageId ?? "", title: title ?? "", link: link ?? "", leaf: leaf ?? false, widgets: mappedWidgets) - } -} - -extension ObservableOpenHABSitemapPage { - func filter(_ isIncluded: (OpenHABWidget) throws -> Bool) rethrows -> ObservableOpenHABSitemapPage { - let filteredOpenHABSitemapPage = try ObservableOpenHABSitemapPage( - pageId: pageId, - title: title, - link: link, - leaf: leaf, - expandedWidgets: widgets.filter(isIncluded) - ) - return filteredOpenHABSitemapPage - } -} diff --git a/openHABWatch Extension/openHABWatch Extension/Model/ObservableOpenHABWidget.swift b/openHABWatch Extension/openHABWatch Extension/Model/ObservableOpenHABWidget.swift deleted file mode 100644 index 9adf56fe7..000000000 --- a/openHABWatch Extension/openHABWatch Extension/Model/ObservableOpenHABWidget.swift +++ /dev/null @@ -1,283 +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 -#if canImport(Combine) -import Combine -#endif -import Foundation -import MapKit -import OpenHABCore -import os.log - -@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *) -class ObservableOpenHABWidget: NSObject, MKAnnotation, Identifiable, ObservableObject { - var id: String = "" - - var sendCommand: ((_ item: OpenHABItem, _ command: String?) -> Void)? - var widgetId = "" - @Published var label = "" - var icon = "" - var type = "" - var url = "" - var period = "" - var minValue = 0.0 - var maxValue = 100.0 - var step = 1.0 - var refresh = 0 - var height = 44.0 - var isLeaf = false - var iconColor = "" - var labelcolor = "" - var valuecolor = "" - var service = "" - @Published var state = "" - var text = "" - var legend: Bool? - var encoding = "" - @Published var item: OpenHABItem? - var linkedPage: OpenHABPage? - var mappings: [OpenHABWidgetMapping] = [] - var image: UIImage? - var widgets: [ObservableOpenHABWidget] = [] - public var visibility = true - public var switchSupport = false - public var forceAsItem: Bool? - - @Published public var stateEnumBinding: WidgetTypeEnum = .unassigned - - // Text prior to "[" - var labelText: String? { - let array = label.components(separatedBy: "[") - return array[0].trimmingCharacters(in: .whitespaces) - } - - // Text between square brackets - public var labelValue: String? { - let pattern = /\[(.*?)\]/.dotMatchesNewlines() - guard let firstMatch = label.firstMatch(of: pattern) else { return nil } - return String(firstMatch.1) - } - - var coordinate: CLLocationCoordinate2D { - item?.stateAsLocation()?.coordinate ?? kCLLocationCoordinate2DInvalid - } - - var mappingsOrItemOptions: [OpenHABWidgetMapping] { - if mappings.isEmpty, let commandOptions = item?.commandDescription?.commandOptions { - commandOptions.map { OpenHABWidgetMapping(command: $0.command, label: $0.label ?? "") } - } else if mappings.isEmpty, let stateOptions = item?.stateDescription?.options { - stateOptions.map { OpenHABWidgetMapping(command: $0.value, label: $0.label) } - } else { - mappings - } - } - - public var stateValueAsBool: Bool? { - item?.state?.parseAsBool() - } - - public var stateValueAsBrightness: Int? { - item?.state?.parseAsBrightness() - } - - public var stateValueAsUIColor: UIColor? { - item?.state?.parseAsUIColor() - } - - public var stateValueAsNumberState: NumberState? { - item?.state?.parseAsNumber(format: item?.stateDescription?.numberPattern) - } - - var adjustedValue: Double { - if let item { - adj(item.stateAsDouble()) - } else { - minValue - } - } - - var stateEnum: WidgetTypeEnum { - switch type { - case "Frame": - .frame - case "Switch": - // Reflecting the discussion held in https://github.com/openhab/openhab-core/issues/952 - if !mappings.isEmpty { - .segmented(Int(mappingIndex(byCommand: item?.state) ?? -1)) - } else if item?.isOfTypeOrGroupType(.switchItem) ?? false { - .switcher(item?.state == "ON" ? true : false) - } else if item?.isOfTypeOrGroupType(.rollershutter) ?? false { - .rollershutter - } else if !mappingsOrItemOptions.isEmpty { - .segmented(Int(mappingIndex(byCommand: item?.state) ?? -1)) - } else { - .switcher(item?.state == "ON" ? true : false) - } - case "Setpoint": - .setpoint - case "Slider": - .slider // (adjustedValue) - case "Selection": - .selection - case "Colorpicker": - .colorpicker - case "Chart": - .chart - case "Image": - .image - case "Video": - .video - case "Webview": - .webview - case "Mapview": - .mapview - default: - .unassigned - } - } - - public func sendItemUpdate(state: NumberState?) { - guard let item, let state else { - os_log("ItemUpdate for Item or State = nil", log: .default, type: .info) - return - } - if item.isOfTypeOrGroupType(.numberWithDimension) { - // For number items, include unit (if present) in command - sendCommand(state.toString(locale: Locale(identifier: "US"))) - } else { - // For all other items, send the plain value - sendCommand(state.stringValue) - } - } - - func sendCommandDouble(_ command: Double) { - sendCommand(String(command)) - } - - func sendCommand(_ command: String?) { - guard let item else { - os_log("Command for Item = nil", log: .default, type: .info) - return - } - guard let sendCommand else { - os_log("sendCommand closure not set", log: .default, type: .info) - return - } - sendCommand(item, command) - } - - func mappingIndex(byCommand command: String?) -> Int? { - mappingsOrItemOptions.firstIndex { $0.command == command } - } - - private func adj(_ raw: Double) -> Double { - var valueAdjustedToStep = floor((raw - minValue) / step) * step - valueAdjustedToStep += minValue - return valueAdjustedToStep.clamped(to: minValue ... maxValue) - } -} - -extension ObservableOpenHABWidget { - // This is an ugly initializer - convenience init(widgetId: String, label: String, icon: String, type: String, url: String?, period: String?, minValue: Double?, maxValue: Double?, step: Double?, refresh: Int?, height: Double?, isLeaf: Bool?, iconColor: String?, labelColor: String?, valueColor: String?, service: String?, state: String?, text: String?, legend: Bool?, encoding: String?, item: OpenHABItem?, linkedPage: OpenHABPage?, mappings: [OpenHABWidgetMapping], widgets: [ObservableOpenHABWidget], forceAsItem: Bool?) { - self.init() - - id = widgetId - - self.widgetId = widgetId - self.label = label - self.type = type - self.icon = icon - self.url = url ?? "" - self.period = period ?? "" - self.minValue = minValue ?? 0.0 - self.maxValue = maxValue ?? 100.0 - self.step = step ?? 1.0 - // Consider a minimal refresh rate of 100 ms, but 0 is special and means 'no refresh' - if let refreshVal = refresh, refreshVal > 0 { - self.refresh = max(100, refreshVal) - } else { - self.refresh = 0 - } - self.height = height ?? 44.0 - self.isLeaf = isLeaf ?? false - self.iconColor = iconColor ?? "" - labelcolor = labelColor ?? "" - valuecolor = valueColor ?? "" - self.service = service ?? "" - self.state = state ?? "" - self.text = text ?? "" - self.legend = legend - self.encoding = encoding ?? "" - self.item = item - self.linkedPage = linkedPage - self.mappings = mappings - self.widgets = widgets - - // Sanitize minValue, maxValue and step: min <= max, step >= 0 - self.maxValue = max(self.minValue, self.maxValue) - self.step = abs(self.step) - - self.forceAsItem = forceAsItem - - stateEnumBinding = stateEnum - } -} - -extension ObservableOpenHABWidget { - public struct CodingData: Decodable { - let widgetId: String - let label: String - let type: String - let icon: String - let url: String? - let period: String? - let minValue: Double? - let maxValue: Double? - let step: Double? - let refresh: Int? - let height: Double? - let isLeaf: Bool? - let iconColor: String? - let labelcolor: String? - let valuecolor: String? - let service: String? - let state: String? - let text: String? - let legend: Bool? - let encoding: String? - let groupType: String? - let item: OpenHABItem.CodingData? - let linkedPage: OpenHABPage.CodingData? - let mappings: [OpenHABWidgetMapping] - let widgets: [ObservableOpenHABWidget.CodingData] - let forceAsItem: Bool? - } -} - -extension ObservableOpenHABWidget.CodingData { - var openHABWidget: ObservableOpenHABWidget { - let mappedWidgets = widgets.map(\.openHABWidget) - // swiftlint:disable:next line_length - return ObservableOpenHABWidget(widgetId: widgetId, label: label, icon: icon, type: type, url: url, period: period, minValue: minValue, maxValue: maxValue, step: step, refresh: refresh, height: height, isLeaf: isLeaf, iconColor: iconColor, labelColor: labelcolor, valueColor: valuecolor, service: service, state: state, text: text, legend: legend, encoding: encoding, item: item?.openHABItem, linkedPage: linkedPage?.openHABSitemapPage, mappings: mappings, widgets: mappedWidgets, forceAsItem: forceAsItem) - } -} - -// Recursive parsing of nested widget structure -extension [OpenHABWidget] { - mutating func flatten(_ widgets: [Element]) { - for widget in widgets { - append(widget) - flatten(widget.widgets) - } - } -} diff --git a/openHABWatch Extension/openHABWatch Extension/Model/ObservableOpenHABWidgetExtension.swift b/openHABWatch Extension/openHABWatch Extension/Model/OpenHABWidgetExtension.swift similarity index 100% rename from openHABWatch Extension/openHABWatch Extension/Model/ObservableOpenHABWidgetExtension.swift rename to openHABWatch Extension/openHABWatch Extension/Model/OpenHABWidgetExtension.swift diff --git a/openHABWatch Extension/openHABWatch Extension/UserData.swift b/openHABWatch Extension/openHABWatch Extension/UserData.swift index 10a644304..78c2d8a5f 100644 --- a/openHABWatch Extension/openHABWatch Extension/UserData.swift +++ b/openHABWatch Extension/openHABWatch Extension/UserData.swift @@ -25,7 +25,7 @@ final class UserData: ObservableObject { let decoder = JSONDecoder() - var openHABSitemapPage: ObservableOpenHABSitemapPage? + var openHABSitemapPage: OpenHABPage? private var commandOperation: Alamofire.Request? private var currentPageOperation: Alamofire.Request? @@ -42,7 +42,7 @@ final class UserData: ObservableObject { // Self-executing closure // Inspired by https://www.swiftbysundell.com/posts/inline-types-and-functions-in-swift openHABSitemapPage = try { - let sitemapPageCodingData = try data.decoded(as: ObservableOpenHABSitemapPage.CodingData.self) + let sitemapPageCodingData = try data.decoded(as: OpenHABPage.CodingData.self) return sitemapPageCodingData.openHABSitemapPage }() } catch { @@ -111,7 +111,7 @@ final class UserData: ObservableObject { // Self-executing closure // Inspired by https://www.swiftbysundell.com/posts/inline-types-and-functions-in-swift openHABSitemapPage = try { - let sitemapPageCodingData = try data.decoded(as: ObservableOpenHABSitemapPage.CodingData.self) + let sitemapPageCodingData = try data.decoded(as: OpenHABPage.CodingData.self) return sitemapPageCodingData.openHABSitemapPage }() } catch { From 9d3c3a6b15fe9ed13fdf335812a1709fcbc9a5f8 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Sun, 15 Sep 2024 20:51:32 +0200 Subject: [PATCH 011/476] Merge result --- .../xcshareddata/swiftpm/Package.resolved | 83 +------------------ 1 file changed, 1 insertion(+), 82 deletions(-) diff --git a/openHAB.xcworkspace/xcshareddata/swiftpm/Package.resolved b/openHAB.xcworkspace/xcshareddata/swiftpm/Package.resolved index 329446dfd..38af9155c 100644 --- a/openHAB.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/openHAB.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "a0e070693d4bd70bdbabd8b73137d27ca8a0eee3a6c62258b0bf76ccac438c62", + "originHash" : "45c4b21381af160597cca953a315d1e460c404f7c9224de5f8844679c017063b", "pins" : [ { "identity" : "alamofire", @@ -19,60 +19,6 @@ "version" : "7.11.0" } }, - { - "identity" : "leveldb", - "kind" : "remoteSourceControl", - "location" : "https://github.com/firebase/leveldb.git", - "state" : { - "revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1", - "version" : "1.22.5" - } - }, - { - "identity" : "nanopb", - "kind" : "remoteSourceControl", - "location" : "https://github.com/firebase/nanopb.git", - "state" : { - "revision" : "b7e1104502eca3a213b46303391ca4d3bc8ddec1", - "version" : "2.30910.0" - } - }, - { - "identity" : "promises", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/promises.git", - "state" : { - "revision" : "540318ecedd63d883069ae7f1ed811a2df00b6ac", - "version" : "2.4.0" - } - }, - { - "identity" : "sfsafesymbols", - "kind" : "remoteSourceControl", - "location" : "https://github.com/SFSafeSymbols/SFSafeSymbols", - "state" : { - "revision" : "e2e28f4e56e1769c2ec3c61c9355fc64eb7a535a", - "version" : "5.3.0" - } - }, - { - "identity" : "sidemenu", - "kind" : "remoteSourceControl", - "location" : "https://github.com/jonkykong/SideMenu.git", - "state" : { - "revision" : "8bd4fd128923cf5494fa726839af8afe12908ad9", - "version" : "6.5.0" - } - }, - { - "identity" : "svgkit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/SVGKit/SVGKit.git", - "state" : { - "branch" : "3.x", - "revision" : "cf4dca96801dbbbdb2f37dc8c8571bfaf05c9d41" - } - }, { "identity" : "swift-collections", "kind" : "remoteSourceControl", @@ -91,15 +37,6 @@ "version" : "1.3.0" } }, - { - "identity" : "swift-log", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-log", - "state" : { - "revision" : "9cb486020ebf03bfa5b5df985387a14a98744537", - "version" : "1.6.1" - } - }, { "identity" : "swift-openapi-runtime", "kind" : "remoteSourceControl", @@ -117,24 +54,6 @@ "revision" : "9bf4c712ad7989d6a91dbe68748b8829a50837e4", "version" : "1.0.2" } - }, - { - "identity" : "swift-protobuf", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-protobuf.git", - "state" : { - "revision" : "edb6ed4919f7756157fe02f2552b7e3850a538e5", - "version" : "1.28.1" - } - }, - { - "identity" : "swiftmessages", - "kind" : "remoteSourceControl", - "location" : "https://github.com/SwiftKickMobile/SwiftMessages.git", - "state" : { - "revision" : "62e12e138fc3eedf88c7553dd5d98712aa119f40", - "version" : "9.0.9" - } } ], "version" : 3 From b6db98b23f56b878c733c125ef0931461be7d11c Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Mon, 16 Sep 2024 09:57:14 +0200 Subject: [PATCH 012/476] Properly testing decoding with swift-openapi-generator --- .../OpenHABCoreTests/JSONParserTests.swift | 22 +- .../xcshareddata/swiftpm/Package.resolved | 200 +++++++++++++++++- 2 files changed, 210 insertions(+), 12 deletions(-) diff --git a/OpenHABCore/Tests/OpenHABCoreTests/JSONParserTests.swift b/OpenHABCore/Tests/OpenHABCoreTests/JSONParserTests.swift index dca4e4b72..7b24f6616 100644 --- a/OpenHABCore/Tests/OpenHABCoreTests/JSONParserTests.swift +++ b/OpenHABCore/Tests/OpenHABCoreTests/JSONParserTests.swift @@ -32,8 +32,8 @@ final class JSONParserTests: XCTestCase { func testJSONSitemapDecoder() { let data = Data(jsonSitemap3.utf8) do { - let codingData = try decoder.decode([OpenHABSitemap.CodingData].self, from: data) - XCTAssertEqual(codingData[0].openHABSitemap.homepageLink, "https://192.168.2.63:8444/rest/sitemaps/myHome/myHome", "Sitemap properly parsed") + let codingData = try decoder.decode([Components.Schemas.SitemapDTO].self, from: data) + XCTAssertEqual(codingData[0].homepage?.link, "https://192.168.2.63:8444/rest/sitemaps/myHome/myHome", "Sitemap properly parsed") } catch { XCTFail("Whoops, an error occured: \(error)") } @@ -47,8 +47,8 @@ final class JSONParserTests: XCTestCase { """ let data = Data(json.utf8) do { - let codingData = try decoder.decode([OpenHABSitemap.CodingData].self, from: data) - XCTAssertEqual(codingData[0].openHABSitemap.homepageLink, "http://192.xxx:8080/rest/sitemaps/Haus/Haus", "Sitemap properly parsed") + let codingData = try decoder.decode([Components.Schemas.SitemapDTO].self, from: data) + XCTAssertEqual(codingData[0].homepage?.link, "http://192.xxx:8080/rest/sitemaps/Haus/Haus", "Sitemap properly parsed") } catch { XCTFail("Whoops, an error occured: \(error)") } @@ -384,8 +384,8 @@ final class JSONParserTests: XCTestCase { {"name":"watch","label":"watch","link":"https://192.168.2.15:8444/rest/sitemaps/watch","homepage":{"id":"watch","title":"watch","link":"https://192.168.2.15:8444/rest/sitemaps/watch/watch","leaf":false,"timeout":false,"widgets":[{"widgetId":"00","type":"Frame","label":"Ground floor","icon":"frame","mappings":[],"widgets":[{"widgetId":"0000","type":"Switch","label":"Licht Oberlicht","icon":"switch","mappings":[],"item":{"link":"https://192.168.2.15:8444/rest/items/lcnLightSwitch14_1","state":"OFF","editable":false,"type":"Switch","name":"lcnLightSwitch14_1","label":"Licht Oberlicht","tags":["Lighting"],"groupNames":["G_PresenceSimulation","gLcn"]},"widgets":[]},{"widgetId":"0001","type":"Switch","label":"Licht Keller WC Decke","icon":"colorpicker","mappings":[],"item":{"link":"https://192.168.2.15:8444/rest/items/lcnLightSwitch6_1","state":"OFF","editable":false,"type":"Switch","name":"lcnLightSwitch6_1","label":"Licht Keller WC Decke","category":"colorpicker","tags":["Lighting"],"groupNames":["gKellerLicht","gLcn"]},"widgets":[]}]}]}} """.data(using: .utf8)! do { - let codingData = try decoder.decode(OpenHABSitemap.CodingData.self, from: json) - XCTAssertEqual(codingData.page?.link, "https://192.168.2.15:8444/rest/sitemaps/watch/watch", "OpenHABSitemapPage properly parsed") + let codingData = try decoder.decode(Components.Schemas.SitemapDTO.self, from: json) + XCTAssertEqual(codingData.homepage?.link, "https://192.168.2.15:8444/rest/sitemaps/watch/watch", "OpenHABSitemapPage properly parsed") // XCTAssert(codingData.openHABSitemapPage. widgets[0].type == "Frame", "") // XCTAssert(.widgets[0].linkedPage?.pageId == "0000", "widget properly parsed") } catch { @@ -481,7 +481,7 @@ final class JSONParserTests: XCTestCase { signpostID: signpostID, "Begin" ) - let codingData = try decoder.decode(OpenHABSitemap.CodingData.self, from: contents) + let codingData = try decoder.decode(Components.Schemas.SitemapDTO.self, from: contents) os_signpost( .end, log: log, @@ -490,13 +490,13 @@ final class JSONParserTests: XCTestCase { "End" ) - let widgets: [OpenHABWidget.CodingData] = try XCTUnwrap(codingData.page?.widgets) + let widgets = try XCTUnwrap(codingData.homepage?.widgets) let widget = widgets[0] XCTAssertEqual(widget.label, "Flat Scenes") - XCTAssertEqual(widget.widgets[0].label, "Scenes") - XCTAssertEqual(codingData.page?.link, "https://192.168.0.9:8443/rest/sitemaps/default/default") + XCTAssertEqual(widget.widgets?[0].label, "Scenes") + XCTAssertEqual(codingData.homepage?.link, "https://192.168.0.9:8443/rest/sitemaps/default/default") let widget2 = widgets[10] - XCTAssertEqual(widget2.widgets[0].label, "Admin Items") + XCTAssertEqual(widget2.widgets?[0].label, "Admin Items") } } diff --git a/openHAB.xcworkspace/xcshareddata/swiftpm/Package.resolved b/openHAB.xcworkspace/xcshareddata/swiftpm/Package.resolved index 38af9155c..ef5149f8d 100644 --- a/openHAB.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/openHAB.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,6 +1,15 @@ { - "originHash" : "45c4b21381af160597cca953a315d1e460c404f7c9224de5f8844679c017063b", + "originHash" : "a0e070693d4bd70bdbabd8b73137d27ca8a0eee3a6c62258b0bf76ccac438c62", "pins" : [ + { + "identity" : "abseil-cpp-binary", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/abseil-cpp-binary.git", + "state" : { + "revision" : "194a6706acbd25e4ef639bcaddea16e8758a3e27", + "version" : "1.2024011602.0" + } + }, { "identity" : "alamofire", "kind" : "remoteSourceControl", @@ -10,6 +19,114 @@ "version" : "5.9.1" } }, + { + "identity" : "alamofirenetworkactivityindicator", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Alamofire/AlamofireNetworkActivityIndicator.git", + "state" : { + "revision" : "392bed083e8d193aca16bfa684ee24e4bcff0510", + "version" : "3.1.0" + } + }, + { + "identity" : "app-check", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/app-check.git", + "state" : { + "revision" : "3b62f154d00019ae29a71e9738800bb6f18b236d", + "version" : "10.19.2" + } + }, + { + "identity" : "cocoalumberjack", + "kind" : "remoteSourceControl", + "location" : "https://github.com/CocoaLumberjack/CocoaLumberjack.git", + "state" : { + "revision" : "4b8714a7fb84d42393314ce897127b3939885ec3", + "version" : "3.8.5" + } + }, + { + "identity" : "devicekit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/devicekit/DeviceKit.git", + "state" : { + "revision" : "d37e70cb2646666dcf276d7d3d4a9760a41ff8a6", + "version" : "4.9.0" + } + }, + { + "identity" : "firebase-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/firebase-ios-sdk.git", + "state" : { + "revision" : "eca84fd638116dd6adb633b5a3f31cc7befcbb7d", + "version" : "10.29.0" + } + }, + { + "identity" : "flexcolorpicker", + "kind" : "remoteSourceControl", + "location" : "https://github.com/RastislavMirek/FlexColorPicker.git", + "state" : { + "revision" : "72a5c2c5e28074e6c5f13efe3c98eb780ae2f906", + "version" : "1.4.4" + } + }, + { + "identity" : "googleappmeasurement", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleAppMeasurement.git", + "state" : { + "revision" : "fe727587518729046fc1465625b9afd80b5ab361", + "version" : "10.28.0" + } + }, + { + "identity" : "googledatatransport", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleDataTransport.git", + "state" : { + "revision" : "a637d318ae7ae246b02d7305121275bc75ed5565", + "version" : "9.4.0" + } + }, + { + "identity" : "googleutilities", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleUtilities.git", + "state" : { + "revision" : "57a1d307f42df690fdef2637f3e5b776da02aad6", + "version" : "7.13.3" + } + }, + { + "identity" : "grpc-binary", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/grpc-binary.git", + "state" : { + "revision" : "e9fad491d0673bdda7063a0341fb6b47a30c5359", + "version" : "1.62.2" + } + }, + { + "identity" : "gtm-session-fetcher", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/gtm-session-fetcher.git", + "state" : { + "revision" : "a2ab612cb980066ee56d90d60d8462992c07f24b", + "version" : "3.5.0" + } + }, + { + "identity" : "interop-ios-for-google-sdks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/interop-ios-for-google-sdks.git", + "state" : { + "revision" : "2d12673670417654f08f5f90fdd62926dc3a2648", + "version" : "100.0.0" + } + }, { "identity" : "kingfisher", "kind" : "remoteSourceControl", @@ -19,6 +136,60 @@ "version" : "7.11.0" } }, + { + "identity" : "leveldb", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/leveldb.git", + "state" : { + "revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1", + "version" : "1.22.5" + } + }, + { + "identity" : "nanopb", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/nanopb.git", + "state" : { + "revision" : "b7e1104502eca3a213b46303391ca4d3bc8ddec1", + "version" : "2.30910.0" + } + }, + { + "identity" : "promises", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/promises.git", + "state" : { + "revision" : "540318ecedd63d883069ae7f1ed811a2df00b6ac", + "version" : "2.4.0" + } + }, + { + "identity" : "sfsafesymbols", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SFSafeSymbols/SFSafeSymbols", + "state" : { + "revision" : "e2e28f4e56e1769c2ec3c61c9355fc64eb7a535a", + "version" : "5.3.0" + } + }, + { + "identity" : "sidemenu", + "kind" : "remoteSourceControl", + "location" : "https://github.com/jonkykong/SideMenu.git", + "state" : { + "revision" : "8bd4fd128923cf5494fa726839af8afe12908ad9", + "version" : "6.5.0" + } + }, + { + "identity" : "svgkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SVGKit/SVGKit.git", + "state" : { + "branch" : "3.x", + "revision" : "cf4dca96801dbbbdb2f37dc8c8571bfaf05c9d41" + } + }, { "identity" : "swift-collections", "kind" : "remoteSourceControl", @@ -37,6 +208,15 @@ "version" : "1.3.0" } }, + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log", + "state" : { + "revision" : "9cb486020ebf03bfa5b5df985387a14a98744537", + "version" : "1.6.1" + } + }, { "identity" : "swift-openapi-runtime", "kind" : "remoteSourceControl", @@ -54,6 +234,24 @@ "revision" : "9bf4c712ad7989d6a91dbe68748b8829a50837e4", "version" : "1.0.2" } + }, + { + "identity" : "swift-protobuf", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-protobuf.git", + "state" : { + "revision" : "edb6ed4919f7756157fe02f2552b7e3850a538e5", + "version" : "1.28.1" + } + }, + { + "identity" : "swiftmessages", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SwiftKickMobile/SwiftMessages.git", + "state" : { + "revision" : "62e12e138fc3eedf88c7553dd5d98712aa119f40", + "version" : "9.0.9" + } } ], "version" : 3 From 4514c569202d8f783a1711bc202548dafed94bec Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Mon, 16 Sep 2024 18:14:07 +0200 Subject: [PATCH 013/476] Properly showing selected state in SelectionView --- openHAB/OpenHABSitemapViewController.swift | 9 +++------ openHAB/SelectionView.swift | 19 +++++++++++-------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/openHAB/OpenHABSitemapViewController.swift b/openHAB/OpenHABSitemapViewController.swift index c0fb45992..15e3a2172 100644 --- a/openHAB/OpenHABSitemapViewController.swift +++ b/openHAB/OpenHABSitemapViewController.swift @@ -776,16 +776,13 @@ extension OpenHABSitemapViewController: UITableViewDelegate, UITableViewDataSour newViewController.openHABRootUrl = openHABRootUrl navigationController?.pushViewController(newViewController, animated: true) } else if widget?.type == .selection { - os_log("Selected selection widget", log: .viewCycle, type: .info) selectedWidgetRow = indexPath.row let selectedWidget: OpenHABWidget? = relevantWidget(indexPath: indexPath) + let selectionItemState = selectedWidget?.item?.state + logger.info("Selected selection widget in status: \(selectionItemState ?? "unknown")") let hostingController = UIHostingController(rootView: SelectionView( mappings: selectedWidget?.mappingsOrItemOptions ?? [], - selectionItem: - Binding( - get: { selectedWidget?.item }, - set: { selectedWidget?.item = $0 } - ), + selectionItemState: selectionItemState, onSelection: { selectedMappingIndex in let selectedWidget: OpenHABWidget? = self.relevantPage?.widgets[self.selectedWidgetRow] let selectedMapping: OpenHABWidgetMapping? = selectedWidget?.mappingsOrItemOptions[selectedMappingIndex] diff --git a/openHAB/SelectionView.swift b/openHAB/SelectionView.swift index cd8c2d388..ad31af6d7 100644 --- a/openHAB/SelectionView.swift +++ b/openHAB/SelectionView.swift @@ -15,39 +15,42 @@ import SwiftUI struct SelectionView: View { var mappings: [OpenHABWidgetMapping] // List of mappings (instead of AnyHashable, we use a concrete type) - @Binding var selectionItem: OpenHABItem? // Binding to track the selected item state + @State var selectionItemState: String? // To track the selected item state var onSelection: (Int) -> Void // Closure to handle selection + private let logger = Logger(subsystem: "org.openhab.app", category: "SelectionView") + var body: some View { List(0 ..< mappings.count, id: \.self) { index in let mapping = mappings[index] HStack { Text(mapping.label) Spacer() - if selectionItem?.state == mapping.command { + if selectionItemState == mapping.command { Image(systemSymbol: .checkmark) .foregroundColor(.blue) } } - .contentShape(Rectangle()) // Ensures entire row is tappable + .contentShape(.interaction, Rectangle()) // Ensures entire row is tappable .onTapGesture { - os_log("Selected mapping %d", log: .viewCycle, type: .info, index) + selectionItemState = mappings[index].command + logger.info("Selected mapping \(index)") onSelection(index) } + .accessibilityElement(children: .combine) + .accessibilityAddTraits(.isButton) } .navigationTitle("Select Mapping") // Navigation title } } #Preview { - let selectedItem: OpenHABItem? = OpenHABItem(name: "", type: "", state: "command2", link: "", label: nil, groupType: nil, stateDescription: nil, commandDescription: nil, members: [], category: nil, options: nil) - - return SelectionView( + SelectionView( mappings: [ OpenHABWidgetMapping(command: "command1", label: "Option 1"), OpenHABWidgetMapping(command: "command2", label: "Option 2") ], - selectionItem: .constant(selectedItem) + selectionItemState: "command2" ) { selectedMappingIndex in print("Selected mapping at index \(selectedMappingIndex)") } From 45cdd7bf313b3ed5f7b7da9bc3fc40dbfcd0267a Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Sat, 22 Feb 2025 16:50:03 +0100 Subject: [PATCH 014/476] Updated swift-openapi-runtime to 1.8.0 in local installation and rerun generator Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../GeneratedSources/openapi/Client.swift | 198 +- .../GeneratedSources/openapi/Types.swift | 2392 +++++++++-------- .../xcshareddata/swiftpm/Package.resolved | 44 +- openHAB/DrawerView.swift | 36 +- openHAB/OpenHABRootViewController.swift | 2 - 5 files changed, 1376 insertions(+), 1296 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Client.swift b/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Client.swift index 258e7a671..90d4582c7 100644 --- a/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Client.swift +++ b/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Client.swift @@ -1,27 +1,15 @@ -// Copyright (c) 2010-2025 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 - // Generated by swift-openapi-generator, do not modify. @_spi(Generated) import OpenAPIRuntime #if os(Linux) +@preconcurrency import struct Foundation.URL @preconcurrency import struct Foundation.Data @preconcurrency import struct Foundation.Date -@preconcurrency import struct Foundation.URL #else +import struct Foundation.URL import struct Foundation.Data import struct Foundation.Date -import struct Foundation.URL #endif import HTTPTypes - public struct Client: APIProtocol { /// The underlying HTTP client. private let client: UniversalClient @@ -33,22 +21,22 @@ public struct Client: APIProtocol { /// - configuration: A set of configuration values for the client. /// - transport: A transport that performs HTTP operations. /// - middlewares: A list of middlewares to call before the transport. - public init(serverURL: Foundation.URL, - configuration: Configuration = .init(), - transport: any ClientTransport, - middlewares: [any ClientMiddleware] = []) { - client = .init( + public init( + serverURL: Foundation.URL, + configuration: Configuration = .init(), + transport: any ClientTransport, + middlewares: [any ClientMiddleware] = [] + ) { + self.client = .init( serverURL: serverURL, configuration: configuration, transport: transport, middlewares: middlewares ) } - private var converter: Converter { client.converter } - /// Adds a new member to a group item. /// /// - Remark: HTTP `PUT /items/{itemName}/members/{memberItemName}`. @@ -75,13 +63,13 @@ public struct Client: APIProtocol { deserializer: { response, responseBody in switch response.status.code { case 200: - .ok(.init()) + return .ok(.init()) case 404: - .notFound(.init()) + return .notFound(.init()) case 405: - .methodNotAllowed(.init()) + return .methodNotAllowed(.init()) default: - .undocumented( + return .undocumented( statusCode: response.status.code, .init( headerFields: response.headerFields, @@ -92,7 +80,6 @@ public struct Client: APIProtocol { } ) } - /// Removes an existing member from a group item. /// /// - Remark: HTTP `DELETE /items/{itemName}/members/{memberItemName}`. @@ -119,13 +106,13 @@ public struct Client: APIProtocol { deserializer: { response, responseBody in switch response.status.code { case 200: - .ok(.init()) + return .ok(.init()) case 404: - .notFound(.init()) + return .notFound(.init()) case 405: - .methodNotAllowed(.init()) + return .methodNotAllowed(.init()) default: - .undocumented( + return .undocumented( statusCode: response.status.code, .init( headerFields: response.headerFields, @@ -136,7 +123,6 @@ public struct Client: APIProtocol { } ) } - /// Adds metadata to an item. /// /// - Remark: HTTP `PUT /items/{itemname}/metadata/{namespace}`. @@ -158,9 +144,10 @@ public struct Client: APIProtocol { method: .put ) suppressMutabilityWarning(&request) - let body: OpenAPIRuntime.HTTPBody? = switch input.body { + let body: OpenAPIRuntime.HTTPBody? + switch input.body { case let .json(value): - try converter.setRequiredRequestBodyAsJSON( + body = try converter.setRequiredRequestBodyAsJSON( value, headerFields: &request.headerFields, contentType: "application/json; charset=utf-8" @@ -171,17 +158,17 @@ public struct Client: APIProtocol { deserializer: { response, responseBody in switch response.status.code { case 200: - .ok(.init()) + return .ok(.init()) case 201: - .created(.init()) + return .created(.init()) case 400: - .badRequest(.init()) + return .badRequest(.init()) case 404: - .notFound(.init()) + return .notFound(.init()) case 405: - .methodNotAllowed(.init()) + return .methodNotAllowed(.init()) default: - .undocumented( + return .undocumented( statusCode: response.status.code, .init( headerFields: response.headerFields, @@ -192,7 +179,6 @@ public struct Client: APIProtocol { } ) } - /// Removes metadata from an item. /// /// - Remark: HTTP `DELETE /items/{itemname}/metadata/{namespace}`. @@ -219,13 +205,13 @@ public struct Client: APIProtocol { deserializer: { response, responseBody in switch response.status.code { case 200: - .ok(.init()) + return .ok(.init()) case 404: - .notFound(.init()) + return .notFound(.init()) case 405: - .methodNotAllowed(.init()) + return .methodNotAllowed(.init()) default: - .undocumented( + return .undocumented( statusCode: response.status.code, .init( headerFields: response.headerFields, @@ -236,7 +222,6 @@ public struct Client: APIProtocol { } ) } - /// Adds a tag to an item. /// /// - Remark: HTTP `PUT /items/{itemname}/tags/{tag}`. @@ -263,13 +248,13 @@ public struct Client: APIProtocol { deserializer: { response, responseBody in switch response.status.code { case 200: - .ok(.init()) + return .ok(.init()) case 404: - .notFound(.init()) + return .notFound(.init()) case 405: - .methodNotAllowed(.init()) + return .methodNotAllowed(.init()) default: - .undocumented( + return .undocumented( statusCode: response.status.code, .init( headerFields: response.headerFields, @@ -280,7 +265,6 @@ public struct Client: APIProtocol { } ) } - /// Removes a tag from an item. /// /// - Remark: HTTP `DELETE /items/{itemname}/tags/{tag}`. @@ -307,13 +291,13 @@ public struct Client: APIProtocol { deserializer: { response, responseBody in switch response.status.code { case 200: - .ok(.init()) + return .ok(.init()) case 404: - .notFound(.init()) + return .notFound(.init()) case 405: - .methodNotAllowed(.init()) + return .methodNotAllowed(.init()) default: - .undocumented( + return .undocumented( statusCode: response.status.code, .init( headerFields: response.headerFields, @@ -324,7 +308,6 @@ public struct Client: APIProtocol { } ) } - /// Gets a single item. /// /// - Remark: HTTP `GET /items/{itemname}`. @@ -408,7 +391,6 @@ public struct Client: APIProtocol { } ) } - /// Sends a command to an item. /// /// - Remark: HTTP `POST /items/{itemname}`. @@ -429,9 +411,10 @@ public struct Client: APIProtocol { method: .post ) suppressMutabilityWarning(&request) - let body: OpenAPIRuntime.HTTPBody? = switch input.body { + let body: OpenAPIRuntime.HTTPBody? + switch input.body { case let .plainText(value): - try converter.setRequiredRequestBodyAsBinary( + body = try converter.setRequiredRequestBodyAsBinary( value, headerFields: &request.headerFields, contentType: "text/plain" @@ -442,13 +425,13 @@ public struct Client: APIProtocol { deserializer: { response, responseBody in switch response.status.code { case 200: - .ok(.init()) + return .ok(.init()) case 400: - .badRequest(.init()) + return .badRequest(.init()) case 404: - .notFound(.init()) + return .notFound(.init()) default: - .undocumented( + return .undocumented( statusCode: response.status.code, .init( headerFields: response.headerFields, @@ -459,7 +442,6 @@ public struct Client: APIProtocol { } ) } - /// Adds a new item to the registry or updates the existing item. /// /// - Remark: HTTP `PUT /items/{itemname}`. @@ -489,9 +471,10 @@ public struct Client: APIProtocol { in: &request.headerFields, contentTypes: input.headers.accept ) - let body: OpenAPIRuntime.HTTPBody? = switch input.body { + let body: OpenAPIRuntime.HTTPBody? + switch input.body { case let .json(value): - try converter.setRequiredRequestBodyAsJSON( + body = try converter.setRequiredRequestBodyAsJSON( value, headerFields: &request.headerFields, contentType: "application/json; charset=utf-8" @@ -543,7 +526,6 @@ public struct Client: APIProtocol { } ) } - /// Removes an item from the registry. /// /// - Remark: HTTP `DELETE /items/{itemname}`. @@ -569,11 +551,11 @@ public struct Client: APIProtocol { deserializer: { response, responseBody in switch response.status.code { case 200: - .ok(.init()) + return .ok(.init()) case 404: - .notFound(.init()) + return .notFound(.init()) default: - .undocumented( + return .undocumented( statusCode: response.status.code, .init( headerFields: response.headerFields, @@ -584,7 +566,6 @@ public struct Client: APIProtocol { } ) } - /// Get all available items. /// /// - Remark: HTTP `GET /items`. @@ -692,7 +673,6 @@ public struct Client: APIProtocol { } ) } - /// Adds a list of items to the registry or updates the existing items. /// /// - Remark: HTTP `PUT /items`. @@ -715,9 +695,10 @@ public struct Client: APIProtocol { in: &request.headerFields, contentTypes: input.headers.accept ) - let body: OpenAPIRuntime.HTTPBody? = switch input.body { + let body: OpenAPIRuntime.HTTPBody? + switch input.body { case let .json(value): - try converter.setRequiredRequestBodyAsJSON( + body = try converter.setRequiredRequestBodyAsJSON( value, headerFields: &request.headerFields, contentType: "application/json; charset=utf-8" @@ -763,7 +744,6 @@ public struct Client: APIProtocol { } ) } - /// Gets the state of an item. /// /// - Remark: HTTP `GET /items/{itemname}/state`. @@ -828,7 +808,6 @@ public struct Client: APIProtocol { } ) } - /// Updates the state of an item. /// /// - Remark: HTTP `PUT /items/{itemname}/state`. @@ -854,9 +833,10 @@ public struct Client: APIProtocol { name: "Accept-Language", value: input.headers.Accept_hyphen_Language ) - let body: OpenAPIRuntime.HTTPBody? = switch input.body { + let body: OpenAPIRuntime.HTTPBody? + switch input.body { case let .plainText(value): - try converter.setRequiredRequestBodyAsBinary( + body = try converter.setRequiredRequestBodyAsBinary( value, headerFields: &request.headerFields, contentType: "text/plain" @@ -867,13 +847,13 @@ public struct Client: APIProtocol { deserializer: { response, responseBody in switch response.status.code { case 202: - .accepted(.init()) + return .accepted(.init()) case 400: - .badRequest(.init()) + return .badRequest(.init()) case 404: - .notFound(.init()) + return .notFound(.init()) default: - .undocumented( + return .undocumented( statusCode: response.status.code, .init( headerFields: response.headerFields, @@ -884,7 +864,6 @@ public struct Client: APIProtocol { } ) } - /// Gets the namespace of an item. /// /// - Remark: HTTP `GET /items/{itemname}/metadata/namespaces`. @@ -954,7 +933,6 @@ public struct Client: APIProtocol { } ) } - /// Gets the item which defines the requested semantics of an item. /// /// - Remark: HTTP `GET /items/{itemName}/semantic/{semanticClass}`. @@ -986,11 +964,11 @@ public struct Client: APIProtocol { deserializer: { response, responseBody in switch response.status.code { case 200: - .ok(.init()) + return .ok(.init()) case 404: - .notFound(.init()) + return .notFound(.init()) default: - .undocumented( + return .undocumented( statusCode: response.status.code, .init( headerFields: response.headerFields, @@ -1001,7 +979,6 @@ public struct Client: APIProtocol { } ) } - /// Remove unused/orphaned metadata. /// /// - Remark: HTTP `POST /items/metadata/purge`. @@ -1010,7 +987,7 @@ public struct Client: APIProtocol { try await client.send( input: input, forOperation: Operations.purgeDatabase.id, - serializer: { _ in + serializer: { input in let path = try converter.renderedPath( template: "/items/metadata/purge", parameters: [] @@ -1025,9 +1002,9 @@ public struct Client: APIProtocol { deserializer: { response, responseBody in switch response.status.code { case 200: - .ok(.init()) + return .ok(.init()) default: - .undocumented( + return .undocumented( statusCode: response.status.code, .init( headerFields: response.headerFields, @@ -1038,7 +1015,6 @@ public struct Client: APIProtocol { } ) } - /// Creates a sitemap event subscription. /// /// - Remark: HTTP `POST /sitemaps/events/subscribe`. @@ -1103,7 +1079,6 @@ public struct Client: APIProtocol { } ) } - /// Polls the data for one page of a sitemap. /// /// - Remark: HTTP `GET /sitemaps/{sitemapname}/{pageid}`. @@ -1195,7 +1170,6 @@ public struct Client: APIProtocol { } ) } - /// Polls the data for a whole sitemap. Not recommended due to potentially high traffic. /// /// - Remark: HTTP `GET /sitemaps/{sitemapname}/*`. @@ -1286,7 +1260,6 @@ public struct Client: APIProtocol { } ) } - /// Get sitemap by name. /// /// - Remark: HTTP `GET /sitemaps/{sitemapname}`. @@ -1375,7 +1348,6 @@ public struct Client: APIProtocol { } ) } - /// Get sitemap events for a whole sitemap. Not recommended due to potentially high traffic. /// /// - Remark: HTTP `GET /sitemaps/events/{subscriptionid}/*`. @@ -1408,13 +1380,13 @@ public struct Client: APIProtocol { deserializer: { response, responseBody in switch response.status.code { case 200: - .ok(.init()) + return .ok(.init()) case 400: - .badRequest(.init()) + return .badRequest(.init()) case 404: - .notFound(.init()) + return .notFound(.init()) default: - .undocumented( + return .undocumented( statusCode: response.status.code, .init( headerFields: response.headerFields, @@ -1425,7 +1397,6 @@ public struct Client: APIProtocol { } ) } - /// Get sitemap events. /// /// - Remark: HTTP `GET /sitemaps/events/{subscriptionid}`. @@ -1515,7 +1486,6 @@ public struct Client: APIProtocol { } ) } - /// Get all available sitemaps. /// /// - Remark: HTTP `GET /sitemaps`. @@ -1576,7 +1546,6 @@ public struct Client: APIProtocol { } ) } - /// Get all registered UI components in the specified namespace. /// /// - Remark: HTTP `GET /ui/components/{namespace}`. @@ -1646,7 +1615,6 @@ public struct Client: APIProtocol { } ) } - /// Add a UI component in the specified namespace. /// /// - Remark: HTTP `POST /ui/components/{namespace}`. @@ -1671,11 +1639,12 @@ public struct Client: APIProtocol { in: &request.headerFields, contentTypes: input.headers.accept ) - let body: OpenAPIRuntime.HTTPBody? = switch input.body { + let body: OpenAPIRuntime.HTTPBody? + switch input.body { case .none: - nil + body = nil case let .json(value): - try converter.setOptionalRequestBodyAsJSON( + body = try converter.setOptionalRequestBodyAsJSON( value, headerFields: &request.headerFields, contentType: "application/json; charset=utf-8" @@ -1719,7 +1688,6 @@ public struct Client: APIProtocol { } ) } - /// Get a specific UI component in the specified namespace. /// /// - Remark: HTTP `GET /ui/components/{namespace}/{componentUID}`. @@ -1785,7 +1753,6 @@ public struct Client: APIProtocol { } ) } - /// Update a specific UI component in the specified namespace. /// /// - Remark: HTTP `PUT /ui/components/{namespace}/{componentUID}`. @@ -1811,11 +1778,12 @@ public struct Client: APIProtocol { in: &request.headerFields, contentTypes: input.headers.accept ) - let body: OpenAPIRuntime.HTTPBody? = switch input.body { + let body: OpenAPIRuntime.HTTPBody? + switch input.body { case .none: - nil + body = nil case let .json(value): - try converter.setOptionalRequestBodyAsJSON( + body = try converter.setOptionalRequestBodyAsJSON( value, headerFields: &request.headerFields, contentType: "application/json; charset=utf-8" @@ -1861,7 +1829,6 @@ public struct Client: APIProtocol { } ) } - /// Remove a specific UI component in the specified namespace. /// /// - Remark: HTTP `DELETE /ui/components/{namespace}/{componentUID}`. @@ -1888,11 +1855,11 @@ public struct Client: APIProtocol { deserializer: { response, responseBody in switch response.status.code { case 200: - .ok(.init()) + return .ok(.init()) case 404: - .notFound(.init()) + return .notFound(.init()) default: - .undocumented( + return .undocumented( statusCode: response.status.code, .init( headerFields: response.headerFields, @@ -1903,7 +1870,6 @@ public struct Client: APIProtocol { } ) } - /// Get all registered UI tiles. /// /// - Remark: HTTP `GET /ui/tiles`. diff --git a/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Types.swift b/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Types.swift index a103bd6a6..59399c13f 100644 --- a/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Types.swift +++ b/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Types.swift @@ -1,24 +1,13 @@ -// Copyright (c) 2010-2025 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 - // Generated by swift-openapi-generator, do not modify. @_spi(Generated) import OpenAPIRuntime #if os(Linux) +@preconcurrency import struct Foundation.URL @preconcurrency import struct Foundation.Data @preconcurrency import struct Foundation.Date -@preconcurrency import struct Foundation.URL #else +import struct Foundation.URL import struct Foundation.Data import struct Foundation.Date -import struct Foundation.URL #endif /// A type that performs HTTP operations defined by the OpenAPI document. public protocol APIProtocol: Sendable { @@ -175,346 +164,364 @@ public protocol APIProtocol: Sendable { } /// Convenience overloads for operation inputs. -public extension APIProtocol { +extension APIProtocol { /// Adds a new member to a group item. /// /// - Remark: HTTP `PUT /items/{itemName}/members/{memberItemName}`. /// - Remark: Generated from `#/paths//items/{itemName}/members/{memberItemName}/put(addMemberToGroupItem)`. - func addMemberToGroupItem(path: Operations.addMemberToGroupItem.Input.Path) async throws -> Operations.addMemberToGroupItem.Output { + public func addMemberToGroupItem(path: Operations.addMemberToGroupItem.Input.Path) async throws -> Operations.addMemberToGroupItem.Output { try await addMemberToGroupItem(Operations.addMemberToGroupItem.Input(path: path)) } - /// Removes an existing member from a group item. /// /// - Remark: HTTP `DELETE /items/{itemName}/members/{memberItemName}`. /// - Remark: Generated from `#/paths//items/{itemName}/members/{memberItemName}/delete(removeMemberFromGroupItem)`. - func removeMemberFromGroupItem(path: Operations.removeMemberFromGroupItem.Input.Path) async throws -> Operations.removeMemberFromGroupItem.Output { + public func removeMemberFromGroupItem(path: Operations.removeMemberFromGroupItem.Input.Path) async throws -> Operations.removeMemberFromGroupItem.Output { try await removeMemberFromGroupItem(Operations.removeMemberFromGroupItem.Input(path: path)) } - /// Adds metadata to an item. /// /// - Remark: HTTP `PUT /items/{itemname}/metadata/{namespace}`. /// - Remark: Generated from `#/paths//items/{itemname}/metadata/{namespace}/put(addMetadataToItem)`. - func addMetadataToItem(path: Operations.addMetadataToItem.Input.Path, - body: Operations.addMetadataToItem.Input.Body) async throws -> Operations.addMetadataToItem.Output { + public func addMetadataToItem( + path: Operations.addMetadataToItem.Input.Path, + body: Operations.addMetadataToItem.Input.Body + ) async throws -> Operations.addMetadataToItem.Output { try await addMetadataToItem(Operations.addMetadataToItem.Input( path: path, body: body )) } - /// Removes metadata from an item. /// /// - Remark: HTTP `DELETE /items/{itemname}/metadata/{namespace}`. /// - Remark: Generated from `#/paths//items/{itemname}/metadata/{namespace}/delete(removeMetadataFromItem)`. - func removeMetadataFromItem(path: Operations.removeMetadataFromItem.Input.Path) async throws -> Operations.removeMetadataFromItem.Output { + public func removeMetadataFromItem(path: Operations.removeMetadataFromItem.Input.Path) async throws -> Operations.removeMetadataFromItem.Output { try await removeMetadataFromItem(Operations.removeMetadataFromItem.Input(path: path)) } - /// Adds a tag to an item. /// /// - Remark: HTTP `PUT /items/{itemname}/tags/{tag}`. /// - Remark: Generated from `#/paths//items/{itemname}/tags/{tag}/put(addTagToItem)`. - func addTagToItem(path: Operations.addTagToItem.Input.Path) async throws -> Operations.addTagToItem.Output { + public func addTagToItem(path: Operations.addTagToItem.Input.Path) async throws -> Operations.addTagToItem.Output { try await addTagToItem(Operations.addTagToItem.Input(path: path)) } - /// Removes a tag from an item. /// /// - Remark: HTTP `DELETE /items/{itemname}/tags/{tag}`. /// - Remark: Generated from `#/paths//items/{itemname}/tags/{tag}/delete(removeTagFromItem)`. - func removeTagFromItem(path: Operations.removeTagFromItem.Input.Path) async throws -> Operations.removeTagFromItem.Output { + public func removeTagFromItem(path: Operations.removeTagFromItem.Input.Path) async throws -> Operations.removeTagFromItem.Output { try await removeTagFromItem(Operations.removeTagFromItem.Input(path: path)) } - /// Gets a single item. /// /// - Remark: HTTP `GET /items/{itemname}`. /// - Remark: Generated from `#/paths//items/{itemname}/get(getItemByName)`. - func getItemByName(path: Operations.getItemByName.Input.Path, - query: Operations.getItemByName.Input.Query = .init(), - headers: Operations.getItemByName.Input.Headers = .init()) async throws -> Operations.getItemByName.Output { + public func getItemByName( + path: Operations.getItemByName.Input.Path, + query: Operations.getItemByName.Input.Query = .init(), + headers: Operations.getItemByName.Input.Headers = .init() + ) async throws -> Operations.getItemByName.Output { try await getItemByName(Operations.getItemByName.Input( path: path, query: query, headers: headers )) } - /// Sends a command to an item. /// /// - Remark: HTTP `POST /items/{itemname}`. /// - Remark: Generated from `#/paths//items/{itemname}/post(sendItemCommand)`. - func sendItemCommand(path: Operations.sendItemCommand.Input.Path, - body: Operations.sendItemCommand.Input.Body) async throws -> Operations.sendItemCommand.Output { + public func sendItemCommand( + path: Operations.sendItemCommand.Input.Path, + body: Operations.sendItemCommand.Input.Body + ) async throws -> Operations.sendItemCommand.Output { try await sendItemCommand(Operations.sendItemCommand.Input( path: path, body: body )) } - /// Adds a new item to the registry or updates the existing item. /// /// - Remark: HTTP `PUT /items/{itemname}`. /// - Remark: Generated from `#/paths//items/{itemname}/put(addOrUpdateItemInRegistry)`. - func addOrUpdateItemInRegistry(path: Operations.addOrUpdateItemInRegistry.Input.Path, - headers: Operations.addOrUpdateItemInRegistry.Input.Headers = .init(), - body: Operations.addOrUpdateItemInRegistry.Input.Body) async throws -> Operations.addOrUpdateItemInRegistry.Output { + public func addOrUpdateItemInRegistry( + path: Operations.addOrUpdateItemInRegistry.Input.Path, + headers: Operations.addOrUpdateItemInRegistry.Input.Headers = .init(), + body: Operations.addOrUpdateItemInRegistry.Input.Body + ) async throws -> Operations.addOrUpdateItemInRegistry.Output { try await addOrUpdateItemInRegistry(Operations.addOrUpdateItemInRegistry.Input( path: path, headers: headers, body: body )) } - /// Removes an item from the registry. /// /// - Remark: HTTP `DELETE /items/{itemname}`. /// - Remark: Generated from `#/paths//items/{itemname}/delete(removeItemFromRegistry)`. - func removeItemFromRegistry(path: Operations.removeItemFromRegistry.Input.Path) async throws -> Operations.removeItemFromRegistry.Output { + public func removeItemFromRegistry(path: Operations.removeItemFromRegistry.Input.Path) async throws -> Operations.removeItemFromRegistry.Output { try await removeItemFromRegistry(Operations.removeItemFromRegistry.Input(path: path)) } - /// Get all available items. /// /// - Remark: HTTP `GET /items`. /// - Remark: Generated from `#/paths//items/get(getItems)`. - func getItems(query: Operations.getItems.Input.Query = .init(), - headers: Operations.getItems.Input.Headers = .init()) async throws -> Operations.getItems.Output { + public func getItems( + query: Operations.getItems.Input.Query = .init(), + headers: Operations.getItems.Input.Headers = .init() + ) async throws -> Operations.getItems.Output { try await getItems(Operations.getItems.Input( query: query, headers: headers )) } - /// Adds a list of items to the registry or updates the existing items. /// /// - Remark: HTTP `PUT /items`. /// - Remark: Generated from `#/paths//items/put(addOrUpdateItemsInRegistry)`. - func addOrUpdateItemsInRegistry(headers: Operations.addOrUpdateItemsInRegistry.Input.Headers = .init(), - body: Operations.addOrUpdateItemsInRegistry.Input.Body) async throws -> Operations.addOrUpdateItemsInRegistry.Output { + public func addOrUpdateItemsInRegistry( + headers: Operations.addOrUpdateItemsInRegistry.Input.Headers = .init(), + body: Operations.addOrUpdateItemsInRegistry.Input.Body + ) async throws -> Operations.addOrUpdateItemsInRegistry.Output { try await addOrUpdateItemsInRegistry(Operations.addOrUpdateItemsInRegistry.Input( headers: headers, body: body )) } - /// Gets the state of an item. /// /// - Remark: HTTP `GET /items/{itemname}/state`. /// - Remark: Generated from `#/paths//items/{itemname}/state/get(getItemState_1)`. - func getItemState_1(path: Operations.getItemState_1.Input.Path, - headers: Operations.getItemState_1.Input.Headers = .init()) async throws -> Operations.getItemState_1.Output { + public func getItemState_1( + path: Operations.getItemState_1.Input.Path, + headers: Operations.getItemState_1.Input.Headers = .init() + ) async throws -> Operations.getItemState_1.Output { try await getItemState_1(Operations.getItemState_1.Input( path: path, headers: headers )) } - /// Updates the state of an item. /// /// - Remark: HTTP `PUT /items/{itemname}/state`. /// - Remark: Generated from `#/paths//items/{itemname}/state/put(updateItemState)`. - func updateItemState(path: Operations.updateItemState.Input.Path, - headers: Operations.updateItemState.Input.Headers = .init(), - body: Operations.updateItemState.Input.Body) async throws -> Operations.updateItemState.Output { + public func updateItemState( + path: Operations.updateItemState.Input.Path, + headers: Operations.updateItemState.Input.Headers = .init(), + body: Operations.updateItemState.Input.Body + ) async throws -> Operations.updateItemState.Output { try await updateItemState(Operations.updateItemState.Input( path: path, headers: headers, body: body )) } - /// Gets the namespace of an item. /// /// - Remark: HTTP `GET /items/{itemname}/metadata/namespaces`. /// - Remark: Generated from `#/paths//items/{itemname}/metadata/namespaces/get(getItemNamespaces)`. - func getItemNamespaces(path: Operations.getItemNamespaces.Input.Path, - headers: Operations.getItemNamespaces.Input.Headers = .init()) async throws -> Operations.getItemNamespaces.Output { + public func getItemNamespaces( + path: Operations.getItemNamespaces.Input.Path, + headers: Operations.getItemNamespaces.Input.Headers = .init() + ) async throws -> Operations.getItemNamespaces.Output { try await getItemNamespaces(Operations.getItemNamespaces.Input( path: path, headers: headers )) } - /// Gets the item which defines the requested semantics of an item. /// /// - Remark: HTTP `GET /items/{itemName}/semantic/{semanticClass}`. /// - Remark: Generated from `#/paths//items/{itemName}/semantic/{semanticClass}/get(getSemanticItem)`. - func getSemanticItem(path: Operations.getSemanticItem.Input.Path, - headers: Operations.getSemanticItem.Input.Headers = .init()) async throws -> Operations.getSemanticItem.Output { + public func getSemanticItem( + path: Operations.getSemanticItem.Input.Path, + headers: Operations.getSemanticItem.Input.Headers = .init() + ) async throws -> Operations.getSemanticItem.Output { try await getSemanticItem(Operations.getSemanticItem.Input( path: path, headers: headers )) } - /// Remove unused/orphaned metadata. /// /// - Remark: HTTP `POST /items/metadata/purge`. /// - Remark: Generated from `#/paths//items/metadata/purge/post(purgeDatabase)`. - func purgeDatabase() async throws -> Operations.purgeDatabase.Output { + public func purgeDatabase() async throws -> Operations.purgeDatabase.Output { try await purgeDatabase(Operations.purgeDatabase.Input()) } - /// Creates a sitemap event subscription. /// /// - Remark: HTTP `POST /sitemaps/events/subscribe`. /// - Remark: Generated from `#/paths//sitemaps/events/subscribe/post(createSitemapEventSubscription)`. - func createSitemapEventSubscription(headers: Operations.createSitemapEventSubscription.Input.Headers = .init()) async throws -> Operations.createSitemapEventSubscription.Output { + public func createSitemapEventSubscription(headers: Operations.createSitemapEventSubscription.Input.Headers = .init()) async throws -> Operations.createSitemapEventSubscription.Output { try await createSitemapEventSubscription(Operations.createSitemapEventSubscription.Input(headers: headers)) } - /// Polls the data for one page of a sitemap. /// /// - Remark: HTTP `GET /sitemaps/{sitemapname}/{pageid}`. /// - Remark: Generated from `#/paths//sitemaps/{sitemapname}/{pageid}/get(pollDataForPage)`. - func pollDataForPage(path: Operations.pollDataForPage.Input.Path, - query: Operations.pollDataForPage.Input.Query = .init(), - headers: Operations.pollDataForPage.Input.Headers = .init()) async throws -> Operations.pollDataForPage.Output { + public func pollDataForPage( + path: Operations.pollDataForPage.Input.Path, + query: Operations.pollDataForPage.Input.Query = .init(), + headers: Operations.pollDataForPage.Input.Headers = .init() + ) async throws -> Operations.pollDataForPage.Output { try await pollDataForPage(Operations.pollDataForPage.Input( path: path, query: query, headers: headers )) } - /// Polls the data for a whole sitemap. Not recommended due to potentially high traffic. /// /// - Remark: HTTP `GET /sitemaps/{sitemapname}/*`. /// - Remark: Generated from `#/paths//sitemaps/{sitemapname}/*/get(pollDataForSitemap)`. - func pollDataForSitemap(path: Operations.pollDataForSitemap.Input.Path, - query: Operations.pollDataForSitemap.Input.Query = .init(), - headers: Operations.pollDataForSitemap.Input.Headers = .init()) async throws -> Operations.pollDataForSitemap.Output { + public func pollDataForSitemap( + path: Operations.pollDataForSitemap.Input.Path, + query: Operations.pollDataForSitemap.Input.Query = .init(), + headers: Operations.pollDataForSitemap.Input.Headers = .init() + ) async throws -> Operations.pollDataForSitemap.Output { try await pollDataForSitemap(Operations.pollDataForSitemap.Input( path: path, query: query, headers: headers )) } - /// Get sitemap by name. /// /// - Remark: HTTP `GET /sitemaps/{sitemapname}`. /// - Remark: Generated from `#/paths//sitemaps/{sitemapname}/get(getSitemapByName)`. - func getSitemapByName(path: Operations.getSitemapByName.Input.Path, - query: Operations.getSitemapByName.Input.Query = .init(), - headers: Operations.getSitemapByName.Input.Headers = .init()) async throws -> Operations.getSitemapByName.Output { + public func getSitemapByName( + path: Operations.getSitemapByName.Input.Path, + query: Operations.getSitemapByName.Input.Query = .init(), + headers: Operations.getSitemapByName.Input.Headers = .init() + ) async throws -> Operations.getSitemapByName.Output { try await getSitemapByName(Operations.getSitemapByName.Input( path: path, query: query, headers: headers )) } - /// Get sitemap events for a whole sitemap. Not recommended due to potentially high traffic. /// /// - Remark: HTTP `GET /sitemaps/events/{subscriptionid}/*`. /// - Remark: Generated from `#/paths//sitemaps/events/{subscriptionid}/*/get(getSitemapEvents)`. - func getSitemapEvents(path: Operations.getSitemapEvents.Input.Path, - query: Operations.getSitemapEvents.Input.Query = .init()) async throws -> Operations.getSitemapEvents.Output { + public func getSitemapEvents( + path: Operations.getSitemapEvents.Input.Path, + query: Operations.getSitemapEvents.Input.Query = .init() + ) async throws -> Operations.getSitemapEvents.Output { try await getSitemapEvents(Operations.getSitemapEvents.Input( path: path, query: query )) } - /// Get sitemap events. /// /// - Remark: HTTP `GET /sitemaps/events/{subscriptionid}`. /// - Remark: Generated from `#/paths//sitemaps/events/{subscriptionid}/get(getSitemapEvents_1)`. - func getSitemapEvents_1(path: Operations.getSitemapEvents_1.Input.Path, - query: Operations.getSitemapEvents_1.Input.Query = .init(), - headers: Operations.getSitemapEvents_1.Input.Headers = .init()) async throws -> Operations.getSitemapEvents_1.Output { + public func getSitemapEvents_1( + path: Operations.getSitemapEvents_1.Input.Path, + query: Operations.getSitemapEvents_1.Input.Query = .init(), + headers: Operations.getSitemapEvents_1.Input.Headers = .init() + ) async throws -> Operations.getSitemapEvents_1.Output { try await getSitemapEvents_1(Operations.getSitemapEvents_1.Input( path: path, query: query, headers: headers )) } - /// Get all available sitemaps. /// /// - Remark: HTTP `GET /sitemaps`. /// - Remark: Generated from `#/paths//sitemaps/get(getSitemaps)`. - func getSitemaps(headers: Operations.getSitemaps.Input.Headers = .init()) async throws -> Operations.getSitemaps.Output { + public func getSitemaps(headers: Operations.getSitemaps.Input.Headers = .init()) async throws -> Operations.getSitemaps.Output { try await getSitemaps(Operations.getSitemaps.Input(headers: headers)) } - /// Get all registered UI components in the specified namespace. /// /// - Remark: HTTP `GET /ui/components/{namespace}`. /// - Remark: Generated from `#/paths//ui/components/{namespace}/get(getRegisteredUIComponentsInNamespace)`. - func getRegisteredUIComponentsInNamespace(path: Operations.getRegisteredUIComponentsInNamespace.Input.Path, - query: Operations.getRegisteredUIComponentsInNamespace.Input.Query = .init(), - headers: Operations.getRegisteredUIComponentsInNamespace.Input.Headers = .init()) async throws -> Operations.getRegisteredUIComponentsInNamespace.Output { + public func getRegisteredUIComponentsInNamespace( + path: Operations.getRegisteredUIComponentsInNamespace.Input.Path, + query: Operations.getRegisteredUIComponentsInNamespace.Input.Query = .init(), + headers: Operations.getRegisteredUIComponentsInNamespace.Input.Headers = .init() + ) async throws -> Operations.getRegisteredUIComponentsInNamespace.Output { try await getRegisteredUIComponentsInNamespace(Operations.getRegisteredUIComponentsInNamespace.Input( path: path, query: query, headers: headers )) } - /// Add a UI component in the specified namespace. /// /// - Remark: HTTP `POST /ui/components/{namespace}`. /// - Remark: Generated from `#/paths//ui/components/{namespace}/post(addUIComponentToNamespace)`. - func addUIComponentToNamespace(path: Operations.addUIComponentToNamespace.Input.Path, - headers: Operations.addUIComponentToNamespace.Input.Headers = .init(), - body: Operations.addUIComponentToNamespace.Input.Body? = nil) async throws -> Operations.addUIComponentToNamespace.Output { + public func addUIComponentToNamespace( + path: Operations.addUIComponentToNamespace.Input.Path, + headers: Operations.addUIComponentToNamespace.Input.Headers = .init(), + body: Operations.addUIComponentToNamespace.Input.Body? = nil + ) async throws -> Operations.addUIComponentToNamespace.Output { try await addUIComponentToNamespace(Operations.addUIComponentToNamespace.Input( path: path, headers: headers, body: body )) } - /// Get a specific UI component in the specified namespace. /// /// - Remark: HTTP `GET /ui/components/{namespace}/{componentUID}`. /// - Remark: Generated from `#/paths//ui/components/{namespace}/{componentUID}/get(getUIComponentInNamespace)`. - func getUIComponentInNamespace(path: Operations.getUIComponentInNamespace.Input.Path, - headers: Operations.getUIComponentInNamespace.Input.Headers = .init()) async throws -> Operations.getUIComponentInNamespace.Output { + public func getUIComponentInNamespace( + path: Operations.getUIComponentInNamespace.Input.Path, + headers: Operations.getUIComponentInNamespace.Input.Headers = .init() + ) async throws -> Operations.getUIComponentInNamespace.Output { try await getUIComponentInNamespace(Operations.getUIComponentInNamespace.Input( path: path, headers: headers )) } - /// Update a specific UI component in the specified namespace. /// /// - Remark: HTTP `PUT /ui/components/{namespace}/{componentUID}`. /// - Remark: Generated from `#/paths//ui/components/{namespace}/{componentUID}/put(updateUIComponentInNamespace)`. - func updateUIComponentInNamespace(path: Operations.updateUIComponentInNamespace.Input.Path, - headers: Operations.updateUIComponentInNamespace.Input.Headers = .init(), - body: Operations.updateUIComponentInNamespace.Input.Body? = nil) async throws -> Operations.updateUIComponentInNamespace.Output { + public func updateUIComponentInNamespace( + path: Operations.updateUIComponentInNamespace.Input.Path, + headers: Operations.updateUIComponentInNamespace.Input.Headers = .init(), + body: Operations.updateUIComponentInNamespace.Input.Body? = nil + ) async throws -> Operations.updateUIComponentInNamespace.Output { try await updateUIComponentInNamespace(Operations.updateUIComponentInNamespace.Input( path: path, headers: headers, body: body )) } - /// Remove a specific UI component in the specified namespace. /// /// - Remark: HTTP `DELETE /ui/components/{namespace}/{componentUID}`. /// - Remark: Generated from `#/paths//ui/components/{namespace}/{componentUID}/delete(removeUIComponentFromNamespace)`. - func removeUIComponentFromNamespace(path: Operations.removeUIComponentFromNamespace.Input.Path) async throws -> Operations.removeUIComponentFromNamespace.Output { + public func removeUIComponentFromNamespace(path: Operations.removeUIComponentFromNamespace.Input.Path) async throws -> Operations.removeUIComponentFromNamespace.Output { try await removeUIComponentFromNamespace(Operations.removeUIComponentFromNamespace.Input(path: path)) } - /// Get all registered UI tiles. /// /// - Remark: HTTP `GET /ui/tiles`. /// - Remark: Generated from `#/paths//ui/tiles/get(getUITiles)`. - func getUITiles(headers: Operations.getUITiles.Input.Headers = .init()) async throws -> Operations.getUITiles.Output { + public func getUITiles(headers: Operations.getUITiles.Input.Headers = .init()) async throws -> Operations.getUITiles.Output { try await getUITiles(Operations.getUITiles.Input(headers: headers)) } } /// Server URLs defined in the OpenAPI document. public enum Servers { + public enum Server1 { + public static func url() throws -> Foundation.URL { + try Foundation.URL( + validatingOpenAPIServerURL: "/rest", + variables: [] + ) + } + } + @available(*, deprecated, renamed: "Servers.Server1.url") public static func server1() throws -> Foundation.URL { try Foundation.URL( validatingOpenAPIServerURL: "/rest", @@ -543,12 +550,11 @@ public enum Components { public var required: Swift.Bool? /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterDTO/type`. @frozen public enum _typePayload: String, Codable, Hashable, Sendable, CaseIterable { - case TEXT - case INTEGER - case DECIMAL - case BOOLEAN + case TEXT = "TEXT" + case INTEGER = "INTEGER" + case DECIMAL = "DECIMAL" + case BOOLEAN = "BOOLEAN" } - /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterDTO/type`. public var _type: Components.Schemas.ConfigDescriptionParameterDTO._typePayload? /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterDTO/min`. @@ -606,28 +612,30 @@ public enum Components { /// - unitLabel: /// - options: /// - filterCriteria: - public init(context: Swift.String? = nil, - defaultValue: Swift.String? = nil, - description: Swift.String? = nil, - label: Swift.String? = nil, - name: Swift.String? = nil, - required: Swift.Bool? = nil, - _type: Components.Schemas.ConfigDescriptionParameterDTO._typePayload? = nil, - min: Swift.Double? = nil, - max: Swift.Double? = nil, - stepsize: Swift.Double? = nil, - pattern: Swift.String? = nil, - readOnly: Swift.Bool? = nil, - multiple: Swift.Bool? = nil, - multipleLimit: Swift.Int32? = nil, - groupName: Swift.String? = nil, - advanced: Swift.Bool? = nil, - verify: Swift.Bool? = nil, - limitToOptions: Swift.Bool? = nil, - unit: Swift.String? = nil, - unitLabel: Swift.String? = nil, - options: [Components.Schemas.ParameterOptionDTO]? = nil, - filterCriteria: [Components.Schemas.FilterCriteriaDTO]? = nil) { + public init( + context: Swift.String? = nil, + defaultValue: Swift.String? = nil, + description: Swift.String? = nil, + label: Swift.String? = nil, + name: Swift.String? = nil, + required: Swift.Bool? = nil, + _type: Components.Schemas.ConfigDescriptionParameterDTO._typePayload? = nil, + min: Swift.Double? = nil, + max: Swift.Double? = nil, + stepsize: Swift.Double? = nil, + pattern: Swift.String? = nil, + readOnly: Swift.Bool? = nil, + multiple: Swift.Bool? = nil, + multipleLimit: Swift.Int32? = nil, + groupName: Swift.String? = nil, + advanced: Swift.Bool? = nil, + verify: Swift.Bool? = nil, + limitToOptions: Swift.Bool? = nil, + unit: Swift.String? = nil, + unitLabel: Swift.String? = nil, + options: [Components.Schemas.ParameterOptionDTO]? = nil, + filterCriteria: [Components.Schemas.FilterCriteriaDTO]? = nil + ) { self.context = context self.defaultValue = defaultValue self.description = description @@ -651,7 +659,6 @@ public enum Components { self.options = options self.filterCriteria = filterCriteria } - public enum CodingKeys: String, CodingKey { case context case defaultValue @@ -677,7 +684,6 @@ public enum Components { case filterCriteria } } - /// - Remark: Generated from `#/components/schemas/FilterCriteriaDTO`. public struct FilterCriteriaDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/FilterCriteriaDTO/value`. @@ -689,18 +695,18 @@ public enum Components { /// - Parameters: /// - value: /// - name: - public init(value: Swift.String? = nil, - name: Swift.String? = nil) { + public init( + value: Swift.String? = nil, + name: Swift.String? = nil + ) { self.value = value self.name = name } - public enum CodingKeys: String, CodingKey { case value case name } } - /// - Remark: Generated from `#/components/schemas/ParameterOptionDTO`. public struct ParameterOptionDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/ParameterOptionDTO/label`. @@ -712,18 +718,18 @@ public enum Components { /// - Parameters: /// - label: /// - value: - public init(label: Swift.String? = nil, - value: Swift.String? = nil) { + public init( + label: Swift.String? = nil, + value: Swift.String? = nil + ) { self.label = label self.value = value } - public enum CodingKeys: String, CodingKey { case label case value } } - /// - Remark: Generated from `#/components/schemas/CommandDescription`. public struct CommandDescription: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/CommandDescription/commandOptions`. @@ -735,12 +741,10 @@ public enum Components { public init(commandOptions: [Components.Schemas.CommandOption]? = nil) { self.commandOptions = commandOptions } - public enum CodingKeys: String, CodingKey { case commandOptions } } - /// - Remark: Generated from `#/components/schemas/CommandOption`. public struct CommandOption: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/CommandOption/command`. @@ -752,18 +756,18 @@ public enum Components { /// - Parameters: /// - command: /// - label: - public init(command: Swift.String? = nil, - label: Swift.String? = nil) { + public init( + command: Swift.String? = nil, + label: Swift.String? = nil + ) { self.command = command self.label = label } - public enum CodingKeys: String, CodingKey { case command case label } } - /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterGroupDTO`. public struct ConfigDescriptionParameterGroupDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterGroupDTO/name`. @@ -784,18 +788,19 @@ public enum Components { /// - advanced: /// - label: /// - description: - public init(name: Swift.String? = nil, - context: Swift.String? = nil, - advanced: Swift.Bool? = nil, - label: Swift.String? = nil, - description: Swift.String? = nil) { + public init( + name: Swift.String? = nil, + context: Swift.String? = nil, + advanced: Swift.Bool? = nil, + label: Swift.String? = nil, + description: Swift.String? = nil + ) { self.name = name self.context = context self.advanced = advanced self.label = label self.description = description } - public enum CodingKeys: String, CodingKey { case name case context @@ -804,7 +809,6 @@ public enum Components { case description } } - /// - Remark: Generated from `#/components/schemas/StateDescription`. public struct StateDescription: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/StateDescription/minimum`. @@ -828,12 +832,14 @@ public enum Components { /// - pattern: /// - readOnly: /// - options: - public init(minimum: Swift.Double? = nil, - maximum: Swift.Double? = nil, - step: Swift.Double? = nil, - pattern: Swift.String? = nil, - readOnly: Swift.Bool? = nil, - options: [Components.Schemas.StateOption]? = nil) { + public init( + minimum: Swift.Double? = nil, + maximum: Swift.Double? = nil, + step: Swift.Double? = nil, + pattern: Swift.String? = nil, + readOnly: Swift.Bool? = nil, + options: [Components.Schemas.StateOption]? = nil + ) { self.minimum = minimum self.maximum = maximum self.step = step @@ -841,7 +847,6 @@ public enum Components { self.readOnly = readOnly self.options = options } - public enum CodingKeys: String, CodingKey { case minimum case maximum @@ -851,7 +856,6 @@ public enum Components { case options } } - /// - Remark: Generated from `#/components/schemas/StateOption`. public struct StateOption: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/StateOption/value`. @@ -863,18 +867,18 @@ public enum Components { /// - Parameters: /// - value: /// - label: - public init(value: Swift.String? = nil, - label: Swift.String? = nil) { + public init( + value: Swift.String? = nil, + label: Swift.String? = nil + ) { self.value = value self.label = label } - public enum CodingKeys: String, CodingKey { case value case label } } - /// - Remark: Generated from `#/components/schemas/ConfigDescriptionDTO`. public struct ConfigDescriptionDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/ConfigDescriptionDTO/uri`. @@ -889,21 +893,21 @@ public enum Components { /// - uri: /// - parameters: /// - parameterGroups: - public init(uri: Swift.String? = nil, - parameters: [Components.Schemas.ConfigDescriptionParameterDTO]? = nil, - parameterGroups: [Components.Schemas.ConfigDescriptionParameterGroupDTO]? = nil) { + public init( + uri: Swift.String? = nil, + parameters: [Components.Schemas.ConfigDescriptionParameterDTO]? = nil, + parameterGroups: [Components.Schemas.ConfigDescriptionParameterGroupDTO]? = nil + ) { self.uri = uri self.parameters = parameters self.parameterGroups = parameterGroups } - public enum CodingKeys: String, CodingKey { case uri case parameters case parameterGroups } } - /// - Remark: Generated from `#/components/schemas/MetadataDTO`. public struct MetadataDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/MetadataDTO/value`. @@ -919,16 +923,13 @@ public enum Components { public init(additionalProperties: [String: OpenAPIRuntime.OpenAPIObjectContainer] = .init()) { self.additionalProperties = additionalProperties } - public init(from decoder: any Decoder) throws { additionalProperties = try decoder.decodeAdditionalProperties(knownKeys: []) } - public func encode(to encoder: any Encoder) throws { try encoder.encodeAdditionalProperties(additionalProperties) } } - /// - Remark: Generated from `#/components/schemas/MetadataDTO/config`. public var config: Components.Schemas.MetadataDTO.configPayload? /// Creates a new `MetadataDTO`. @@ -936,18 +937,18 @@ public enum Components { /// - Parameters: /// - value: /// - config: - public init(value: Swift.String? = nil, - config: Components.Schemas.MetadataDTO.configPayload? = nil) { + public init( + value: Swift.String? = nil, + config: Components.Schemas.MetadataDTO.configPayload? = nil + ) { self.value = value self.config = config } - public enum CodingKeys: String, CodingKey { case value case config } } - /// - Remark: Generated from `#/components/schemas/EnrichedItemDTO`. public struct EnrichedItemDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/EnrichedItemDTO/type`. @@ -985,16 +986,13 @@ public enum Components { public init(additionalProperties: [String: OpenAPIRuntime.OpenAPIObjectContainer] = .init()) { self.additionalProperties = additionalProperties } - public init(from decoder: any Decoder) throws { additionalProperties = try decoder.decodeAdditionalProperties(knownKeys: []) } - public func encode(to encoder: any Encoder) throws { try encoder.encodeAdditionalProperties(additionalProperties) } } - /// - Remark: Generated from `#/components/schemas/EnrichedItemDTO/metadata`. public var metadata: Components.Schemas.EnrichedItemDTO.metadataPayload? /// - Remark: Generated from `#/components/schemas/EnrichedItemDTO/editable`. @@ -1016,20 +1014,22 @@ public enum Components { /// - commandDescription: /// - metadata: /// - editable: - public init(_type: Swift.String? = nil, - name: Swift.String? = nil, - label: Swift.String? = nil, - category: Swift.String? = nil, - tags: [Swift.String]? = nil, - groupNames: [Swift.String]? = nil, - link: Swift.String? = nil, - state: Swift.String? = nil, - transformedState: Swift.String? = nil, - stateDescription: Components.Schemas.StateDescription? = nil, - unitSymbol: Swift.String? = nil, - commandDescription: Components.Schemas.CommandDescription? = nil, - metadata: Components.Schemas.EnrichedItemDTO.metadataPayload? = nil, - editable: Swift.Bool? = nil) { + public init( + _type: Swift.String? = nil, + name: Swift.String? = nil, + label: Swift.String? = nil, + category: Swift.String? = nil, + tags: [Swift.String]? = nil, + groupNames: [Swift.String]? = nil, + link: Swift.String? = nil, + state: Swift.String? = nil, + transformedState: Swift.String? = nil, + stateDescription: Components.Schemas.StateDescription? = nil, + unitSymbol: Swift.String? = nil, + commandDescription: Components.Schemas.CommandDescription? = nil, + metadata: Components.Schemas.EnrichedItemDTO.metadataPayload? = nil, + editable: Swift.Bool? = nil + ) { self._type = _type self.name = name self.label = label @@ -1045,7 +1045,6 @@ public enum Components { self.metadata = metadata self.editable = editable } - public enum CodingKeys: String, CodingKey { case _type = "type" case name @@ -1063,7 +1062,6 @@ public enum Components { case editable } } - /// - Remark: Generated from `#/components/schemas/GroupFunctionDTO`. public struct GroupFunctionDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/GroupFunctionDTO/name`. @@ -1075,18 +1073,18 @@ public enum Components { /// - Parameters: /// - name: /// - params: - public init(name: Swift.String? = nil, - params: [Swift.String]? = nil) { + public init( + name: Swift.String? = nil, + params: [Swift.String]? = nil + ) { self.name = name self.params = params } - public enum CodingKeys: String, CodingKey { case name case params } } - /// - Remark: Generated from `#/components/schemas/GroupItemDTO`. public struct GroupItemDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/GroupItemDTO/type`. @@ -1116,14 +1114,16 @@ public enum Components { /// - groupNames: /// - groupType: /// - function: - public init(_type: Swift.String? = nil, - name: Swift.String? = nil, - label: Swift.String? = nil, - category: Swift.String? = nil, - tags: [Swift.String]? = nil, - groupNames: [Swift.String]? = nil, - groupType: Swift.String? = nil, - function: Components.Schemas.GroupFunctionDTO? = nil) { + public init( + _type: Swift.String? = nil, + name: Swift.String? = nil, + label: Swift.String? = nil, + category: Swift.String? = nil, + tags: [Swift.String]? = nil, + groupNames: [Swift.String]? = nil, + groupType: Swift.String? = nil, + function: Components.Schemas.GroupFunctionDTO? = nil + ) { self._type = _type self.name = name self.label = label @@ -1133,7 +1133,6 @@ public enum Components { self.groupType = groupType self.function = function } - public enum CodingKeys: String, CodingKey { case _type = "type" case name @@ -1145,7 +1144,6 @@ public enum Components { case function } } - /// - Remark: Generated from `#/components/schemas/JerseyResponseBuilderDTO`. public struct JerseyResponseBuilderDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/JerseyResponseBuilderDTO/status`. @@ -1157,18 +1155,18 @@ public enum Components { /// - Parameters: /// - status: /// - context: - public init(status: Swift.String? = nil, - context: Components.Schemas.ContextDTO? = nil) { + public init( + status: Swift.String? = nil, + context: Components.Schemas.ContextDTO? = nil + ) { self.status = status self.context = context } - public enum CodingKeys: String, CodingKey { case status case context } } - /// - Remark: Generated from `#/components/schemas/ContextDTO`. public struct ContextDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/ContextDTO/headers`. @@ -1180,12 +1178,10 @@ public enum Components { public init(headers: Components.Schemas.HeadersDTO? = nil) { self.headers = headers } - public enum CodingKeys: String, CodingKey { case headers } } - /// - Remark: Generated from `#/components/schemas/HeadersDTO`. public struct HeadersDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/HeadersDTO/Location`. @@ -1197,12 +1193,10 @@ public enum Components { public init(Location: [Swift.String]? = nil) { self.Location = Location } - public enum CodingKeys: String, CodingKey { case Location } } - /// - Remark: Generated from `#/components/schemas/MappingDTO`. public struct MappingDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/MappingDTO/row`. @@ -1226,12 +1220,14 @@ public enum Components { /// - releaseCommand: /// - label: /// - icon: - public init(row: Swift.Int32? = nil, - column: Swift.Int32? = nil, - command: Swift.String? = nil, - releaseCommand: Swift.String? = nil, - label: Swift.String? = nil, - icon: Swift.String? = nil) { + public init( + row: Swift.Int32? = nil, + column: Swift.Int32? = nil, + command: Swift.String? = nil, + releaseCommand: Swift.String? = nil, + label: Swift.String? = nil, + icon: Swift.String? = nil + ) { self.row = row self.column = column self.command = command @@ -1239,7 +1235,6 @@ public enum Components { self.label = label self.icon = icon } - public enum CodingKeys: String, CodingKey { case row case column @@ -1249,89 +1244,80 @@ public enum Components { case icon } } - /// - Remark: Generated from `#/components/schemas/PageDTO`. public struct PageDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/PageDTO/id`. public var id: Swift.String? { - get { - storage.value.id + get { + self.storage.value.id } _modify { - yield &storage.value.id + yield &self.storage.value.id } } - /// - Remark: Generated from `#/components/schemas/PageDTO/title`. public var title: Swift.String? { - get { - storage.value.title + get { + self.storage.value.title } _modify { - yield &storage.value.title + yield &self.storage.value.title } } - /// - Remark: Generated from `#/components/schemas/PageDTO/icon`. public var icon: Swift.String? { - get { - storage.value.icon + get { + self.storage.value.icon } _modify { - yield &storage.value.icon + yield &self.storage.value.icon } } - /// - Remark: Generated from `#/components/schemas/PageDTO/link`. public var link: Swift.String? { - get { - storage.value.link + get { + self.storage.value.link } _modify { - yield &storage.value.link + yield &self.storage.value.link } } - /// - Remark: Generated from `#/components/schemas/PageDTO/parent`. public var parent: Components.Schemas.PageDTO? { - get { - storage.value.parent + get { + self.storage.value.parent } _modify { - yield &storage.value.parent + yield &self.storage.value.parent } } - /// - Remark: Generated from `#/components/schemas/PageDTO/leaf`. public var leaf: Swift.Bool? { - get { - storage.value.leaf + get { + self.storage.value.leaf } _modify { - yield &storage.value.leaf + yield &self.storage.value.leaf } } - /// - Remark: Generated from `#/components/schemas/PageDTO/timeout`. public var timeout: Swift.Bool? { - get { - storage.value.timeout + get { + self.storage.value.timeout } _modify { - yield &storage.value.timeout + yield &self.storage.value.timeout } } - /// - Remark: Generated from `#/components/schemas/PageDTO/widgets`. public var widgets: [Components.Schemas.WidgetDTO]? { - get { - storage.value.widgets + get { + self.storage.value.widgets } _modify { - yield &storage.value.widgets + yield &self.storage.value.widgets } } - /// Creates a new `PageDTO`. /// /// - Parameters: @@ -1343,15 +1329,17 @@ public enum Components { /// - leaf: /// - timeout: /// - widgets: - public init(id: Swift.String? = nil, - title: Swift.String? = nil, - icon: Swift.String? = nil, - link: Swift.String? = nil, - parent: Components.Schemas.PageDTO? = nil, - leaf: Swift.Bool? = nil, - timeout: Swift.Bool? = nil, - widgets: [Components.Schemas.WidgetDTO]? = nil) { - storage = .init(value: .init( + public init( + id: Swift.String? = nil, + title: Swift.String? = nil, + icon: Swift.String? = nil, + link: Swift.String? = nil, + parent: Components.Schemas.PageDTO? = nil, + leaf: Swift.Bool? = nil, + timeout: Swift.Bool? = nil, + widgets: [Components.Schemas.WidgetDTO]? = nil + ) { + self.storage = .init(value: .init( id: id, title: title, icon: icon, @@ -1362,7 +1350,6 @@ public enum Components { widgets: widgets )) } - public enum CodingKeys: String, CodingKey { case id case title @@ -1373,15 +1360,12 @@ public enum Components { case timeout case widgets } - public init(from decoder: any Decoder) throws { - storage = try .init(from: decoder) + self.storage = try .init(from: decoder) } - public func encode(to encoder: any Encoder) throws { - try storage.encode(to: encoder) + try self.storage.encode(to: encoder) } - /// Internal reference storage to allow type recursion. private var storage: OpenAPIRuntime.CopyOnWriteBox private struct Storage: Codable, Hashable, Sendable { @@ -1401,14 +1385,16 @@ public enum Components { var timeout: Swift.Bool? /// - Remark: Generated from `#/components/schemas/PageDTO/widgets`. var widgets: [Components.Schemas.WidgetDTO]? - init(id: Swift.String? = nil, - title: Swift.String? = nil, - icon: Swift.String? = nil, - link: Swift.String? = nil, - parent: Components.Schemas.PageDTO? = nil, - leaf: Swift.Bool? = nil, - timeout: Swift.Bool? = nil, - widgets: [Components.Schemas.WidgetDTO]? = nil) { + init( + id: Swift.String? = nil, + title: Swift.String? = nil, + icon: Swift.String? = nil, + link: Swift.String? = nil, + parent: Components.Schemas.PageDTO? = nil, + leaf: Swift.Bool? = nil, + timeout: Swift.Bool? = nil, + widgets: [Components.Schemas.WidgetDTO]? = nil + ) { self.id = id self.title = title self.icon = icon @@ -1418,403 +1404,362 @@ public enum Components { self.timeout = timeout self.widgets = widgets } - typealias CodingKeys = Components.Schemas.PageDTO.CodingKeys } } - /// - Remark: Generated from `#/components/schemas/WidgetDTO`. public struct WidgetDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/WidgetDTO/widgetId`. public var widgetId: Swift.String? { - get { - storage.value.widgetId + get { + self.storage.value.widgetId } _modify { - yield &storage.value.widgetId + yield &self.storage.value.widgetId } } - /// - Remark: Generated from `#/components/schemas/WidgetDTO/type`. public var _type: Swift.String? { - get { - storage.value._type + get { + self.storage.value._type } _modify { - yield &storage.value._type + yield &self.storage.value._type } } - /// - Remark: Generated from `#/components/schemas/WidgetDTO/name`. public var name: Swift.String? { - get { - storage.value.name + get { + self.storage.value.name } _modify { - yield &storage.value.name + yield &self.storage.value.name } } - /// - Remark: Generated from `#/components/schemas/WidgetDTO/visibility`. public var visibility: Swift.Bool? { - get { - storage.value.visibility + get { + self.storage.value.visibility } _modify { - yield &storage.value.visibility + yield &self.storage.value.visibility } } - /// - Remark: Generated from `#/components/schemas/WidgetDTO/label`. public var label: Swift.String? { - get { - storage.value.label + get { + self.storage.value.label } _modify { - yield &storage.value.label + yield &self.storage.value.label } } - /// - Remark: Generated from `#/components/schemas/WidgetDTO/labelSource`. public var labelSource: Swift.String? { - get { - storage.value.labelSource + get { + self.storage.value.labelSource } _modify { - yield &storage.value.labelSource + yield &self.storage.value.labelSource } } - /// - Remark: Generated from `#/components/schemas/WidgetDTO/icon`. public var icon: Swift.String? { - get { - storage.value.icon + get { + self.storage.value.icon } _modify { - yield &storage.value.icon + yield &self.storage.value.icon } } - /// - Remark: Generated from `#/components/schemas/WidgetDTO/staticIcon`. public var staticIcon: Swift.Bool? { - get { - storage.value.staticIcon + get { + self.storage.value.staticIcon } _modify { - yield &storage.value.staticIcon + yield &self.storage.value.staticIcon } } - /// - Remark: Generated from `#/components/schemas/WidgetDTO/labelcolor`. public var labelcolor: Swift.String? { - get { - storage.value.labelcolor + get { + self.storage.value.labelcolor } _modify { - yield &storage.value.labelcolor + yield &self.storage.value.labelcolor } } - /// - Remark: Generated from `#/components/schemas/WidgetDTO/valuecolor`. public var valuecolor: Swift.String? { - get { - storage.value.valuecolor + get { + self.storage.value.valuecolor } _modify { - yield &storage.value.valuecolor + yield &self.storage.value.valuecolor } } - /// - Remark: Generated from `#/components/schemas/WidgetDTO/iconcolor`. public var iconcolor: Swift.String? { - get { - storage.value.iconcolor + get { + self.storage.value.iconcolor } _modify { - yield &storage.value.iconcolor + yield &self.storage.value.iconcolor } } - /// - Remark: Generated from `#/components/schemas/WidgetDTO/pattern`. public var pattern: Swift.String? { - get { - storage.value.pattern + get { + self.storage.value.pattern } _modify { - yield &storage.value.pattern + yield &self.storage.value.pattern } } - /// - Remark: Generated from `#/components/schemas/WidgetDTO/unit`. public var unit: Swift.String? { - get { - storage.value.unit + get { + self.storage.value.unit } _modify { - yield &storage.value.unit + yield &self.storage.value.unit } } - /// - Remark: Generated from `#/components/schemas/WidgetDTO/mappings`. public var mappings: [Components.Schemas.MappingDTO]? { - get { - storage.value.mappings + get { + self.storage.value.mappings } _modify { - yield &storage.value.mappings + yield &self.storage.value.mappings } } - /// - Remark: Generated from `#/components/schemas/WidgetDTO/switchSupport`. public var switchSupport: Swift.Bool? { - get { - storage.value.switchSupport + get { + self.storage.value.switchSupport } _modify { - yield &storage.value.switchSupport + yield &self.storage.value.switchSupport } } - /// - Remark: Generated from `#/components/schemas/WidgetDTO/releaseOnly`. public var releaseOnly: Swift.Bool? { - get { - storage.value.releaseOnly + get { + self.storage.value.releaseOnly } _modify { - yield &storage.value.releaseOnly + yield &self.storage.value.releaseOnly } } - /// - Remark: Generated from `#/components/schemas/WidgetDTO/sendFrequency`. public var sendFrequency: Swift.Int32? { - get { - storage.value.sendFrequency + get { + self.storage.value.sendFrequency } _modify { - yield &storage.value.sendFrequency + yield &self.storage.value.sendFrequency } } - /// - Remark: Generated from `#/components/schemas/WidgetDTO/refresh`. public var refresh: Swift.Int32? { - get { - storage.value.refresh + get { + self.storage.value.refresh } _modify { - yield &storage.value.refresh + yield &self.storage.value.refresh } } - /// - Remark: Generated from `#/components/schemas/WidgetDTO/height`. public var height: Swift.Int32? { - get { - storage.value.height + get { + self.storage.value.height } _modify { - yield &storage.value.height + yield &self.storage.value.height } } - /// - Remark: Generated from `#/components/schemas/WidgetDTO/minValue`. public var minValue: Swift.Double? { - get { - storage.value.minValue + get { + self.storage.value.minValue } _modify { - yield &storage.value.minValue + yield &self.storage.value.minValue } } - /// - Remark: Generated from `#/components/schemas/WidgetDTO/maxValue`. public var maxValue: Swift.Double? { - get { - storage.value.maxValue + get { + self.storage.value.maxValue } _modify { - yield &storage.value.maxValue + yield &self.storage.value.maxValue } } - /// - Remark: Generated from `#/components/schemas/WidgetDTO/step`. public var step: Swift.Double? { - get { - storage.value.step + get { + self.storage.value.step } _modify { - yield &storage.value.step + yield &self.storage.value.step } } - /// - Remark: Generated from `#/components/schemas/WidgetDTO/inputHint`. public var inputHint: Swift.String? { - get { - storage.value.inputHint + get { + self.storage.value.inputHint } _modify { - yield &storage.value.inputHint + yield &self.storage.value.inputHint } } - /// - Remark: Generated from `#/components/schemas/WidgetDTO/url`. public var url: Swift.String? { - get { - storage.value.url + get { + self.storage.value.url } _modify { - yield &storage.value.url + yield &self.storage.value.url } } - /// - Remark: Generated from `#/components/schemas/WidgetDTO/encoding`. public var encoding: Swift.String? { - get { - storage.value.encoding + get { + self.storage.value.encoding } _modify { - yield &storage.value.encoding + yield &self.storage.value.encoding } } - /// - Remark: Generated from `#/components/schemas/WidgetDTO/service`. public var service: Swift.String? { - get { - storage.value.service + get { + self.storage.value.service } _modify { - yield &storage.value.service + yield &self.storage.value.service } } - /// - Remark: Generated from `#/components/schemas/WidgetDTO/period`. public var period: Swift.String? { - get { - storage.value.period + get { + self.storage.value.period } _modify { - yield &storage.value.period + yield &self.storage.value.period } } - /// - Remark: Generated from `#/components/schemas/WidgetDTO/yAxisDecimalPattern`. public var yAxisDecimalPattern: Swift.String? { - get { - storage.value.yAxisDecimalPattern + get { + self.storage.value.yAxisDecimalPattern } _modify { - yield &storage.value.yAxisDecimalPattern + yield &self.storage.value.yAxisDecimalPattern } } - /// - Remark: Generated from `#/components/schemas/WidgetDTO/legend`. public var legend: Swift.Bool? { - get { - storage.value.legend + get { + self.storage.value.legend } _modify { - yield &storage.value.legend + yield &self.storage.value.legend } } - /// - Remark: Generated from `#/components/schemas/WidgetDTO/forceAsItem`. public var forceAsItem: Swift.Bool? { - get { - storage.value.forceAsItem + get { + self.storage.value.forceAsItem } _modify { - yield &storage.value.forceAsItem + yield &self.storage.value.forceAsItem } } - /// - Remark: Generated from `#/components/schemas/WidgetDTO/row`. public var row: Swift.Int32? { - get { - storage.value.row + get { + self.storage.value.row } _modify { - yield &storage.value.row + yield &self.storage.value.row } } - /// - Remark: Generated from `#/components/schemas/WidgetDTO/column`. public var column: Swift.Int32? { - get { - storage.value.column + get { + self.storage.value.column } _modify { - yield &storage.value.column + yield &self.storage.value.column } } - /// - Remark: Generated from `#/components/schemas/WidgetDTO/command`. public var command: Swift.String? { - get { - storage.value.command + get { + self.storage.value.command } _modify { - yield &storage.value.command + yield &self.storage.value.command } } - /// - Remark: Generated from `#/components/schemas/WidgetDTO/releaseCommand`. public var releaseCommand: Swift.String? { - get { - storage.value.releaseCommand + get { + self.storage.value.releaseCommand } _modify { - yield &storage.value.releaseCommand + yield &self.storage.value.releaseCommand } } - /// - Remark: Generated from `#/components/schemas/WidgetDTO/stateless`. public var stateless: Swift.Bool? { - get { - storage.value.stateless + get { + self.storage.value.stateless } _modify { - yield &storage.value.stateless + yield &self.storage.value.stateless } } - /// - Remark: Generated from `#/components/schemas/WidgetDTO/state`. public var state: Swift.String? { - get { - storage.value.state + get { + self.storage.value.state } _modify { - yield &storage.value.state + yield &self.storage.value.state } } - /// - Remark: Generated from `#/components/schemas/WidgetDTO/item`. public var item: Components.Schemas.EnrichedItemDTO? { - get { - storage.value.item + get { + self.storage.value.item } _modify { - yield &storage.value.item + yield &self.storage.value.item } } - /// - Remark: Generated from `#/components/schemas/WidgetDTO/linkedPage`. public var linkedPage: Components.Schemas.PageDTO? { - get { - storage.value.linkedPage + get { + self.storage.value.linkedPage } _modify { - yield &storage.value.linkedPage + yield &self.storage.value.linkedPage } } - /// - Remark: Generated from `#/components/schemas/WidgetDTO/widgets`. public var widgets: [Components.Schemas.WidgetDTO]? { - get { - storage.value.widgets + get { + self.storage.value.widgets } _modify { - yield &storage.value.widgets + yield &self.storage.value.widgets } } - /// Creates a new `WidgetDTO`. /// /// - Parameters: @@ -1857,46 +1802,48 @@ public enum Components { /// - item: /// - linkedPage: /// - widgets: - public init(widgetId: Swift.String? = nil, - _type: Swift.String? = nil, - name: Swift.String? = nil, - visibility: Swift.Bool? = nil, - label: Swift.String? = nil, - labelSource: Swift.String? = nil, - icon: Swift.String? = nil, - staticIcon: Swift.Bool? = nil, - labelcolor: Swift.String? = nil, - valuecolor: Swift.String? = nil, - iconcolor: Swift.String? = nil, - pattern: Swift.String? = nil, - unit: Swift.String? = nil, - mappings: [Components.Schemas.MappingDTO]? = nil, - switchSupport: Swift.Bool? = nil, - releaseOnly: Swift.Bool? = nil, - sendFrequency: Swift.Int32? = nil, - refresh: Swift.Int32? = nil, - height: Swift.Int32? = nil, - minValue: Swift.Double? = nil, - maxValue: Swift.Double? = nil, - step: Swift.Double? = nil, - inputHint: Swift.String? = nil, - url: Swift.String? = nil, - encoding: Swift.String? = nil, - service: Swift.String? = nil, - period: Swift.String? = nil, - yAxisDecimalPattern: Swift.String? = nil, - legend: Swift.Bool? = nil, - forceAsItem: Swift.Bool? = nil, - row: Swift.Int32? = nil, - column: Swift.Int32? = nil, - command: Swift.String? = nil, - releaseCommand: Swift.String? = nil, - stateless: Swift.Bool? = nil, - state: Swift.String? = nil, - item: Components.Schemas.EnrichedItemDTO? = nil, - linkedPage: Components.Schemas.PageDTO? = nil, - widgets: [Components.Schemas.WidgetDTO]? = nil) { - storage = .init(value: .init( + public init( + widgetId: Swift.String? = nil, + _type: Swift.String? = nil, + name: Swift.String? = nil, + visibility: Swift.Bool? = nil, + label: Swift.String? = nil, + labelSource: Swift.String? = nil, + icon: Swift.String? = nil, + staticIcon: Swift.Bool? = nil, + labelcolor: Swift.String? = nil, + valuecolor: Swift.String? = nil, + iconcolor: Swift.String? = nil, + pattern: Swift.String? = nil, + unit: Swift.String? = nil, + mappings: [Components.Schemas.MappingDTO]? = nil, + switchSupport: Swift.Bool? = nil, + releaseOnly: Swift.Bool? = nil, + sendFrequency: Swift.Int32? = nil, + refresh: Swift.Int32? = nil, + height: Swift.Int32? = nil, + minValue: Swift.Double? = nil, + maxValue: Swift.Double? = nil, + step: Swift.Double? = nil, + inputHint: Swift.String? = nil, + url: Swift.String? = nil, + encoding: Swift.String? = nil, + service: Swift.String? = nil, + period: Swift.String? = nil, + yAxisDecimalPattern: Swift.String? = nil, + legend: Swift.Bool? = nil, + forceAsItem: Swift.Bool? = nil, + row: Swift.Int32? = nil, + column: Swift.Int32? = nil, + command: Swift.String? = nil, + releaseCommand: Swift.String? = nil, + stateless: Swift.Bool? = nil, + state: Swift.String? = nil, + item: Components.Schemas.EnrichedItemDTO? = nil, + linkedPage: Components.Schemas.PageDTO? = nil, + widgets: [Components.Schemas.WidgetDTO]? = nil + ) { + self.storage = .init(value: .init( widgetId: widgetId, _type: _type, name: name, @@ -1938,7 +1885,6 @@ public enum Components { widgets: widgets )) } - public enum CodingKeys: String, CodingKey { case widgetId case _type = "type" @@ -1980,15 +1926,12 @@ public enum Components { case linkedPage case widgets } - public init(from decoder: any Decoder) throws { - storage = try .init(from: decoder) + self.storage = try .init(from: decoder) } - public func encode(to encoder: any Encoder) throws { - try storage.encode(to: encoder) + try self.storage.encode(to: encoder) } - /// Internal reference storage to allow type recursion. private var storage: OpenAPIRuntime.CopyOnWriteBox private struct Storage: Codable, Hashable, Sendable { @@ -2070,45 +2013,47 @@ public enum Components { var linkedPage: Components.Schemas.PageDTO? /// - Remark: Generated from `#/components/schemas/WidgetDTO/widgets`. var widgets: [Components.Schemas.WidgetDTO]? - init(widgetId: Swift.String? = nil, - _type: Swift.String? = nil, - name: Swift.String? = nil, - visibility: Swift.Bool? = nil, - label: Swift.String? = nil, - labelSource: Swift.String? = nil, - icon: Swift.String? = nil, - staticIcon: Swift.Bool? = nil, - labelcolor: Swift.String? = nil, - valuecolor: Swift.String? = nil, - iconcolor: Swift.String? = nil, - pattern: Swift.String? = nil, - unit: Swift.String? = nil, - mappings: [Components.Schemas.MappingDTO]? = nil, - switchSupport: Swift.Bool? = nil, - releaseOnly: Swift.Bool? = nil, - sendFrequency: Swift.Int32? = nil, - refresh: Swift.Int32? = nil, - height: Swift.Int32? = nil, - minValue: Swift.Double? = nil, - maxValue: Swift.Double? = nil, - step: Swift.Double? = nil, - inputHint: Swift.String? = nil, - url: Swift.String? = nil, - encoding: Swift.String? = nil, - service: Swift.String? = nil, - period: Swift.String? = nil, - yAxisDecimalPattern: Swift.String? = nil, - legend: Swift.Bool? = nil, - forceAsItem: Swift.Bool? = nil, - row: Swift.Int32? = nil, - column: Swift.Int32? = nil, - command: Swift.String? = nil, - releaseCommand: Swift.String? = nil, - stateless: Swift.Bool? = nil, - state: Swift.String? = nil, - item: Components.Schemas.EnrichedItemDTO? = nil, - linkedPage: Components.Schemas.PageDTO? = nil, - widgets: [Components.Schemas.WidgetDTO]? = nil) { + init( + widgetId: Swift.String? = nil, + _type: Swift.String? = nil, + name: Swift.String? = nil, + visibility: Swift.Bool? = nil, + label: Swift.String? = nil, + labelSource: Swift.String? = nil, + icon: Swift.String? = nil, + staticIcon: Swift.Bool? = nil, + labelcolor: Swift.String? = nil, + valuecolor: Swift.String? = nil, + iconcolor: Swift.String? = nil, + pattern: Swift.String? = nil, + unit: Swift.String? = nil, + mappings: [Components.Schemas.MappingDTO]? = nil, + switchSupport: Swift.Bool? = nil, + releaseOnly: Swift.Bool? = nil, + sendFrequency: Swift.Int32? = nil, + refresh: Swift.Int32? = nil, + height: Swift.Int32? = nil, + minValue: Swift.Double? = nil, + maxValue: Swift.Double? = nil, + step: Swift.Double? = nil, + inputHint: Swift.String? = nil, + url: Swift.String? = nil, + encoding: Swift.String? = nil, + service: Swift.String? = nil, + period: Swift.String? = nil, + yAxisDecimalPattern: Swift.String? = nil, + legend: Swift.Bool? = nil, + forceAsItem: Swift.Bool? = nil, + row: Swift.Int32? = nil, + column: Swift.Int32? = nil, + command: Swift.String? = nil, + releaseCommand: Swift.String? = nil, + stateless: Swift.Bool? = nil, + state: Swift.String? = nil, + item: Components.Schemas.EnrichedItemDTO? = nil, + linkedPage: Components.Schemas.PageDTO? = nil, + widgets: [Components.Schemas.WidgetDTO]? = nil + ) { self.widgetId = widgetId self._type = _type self.name = name @@ -2149,11 +2094,9 @@ public enum Components { self.linkedPage = linkedPage self.widgets = widgets } - typealias CodingKeys = Components.Schemas.WidgetDTO.CodingKeys } } - /// - Remark: Generated from `#/components/schemas/SitemapWidgetEvent`. public struct SitemapWidgetEvent: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/SitemapWidgetEvent/widgetId`. @@ -2201,20 +2144,22 @@ public enum Components { /// - item: /// - sitemapName: /// - pageId: - public init(widgetId: Swift.String? = nil, - label: Swift.String? = nil, - labelSource: Swift.String? = nil, - icon: Swift.String? = nil, - labelcolor: Swift.String? = nil, - valuecolor: Swift.String? = nil, - iconcolor: Swift.String? = nil, - state: Swift.String? = nil, - reloadIcon: Swift.Bool? = nil, - visibility: Swift.Bool? = nil, - descriptionChanged: Swift.Bool? = nil, - item: Components.Schemas.EnrichedItemDTO? = nil, - sitemapName: Swift.String? = nil, - pageId: Swift.String? = nil) { + public init( + widgetId: Swift.String? = nil, + label: Swift.String? = nil, + labelSource: Swift.String? = nil, + icon: Swift.String? = nil, + labelcolor: Swift.String? = nil, + valuecolor: Swift.String? = nil, + iconcolor: Swift.String? = nil, + state: Swift.String? = nil, + reloadIcon: Swift.Bool? = nil, + visibility: Swift.Bool? = nil, + descriptionChanged: Swift.Bool? = nil, + item: Components.Schemas.EnrichedItemDTO? = nil, + sitemapName: Swift.String? = nil, + pageId: Swift.String? = nil + ) { self.widgetId = widgetId self.label = label self.labelSource = labelSource @@ -2230,7 +2175,6 @@ public enum Components { self.sitemapName = sitemapName self.pageId = pageId } - public enum CodingKeys: String, CodingKey { case widgetId case label @@ -2248,7 +2192,6 @@ public enum Components { case pageId } } - /// - Remark: Generated from `#/components/schemas/SitemapDTO`. public struct SitemapDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/SitemapDTO/name`. @@ -2269,18 +2212,19 @@ public enum Components { /// - label: /// - link: /// - homepage: - public init(name: Swift.String? = nil, - icon: Swift.String? = nil, - label: Swift.String? = nil, - link: Swift.String? = nil, - homepage: Components.Schemas.PageDTO? = nil) { + public init( + name: Swift.String? = nil, + icon: Swift.String? = nil, + label: Swift.String? = nil, + link: Swift.String? = nil, + homepage: Components.Schemas.PageDTO? = nil + ) { self.name = name self.icon = icon self.label = label self.link = link self.homepage = homepage } - public enum CodingKeys: String, CodingKey { case name case icon @@ -2289,7 +2233,6 @@ public enum Components { case homepage } } - /// - Remark: Generated from `#/components/schemas/RootUIComponent`. public struct RootUIComponent: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/RootUIComponent/component`. @@ -2305,16 +2248,13 @@ public enum Components { public init(additionalProperties: [String: OpenAPIRuntime.OpenAPIObjectContainer] = .init()) { self.additionalProperties = additionalProperties } - public init(from decoder: any Decoder) throws { additionalProperties = try decoder.decodeAdditionalProperties(knownKeys: []) } - public func encode(to encoder: any Encoder) throws { try encoder.encodeAdditionalProperties(additionalProperties) } } - /// - Remark: Generated from `#/components/schemas/RootUIComponent/config`. public var config: Components.Schemas.RootUIComponent.configPayload? /// - Remark: Generated from `#/components/schemas/RootUIComponent/slots`. @@ -2328,16 +2268,13 @@ public enum Components { public init(additionalProperties: [String: [Components.Schemas.UIComponent]] = .init()) { self.additionalProperties = additionalProperties } - public init(from decoder: any Decoder) throws { additionalProperties = try decoder.decodeAdditionalProperties(knownKeys: []) } - public func encode(to encoder: any Encoder) throws { try encoder.encodeAdditionalProperties(additionalProperties) } } - /// - Remark: Generated from `#/components/schemas/RootUIComponent/slots`. public var slots: Components.Schemas.RootUIComponent.slotsPayload? /// - Remark: Generated from `#/components/schemas/RootUIComponent/uid`. @@ -2361,14 +2298,16 @@ public enum Components { /// - props: /// - timestamp: /// - _type: - public init(component: Swift.String? = nil, - config: Components.Schemas.RootUIComponent.configPayload? = nil, - slots: Components.Schemas.RootUIComponent.slotsPayload? = nil, - uid: Swift.String? = nil, - tags: [Swift.String]? = nil, - props: Components.Schemas.ConfigDescriptionDTO? = nil, - timestamp: Foundation.Date? = nil, - _type: Swift.String? = nil) { + public init( + component: Swift.String? = nil, + config: Components.Schemas.RootUIComponent.configPayload? = nil, + slots: Components.Schemas.RootUIComponent.slotsPayload? = nil, + uid: Swift.String? = nil, + tags: [Swift.String]? = nil, + props: Components.Schemas.ConfigDescriptionDTO? = nil, + timestamp: Foundation.Date? = nil, + _type: Swift.String? = nil + ) { self.component = component self.config = config self.slots = slots @@ -2378,7 +2317,6 @@ public enum Components { self.timestamp = timestamp self._type = _type } - public enum CodingKeys: String, CodingKey { case component case config @@ -2390,7 +2328,6 @@ public enum Components { case _type = "type" } } - /// - Remark: Generated from `#/components/schemas/UIComponent`. public struct UIComponent: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/UIComponent/component`. @@ -2406,16 +2343,13 @@ public enum Components { public init(additionalProperties: [String: OpenAPIRuntime.OpenAPIObjectContainer] = .init()) { self.additionalProperties = additionalProperties } - public init(from decoder: any Decoder) throws { additionalProperties = try decoder.decodeAdditionalProperties(knownKeys: []) } - public func encode(to encoder: any Encoder) throws { try encoder.encodeAdditionalProperties(additionalProperties) } } - /// - Remark: Generated from `#/components/schemas/UIComponent/config`. public var config: Components.Schemas.UIComponent.configPayload? /// - Remark: Generated from `#/components/schemas/UIComponent/type`. @@ -2426,21 +2360,21 @@ public enum Components { /// - component: /// - config: /// - _type: - public init(component: Swift.String? = nil, - config: Components.Schemas.UIComponent.configPayload? = nil, - _type: Swift.String? = nil) { + public init( + component: Swift.String? = nil, + config: Components.Schemas.UIComponent.configPayload? = nil, + _type: Swift.String? = nil + ) { self.component = component self.config = config self._type = _type } - public enum CodingKeys: String, CodingKey { case component case config case _type = "type" } } - /// - Remark: Generated from `#/components/schemas/TileDTO`. public struct TileDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/TileDTO/name`. @@ -2458,16 +2392,17 @@ public enum Components { /// - url: /// - overlay: /// - imageUrl: - public init(name: Swift.String? = nil, - url: Swift.String? = nil, - overlay: Swift.String? = nil, - imageUrl: Swift.String? = nil) { + public init( + name: Swift.String? = nil, + url: Swift.String? = nil, + overlay: Swift.String? = nil, + imageUrl: Swift.String? = nil + ) { self.name = name self.url = url self.overlay = overlay self.imageUrl = imageUrl } - public enum CodingKeys: String, CodingKey { case name case url @@ -2476,7 +2411,6 @@ public enum Components { } } } - /// Types generated from the `#/components/parameters` section of the OpenAPI document. public enum Parameters {} /// Types generated from the `#/components/requestBodies` section of the OpenAPI document. @@ -2511,13 +2445,14 @@ public enum Operations { /// - Parameters: /// - itemName: item name /// - memberItemName: member item name - public init(itemName: Swift.String, - memberItemName: Swift.String) { + public init( + itemName: Swift.String, + memberItemName: Swift.String + ) { self.itemName = itemName self.memberItemName = memberItemName } } - public var path: Operations.addMemberToGroupItem.Input.Path /// Creates a new `Input`. /// @@ -2527,19 +2462,25 @@ public enum Operations { self.path = path } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// Creates a new `Ok`. public init() {} } - /// OK /// /// - Remark: Generated from `#/paths//items/{itemName}/members/{memberItemName}/put(addMemberToGroupItem)/responses/200`. /// /// HTTP response code: `200 ok`. case ok(Operations.addMemberToGroupItem.Output.Ok) + /// OK + /// + /// - Remark: Generated from `#/paths//items/{itemName}/members/{memberItemName}/put(addMemberToGroupItem)/responses/200`. + /// + /// HTTP response code: `200 ok`. + public static var ok: Self { + .ok(.init()) + } /// The associated value of the enum case if `self` is `.ok`. /// /// - Throws: An error if `self` is not `.ok`. @@ -2548,7 +2489,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -2557,18 +2498,24 @@ public enum Operations { } } } - public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } - /// Item or member item not found or item is not of type group item. /// /// - Remark: Generated from `#/paths//items/{itemName}/members/{memberItemName}/put(addMemberToGroupItem)/responses/404`. /// /// HTTP response code: `404 notFound`. case notFound(Operations.addMemberToGroupItem.Output.NotFound) + /// Item or member item not found or item is not of type group item. + /// + /// - Remark: Generated from `#/paths//items/{itemName}/members/{memberItemName}/put(addMemberToGroupItem)/responses/404`. + /// + /// HTTP response code: `404 notFound`. + public static var notFound: Self { + .notFound(.init()) + } /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -2577,7 +2524,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -2586,18 +2533,24 @@ public enum Operations { } } } - public struct MethodNotAllowed: Sendable, Hashable { /// Creates a new `MethodNotAllowed`. public init() {} } - /// Member item is not editable. /// /// - Remark: Generated from `#/paths//items/{itemName}/members/{memberItemName}/put(addMemberToGroupItem)/responses/405`. /// /// HTTP response code: `405 methodNotAllowed`. case methodNotAllowed(Operations.addMemberToGroupItem.Output.MethodNotAllowed) + /// Member item is not editable. + /// + /// - Remark: Generated from `#/paths//items/{itemName}/members/{memberItemName}/put(addMemberToGroupItem)/responses/405`. + /// + /// HTTP response code: `405 methodNotAllowed`. + public static var methodNotAllowed: Self { + .methodNotAllowed(.init()) + } /// The associated value of the enum case if `self` is `.methodNotAllowed`. /// /// - Throws: An error if `self` is not `.methodNotAllowed`. @@ -2606,7 +2559,7 @@ public enum Operations { get throws { switch self { case let .methodNotAllowed(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "methodNotAllowed", @@ -2615,14 +2568,12 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } } - /// Removes an existing member from a group item. /// /// - Remark: HTTP `DELETE /items/{itemName}/members/{memberItemName}`. @@ -2645,13 +2596,14 @@ public enum Operations { /// - Parameters: /// - itemName: item name /// - memberItemName: member item name - public init(itemName: Swift.String, - memberItemName: Swift.String) { + public init( + itemName: Swift.String, + memberItemName: Swift.String + ) { self.itemName = itemName self.memberItemName = memberItemName } } - public var path: Operations.removeMemberFromGroupItem.Input.Path /// Creates a new `Input`. /// @@ -2661,19 +2613,25 @@ public enum Operations { self.path = path } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// Creates a new `Ok`. public init() {} } - /// OK /// /// - Remark: Generated from `#/paths//items/{itemName}/members/{memberItemName}/delete(removeMemberFromGroupItem)/responses/200`. /// /// HTTP response code: `200 ok`. case ok(Operations.removeMemberFromGroupItem.Output.Ok) + /// OK + /// + /// - Remark: Generated from `#/paths//items/{itemName}/members/{memberItemName}/delete(removeMemberFromGroupItem)/responses/200`. + /// + /// HTTP response code: `200 ok`. + public static var ok: Self { + .ok(.init()) + } /// The associated value of the enum case if `self` is `.ok`. /// /// - Throws: An error if `self` is not `.ok`. @@ -2682,7 +2640,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -2691,18 +2649,24 @@ public enum Operations { } } } - public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } - /// Item or member item not found or item is not of type group item. /// /// - Remark: Generated from `#/paths//items/{itemName}/members/{memberItemName}/delete(removeMemberFromGroupItem)/responses/404`. /// /// HTTP response code: `404 notFound`. case notFound(Operations.removeMemberFromGroupItem.Output.NotFound) + /// Item or member item not found or item is not of type group item. + /// + /// - Remark: Generated from `#/paths//items/{itemName}/members/{memberItemName}/delete(removeMemberFromGroupItem)/responses/404`. + /// + /// HTTP response code: `404 notFound`. + public static var notFound: Self { + .notFound(.init()) + } /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -2711,7 +2675,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -2720,18 +2684,24 @@ public enum Operations { } } } - public struct MethodNotAllowed: Sendable, Hashable { /// Creates a new `MethodNotAllowed`. public init() {} } - /// Member item is not editable. /// /// - Remark: Generated from `#/paths//items/{itemName}/members/{memberItemName}/delete(removeMemberFromGroupItem)/responses/405`. /// /// HTTP response code: `405 methodNotAllowed`. case methodNotAllowed(Operations.removeMemberFromGroupItem.Output.MethodNotAllowed) + /// Member item is not editable. + /// + /// - Remark: Generated from `#/paths//items/{itemName}/members/{memberItemName}/delete(removeMemberFromGroupItem)/responses/405`. + /// + /// HTTP response code: `405 methodNotAllowed`. + public static var methodNotAllowed: Self { + .methodNotAllowed(.init()) + } /// The associated value of the enum case if `self` is `.methodNotAllowed`. /// /// - Throws: An error if `self` is not `.methodNotAllowed`. @@ -2740,7 +2710,7 @@ public enum Operations { get throws { switch self { case let .methodNotAllowed(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "methodNotAllowed", @@ -2749,14 +2719,12 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } } - /// Adds metadata to an item. /// /// - Remark: HTTP `PUT /items/{itemname}/metadata/{namespace}`. @@ -2779,45 +2747,53 @@ public enum Operations { /// - Parameters: /// - itemname: item name /// - namespace: namespace - public init(itemname: Swift.String, - namespace: Swift.String) { + public init( + itemname: Swift.String, + namespace: Swift.String + ) { self.itemname = itemname self.namespace = namespace } } - public var path: Operations.addMetadataToItem.Input.Path /// - Remark: Generated from `#/paths/items/{itemname}/metadata/{namespace}/PUT/requestBody`. @frozen public enum Body: Sendable, Hashable { /// - Remark: Generated from `#/paths/items/{itemname}/metadata/{namespace}/PUT/requestBody/content/application\/json`. case json(Components.Schemas.MetadataDTO) } - public var body: Operations.addMetadataToItem.Input.Body /// Creates a new `Input`. /// /// - Parameters: /// - path: /// - body: - public init(path: Operations.addMetadataToItem.Input.Path, - body: Operations.addMetadataToItem.Input.Body) { + public init( + path: Operations.addMetadataToItem.Input.Path, + body: Operations.addMetadataToItem.Input.Body + ) { self.path = path self.body = body } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// Creates a new `Ok`. public init() {} } - /// OK /// /// - Remark: Generated from `#/paths//items/{itemname}/metadata/{namespace}/put(addMetadataToItem)/responses/200`. /// /// HTTP response code: `200 ok`. case ok(Operations.addMetadataToItem.Output.Ok) + /// OK + /// + /// - Remark: Generated from `#/paths//items/{itemname}/metadata/{namespace}/put(addMetadataToItem)/responses/200`. + /// + /// HTTP response code: `200 ok`. + public static var ok: Self { + .ok(.init()) + } /// The associated value of the enum case if `self` is `.ok`. /// /// - Throws: An error if `self` is not `.ok`. @@ -2826,7 +2802,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -2835,18 +2811,24 @@ public enum Operations { } } } - public struct Created: Sendable, Hashable { /// Creates a new `Created`. public init() {} } - /// Created /// /// - Remark: Generated from `#/paths//items/{itemname}/metadata/{namespace}/put(addMetadataToItem)/responses/201`. /// /// HTTP response code: `201 created`. case created(Operations.addMetadataToItem.Output.Created) + /// Created + /// + /// - Remark: Generated from `#/paths//items/{itemname}/metadata/{namespace}/put(addMetadataToItem)/responses/201`. + /// + /// HTTP response code: `201 created`. + public static var created: Self { + .created(.init()) + } /// The associated value of the enum case if `self` is `.created`. /// /// - Throws: An error if `self` is not `.created`. @@ -2855,7 +2837,7 @@ public enum Operations { get throws { switch self { case let .created(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "created", @@ -2864,18 +2846,24 @@ public enum Operations { } } } - public struct BadRequest: Sendable, Hashable { /// Creates a new `BadRequest`. public init() {} } - /// Metadata value empty. /// /// - Remark: Generated from `#/paths//items/{itemname}/metadata/{namespace}/put(addMetadataToItem)/responses/400`. /// /// HTTP response code: `400 badRequest`. case badRequest(Operations.addMetadataToItem.Output.BadRequest) + /// Metadata value empty. + /// + /// - Remark: Generated from `#/paths//items/{itemname}/metadata/{namespace}/put(addMetadataToItem)/responses/400`. + /// + /// HTTP response code: `400 badRequest`. + public static var badRequest: Self { + .badRequest(.init()) + } /// The associated value of the enum case if `self` is `.badRequest`. /// /// - Throws: An error if `self` is not `.badRequest`. @@ -2884,7 +2872,7 @@ public enum Operations { get throws { switch self { case let .badRequest(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "badRequest", @@ -2893,18 +2881,24 @@ public enum Operations { } } } - public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } - /// Item not found. /// /// - Remark: Generated from `#/paths//items/{itemname}/metadata/{namespace}/put(addMetadataToItem)/responses/404`. /// /// HTTP response code: `404 notFound`. case notFound(Operations.addMetadataToItem.Output.NotFound) + /// Item not found. + /// + /// - Remark: Generated from `#/paths//items/{itemname}/metadata/{namespace}/put(addMetadataToItem)/responses/404`. + /// + /// HTTP response code: `404 notFound`. + public static var notFound: Self { + .notFound(.init()) + } /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -2913,7 +2907,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -2922,18 +2916,24 @@ public enum Operations { } } } - public struct MethodNotAllowed: Sendable, Hashable { /// Creates a new `MethodNotAllowed`. public init() {} } - /// Metadata not editable. /// /// - Remark: Generated from `#/paths//items/{itemname}/metadata/{namespace}/put(addMetadataToItem)/responses/405`. /// /// HTTP response code: `405 methodNotAllowed`. case methodNotAllowed(Operations.addMetadataToItem.Output.MethodNotAllowed) + /// Metadata not editable. + /// + /// - Remark: Generated from `#/paths//items/{itemname}/metadata/{namespace}/put(addMetadataToItem)/responses/405`. + /// + /// HTTP response code: `405 methodNotAllowed`. + public static var methodNotAllowed: Self { + .methodNotAllowed(.init()) + } /// The associated value of the enum case if `self` is `.methodNotAllowed`. /// /// - Throws: An error if `self` is not `.methodNotAllowed`. @@ -2942,7 +2942,7 @@ public enum Operations { get throws { switch self { case let .methodNotAllowed(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "methodNotAllowed", @@ -2951,14 +2951,12 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } } - /// Removes metadata from an item. /// /// - Remark: HTTP `DELETE /items/{itemname}/metadata/{namespace}`. @@ -2981,13 +2979,14 @@ public enum Operations { /// - Parameters: /// - itemname: item name /// - namespace: namespace - public init(itemname: Swift.String, - namespace: Swift.String) { + public init( + itemname: Swift.String, + namespace: Swift.String + ) { self.itemname = itemname self.namespace = namespace } } - public var path: Operations.removeMetadataFromItem.Input.Path /// Creates a new `Input`. /// @@ -2997,19 +2996,25 @@ public enum Operations { self.path = path } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// Creates a new `Ok`. public init() {} } - /// OK /// /// - Remark: Generated from `#/paths//items/{itemname}/metadata/{namespace}/delete(removeMetadataFromItem)/responses/200`. /// /// HTTP response code: `200 ok`. case ok(Operations.removeMetadataFromItem.Output.Ok) + /// OK + /// + /// - Remark: Generated from `#/paths//items/{itemname}/metadata/{namespace}/delete(removeMetadataFromItem)/responses/200`. + /// + /// HTTP response code: `200 ok`. + public static var ok: Self { + .ok(.init()) + } /// The associated value of the enum case if `self` is `.ok`. /// /// - Throws: An error if `self` is not `.ok`. @@ -3018,7 +3023,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -3027,18 +3032,24 @@ public enum Operations { } } } - public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } - /// Item not found. /// /// - Remark: Generated from `#/paths//items/{itemname}/metadata/{namespace}/delete(removeMetadataFromItem)/responses/404`. /// /// HTTP response code: `404 notFound`. case notFound(Operations.removeMetadataFromItem.Output.NotFound) + /// Item not found. + /// + /// - Remark: Generated from `#/paths//items/{itemname}/metadata/{namespace}/delete(removeMetadataFromItem)/responses/404`. + /// + /// HTTP response code: `404 notFound`. + public static var notFound: Self { + .notFound(.init()) + } /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -3047,7 +3058,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -3056,18 +3067,24 @@ public enum Operations { } } } - public struct MethodNotAllowed: Sendable, Hashable { /// Creates a new `MethodNotAllowed`. public init() {} } - /// Meta data not editable. /// /// - Remark: Generated from `#/paths//items/{itemname}/metadata/{namespace}/delete(removeMetadataFromItem)/responses/405`. /// /// HTTP response code: `405 methodNotAllowed`. case methodNotAllowed(Operations.removeMetadataFromItem.Output.MethodNotAllowed) + /// Meta data not editable. + /// + /// - Remark: Generated from `#/paths//items/{itemname}/metadata/{namespace}/delete(removeMetadataFromItem)/responses/405`. + /// + /// HTTP response code: `405 methodNotAllowed`. + public static var methodNotAllowed: Self { + .methodNotAllowed(.init()) + } /// The associated value of the enum case if `self` is `.methodNotAllowed`. /// /// - Throws: An error if `self` is not `.methodNotAllowed`. @@ -3076,7 +3093,7 @@ public enum Operations { get throws { switch self { case let .methodNotAllowed(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "methodNotAllowed", @@ -3085,14 +3102,12 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } } - /// Adds a tag to an item. /// /// - Remark: HTTP `PUT /items/{itemname}/tags/{tag}`. @@ -3115,13 +3130,14 @@ public enum Operations { /// - Parameters: /// - itemname: item name /// - tag: tag - public init(itemname: Swift.String, - tag: Swift.String) { + public init( + itemname: Swift.String, + tag: Swift.String + ) { self.itemname = itemname self.tag = tag } } - public var path: Operations.addTagToItem.Input.Path /// Creates a new `Input`. /// @@ -3131,19 +3147,25 @@ public enum Operations { self.path = path } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// Creates a new `Ok`. public init() {} } - /// OK /// /// - Remark: Generated from `#/paths//items/{itemname}/tags/{tag}/put(addTagToItem)/responses/200`. /// /// HTTP response code: `200 ok`. case ok(Operations.addTagToItem.Output.Ok) + /// OK + /// + /// - Remark: Generated from `#/paths//items/{itemname}/tags/{tag}/put(addTagToItem)/responses/200`. + /// + /// HTTP response code: `200 ok`. + public static var ok: Self { + .ok(.init()) + } /// The associated value of the enum case if `self` is `.ok`. /// /// - Throws: An error if `self` is not `.ok`. @@ -3152,7 +3174,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -3161,18 +3183,24 @@ public enum Operations { } } } - public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } - /// Item not found. /// /// - Remark: Generated from `#/paths//items/{itemname}/tags/{tag}/put(addTagToItem)/responses/404`. /// /// HTTP response code: `404 notFound`. case notFound(Operations.addTagToItem.Output.NotFound) + /// Item not found. + /// + /// - Remark: Generated from `#/paths//items/{itemname}/tags/{tag}/put(addTagToItem)/responses/404`. + /// + /// HTTP response code: `404 notFound`. + public static var notFound: Self { + .notFound(.init()) + } /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -3181,7 +3209,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -3190,18 +3218,24 @@ public enum Operations { } } } - public struct MethodNotAllowed: Sendable, Hashable { /// Creates a new `MethodNotAllowed`. public init() {} } - /// Item not editable. /// /// - Remark: Generated from `#/paths//items/{itemname}/tags/{tag}/put(addTagToItem)/responses/405`. /// /// HTTP response code: `405 methodNotAllowed`. case methodNotAllowed(Operations.addTagToItem.Output.MethodNotAllowed) + /// Item not editable. + /// + /// - Remark: Generated from `#/paths//items/{itemname}/tags/{tag}/put(addTagToItem)/responses/405`. + /// + /// HTTP response code: `405 methodNotAllowed`. + public static var methodNotAllowed: Self { + .methodNotAllowed(.init()) + } /// The associated value of the enum case if `self` is `.methodNotAllowed`. /// /// - Throws: An error if `self` is not `.methodNotAllowed`. @@ -3210,7 +3244,7 @@ public enum Operations { get throws { switch self { case let .methodNotAllowed(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "methodNotAllowed", @@ -3219,14 +3253,12 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } } - /// Removes a tag from an item. /// /// - Remark: HTTP `DELETE /items/{itemname}/tags/{tag}`. @@ -3249,13 +3281,14 @@ public enum Operations { /// - Parameters: /// - itemname: item name /// - tag: tag - public init(itemname: Swift.String, - tag: Swift.String) { + public init( + itemname: Swift.String, + tag: Swift.String + ) { self.itemname = itemname self.tag = tag } } - public var path: Operations.removeTagFromItem.Input.Path /// Creates a new `Input`. /// @@ -3265,19 +3298,25 @@ public enum Operations { self.path = path } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// Creates a new `Ok`. public init() {} } - /// OK /// /// - Remark: Generated from `#/paths//items/{itemname}/tags/{tag}/delete(removeTagFromItem)/responses/200`. /// /// HTTP response code: `200 ok`. case ok(Operations.removeTagFromItem.Output.Ok) + /// OK + /// + /// - Remark: Generated from `#/paths//items/{itemname}/tags/{tag}/delete(removeTagFromItem)/responses/200`. + /// + /// HTTP response code: `200 ok`. + public static var ok: Self { + .ok(.init()) + } /// The associated value of the enum case if `self` is `.ok`. /// /// - Throws: An error if `self` is not `.ok`. @@ -3286,7 +3325,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -3295,18 +3334,24 @@ public enum Operations { } } } - public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } - /// Item not found. /// /// - Remark: Generated from `#/paths//items/{itemname}/tags/{tag}/delete(removeTagFromItem)/responses/404`. /// /// HTTP response code: `404 notFound`. case notFound(Operations.removeTagFromItem.Output.NotFound) + /// Item not found. + /// + /// - Remark: Generated from `#/paths//items/{itemname}/tags/{tag}/delete(removeTagFromItem)/responses/404`. + /// + /// HTTP response code: `404 notFound`. + public static var notFound: Self { + .notFound(.init()) + } /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -3315,7 +3360,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -3324,18 +3369,24 @@ public enum Operations { } } } - public struct MethodNotAllowed: Sendable, Hashable { /// Creates a new `MethodNotAllowed`. public init() {} } - /// Item not editable. /// /// - Remark: Generated from `#/paths//items/{itemname}/tags/{tag}/delete(removeTagFromItem)/responses/405`. /// /// HTTP response code: `405 methodNotAllowed`. case methodNotAllowed(Operations.removeTagFromItem.Output.MethodNotAllowed) + /// Item not editable. + /// + /// - Remark: Generated from `#/paths//items/{itemname}/tags/{tag}/delete(removeTagFromItem)/responses/405`. + /// + /// HTTP response code: `405 methodNotAllowed`. + public static var methodNotAllowed: Self { + .methodNotAllowed(.init()) + } /// The associated value of the enum case if `self` is `.methodNotAllowed`. /// /// - Throws: An error if `self` is not `.methodNotAllowed`. @@ -3344,7 +3395,7 @@ public enum Operations { get throws { switch self { case let .methodNotAllowed(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "methodNotAllowed", @@ -3353,14 +3404,12 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } } - /// Gets a single item. /// /// - Remark: HTTP `GET /items/{itemname}`. @@ -3382,7 +3431,6 @@ public enum Operations { self.itemname = itemname } } - public var path: Operations.getItemByName.Input.Path /// - Remark: Generated from `#/paths/items/{itemname}/GET/query`. public struct Query: Sendable, Hashable { @@ -3399,13 +3447,14 @@ public enum Operations { /// - Parameters: /// - metadata: metadata selector - a comma separated list or a regular expression (returns all if no value given) /// - recursive: get member items if the item is a group item - public init(metadata: Swift.String? = nil, - recursive: Swift.Bool? = nil) { + public init( + metadata: Swift.String? = nil, + recursive: Swift.Bool? = nil + ) { self.metadata = metadata self.recursive = recursive } } - public var query: Operations.getItemByName.Input.Query /// - Remark: Generated from `#/paths/items/{itemname}/GET/header`. public struct Headers: Sendable, Hashable { @@ -3419,13 +3468,14 @@ public enum Operations { /// - Parameters: /// - Accept_hyphen_Language: language /// - accept: - public init(Accept_hyphen_Language: Swift.String? = nil, - accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + public init( + Accept_hyphen_Language: Swift.String? = nil, + accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues() + ) { self.Accept_hyphen_Language = Accept_hyphen_Language self.accept = accept } } - public var headers: Operations.getItemByName.Input.Headers /// Creates a new `Input`. /// @@ -3433,15 +3483,16 @@ public enum Operations { /// - path: /// - query: /// - headers: - public init(path: Operations.getItemByName.Input.Path, - query: Operations.getItemByName.Input.Query = .init(), - headers: Operations.getItemByName.Input.Headers = .init()) { + public init( + path: Operations.getItemByName.Input.Path, + query: Operations.getItemByName.Input.Query = .init(), + headers: Operations.getItemByName.Input.Headers = .init() + ) { self.path = path self.query = query self.headers = headers } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/items/{itemname}/GET/responses/200/content`. @@ -3456,12 +3507,11 @@ public enum Operations { get throws { switch self { case let .json(body): - body + return body } } } } - /// Received HTTP response body public var body: Operations.getItemByName.Output.Ok.Body /// Creates a new `Ok`. @@ -3472,7 +3522,6 @@ public enum Operations { self.body = body } } - /// OK /// /// - Remark: Generated from `#/paths//items/{itemname}/get(getItemByName)/responses/200`. @@ -3487,7 +3536,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -3496,18 +3545,24 @@ public enum Operations { } } } - public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } - /// Item not found /// /// - Remark: Generated from `#/paths//items/{itemname}/get(getItemByName)/responses/404`. /// /// HTTP response code: `404 notFound`. case notFound(Operations.getItemByName.Output.NotFound) + /// Item not found + /// + /// - Remark: Generated from `#/paths//items/{itemname}/get(getItemByName)/responses/404`. + /// + /// HTTP response code: `404 notFound`. + public static var notFound: Self { + .notFound(.init()) + } /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -3516,7 +3571,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -3525,13 +3580,11 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } - @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -3543,16 +3596,14 @@ public enum Operations { self = .other(rawValue) } } - public var rawValue: Swift.String { switch self { case let .other(string): - string + return string case .json: - "application/json" + return "application/json" } } - public static var allCases: [Self] { [ .json @@ -3560,7 +3611,6 @@ public enum Operations { } } } - /// Sends a command to an item. /// /// - Remark: HTTP `POST /items/{itemname}`. @@ -3582,39 +3632,45 @@ public enum Operations { self.itemname = itemname } } - public var path: Operations.sendItemCommand.Input.Path /// - Remark: Generated from `#/paths/items/{itemname}/POST/requestBody`. @frozen public enum Body: Sendable, Hashable { /// - Remark: Generated from `#/paths/items/{itemname}/POST/requestBody/content/text\/plain`. case plainText(OpenAPIRuntime.HTTPBody) } - public var body: Operations.sendItemCommand.Input.Body /// Creates a new `Input`. /// /// - Parameters: /// - path: /// - body: - public init(path: Operations.sendItemCommand.Input.Path, - body: Operations.sendItemCommand.Input.Body) { + public init( + path: Operations.sendItemCommand.Input.Path, + body: Operations.sendItemCommand.Input.Body + ) { self.path = path self.body = body } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// Creates a new `Ok`. public init() {} } - /// OK /// /// - Remark: Generated from `#/paths//items/{itemname}/post(sendItemCommand)/responses/200`. /// /// HTTP response code: `200 ok`. case ok(Operations.sendItemCommand.Output.Ok) + /// OK + /// + /// - Remark: Generated from `#/paths//items/{itemname}/post(sendItemCommand)/responses/200`. + /// + /// HTTP response code: `200 ok`. + public static var ok: Self { + .ok(.init()) + } /// The associated value of the enum case if `self` is `.ok`. /// /// - Throws: An error if `self` is not `.ok`. @@ -3623,7 +3679,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -3632,18 +3688,24 @@ public enum Operations { } } } - public struct BadRequest: Sendable, Hashable { /// Creates a new `BadRequest`. public init() {} } - /// Item command null /// /// - Remark: Generated from `#/paths//items/{itemname}/post(sendItemCommand)/responses/400`. /// /// HTTP response code: `400 badRequest`. case badRequest(Operations.sendItemCommand.Output.BadRequest) + /// Item command null + /// + /// - Remark: Generated from `#/paths//items/{itemname}/post(sendItemCommand)/responses/400`. + /// + /// HTTP response code: `400 badRequest`. + public static var badRequest: Self { + .badRequest(.init()) + } /// The associated value of the enum case if `self` is `.badRequest`. /// /// - Throws: An error if `self` is not `.badRequest`. @@ -3652,7 +3714,7 @@ public enum Operations { get throws { switch self { case let .badRequest(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "badRequest", @@ -3661,18 +3723,24 @@ public enum Operations { } } } - public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } - /// Item not found /// /// - Remark: Generated from `#/paths//items/{itemname}/post(sendItemCommand)/responses/404`. /// /// HTTP response code: `404 notFound`. case notFound(Operations.sendItemCommand.Output.NotFound) + /// Item not found + /// + /// - Remark: Generated from `#/paths//items/{itemname}/post(sendItemCommand)/responses/404`. + /// + /// HTTP response code: `404 notFound`. + public static var notFound: Self { + .notFound(.init()) + } /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -3681,7 +3749,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -3690,14 +3758,12 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } } - /// Adds a new item to the registry or updates the existing item. /// /// - Remark: HTTP `PUT /items/{itemname}`. @@ -3719,7 +3785,6 @@ public enum Operations { self.itemname = itemname } } - public var path: Operations.addOrUpdateItemInRegistry.Input.Path /// - Remark: Generated from `#/paths/items/{itemname}/PUT/header`. public struct Headers: Sendable, Hashable { @@ -3733,20 +3798,20 @@ public enum Operations { /// - Parameters: /// - Accept_hyphen_Language: language /// - accept: - public init(Accept_hyphen_Language: Swift.String? = nil, - accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + public init( + Accept_hyphen_Language: Swift.String? = nil, + accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues() + ) { self.Accept_hyphen_Language = Accept_hyphen_Language self.accept = accept } } - public var headers: Operations.addOrUpdateItemInRegistry.Input.Headers /// - Remark: Generated from `#/paths/items/{itemname}/PUT/requestBody`. @frozen public enum Body: Sendable, Hashable { /// - Remark: Generated from `#/paths/items/{itemname}/PUT/requestBody/content/application\/json`. case json(Components.Schemas.GroupItemDTO) } - public var body: Operations.addOrUpdateItemInRegistry.Input.Body /// Creates a new `Input`. /// @@ -3754,15 +3819,16 @@ public enum Operations { /// - path: /// - headers: /// - body: - public init(path: Operations.addOrUpdateItemInRegistry.Input.Path, - headers: Operations.addOrUpdateItemInRegistry.Input.Headers = .init(), - body: Operations.addOrUpdateItemInRegistry.Input.Body) { + public init( + path: Operations.addOrUpdateItemInRegistry.Input.Path, + headers: Operations.addOrUpdateItemInRegistry.Input.Headers = .init(), + body: Operations.addOrUpdateItemInRegistry.Input.Body + ) { self.path = path self.headers = headers self.body = body } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/items/{itemname}/PUT/responses/200/content`. @@ -3777,12 +3843,11 @@ public enum Operations { get throws { switch self { case let .any(body): - body + return body } } } } - /// Received HTTP response body public var body: Operations.addOrUpdateItemInRegistry.Output.Ok.Body /// Creates a new `Ok`. @@ -3793,7 +3858,6 @@ public enum Operations { self.body = body } } - /// OK /// /// - Remark: Generated from `#/paths//items/{itemname}/put(addOrUpdateItemInRegistry)/responses/200`. @@ -3808,7 +3872,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -3817,18 +3881,24 @@ public enum Operations { } } } - public struct Created: Sendable, Hashable { /// Creates a new `Created`. public init() {} } - /// Item created. /// /// - Remark: Generated from `#/paths//items/{itemname}/put(addOrUpdateItemInRegistry)/responses/201`. /// /// HTTP response code: `201 created`. case created(Operations.addOrUpdateItemInRegistry.Output.Created) + /// Item created. + /// + /// - Remark: Generated from `#/paths//items/{itemname}/put(addOrUpdateItemInRegistry)/responses/201`. + /// + /// HTTP response code: `201 created`. + public static var created: Self { + .created(.init()) + } /// The associated value of the enum case if `self` is `.created`. /// /// - Throws: An error if `self` is not `.created`. @@ -3837,7 +3907,7 @@ public enum Operations { get throws { switch self { case let .created(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "created", @@ -3846,18 +3916,24 @@ public enum Operations { } } } - public struct BadRequest: Sendable, Hashable { /// Creates a new `BadRequest`. public init() {} } - /// Payload invalid. /// /// - Remark: Generated from `#/paths//items/{itemname}/put(addOrUpdateItemInRegistry)/responses/400`. /// /// HTTP response code: `400 badRequest`. case badRequest(Operations.addOrUpdateItemInRegistry.Output.BadRequest) + /// Payload invalid. + /// + /// - Remark: Generated from `#/paths//items/{itemname}/put(addOrUpdateItemInRegistry)/responses/400`. + /// + /// HTTP response code: `400 badRequest`. + public static var badRequest: Self { + .badRequest(.init()) + } /// The associated value of the enum case if `self` is `.badRequest`. /// /// - Throws: An error if `self` is not `.badRequest`. @@ -3866,7 +3942,7 @@ public enum Operations { get throws { switch self { case let .badRequest(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "badRequest", @@ -3875,18 +3951,24 @@ public enum Operations { } } } - public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } - /// Item not found or name in path invalid. /// /// - Remark: Generated from `#/paths//items/{itemname}/put(addOrUpdateItemInRegistry)/responses/404`. /// /// HTTP response code: `404 notFound`. case notFound(Operations.addOrUpdateItemInRegistry.Output.NotFound) + /// Item not found or name in path invalid. + /// + /// - Remark: Generated from `#/paths//items/{itemname}/put(addOrUpdateItemInRegistry)/responses/404`. + /// + /// HTTP response code: `404 notFound`. + public static var notFound: Self { + .notFound(.init()) + } /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -3895,7 +3977,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -3904,18 +3986,24 @@ public enum Operations { } } } - public struct MethodNotAllowed: Sendable, Hashable { /// Creates a new `MethodNotAllowed`. public init() {} } - /// Item not editable. /// /// - Remark: Generated from `#/paths//items/{itemname}/put(addOrUpdateItemInRegistry)/responses/405`. /// /// HTTP response code: `405 methodNotAllowed`. case methodNotAllowed(Operations.addOrUpdateItemInRegistry.Output.MethodNotAllowed) + /// Item not editable. + /// + /// - Remark: Generated from `#/paths//items/{itemname}/put(addOrUpdateItemInRegistry)/responses/405`. + /// + /// HTTP response code: `405 methodNotAllowed`. + public static var methodNotAllowed: Self { + .methodNotAllowed(.init()) + } /// The associated value of the enum case if `self` is `.methodNotAllowed`. /// /// - Throws: An error if `self` is not `.methodNotAllowed`. @@ -3924,7 +4012,7 @@ public enum Operations { get throws { switch self { case let .methodNotAllowed(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "methodNotAllowed", @@ -3933,13 +4021,11 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } - @frozen public enum AcceptableContentType: AcceptableProtocol { case any case other(Swift.String) @@ -3951,16 +4037,14 @@ public enum Operations { self = .other(rawValue) } } - public var rawValue: Swift.String { switch self { case let .other(string): - string + return string case .any: - "*/*" + return "*/*" } } - public static var allCases: [Self] { [ .any @@ -3968,7 +4052,6 @@ public enum Operations { } } } - /// Removes an item from the registry. /// /// - Remark: HTTP `DELETE /items/{itemname}`. @@ -3990,7 +4073,6 @@ public enum Operations { self.itemname = itemname } } - public var path: Operations.removeItemFromRegistry.Input.Path /// Creates a new `Input`. /// @@ -4000,19 +4082,25 @@ public enum Operations { self.path = path } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// Creates a new `Ok`. public init() {} } - /// OK /// /// - Remark: Generated from `#/paths//items/{itemname}/delete(removeItemFromRegistry)/responses/200`. /// /// HTTP response code: `200 ok`. case ok(Operations.removeItemFromRegistry.Output.Ok) + /// OK + /// + /// - Remark: Generated from `#/paths//items/{itemname}/delete(removeItemFromRegistry)/responses/200`. + /// + /// HTTP response code: `200 ok`. + public static var ok: Self { + .ok(.init()) + } /// The associated value of the enum case if `self` is `.ok`. /// /// - Throws: An error if `self` is not `.ok`. @@ -4021,7 +4109,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -4030,18 +4118,24 @@ public enum Operations { } } } - public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } - /// Item not found or item is not editable. /// /// - Remark: Generated from `#/paths//items/{itemname}/delete(removeItemFromRegistry)/responses/404`. /// /// HTTP response code: `404 notFound`. case notFound(Operations.removeItemFromRegistry.Output.NotFound) + /// Item not found or item is not editable. + /// + /// - Remark: Generated from `#/paths//items/{itemname}/delete(removeItemFromRegistry)/responses/404`. + /// + /// HTTP response code: `404 notFound`. + public static var notFound: Self { + .notFound(.init()) + } /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -4050,7 +4144,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -4059,14 +4153,12 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } } - /// Get all available items. /// /// - Remark: HTTP `GET /items`. @@ -4109,12 +4201,14 @@ public enum Operations { /// - recursive: get member items recursively /// - fields: limit output to the given fields (comma separated) /// - staticDataOnly: provides a cacheable list of values not expected to change regularly and checks the If-Modified-Since header, all other parameters are ignored except "metadata" - public init(_type: Swift.String? = nil, - tags: Swift.String? = nil, - metadata: Swift.String? = nil, - recursive: Swift.Bool? = nil, - fields: Swift.String? = nil, - staticDataOnly: Swift.Bool? = nil) { + public init( + _type: Swift.String? = nil, + tags: Swift.String? = nil, + metadata: Swift.String? = nil, + recursive: Swift.Bool? = nil, + fields: Swift.String? = nil, + staticDataOnly: Swift.Bool? = nil + ) { self._type = _type self.tags = tags self.metadata = metadata @@ -4123,7 +4217,6 @@ public enum Operations { self.staticDataOnly = staticDataOnly } } - public var query: Operations.getItems.Input.Query /// - Remark: Generated from `#/paths/items/GET/header`. public struct Headers: Sendable, Hashable { @@ -4137,26 +4230,28 @@ public enum Operations { /// - Parameters: /// - Accept_hyphen_Language: language /// - accept: - public init(Accept_hyphen_Language: Swift.String? = nil, - accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + public init( + Accept_hyphen_Language: Swift.String? = nil, + accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues() + ) { self.Accept_hyphen_Language = Accept_hyphen_Language self.accept = accept } } - public var headers: Operations.getItems.Input.Headers /// Creates a new `Input`. /// /// - Parameters: /// - query: /// - headers: - public init(query: Operations.getItems.Input.Query = .init(), - headers: Operations.getItems.Input.Headers = .init()) { + public init( + query: Operations.getItems.Input.Query = .init(), + headers: Operations.getItems.Input.Headers = .init() + ) { self.query = query self.headers = headers } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/items/GET/responses/200/content`. @@ -4171,12 +4266,11 @@ public enum Operations { get throws { switch self { case let .json(body): - body + return body } } } } - /// Received HTTP response body public var body: Operations.getItems.Output.Ok.Body /// Creates a new `Ok`. @@ -4187,7 +4281,6 @@ public enum Operations { self.body = body } } - /// OK /// /// - Remark: Generated from `#/paths//items/get(getItems)/responses/200`. @@ -4202,7 +4295,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -4211,13 +4304,11 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } - @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -4229,16 +4320,14 @@ public enum Operations { self = .other(rawValue) } } - public var rawValue: Swift.String { switch self { case let .other(string): - string + return string case .json: - "application/json" + return "application/json" } } - public static var allCases: [Self] { [ .json @@ -4246,7 +4335,6 @@ public enum Operations { } } } - /// Adds a list of items to the registry or updates the existing items. /// /// - Remark: HTTP `PUT /items`. @@ -4265,27 +4353,26 @@ public enum Operations { self.accept = accept } } - public var headers: Operations.addOrUpdateItemsInRegistry.Input.Headers /// - Remark: Generated from `#/paths/items/PUT/requestBody`. @frozen public enum Body: Sendable, Hashable { /// - Remark: Generated from `#/paths/items/PUT/requestBody/content/application\/json`. case json([Components.Schemas.GroupItemDTO]) } - public var body: Operations.addOrUpdateItemsInRegistry.Input.Body /// Creates a new `Input`. /// /// - Parameters: /// - headers: /// - body: - public init(headers: Operations.addOrUpdateItemsInRegistry.Input.Headers = .init(), - body: Operations.addOrUpdateItemsInRegistry.Input.Body) { + public init( + headers: Operations.addOrUpdateItemsInRegistry.Input.Headers = .init(), + body: Operations.addOrUpdateItemsInRegistry.Input.Body + ) { self.headers = headers self.body = body } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/items/PUT/responses/200/content`. @@ -4300,12 +4387,11 @@ public enum Operations { get throws { switch self { case let .any(body): - body + return body } } } } - /// Received HTTP response body public var body: Operations.addOrUpdateItemsInRegistry.Output.Ok.Body /// Creates a new `Ok`. @@ -4316,7 +4402,6 @@ public enum Operations { self.body = body } } - /// OK /// /// - Remark: Generated from `#/paths//items/put(addOrUpdateItemsInRegistry)/responses/200`. @@ -4331,7 +4416,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -4340,18 +4425,24 @@ public enum Operations { } } } - public struct BadRequest: Sendable, Hashable { /// Creates a new `BadRequest`. public init() {} } - /// Payload is invalid. /// /// - Remark: Generated from `#/paths//items/put(addOrUpdateItemsInRegistry)/responses/400`. /// /// HTTP response code: `400 badRequest`. case badRequest(Operations.addOrUpdateItemsInRegistry.Output.BadRequest) + /// Payload is invalid. + /// + /// - Remark: Generated from `#/paths//items/put(addOrUpdateItemsInRegistry)/responses/400`. + /// + /// HTTP response code: `400 badRequest`. + public static var badRequest: Self { + .badRequest(.init()) + } /// The associated value of the enum case if `self` is `.badRequest`. /// /// - Throws: An error if `self` is not `.badRequest`. @@ -4360,7 +4451,7 @@ public enum Operations { get throws { switch self { case let .badRequest(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "badRequest", @@ -4369,13 +4460,11 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } - @frozen public enum AcceptableContentType: AcceptableProtocol { case any case other(Swift.String) @@ -4387,16 +4476,14 @@ public enum Operations { self = .other(rawValue) } } - public var rawValue: Swift.String { switch self { case let .other(string): - string + return string case .any: - "*/*" + return "*/*" } } - public static var allCases: [Self] { [ .any @@ -4404,7 +4491,6 @@ public enum Operations { } } } - /// Gets the state of an item. /// /// - Remark: HTTP `GET /items/{itemname}/state`. @@ -4426,7 +4512,6 @@ public enum Operations { self.itemname = itemname } } - public var path: Operations.getItemState_1.Input.Path /// - Remark: Generated from `#/paths/items/{itemname}/state/GET/header`. public struct Headers: Sendable, Hashable { @@ -4439,20 +4524,20 @@ public enum Operations { self.accept = accept } } - public var headers: Operations.getItemState_1.Input.Headers /// Creates a new `Input`. /// /// - Parameters: /// - path: /// - headers: - public init(path: Operations.getItemState_1.Input.Path, - headers: Operations.getItemState_1.Input.Headers = .init()) { + public init( + path: Operations.getItemState_1.Input.Path, + headers: Operations.getItemState_1.Input.Headers = .init() + ) { self.path = path self.headers = headers } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/items/{itemname}/state/GET/responses/200/content`. @@ -4467,12 +4552,11 @@ public enum Operations { get throws { switch self { case let .plainText(body): - body + return body } } } } - /// Received HTTP response body public var body: Operations.getItemState_1.Output.Ok.Body /// Creates a new `Ok`. @@ -4483,7 +4567,6 @@ public enum Operations { self.body = body } } - /// OK /// /// - Remark: Generated from `#/paths//items/{itemname}/state/get(getItemState_1)/responses/200`. @@ -4498,7 +4581,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -4507,18 +4590,24 @@ public enum Operations { } } } - public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } - /// Item not found /// /// - Remark: Generated from `#/paths//items/{itemname}/state/get(getItemState_1)/responses/404`. /// /// HTTP response code: `404 notFound`. case notFound(Operations.getItemState_1.Output.NotFound) + /// Item not found + /// + /// - Remark: Generated from `#/paths//items/{itemname}/state/get(getItemState_1)/responses/404`. + /// + /// HTTP response code: `404 notFound`. + public static var notFound: Self { + .notFound(.init()) + } /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -4527,7 +4616,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -4536,13 +4625,11 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } - @frozen public enum AcceptableContentType: AcceptableProtocol { case plainText case other(Swift.String) @@ -4554,16 +4641,14 @@ public enum Operations { self = .other(rawValue) } } - public var rawValue: Swift.String { switch self { case let .other(string): - string + return string case .plainText: - "text/plain" + return "text/plain" } } - public static var allCases: [Self] { [ .plainText @@ -4571,7 +4656,6 @@ public enum Operations { } } } - /// Updates the state of an item. /// /// - Remark: HTTP `PUT /items/{itemname}/state`. @@ -4593,7 +4677,6 @@ public enum Operations { self.itemname = itemname } } - public var path: Operations.updateItemState.Input.Path /// - Remark: Generated from `#/paths/items/{itemname}/state/PUT/header`. public struct Headers: Sendable, Hashable { @@ -4609,14 +4692,12 @@ public enum Operations { self.Accept_hyphen_Language = Accept_hyphen_Language } } - public var headers: Operations.updateItemState.Input.Headers /// - Remark: Generated from `#/paths/items/{itemname}/state/PUT/requestBody`. @frozen public enum Body: Sendable, Hashable { /// - Remark: Generated from `#/paths/items/{itemname}/state/PUT/requestBody/content/text\/plain`. case plainText(OpenAPIRuntime.HTTPBody) } - public var body: Operations.updateItemState.Input.Body /// Creates a new `Input`. /// @@ -4624,27 +4705,35 @@ public enum Operations { /// - path: /// - headers: /// - body: - public init(path: Operations.updateItemState.Input.Path, - headers: Operations.updateItemState.Input.Headers = .init(), - body: Operations.updateItemState.Input.Body) { + public init( + path: Operations.updateItemState.Input.Path, + headers: Operations.updateItemState.Input.Headers = .init(), + body: Operations.updateItemState.Input.Body + ) { self.path = path self.headers = headers self.body = body } } - @frozen public enum Output: Sendable, Hashable { public struct Accepted: Sendable, Hashable { /// Creates a new `Accepted`. public init() {} } - /// Accepted /// /// - Remark: Generated from `#/paths//items/{itemname}/state/put(updateItemState)/responses/202`. /// /// HTTP response code: `202 accepted`. case accepted(Operations.updateItemState.Output.Accepted) + /// Accepted + /// + /// - Remark: Generated from `#/paths//items/{itemname}/state/put(updateItemState)/responses/202`. + /// + /// HTTP response code: `202 accepted`. + public static var accepted: Self { + .accepted(.init()) + } /// The associated value of the enum case if `self` is `.accepted`. /// /// - Throws: An error if `self` is not `.accepted`. @@ -4653,7 +4742,7 @@ public enum Operations { get throws { switch self { case let .accepted(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "accepted", @@ -4662,18 +4751,24 @@ public enum Operations { } } } - public struct BadRequest: Sendable, Hashable { /// Creates a new `BadRequest`. public init() {} } - /// Item state null /// /// - Remark: Generated from `#/paths//items/{itemname}/state/put(updateItemState)/responses/400`. /// /// HTTP response code: `400 badRequest`. case badRequest(Operations.updateItemState.Output.BadRequest) + /// Item state null + /// + /// - Remark: Generated from `#/paths//items/{itemname}/state/put(updateItemState)/responses/400`. + /// + /// HTTP response code: `400 badRequest`. + public static var badRequest: Self { + .badRequest(.init()) + } /// The associated value of the enum case if `self` is `.badRequest`. /// /// - Throws: An error if `self` is not `.badRequest`. @@ -4682,7 +4777,7 @@ public enum Operations { get throws { switch self { case let .badRequest(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "badRequest", @@ -4691,18 +4786,24 @@ public enum Operations { } } } - public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } - /// Item not found /// /// - Remark: Generated from `#/paths//items/{itemname}/state/put(updateItemState)/responses/404`. /// /// HTTP response code: `404 notFound`. case notFound(Operations.updateItemState.Output.NotFound) + /// Item not found + /// + /// - Remark: Generated from `#/paths//items/{itemname}/state/put(updateItemState)/responses/404`. + /// + /// HTTP response code: `404 notFound`. + public static var notFound: Self { + .notFound(.init()) + } /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -4711,7 +4812,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -4720,14 +4821,12 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } } - /// Gets the namespace of an item. /// /// - Remark: HTTP `GET /items/{itemname}/metadata/namespaces`. @@ -4749,7 +4848,6 @@ public enum Operations { self.itemname = itemname } } - public var path: Operations.getItemNamespaces.Input.Path /// - Remark: Generated from `#/paths/items/{itemname}/metadata/namespaces/GET/header`. public struct Headers: Sendable, Hashable { @@ -4763,26 +4861,28 @@ public enum Operations { /// - Parameters: /// - Accept_hyphen_Language: language /// - accept: - public init(Accept_hyphen_Language: Swift.String? = nil, - accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + public init( + Accept_hyphen_Language: Swift.String? = nil, + accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues() + ) { self.Accept_hyphen_Language = Accept_hyphen_Language self.accept = accept } } - public var headers: Operations.getItemNamespaces.Input.Headers /// Creates a new `Input`. /// /// - Parameters: /// - path: /// - headers: - public init(path: Operations.getItemNamespaces.Input.Path, - headers: Operations.getItemNamespaces.Input.Headers = .init()) { + public init( + path: Operations.getItemNamespaces.Input.Path, + headers: Operations.getItemNamespaces.Input.Headers = .init() + ) { self.path = path self.headers = headers } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/items/{itemname}/metadata/namespaces/GET/responses/200/content`. @@ -4797,12 +4897,11 @@ public enum Operations { get throws { switch self { case let .json(body): - body + return body } } } } - /// Received HTTP response body public var body: Operations.getItemNamespaces.Output.Ok.Body /// Creates a new `Ok`. @@ -4813,7 +4912,6 @@ public enum Operations { self.body = body } } - /// OK /// /// - Remark: Generated from `#/paths//items/{itemname}/metadata/namespaces/get(getItemNamespaces)/responses/200`. @@ -4828,7 +4926,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -4837,18 +4935,24 @@ public enum Operations { } } } - public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } - /// Item not found /// /// - Remark: Generated from `#/paths//items/{itemname}/metadata/namespaces/get(getItemNamespaces)/responses/404`. /// /// HTTP response code: `404 notFound`. case notFound(Operations.getItemNamespaces.Output.NotFound) + /// Item not found + /// + /// - Remark: Generated from `#/paths//items/{itemname}/metadata/namespaces/get(getItemNamespaces)/responses/404`. + /// + /// HTTP response code: `404 notFound`. + public static var notFound: Self { + .notFound(.init()) + } /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -4857,7 +4961,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -4866,13 +4970,11 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } - @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -4884,16 +4986,14 @@ public enum Operations { self = .other(rawValue) } } - public var rawValue: Swift.String { switch self { case let .other(string): - string + return string case .json: - "application/json" + return "application/json" } } - public static var allCases: [Self] { [ .json @@ -4901,7 +5001,6 @@ public enum Operations { } } } - /// Gets the item which defines the requested semantics of an item. /// /// - Remark: HTTP `GET /items/{itemName}/semantic/{semanticClass}`. @@ -4924,13 +5023,14 @@ public enum Operations { /// - Parameters: /// - itemName: item name /// - semanticClass: semantic class - public init(itemName: Swift.String, - semanticClass: Swift.String) { + public init( + itemName: Swift.String, + semanticClass: Swift.String + ) { self.itemName = itemName self.semanticClass = semanticClass } } - public var path: Operations.getSemanticItem.Input.Path /// - Remark: Generated from `#/paths/items/{itemName}/semantic/{semanticClass}/GET/header`. public struct Headers: Sendable, Hashable { @@ -4946,32 +5046,39 @@ public enum Operations { self.Accept_hyphen_Language = Accept_hyphen_Language } } - public var headers: Operations.getSemanticItem.Input.Headers /// Creates a new `Input`. /// /// - Parameters: /// - path: /// - headers: - public init(path: Operations.getSemanticItem.Input.Path, - headers: Operations.getSemanticItem.Input.Headers = .init()) { + public init( + path: Operations.getSemanticItem.Input.Path, + headers: Operations.getSemanticItem.Input.Headers = .init() + ) { self.path = path self.headers = headers } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// Creates a new `Ok`. public init() {} } - /// OK /// /// - Remark: Generated from `#/paths//items/{itemName}/semantic/{semanticClass}/get(getSemanticItem)/responses/200`. /// /// HTTP response code: `200 ok`. case ok(Operations.getSemanticItem.Output.Ok) + /// OK + /// + /// - Remark: Generated from `#/paths//items/{itemName}/semantic/{semanticClass}/get(getSemanticItem)/responses/200`. + /// + /// HTTP response code: `200 ok`. + public static var ok: Self { + .ok(.init()) + } /// The associated value of the enum case if `self` is `.ok`. /// /// - Throws: An error if `self` is not `.ok`. @@ -4980,7 +5087,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -4989,18 +5096,24 @@ public enum Operations { } } } - public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } - /// Item not found /// /// - Remark: Generated from `#/paths//items/{itemName}/semantic/{semanticClass}/get(getSemanticItem)/responses/404`. /// /// HTTP response code: `404 notFound`. - case notFound(Operations.getSemanticItem.Output.NotFound) + case notFound(Operations.getSemanticItem.Output.NotFound) + /// Item not found + /// + /// - Remark: Generated from `#/paths//items/{itemName}/semantic/{semanticClass}/get(getSemanticItem)/responses/404`. + /// + /// HTTP response code: `404 notFound`. + public static var notFound: Self { + .notFound(.init()) + } /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -5009,7 +5122,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -5018,14 +5131,12 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } } - /// Remove unused/orphaned metadata. /// /// - Remark: HTTP `POST /items/metadata/purge`. @@ -5036,19 +5147,25 @@ public enum Operations { /// Creates a new `Input`. public init() {} } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// Creates a new `Ok`. public init() {} } - /// OK /// /// - Remark: Generated from `#/paths//items/metadata/purge/post(purgeDatabase)/responses/200`. /// /// HTTP response code: `200 ok`. case ok(Operations.purgeDatabase.Output.Ok) + /// OK + /// + /// - Remark: Generated from `#/paths//items/metadata/purge/post(purgeDatabase)/responses/200`. + /// + /// HTTP response code: `200 ok`. + public static var ok: Self { + .ok(.init()) + } /// The associated value of the enum case if `self` is `.ok`. /// /// - Throws: An error if `self` is not `.ok`. @@ -5057,7 +5174,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -5066,14 +5183,12 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } } - /// Creates a sitemap event subscription. /// /// - Remark: HTTP `POST /sitemaps/events/subscribe`. @@ -5092,7 +5207,6 @@ public enum Operations { self.accept = accept } } - public var headers: Operations.createSitemapEventSubscription.Input.Headers /// Creates a new `Input`. /// @@ -5102,19 +5216,25 @@ public enum Operations { self.headers = headers } } - @frozen public enum Output: Sendable, Hashable { public struct Created: Sendable, Hashable { /// Creates a new `Created`. public init() {} } - /// Subscription created. /// /// - Remark: Generated from `#/paths//sitemaps/events/subscribe/post(createSitemapEventSubscription)/responses/201`. /// /// HTTP response code: `201 created`. case created(Operations.createSitemapEventSubscription.Output.Created) + /// Subscription created. + /// + /// - Remark: Generated from `#/paths//sitemaps/events/subscribe/post(createSitemapEventSubscription)/responses/201`. + /// + /// HTTP response code: `201 created`. + public static var created: Self { + .created(.init()) + } /// The associated value of the enum case if `self` is `.created`. /// /// - Throws: An error if `self` is not `.created`. @@ -5123,7 +5243,7 @@ public enum Operations { get throws { switch self { case let .created(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "created", @@ -5132,7 +5252,6 @@ public enum Operations { } } } - public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/sitemaps/events/subscribe/POST/responses/200/content`. @frozen public enum Body: Sendable, Hashable { @@ -5146,12 +5265,11 @@ public enum Operations { get throws { switch self { case let .json(body): - body + return body } } } } - /// Received HTTP response body public var body: Operations.createSitemapEventSubscription.Output.Ok.Body /// Creates a new `Ok`. @@ -5162,7 +5280,6 @@ public enum Operations { self.body = body } } - /// OK /// /// - Remark: Generated from `#/paths//sitemaps/events/subscribe/post(createSitemapEventSubscription)/responses/200`. @@ -5177,7 +5294,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -5186,18 +5303,24 @@ public enum Operations { } } } - public struct ServiceUnavailable: Sendable, Hashable { /// Creates a new `ServiceUnavailable`. public init() {} } - /// Subscriptions limit reached. /// /// - Remark: Generated from `#/paths//sitemaps/events/subscribe/post(createSitemapEventSubscription)/responses/503`. /// /// HTTP response code: `503 serviceUnavailable`. case serviceUnavailable(Operations.createSitemapEventSubscription.Output.ServiceUnavailable) + /// Subscriptions limit reached. + /// + /// - Remark: Generated from `#/paths//sitemaps/events/subscribe/post(createSitemapEventSubscription)/responses/503`. + /// + /// HTTP response code: `503 serviceUnavailable`. + public static var serviceUnavailable: Self { + .serviceUnavailable(.init()) + } /// The associated value of the enum case if `self` is `.serviceUnavailable`. /// /// - Throws: An error if `self` is not `.serviceUnavailable`. @@ -5206,7 +5329,7 @@ public enum Operations { get throws { switch self { case let .serviceUnavailable(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "serviceUnavailable", @@ -5215,13 +5338,11 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } - @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -5233,16 +5354,14 @@ public enum Operations { self = .other(rawValue) } } - public var rawValue: Swift.String { switch self { case let .other(string): - string + return string case .json: - "application/json" + return "application/json" } } - public static var allCases: [Self] { [ .json @@ -5250,7 +5369,6 @@ public enum Operations { } } } - /// Polls the data for one page of a sitemap. /// /// - Remark: HTTP `GET /sitemaps/{sitemapname}/{pageid}`. @@ -5273,13 +5391,14 @@ public enum Operations { /// - Parameters: /// - sitemapname: sitemap name /// - pageid: page id - public init(sitemapname: Swift.String, - pageid: Swift.String) { + public init( + sitemapname: Swift.String, + pageid: Swift.String + ) { self.sitemapname = sitemapname self.pageid = pageid } } - public var path: Operations.pollDataForPage.Input.Path /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/{pageid}/GET/query`. public struct Query: Sendable, Hashable { @@ -5296,13 +5415,14 @@ public enum Operations { /// - Parameters: /// - subscriptionid: subscriptionid /// - includeHidden: include hidden widgets - public init(subscriptionid: Swift.String? = nil, - includeHidden: Swift.Bool? = nil) { + public init( + subscriptionid: Swift.String? = nil, + includeHidden: Swift.Bool? = nil + ) { self.subscriptionid = subscriptionid self.includeHidden = includeHidden } } - public var query: Operations.pollDataForPage.Input.Query /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/{pageid}/GET/header`. public struct Headers: Sendable, Hashable { @@ -5321,15 +5441,16 @@ public enum Operations { /// - Accept_hyphen_Language: language /// - X_hyphen_Atmosphere_hyphen_Transport: X-Atmosphere-Transport for long polling /// - accept: - public init(Accept_hyphen_Language: Swift.String? = nil, - X_hyphen_Atmosphere_hyphen_Transport: Swift.String? = nil, - accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + public init( + Accept_hyphen_Language: Swift.String? = nil, + X_hyphen_Atmosphere_hyphen_Transport: Swift.String? = nil, + accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues() + ) { self.Accept_hyphen_Language = Accept_hyphen_Language self.X_hyphen_Atmosphere_hyphen_Transport = X_hyphen_Atmosphere_hyphen_Transport self.accept = accept } } - public var headers: Operations.pollDataForPage.Input.Headers /// Creates a new `Input`. /// @@ -5337,15 +5458,16 @@ public enum Operations { /// - path: /// - query: /// - headers: - public init(path: Operations.pollDataForPage.Input.Path, - query: Operations.pollDataForPage.Input.Query = .init(), - headers: Operations.pollDataForPage.Input.Headers = .init()) { + public init( + path: Operations.pollDataForPage.Input.Path, + query: Operations.pollDataForPage.Input.Query = .init(), + headers: Operations.pollDataForPage.Input.Headers = .init() + ) { self.path = path self.query = query self.headers = headers } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/{pageid}/GET/responses/200/content`. @@ -5360,12 +5482,11 @@ public enum Operations { get throws { switch self { case let .json(body): - body + return body } } } } - /// Received HTTP response body public var body: Operations.pollDataForPage.Output.Ok.Body /// Creates a new `Ok`. @@ -5376,7 +5497,6 @@ public enum Operations { self.body = body } } - /// OK /// /// - Remark: Generated from `#/paths//sitemaps/{sitemapname}/{pageid}/get(pollDataForPage)/responses/200`. @@ -5391,7 +5511,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -5400,18 +5520,24 @@ public enum Operations { } } } - public struct BadRequest: Sendable, Hashable { /// Creates a new `BadRequest`. public init() {} } - /// Invalid subscription id has been provided. /// /// - Remark: Generated from `#/paths//sitemaps/{sitemapname}/{pageid}/get(pollDataForPage)/responses/400`. /// /// HTTP response code: `400 badRequest`. case badRequest(Operations.pollDataForPage.Output.BadRequest) + /// Invalid subscription id has been provided. + /// + /// - Remark: Generated from `#/paths//sitemaps/{sitemapname}/{pageid}/get(pollDataForPage)/responses/400`. + /// + /// HTTP response code: `400 badRequest`. + public static var badRequest: Self { + .badRequest(.init()) + } /// The associated value of the enum case if `self` is `.badRequest`. /// /// - Throws: An error if `self` is not `.badRequest`. @@ -5420,7 +5546,7 @@ public enum Operations { get throws { switch self { case let .badRequest(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "badRequest", @@ -5429,18 +5555,24 @@ public enum Operations { } } } - public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } - /// Sitemap with requested name does not exist or page does not exist, or page refers to a non-linkable widget /// /// - Remark: Generated from `#/paths//sitemaps/{sitemapname}/{pageid}/get(pollDataForPage)/responses/404`. /// /// HTTP response code: `404 notFound`. case notFound(Operations.pollDataForPage.Output.NotFound) + /// Sitemap with requested name does not exist or page does not exist, or page refers to a non-linkable widget + /// + /// - Remark: Generated from `#/paths//sitemaps/{sitemapname}/{pageid}/get(pollDataForPage)/responses/404`. + /// + /// HTTP response code: `404 notFound`. + public static var notFound: Self { + .notFound(.init()) + } /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -5449,7 +5581,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -5458,13 +5590,11 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } - @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -5476,16 +5606,14 @@ public enum Operations { self = .other(rawValue) } } - public var rawValue: Swift.String { switch self { case let .other(string): - string + return string case .json: - "application/json" + return "application/json" } } - public static var allCases: [Self] { [ .json @@ -5493,7 +5621,6 @@ public enum Operations { } } } - /// Polls the data for a whole sitemap. Not recommended due to potentially high traffic. /// /// - Remark: HTTP `GET /sitemaps/{sitemapname}/*`. @@ -5515,7 +5642,6 @@ public enum Operations { self.sitemapname = sitemapname } } - public var path: Operations.pollDataForSitemap.Input.Path /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/*/GET/query`. public struct Query: Sendable, Hashable { @@ -5532,13 +5658,14 @@ public enum Operations { /// - Parameters: /// - subscriptionid: subscriptionid /// - includeHidden: include hidden widgets - public init(subscriptionid: Swift.String? = nil, - includeHidden: Swift.Bool? = nil) { + public init( + subscriptionid: Swift.String? = nil, + includeHidden: Swift.Bool? = nil + ) { self.subscriptionid = subscriptionid self.includeHidden = includeHidden } } - public var query: Operations.pollDataForSitemap.Input.Query /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/*/GET/header`. public struct Headers: Sendable, Hashable { @@ -5557,15 +5684,16 @@ public enum Operations { /// - Accept_hyphen_Language: language /// - X_hyphen_Atmosphere_hyphen_Transport: X-Atmosphere-Transport for long polling /// - accept: - public init(Accept_hyphen_Language: Swift.String? = nil, - X_hyphen_Atmosphere_hyphen_Transport: Swift.String? = nil, - accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + public init( + Accept_hyphen_Language: Swift.String? = nil, + X_hyphen_Atmosphere_hyphen_Transport: Swift.String? = nil, + accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues() + ) { self.Accept_hyphen_Language = Accept_hyphen_Language self.X_hyphen_Atmosphere_hyphen_Transport = X_hyphen_Atmosphere_hyphen_Transport self.accept = accept } } - public var headers: Operations.pollDataForSitemap.Input.Headers /// Creates a new `Input`. /// @@ -5573,15 +5701,16 @@ public enum Operations { /// - path: /// - query: /// - headers: - public init(path: Operations.pollDataForSitemap.Input.Path, - query: Operations.pollDataForSitemap.Input.Query = .init(), - headers: Operations.pollDataForSitemap.Input.Headers = .init()) { + public init( + path: Operations.pollDataForSitemap.Input.Path, + query: Operations.pollDataForSitemap.Input.Query = .init(), + headers: Operations.pollDataForSitemap.Input.Headers = .init() + ) { self.path = path self.query = query self.headers = headers } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/*/GET/responses/200/content`. @@ -5596,12 +5725,11 @@ public enum Operations { get throws { switch self { case let .json(body): - body + return body } } } } - /// Received HTTP response body public var body: Operations.pollDataForSitemap.Output.Ok.Body /// Creates a new `Ok`. @@ -5612,7 +5740,6 @@ public enum Operations { self.body = body } } - /// OK /// /// - Remark: Generated from `#/paths//sitemaps/{sitemapname}/*/get(pollDataForSitemap)/responses/200`. @@ -5627,7 +5754,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -5636,18 +5763,24 @@ public enum Operations { } } } - public struct BadRequest: Sendable, Hashable { /// Creates a new `BadRequest`. public init() {} } - /// Invalid subscription id has been provided. /// /// - Remark: Generated from `#/paths//sitemaps/{sitemapname}/*/get(pollDataForSitemap)/responses/400`. /// /// HTTP response code: `400 badRequest`. case badRequest(Operations.pollDataForSitemap.Output.BadRequest) + /// Invalid subscription id has been provided. + /// + /// - Remark: Generated from `#/paths//sitemaps/{sitemapname}/*/get(pollDataForSitemap)/responses/400`. + /// + /// HTTP response code: `400 badRequest`. + public static var badRequest: Self { + .badRequest(.init()) + } /// The associated value of the enum case if `self` is `.badRequest`. /// /// - Throws: An error if `self` is not `.badRequest`. @@ -5656,7 +5789,7 @@ public enum Operations { get throws { switch self { case let .badRequest(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "badRequest", @@ -5665,18 +5798,24 @@ public enum Operations { } } } - public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } - /// Sitemap with requested name does not exist /// /// - Remark: Generated from `#/paths//sitemaps/{sitemapname}/*/get(pollDataForSitemap)/responses/404`. /// /// HTTP response code: `404 notFound`. case notFound(Operations.pollDataForSitemap.Output.NotFound) + /// Sitemap with requested name does not exist + /// + /// - Remark: Generated from `#/paths//sitemaps/{sitemapname}/*/get(pollDataForSitemap)/responses/404`. + /// + /// HTTP response code: `404 notFound`. + public static var notFound: Self { + .notFound(.init()) + } /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -5685,7 +5824,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -5694,13 +5833,11 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } - @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -5712,16 +5849,14 @@ public enum Operations { self = .other(rawValue) } } - public var rawValue: Swift.String { switch self { case let .other(string): - string + return string case .json: - "application/json" + return "application/json" } } - public static var allCases: [Self] { [ .json @@ -5729,7 +5864,6 @@ public enum Operations { } } } - /// Get sitemap by name. /// /// - Remark: HTTP `GET /sitemaps/{sitemapname}`. @@ -5751,7 +5885,6 @@ public enum Operations { self.sitemapname = sitemapname } } - public var path: Operations.getSitemapByName.Input.Path /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/GET/query`. public struct Query: Sendable, Hashable { @@ -5769,15 +5902,16 @@ public enum Operations { /// - _type: /// - jsoncallback: /// - includeHidden: include hidden widgets - public init(_type: Swift.String? = nil, - jsoncallback: Swift.String? = nil, - includeHidden: Swift.Bool? = nil) { + public init( + _type: Swift.String? = nil, + jsoncallback: Swift.String? = nil, + includeHidden: Swift.Bool? = nil + ) { self._type = _type self.jsoncallback = jsoncallback self.includeHidden = includeHidden } } - public var query: Operations.getSitemapByName.Input.Query /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/GET/header`. public struct Headers: Sendable, Hashable { @@ -5791,13 +5925,14 @@ public enum Operations { /// - Parameters: /// - Accept_hyphen_Language: language /// - accept: - public init(Accept_hyphen_Language: Swift.String? = nil, - accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + public init( + Accept_hyphen_Language: Swift.String? = nil, + accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues() + ) { self.Accept_hyphen_Language = Accept_hyphen_Language self.accept = accept } } - public var headers: Operations.getSitemapByName.Input.Headers /// Creates a new `Input`. /// @@ -5805,15 +5940,16 @@ public enum Operations { /// - path: /// - query: /// - headers: - public init(path: Operations.getSitemapByName.Input.Path, - query: Operations.getSitemapByName.Input.Query = .init(), - headers: Operations.getSitemapByName.Input.Headers = .init()) { + public init( + path: Operations.getSitemapByName.Input.Path, + query: Operations.getSitemapByName.Input.Query = .init(), + headers: Operations.getSitemapByName.Input.Headers = .init() + ) { self.path = path self.query = query self.headers = headers } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/GET/responses/200/content`. @@ -5828,12 +5964,11 @@ public enum Operations { get throws { switch self { case let .json(body): - body + return body } } } } - /// Received HTTP response body public var body: Operations.getSitemapByName.Output.Ok.Body /// Creates a new `Ok`. @@ -5844,7 +5979,6 @@ public enum Operations { self.body = body } } - /// OK /// /// - Remark: Generated from `#/paths//sitemaps/{sitemapname}/get(getSitemapByName)/responses/200`. @@ -5859,7 +5993,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -5868,13 +6002,11 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } - @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -5886,16 +6018,14 @@ public enum Operations { self = .other(rawValue) } } - public var rawValue: Swift.String { switch self { case let .other(string): - string + return string case .json: - "application/json" + return "application/json" } } - public static var allCases: [Self] { [ .json @@ -5903,7 +6033,6 @@ public enum Operations { } } } - /// Get sitemap events for a whole sitemap. Not recommended due to potentially high traffic. /// /// - Remark: HTTP `GET /sitemaps/events/{subscriptionid}/*`. @@ -5925,7 +6054,6 @@ public enum Operations { self.subscriptionid = subscriptionid } } - public var path: Operations.getSitemapEvents.Input.Path /// - Remark: Generated from `#/paths/sitemaps/events/{subscriptionid}/*/GET/query`. public struct Query: Sendable, Hashable { @@ -5941,32 +6069,39 @@ public enum Operations { self.sitemap = sitemap } } - public var query: Operations.getSitemapEvents.Input.Query /// Creates a new `Input`. /// /// - Parameters: /// - path: /// - query: - public init(path: Operations.getSitemapEvents.Input.Path, - query: Operations.getSitemapEvents.Input.Query = .init()) { + public init( + path: Operations.getSitemapEvents.Input.Path, + query: Operations.getSitemapEvents.Input.Query = .init() + ) { self.path = path self.query = query } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// Creates a new `Ok`. public init() {} } - /// OK /// /// - Remark: Generated from `#/paths//sitemaps/events/{subscriptionid}/*/get(getSitemapEvents)/responses/200`. /// /// HTTP response code: `200 ok`. case ok(Operations.getSitemapEvents.Output.Ok) + /// OK + /// + /// - Remark: Generated from `#/paths//sitemaps/events/{subscriptionid}/*/get(getSitemapEvents)/responses/200`. + /// + /// HTTP response code: `200 ok`. + public static var ok: Self { + .ok(.init()) + } /// The associated value of the enum case if `self` is `.ok`. /// /// - Throws: An error if `self` is not `.ok`. @@ -5975,7 +6110,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -5984,18 +6119,24 @@ public enum Operations { } } } - public struct BadRequest: Sendable, Hashable { /// Creates a new `BadRequest`. public init() {} } - /// Missing sitemap parameter, or sitemap not linked successfully to the subscription. /// /// - Remark: Generated from `#/paths//sitemaps/events/{subscriptionid}/*/get(getSitemapEvents)/responses/400`. /// /// HTTP response code: `400 badRequest`. case badRequest(Operations.getSitemapEvents.Output.BadRequest) + /// Missing sitemap parameter, or sitemap not linked successfully to the subscription. + /// + /// - Remark: Generated from `#/paths//sitemaps/events/{subscriptionid}/*/get(getSitemapEvents)/responses/400`. + /// + /// HTTP response code: `400 badRequest`. + public static var badRequest: Self { + .badRequest(.init()) + } /// The associated value of the enum case if `self` is `.badRequest`. /// /// - Throws: An error if `self` is not `.badRequest`. @@ -6004,7 +6145,7 @@ public enum Operations { get throws { switch self { case let .badRequest(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "badRequest", @@ -6013,18 +6154,24 @@ public enum Operations { } } } - public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } - /// Subscription not found. /// /// - Remark: Generated from `#/paths//sitemaps/events/{subscriptionid}/*/get(getSitemapEvents)/responses/404`. /// /// HTTP response code: `404 notFound`. case notFound(Operations.getSitemapEvents.Output.NotFound) + /// Subscription not found. + /// + /// - Remark: Generated from `#/paths//sitemaps/events/{subscriptionid}/*/get(getSitemapEvents)/responses/404`. + /// + /// HTTP response code: `404 notFound`. + public static var notFound: Self { + .notFound(.init()) + } /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -6033,7 +6180,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -6042,14 +6189,12 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } } - /// Get sitemap events. /// /// - Remark: HTTP `GET /sitemaps/events/{subscriptionid}`. @@ -6071,7 +6216,6 @@ public enum Operations { self.subscriptionid = subscriptionid } } - public var path: Operations.getSitemapEvents_1.Input.Path /// - Remark: Generated from `#/paths/sitemaps/events/{subscriptionid}/GET/query`. public struct Query: Sendable, Hashable { @@ -6088,13 +6232,14 @@ public enum Operations { /// - Parameters: /// - sitemap: sitemap name /// - pageid: page id - public init(sitemap: Swift.String? = nil, - pageid: Swift.String? = nil) { + public init( + sitemap: Swift.String? = nil, + pageid: Swift.String? = nil + ) { self.sitemap = sitemap self.pageid = pageid } } - public var query: Operations.getSitemapEvents_1.Input.Query /// - Remark: Generated from `#/paths/sitemaps/events/{subscriptionid}/GET/header`. public struct Headers: Sendable, Hashable { @@ -6107,7 +6252,6 @@ public enum Operations { self.accept = accept } } - public var headers: Operations.getSitemapEvents_1.Input.Headers /// Creates a new `Input`. /// @@ -6115,15 +6259,16 @@ public enum Operations { /// - path: /// - query: /// - headers: - public init(path: Operations.getSitemapEvents_1.Input.Path, - query: Operations.getSitemapEvents_1.Input.Query = .init(), - headers: Operations.getSitemapEvents_1.Input.Headers = .init()) { + public init( + path: Operations.getSitemapEvents_1.Input.Path, + query: Operations.getSitemapEvents_1.Input.Query = .init(), + headers: Operations.getSitemapEvents_1.Input.Headers = .init() + ) { self.path = path self.query = query self.headers = headers } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/sitemaps/events/{subscriptionid}/GET/responses/200/content`. @@ -6138,7 +6283,7 @@ public enum Operations { get throws { switch self { case let .text_event_hyphen_stream(body): - body + return body default: try throwUnexpectedResponseBody( expectedContent: "text/event-stream", @@ -6147,7 +6292,6 @@ public enum Operations { } } } - /// - Remark: Generated from `#/paths/sitemaps/events/{subscriptionid}/GET/responses/200/content/application\/json`. case json(Components.Schemas.SitemapWidgetEvent) /// The associated value of the enum case if `self` is `.json`. @@ -6158,7 +6302,7 @@ public enum Operations { get throws { switch self { case let .json(body): - body + return body default: try throwUnexpectedResponseBody( expectedContent: "application/json", @@ -6168,7 +6312,6 @@ public enum Operations { } } } - /// Received HTTP response body public var body: Operations.getSitemapEvents_1.Output.Ok.Body /// Creates a new `Ok`. @@ -6179,7 +6322,6 @@ public enum Operations { self.body = body } } - /// OK /// /// - Remark: Generated from `#/paths//sitemaps/events/{subscriptionid}/get(getSitemapEvents_1)/responses/200`. @@ -6194,7 +6336,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -6203,18 +6345,24 @@ public enum Operations { } } } - public struct BadRequest: Sendable, Hashable { /// Creates a new `BadRequest`. public init() {} } - /// Missing sitemap or page parameter, or page not linked successfully to the subscription. /// /// - Remark: Generated from `#/paths//sitemaps/events/{subscriptionid}/get(getSitemapEvents_1)/responses/400`. /// /// HTTP response code: `400 badRequest`. case badRequest(Operations.getSitemapEvents_1.Output.BadRequest) + /// Missing sitemap or page parameter, or page not linked successfully to the subscription. + /// + /// - Remark: Generated from `#/paths//sitemaps/events/{subscriptionid}/get(getSitemapEvents_1)/responses/400`. + /// + /// HTTP response code: `400 badRequest`. + public static var badRequest: Self { + .badRequest(.init()) + } /// The associated value of the enum case if `self` is `.badRequest`. /// /// - Throws: An error if `self` is not `.badRequest`. @@ -6223,7 +6371,7 @@ public enum Operations { get throws { switch self { case let .badRequest(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "badRequest", @@ -6232,18 +6380,24 @@ public enum Operations { } } } - public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } - /// Subscription not found. /// /// - Remark: Generated from `#/paths//sitemaps/events/{subscriptionid}/get(getSitemapEvents_1)/responses/404`. /// /// HTTP response code: `404 notFound`. case notFound(Operations.getSitemapEvents_1.Output.NotFound) + /// Subscription not found. + /// + /// - Remark: Generated from `#/paths//sitemaps/events/{subscriptionid}/get(getSitemapEvents_1)/responses/404`. + /// + /// HTTP response code: `404 notFound`. + public static var notFound: Self { + .notFound(.init()) + } /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -6252,7 +6406,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -6261,13 +6415,11 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } - @frozen public enum AcceptableContentType: AcceptableProtocol { case text_event_hyphen_stream case json @@ -6282,18 +6434,16 @@ public enum Operations { self = .other(rawValue) } } - public var rawValue: Swift.String { switch self { case let .other(string): - string + return string case .text_event_hyphen_stream: - "text/event-stream" + return "text/event-stream" case .json: - "application/json" + return "application/json" } } - public static var allCases: [Self] { [ .text_event_hyphen_stream, @@ -6302,7 +6452,6 @@ public enum Operations { } } } - /// Get all available sitemaps. /// /// - Remark: HTTP `GET /sitemaps`. @@ -6321,7 +6470,6 @@ public enum Operations { self.accept = accept } } - public var headers: Operations.getSitemaps.Input.Headers /// Creates a new `Input`. /// @@ -6331,7 +6479,6 @@ public enum Operations { self.headers = headers } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/sitemaps/GET/responses/200/content`. @@ -6346,12 +6493,11 @@ public enum Operations { get throws { switch self { case let .json(body): - body + return body } } } } - /// Received HTTP response body public var body: Operations.getSitemaps.Output.Ok.Body /// Creates a new `Ok`. @@ -6362,7 +6508,6 @@ public enum Operations { self.body = body } } - /// OK /// /// - Remark: Generated from `#/paths//sitemaps/get(getSitemaps)/responses/200`. @@ -6377,7 +6522,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -6386,13 +6531,11 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } - @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -6404,16 +6547,14 @@ public enum Operations { self = .other(rawValue) } } - public var rawValue: Swift.String { switch self { case let .other(string): - string + return string case .json: - "application/json" + return "application/json" } } - public static var allCases: [Self] { [ .json @@ -6421,7 +6562,6 @@ public enum Operations { } } } - /// Get all registered UI components in the specified namespace. /// /// - Remark: HTTP `GET /ui/components/{namespace}`. @@ -6441,7 +6581,6 @@ public enum Operations { self.namespace = namespace } } - public var path: Operations.getRegisteredUIComponentsInNamespace.Input.Path /// - Remark: Generated from `#/paths/ui/components/{namespace}/GET/query`. public struct Query: Sendable, Hashable { @@ -6457,7 +6596,6 @@ public enum Operations { self.summary = summary } } - public var query: Operations.getRegisteredUIComponentsInNamespace.Input.Query /// - Remark: Generated from `#/paths/ui/components/{namespace}/GET/header`. public struct Headers: Sendable, Hashable { @@ -6470,7 +6608,6 @@ public enum Operations { self.accept = accept } } - public var headers: Operations.getRegisteredUIComponentsInNamespace.Input.Headers /// Creates a new `Input`. /// @@ -6478,15 +6615,16 @@ public enum Operations { /// - path: /// - query: /// - headers: - public init(path: Operations.getRegisteredUIComponentsInNamespace.Input.Path, - query: Operations.getRegisteredUIComponentsInNamespace.Input.Query = .init(), - headers: Operations.getRegisteredUIComponentsInNamespace.Input.Headers = .init()) { + public init( + path: Operations.getRegisteredUIComponentsInNamespace.Input.Path, + query: Operations.getRegisteredUIComponentsInNamespace.Input.Query = .init(), + headers: Operations.getRegisteredUIComponentsInNamespace.Input.Headers = .init() + ) { self.path = path self.query = query self.headers = headers } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/ui/components/{namespace}/GET/responses/200/content`. @@ -6501,12 +6639,11 @@ public enum Operations { get throws { switch self { case let .json(body): - body + return body } } } } - /// Received HTTP response body public var body: Operations.getRegisteredUIComponentsInNamespace.Output.Ok.Body /// Creates a new `Ok`. @@ -6517,7 +6654,6 @@ public enum Operations { self.body = body } } - /// OK /// /// - Remark: Generated from `#/paths//ui/components/{namespace}/get(getRegisteredUIComponentsInNamespace)/responses/200`. @@ -6532,7 +6668,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -6541,13 +6677,11 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } - @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -6559,16 +6693,14 @@ public enum Operations { self = .other(rawValue) } } - public var rawValue: Swift.String { switch self { case let .other(string): - string + return string case .json: - "application/json" + return "application/json" } } - public static var allCases: [Self] { [ .json @@ -6576,7 +6708,6 @@ public enum Operations { } } } - /// Add a UI component in the specified namespace. /// /// - Remark: HTTP `POST /ui/components/{namespace}`. @@ -6596,7 +6727,6 @@ public enum Operations { self.namespace = namespace } } - public var path: Operations.addUIComponentToNamespace.Input.Path /// - Remark: Generated from `#/paths/ui/components/{namespace}/POST/header`. public struct Headers: Sendable, Hashable { @@ -6609,14 +6739,12 @@ public enum Operations { self.accept = accept } } - public var headers: Operations.addUIComponentToNamespace.Input.Headers /// - Remark: Generated from `#/paths/ui/components/{namespace}/POST/requestBody`. @frozen public enum Body: Sendable, Hashable { /// - Remark: Generated from `#/paths/ui/components/{namespace}/POST/requestBody/content/application\/json`. case json(Components.Schemas.RootUIComponent) } - public var body: Operations.addUIComponentToNamespace.Input.Body? /// Creates a new `Input`. /// @@ -6624,15 +6752,16 @@ public enum Operations { /// - path: /// - headers: /// - body: - public init(path: Operations.addUIComponentToNamespace.Input.Path, - headers: Operations.addUIComponentToNamespace.Input.Headers = .init(), - body: Operations.addUIComponentToNamespace.Input.Body? = nil) { + public init( + path: Operations.addUIComponentToNamespace.Input.Path, + headers: Operations.addUIComponentToNamespace.Input.Headers = .init(), + body: Operations.addUIComponentToNamespace.Input.Body? = nil + ) { self.path = path self.headers = headers self.body = body } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/ui/components/{namespace}/POST/responses/200/content`. @@ -6647,12 +6776,11 @@ public enum Operations { get throws { switch self { case let .json(body): - body + return body } } } } - /// Received HTTP response body public var body: Operations.addUIComponentToNamespace.Output.Ok.Body /// Creates a new `Ok`. @@ -6663,7 +6791,6 @@ public enum Operations { self.body = body } } - /// OK /// /// - Remark: Generated from `#/paths//ui/components/{namespace}/post(addUIComponentToNamespace)/responses/200`. @@ -6678,7 +6805,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -6687,13 +6814,11 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } - @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -6705,16 +6830,14 @@ public enum Operations { self = .other(rawValue) } } - public var rawValue: Swift.String { switch self { case let .other(string): - string + return string case .json: - "application/json" + return "application/json" } } - public static var allCases: [Self] { [ .json @@ -6722,7 +6845,6 @@ public enum Operations { } } } - /// Get a specific UI component in the specified namespace. /// /// - Remark: HTTP `GET /ui/components/{namespace}/{componentUID}`. @@ -6741,13 +6863,14 @@ public enum Operations { /// - Parameters: /// - namespace: /// - componentUID: - public init(namespace: Swift.String, - componentUID: Swift.String) { + public init( + namespace: Swift.String, + componentUID: Swift.String + ) { self.namespace = namespace self.componentUID = componentUID } } - public var path: Operations.getUIComponentInNamespace.Input.Path /// - Remark: Generated from `#/paths/ui/components/{namespace}/{componentUID}/GET/header`. public struct Headers: Sendable, Hashable { @@ -6760,20 +6883,20 @@ public enum Operations { self.accept = accept } } - public var headers: Operations.getUIComponentInNamespace.Input.Headers /// Creates a new `Input`. /// /// - Parameters: /// - path: /// - headers: - public init(path: Operations.getUIComponentInNamespace.Input.Path, - headers: Operations.getUIComponentInNamespace.Input.Headers = .init()) { + public init( + path: Operations.getUIComponentInNamespace.Input.Path, + headers: Operations.getUIComponentInNamespace.Input.Headers = .init() + ) { self.path = path self.headers = headers } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/ui/components/{namespace}/{componentUID}/GET/responses/200/content`. @@ -6788,12 +6911,11 @@ public enum Operations { get throws { switch self { case let .json(body): - body + return body } } } } - /// Received HTTP response body public var body: Operations.getUIComponentInNamespace.Output.Ok.Body /// Creates a new `Ok`. @@ -6804,7 +6926,6 @@ public enum Operations { self.body = body } } - /// OK /// /// - Remark: Generated from `#/paths//ui/components/{namespace}/{componentUID}/get(getUIComponentInNamespace)/responses/200`. @@ -6819,7 +6940,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -6828,18 +6949,24 @@ public enum Operations { } } } - public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } - /// Component not found /// /// - Remark: Generated from `#/paths//ui/components/{namespace}/{componentUID}/get(getUIComponentInNamespace)/responses/404`. /// /// HTTP response code: `404 notFound`. case notFound(Operations.getUIComponentInNamespace.Output.NotFound) + /// Component not found + /// + /// - Remark: Generated from `#/paths//ui/components/{namespace}/{componentUID}/get(getUIComponentInNamespace)/responses/404`. + /// + /// HTTP response code: `404 notFound`. + public static var notFound: Self { + .notFound(.init()) + } /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -6848,7 +6975,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -6857,13 +6984,11 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } - @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -6875,16 +7000,14 @@ public enum Operations { self = .other(rawValue) } } - public var rawValue: Swift.String { switch self { case let .other(string): - string + return string case .json: - "application/json" + return "application/json" } } - public static var allCases: [Self] { [ .json @@ -6892,7 +7015,6 @@ public enum Operations { } } } - /// Update a specific UI component in the specified namespace. /// /// - Remark: HTTP `PUT /ui/components/{namespace}/{componentUID}`. @@ -6911,13 +7033,14 @@ public enum Operations { /// - Parameters: /// - namespace: /// - componentUID: - public init(namespace: Swift.String, - componentUID: Swift.String) { + public init( + namespace: Swift.String, + componentUID: Swift.String + ) { self.namespace = namespace self.componentUID = componentUID } } - public var path: Operations.updateUIComponentInNamespace.Input.Path /// - Remark: Generated from `#/paths/ui/components/{namespace}/{componentUID}/PUT/header`. public struct Headers: Sendable, Hashable { @@ -6930,14 +7053,12 @@ public enum Operations { self.accept = accept } } - public var headers: Operations.updateUIComponentInNamespace.Input.Headers /// - Remark: Generated from `#/paths/ui/components/{namespace}/{componentUID}/PUT/requestBody`. @frozen public enum Body: Sendable, Hashable { /// - Remark: Generated from `#/paths/ui/components/{namespace}/{componentUID}/PUT/requestBody/content/application\/json`. case json(Components.Schemas.RootUIComponent) } - public var body: Operations.updateUIComponentInNamespace.Input.Body? /// Creates a new `Input`. /// @@ -6945,15 +7066,16 @@ public enum Operations { /// - path: /// - headers: /// - body: - public init(path: Operations.updateUIComponentInNamespace.Input.Path, - headers: Operations.updateUIComponentInNamespace.Input.Headers = .init(), - body: Operations.updateUIComponentInNamespace.Input.Body? = nil) { + public init( + path: Operations.updateUIComponentInNamespace.Input.Path, + headers: Operations.updateUIComponentInNamespace.Input.Headers = .init(), + body: Operations.updateUIComponentInNamespace.Input.Body? = nil + ) { self.path = path self.headers = headers self.body = body } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/ui/components/{namespace}/{componentUID}/PUT/responses/200/content`. @@ -6968,12 +7090,11 @@ public enum Operations { get throws { switch self { case let .json(body): - body + return body } } } } - /// Received HTTP response body public var body: Operations.updateUIComponentInNamespace.Output.Ok.Body /// Creates a new `Ok`. @@ -6984,7 +7105,6 @@ public enum Operations { self.body = body } } - /// OK /// /// - Remark: Generated from `#/paths//ui/components/{namespace}/{componentUID}/put(updateUIComponentInNamespace)/responses/200`. @@ -6999,7 +7119,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -7008,18 +7128,24 @@ public enum Operations { } } } - public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } - /// Component not found /// /// - Remark: Generated from `#/paths//ui/components/{namespace}/{componentUID}/put(updateUIComponentInNamespace)/responses/404`. /// /// HTTP response code: `404 notFound`. case notFound(Operations.updateUIComponentInNamespace.Output.NotFound) + /// Component not found + /// + /// - Remark: Generated from `#/paths//ui/components/{namespace}/{componentUID}/put(updateUIComponentInNamespace)/responses/404`. + /// + /// HTTP response code: `404 notFound`. + public static var notFound: Self { + .notFound(.init()) + } /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -7028,7 +7154,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -7037,13 +7163,11 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } - @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -7055,16 +7179,14 @@ public enum Operations { self = .other(rawValue) } } - public var rawValue: Swift.String { switch self { case let .other(string): - string + return string case .json: - "application/json" + return "application/json" } } - public static var allCases: [Self] { [ .json @@ -7072,7 +7194,6 @@ public enum Operations { } } } - /// Remove a specific UI component in the specified namespace. /// /// - Remark: HTTP `DELETE /ui/components/{namespace}/{componentUID}`. @@ -7091,13 +7212,14 @@ public enum Operations { /// - Parameters: /// - namespace: /// - componentUID: - public init(namespace: Swift.String, - componentUID: Swift.String) { + public init( + namespace: Swift.String, + componentUID: Swift.String + ) { self.namespace = namespace self.componentUID = componentUID } } - public var path: Operations.removeUIComponentFromNamespace.Input.Path /// Creates a new `Input`. /// @@ -7107,19 +7229,25 @@ public enum Operations { self.path = path } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// Creates a new `Ok`. public init() {} } - /// OK /// /// - Remark: Generated from `#/paths//ui/components/{namespace}/{componentUID}/delete(removeUIComponentFromNamespace)/responses/200`. /// /// HTTP response code: `200 ok`. case ok(Operations.removeUIComponentFromNamespace.Output.Ok) + /// OK + /// + /// - Remark: Generated from `#/paths//ui/components/{namespace}/{componentUID}/delete(removeUIComponentFromNamespace)/responses/200`. + /// + /// HTTP response code: `200 ok`. + public static var ok: Self { + .ok(.init()) + } /// The associated value of the enum case if `self` is `.ok`. /// /// - Throws: An error if `self` is not `.ok`. @@ -7128,7 +7256,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -7137,18 +7265,24 @@ public enum Operations { } } } - public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } - /// Component not found /// /// - Remark: Generated from `#/paths//ui/components/{namespace}/{componentUID}/delete(removeUIComponentFromNamespace)/responses/404`. /// /// HTTP response code: `404 notFound`. case notFound(Operations.removeUIComponentFromNamespace.Output.NotFound) + /// Component not found + /// + /// - Remark: Generated from `#/paths//ui/components/{namespace}/{componentUID}/delete(removeUIComponentFromNamespace)/responses/404`. + /// + /// HTTP response code: `404 notFound`. + public static var notFound: Self { + .notFound(.init()) + } /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -7157,7 +7291,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -7166,14 +7300,12 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } } - /// Get all registered UI tiles. /// /// - Remark: HTTP `GET /ui/tiles`. @@ -7192,7 +7324,6 @@ public enum Operations { self.accept = accept } } - public var headers: Operations.getUITiles.Input.Headers /// Creates a new `Input`. /// @@ -7202,7 +7333,6 @@ public enum Operations { self.headers = headers } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/ui/tiles/GET/responses/200/content`. @@ -7217,12 +7347,11 @@ public enum Operations { get throws { switch self { case let .json(body): - body + return body } } } } - /// Received HTTP response body public var body: Operations.getUITiles.Output.Ok.Body /// Creates a new `Ok`. @@ -7233,7 +7362,6 @@ public enum Operations { self.body = body } } - /// OK /// /// - Remark: Generated from `#/paths//ui/tiles/get(getUITiles)/responses/200`. @@ -7248,7 +7376,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -7257,13 +7385,11 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } - @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -7275,16 +7401,14 @@ public enum Operations { self = .other(rawValue) } } - public var rawValue: Swift.String { switch self { case let .other(string): - string + return string case .json: - "application/json" + return "application/json" } } - public static var allCases: [Self] { [ .json diff --git a/openHAB.xcworkspace/xcshareddata/swiftpm/Package.resolved b/openHAB.xcworkspace/xcshareddata/swiftpm/Package.resolved index de13d5644..27d1be534 100644 --- a/openHAB.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/openHAB.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "a0e070693d4bd70bdbabd8b73137d27ca8a0eee3a6c62258b0bf76ccac438c62", + "originHash" : "2ad7ac9694caf3d8c6de68292ac2e209c51ffb7db35b0cf43f6c36eaa4ab31c1", "pins" : [ { "identity" : "abseil-cpp-binary", @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/Alamofire/Alamofire.git", "state" : { - "revision" : "f455c2975872ccd2d9c81594c658af65716e9b9a", - "version" : "5.9.1" + "revision" : "513364f870f6bfc468f9d2ff0a95caccc10044c5", + "version" : "5.10.2" } }, { @@ -132,8 +132,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/onevcat/Kingfisher.git", "state" : { - "revision" : "5b92f029fab2cce44386d28588098b5be0824ef5", - "version" : "7.11.0" + "revision" : "2ef543ee21d63734e1c004ad6c870255e8716c50", + "version" : "7.12.0" } }, { @@ -168,8 +168,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/SDWebImage/SDWebImage.git", "state" : { - "revision" : "8a1be70a625683bc04d6903e2935bf23f3c6d609", - "version" : "5.19.7" + "revision" : "e7d3256c497af9330a0df866ee38f544ddd87c49", + "version" : "5.20.1" } }, { @@ -177,8 +177,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/SDWebImage/SDWebImageSVGCoder.git", "state" : { - "revision" : "950167445ab703740569869c8b7510efc9d09a26", - "version" : "1.7.0" + "revision" : "85b5d58ad02c207c496fa34426dc6560d6ae32f0", + "version" : "1.8.0" } }, { @@ -186,8 +186,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/SDWebImage/SDWebImageSwiftUI.git", "state" : { - "revision" : "5aa947356f4ea49a0c3b9968564267f6ea5abea7", - "version" : "3.1.2" + "revision" : "451c6dfd5ecec2cf626d1d9ca81c2d4a60355172", + "version" : "3.1.3" } }, { @@ -214,7 +214,7 @@ "location" : "https://github.com/SVGKit/SVGKit.git", "state" : { "branch" : "3.x", - "revision" : "6f75e96948fa72fa5ebc5d1f0bfa761a5614c546" + "revision" : "026d2168d07b621e4701a8b2aac6c1eaf05c1df5" } }, { @@ -222,8 +222,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections", "state" : { - "revision" : "3d2dc41a01f9e49d84f0a3925fb858bed64f702d", - "version" : "1.1.2" + "revision" : "671108c96644956dddcd89dd59c203dcdb36cec7", + "version" : "1.1.4" } }, { @@ -231,8 +231,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-http-types", "state" : { - "revision" : "ae67c8178eb46944fd85e4dc6dd970e1f3ed6ccd", - "version" : "1.3.0" + "revision" : "ef18d829e8b92d731ad27bb81583edd2094d1ce3", + "version" : "1.3.1" } }, { @@ -240,8 +240,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-log", "state" : { - "revision" : "9cb486020ebf03bfa5b5df985387a14a98744537", - "version" : "1.6.1" + "revision" : "96a2f8a0fa41e9e09af4585e2724c4e825410b91", + "version" : "1.6.2" } }, { @@ -249,8 +249,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-openapi-runtime", "state" : { - "revision" : "26e8ae3515d1ff3607e924ac96fc0094775f55e8", - "version" : "1.5.0" + "revision" : "23146bc8710ac5e57abb693113f02dc274cf39b6", + "version" : "1.8.0" } }, { @@ -267,8 +267,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-protobuf.git", "state" : { - "revision" : "edb6ed4919f7756157fe02f2552b7e3850a538e5", - "version" : "1.28.1" + "revision" : "ebc7251dd5b37f627c93698e4374084d98409633", + "version" : "1.28.2" } }, { diff --git a/openHAB/DrawerView.swift b/openHAB/DrawerView.swift index ec396e35c..c2759f5c7 100644 --- a/openHAB/DrawerView.swift +++ b/openHAB/DrawerView.swift @@ -16,24 +16,16 @@ import SafariServices import SFSafeSymbols import SwiftUI -// func deriveSitemaps(_ response: Data?) -> [OpenHABSitemap] { -// var sitemaps = [OpenHABSitemap]() -// -// if let response { -// do { -// os_log("Response will be decoded by JSON", log: .remoteAccess, type: .info) -// let sitemapsCodingData = try response.decoded(as: [OpenHABSitemap.CodingData].self) -// for sitemapCodingDatum in sitemapsCodingData { -// os_log("Sitemap %{PUBLIC}@", log: .remoteAccess, type: .info, sitemapCodingDatum.label) -// sitemaps.append(sitemapCodingDatum.openHABSitemap) -// } -// } catch { -// os_log("Should not throw %{PUBLIC}@", log: .notifications, type: .error, error.localizedDescription) -// } -// } -// -// return sitemaps -// } +enum DrawerViewError: Error, CustomDebugStringConvertible { + case noRootURL + + var debugDescription: String { + switch self { + case .noRootURL: + "No root URL" + } + } +} struct ImageView: View { let url: String @@ -275,9 +267,9 @@ struct DrawerView: View { let apiactor = await APIActor() Task { do { - guard let url = URL(string: appData?.openHABRootUrl ?? "") else { throw NSError(domain: "", code: 0, userInfo: nil) } + guard let url = URL(string: appData?.openHABRootUrl ?? "") else { throw DrawerViewError.noRootURL } await apiactor.updateBaseURL(with: url) - + sitemaps = try await apiactor.openHABSitemaps() if sitemaps.last?.name == "_default", sitemaps.count > 1 { sitemaps = Array(sitemaps.dropLast()) @@ -296,9 +288,9 @@ struct DrawerView: View { Task { do { - guard let url = URL(string: appData?.openHABRootUrl ?? "") else { throw NSError(domain: "", code: 0, userInfo: nil) } + guard let url = URL(string: appData?.openHABRootUrl ?? "") else { throw DrawerViewError.noRootURL } await apiactor.updateBaseURL(with: url) - + uiTiles = try await apiactor.openHABTiles() os_log("ui tiles response", log: .viewCycle, type: .info) } catch { diff --git a/openHAB/OpenHABRootViewController.swift b/openHAB/OpenHABRootViewController.swift index 209c1ae35..02f979277 100644 --- a/openHAB/OpenHABRootViewController.swift +++ b/openHAB/OpenHABRootViewController.swift @@ -9,8 +9,6 @@ // // SPDX-License-Identifier: EPL-2.0 -// swiftlint:disable body_length - import Combine import FirebaseCrashlytics import Foundation From dc18495f25a3bbb6f3224f35c7553508d490a911 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Mon, 24 Feb 2025 12:35:34 +0100 Subject: [PATCH 015/476] Regenerated Client and Types with openapi-generato for tags root and systeminfo Prepared OpenHABServerProperties for decoding via OpenAPI: Components.Schemas.RootBean, also added new OpenHABLink class Renamed APIActor in OpenAPIService Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../GeneratedSources/openapi/Client.swift | 379 ++- .../GeneratedSources/openapi/Types.swift | 2764 +++++++++++------ .../OpenHABCore/Model/OpenHABLink.swift | 32 + .../Model/OpenHABServerProperties.swift | 21 +- .../Sources/OpenHABCore/Util/HTTPClient.swift | 21 +- .../{APIActor.swift => OpenAPIService.swift} | 76 +- .../openapi/openapi-generator-config.yml | 2 + openHAB/DrawerView.swift | 10 +- openHAB/OpenHABSitemapViewController.swift | 8 +- 9 files changed, 2315 insertions(+), 998 deletions(-) create mode 100644 OpenHABCore/Sources/OpenHABCore/Model/OpenHABLink.swift rename OpenHABCore/Sources/OpenHABCore/Util/{APIActor.swift => OpenAPIService.swift} (79%) diff --git a/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Client.swift b/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Client.swift index 90d4582c7..26930d303 100644 --- a/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Client.swift +++ b/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Client.swift @@ -1,15 +1,27 @@ +// Copyright (c) 2010-2025 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 + // Generated by swift-openapi-generator, do not modify. @_spi(Generated) import OpenAPIRuntime #if os(Linux) -@preconcurrency import struct Foundation.URL @preconcurrency import struct Foundation.Data @preconcurrency import struct Foundation.Date +@preconcurrency import struct Foundation.URL #else -import struct Foundation.URL import struct Foundation.Data import struct Foundation.Date +import struct Foundation.URL #endif import HTTPTypes + public struct Client: APIProtocol { /// The underlying HTTP client. private let client: UniversalClient @@ -21,22 +33,22 @@ public struct Client: APIProtocol { /// - configuration: A set of configuration values for the client. /// - transport: A transport that performs HTTP operations. /// - middlewares: A list of middlewares to call before the transport. - public init( - serverURL: Foundation.URL, - configuration: Configuration = .init(), - transport: any ClientTransport, - middlewares: [any ClientMiddleware] = [] - ) { - self.client = .init( + public init(serverURL: Foundation.URL, + configuration: Configuration = .init(), + transport: any ClientTransport, + middlewares: [any ClientMiddleware] = []) { + client = .init( serverURL: serverURL, configuration: configuration, transport: transport, middlewares: middlewares ) } + private var converter: Converter { client.converter } + /// Adds a new member to a group item. /// /// - Remark: HTTP `PUT /items/{itemName}/members/{memberItemName}`. @@ -63,13 +75,13 @@ public struct Client: APIProtocol { deserializer: { response, responseBody in switch response.status.code { case 200: - return .ok(.init()) + .ok(.init()) case 404: - return .notFound(.init()) + .notFound(.init()) case 405: - return .methodNotAllowed(.init()) + .methodNotAllowed(.init()) default: - return .undocumented( + .undocumented( statusCode: response.status.code, .init( headerFields: response.headerFields, @@ -80,6 +92,7 @@ public struct Client: APIProtocol { } ) } + /// Removes an existing member from a group item. /// /// - Remark: HTTP `DELETE /items/{itemName}/members/{memberItemName}`. @@ -106,13 +119,13 @@ public struct Client: APIProtocol { deserializer: { response, responseBody in switch response.status.code { case 200: - return .ok(.init()) + .ok(.init()) case 404: - return .notFound(.init()) + .notFound(.init()) case 405: - return .methodNotAllowed(.init()) + .methodNotAllowed(.init()) default: - return .undocumented( + .undocumented( statusCode: response.status.code, .init( headerFields: response.headerFields, @@ -123,6 +136,7 @@ public struct Client: APIProtocol { } ) } + /// Adds metadata to an item. /// /// - Remark: HTTP `PUT /items/{itemname}/metadata/{namespace}`. @@ -144,10 +158,9 @@ public struct Client: APIProtocol { method: .put ) suppressMutabilityWarning(&request) - let body: OpenAPIRuntime.HTTPBody? - switch input.body { + let body: OpenAPIRuntime.HTTPBody? = switch input.body { case let .json(value): - body = try converter.setRequiredRequestBodyAsJSON( + try converter.setRequiredRequestBodyAsJSON( value, headerFields: &request.headerFields, contentType: "application/json; charset=utf-8" @@ -158,17 +171,17 @@ public struct Client: APIProtocol { deserializer: { response, responseBody in switch response.status.code { case 200: - return .ok(.init()) + .ok(.init()) case 201: - return .created(.init()) + .created(.init()) case 400: - return .badRequest(.init()) + .badRequest(.init()) case 404: - return .notFound(.init()) + .notFound(.init()) case 405: - return .methodNotAllowed(.init()) + .methodNotAllowed(.init()) default: - return .undocumented( + .undocumented( statusCode: response.status.code, .init( headerFields: response.headerFields, @@ -179,6 +192,7 @@ public struct Client: APIProtocol { } ) } + /// Removes metadata from an item. /// /// - Remark: HTTP `DELETE /items/{itemname}/metadata/{namespace}`. @@ -205,13 +219,13 @@ public struct Client: APIProtocol { deserializer: { response, responseBody in switch response.status.code { case 200: - return .ok(.init()) + .ok(.init()) case 404: - return .notFound(.init()) + .notFound(.init()) case 405: - return .methodNotAllowed(.init()) + .methodNotAllowed(.init()) default: - return .undocumented( + .undocumented( statusCode: response.status.code, .init( headerFields: response.headerFields, @@ -222,6 +236,7 @@ public struct Client: APIProtocol { } ) } + /// Adds a tag to an item. /// /// - Remark: HTTP `PUT /items/{itemname}/tags/{tag}`. @@ -248,13 +263,13 @@ public struct Client: APIProtocol { deserializer: { response, responseBody in switch response.status.code { case 200: - return .ok(.init()) + .ok(.init()) case 404: - return .notFound(.init()) + .notFound(.init()) case 405: - return .methodNotAllowed(.init()) + .methodNotAllowed(.init()) default: - return .undocumented( + .undocumented( statusCode: response.status.code, .init( headerFields: response.headerFields, @@ -265,6 +280,7 @@ public struct Client: APIProtocol { } ) } + /// Removes a tag from an item. /// /// - Remark: HTTP `DELETE /items/{itemname}/tags/{tag}`. @@ -291,13 +307,13 @@ public struct Client: APIProtocol { deserializer: { response, responseBody in switch response.status.code { case 200: - return .ok(.init()) + .ok(.init()) case 404: - return .notFound(.init()) + .notFound(.init()) case 405: - return .methodNotAllowed(.init()) + .methodNotAllowed(.init()) default: - return .undocumented( + .undocumented( statusCode: response.status.code, .init( headerFields: response.headerFields, @@ -308,6 +324,7 @@ public struct Client: APIProtocol { } ) } + /// Gets a single item. /// /// - Remark: HTTP `GET /items/{itemname}`. @@ -391,6 +408,7 @@ public struct Client: APIProtocol { } ) } + /// Sends a command to an item. /// /// - Remark: HTTP `POST /items/{itemname}`. @@ -411,10 +429,9 @@ public struct Client: APIProtocol { method: .post ) suppressMutabilityWarning(&request) - let body: OpenAPIRuntime.HTTPBody? - switch input.body { + let body: OpenAPIRuntime.HTTPBody? = switch input.body { case let .plainText(value): - body = try converter.setRequiredRequestBodyAsBinary( + try converter.setRequiredRequestBodyAsBinary( value, headerFields: &request.headerFields, contentType: "text/plain" @@ -425,13 +442,13 @@ public struct Client: APIProtocol { deserializer: { response, responseBody in switch response.status.code { case 200: - return .ok(.init()) + .ok(.init()) case 400: - return .badRequest(.init()) + .badRequest(.init()) case 404: - return .notFound(.init()) + .notFound(.init()) default: - return .undocumented( + .undocumented( statusCode: response.status.code, .init( headerFields: response.headerFields, @@ -442,6 +459,7 @@ public struct Client: APIProtocol { } ) } + /// Adds a new item to the registry or updates the existing item. /// /// - Remark: HTTP `PUT /items/{itemname}`. @@ -471,10 +489,9 @@ public struct Client: APIProtocol { in: &request.headerFields, contentTypes: input.headers.accept ) - let body: OpenAPIRuntime.HTTPBody? - switch input.body { + let body: OpenAPIRuntime.HTTPBody? = switch input.body { case let .json(value): - body = try converter.setRequiredRequestBodyAsJSON( + try converter.setRequiredRequestBodyAsJSON( value, headerFields: &request.headerFields, contentType: "application/json; charset=utf-8" @@ -526,6 +543,7 @@ public struct Client: APIProtocol { } ) } + /// Removes an item from the registry. /// /// - Remark: HTTP `DELETE /items/{itemname}`. @@ -551,11 +569,11 @@ public struct Client: APIProtocol { deserializer: { response, responseBody in switch response.status.code { case 200: - return .ok(.init()) + .ok(.init()) case 404: - return .notFound(.init()) + .notFound(.init()) default: - return .undocumented( + .undocumented( statusCode: response.status.code, .init( headerFields: response.headerFields, @@ -566,6 +584,7 @@ public struct Client: APIProtocol { } ) } + /// Get all available items. /// /// - Remark: HTTP `GET /items`. @@ -673,6 +692,7 @@ public struct Client: APIProtocol { } ) } + /// Adds a list of items to the registry or updates the existing items. /// /// - Remark: HTTP `PUT /items`. @@ -695,10 +715,9 @@ public struct Client: APIProtocol { in: &request.headerFields, contentTypes: input.headers.accept ) - let body: OpenAPIRuntime.HTTPBody? - switch input.body { + let body: OpenAPIRuntime.HTTPBody? = switch input.body { case let .json(value): - body = try converter.setRequiredRequestBodyAsJSON( + try converter.setRequiredRequestBodyAsJSON( value, headerFields: &request.headerFields, contentType: "application/json; charset=utf-8" @@ -744,6 +763,7 @@ public struct Client: APIProtocol { } ) } + /// Gets the state of an item. /// /// - Remark: HTTP `GET /items/{itemname}/state`. @@ -808,6 +828,7 @@ public struct Client: APIProtocol { } ) } + /// Updates the state of an item. /// /// - Remark: HTTP `PUT /items/{itemname}/state`. @@ -833,10 +854,9 @@ public struct Client: APIProtocol { name: "Accept-Language", value: input.headers.Accept_hyphen_Language ) - let body: OpenAPIRuntime.HTTPBody? - switch input.body { + let body: OpenAPIRuntime.HTTPBody? = switch input.body { case let .plainText(value): - body = try converter.setRequiredRequestBodyAsBinary( + try converter.setRequiredRequestBodyAsBinary( value, headerFields: &request.headerFields, contentType: "text/plain" @@ -847,13 +867,13 @@ public struct Client: APIProtocol { deserializer: { response, responseBody in switch response.status.code { case 202: - return .accepted(.init()) + .accepted(.init()) case 400: - return .badRequest(.init()) + .badRequest(.init()) case 404: - return .notFound(.init()) + .notFound(.init()) default: - return .undocumented( + .undocumented( statusCode: response.status.code, .init( headerFields: response.headerFields, @@ -864,6 +884,7 @@ public struct Client: APIProtocol { } ) } + /// Gets the namespace of an item. /// /// - Remark: HTTP `GET /items/{itemname}/metadata/namespaces`. @@ -933,6 +954,7 @@ public struct Client: APIProtocol { } ) } + /// Gets the item which defines the requested semantics of an item. /// /// - Remark: HTTP `GET /items/{itemName}/semantic/{semanticClass}`. @@ -964,11 +986,11 @@ public struct Client: APIProtocol { deserializer: { response, responseBody in switch response.status.code { case 200: - return .ok(.init()) + .ok(.init()) case 404: - return .notFound(.init()) + .notFound(.init()) default: - return .undocumented( + .undocumented( statusCode: response.status.code, .init( headerFields: response.headerFields, @@ -979,6 +1001,7 @@ public struct Client: APIProtocol { } ) } + /// Remove unused/orphaned metadata. /// /// - Remark: HTTP `POST /items/metadata/purge`. @@ -987,7 +1010,7 @@ public struct Client: APIProtocol { try await client.send( input: input, forOperation: Operations.purgeDatabase.id, - serializer: { input in + serializer: { _ in let path = try converter.renderedPath( template: "/items/metadata/purge", parameters: [] @@ -1002,7 +1025,68 @@ public struct Client: APIProtocol { deserializer: { response, responseBody in switch response.status.code { case 200: - return .ok(.init()) + .ok(.init()) + default: + .undocumented( + statusCode: response.status.code, + .init( + headerFields: response.headerFields, + body: responseBody + ) + ) + } + } + ) + } + + /// Gets information about the runtime, the API version and links to resources. + /// + /// - Remark: HTTP `GET //`. + /// - Remark: Generated from `#/paths////get(getRoot)`. + public func getRoot(_ input: Operations.getRoot.Input) async throws -> Operations.getRoot.Output { + try await client.send( + input: input, + forOperation: Operations.getRoot.id, + serializer: { input in + let path = try converter.renderedPath( + template: "//", + parameters: [] + ) + var request: HTTPTypes.HTTPRequest = .init( + soar_path: path, + method: .get + ) + suppressMutabilityWarning(&request) + converter.setAcceptHeader( + in: &request.headerFields, + contentTypes: input.headers.accept + ) + return (request, nil) + }, + deserializer: { response, responseBody in + switch response.status.code { + case 200: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Operations.getRoot.Output.Ok.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Components.Schemas.RootBean.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .ok(.init(body: body)) default: return .undocumented( statusCode: response.status.code, @@ -1015,6 +1099,129 @@ public struct Client: APIProtocol { } ) } + + /// Gets information about the system. + /// + /// - Remark: HTTP `GET /systeminfo`. + /// - Remark: Generated from `#/paths//systeminfo/get(getSystemInformation)`. + public func getSystemInformation(_ input: Operations.getSystemInformation.Input) async throws -> Operations.getSystemInformation.Output { + try await client.send( + input: input, + forOperation: Operations.getSystemInformation.id, + serializer: { input in + let path = try converter.renderedPath( + template: "/systeminfo", + parameters: [] + ) + var request: HTTPTypes.HTTPRequest = .init( + soar_path: path, + method: .get + ) + suppressMutabilityWarning(&request) + converter.setAcceptHeader( + in: &request.headerFields, + contentTypes: input.headers.accept + ) + return (request, nil) + }, + deserializer: { response, responseBody in + switch response.status.code { + case 200: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Operations.getSystemInformation.Output.Ok.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Components.Schemas.SystemInfoBean.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .ok(.init(body: body)) + default: + return .undocumented( + statusCode: response.status.code, + .init( + headerFields: response.headerFields, + body: responseBody + ) + ) + } + } + ) + } + + /// Get all supported dimensions and their system units. + /// + /// - Remark: HTTP `GET /systeminfo/uom`. + /// - Remark: Generated from `#/paths//systeminfo/uom/get(getUoMInformation)`. + public func getUoMInformation(_ input: Operations.getUoMInformation.Input) async throws -> Operations.getUoMInformation.Output { + try await client.send( + input: input, + forOperation: Operations.getUoMInformation.id, + serializer: { input in + let path = try converter.renderedPath( + template: "/systeminfo/uom", + parameters: [] + ) + var request: HTTPTypes.HTTPRequest = .init( + soar_path: path, + method: .get + ) + suppressMutabilityWarning(&request) + converter.setAcceptHeader( + in: &request.headerFields, + contentTypes: input.headers.accept + ) + return (request, nil) + }, + deserializer: { response, responseBody in + switch response.status.code { + case 200: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Operations.getUoMInformation.Output.Ok.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Components.Schemas.UoMInfoBean.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .ok(.init(body: body)) + default: + return .undocumented( + statusCode: response.status.code, + .init( + headerFields: response.headerFields, + body: responseBody + ) + ) + } + } + ) + } + /// Creates a sitemap event subscription. /// /// - Remark: HTTP `POST /sitemaps/events/subscribe`. @@ -1079,6 +1286,7 @@ public struct Client: APIProtocol { } ) } + /// Polls the data for one page of a sitemap. /// /// - Remark: HTTP `GET /sitemaps/{sitemapname}/{pageid}`. @@ -1170,6 +1378,7 @@ public struct Client: APIProtocol { } ) } + /// Polls the data for a whole sitemap. Not recommended due to potentially high traffic. /// /// - Remark: HTTP `GET /sitemaps/{sitemapname}/*`. @@ -1260,6 +1469,7 @@ public struct Client: APIProtocol { } ) } + /// Get sitemap by name. /// /// - Remark: HTTP `GET /sitemaps/{sitemapname}`. @@ -1348,6 +1558,7 @@ public struct Client: APIProtocol { } ) } + /// Get sitemap events for a whole sitemap. Not recommended due to potentially high traffic. /// /// - Remark: HTTP `GET /sitemaps/events/{subscriptionid}/*`. @@ -1380,13 +1591,13 @@ public struct Client: APIProtocol { deserializer: { response, responseBody in switch response.status.code { case 200: - return .ok(.init()) + .ok(.init()) case 400: - return .badRequest(.init()) + .badRequest(.init()) case 404: - return .notFound(.init()) + .notFound(.init()) default: - return .undocumented( + .undocumented( statusCode: response.status.code, .init( headerFields: response.headerFields, @@ -1397,6 +1608,7 @@ public struct Client: APIProtocol { } ) } + /// Get sitemap events. /// /// - Remark: HTTP `GET /sitemaps/events/{subscriptionid}`. @@ -1486,6 +1698,7 @@ public struct Client: APIProtocol { } ) } + /// Get all available sitemaps. /// /// - Remark: HTTP `GET /sitemaps`. @@ -1546,6 +1759,7 @@ public struct Client: APIProtocol { } ) } + /// Get all registered UI components in the specified namespace. /// /// - Remark: HTTP `GET /ui/components/{namespace}`. @@ -1615,6 +1829,7 @@ public struct Client: APIProtocol { } ) } + /// Add a UI component in the specified namespace. /// /// - Remark: HTTP `POST /ui/components/{namespace}`. @@ -1639,12 +1854,11 @@ public struct Client: APIProtocol { in: &request.headerFields, contentTypes: input.headers.accept ) - let body: OpenAPIRuntime.HTTPBody? - switch input.body { + let body: OpenAPIRuntime.HTTPBody? = switch input.body { case .none: - body = nil + nil case let .json(value): - body = try converter.setOptionalRequestBodyAsJSON( + try converter.setOptionalRequestBodyAsJSON( value, headerFields: &request.headerFields, contentType: "application/json; charset=utf-8" @@ -1688,6 +1902,7 @@ public struct Client: APIProtocol { } ) } + /// Get a specific UI component in the specified namespace. /// /// - Remark: HTTP `GET /ui/components/{namespace}/{componentUID}`. @@ -1753,6 +1968,7 @@ public struct Client: APIProtocol { } ) } + /// Update a specific UI component in the specified namespace. /// /// - Remark: HTTP `PUT /ui/components/{namespace}/{componentUID}`. @@ -1778,12 +1994,11 @@ public struct Client: APIProtocol { in: &request.headerFields, contentTypes: input.headers.accept ) - let body: OpenAPIRuntime.HTTPBody? - switch input.body { + let body: OpenAPIRuntime.HTTPBody? = switch input.body { case .none: - body = nil + nil case let .json(value): - body = try converter.setOptionalRequestBodyAsJSON( + try converter.setOptionalRequestBodyAsJSON( value, headerFields: &request.headerFields, contentType: "application/json; charset=utf-8" @@ -1829,6 +2044,7 @@ public struct Client: APIProtocol { } ) } + /// Remove a specific UI component in the specified namespace. /// /// - Remark: HTTP `DELETE /ui/components/{namespace}/{componentUID}`. @@ -1855,11 +2071,11 @@ public struct Client: APIProtocol { deserializer: { response, responseBody in switch response.status.code { case 200: - return .ok(.init()) + .ok(.init()) case 404: - return .notFound(.init()) + .notFound(.init()) default: - return .undocumented( + .undocumented( statusCode: response.status.code, .init( headerFields: response.headerFields, @@ -1870,6 +2086,7 @@ public struct Client: APIProtocol { } ) } + /// Get all registered UI tiles. /// /// - Remark: HTTP `GET /ui/tiles`. diff --git a/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Types.swift b/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Types.swift index 59399c13f..3111cba25 100644 --- a/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Types.swift +++ b/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Types.swift @@ -1,13 +1,24 @@ +// Copyright (c) 2010-2025 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 + // Generated by swift-openapi-generator, do not modify. @_spi(Generated) import OpenAPIRuntime #if os(Linux) -@preconcurrency import struct Foundation.URL @preconcurrency import struct Foundation.Data @preconcurrency import struct Foundation.Date +@preconcurrency import struct Foundation.URL #else -import struct Foundation.URL import struct Foundation.Data import struct Foundation.Date +import struct Foundation.URL #endif /// A type that performs HTTP operations defined by the OpenAPI document. public protocol APIProtocol: Sendable { @@ -96,6 +107,21 @@ public protocol APIProtocol: Sendable { /// - Remark: HTTP `POST /items/metadata/purge`. /// - Remark: Generated from `#/paths//items/metadata/purge/post(purgeDatabase)`. func purgeDatabase(_ input: Operations.purgeDatabase.Input) async throws -> Operations.purgeDatabase.Output + /// Gets information about the runtime, the API version and links to resources. + /// + /// - Remark: HTTP `GET //`. + /// - Remark: Generated from `#/paths////get(getRoot)`. + func getRoot(_ input: Operations.getRoot.Input) async throws -> Operations.getRoot.Output + /// Gets information about the system. + /// + /// - Remark: HTTP `GET /systeminfo`. + /// - Remark: Generated from `#/paths//systeminfo/get(getSystemInformation)`. + func getSystemInformation(_ input: Operations.getSystemInformation.Input) async throws -> Operations.getSystemInformation.Output + /// Get all supported dimensions and their system units. + /// + /// - Remark: HTTP `GET /systeminfo/uom`. + /// - Remark: Generated from `#/paths//systeminfo/uom/get(getUoMInformation)`. + func getUoMInformation(_ input: Operations.getUoMInformation.Input) async throws -> Operations.getUoMInformation.Output /// Creates a sitemap event subscription. /// /// - Remark: HTTP `POST /sitemaps/events/subscribe`. @@ -164,349 +190,364 @@ public protocol APIProtocol: Sendable { } /// Convenience overloads for operation inputs. -extension APIProtocol { +public extension APIProtocol { /// Adds a new member to a group item. /// /// - Remark: HTTP `PUT /items/{itemName}/members/{memberItemName}`. /// - Remark: Generated from `#/paths//items/{itemName}/members/{memberItemName}/put(addMemberToGroupItem)`. - public func addMemberToGroupItem(path: Operations.addMemberToGroupItem.Input.Path) async throws -> Operations.addMemberToGroupItem.Output { + func addMemberToGroupItem(path: Operations.addMemberToGroupItem.Input.Path) async throws -> Operations.addMemberToGroupItem.Output { try await addMemberToGroupItem(Operations.addMemberToGroupItem.Input(path: path)) } + /// Removes an existing member from a group item. /// /// - Remark: HTTP `DELETE /items/{itemName}/members/{memberItemName}`. /// - Remark: Generated from `#/paths//items/{itemName}/members/{memberItemName}/delete(removeMemberFromGroupItem)`. - public func removeMemberFromGroupItem(path: Operations.removeMemberFromGroupItem.Input.Path) async throws -> Operations.removeMemberFromGroupItem.Output { + func removeMemberFromGroupItem(path: Operations.removeMemberFromGroupItem.Input.Path) async throws -> Operations.removeMemberFromGroupItem.Output { try await removeMemberFromGroupItem(Operations.removeMemberFromGroupItem.Input(path: path)) } + /// Adds metadata to an item. /// /// - Remark: HTTP `PUT /items/{itemname}/metadata/{namespace}`. /// - Remark: Generated from `#/paths//items/{itemname}/metadata/{namespace}/put(addMetadataToItem)`. - public func addMetadataToItem( - path: Operations.addMetadataToItem.Input.Path, - body: Operations.addMetadataToItem.Input.Body - ) async throws -> Operations.addMetadataToItem.Output { + func addMetadataToItem(path: Operations.addMetadataToItem.Input.Path, + body: Operations.addMetadataToItem.Input.Body) async throws -> Operations.addMetadataToItem.Output { try await addMetadataToItem(Operations.addMetadataToItem.Input( path: path, body: body )) } + /// Removes metadata from an item. /// /// - Remark: HTTP `DELETE /items/{itemname}/metadata/{namespace}`. /// - Remark: Generated from `#/paths//items/{itemname}/metadata/{namespace}/delete(removeMetadataFromItem)`. - public func removeMetadataFromItem(path: Operations.removeMetadataFromItem.Input.Path) async throws -> Operations.removeMetadataFromItem.Output { + func removeMetadataFromItem(path: Operations.removeMetadataFromItem.Input.Path) async throws -> Operations.removeMetadataFromItem.Output { try await removeMetadataFromItem(Operations.removeMetadataFromItem.Input(path: path)) } + /// Adds a tag to an item. /// /// - Remark: HTTP `PUT /items/{itemname}/tags/{tag}`. /// - Remark: Generated from `#/paths//items/{itemname}/tags/{tag}/put(addTagToItem)`. - public func addTagToItem(path: Operations.addTagToItem.Input.Path) async throws -> Operations.addTagToItem.Output { + func addTagToItem(path: Operations.addTagToItem.Input.Path) async throws -> Operations.addTagToItem.Output { try await addTagToItem(Operations.addTagToItem.Input(path: path)) } + /// Removes a tag from an item. /// /// - Remark: HTTP `DELETE /items/{itemname}/tags/{tag}`. /// - Remark: Generated from `#/paths//items/{itemname}/tags/{tag}/delete(removeTagFromItem)`. - public func removeTagFromItem(path: Operations.removeTagFromItem.Input.Path) async throws -> Operations.removeTagFromItem.Output { + func removeTagFromItem(path: Operations.removeTagFromItem.Input.Path) async throws -> Operations.removeTagFromItem.Output { try await removeTagFromItem(Operations.removeTagFromItem.Input(path: path)) } + /// Gets a single item. /// /// - Remark: HTTP `GET /items/{itemname}`. /// - Remark: Generated from `#/paths//items/{itemname}/get(getItemByName)`. - public func getItemByName( - path: Operations.getItemByName.Input.Path, - query: Operations.getItemByName.Input.Query = .init(), - headers: Operations.getItemByName.Input.Headers = .init() - ) async throws -> Operations.getItemByName.Output { + func getItemByName(path: Operations.getItemByName.Input.Path, + query: Operations.getItemByName.Input.Query = .init(), + headers: Operations.getItemByName.Input.Headers = .init()) async throws -> Operations.getItemByName.Output { try await getItemByName(Operations.getItemByName.Input( path: path, query: query, headers: headers )) } + /// Sends a command to an item. /// /// - Remark: HTTP `POST /items/{itemname}`. /// - Remark: Generated from `#/paths//items/{itemname}/post(sendItemCommand)`. - public func sendItemCommand( - path: Operations.sendItemCommand.Input.Path, - body: Operations.sendItemCommand.Input.Body - ) async throws -> Operations.sendItemCommand.Output { + func sendItemCommand(path: Operations.sendItemCommand.Input.Path, + body: Operations.sendItemCommand.Input.Body) async throws -> Operations.sendItemCommand.Output { try await sendItemCommand(Operations.sendItemCommand.Input( path: path, body: body )) } + /// Adds a new item to the registry or updates the existing item. /// /// - Remark: HTTP `PUT /items/{itemname}`. /// - Remark: Generated from `#/paths//items/{itemname}/put(addOrUpdateItemInRegistry)`. - public func addOrUpdateItemInRegistry( - path: Operations.addOrUpdateItemInRegistry.Input.Path, - headers: Operations.addOrUpdateItemInRegistry.Input.Headers = .init(), - body: Operations.addOrUpdateItemInRegistry.Input.Body - ) async throws -> Operations.addOrUpdateItemInRegistry.Output { + func addOrUpdateItemInRegistry(path: Operations.addOrUpdateItemInRegistry.Input.Path, + headers: Operations.addOrUpdateItemInRegistry.Input.Headers = .init(), + body: Operations.addOrUpdateItemInRegistry.Input.Body) async throws -> Operations.addOrUpdateItemInRegistry.Output { try await addOrUpdateItemInRegistry(Operations.addOrUpdateItemInRegistry.Input( path: path, headers: headers, body: body )) } + /// Removes an item from the registry. /// /// - Remark: HTTP `DELETE /items/{itemname}`. /// - Remark: Generated from `#/paths//items/{itemname}/delete(removeItemFromRegistry)`. - public func removeItemFromRegistry(path: Operations.removeItemFromRegistry.Input.Path) async throws -> Operations.removeItemFromRegistry.Output { + func removeItemFromRegistry(path: Operations.removeItemFromRegistry.Input.Path) async throws -> Operations.removeItemFromRegistry.Output { try await removeItemFromRegistry(Operations.removeItemFromRegistry.Input(path: path)) } + /// Get all available items. /// /// - Remark: HTTP `GET /items`. /// - Remark: Generated from `#/paths//items/get(getItems)`. - public func getItems( - query: Operations.getItems.Input.Query = .init(), - headers: Operations.getItems.Input.Headers = .init() - ) async throws -> Operations.getItems.Output { + func getItems(query: Operations.getItems.Input.Query = .init(), + headers: Operations.getItems.Input.Headers = .init()) async throws -> Operations.getItems.Output { try await getItems(Operations.getItems.Input( query: query, headers: headers )) } + /// Adds a list of items to the registry or updates the existing items. /// /// - Remark: HTTP `PUT /items`. /// - Remark: Generated from `#/paths//items/put(addOrUpdateItemsInRegistry)`. - public func addOrUpdateItemsInRegistry( - headers: Operations.addOrUpdateItemsInRegistry.Input.Headers = .init(), - body: Operations.addOrUpdateItemsInRegistry.Input.Body - ) async throws -> Operations.addOrUpdateItemsInRegistry.Output { + func addOrUpdateItemsInRegistry(headers: Operations.addOrUpdateItemsInRegistry.Input.Headers = .init(), + body: Operations.addOrUpdateItemsInRegistry.Input.Body) async throws -> Operations.addOrUpdateItemsInRegistry.Output { try await addOrUpdateItemsInRegistry(Operations.addOrUpdateItemsInRegistry.Input( headers: headers, body: body )) } + /// Gets the state of an item. /// /// - Remark: HTTP `GET /items/{itemname}/state`. /// - Remark: Generated from `#/paths//items/{itemname}/state/get(getItemState_1)`. - public func getItemState_1( - path: Operations.getItemState_1.Input.Path, - headers: Operations.getItemState_1.Input.Headers = .init() - ) async throws -> Operations.getItemState_1.Output { + func getItemState_1(path: Operations.getItemState_1.Input.Path, + headers: Operations.getItemState_1.Input.Headers = .init()) async throws -> Operations.getItemState_1.Output { try await getItemState_1(Operations.getItemState_1.Input( path: path, headers: headers )) } + /// Updates the state of an item. /// /// - Remark: HTTP `PUT /items/{itemname}/state`. /// - Remark: Generated from `#/paths//items/{itemname}/state/put(updateItemState)`. - public func updateItemState( - path: Operations.updateItemState.Input.Path, - headers: Operations.updateItemState.Input.Headers = .init(), - body: Operations.updateItemState.Input.Body - ) async throws -> Operations.updateItemState.Output { + func updateItemState(path: Operations.updateItemState.Input.Path, + headers: Operations.updateItemState.Input.Headers = .init(), + body: Operations.updateItemState.Input.Body) async throws -> Operations.updateItemState.Output { try await updateItemState(Operations.updateItemState.Input( path: path, headers: headers, body: body )) } + /// Gets the namespace of an item. /// /// - Remark: HTTP `GET /items/{itemname}/metadata/namespaces`. /// - Remark: Generated from `#/paths//items/{itemname}/metadata/namespaces/get(getItemNamespaces)`. - public func getItemNamespaces( - path: Operations.getItemNamespaces.Input.Path, - headers: Operations.getItemNamespaces.Input.Headers = .init() - ) async throws -> Operations.getItemNamespaces.Output { + func getItemNamespaces(path: Operations.getItemNamespaces.Input.Path, + headers: Operations.getItemNamespaces.Input.Headers = .init()) async throws -> Operations.getItemNamespaces.Output { try await getItemNamespaces(Operations.getItemNamespaces.Input( path: path, headers: headers )) } + /// Gets the item which defines the requested semantics of an item. /// /// - Remark: HTTP `GET /items/{itemName}/semantic/{semanticClass}`. /// - Remark: Generated from `#/paths//items/{itemName}/semantic/{semanticClass}/get(getSemanticItem)`. - public func getSemanticItem( - path: Operations.getSemanticItem.Input.Path, - headers: Operations.getSemanticItem.Input.Headers = .init() - ) async throws -> Operations.getSemanticItem.Output { + func getSemanticItem(path: Operations.getSemanticItem.Input.Path, + headers: Operations.getSemanticItem.Input.Headers = .init()) async throws -> Operations.getSemanticItem.Output { try await getSemanticItem(Operations.getSemanticItem.Input( path: path, headers: headers )) } + /// Remove unused/orphaned metadata. /// /// - Remark: HTTP `POST /items/metadata/purge`. /// - Remark: Generated from `#/paths//items/metadata/purge/post(purgeDatabase)`. - public func purgeDatabase() async throws -> Operations.purgeDatabase.Output { + func purgeDatabase() async throws -> Operations.purgeDatabase.Output { try await purgeDatabase(Operations.purgeDatabase.Input()) } + + /// Gets information about the runtime, the API version and links to resources. + /// + /// - Remark: HTTP `GET //`. + /// - Remark: Generated from `#/paths////get(getRoot)`. + func getRoot(headers: Operations.getRoot.Input.Headers = .init()) async throws -> Operations.getRoot.Output { + try await getRoot(Operations.getRoot.Input(headers: headers)) + } + + /// Gets information about the system. + /// + /// - Remark: HTTP `GET /systeminfo`. + /// - Remark: Generated from `#/paths//systeminfo/get(getSystemInformation)`. + func getSystemInformation(headers: Operations.getSystemInformation.Input.Headers = .init()) async throws -> Operations.getSystemInformation.Output { + try await getSystemInformation(Operations.getSystemInformation.Input(headers: headers)) + } + + /// Get all supported dimensions and their system units. + /// + /// - Remark: HTTP `GET /systeminfo/uom`. + /// - Remark: Generated from `#/paths//systeminfo/uom/get(getUoMInformation)`. + func getUoMInformation(headers: Operations.getUoMInformation.Input.Headers = .init()) async throws -> Operations.getUoMInformation.Output { + try await getUoMInformation(Operations.getUoMInformation.Input(headers: headers)) + } + /// Creates a sitemap event subscription. /// /// - Remark: HTTP `POST /sitemaps/events/subscribe`. /// - Remark: Generated from `#/paths//sitemaps/events/subscribe/post(createSitemapEventSubscription)`. - public func createSitemapEventSubscription(headers: Operations.createSitemapEventSubscription.Input.Headers = .init()) async throws -> Operations.createSitemapEventSubscription.Output { + func createSitemapEventSubscription(headers: Operations.createSitemapEventSubscription.Input.Headers = .init()) async throws -> Operations.createSitemapEventSubscription.Output { try await createSitemapEventSubscription(Operations.createSitemapEventSubscription.Input(headers: headers)) } + /// Polls the data for one page of a sitemap. /// /// - Remark: HTTP `GET /sitemaps/{sitemapname}/{pageid}`. /// - Remark: Generated from `#/paths//sitemaps/{sitemapname}/{pageid}/get(pollDataForPage)`. - public func pollDataForPage( - path: Operations.pollDataForPage.Input.Path, - query: Operations.pollDataForPage.Input.Query = .init(), - headers: Operations.pollDataForPage.Input.Headers = .init() - ) async throws -> Operations.pollDataForPage.Output { + func pollDataForPage(path: Operations.pollDataForPage.Input.Path, + query: Operations.pollDataForPage.Input.Query = .init(), + headers: Operations.pollDataForPage.Input.Headers = .init()) async throws -> Operations.pollDataForPage.Output { try await pollDataForPage(Operations.pollDataForPage.Input( path: path, query: query, headers: headers )) } + /// Polls the data for a whole sitemap. Not recommended due to potentially high traffic. /// /// - Remark: HTTP `GET /sitemaps/{sitemapname}/*`. /// - Remark: Generated from `#/paths//sitemaps/{sitemapname}/*/get(pollDataForSitemap)`. - public func pollDataForSitemap( - path: Operations.pollDataForSitemap.Input.Path, - query: Operations.pollDataForSitemap.Input.Query = .init(), - headers: Operations.pollDataForSitemap.Input.Headers = .init() - ) async throws -> Operations.pollDataForSitemap.Output { + func pollDataForSitemap(path: Operations.pollDataForSitemap.Input.Path, + query: Operations.pollDataForSitemap.Input.Query = .init(), + headers: Operations.pollDataForSitemap.Input.Headers = .init()) async throws -> Operations.pollDataForSitemap.Output { try await pollDataForSitemap(Operations.pollDataForSitemap.Input( path: path, query: query, headers: headers )) } + /// Get sitemap by name. /// /// - Remark: HTTP `GET /sitemaps/{sitemapname}`. /// - Remark: Generated from `#/paths//sitemaps/{sitemapname}/get(getSitemapByName)`. - public func getSitemapByName( - path: Operations.getSitemapByName.Input.Path, - query: Operations.getSitemapByName.Input.Query = .init(), - headers: Operations.getSitemapByName.Input.Headers = .init() - ) async throws -> Operations.getSitemapByName.Output { + func getSitemapByName(path: Operations.getSitemapByName.Input.Path, + query: Operations.getSitemapByName.Input.Query = .init(), + headers: Operations.getSitemapByName.Input.Headers = .init()) async throws -> Operations.getSitemapByName.Output { try await getSitemapByName(Operations.getSitemapByName.Input( path: path, query: query, headers: headers )) } + /// Get sitemap events for a whole sitemap. Not recommended due to potentially high traffic. /// /// - Remark: HTTP `GET /sitemaps/events/{subscriptionid}/*`. /// - Remark: Generated from `#/paths//sitemaps/events/{subscriptionid}/*/get(getSitemapEvents)`. - public func getSitemapEvents( - path: Operations.getSitemapEvents.Input.Path, - query: Operations.getSitemapEvents.Input.Query = .init() - ) async throws -> Operations.getSitemapEvents.Output { + func getSitemapEvents(path: Operations.getSitemapEvents.Input.Path, + query: Operations.getSitemapEvents.Input.Query = .init()) async throws -> Operations.getSitemapEvents.Output { try await getSitemapEvents(Operations.getSitemapEvents.Input( path: path, query: query )) } + /// Get sitemap events. /// /// - Remark: HTTP `GET /sitemaps/events/{subscriptionid}`. /// - Remark: Generated from `#/paths//sitemaps/events/{subscriptionid}/get(getSitemapEvents_1)`. - public func getSitemapEvents_1( - path: Operations.getSitemapEvents_1.Input.Path, - query: Operations.getSitemapEvents_1.Input.Query = .init(), - headers: Operations.getSitemapEvents_1.Input.Headers = .init() - ) async throws -> Operations.getSitemapEvents_1.Output { + func getSitemapEvents_1(path: Operations.getSitemapEvents_1.Input.Path, + query: Operations.getSitemapEvents_1.Input.Query = .init(), + headers: Operations.getSitemapEvents_1.Input.Headers = .init()) async throws -> Operations.getSitemapEvents_1.Output { try await getSitemapEvents_1(Operations.getSitemapEvents_1.Input( path: path, query: query, headers: headers )) } + /// Get all available sitemaps. /// /// - Remark: HTTP `GET /sitemaps`. /// - Remark: Generated from `#/paths//sitemaps/get(getSitemaps)`. - public func getSitemaps(headers: Operations.getSitemaps.Input.Headers = .init()) async throws -> Operations.getSitemaps.Output { + func getSitemaps(headers: Operations.getSitemaps.Input.Headers = .init()) async throws -> Operations.getSitemaps.Output { try await getSitemaps(Operations.getSitemaps.Input(headers: headers)) } + /// Get all registered UI components in the specified namespace. /// /// - Remark: HTTP `GET /ui/components/{namespace}`. /// - Remark: Generated from `#/paths//ui/components/{namespace}/get(getRegisteredUIComponentsInNamespace)`. - public func getRegisteredUIComponentsInNamespace( - path: Operations.getRegisteredUIComponentsInNamespace.Input.Path, - query: Operations.getRegisteredUIComponentsInNamespace.Input.Query = .init(), - headers: Operations.getRegisteredUIComponentsInNamespace.Input.Headers = .init() - ) async throws -> Operations.getRegisteredUIComponentsInNamespace.Output { + func getRegisteredUIComponentsInNamespace(path: Operations.getRegisteredUIComponentsInNamespace.Input.Path, + query: Operations.getRegisteredUIComponentsInNamespace.Input.Query = .init(), + headers: Operations.getRegisteredUIComponentsInNamespace.Input.Headers = .init()) async throws -> Operations.getRegisteredUIComponentsInNamespace.Output { try await getRegisteredUIComponentsInNamespace(Operations.getRegisteredUIComponentsInNamespace.Input( path: path, query: query, headers: headers )) } + /// Add a UI component in the specified namespace. /// /// - Remark: HTTP `POST /ui/components/{namespace}`. /// - Remark: Generated from `#/paths//ui/components/{namespace}/post(addUIComponentToNamespace)`. - public func addUIComponentToNamespace( - path: Operations.addUIComponentToNamespace.Input.Path, - headers: Operations.addUIComponentToNamespace.Input.Headers = .init(), - body: Operations.addUIComponentToNamespace.Input.Body? = nil - ) async throws -> Operations.addUIComponentToNamespace.Output { + func addUIComponentToNamespace(path: Operations.addUIComponentToNamespace.Input.Path, + headers: Operations.addUIComponentToNamespace.Input.Headers = .init(), + body: Operations.addUIComponentToNamespace.Input.Body? = nil) async throws -> Operations.addUIComponentToNamespace.Output { try await addUIComponentToNamespace(Operations.addUIComponentToNamespace.Input( path: path, headers: headers, body: body )) } + /// Get a specific UI component in the specified namespace. /// /// - Remark: HTTP `GET /ui/components/{namespace}/{componentUID}`. /// - Remark: Generated from `#/paths//ui/components/{namespace}/{componentUID}/get(getUIComponentInNamespace)`. - public func getUIComponentInNamespace( - path: Operations.getUIComponentInNamespace.Input.Path, - headers: Operations.getUIComponentInNamespace.Input.Headers = .init() - ) async throws -> Operations.getUIComponentInNamespace.Output { + func getUIComponentInNamespace(path: Operations.getUIComponentInNamespace.Input.Path, + headers: Operations.getUIComponentInNamespace.Input.Headers = .init()) async throws -> Operations.getUIComponentInNamespace.Output { try await getUIComponentInNamespace(Operations.getUIComponentInNamespace.Input( path: path, headers: headers )) } + /// Update a specific UI component in the specified namespace. /// /// - Remark: HTTP `PUT /ui/components/{namespace}/{componentUID}`. /// - Remark: Generated from `#/paths//ui/components/{namespace}/{componentUID}/put(updateUIComponentInNamespace)`. - public func updateUIComponentInNamespace( - path: Operations.updateUIComponentInNamespace.Input.Path, - headers: Operations.updateUIComponentInNamespace.Input.Headers = .init(), - body: Operations.updateUIComponentInNamespace.Input.Body? = nil - ) async throws -> Operations.updateUIComponentInNamespace.Output { + func updateUIComponentInNamespace(path: Operations.updateUIComponentInNamespace.Input.Path, + headers: Operations.updateUIComponentInNamespace.Input.Headers = .init(), + body: Operations.updateUIComponentInNamespace.Input.Body? = nil) async throws -> Operations.updateUIComponentInNamespace.Output { try await updateUIComponentInNamespace(Operations.updateUIComponentInNamespace.Input( path: path, headers: headers, body: body )) } + /// Remove a specific UI component in the specified namespace. /// /// - Remark: HTTP `DELETE /ui/components/{namespace}/{componentUID}`. /// - Remark: Generated from `#/paths//ui/components/{namespace}/{componentUID}/delete(removeUIComponentFromNamespace)`. - public func removeUIComponentFromNamespace(path: Operations.removeUIComponentFromNamespace.Input.Path) async throws -> Operations.removeUIComponentFromNamespace.Output { + func removeUIComponentFromNamespace(path: Operations.removeUIComponentFromNamespace.Input.Path) async throws -> Operations.removeUIComponentFromNamespace.Output { try await removeUIComponentFromNamespace(Operations.removeUIComponentFromNamespace.Input(path: path)) } + /// Get all registered UI tiles. /// /// - Remark: HTTP `GET /ui/tiles`. /// - Remark: Generated from `#/paths//ui/tiles/get(getUITiles)`. - public func getUITiles(headers: Operations.getUITiles.Input.Headers = .init()) async throws -> Operations.getUITiles.Output { + func getUITiles(headers: Operations.getUITiles.Input.Headers = .init()) async throws -> Operations.getUITiles.Output { try await getUITiles(Operations.getUITiles.Input(headers: headers)) } } @@ -521,6 +562,7 @@ public enum Servers { ) } } + @available(*, deprecated, renamed: "Servers.Server1.url") public static func server1() throws -> Foundation.URL { try Foundation.URL( @@ -550,11 +592,12 @@ public enum Components { public var required: Swift.Bool? /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterDTO/type`. @frozen public enum _typePayload: String, Codable, Hashable, Sendable, CaseIterable { - case TEXT = "TEXT" - case INTEGER = "INTEGER" - case DECIMAL = "DECIMAL" - case BOOLEAN = "BOOLEAN" + case TEXT + case INTEGER + case DECIMAL + case BOOLEAN } + /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterDTO/type`. public var _type: Components.Schemas.ConfigDescriptionParameterDTO._typePayload? /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterDTO/min`. @@ -612,30 +655,28 @@ public enum Components { /// - unitLabel: /// - options: /// - filterCriteria: - public init( - context: Swift.String? = nil, - defaultValue: Swift.String? = nil, - description: Swift.String? = nil, - label: Swift.String? = nil, - name: Swift.String? = nil, - required: Swift.Bool? = nil, - _type: Components.Schemas.ConfigDescriptionParameterDTO._typePayload? = nil, - min: Swift.Double? = nil, - max: Swift.Double? = nil, - stepsize: Swift.Double? = nil, - pattern: Swift.String? = nil, - readOnly: Swift.Bool? = nil, - multiple: Swift.Bool? = nil, - multipleLimit: Swift.Int32? = nil, - groupName: Swift.String? = nil, - advanced: Swift.Bool? = nil, - verify: Swift.Bool? = nil, - limitToOptions: Swift.Bool? = nil, - unit: Swift.String? = nil, - unitLabel: Swift.String? = nil, - options: [Components.Schemas.ParameterOptionDTO]? = nil, - filterCriteria: [Components.Schemas.FilterCriteriaDTO]? = nil - ) { + public init(context: Swift.String? = nil, + defaultValue: Swift.String? = nil, + description: Swift.String? = nil, + label: Swift.String? = nil, + name: Swift.String? = nil, + required: Swift.Bool? = nil, + _type: Components.Schemas.ConfigDescriptionParameterDTO._typePayload? = nil, + min: Swift.Double? = nil, + max: Swift.Double? = nil, + stepsize: Swift.Double? = nil, + pattern: Swift.String? = nil, + readOnly: Swift.Bool? = nil, + multiple: Swift.Bool? = nil, + multipleLimit: Swift.Int32? = nil, + groupName: Swift.String? = nil, + advanced: Swift.Bool? = nil, + verify: Swift.Bool? = nil, + limitToOptions: Swift.Bool? = nil, + unit: Swift.String? = nil, + unitLabel: Swift.String? = nil, + options: [Components.Schemas.ParameterOptionDTO]? = nil, + filterCriteria: [Components.Schemas.FilterCriteriaDTO]? = nil) { self.context = context self.defaultValue = defaultValue self.description = description @@ -659,6 +700,7 @@ public enum Components { self.options = options self.filterCriteria = filterCriteria } + public enum CodingKeys: String, CodingKey { case context case defaultValue @@ -684,6 +726,7 @@ public enum Components { case filterCriteria } } + /// - Remark: Generated from `#/components/schemas/FilterCriteriaDTO`. public struct FilterCriteriaDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/FilterCriteriaDTO/value`. @@ -695,18 +738,18 @@ public enum Components { /// - Parameters: /// - value: /// - name: - public init( - value: Swift.String? = nil, - name: Swift.String? = nil - ) { + public init(value: Swift.String? = nil, + name: Swift.String? = nil) { self.value = value self.name = name } + public enum CodingKeys: String, CodingKey { case value case name } } + /// - Remark: Generated from `#/components/schemas/ParameterOptionDTO`. public struct ParameterOptionDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/ParameterOptionDTO/label`. @@ -718,18 +761,18 @@ public enum Components { /// - Parameters: /// - label: /// - value: - public init( - label: Swift.String? = nil, - value: Swift.String? = nil - ) { + public init(label: Swift.String? = nil, + value: Swift.String? = nil) { self.label = label self.value = value } + public enum CodingKeys: String, CodingKey { case label case value } } + /// - Remark: Generated from `#/components/schemas/CommandDescription`. public struct CommandDescription: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/CommandDescription/commandOptions`. @@ -741,10 +784,12 @@ public enum Components { public init(commandOptions: [Components.Schemas.CommandOption]? = nil) { self.commandOptions = commandOptions } + public enum CodingKeys: String, CodingKey { case commandOptions } } + /// - Remark: Generated from `#/components/schemas/CommandOption`. public struct CommandOption: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/CommandOption/command`. @@ -756,18 +801,18 @@ public enum Components { /// - Parameters: /// - command: /// - label: - public init( - command: Swift.String? = nil, - label: Swift.String? = nil - ) { + public init(command: Swift.String? = nil, + label: Swift.String? = nil) { self.command = command self.label = label } + public enum CodingKeys: String, CodingKey { case command case label } } + /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterGroupDTO`. public struct ConfigDescriptionParameterGroupDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterGroupDTO/name`. @@ -788,19 +833,18 @@ public enum Components { /// - advanced: /// - label: /// - description: - public init( - name: Swift.String? = nil, - context: Swift.String? = nil, - advanced: Swift.Bool? = nil, - label: Swift.String? = nil, - description: Swift.String? = nil - ) { + public init(name: Swift.String? = nil, + context: Swift.String? = nil, + advanced: Swift.Bool? = nil, + label: Swift.String? = nil, + description: Swift.String? = nil) { self.name = name self.context = context self.advanced = advanced self.label = label self.description = description } + public enum CodingKeys: String, CodingKey { case name case context @@ -809,6 +853,7 @@ public enum Components { case description } } + /// - Remark: Generated from `#/components/schemas/StateDescription`. public struct StateDescription: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/StateDescription/minimum`. @@ -832,14 +877,12 @@ public enum Components { /// - pattern: /// - readOnly: /// - options: - public init( - minimum: Swift.Double? = nil, - maximum: Swift.Double? = nil, - step: Swift.Double? = nil, - pattern: Swift.String? = nil, - readOnly: Swift.Bool? = nil, - options: [Components.Schemas.StateOption]? = nil - ) { + public init(minimum: Swift.Double? = nil, + maximum: Swift.Double? = nil, + step: Swift.Double? = nil, + pattern: Swift.String? = nil, + readOnly: Swift.Bool? = nil, + options: [Components.Schemas.StateOption]? = nil) { self.minimum = minimum self.maximum = maximum self.step = step @@ -847,6 +890,7 @@ public enum Components { self.readOnly = readOnly self.options = options } + public enum CodingKeys: String, CodingKey { case minimum case maximum @@ -856,6 +900,7 @@ public enum Components { case options } } + /// - Remark: Generated from `#/components/schemas/StateOption`. public struct StateOption: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/StateOption/value`. @@ -867,18 +912,18 @@ public enum Components { /// - Parameters: /// - value: /// - label: - public init( - value: Swift.String? = nil, - label: Swift.String? = nil - ) { + public init(value: Swift.String? = nil, + label: Swift.String? = nil) { self.value = value self.label = label } + public enum CodingKeys: String, CodingKey { case value case label } } + /// - Remark: Generated from `#/components/schemas/ConfigDescriptionDTO`. public struct ConfigDescriptionDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/ConfigDescriptionDTO/uri`. @@ -893,21 +938,21 @@ public enum Components { /// - uri: /// - parameters: /// - parameterGroups: - public init( - uri: Swift.String? = nil, - parameters: [Components.Schemas.ConfigDescriptionParameterDTO]? = nil, - parameterGroups: [Components.Schemas.ConfigDescriptionParameterGroupDTO]? = nil - ) { + public init(uri: Swift.String? = nil, + parameters: [Components.Schemas.ConfigDescriptionParameterDTO]? = nil, + parameterGroups: [Components.Schemas.ConfigDescriptionParameterGroupDTO]? = nil) { self.uri = uri self.parameters = parameters self.parameterGroups = parameterGroups } + public enum CodingKeys: String, CodingKey { case uri case parameters case parameterGroups } } + /// - Remark: Generated from `#/components/schemas/MetadataDTO`. public struct MetadataDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/MetadataDTO/value`. @@ -923,13 +968,16 @@ public enum Components { public init(additionalProperties: [String: OpenAPIRuntime.OpenAPIObjectContainer] = .init()) { self.additionalProperties = additionalProperties } + public init(from decoder: any Decoder) throws { additionalProperties = try decoder.decodeAdditionalProperties(knownKeys: []) } + public func encode(to encoder: any Encoder) throws { try encoder.encodeAdditionalProperties(additionalProperties) } } + /// - Remark: Generated from `#/components/schemas/MetadataDTO/config`. public var config: Components.Schemas.MetadataDTO.configPayload? /// Creates a new `MetadataDTO`. @@ -937,18 +985,18 @@ public enum Components { /// - Parameters: /// - value: /// - config: - public init( - value: Swift.String? = nil, - config: Components.Schemas.MetadataDTO.configPayload? = nil - ) { + public init(value: Swift.String? = nil, + config: Components.Schemas.MetadataDTO.configPayload? = nil) { self.value = value self.config = config } + public enum CodingKeys: String, CodingKey { case value case config } } + /// - Remark: Generated from `#/components/schemas/EnrichedItemDTO`. public struct EnrichedItemDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/EnrichedItemDTO/type`. @@ -986,13 +1034,16 @@ public enum Components { public init(additionalProperties: [String: OpenAPIRuntime.OpenAPIObjectContainer] = .init()) { self.additionalProperties = additionalProperties } + public init(from decoder: any Decoder) throws { additionalProperties = try decoder.decodeAdditionalProperties(knownKeys: []) } + public func encode(to encoder: any Encoder) throws { try encoder.encodeAdditionalProperties(additionalProperties) } } + /// - Remark: Generated from `#/components/schemas/EnrichedItemDTO/metadata`. public var metadata: Components.Schemas.EnrichedItemDTO.metadataPayload? /// - Remark: Generated from `#/components/schemas/EnrichedItemDTO/editable`. @@ -1014,22 +1065,20 @@ public enum Components { /// - commandDescription: /// - metadata: /// - editable: - public init( - _type: Swift.String? = nil, - name: Swift.String? = nil, - label: Swift.String? = nil, - category: Swift.String? = nil, - tags: [Swift.String]? = nil, - groupNames: [Swift.String]? = nil, - link: Swift.String? = nil, - state: Swift.String? = nil, - transformedState: Swift.String? = nil, - stateDescription: Components.Schemas.StateDescription? = nil, - unitSymbol: Swift.String? = nil, - commandDescription: Components.Schemas.CommandDescription? = nil, - metadata: Components.Schemas.EnrichedItemDTO.metadataPayload? = nil, - editable: Swift.Bool? = nil - ) { + public init(_type: Swift.String? = nil, + name: Swift.String? = nil, + label: Swift.String? = nil, + category: Swift.String? = nil, + tags: [Swift.String]? = nil, + groupNames: [Swift.String]? = nil, + link: Swift.String? = nil, + state: Swift.String? = nil, + transformedState: Swift.String? = nil, + stateDescription: Components.Schemas.StateDescription? = nil, + unitSymbol: Swift.String? = nil, + commandDescription: Components.Schemas.CommandDescription? = nil, + metadata: Components.Schemas.EnrichedItemDTO.metadataPayload? = nil, + editable: Swift.Bool? = nil) { self._type = _type self.name = name self.label = label @@ -1045,6 +1094,7 @@ public enum Components { self.metadata = metadata self.editable = editable } + public enum CodingKeys: String, CodingKey { case _type = "type" case name @@ -1062,6 +1112,7 @@ public enum Components { case editable } } + /// - Remark: Generated from `#/components/schemas/GroupFunctionDTO`. public struct GroupFunctionDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/GroupFunctionDTO/name`. @@ -1073,18 +1124,18 @@ public enum Components { /// - Parameters: /// - name: /// - params: - public init( - name: Swift.String? = nil, - params: [Swift.String]? = nil - ) { + public init(name: Swift.String? = nil, + params: [Swift.String]? = nil) { self.name = name self.params = params } + public enum CodingKeys: String, CodingKey { case name case params } } + /// - Remark: Generated from `#/components/schemas/GroupItemDTO`. public struct GroupItemDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/GroupItemDTO/type`. @@ -1114,16 +1165,14 @@ public enum Components { /// - groupNames: /// - groupType: /// - function: - public init( - _type: Swift.String? = nil, - name: Swift.String? = nil, - label: Swift.String? = nil, - category: Swift.String? = nil, - tags: [Swift.String]? = nil, - groupNames: [Swift.String]? = nil, - groupType: Swift.String? = nil, - function: Components.Schemas.GroupFunctionDTO? = nil - ) { + public init(_type: Swift.String? = nil, + name: Swift.String? = nil, + label: Swift.String? = nil, + category: Swift.String? = nil, + tags: [Swift.String]? = nil, + groupNames: [Swift.String]? = nil, + groupType: Swift.String? = nil, + function: Components.Schemas.GroupFunctionDTO? = nil) { self._type = _type self.name = name self.label = label @@ -1133,6 +1182,7 @@ public enum Components { self.groupType = groupType self.function = function } + public enum CodingKeys: String, CodingKey { case _type = "type" case name @@ -1144,6 +1194,7 @@ public enum Components { case function } } + /// - Remark: Generated from `#/components/schemas/JerseyResponseBuilderDTO`. public struct JerseyResponseBuilderDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/JerseyResponseBuilderDTO/status`. @@ -1155,18 +1206,18 @@ public enum Components { /// - Parameters: /// - status: /// - context: - public init( - status: Swift.String? = nil, - context: Components.Schemas.ContextDTO? = nil - ) { + public init(status: Swift.String? = nil, + context: Components.Schemas.ContextDTO? = nil) { self.status = status self.context = context } + public enum CodingKeys: String, CodingKey { case status case context } } + /// - Remark: Generated from `#/components/schemas/ContextDTO`. public struct ContextDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/ContextDTO/headers`. @@ -1178,10 +1229,12 @@ public enum Components { public init(headers: Components.Schemas.HeadersDTO? = nil) { self.headers = headers } + public enum CodingKeys: String, CodingKey { case headers } } + /// - Remark: Generated from `#/components/schemas/HeadersDTO`. public struct HeadersDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/HeadersDTO/Location`. @@ -1193,10 +1246,268 @@ public enum Components { public init(Location: [Swift.String]? = nil) { self.Location = Location } + public enum CodingKeys: String, CodingKey { case Location } } + + /// - Remark: Generated from `#/components/schemas/Links`. + public struct Links: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/Links/type`. + public var _type: Swift.String? + /// - Remark: Generated from `#/components/schemas/Links/url`. + public var url: Swift.String? + /// Creates a new `Links`. + /// + /// - Parameters: + /// - _type: + /// - url: + public init(_type: Swift.String? = nil, + url: Swift.String? = nil) { + self._type = _type + self.url = url + } + + public enum CodingKeys: String, CodingKey { + case _type = "type" + case url + } + } + + /// - Remark: Generated from `#/components/schemas/RootBean`. + public struct RootBean: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/RootBean/version`. + public var version: Swift.String? + /// - Remark: Generated from `#/components/schemas/RootBean/locale`. + public var locale: Swift.String? + /// - Remark: Generated from `#/components/schemas/RootBean/measurementSystem`. + public var measurementSystem: Swift.String? + /// - Remark: Generated from `#/components/schemas/RootBean/runtimeInfo`. + public var runtimeInfo: Components.Schemas.RuntimeInfo? + /// - Remark: Generated from `#/components/schemas/RootBean/links`. + public var links: [Components.Schemas.Links]? + /// Creates a new `RootBean`. + /// + /// - Parameters: + /// - version: + /// - locale: + /// - measurementSystem: + /// - runtimeInfo: + /// - links: + public init(version: Swift.String? = nil, + locale: Swift.String? = nil, + measurementSystem: Swift.String? = nil, + runtimeInfo: Components.Schemas.RuntimeInfo? = nil, + links: [Components.Schemas.Links]? = nil) { + self.version = version + self.locale = locale + self.measurementSystem = measurementSystem + self.runtimeInfo = runtimeInfo + self.links = links + } + + public enum CodingKeys: String, CodingKey { + case version + case locale + case measurementSystem + case runtimeInfo + case links + } + } + + /// - Remark: Generated from `#/components/schemas/RuntimeInfo`. + public struct RuntimeInfo: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/RuntimeInfo/version`. + public var version: Swift.String? + /// - Remark: Generated from `#/components/schemas/RuntimeInfo/buildString`. + public var buildString: Swift.String? + /// Creates a new `RuntimeInfo`. + /// + /// - Parameters: + /// - version: + /// - buildString: + public init(version: Swift.String? = nil, + buildString: Swift.String? = nil) { + self.version = version + self.buildString = buildString + } + + public enum CodingKeys: String, CodingKey { + case version + case buildString + } + } + + /// - Remark: Generated from `#/components/schemas/SystemInfo`. + public struct SystemInfo: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/SystemInfo/configFolder`. + public var configFolder: Swift.String? + /// - Remark: Generated from `#/components/schemas/SystemInfo/userdataFolder`. + public var userdataFolder: Swift.String? + /// - Remark: Generated from `#/components/schemas/SystemInfo/logFolder`. + public var logFolder: Swift.String? + /// - Remark: Generated from `#/components/schemas/SystemInfo/javaVersion`. + public var javaVersion: Swift.String? + /// - Remark: Generated from `#/components/schemas/SystemInfo/javaVendor`. + public var javaVendor: Swift.String? + /// - Remark: Generated from `#/components/schemas/SystemInfo/javaVendorVersion`. + public var javaVendorVersion: Swift.String? + /// - Remark: Generated from `#/components/schemas/SystemInfo/osName`. + public var osName: Swift.String? + /// - Remark: Generated from `#/components/schemas/SystemInfo/osVersion`. + public var osVersion: Swift.String? + /// - Remark: Generated from `#/components/schemas/SystemInfo/osArchitecture`. + public var osArchitecture: Swift.String? + /// - Remark: Generated from `#/components/schemas/SystemInfo/availableProcessors`. + public var availableProcessors: Swift.Int32? + /// - Remark: Generated from `#/components/schemas/SystemInfo/freeMemory`. + public var freeMemory: Swift.Int64? + /// - Remark: Generated from `#/components/schemas/SystemInfo/totalMemory`. + public var totalMemory: Swift.Int64? + /// - Remark: Generated from `#/components/schemas/SystemInfo/uptime`. + public var uptime: Swift.Int64? + /// - Remark: Generated from `#/components/schemas/SystemInfo/startLevel`. + public var startLevel: Swift.Int32? + /// Creates a new `SystemInfo`. + /// + /// - Parameters: + /// - configFolder: + /// - userdataFolder: + /// - logFolder: + /// - javaVersion: + /// - javaVendor: + /// - javaVendorVersion: + /// - osName: + /// - osVersion: + /// - osArchitecture: + /// - availableProcessors: + /// - freeMemory: + /// - totalMemory: + /// - uptime: + /// - startLevel: + public init(configFolder: Swift.String? = nil, + userdataFolder: Swift.String? = nil, + logFolder: Swift.String? = nil, + javaVersion: Swift.String? = nil, + javaVendor: Swift.String? = nil, + javaVendorVersion: Swift.String? = nil, + osName: Swift.String? = nil, + osVersion: Swift.String? = nil, + osArchitecture: Swift.String? = nil, + availableProcessors: Swift.Int32? = nil, + freeMemory: Swift.Int64? = nil, + totalMemory: Swift.Int64? = nil, + uptime: Swift.Int64? = nil, + startLevel: Swift.Int32? = nil) { + self.configFolder = configFolder + self.userdataFolder = userdataFolder + self.logFolder = logFolder + self.javaVersion = javaVersion + self.javaVendor = javaVendor + self.javaVendorVersion = javaVendorVersion + self.osName = osName + self.osVersion = osVersion + self.osArchitecture = osArchitecture + self.availableProcessors = availableProcessors + self.freeMemory = freeMemory + self.totalMemory = totalMemory + self.uptime = uptime + self.startLevel = startLevel + } + + public enum CodingKeys: String, CodingKey { + case configFolder + case userdataFolder + case logFolder + case javaVersion + case javaVendor + case javaVendorVersion + case osName + case osVersion + case osArchitecture + case availableProcessors + case freeMemory + case totalMemory + case uptime + case startLevel + } + } + + /// - Remark: Generated from `#/components/schemas/SystemInfoBean`. + public struct SystemInfoBean: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/SystemInfoBean/systemInfo`. + public var systemInfo: Components.Schemas.SystemInfo? + /// Creates a new `SystemInfoBean`. + /// + /// - Parameters: + /// - systemInfo: + public init(systemInfo: Components.Schemas.SystemInfo? = nil) { + self.systemInfo = systemInfo + } + + public enum CodingKeys: String, CodingKey { + case systemInfo + } + } + + /// - Remark: Generated from `#/components/schemas/DimensionInfo`. + public struct DimensionInfo: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/DimensionInfo/dimension`. + public var dimension: Swift.String? + /// - Remark: Generated from `#/components/schemas/DimensionInfo/systemUnit`. + public var systemUnit: Swift.String? + /// Creates a new `DimensionInfo`. + /// + /// - Parameters: + /// - dimension: + /// - systemUnit: + public init(dimension: Swift.String? = nil, + systemUnit: Swift.String? = nil) { + self.dimension = dimension + self.systemUnit = systemUnit + } + + public enum CodingKeys: String, CodingKey { + case dimension + case systemUnit + } + } + + /// - Remark: Generated from `#/components/schemas/UoMInfo`. + public struct UoMInfo: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/UoMInfo/dimensions`. + public var dimensions: [Components.Schemas.DimensionInfo]? + /// Creates a new `UoMInfo`. + /// + /// - Parameters: + /// - dimensions: + public init(dimensions: [Components.Schemas.DimensionInfo]? = nil) { + self.dimensions = dimensions + } + + public enum CodingKeys: String, CodingKey { + case dimensions + } + } + + /// - Remark: Generated from `#/components/schemas/UoMInfoBean`. + public struct UoMInfoBean: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/UoMInfoBean/uomInfo`. + public var uomInfo: Components.Schemas.UoMInfo? + /// Creates a new `UoMInfoBean`. + /// + /// - Parameters: + /// - uomInfo: + public init(uomInfo: Components.Schemas.UoMInfo? = nil) { + self.uomInfo = uomInfo + } + + public enum CodingKeys: String, CodingKey { + case uomInfo + } + } + /// - Remark: Generated from `#/components/schemas/MappingDTO`. public struct MappingDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/MappingDTO/row`. @@ -1220,14 +1531,12 @@ public enum Components { /// - releaseCommand: /// - label: /// - icon: - public init( - row: Swift.Int32? = nil, - column: Swift.Int32? = nil, - command: Swift.String? = nil, - releaseCommand: Swift.String? = nil, - label: Swift.String? = nil, - icon: Swift.String? = nil - ) { + public init(row: Swift.Int32? = nil, + column: Swift.Int32? = nil, + command: Swift.String? = nil, + releaseCommand: Swift.String? = nil, + label: Swift.String? = nil, + icon: Swift.String? = nil) { self.row = row self.column = column self.command = command @@ -1235,6 +1544,7 @@ public enum Components { self.label = label self.icon = icon } + public enum CodingKeys: String, CodingKey { case row case column @@ -1244,80 +1554,89 @@ public enum Components { case icon } } + /// - Remark: Generated from `#/components/schemas/PageDTO`. public struct PageDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/PageDTO/id`. public var id: Swift.String? { - get { - self.storage.value.id + get { + storage.value.id } _modify { - yield &self.storage.value.id + yield &storage.value.id } } + /// - Remark: Generated from `#/components/schemas/PageDTO/title`. public var title: Swift.String? { - get { - self.storage.value.title + get { + storage.value.title } _modify { - yield &self.storage.value.title + yield &storage.value.title } } + /// - Remark: Generated from `#/components/schemas/PageDTO/icon`. public var icon: Swift.String? { - get { - self.storage.value.icon + get { + storage.value.icon } _modify { - yield &self.storage.value.icon + yield &storage.value.icon } } + /// - Remark: Generated from `#/components/schemas/PageDTO/link`. public var link: Swift.String? { - get { - self.storage.value.link + get { + storage.value.link } _modify { - yield &self.storage.value.link + yield &storage.value.link } } + /// - Remark: Generated from `#/components/schemas/PageDTO/parent`. public var parent: Components.Schemas.PageDTO? { - get { - self.storage.value.parent + get { + storage.value.parent } _modify { - yield &self.storage.value.parent + yield &storage.value.parent } } + /// - Remark: Generated from `#/components/schemas/PageDTO/leaf`. public var leaf: Swift.Bool? { - get { - self.storage.value.leaf + get { + storage.value.leaf } _modify { - yield &self.storage.value.leaf + yield &storage.value.leaf } } + /// - Remark: Generated from `#/components/schemas/PageDTO/timeout`. public var timeout: Swift.Bool? { - get { - self.storage.value.timeout + get { + storage.value.timeout } _modify { - yield &self.storage.value.timeout + yield &storage.value.timeout } } + /// - Remark: Generated from `#/components/schemas/PageDTO/widgets`. public var widgets: [Components.Schemas.WidgetDTO]? { - get { - self.storage.value.widgets + get { + storage.value.widgets } _modify { - yield &self.storage.value.widgets + yield &storage.value.widgets } } + /// Creates a new `PageDTO`. /// /// - Parameters: @@ -1329,17 +1648,15 @@ public enum Components { /// - leaf: /// - timeout: /// - widgets: - public init( - id: Swift.String? = nil, - title: Swift.String? = nil, - icon: Swift.String? = nil, - link: Swift.String? = nil, - parent: Components.Schemas.PageDTO? = nil, - leaf: Swift.Bool? = nil, - timeout: Swift.Bool? = nil, - widgets: [Components.Schemas.WidgetDTO]? = nil - ) { - self.storage = .init(value: .init( + public init(id: Swift.String? = nil, + title: Swift.String? = nil, + icon: Swift.String? = nil, + link: Swift.String? = nil, + parent: Components.Schemas.PageDTO? = nil, + leaf: Swift.Bool? = nil, + timeout: Swift.Bool? = nil, + widgets: [Components.Schemas.WidgetDTO]? = nil) { + storage = .init(value: .init( id: id, title: title, icon: icon, @@ -1350,6 +1667,7 @@ public enum Components { widgets: widgets )) } + public enum CodingKeys: String, CodingKey { case id case title @@ -1360,12 +1678,15 @@ public enum Components { case timeout case widgets } + public init(from decoder: any Decoder) throws { - self.storage = try .init(from: decoder) + storage = try .init(from: decoder) } + public func encode(to encoder: any Encoder) throws { - try self.storage.encode(to: encoder) + try storage.encode(to: encoder) } + /// Internal reference storage to allow type recursion. private var storage: OpenAPIRuntime.CopyOnWriteBox private struct Storage: Codable, Hashable, Sendable { @@ -1385,16 +1706,14 @@ public enum Components { var timeout: Swift.Bool? /// - Remark: Generated from `#/components/schemas/PageDTO/widgets`. var widgets: [Components.Schemas.WidgetDTO]? - init( - id: Swift.String? = nil, - title: Swift.String? = nil, - icon: Swift.String? = nil, - link: Swift.String? = nil, - parent: Components.Schemas.PageDTO? = nil, - leaf: Swift.Bool? = nil, - timeout: Swift.Bool? = nil, - widgets: [Components.Schemas.WidgetDTO]? = nil - ) { + init(id: Swift.String? = nil, + title: Swift.String? = nil, + icon: Swift.String? = nil, + link: Swift.String? = nil, + parent: Components.Schemas.PageDTO? = nil, + leaf: Swift.Bool? = nil, + timeout: Swift.Bool? = nil, + widgets: [Components.Schemas.WidgetDTO]? = nil) { self.id = id self.title = title self.icon = icon @@ -1404,362 +1723,403 @@ public enum Components { self.timeout = timeout self.widgets = widgets } + typealias CodingKeys = Components.Schemas.PageDTO.CodingKeys } } + /// - Remark: Generated from `#/components/schemas/WidgetDTO`. public struct WidgetDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/WidgetDTO/widgetId`. public var widgetId: Swift.String? { - get { - self.storage.value.widgetId + get { + storage.value.widgetId } _modify { - yield &self.storage.value.widgetId + yield &storage.value.widgetId } } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/type`. public var _type: Swift.String? { - get { - self.storage.value._type + get { + storage.value._type } _modify { - yield &self.storage.value._type + yield &storage.value._type } } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/name`. public var name: Swift.String? { - get { - self.storage.value.name + get { + storage.value.name } _modify { - yield &self.storage.value.name + yield &storage.value.name } } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/visibility`. public var visibility: Swift.Bool? { - get { - self.storage.value.visibility + get { + storage.value.visibility } _modify { - yield &self.storage.value.visibility + yield &storage.value.visibility } } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/label`. public var label: Swift.String? { - get { - self.storage.value.label + get { + storage.value.label } _modify { - yield &self.storage.value.label + yield &storage.value.label } } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/labelSource`. public var labelSource: Swift.String? { - get { - self.storage.value.labelSource + get { + storage.value.labelSource } _modify { - yield &self.storage.value.labelSource + yield &storage.value.labelSource } } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/icon`. public var icon: Swift.String? { - get { - self.storage.value.icon + get { + storage.value.icon } _modify { - yield &self.storage.value.icon + yield &storage.value.icon } } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/staticIcon`. public var staticIcon: Swift.Bool? { - get { - self.storage.value.staticIcon + get { + storage.value.staticIcon } _modify { - yield &self.storage.value.staticIcon + yield &storage.value.staticIcon } } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/labelcolor`. public var labelcolor: Swift.String? { - get { - self.storage.value.labelcolor + get { + storage.value.labelcolor } _modify { - yield &self.storage.value.labelcolor + yield &storage.value.labelcolor } } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/valuecolor`. public var valuecolor: Swift.String? { - get { - self.storage.value.valuecolor + get { + storage.value.valuecolor } _modify { - yield &self.storage.value.valuecolor + yield &storage.value.valuecolor } } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/iconcolor`. public var iconcolor: Swift.String? { - get { - self.storage.value.iconcolor + get { + storage.value.iconcolor } _modify { - yield &self.storage.value.iconcolor + yield &storage.value.iconcolor } } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/pattern`. public var pattern: Swift.String? { - get { - self.storage.value.pattern + get { + storage.value.pattern } _modify { - yield &self.storage.value.pattern + yield &storage.value.pattern } } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/unit`. public var unit: Swift.String? { - get { - self.storage.value.unit + get { + storage.value.unit } _modify { - yield &self.storage.value.unit + yield &storage.value.unit } } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/mappings`. public var mappings: [Components.Schemas.MappingDTO]? { - get { - self.storage.value.mappings + get { + storage.value.mappings } _modify { - yield &self.storage.value.mappings + yield &storage.value.mappings } } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/switchSupport`. public var switchSupport: Swift.Bool? { - get { - self.storage.value.switchSupport + get { + storage.value.switchSupport } _modify { - yield &self.storage.value.switchSupport + yield &storage.value.switchSupport } } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/releaseOnly`. public var releaseOnly: Swift.Bool? { - get { - self.storage.value.releaseOnly + get { + storage.value.releaseOnly } _modify { - yield &self.storage.value.releaseOnly + yield &storage.value.releaseOnly } } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/sendFrequency`. public var sendFrequency: Swift.Int32? { - get { - self.storage.value.sendFrequency + get { + storage.value.sendFrequency } _modify { - yield &self.storage.value.sendFrequency + yield &storage.value.sendFrequency } } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/refresh`. public var refresh: Swift.Int32? { - get { - self.storage.value.refresh + get { + storage.value.refresh } _modify { - yield &self.storage.value.refresh + yield &storage.value.refresh } } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/height`. public var height: Swift.Int32? { - get { - self.storage.value.height + get { + storage.value.height } _modify { - yield &self.storage.value.height + yield &storage.value.height } } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/minValue`. public var minValue: Swift.Double? { - get { - self.storage.value.minValue + get { + storage.value.minValue } _modify { - yield &self.storage.value.minValue + yield &storage.value.minValue } } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/maxValue`. public var maxValue: Swift.Double? { - get { - self.storage.value.maxValue + get { + storage.value.maxValue } _modify { - yield &self.storage.value.maxValue + yield &storage.value.maxValue } } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/step`. public var step: Swift.Double? { - get { - self.storage.value.step + get { + storage.value.step } _modify { - yield &self.storage.value.step + yield &storage.value.step } } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/inputHint`. public var inputHint: Swift.String? { - get { - self.storage.value.inputHint + get { + storage.value.inputHint } _modify { - yield &self.storage.value.inputHint + yield &storage.value.inputHint } } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/url`. public var url: Swift.String? { - get { - self.storage.value.url + get { + storage.value.url } _modify { - yield &self.storage.value.url + yield &storage.value.url } } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/encoding`. public var encoding: Swift.String? { - get { - self.storage.value.encoding + get { + storage.value.encoding } _modify { - yield &self.storage.value.encoding + yield &storage.value.encoding } } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/service`. public var service: Swift.String? { - get { - self.storage.value.service + get { + storage.value.service } _modify { - yield &self.storage.value.service + yield &storage.value.service } } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/period`. public var period: Swift.String? { - get { - self.storage.value.period + get { + storage.value.period } _modify { - yield &self.storage.value.period + yield &storage.value.period } } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/yAxisDecimalPattern`. public var yAxisDecimalPattern: Swift.String? { - get { - self.storage.value.yAxisDecimalPattern + get { + storage.value.yAxisDecimalPattern } _modify { - yield &self.storage.value.yAxisDecimalPattern + yield &storage.value.yAxisDecimalPattern } } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/legend`. public var legend: Swift.Bool? { - get { - self.storage.value.legend + get { + storage.value.legend } _modify { - yield &self.storage.value.legend + yield &storage.value.legend } } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/forceAsItem`. public var forceAsItem: Swift.Bool? { - get { - self.storage.value.forceAsItem + get { + storage.value.forceAsItem } _modify { - yield &self.storage.value.forceAsItem + yield &storage.value.forceAsItem } } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/row`. public var row: Swift.Int32? { - get { - self.storage.value.row + get { + storage.value.row } _modify { - yield &self.storage.value.row + yield &storage.value.row } } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/column`. public var column: Swift.Int32? { - get { - self.storage.value.column + get { + storage.value.column } _modify { - yield &self.storage.value.column + yield &storage.value.column } } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/command`. public var command: Swift.String? { - get { - self.storage.value.command + get { + storage.value.command } _modify { - yield &self.storage.value.command + yield &storage.value.command } } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/releaseCommand`. public var releaseCommand: Swift.String? { - get { - self.storage.value.releaseCommand + get { + storage.value.releaseCommand } _modify { - yield &self.storage.value.releaseCommand + yield &storage.value.releaseCommand } } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/stateless`. public var stateless: Swift.Bool? { - get { - self.storage.value.stateless + get { + storage.value.stateless } _modify { - yield &self.storage.value.stateless + yield &storage.value.stateless } } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/state`. public var state: Swift.String? { - get { - self.storage.value.state + get { + storage.value.state } _modify { - yield &self.storage.value.state + yield &storage.value.state } } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/item`. public var item: Components.Schemas.EnrichedItemDTO? { - get { - self.storage.value.item + get { + storage.value.item } _modify { - yield &self.storage.value.item + yield &storage.value.item } } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/linkedPage`. public var linkedPage: Components.Schemas.PageDTO? { - get { - self.storage.value.linkedPage + get { + storage.value.linkedPage } _modify { - yield &self.storage.value.linkedPage + yield &storage.value.linkedPage } } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/widgets`. public var widgets: [Components.Schemas.WidgetDTO]? { - get { - self.storage.value.widgets + get { + storage.value.widgets } _modify { - yield &self.storage.value.widgets + yield &storage.value.widgets } } + /// Creates a new `WidgetDTO`. /// /// - Parameters: @@ -1802,48 +2162,46 @@ public enum Components { /// - item: /// - linkedPage: /// - widgets: - public init( - widgetId: Swift.String? = nil, - _type: Swift.String? = nil, - name: Swift.String? = nil, - visibility: Swift.Bool? = nil, - label: Swift.String? = nil, - labelSource: Swift.String? = nil, - icon: Swift.String? = nil, - staticIcon: Swift.Bool? = nil, - labelcolor: Swift.String? = nil, - valuecolor: Swift.String? = nil, - iconcolor: Swift.String? = nil, - pattern: Swift.String? = nil, - unit: Swift.String? = nil, - mappings: [Components.Schemas.MappingDTO]? = nil, - switchSupport: Swift.Bool? = nil, - releaseOnly: Swift.Bool? = nil, - sendFrequency: Swift.Int32? = nil, - refresh: Swift.Int32? = nil, - height: Swift.Int32? = nil, - minValue: Swift.Double? = nil, - maxValue: Swift.Double? = nil, - step: Swift.Double? = nil, - inputHint: Swift.String? = nil, - url: Swift.String? = nil, - encoding: Swift.String? = nil, - service: Swift.String? = nil, - period: Swift.String? = nil, - yAxisDecimalPattern: Swift.String? = nil, - legend: Swift.Bool? = nil, - forceAsItem: Swift.Bool? = nil, - row: Swift.Int32? = nil, - column: Swift.Int32? = nil, - command: Swift.String? = nil, - releaseCommand: Swift.String? = nil, - stateless: Swift.Bool? = nil, - state: Swift.String? = nil, - item: Components.Schemas.EnrichedItemDTO? = nil, - linkedPage: Components.Schemas.PageDTO? = nil, - widgets: [Components.Schemas.WidgetDTO]? = nil - ) { - self.storage = .init(value: .init( + public init(widgetId: Swift.String? = nil, + _type: Swift.String? = nil, + name: Swift.String? = nil, + visibility: Swift.Bool? = nil, + label: Swift.String? = nil, + labelSource: Swift.String? = nil, + icon: Swift.String? = nil, + staticIcon: Swift.Bool? = nil, + labelcolor: Swift.String? = nil, + valuecolor: Swift.String? = nil, + iconcolor: Swift.String? = nil, + pattern: Swift.String? = nil, + unit: Swift.String? = nil, + mappings: [Components.Schemas.MappingDTO]? = nil, + switchSupport: Swift.Bool? = nil, + releaseOnly: Swift.Bool? = nil, + sendFrequency: Swift.Int32? = nil, + refresh: Swift.Int32? = nil, + height: Swift.Int32? = nil, + minValue: Swift.Double? = nil, + maxValue: Swift.Double? = nil, + step: Swift.Double? = nil, + inputHint: Swift.String? = nil, + url: Swift.String? = nil, + encoding: Swift.String? = nil, + service: Swift.String? = nil, + period: Swift.String? = nil, + yAxisDecimalPattern: Swift.String? = nil, + legend: Swift.Bool? = nil, + forceAsItem: Swift.Bool? = nil, + row: Swift.Int32? = nil, + column: Swift.Int32? = nil, + command: Swift.String? = nil, + releaseCommand: Swift.String? = nil, + stateless: Swift.Bool? = nil, + state: Swift.String? = nil, + item: Components.Schemas.EnrichedItemDTO? = nil, + linkedPage: Components.Schemas.PageDTO? = nil, + widgets: [Components.Schemas.WidgetDTO]? = nil) { + storage = .init(value: .init( widgetId: widgetId, _type: _type, name: name, @@ -1885,6 +2243,7 @@ public enum Components { widgets: widgets )) } + public enum CodingKeys: String, CodingKey { case widgetId case _type = "type" @@ -1926,12 +2285,15 @@ public enum Components { case linkedPage case widgets } + public init(from decoder: any Decoder) throws { - self.storage = try .init(from: decoder) + storage = try .init(from: decoder) } + public func encode(to encoder: any Encoder) throws { - try self.storage.encode(to: encoder) + try storage.encode(to: encoder) } + /// Internal reference storage to allow type recursion. private var storage: OpenAPIRuntime.CopyOnWriteBox private struct Storage: Codable, Hashable, Sendable { @@ -2013,47 +2375,45 @@ public enum Components { var linkedPage: Components.Schemas.PageDTO? /// - Remark: Generated from `#/components/schemas/WidgetDTO/widgets`. var widgets: [Components.Schemas.WidgetDTO]? - init( - widgetId: Swift.String? = nil, - _type: Swift.String? = nil, - name: Swift.String? = nil, - visibility: Swift.Bool? = nil, - label: Swift.String? = nil, - labelSource: Swift.String? = nil, - icon: Swift.String? = nil, - staticIcon: Swift.Bool? = nil, - labelcolor: Swift.String? = nil, - valuecolor: Swift.String? = nil, - iconcolor: Swift.String? = nil, - pattern: Swift.String? = nil, - unit: Swift.String? = nil, - mappings: [Components.Schemas.MappingDTO]? = nil, - switchSupport: Swift.Bool? = nil, - releaseOnly: Swift.Bool? = nil, - sendFrequency: Swift.Int32? = nil, - refresh: Swift.Int32? = nil, - height: Swift.Int32? = nil, - minValue: Swift.Double? = nil, - maxValue: Swift.Double? = nil, - step: Swift.Double? = nil, - inputHint: Swift.String? = nil, - url: Swift.String? = nil, - encoding: Swift.String? = nil, - service: Swift.String? = nil, - period: Swift.String? = nil, - yAxisDecimalPattern: Swift.String? = nil, - legend: Swift.Bool? = nil, - forceAsItem: Swift.Bool? = nil, - row: Swift.Int32? = nil, - column: Swift.Int32? = nil, - command: Swift.String? = nil, - releaseCommand: Swift.String? = nil, - stateless: Swift.Bool? = nil, - state: Swift.String? = nil, - item: Components.Schemas.EnrichedItemDTO? = nil, - linkedPage: Components.Schemas.PageDTO? = nil, - widgets: [Components.Schemas.WidgetDTO]? = nil - ) { + init(widgetId: Swift.String? = nil, + _type: Swift.String? = nil, + name: Swift.String? = nil, + visibility: Swift.Bool? = nil, + label: Swift.String? = nil, + labelSource: Swift.String? = nil, + icon: Swift.String? = nil, + staticIcon: Swift.Bool? = nil, + labelcolor: Swift.String? = nil, + valuecolor: Swift.String? = nil, + iconcolor: Swift.String? = nil, + pattern: Swift.String? = nil, + unit: Swift.String? = nil, + mappings: [Components.Schemas.MappingDTO]? = nil, + switchSupport: Swift.Bool? = nil, + releaseOnly: Swift.Bool? = nil, + sendFrequency: Swift.Int32? = nil, + refresh: Swift.Int32? = nil, + height: Swift.Int32? = nil, + minValue: Swift.Double? = nil, + maxValue: Swift.Double? = nil, + step: Swift.Double? = nil, + inputHint: Swift.String? = nil, + url: Swift.String? = nil, + encoding: Swift.String? = nil, + service: Swift.String? = nil, + period: Swift.String? = nil, + yAxisDecimalPattern: Swift.String? = nil, + legend: Swift.Bool? = nil, + forceAsItem: Swift.Bool? = nil, + row: Swift.Int32? = nil, + column: Swift.Int32? = nil, + command: Swift.String? = nil, + releaseCommand: Swift.String? = nil, + stateless: Swift.Bool? = nil, + state: Swift.String? = nil, + item: Components.Schemas.EnrichedItemDTO? = nil, + linkedPage: Components.Schemas.PageDTO? = nil, + widgets: [Components.Schemas.WidgetDTO]? = nil) { self.widgetId = widgetId self._type = _type self.name = name @@ -2094,9 +2454,11 @@ public enum Components { self.linkedPage = linkedPage self.widgets = widgets } + typealias CodingKeys = Components.Schemas.WidgetDTO.CodingKeys } } + /// - Remark: Generated from `#/components/schemas/SitemapWidgetEvent`. public struct SitemapWidgetEvent: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/SitemapWidgetEvent/widgetId`. @@ -2144,22 +2506,20 @@ public enum Components { /// - item: /// - sitemapName: /// - pageId: - public init( - widgetId: Swift.String? = nil, - label: Swift.String? = nil, - labelSource: Swift.String? = nil, - icon: Swift.String? = nil, - labelcolor: Swift.String? = nil, - valuecolor: Swift.String? = nil, - iconcolor: Swift.String? = nil, - state: Swift.String? = nil, - reloadIcon: Swift.Bool? = nil, - visibility: Swift.Bool? = nil, - descriptionChanged: Swift.Bool? = nil, - item: Components.Schemas.EnrichedItemDTO? = nil, - sitemapName: Swift.String? = nil, - pageId: Swift.String? = nil - ) { + public init(widgetId: Swift.String? = nil, + label: Swift.String? = nil, + labelSource: Swift.String? = nil, + icon: Swift.String? = nil, + labelcolor: Swift.String? = nil, + valuecolor: Swift.String? = nil, + iconcolor: Swift.String? = nil, + state: Swift.String? = nil, + reloadIcon: Swift.Bool? = nil, + visibility: Swift.Bool? = nil, + descriptionChanged: Swift.Bool? = nil, + item: Components.Schemas.EnrichedItemDTO? = nil, + sitemapName: Swift.String? = nil, + pageId: Swift.String? = nil) { self.widgetId = widgetId self.label = label self.labelSource = labelSource @@ -2175,6 +2535,7 @@ public enum Components { self.sitemapName = sitemapName self.pageId = pageId } + public enum CodingKeys: String, CodingKey { case widgetId case label @@ -2192,6 +2553,7 @@ public enum Components { case pageId } } + /// - Remark: Generated from `#/components/schemas/SitemapDTO`. public struct SitemapDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/SitemapDTO/name`. @@ -2212,19 +2574,18 @@ public enum Components { /// - label: /// - link: /// - homepage: - public init( - name: Swift.String? = nil, - icon: Swift.String? = nil, - label: Swift.String? = nil, - link: Swift.String? = nil, - homepage: Components.Schemas.PageDTO? = nil - ) { + public init(name: Swift.String? = nil, + icon: Swift.String? = nil, + label: Swift.String? = nil, + link: Swift.String? = nil, + homepage: Components.Schemas.PageDTO? = nil) { self.name = name self.icon = icon self.label = label self.link = link self.homepage = homepage } + public enum CodingKeys: String, CodingKey { case name case icon @@ -2233,6 +2594,7 @@ public enum Components { case homepage } } + /// - Remark: Generated from `#/components/schemas/RootUIComponent`. public struct RootUIComponent: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/RootUIComponent/component`. @@ -2248,13 +2610,16 @@ public enum Components { public init(additionalProperties: [String: OpenAPIRuntime.OpenAPIObjectContainer] = .init()) { self.additionalProperties = additionalProperties } + public init(from decoder: any Decoder) throws { additionalProperties = try decoder.decodeAdditionalProperties(knownKeys: []) } + public func encode(to encoder: any Encoder) throws { try encoder.encodeAdditionalProperties(additionalProperties) } } + /// - Remark: Generated from `#/components/schemas/RootUIComponent/config`. public var config: Components.Schemas.RootUIComponent.configPayload? /// - Remark: Generated from `#/components/schemas/RootUIComponent/slots`. @@ -2268,13 +2633,16 @@ public enum Components { public init(additionalProperties: [String: [Components.Schemas.UIComponent]] = .init()) { self.additionalProperties = additionalProperties } + public init(from decoder: any Decoder) throws { additionalProperties = try decoder.decodeAdditionalProperties(knownKeys: []) } + public func encode(to encoder: any Encoder) throws { try encoder.encodeAdditionalProperties(additionalProperties) } } + /// - Remark: Generated from `#/components/schemas/RootUIComponent/slots`. public var slots: Components.Schemas.RootUIComponent.slotsPayload? /// - Remark: Generated from `#/components/schemas/RootUIComponent/uid`. @@ -2298,16 +2666,14 @@ public enum Components { /// - props: /// - timestamp: /// - _type: - public init( - component: Swift.String? = nil, - config: Components.Schemas.RootUIComponent.configPayload? = nil, - slots: Components.Schemas.RootUIComponent.slotsPayload? = nil, - uid: Swift.String? = nil, - tags: [Swift.String]? = nil, - props: Components.Schemas.ConfigDescriptionDTO? = nil, - timestamp: Foundation.Date? = nil, - _type: Swift.String? = nil - ) { + public init(component: Swift.String? = nil, + config: Components.Schemas.RootUIComponent.configPayload? = nil, + slots: Components.Schemas.RootUIComponent.slotsPayload? = nil, + uid: Swift.String? = nil, + tags: [Swift.String]? = nil, + props: Components.Schemas.ConfigDescriptionDTO? = nil, + timestamp: Foundation.Date? = nil, + _type: Swift.String? = nil) { self.component = component self.config = config self.slots = slots @@ -2317,6 +2683,7 @@ public enum Components { self.timestamp = timestamp self._type = _type } + public enum CodingKeys: String, CodingKey { case component case config @@ -2328,6 +2695,7 @@ public enum Components { case _type = "type" } } + /// - Remark: Generated from `#/components/schemas/UIComponent`. public struct UIComponent: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/UIComponent/component`. @@ -2343,13 +2711,16 @@ public enum Components { public init(additionalProperties: [String: OpenAPIRuntime.OpenAPIObjectContainer] = .init()) { self.additionalProperties = additionalProperties } + public init(from decoder: any Decoder) throws { additionalProperties = try decoder.decodeAdditionalProperties(knownKeys: []) } + public func encode(to encoder: any Encoder) throws { try encoder.encodeAdditionalProperties(additionalProperties) } } + /// - Remark: Generated from `#/components/schemas/UIComponent/config`. public var config: Components.Schemas.UIComponent.configPayload? /// - Remark: Generated from `#/components/schemas/UIComponent/type`. @@ -2360,21 +2731,21 @@ public enum Components { /// - component: /// - config: /// - _type: - public init( - component: Swift.String? = nil, - config: Components.Schemas.UIComponent.configPayload? = nil, - _type: Swift.String? = nil - ) { + public init(component: Swift.String? = nil, + config: Components.Schemas.UIComponent.configPayload? = nil, + _type: Swift.String? = nil) { self.component = component self.config = config self._type = _type } + public enum CodingKeys: String, CodingKey { case component case config case _type = "type" } } + /// - Remark: Generated from `#/components/schemas/TileDTO`. public struct TileDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/TileDTO/name`. @@ -2392,17 +2763,16 @@ public enum Components { /// - url: /// - overlay: /// - imageUrl: - public init( - name: Swift.String? = nil, - url: Swift.String? = nil, - overlay: Swift.String? = nil, - imageUrl: Swift.String? = nil - ) { + public init(name: Swift.String? = nil, + url: Swift.String? = nil, + overlay: Swift.String? = nil, + imageUrl: Swift.String? = nil) { self.name = name self.url = url self.overlay = overlay self.imageUrl = imageUrl } + public enum CodingKeys: String, CodingKey { case name case url @@ -2411,6 +2781,7 @@ public enum Components { } } } + /// Types generated from the `#/components/parameters` section of the OpenAPI document. public enum Parameters {} /// Types generated from the `#/components/requestBodies` section of the OpenAPI document. @@ -2445,14 +2816,13 @@ public enum Operations { /// - Parameters: /// - itemName: item name /// - memberItemName: member item name - public init( - itemName: Swift.String, - memberItemName: Swift.String - ) { + public init(itemName: Swift.String, + memberItemName: Swift.String) { self.itemName = itemName self.memberItemName = memberItemName } } + public var path: Operations.addMemberToGroupItem.Input.Path /// Creates a new `Input`. /// @@ -2462,11 +2832,13 @@ public enum Operations { self.path = path } } + @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// Creates a new `Ok`. public init() {} } + /// OK /// /// - Remark: Generated from `#/paths//items/{itemName}/members/{memberItemName}/put(addMemberToGroupItem)/responses/200`. @@ -2481,6 +2853,7 @@ public enum Operations { public static var ok: Self { .ok(.init()) } + /// The associated value of the enum case if `self` is `.ok`. /// /// - Throws: An error if `self` is not `.ok`. @@ -2489,7 +2862,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -2498,10 +2871,12 @@ public enum Operations { } } } + public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } + /// Item or member item not found or item is not of type group item. /// /// - Remark: Generated from `#/paths//items/{itemName}/members/{memberItemName}/put(addMemberToGroupItem)/responses/404`. @@ -2516,6 +2891,7 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } + /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -2524,7 +2900,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -2533,10 +2909,12 @@ public enum Operations { } } } + public struct MethodNotAllowed: Sendable, Hashable { /// Creates a new `MethodNotAllowed`. public init() {} } + /// Member item is not editable. /// /// - Remark: Generated from `#/paths//items/{itemName}/members/{memberItemName}/put(addMemberToGroupItem)/responses/405`. @@ -2551,6 +2929,7 @@ public enum Operations { public static var methodNotAllowed: Self { .methodNotAllowed(.init()) } + /// The associated value of the enum case if `self` is `.methodNotAllowed`. /// /// - Throws: An error if `self` is not `.methodNotAllowed`. @@ -2559,7 +2938,7 @@ public enum Operations { get throws { switch self { case let .methodNotAllowed(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "methodNotAllowed", @@ -2568,12 +2947,14 @@ public enum Operations { } } } + /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } } + /// Removes an existing member from a group item. /// /// - Remark: HTTP `DELETE /items/{itemName}/members/{memberItemName}`. @@ -2596,14 +2977,13 @@ public enum Operations { /// - Parameters: /// - itemName: item name /// - memberItemName: member item name - public init( - itemName: Swift.String, - memberItemName: Swift.String - ) { + public init(itemName: Swift.String, + memberItemName: Swift.String) { self.itemName = itemName self.memberItemName = memberItemName } } + public var path: Operations.removeMemberFromGroupItem.Input.Path /// Creates a new `Input`. /// @@ -2613,11 +2993,13 @@ public enum Operations { self.path = path } } + @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// Creates a new `Ok`. public init() {} } + /// OK /// /// - Remark: Generated from `#/paths//items/{itemName}/members/{memberItemName}/delete(removeMemberFromGroupItem)/responses/200`. @@ -2632,6 +3014,7 @@ public enum Operations { public static var ok: Self { .ok(.init()) } + /// The associated value of the enum case if `self` is `.ok`. /// /// - Throws: An error if `self` is not `.ok`. @@ -2640,7 +3023,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -2649,10 +3032,12 @@ public enum Operations { } } } + public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } + /// Item or member item not found or item is not of type group item. /// /// - Remark: Generated from `#/paths//items/{itemName}/members/{memberItemName}/delete(removeMemberFromGroupItem)/responses/404`. @@ -2667,6 +3052,7 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } + /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -2675,7 +3061,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -2684,10 +3070,12 @@ public enum Operations { } } } + public struct MethodNotAllowed: Sendable, Hashable { /// Creates a new `MethodNotAllowed`. public init() {} } + /// Member item is not editable. /// /// - Remark: Generated from `#/paths//items/{itemName}/members/{memberItemName}/delete(removeMemberFromGroupItem)/responses/405`. @@ -2702,6 +3090,7 @@ public enum Operations { public static var methodNotAllowed: Self { .methodNotAllowed(.init()) } + /// The associated value of the enum case if `self` is `.methodNotAllowed`. /// /// - Throws: An error if `self` is not `.methodNotAllowed`. @@ -2710,7 +3099,7 @@ public enum Operations { get throws { switch self { case let .methodNotAllowed(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "methodNotAllowed", @@ -2719,12 +3108,14 @@ public enum Operations { } } } + /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } } + /// Adds metadata to an item. /// /// - Remark: HTTP `PUT /items/{itemname}/metadata/{namespace}`. @@ -2747,39 +3138,39 @@ public enum Operations { /// - Parameters: /// - itemname: item name /// - namespace: namespace - public init( - itemname: Swift.String, - namespace: Swift.String - ) { + public init(itemname: Swift.String, + namespace: Swift.String) { self.itemname = itemname self.namespace = namespace } } + public var path: Operations.addMetadataToItem.Input.Path /// - Remark: Generated from `#/paths/items/{itemname}/metadata/{namespace}/PUT/requestBody`. @frozen public enum Body: Sendable, Hashable { /// - Remark: Generated from `#/paths/items/{itemname}/metadata/{namespace}/PUT/requestBody/content/application\/json`. case json(Components.Schemas.MetadataDTO) } + public var body: Operations.addMetadataToItem.Input.Body /// Creates a new `Input`. /// /// - Parameters: /// - path: /// - body: - public init( - path: Operations.addMetadataToItem.Input.Path, - body: Operations.addMetadataToItem.Input.Body - ) { + public init(path: Operations.addMetadataToItem.Input.Path, + body: Operations.addMetadataToItem.Input.Body) { self.path = path self.body = body } } + @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// Creates a new `Ok`. public init() {} } + /// OK /// /// - Remark: Generated from `#/paths//items/{itemname}/metadata/{namespace}/put(addMetadataToItem)/responses/200`. @@ -2794,6 +3185,7 @@ public enum Operations { public static var ok: Self { .ok(.init()) } + /// The associated value of the enum case if `self` is `.ok`. /// /// - Throws: An error if `self` is not `.ok`. @@ -2802,7 +3194,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -2811,10 +3203,12 @@ public enum Operations { } } } + public struct Created: Sendable, Hashable { /// Creates a new `Created`. public init() {} } + /// Created /// /// - Remark: Generated from `#/paths//items/{itemname}/metadata/{namespace}/put(addMetadataToItem)/responses/201`. @@ -2829,6 +3223,7 @@ public enum Operations { public static var created: Self { .created(.init()) } + /// The associated value of the enum case if `self` is `.created`. /// /// - Throws: An error if `self` is not `.created`. @@ -2837,7 +3232,7 @@ public enum Operations { get throws { switch self { case let .created(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "created", @@ -2846,10 +3241,12 @@ public enum Operations { } } } + public struct BadRequest: Sendable, Hashable { /// Creates a new `BadRequest`. public init() {} } + /// Metadata value empty. /// /// - Remark: Generated from `#/paths//items/{itemname}/metadata/{namespace}/put(addMetadataToItem)/responses/400`. @@ -2864,6 +3261,7 @@ public enum Operations { public static var badRequest: Self { .badRequest(.init()) } + /// The associated value of the enum case if `self` is `.badRequest`. /// /// - Throws: An error if `self` is not `.badRequest`. @@ -2872,7 +3270,7 @@ public enum Operations { get throws { switch self { case let .badRequest(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "badRequest", @@ -2881,10 +3279,12 @@ public enum Operations { } } } + public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } + /// Item not found. /// /// - Remark: Generated from `#/paths//items/{itemname}/metadata/{namespace}/put(addMetadataToItem)/responses/404`. @@ -2899,6 +3299,7 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } + /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -2907,7 +3308,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -2916,10 +3317,12 @@ public enum Operations { } } } + public struct MethodNotAllowed: Sendable, Hashable { /// Creates a new `MethodNotAllowed`. public init() {} } + /// Metadata not editable. /// /// - Remark: Generated from `#/paths//items/{itemname}/metadata/{namespace}/put(addMetadataToItem)/responses/405`. @@ -2934,6 +3337,7 @@ public enum Operations { public static var methodNotAllowed: Self { .methodNotAllowed(.init()) } + /// The associated value of the enum case if `self` is `.methodNotAllowed`. /// /// - Throws: An error if `self` is not `.methodNotAllowed`. @@ -2942,7 +3346,7 @@ public enum Operations { get throws { switch self { case let .methodNotAllowed(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "methodNotAllowed", @@ -2951,12 +3355,14 @@ public enum Operations { } } } + /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } } + /// Removes metadata from an item. /// /// - Remark: HTTP `DELETE /items/{itemname}/metadata/{namespace}`. @@ -2979,14 +3385,13 @@ public enum Operations { /// - Parameters: /// - itemname: item name /// - namespace: namespace - public init( - itemname: Swift.String, - namespace: Swift.String - ) { + public init(itemname: Swift.String, + namespace: Swift.String) { self.itemname = itemname self.namespace = namespace } } + public var path: Operations.removeMetadataFromItem.Input.Path /// Creates a new `Input`. /// @@ -2996,11 +3401,13 @@ public enum Operations { self.path = path } } + @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// Creates a new `Ok`. public init() {} } + /// OK /// /// - Remark: Generated from `#/paths//items/{itemname}/metadata/{namespace}/delete(removeMetadataFromItem)/responses/200`. @@ -3015,6 +3422,7 @@ public enum Operations { public static var ok: Self { .ok(.init()) } + /// The associated value of the enum case if `self` is `.ok`. /// /// - Throws: An error if `self` is not `.ok`. @@ -3023,7 +3431,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -3032,10 +3440,12 @@ public enum Operations { } } } + public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } + /// Item not found. /// /// - Remark: Generated from `#/paths//items/{itemname}/metadata/{namespace}/delete(removeMetadataFromItem)/responses/404`. @@ -3050,6 +3460,7 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } + /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -3058,7 +3469,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -3067,10 +3478,12 @@ public enum Operations { } } } + public struct MethodNotAllowed: Sendable, Hashable { /// Creates a new `MethodNotAllowed`. public init() {} } + /// Meta data not editable. /// /// - Remark: Generated from `#/paths//items/{itemname}/metadata/{namespace}/delete(removeMetadataFromItem)/responses/405`. @@ -3085,6 +3498,7 @@ public enum Operations { public static var methodNotAllowed: Self { .methodNotAllowed(.init()) } + /// The associated value of the enum case if `self` is `.methodNotAllowed`. /// /// - Throws: An error if `self` is not `.methodNotAllowed`. @@ -3093,7 +3507,7 @@ public enum Operations { get throws { switch self { case let .methodNotAllowed(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "methodNotAllowed", @@ -3102,12 +3516,14 @@ public enum Operations { } } } + /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } } + /// Adds a tag to an item. /// /// - Remark: HTTP `PUT /items/{itemname}/tags/{tag}`. @@ -3130,14 +3546,13 @@ public enum Operations { /// - Parameters: /// - itemname: item name /// - tag: tag - public init( - itemname: Swift.String, - tag: Swift.String - ) { + public init(itemname: Swift.String, + tag: Swift.String) { self.itemname = itemname self.tag = tag } } + public var path: Operations.addTagToItem.Input.Path /// Creates a new `Input`. /// @@ -3147,11 +3562,13 @@ public enum Operations { self.path = path } } + @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// Creates a new `Ok`. public init() {} } + /// OK /// /// - Remark: Generated from `#/paths//items/{itemname}/tags/{tag}/put(addTagToItem)/responses/200`. @@ -3166,6 +3583,7 @@ public enum Operations { public static var ok: Self { .ok(.init()) } + /// The associated value of the enum case if `self` is `.ok`. /// /// - Throws: An error if `self` is not `.ok`. @@ -3174,7 +3592,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -3183,10 +3601,12 @@ public enum Operations { } } } + public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } + /// Item not found. /// /// - Remark: Generated from `#/paths//items/{itemname}/tags/{tag}/put(addTagToItem)/responses/404`. @@ -3201,6 +3621,7 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } + /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -3209,7 +3630,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -3218,10 +3639,12 @@ public enum Operations { } } } + public struct MethodNotAllowed: Sendable, Hashable { /// Creates a new `MethodNotAllowed`. public init() {} } + /// Item not editable. /// /// - Remark: Generated from `#/paths//items/{itemname}/tags/{tag}/put(addTagToItem)/responses/405`. @@ -3236,6 +3659,7 @@ public enum Operations { public static var methodNotAllowed: Self { .methodNotAllowed(.init()) } + /// The associated value of the enum case if `self` is `.methodNotAllowed`. /// /// - Throws: An error if `self` is not `.methodNotAllowed`. @@ -3244,7 +3668,7 @@ public enum Operations { get throws { switch self { case let .methodNotAllowed(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "methodNotAllowed", @@ -3253,12 +3677,14 @@ public enum Operations { } } } + /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } } + /// Removes a tag from an item. /// /// - Remark: HTTP `DELETE /items/{itemname}/tags/{tag}`. @@ -3281,14 +3707,13 @@ public enum Operations { /// - Parameters: /// - itemname: item name /// - tag: tag - public init( - itemname: Swift.String, - tag: Swift.String - ) { + public init(itemname: Swift.String, + tag: Swift.String) { self.itemname = itemname self.tag = tag } } + public var path: Operations.removeTagFromItem.Input.Path /// Creates a new `Input`. /// @@ -3298,11 +3723,13 @@ public enum Operations { self.path = path } } + @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// Creates a new `Ok`. public init() {} } + /// OK /// /// - Remark: Generated from `#/paths//items/{itemname}/tags/{tag}/delete(removeTagFromItem)/responses/200`. @@ -3317,6 +3744,7 @@ public enum Operations { public static var ok: Self { .ok(.init()) } + /// The associated value of the enum case if `self` is `.ok`. /// /// - Throws: An error if `self` is not `.ok`. @@ -3325,7 +3753,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -3334,10 +3762,12 @@ public enum Operations { } } } + public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } + /// Item not found. /// /// - Remark: Generated from `#/paths//items/{itemname}/tags/{tag}/delete(removeTagFromItem)/responses/404`. @@ -3352,6 +3782,7 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } + /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -3360,7 +3791,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -3369,10 +3800,12 @@ public enum Operations { } } } + public struct MethodNotAllowed: Sendable, Hashable { /// Creates a new `MethodNotAllowed`. public init() {} } + /// Item not editable. /// /// - Remark: Generated from `#/paths//items/{itemname}/tags/{tag}/delete(removeTagFromItem)/responses/405`. @@ -3387,6 +3820,7 @@ public enum Operations { public static var methodNotAllowed: Self { .methodNotAllowed(.init()) } + /// The associated value of the enum case if `self` is `.methodNotAllowed`. /// /// - Throws: An error if `self` is not `.methodNotAllowed`. @@ -3395,7 +3829,7 @@ public enum Operations { get throws { switch self { case let .methodNotAllowed(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "methodNotAllowed", @@ -3404,12 +3838,14 @@ public enum Operations { } } } + /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } } + /// Gets a single item. /// /// - Remark: HTTP `GET /items/{itemname}`. @@ -3431,6 +3867,7 @@ public enum Operations { self.itemname = itemname } } + public var path: Operations.getItemByName.Input.Path /// - Remark: Generated from `#/paths/items/{itemname}/GET/query`. public struct Query: Sendable, Hashable { @@ -3447,14 +3884,13 @@ public enum Operations { /// - Parameters: /// - metadata: metadata selector - a comma separated list or a regular expression (returns all if no value given) /// - recursive: get member items if the item is a group item - public init( - metadata: Swift.String? = nil, - recursive: Swift.Bool? = nil - ) { + public init(metadata: Swift.String? = nil, + recursive: Swift.Bool? = nil) { self.metadata = metadata self.recursive = recursive } } + public var query: Operations.getItemByName.Input.Query /// - Remark: Generated from `#/paths/items/{itemname}/GET/header`. public struct Headers: Sendable, Hashable { @@ -3468,14 +3904,13 @@ public enum Operations { /// - Parameters: /// - Accept_hyphen_Language: language /// - accept: - public init( - Accept_hyphen_Language: Swift.String? = nil, - accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues() - ) { + public init(Accept_hyphen_Language: Swift.String? = nil, + accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { self.Accept_hyphen_Language = Accept_hyphen_Language self.accept = accept } } + public var headers: Operations.getItemByName.Input.Headers /// Creates a new `Input`. /// @@ -3483,16 +3918,15 @@ public enum Operations { /// - path: /// - query: /// - headers: - public init( - path: Operations.getItemByName.Input.Path, - query: Operations.getItemByName.Input.Query = .init(), - headers: Operations.getItemByName.Input.Headers = .init() - ) { + public init(path: Operations.getItemByName.Input.Path, + query: Operations.getItemByName.Input.Query = .init(), + headers: Operations.getItemByName.Input.Headers = .init()) { self.path = path self.query = query self.headers = headers } } + @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/items/{itemname}/GET/responses/200/content`. @@ -3507,11 +3941,12 @@ public enum Operations { get throws { switch self { case let .json(body): - return body + body } } } } + /// Received HTTP response body public var body: Operations.getItemByName.Output.Ok.Body /// Creates a new `Ok`. @@ -3522,6 +3957,7 @@ public enum Operations { self.body = body } } + /// OK /// /// - Remark: Generated from `#/paths//items/{itemname}/get(getItemByName)/responses/200`. @@ -3536,7 +3972,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -3545,10 +3981,12 @@ public enum Operations { } } } + public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } + /// Item not found /// /// - Remark: Generated from `#/paths//items/{itemname}/get(getItemByName)/responses/404`. @@ -3563,6 +4001,7 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } + /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -3571,7 +4010,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -3580,11 +4019,13 @@ public enum Operations { } } } + /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } + @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -3596,14 +4037,16 @@ public enum Operations { self = .other(rawValue) } } + public var rawValue: Swift.String { switch self { case let .other(string): - return string + string case .json: - return "application/json" + "application/json" } } + public static var allCases: [Self] { [ .json @@ -3611,6 +4054,7 @@ public enum Operations { } } } + /// Sends a command to an item. /// /// - Remark: HTTP `POST /items/{itemname}`. @@ -3632,31 +4076,33 @@ public enum Operations { self.itemname = itemname } } + public var path: Operations.sendItemCommand.Input.Path /// - Remark: Generated from `#/paths/items/{itemname}/POST/requestBody`. @frozen public enum Body: Sendable, Hashable { /// - Remark: Generated from `#/paths/items/{itemname}/POST/requestBody/content/text\/plain`. case plainText(OpenAPIRuntime.HTTPBody) } + public var body: Operations.sendItemCommand.Input.Body /// Creates a new `Input`. /// /// - Parameters: /// - path: /// - body: - public init( - path: Operations.sendItemCommand.Input.Path, - body: Operations.sendItemCommand.Input.Body - ) { + public init(path: Operations.sendItemCommand.Input.Path, + body: Operations.sendItemCommand.Input.Body) { self.path = path self.body = body } } + @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// Creates a new `Ok`. public init() {} } + /// OK /// /// - Remark: Generated from `#/paths//items/{itemname}/post(sendItemCommand)/responses/200`. @@ -3671,6 +4117,7 @@ public enum Operations { public static var ok: Self { .ok(.init()) } + /// The associated value of the enum case if `self` is `.ok`. /// /// - Throws: An error if `self` is not `.ok`. @@ -3679,7 +4126,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -3688,10 +4135,12 @@ public enum Operations { } } } + public struct BadRequest: Sendable, Hashable { /// Creates a new `BadRequest`. public init() {} } + /// Item command null /// /// - Remark: Generated from `#/paths//items/{itemname}/post(sendItemCommand)/responses/400`. @@ -3706,6 +4155,7 @@ public enum Operations { public static var badRequest: Self { .badRequest(.init()) } + /// The associated value of the enum case if `self` is `.badRequest`. /// /// - Throws: An error if `self` is not `.badRequest`. @@ -3714,7 +4164,7 @@ public enum Operations { get throws { switch self { case let .badRequest(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "badRequest", @@ -3723,10 +4173,12 @@ public enum Operations { } } } + public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } + /// Item not found /// /// - Remark: Generated from `#/paths//items/{itemname}/post(sendItemCommand)/responses/404`. @@ -3741,6 +4193,7 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } + /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -3749,7 +4202,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -3758,12 +4211,14 @@ public enum Operations { } } } + /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } } + /// Adds a new item to the registry or updates the existing item. /// /// - Remark: HTTP `PUT /items/{itemname}`. @@ -3785,6 +4240,7 @@ public enum Operations { self.itemname = itemname } } + public var path: Operations.addOrUpdateItemInRegistry.Input.Path /// - Remark: Generated from `#/paths/items/{itemname}/PUT/header`. public struct Headers: Sendable, Hashable { @@ -3798,20 +4254,20 @@ public enum Operations { /// - Parameters: /// - Accept_hyphen_Language: language /// - accept: - public init( - Accept_hyphen_Language: Swift.String? = nil, - accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues() - ) { + public init(Accept_hyphen_Language: Swift.String? = nil, + accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { self.Accept_hyphen_Language = Accept_hyphen_Language self.accept = accept } } + public var headers: Operations.addOrUpdateItemInRegistry.Input.Headers /// - Remark: Generated from `#/paths/items/{itemname}/PUT/requestBody`. @frozen public enum Body: Sendable, Hashable { /// - Remark: Generated from `#/paths/items/{itemname}/PUT/requestBody/content/application\/json`. case json(Components.Schemas.GroupItemDTO) } + public var body: Operations.addOrUpdateItemInRegistry.Input.Body /// Creates a new `Input`. /// @@ -3819,16 +4275,15 @@ public enum Operations { /// - path: /// - headers: /// - body: - public init( - path: Operations.addOrUpdateItemInRegistry.Input.Path, - headers: Operations.addOrUpdateItemInRegistry.Input.Headers = .init(), - body: Operations.addOrUpdateItemInRegistry.Input.Body - ) { + public init(path: Operations.addOrUpdateItemInRegistry.Input.Path, + headers: Operations.addOrUpdateItemInRegistry.Input.Headers = .init(), + body: Operations.addOrUpdateItemInRegistry.Input.Body) { self.path = path self.headers = headers self.body = body } } + @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/items/{itemname}/PUT/responses/200/content`. @@ -3843,11 +4298,12 @@ public enum Operations { get throws { switch self { case let .any(body): - return body + body } } } } + /// Received HTTP response body public var body: Operations.addOrUpdateItemInRegistry.Output.Ok.Body /// Creates a new `Ok`. @@ -3858,6 +4314,7 @@ public enum Operations { self.body = body } } + /// OK /// /// - Remark: Generated from `#/paths//items/{itemname}/put(addOrUpdateItemInRegistry)/responses/200`. @@ -3872,7 +4329,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -3881,10 +4338,12 @@ public enum Operations { } } } + public struct Created: Sendable, Hashable { /// Creates a new `Created`. public init() {} } + /// Item created. /// /// - Remark: Generated from `#/paths//items/{itemname}/put(addOrUpdateItemInRegistry)/responses/201`. @@ -3899,6 +4358,7 @@ public enum Operations { public static var created: Self { .created(.init()) } + /// The associated value of the enum case if `self` is `.created`. /// /// - Throws: An error if `self` is not `.created`. @@ -3907,7 +4367,7 @@ public enum Operations { get throws { switch self { case let .created(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "created", @@ -3916,10 +4376,12 @@ public enum Operations { } } } + public struct BadRequest: Sendable, Hashable { /// Creates a new `BadRequest`. public init() {} } + /// Payload invalid. /// /// - Remark: Generated from `#/paths//items/{itemname}/put(addOrUpdateItemInRegistry)/responses/400`. @@ -3934,6 +4396,7 @@ public enum Operations { public static var badRequest: Self { .badRequest(.init()) } + /// The associated value of the enum case if `self` is `.badRequest`. /// /// - Throws: An error if `self` is not `.badRequest`. @@ -3942,7 +4405,7 @@ public enum Operations { get throws { switch self { case let .badRequest(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "badRequest", @@ -3951,10 +4414,12 @@ public enum Operations { } } } + public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } + /// Item not found or name in path invalid. /// /// - Remark: Generated from `#/paths//items/{itemname}/put(addOrUpdateItemInRegistry)/responses/404`. @@ -3969,6 +4434,7 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } + /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -3977,7 +4443,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -3986,10 +4452,12 @@ public enum Operations { } } } + public struct MethodNotAllowed: Sendable, Hashable { /// Creates a new `MethodNotAllowed`. public init() {} } + /// Item not editable. /// /// - Remark: Generated from `#/paths//items/{itemname}/put(addOrUpdateItemInRegistry)/responses/405`. @@ -4004,6 +4472,7 @@ public enum Operations { public static var methodNotAllowed: Self { .methodNotAllowed(.init()) } + /// The associated value of the enum case if `self` is `.methodNotAllowed`. /// /// - Throws: An error if `self` is not `.methodNotAllowed`. @@ -4012,7 +4481,7 @@ public enum Operations { get throws { switch self { case let .methodNotAllowed(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "methodNotAllowed", @@ -4021,11 +4490,13 @@ public enum Operations { } } } + /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } + @frozen public enum AcceptableContentType: AcceptableProtocol { case any case other(Swift.String) @@ -4037,14 +4508,16 @@ public enum Operations { self = .other(rawValue) } } + public var rawValue: Swift.String { switch self { case let .other(string): - return string + string case .any: - return "*/*" + "*/*" } } + public static var allCases: [Self] { [ .any @@ -4052,6 +4525,7 @@ public enum Operations { } } } + /// Removes an item from the registry. /// /// - Remark: HTTP `DELETE /items/{itemname}`. @@ -4073,6 +4547,7 @@ public enum Operations { self.itemname = itemname } } + public var path: Operations.removeItemFromRegistry.Input.Path /// Creates a new `Input`. /// @@ -4082,11 +4557,13 @@ public enum Operations { self.path = path } } + @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// Creates a new `Ok`. public init() {} } + /// OK /// /// - Remark: Generated from `#/paths//items/{itemname}/delete(removeItemFromRegistry)/responses/200`. @@ -4101,6 +4578,7 @@ public enum Operations { public static var ok: Self { .ok(.init()) } + /// The associated value of the enum case if `self` is `.ok`. /// /// - Throws: An error if `self` is not `.ok`. @@ -4109,7 +4587,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -4118,10 +4596,12 @@ public enum Operations { } } } + public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } + /// Item not found or item is not editable. /// /// - Remark: Generated from `#/paths//items/{itemname}/delete(removeItemFromRegistry)/responses/404`. @@ -4136,6 +4616,7 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } + /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -4144,7 +4625,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -4153,12 +4634,14 @@ public enum Operations { } } } + /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } } + /// Get all available items. /// /// - Remark: HTTP `GET /items`. @@ -4201,14 +4684,12 @@ public enum Operations { /// - recursive: get member items recursively /// - fields: limit output to the given fields (comma separated) /// - staticDataOnly: provides a cacheable list of values not expected to change regularly and checks the If-Modified-Since header, all other parameters are ignored except "metadata" - public init( - _type: Swift.String? = nil, - tags: Swift.String? = nil, - metadata: Swift.String? = nil, - recursive: Swift.Bool? = nil, - fields: Swift.String? = nil, - staticDataOnly: Swift.Bool? = nil - ) { + public init(_type: Swift.String? = nil, + tags: Swift.String? = nil, + metadata: Swift.String? = nil, + recursive: Swift.Bool? = nil, + fields: Swift.String? = nil, + staticDataOnly: Swift.Bool? = nil) { self._type = _type self.tags = tags self.metadata = metadata @@ -4217,6 +4698,7 @@ public enum Operations { self.staticDataOnly = staticDataOnly } } + public var query: Operations.getItems.Input.Query /// - Remark: Generated from `#/paths/items/GET/header`. public struct Headers: Sendable, Hashable { @@ -4230,28 +4712,26 @@ public enum Operations { /// - Parameters: /// - Accept_hyphen_Language: language /// - accept: - public init( - Accept_hyphen_Language: Swift.String? = nil, - accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues() - ) { + public init(Accept_hyphen_Language: Swift.String? = nil, + accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { self.Accept_hyphen_Language = Accept_hyphen_Language self.accept = accept } } + public var headers: Operations.getItems.Input.Headers /// Creates a new `Input`. /// /// - Parameters: /// - query: /// - headers: - public init( - query: Operations.getItems.Input.Query = .init(), - headers: Operations.getItems.Input.Headers = .init() - ) { + public init(query: Operations.getItems.Input.Query = .init(), + headers: Operations.getItems.Input.Headers = .init()) { self.query = query self.headers = headers } } + @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/items/GET/responses/200/content`. @@ -4266,11 +4746,12 @@ public enum Operations { get throws { switch self { case let .json(body): - return body + body } } } } + /// Received HTTP response body public var body: Operations.getItems.Output.Ok.Body /// Creates a new `Ok`. @@ -4281,6 +4762,7 @@ public enum Operations { self.body = body } } + /// OK /// /// - Remark: Generated from `#/paths//items/get(getItems)/responses/200`. @@ -4295,7 +4777,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -4304,11 +4786,13 @@ public enum Operations { } } } + /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } + @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -4320,14 +4804,16 @@ public enum Operations { self = .other(rawValue) } } + public var rawValue: Swift.String { switch self { case let .other(string): - return string + string case .json: - return "application/json" + "application/json" } } + public static var allCases: [Self] { [ .json @@ -4335,6 +4821,7 @@ public enum Operations { } } } + /// Adds a list of items to the registry or updates the existing items. /// /// - Remark: HTTP `PUT /items`. @@ -4353,26 +4840,27 @@ public enum Operations { self.accept = accept } } + public var headers: Operations.addOrUpdateItemsInRegistry.Input.Headers /// - Remark: Generated from `#/paths/items/PUT/requestBody`. @frozen public enum Body: Sendable, Hashable { /// - Remark: Generated from `#/paths/items/PUT/requestBody/content/application\/json`. case json([Components.Schemas.GroupItemDTO]) } + public var body: Operations.addOrUpdateItemsInRegistry.Input.Body /// Creates a new `Input`. /// /// - Parameters: /// - headers: /// - body: - public init( - headers: Operations.addOrUpdateItemsInRegistry.Input.Headers = .init(), - body: Operations.addOrUpdateItemsInRegistry.Input.Body - ) { + public init(headers: Operations.addOrUpdateItemsInRegistry.Input.Headers = .init(), + body: Operations.addOrUpdateItemsInRegistry.Input.Body) { self.headers = headers self.body = body } } + @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/items/PUT/responses/200/content`. @@ -4387,11 +4875,12 @@ public enum Operations { get throws { switch self { case let .any(body): - return body + body } } } } + /// Received HTTP response body public var body: Operations.addOrUpdateItemsInRegistry.Output.Ok.Body /// Creates a new `Ok`. @@ -4402,6 +4891,7 @@ public enum Operations { self.body = body } } + /// OK /// /// - Remark: Generated from `#/paths//items/put(addOrUpdateItemsInRegistry)/responses/200`. @@ -4416,7 +4906,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -4425,10 +4915,12 @@ public enum Operations { } } } + public struct BadRequest: Sendable, Hashable { /// Creates a new `BadRequest`. public init() {} } + /// Payload is invalid. /// /// - Remark: Generated from `#/paths//items/put(addOrUpdateItemsInRegistry)/responses/400`. @@ -4443,6 +4935,7 @@ public enum Operations { public static var badRequest: Self { .badRequest(.init()) } + /// The associated value of the enum case if `self` is `.badRequest`. /// /// - Throws: An error if `self` is not `.badRequest`. @@ -4451,7 +4944,7 @@ public enum Operations { get throws { switch self { case let .badRequest(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "badRequest", @@ -4460,11 +4953,13 @@ public enum Operations { } } } + /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } + @frozen public enum AcceptableContentType: AcceptableProtocol { case any case other(Swift.String) @@ -4476,14 +4971,16 @@ public enum Operations { self = .other(rawValue) } } + public var rawValue: Swift.String { switch self { case let .other(string): - return string + string case .any: - return "*/*" + "*/*" } } + public static var allCases: [Self] { [ .any @@ -4491,6 +4988,7 @@ public enum Operations { } } } + /// Gets the state of an item. /// /// - Remark: HTTP `GET /items/{itemname}/state`. @@ -4512,6 +5010,7 @@ public enum Operations { self.itemname = itemname } } + public var path: Operations.getItemState_1.Input.Path /// - Remark: Generated from `#/paths/items/{itemname}/state/GET/header`. public struct Headers: Sendable, Hashable { @@ -4524,20 +5023,20 @@ public enum Operations { self.accept = accept } } + public var headers: Operations.getItemState_1.Input.Headers /// Creates a new `Input`. /// /// - Parameters: /// - path: /// - headers: - public init( - path: Operations.getItemState_1.Input.Path, - headers: Operations.getItemState_1.Input.Headers = .init() - ) { + public init(path: Operations.getItemState_1.Input.Path, + headers: Operations.getItemState_1.Input.Headers = .init()) { self.path = path self.headers = headers } } + @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/items/{itemname}/state/GET/responses/200/content`. @@ -4552,11 +5051,12 @@ public enum Operations { get throws { switch self { case let .plainText(body): - return body + body } } } } + /// Received HTTP response body public var body: Operations.getItemState_1.Output.Ok.Body /// Creates a new `Ok`. @@ -4567,6 +5067,7 @@ public enum Operations { self.body = body } } + /// OK /// /// - Remark: Generated from `#/paths//items/{itemname}/state/get(getItemState_1)/responses/200`. @@ -4581,7 +5082,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -4590,10 +5091,12 @@ public enum Operations { } } } + public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } + /// Item not found /// /// - Remark: Generated from `#/paths//items/{itemname}/state/get(getItemState_1)/responses/404`. @@ -4608,6 +5111,7 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } + /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -4616,7 +5120,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -4625,11 +5129,13 @@ public enum Operations { } } } + /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } + @frozen public enum AcceptableContentType: AcceptableProtocol { case plainText case other(Swift.String) @@ -4641,14 +5147,16 @@ public enum Operations { self = .other(rawValue) } } + public var rawValue: Swift.String { switch self { case let .other(string): - return string + string case .plainText: - return "text/plain" + "text/plain" } } + public static var allCases: [Self] { [ .plainText @@ -4656,6 +5164,7 @@ public enum Operations { } } } + /// Updates the state of an item. /// /// - Remark: HTTP `PUT /items/{itemname}/state`. @@ -4677,6 +5186,7 @@ public enum Operations { self.itemname = itemname } } + public var path: Operations.updateItemState.Input.Path /// - Remark: Generated from `#/paths/items/{itemname}/state/PUT/header`. public struct Headers: Sendable, Hashable { @@ -4692,12 +5202,14 @@ public enum Operations { self.Accept_hyphen_Language = Accept_hyphen_Language } } + public var headers: Operations.updateItemState.Input.Headers /// - Remark: Generated from `#/paths/items/{itemname}/state/PUT/requestBody`. @frozen public enum Body: Sendable, Hashable { /// - Remark: Generated from `#/paths/items/{itemname}/state/PUT/requestBody/content/text\/plain`. case plainText(OpenAPIRuntime.HTTPBody) } + public var body: Operations.updateItemState.Input.Body /// Creates a new `Input`. /// @@ -4705,21 +5217,21 @@ public enum Operations { /// - path: /// - headers: /// - body: - public init( - path: Operations.updateItemState.Input.Path, - headers: Operations.updateItemState.Input.Headers = .init(), - body: Operations.updateItemState.Input.Body - ) { + public init(path: Operations.updateItemState.Input.Path, + headers: Operations.updateItemState.Input.Headers = .init(), + body: Operations.updateItemState.Input.Body) { self.path = path self.headers = headers self.body = body } } + @frozen public enum Output: Sendable, Hashable { public struct Accepted: Sendable, Hashable { /// Creates a new `Accepted`. public init() {} } + /// Accepted /// /// - Remark: Generated from `#/paths//items/{itemname}/state/put(updateItemState)/responses/202`. @@ -4734,6 +5246,7 @@ public enum Operations { public static var accepted: Self { .accepted(.init()) } + /// The associated value of the enum case if `self` is `.accepted`. /// /// - Throws: An error if `self` is not `.accepted`. @@ -4742,7 +5255,7 @@ public enum Operations { get throws { switch self { case let .accepted(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "accepted", @@ -4751,10 +5264,12 @@ public enum Operations { } } } + public struct BadRequest: Sendable, Hashable { /// Creates a new `BadRequest`. public init() {} } + /// Item state null /// /// - Remark: Generated from `#/paths//items/{itemname}/state/put(updateItemState)/responses/400`. @@ -4769,6 +5284,7 @@ public enum Operations { public static var badRequest: Self { .badRequest(.init()) } + /// The associated value of the enum case if `self` is `.badRequest`. /// /// - Throws: An error if `self` is not `.badRequest`. @@ -4777,7 +5293,7 @@ public enum Operations { get throws { switch self { case let .badRequest(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "badRequest", @@ -4786,10 +5302,12 @@ public enum Operations { } } } + public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } + /// Item not found /// /// - Remark: Generated from `#/paths//items/{itemname}/state/put(updateItemState)/responses/404`. @@ -4804,6 +5322,7 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } + /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -4812,7 +5331,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -4821,12 +5340,14 @@ public enum Operations { } } } + /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } } + /// Gets the namespace of an item. /// /// - Remark: HTTP `GET /items/{itemname}/metadata/namespaces`. @@ -4848,6 +5369,7 @@ public enum Operations { self.itemname = itemname } } + public var path: Operations.getItemNamespaces.Input.Path /// - Remark: Generated from `#/paths/items/{itemname}/metadata/namespaces/GET/header`. public struct Headers: Sendable, Hashable { @@ -4861,28 +5383,26 @@ public enum Operations { /// - Parameters: /// - Accept_hyphen_Language: language /// - accept: - public init( - Accept_hyphen_Language: Swift.String? = nil, - accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues() - ) { + public init(Accept_hyphen_Language: Swift.String? = nil, + accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { self.Accept_hyphen_Language = Accept_hyphen_Language self.accept = accept } } + public var headers: Operations.getItemNamespaces.Input.Headers /// Creates a new `Input`. /// /// - Parameters: /// - path: /// - headers: - public init( - path: Operations.getItemNamespaces.Input.Path, - headers: Operations.getItemNamespaces.Input.Headers = .init() - ) { + public init(path: Operations.getItemNamespaces.Input.Path, + headers: Operations.getItemNamespaces.Input.Headers = .init()) { self.path = path self.headers = headers } } + @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/items/{itemname}/metadata/namespaces/GET/responses/200/content`. @@ -4897,11 +5417,12 @@ public enum Operations { get throws { switch self { case let .json(body): - return body + body } } } } + /// Received HTTP response body public var body: Operations.getItemNamespaces.Output.Ok.Body /// Creates a new `Ok`. @@ -4912,6 +5433,7 @@ public enum Operations { self.body = body } } + /// OK /// /// - Remark: Generated from `#/paths//items/{itemname}/metadata/namespaces/get(getItemNamespaces)/responses/200`. @@ -4926,7 +5448,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -4935,10 +5457,12 @@ public enum Operations { } } } + public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } + /// Item not found /// /// - Remark: Generated from `#/paths//items/{itemname}/metadata/namespaces/get(getItemNamespaces)/responses/404`. @@ -4953,6 +5477,7 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } + /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -4961,7 +5486,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -4970,11 +5495,13 @@ public enum Operations { } } } + /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } + @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -4986,14 +5513,16 @@ public enum Operations { self = .other(rawValue) } } + public var rawValue: Swift.String { switch self { case let .other(string): - return string + string case .json: - return "application/json" + "application/json" } } + public static var allCases: [Self] { [ .json @@ -5001,6 +5530,7 @@ public enum Operations { } } } + /// Gets the item which defines the requested semantics of an item. /// /// - Remark: HTTP `GET /items/{itemName}/semantic/{semanticClass}`. @@ -5023,14 +5553,13 @@ public enum Operations { /// - Parameters: /// - itemName: item name /// - semanticClass: semantic class - public init( - itemName: Swift.String, - semanticClass: Swift.String - ) { + public init(itemName: Swift.String, + semanticClass: Swift.String) { self.itemName = itemName self.semanticClass = semanticClass } } + public var path: Operations.getSemanticItem.Input.Path /// - Remark: Generated from `#/paths/items/{itemName}/semantic/{semanticClass}/GET/header`. public struct Headers: Sendable, Hashable { @@ -5046,25 +5575,26 @@ public enum Operations { self.Accept_hyphen_Language = Accept_hyphen_Language } } + public var headers: Operations.getSemanticItem.Input.Headers /// Creates a new `Input`. /// /// - Parameters: /// - path: /// - headers: - public init( - path: Operations.getSemanticItem.Input.Path, - headers: Operations.getSemanticItem.Input.Headers = .init() - ) { + public init(path: Operations.getSemanticItem.Input.Path, + headers: Operations.getSemanticItem.Input.Headers = .init()) { self.path = path self.headers = headers } } + @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// Creates a new `Ok`. public init() {} } + /// OK /// /// - Remark: Generated from `#/paths//items/{itemName}/semantic/{semanticClass}/get(getSemanticItem)/responses/200`. @@ -5079,6 +5609,7 @@ public enum Operations { public static var ok: Self { .ok(.init()) } + /// The associated value of the enum case if `self` is `.ok`. /// /// - Throws: An error if `self` is not `.ok`. @@ -5087,7 +5618,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -5096,10 +5627,12 @@ public enum Operations { } } } + public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } + /// Item not found /// /// - Remark: Generated from `#/paths//items/{itemName}/semantic/{semanticClass}/get(getSemanticItem)/responses/404`. @@ -5114,6 +5647,7 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } + /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -5122,7 +5656,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -5131,12 +5665,14 @@ public enum Operations { } } } + /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } } + /// Remove unused/orphaned metadata. /// /// - Remark: HTTP `POST /items/metadata/purge`. @@ -5147,11 +5683,13 @@ public enum Operations { /// Creates a new `Input`. public init() {} } + @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// Creates a new `Ok`. public init() {} } + /// OK /// /// - Remark: Generated from `#/paths//items/metadata/purge/post(purgeDatabase)/responses/200`. @@ -5166,6 +5704,7 @@ public enum Operations { public static var ok: Self { .ok(.init()) } + /// The associated value of the enum case if `self` is `.ok`. /// /// - Throws: An error if `self` is not `.ok`. @@ -5174,7 +5713,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -5183,118 +5722,89 @@ public enum Operations { } } } + /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } } - /// Creates a sitemap event subscription. + + /// Gets information about the runtime, the API version and links to resources. /// - /// - Remark: HTTP `POST /sitemaps/events/subscribe`. - /// - Remark: Generated from `#/paths//sitemaps/events/subscribe/post(createSitemapEventSubscription)`. - public enum createSitemapEventSubscription { - public static let id: Swift.String = "createSitemapEventSubscription" + /// - Remark: HTTP `GET //`. + /// - Remark: Generated from `#/paths////get(getRoot)`. + public enum getRoot { + public static let id: Swift.String = "getRoot" public struct Input: Sendable, Hashable { - /// - Remark: Generated from `#/paths/sitemaps/events/subscribe/POST/header`. + /// - Remark: Generated from `#/paths/GET/header`. public struct Headers: Sendable, Hashable { - public var accept: [OpenAPIRuntime.AcceptHeaderContentType] + public var accept: [OpenAPIRuntime.AcceptHeaderContentType] /// Creates a new `Headers`. /// /// - Parameters: /// - accept: - public init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + public init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { self.accept = accept } } - public var headers: Operations.createSitemapEventSubscription.Input.Headers + + public var headers: Operations.getRoot.Input.Headers /// Creates a new `Input`. /// /// - Parameters: /// - headers: - public init(headers: Operations.createSitemapEventSubscription.Input.Headers = .init()) { + public init(headers: Operations.getRoot.Input.Headers = .init()) { self.headers = headers } } + @frozen public enum Output: Sendable, Hashable { - public struct Created: Sendable, Hashable { - /// Creates a new `Created`. - public init() {} - } - /// Subscription created. - /// - /// - Remark: Generated from `#/paths//sitemaps/events/subscribe/post(createSitemapEventSubscription)/responses/201`. - /// - /// HTTP response code: `201 created`. - case created(Operations.createSitemapEventSubscription.Output.Created) - /// Subscription created. - /// - /// - Remark: Generated from `#/paths//sitemaps/events/subscribe/post(createSitemapEventSubscription)/responses/201`. - /// - /// HTTP response code: `201 created`. - public static var created: Self { - .created(.init()) - } - /// The associated value of the enum case if `self` is `.created`. - /// - /// - Throws: An error if `self` is not `.created`. - /// - SeeAlso: `.created`. - public var created: Operations.createSitemapEventSubscription.Output.Created { - get throws { - switch self { - case let .created(response): - return response - default: - try throwUnexpectedResponseStatus( - expectedStatus: "created", - response: self - ) - } - } - } public struct Ok: Sendable, Hashable { - /// - Remark: Generated from `#/paths/sitemaps/events/subscribe/POST/responses/200/content`. + /// - Remark: Generated from `#/paths/GET/responses/200/content`. @frozen public enum Body: Sendable, Hashable { - /// - Remark: Generated from `#/paths/sitemaps/events/subscribe/POST/responses/200/content/application\/json`. - case json(Components.Schemas.JerseyResponseBuilderDTO) + /// - Remark: Generated from `#/paths/GET/responses/200/content/application\/json`. + case json(Components.Schemas.RootBean) /// The associated value of the enum case if `self` is `.json`. /// /// - Throws: An error if `self` is not `.json`. /// - SeeAlso: `.json`. - public var json: Components.Schemas.JerseyResponseBuilderDTO { + public var json: Components.Schemas.RootBean { get throws { switch self { case let .json(body): - return body + body } } } } + /// Received HTTP response body - public var body: Operations.createSitemapEventSubscription.Output.Ok.Body + public var body: Operations.getRoot.Output.Ok.Body /// Creates a new `Ok`. /// /// - Parameters: /// - body: Received HTTP response body - public init(body: Operations.createSitemapEventSubscription.Output.Ok.Body) { + public init(body: Operations.getRoot.Output.Ok.Body) { self.body = body } } + /// OK /// - /// - Remark: Generated from `#/paths//sitemaps/events/subscribe/post(createSitemapEventSubscription)/responses/200`. + /// - Remark: Generated from `#/paths////get(getRoot)/responses/200`. /// /// HTTP response code: `200 ok`. - case ok(Operations.createSitemapEventSubscription.Output.Ok) + case ok(Operations.getRoot.Output.Ok) /// The associated value of the enum case if `self` is `.ok`. /// /// - Throws: An error if `self` is not `.ok`. /// - SeeAlso: `.ok`. - public var ok: Operations.createSitemapEventSubscription.Output.Ok { + public var ok: Operations.getRoot.Output.Ok { get throws { switch self { case let .ok(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -5303,10 +5813,407 @@ public enum Operations { } } } - public struct ServiceUnavailable: Sendable, Hashable { + + /// Undocumented response. + /// + /// A response with a code that is not documented in the OpenAPI document. + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + + @frozen public enum AcceptableContentType: AcceptableProtocol { + case json + case other(Swift.String) + public init?(rawValue: Swift.String) { + switch rawValue.lowercased() { + case "application/json": + self = .json + default: + self = .other(rawValue) + } + } + + public var rawValue: Swift.String { + switch self { + case let .other(string): + string + case .json: + "application/json" + } + } + + public static var allCases: [Self] { + [ + .json + ] + } + } + } + + /// Gets information about the system. + /// + /// - Remark: HTTP `GET /systeminfo`. + /// - Remark: Generated from `#/paths//systeminfo/get(getSystemInformation)`. + public enum getSystemInformation { + public static let id: Swift.String = "getSystemInformation" + public struct Input: Sendable, Hashable { + /// - Remark: Generated from `#/paths/systeminfo/GET/header`. + public struct Headers: Sendable, Hashable { + public var accept: [OpenAPIRuntime.AcceptHeaderContentType] + /// Creates a new `Headers`. + /// + /// - Parameters: + /// - accept: + public init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + self.accept = accept + } + } + + public var headers: Operations.getSystemInformation.Input.Headers + /// Creates a new `Input`. + /// + /// - Parameters: + /// - headers: + public init(headers: Operations.getSystemInformation.Input.Headers = .init()) { + self.headers = headers + } + } + + @frozen public enum Output: Sendable, Hashable { + public struct Ok: Sendable, Hashable { + /// - Remark: Generated from `#/paths/systeminfo/GET/responses/200/content`. + @frozen public enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/systeminfo/GET/responses/200/content/application\/json`. + case json(Components.Schemas.SystemInfoBean) + /// The associated value of the enum case if `self` is `.json`. + /// + /// - Throws: An error if `self` is not `.json`. + /// - SeeAlso: `.json`. + public var json: Components.Schemas.SystemInfoBean { + get throws { + switch self { + case let .json(body): + body + } + } + } + } + + /// Received HTTP response body + public var body: Operations.getSystemInformation.Output.Ok.Body + /// Creates a new `Ok`. + /// + /// - Parameters: + /// - body: Received HTTP response body + public init(body: Operations.getSystemInformation.Output.Ok.Body) { + self.body = body + } + } + + /// OK + /// + /// - Remark: Generated from `#/paths//systeminfo/get(getSystemInformation)/responses/200`. + /// + /// HTTP response code: `200 ok`. + case ok(Operations.getSystemInformation.Output.Ok) + /// The associated value of the enum case if `self` is `.ok`. + /// + /// - Throws: An error if `self` is not `.ok`. + /// - SeeAlso: `.ok`. + public var ok: Operations.getSystemInformation.Output.Ok { + get throws { + switch self { + case let .ok(response): + response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "ok", + response: self + ) + } + } + } + + /// Undocumented response. + /// + /// A response with a code that is not documented in the OpenAPI document. + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + + @frozen public enum AcceptableContentType: AcceptableProtocol { + case json + case other(Swift.String) + public init?(rawValue: Swift.String) { + switch rawValue.lowercased() { + case "application/json": + self = .json + default: + self = .other(rawValue) + } + } + + public var rawValue: Swift.String { + switch self { + case let .other(string): + string + case .json: + "application/json" + } + } + + public static var allCases: [Self] { + [ + .json + ] + } + } + } + + /// Get all supported dimensions and their system units. + /// + /// - Remark: HTTP `GET /systeminfo/uom`. + /// - Remark: Generated from `#/paths//systeminfo/uom/get(getUoMInformation)`. + public enum getUoMInformation { + public static let id: Swift.String = "getUoMInformation" + public struct Input: Sendable, Hashable { + /// - Remark: Generated from `#/paths/systeminfo/uom/GET/header`. + public struct Headers: Sendable, Hashable { + public var accept: [OpenAPIRuntime.AcceptHeaderContentType] + /// Creates a new `Headers`. + /// + /// - Parameters: + /// - accept: + public init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + self.accept = accept + } + } + + public var headers: Operations.getUoMInformation.Input.Headers + /// Creates a new `Input`. + /// + /// - Parameters: + /// - headers: + public init(headers: Operations.getUoMInformation.Input.Headers = .init()) { + self.headers = headers + } + } + + @frozen public enum Output: Sendable, Hashable { + public struct Ok: Sendable, Hashable { + /// - Remark: Generated from `#/paths/systeminfo/uom/GET/responses/200/content`. + @frozen public enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/systeminfo/uom/GET/responses/200/content/application\/json`. + case json(Components.Schemas.UoMInfoBean) + /// The associated value of the enum case if `self` is `.json`. + /// + /// - Throws: An error if `self` is not `.json`. + /// - SeeAlso: `.json`. + public var json: Components.Schemas.UoMInfoBean { + get throws { + switch self { + case let .json(body): + body + } + } + } + } + + /// Received HTTP response body + public var body: Operations.getUoMInformation.Output.Ok.Body + /// Creates a new `Ok`. + /// + /// - Parameters: + /// - body: Received HTTP response body + public init(body: Operations.getUoMInformation.Output.Ok.Body) { + self.body = body + } + } + + /// OK + /// + /// - Remark: Generated from `#/paths//systeminfo/uom/get(getUoMInformation)/responses/200`. + /// + /// HTTP response code: `200 ok`. + case ok(Operations.getUoMInformation.Output.Ok) + /// The associated value of the enum case if `self` is `.ok`. + /// + /// - Throws: An error if `self` is not `.ok`. + /// - SeeAlso: `.ok`. + public var ok: Operations.getUoMInformation.Output.Ok { + get throws { + switch self { + case let .ok(response): + response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "ok", + response: self + ) + } + } + } + + /// Undocumented response. + /// + /// A response with a code that is not documented in the OpenAPI document. + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + + @frozen public enum AcceptableContentType: AcceptableProtocol { + case json + case other(Swift.String) + public init?(rawValue: Swift.String) { + switch rawValue.lowercased() { + case "application/json": + self = .json + default: + self = .other(rawValue) + } + } + + public var rawValue: Swift.String { + switch self { + case let .other(string): + string + case .json: + "application/json" + } + } + + public static var allCases: [Self] { + [ + .json + ] + } + } + } + + /// Creates a sitemap event subscription. + /// + /// - Remark: HTTP `POST /sitemaps/events/subscribe`. + /// - Remark: Generated from `#/paths//sitemaps/events/subscribe/post(createSitemapEventSubscription)`. + public enum createSitemapEventSubscription { + public static let id: Swift.String = "createSitemapEventSubscription" + public struct Input: Sendable, Hashable { + /// - Remark: Generated from `#/paths/sitemaps/events/subscribe/POST/header`. + public struct Headers: Sendable, Hashable { + public var accept: [OpenAPIRuntime.AcceptHeaderContentType] + /// Creates a new `Headers`. + /// + /// - Parameters: + /// - accept: + public init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + self.accept = accept + } + } + + public var headers: Operations.createSitemapEventSubscription.Input.Headers + /// Creates a new `Input`. + /// + /// - Parameters: + /// - headers: + public init(headers: Operations.createSitemapEventSubscription.Input.Headers = .init()) { + self.headers = headers + } + } + + @frozen public enum Output: Sendable, Hashable { + public struct Created: Sendable, Hashable { + /// Creates a new `Created`. + public init() {} + } + + /// Subscription created. + /// + /// - Remark: Generated from `#/paths//sitemaps/events/subscribe/post(createSitemapEventSubscription)/responses/201`. + /// + /// HTTP response code: `201 created`. + case created(Operations.createSitemapEventSubscription.Output.Created) + /// Subscription created. + /// + /// - Remark: Generated from `#/paths//sitemaps/events/subscribe/post(createSitemapEventSubscription)/responses/201`. + /// + /// HTTP response code: `201 created`. + public static var created: Self { + .created(.init()) + } + + /// The associated value of the enum case if `self` is `.created`. + /// + /// - Throws: An error if `self` is not `.created`. + /// - SeeAlso: `.created`. + public var created: Operations.createSitemapEventSubscription.Output.Created { + get throws { + switch self { + case let .created(response): + response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "created", + response: self + ) + } + } + } + + public struct Ok: Sendable, Hashable { + /// - Remark: Generated from `#/paths/sitemaps/events/subscribe/POST/responses/200/content`. + @frozen public enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/sitemaps/events/subscribe/POST/responses/200/content/application\/json`. + case json(Components.Schemas.JerseyResponseBuilderDTO) + /// The associated value of the enum case if `self` is `.json`. + /// + /// - Throws: An error if `self` is not `.json`. + /// - SeeAlso: `.json`. + public var json: Components.Schemas.JerseyResponseBuilderDTO { + get throws { + switch self { + case let .json(body): + body + } + } + } + } + + /// Received HTTP response body + public var body: Operations.createSitemapEventSubscription.Output.Ok.Body + /// Creates a new `Ok`. + /// + /// - Parameters: + /// - body: Received HTTP response body + public init(body: Operations.createSitemapEventSubscription.Output.Ok.Body) { + self.body = body + } + } + + /// OK + /// + /// - Remark: Generated from `#/paths//sitemaps/events/subscribe/post(createSitemapEventSubscription)/responses/200`. + /// + /// HTTP response code: `200 ok`. + case ok(Operations.createSitemapEventSubscription.Output.Ok) + /// The associated value of the enum case if `self` is `.ok`. + /// + /// - Throws: An error if `self` is not `.ok`. + /// - SeeAlso: `.ok`. + public var ok: Operations.createSitemapEventSubscription.Output.Ok { + get throws { + switch self { + case let .ok(response): + response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "ok", + response: self + ) + } + } + } + + public struct ServiceUnavailable: Sendable, Hashable { /// Creates a new `ServiceUnavailable`. public init() {} } + /// Subscriptions limit reached. /// /// - Remark: Generated from `#/paths//sitemaps/events/subscribe/post(createSitemapEventSubscription)/responses/503`. @@ -5321,6 +6228,7 @@ public enum Operations { public static var serviceUnavailable: Self { .serviceUnavailable(.init()) } + /// The associated value of the enum case if `self` is `.serviceUnavailable`. /// /// - Throws: An error if `self` is not `.serviceUnavailable`. @@ -5329,7 +6237,7 @@ public enum Operations { get throws { switch self { case let .serviceUnavailable(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "serviceUnavailable", @@ -5338,11 +6246,13 @@ public enum Operations { } } } + /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } + @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -5354,14 +6264,16 @@ public enum Operations { self = .other(rawValue) } } + public var rawValue: Swift.String { switch self { case let .other(string): - return string + string case .json: - return "application/json" + "application/json" } } + public static var allCases: [Self] { [ .json @@ -5369,6 +6281,7 @@ public enum Operations { } } } + /// Polls the data for one page of a sitemap. /// /// - Remark: HTTP `GET /sitemaps/{sitemapname}/{pageid}`. @@ -5391,14 +6304,13 @@ public enum Operations { /// - Parameters: /// - sitemapname: sitemap name /// - pageid: page id - public init( - sitemapname: Swift.String, - pageid: Swift.String - ) { + public init(sitemapname: Swift.String, + pageid: Swift.String) { self.sitemapname = sitemapname self.pageid = pageid } } + public var path: Operations.pollDataForPage.Input.Path /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/{pageid}/GET/query`. public struct Query: Sendable, Hashable { @@ -5415,14 +6327,13 @@ public enum Operations { /// - Parameters: /// - subscriptionid: subscriptionid /// - includeHidden: include hidden widgets - public init( - subscriptionid: Swift.String? = nil, - includeHidden: Swift.Bool? = nil - ) { + public init(subscriptionid: Swift.String? = nil, + includeHidden: Swift.Bool? = nil) { self.subscriptionid = subscriptionid self.includeHidden = includeHidden } } + public var query: Operations.pollDataForPage.Input.Query /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/{pageid}/GET/header`. public struct Headers: Sendable, Hashable { @@ -5441,16 +6352,15 @@ public enum Operations { /// - Accept_hyphen_Language: language /// - X_hyphen_Atmosphere_hyphen_Transport: X-Atmosphere-Transport for long polling /// - accept: - public init( - Accept_hyphen_Language: Swift.String? = nil, - X_hyphen_Atmosphere_hyphen_Transport: Swift.String? = nil, - accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues() - ) { + public init(Accept_hyphen_Language: Swift.String? = nil, + X_hyphen_Atmosphere_hyphen_Transport: Swift.String? = nil, + accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { self.Accept_hyphen_Language = Accept_hyphen_Language self.X_hyphen_Atmosphere_hyphen_Transport = X_hyphen_Atmosphere_hyphen_Transport self.accept = accept } } + public var headers: Operations.pollDataForPage.Input.Headers /// Creates a new `Input`. /// @@ -5458,16 +6368,15 @@ public enum Operations { /// - path: /// - query: /// - headers: - public init( - path: Operations.pollDataForPage.Input.Path, - query: Operations.pollDataForPage.Input.Query = .init(), - headers: Operations.pollDataForPage.Input.Headers = .init() - ) { + public init(path: Operations.pollDataForPage.Input.Path, + query: Operations.pollDataForPage.Input.Query = .init(), + headers: Operations.pollDataForPage.Input.Headers = .init()) { self.path = path self.query = query self.headers = headers } } + @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/{pageid}/GET/responses/200/content`. @@ -5482,11 +6391,12 @@ public enum Operations { get throws { switch self { case let .json(body): - return body + body } } } } + /// Received HTTP response body public var body: Operations.pollDataForPage.Output.Ok.Body /// Creates a new `Ok`. @@ -5497,6 +6407,7 @@ public enum Operations { self.body = body } } + /// OK /// /// - Remark: Generated from `#/paths//sitemaps/{sitemapname}/{pageid}/get(pollDataForPage)/responses/200`. @@ -5511,7 +6422,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -5520,10 +6431,12 @@ public enum Operations { } } } + public struct BadRequest: Sendable, Hashable { /// Creates a new `BadRequest`. public init() {} } + /// Invalid subscription id has been provided. /// /// - Remark: Generated from `#/paths//sitemaps/{sitemapname}/{pageid}/get(pollDataForPage)/responses/400`. @@ -5538,6 +6451,7 @@ public enum Operations { public static var badRequest: Self { .badRequest(.init()) } + /// The associated value of the enum case if `self` is `.badRequest`. /// /// - Throws: An error if `self` is not `.badRequest`. @@ -5546,7 +6460,7 @@ public enum Operations { get throws { switch self { case let .badRequest(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "badRequest", @@ -5555,10 +6469,12 @@ public enum Operations { } } } + public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } + /// Sitemap with requested name does not exist or page does not exist, or page refers to a non-linkable widget /// /// - Remark: Generated from `#/paths//sitemaps/{sitemapname}/{pageid}/get(pollDataForPage)/responses/404`. @@ -5573,6 +6489,7 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } + /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -5581,7 +6498,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -5590,11 +6507,13 @@ public enum Operations { } } } + /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } + @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -5606,14 +6525,16 @@ public enum Operations { self = .other(rawValue) } } + public var rawValue: Swift.String { switch self { case let .other(string): - return string + string case .json: - return "application/json" + "application/json" } } + public static var allCases: [Self] { [ .json @@ -5621,6 +6542,7 @@ public enum Operations { } } } + /// Polls the data for a whole sitemap. Not recommended due to potentially high traffic. /// /// - Remark: HTTP `GET /sitemaps/{sitemapname}/*`. @@ -5642,6 +6564,7 @@ public enum Operations { self.sitemapname = sitemapname } } + public var path: Operations.pollDataForSitemap.Input.Path /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/*/GET/query`. public struct Query: Sendable, Hashable { @@ -5658,14 +6581,13 @@ public enum Operations { /// - Parameters: /// - subscriptionid: subscriptionid /// - includeHidden: include hidden widgets - public init( - subscriptionid: Swift.String? = nil, - includeHidden: Swift.Bool? = nil - ) { + public init(subscriptionid: Swift.String? = nil, + includeHidden: Swift.Bool? = nil) { self.subscriptionid = subscriptionid self.includeHidden = includeHidden } } + public var query: Operations.pollDataForSitemap.Input.Query /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/*/GET/header`. public struct Headers: Sendable, Hashable { @@ -5684,16 +6606,15 @@ public enum Operations { /// - Accept_hyphen_Language: language /// - X_hyphen_Atmosphere_hyphen_Transport: X-Atmosphere-Transport for long polling /// - accept: - public init( - Accept_hyphen_Language: Swift.String? = nil, - X_hyphen_Atmosphere_hyphen_Transport: Swift.String? = nil, - accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues() - ) { + public init(Accept_hyphen_Language: Swift.String? = nil, + X_hyphen_Atmosphere_hyphen_Transport: Swift.String? = nil, + accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { self.Accept_hyphen_Language = Accept_hyphen_Language self.X_hyphen_Atmosphere_hyphen_Transport = X_hyphen_Atmosphere_hyphen_Transport self.accept = accept } } + public var headers: Operations.pollDataForSitemap.Input.Headers /// Creates a new `Input`. /// @@ -5701,16 +6622,15 @@ public enum Operations { /// - path: /// - query: /// - headers: - public init( - path: Operations.pollDataForSitemap.Input.Path, - query: Operations.pollDataForSitemap.Input.Query = .init(), - headers: Operations.pollDataForSitemap.Input.Headers = .init() - ) { + public init(path: Operations.pollDataForSitemap.Input.Path, + query: Operations.pollDataForSitemap.Input.Query = .init(), + headers: Operations.pollDataForSitemap.Input.Headers = .init()) { self.path = path self.query = query self.headers = headers } } + @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/*/GET/responses/200/content`. @@ -5725,11 +6645,12 @@ public enum Operations { get throws { switch self { case let .json(body): - return body + body } } } } + /// Received HTTP response body public var body: Operations.pollDataForSitemap.Output.Ok.Body /// Creates a new `Ok`. @@ -5740,6 +6661,7 @@ public enum Operations { self.body = body } } + /// OK /// /// - Remark: Generated from `#/paths//sitemaps/{sitemapname}/*/get(pollDataForSitemap)/responses/200`. @@ -5754,7 +6676,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -5763,10 +6685,12 @@ public enum Operations { } } } + public struct BadRequest: Sendable, Hashable { /// Creates a new `BadRequest`. public init() {} } + /// Invalid subscription id has been provided. /// /// - Remark: Generated from `#/paths//sitemaps/{sitemapname}/*/get(pollDataForSitemap)/responses/400`. @@ -5781,6 +6705,7 @@ public enum Operations { public static var badRequest: Self { .badRequest(.init()) } + /// The associated value of the enum case if `self` is `.badRequest`. /// /// - Throws: An error if `self` is not `.badRequest`. @@ -5789,7 +6714,7 @@ public enum Operations { get throws { switch self { case let .badRequest(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "badRequest", @@ -5798,10 +6723,12 @@ public enum Operations { } } } + public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } + /// Sitemap with requested name does not exist /// /// - Remark: Generated from `#/paths//sitemaps/{sitemapname}/*/get(pollDataForSitemap)/responses/404`. @@ -5816,6 +6743,7 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } + /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -5824,7 +6752,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -5833,11 +6761,13 @@ public enum Operations { } } } + /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } + @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -5849,14 +6779,16 @@ public enum Operations { self = .other(rawValue) } } + public var rawValue: Swift.String { switch self { case let .other(string): - return string + string case .json: - return "application/json" + "application/json" } } + public static var allCases: [Self] { [ .json @@ -5864,6 +6796,7 @@ public enum Operations { } } } + /// Get sitemap by name. /// /// - Remark: HTTP `GET /sitemaps/{sitemapname}`. @@ -5885,6 +6818,7 @@ public enum Operations { self.sitemapname = sitemapname } } + public var path: Operations.getSitemapByName.Input.Path /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/GET/query`. public struct Query: Sendable, Hashable { @@ -5902,16 +6836,15 @@ public enum Operations { /// - _type: /// - jsoncallback: /// - includeHidden: include hidden widgets - public init( - _type: Swift.String? = nil, - jsoncallback: Swift.String? = nil, - includeHidden: Swift.Bool? = nil - ) { + public init(_type: Swift.String? = nil, + jsoncallback: Swift.String? = nil, + includeHidden: Swift.Bool? = nil) { self._type = _type self.jsoncallback = jsoncallback self.includeHidden = includeHidden } } + public var query: Operations.getSitemapByName.Input.Query /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/GET/header`. public struct Headers: Sendable, Hashable { @@ -5925,14 +6858,13 @@ public enum Operations { /// - Parameters: /// - Accept_hyphen_Language: language /// - accept: - public init( - Accept_hyphen_Language: Swift.String? = nil, - accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues() - ) { + public init(Accept_hyphen_Language: Swift.String? = nil, + accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { self.Accept_hyphen_Language = Accept_hyphen_Language self.accept = accept } } + public var headers: Operations.getSitemapByName.Input.Headers /// Creates a new `Input`. /// @@ -5940,16 +6872,15 @@ public enum Operations { /// - path: /// - query: /// - headers: - public init( - path: Operations.getSitemapByName.Input.Path, - query: Operations.getSitemapByName.Input.Query = .init(), - headers: Operations.getSitemapByName.Input.Headers = .init() - ) { + public init(path: Operations.getSitemapByName.Input.Path, + query: Operations.getSitemapByName.Input.Query = .init(), + headers: Operations.getSitemapByName.Input.Headers = .init()) { self.path = path self.query = query self.headers = headers } } + @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/GET/responses/200/content`. @@ -5964,11 +6895,12 @@ public enum Operations { get throws { switch self { case let .json(body): - return body + body } } } } + /// Received HTTP response body public var body: Operations.getSitemapByName.Output.Ok.Body /// Creates a new `Ok`. @@ -5979,6 +6911,7 @@ public enum Operations { self.body = body } } + /// OK /// /// - Remark: Generated from `#/paths//sitemaps/{sitemapname}/get(getSitemapByName)/responses/200`. @@ -5993,7 +6926,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -6002,11 +6935,13 @@ public enum Operations { } } } + /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } + @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -6018,14 +6953,16 @@ public enum Operations { self = .other(rawValue) } } + public var rawValue: Swift.String { switch self { case let .other(string): - return string + string case .json: - return "application/json" + "application/json" } } + public static var allCases: [Self] { [ .json @@ -6033,6 +6970,7 @@ public enum Operations { } } } + /// Get sitemap events for a whole sitemap. Not recommended due to potentially high traffic. /// /// - Remark: HTTP `GET /sitemaps/events/{subscriptionid}/*`. @@ -6054,6 +6992,7 @@ public enum Operations { self.subscriptionid = subscriptionid } } + public var path: Operations.getSitemapEvents.Input.Path /// - Remark: Generated from `#/paths/sitemaps/events/{subscriptionid}/*/GET/query`. public struct Query: Sendable, Hashable { @@ -6069,25 +7008,26 @@ public enum Operations { self.sitemap = sitemap } } + public var query: Operations.getSitemapEvents.Input.Query /// Creates a new `Input`. /// /// - Parameters: /// - path: /// - query: - public init( - path: Operations.getSitemapEvents.Input.Path, - query: Operations.getSitemapEvents.Input.Query = .init() - ) { + public init(path: Operations.getSitemapEvents.Input.Path, + query: Operations.getSitemapEvents.Input.Query = .init()) { self.path = path self.query = query } } + @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// Creates a new `Ok`. public init() {} } + /// OK /// /// - Remark: Generated from `#/paths//sitemaps/events/{subscriptionid}/*/get(getSitemapEvents)/responses/200`. @@ -6102,6 +7042,7 @@ public enum Operations { public static var ok: Self { .ok(.init()) } + /// The associated value of the enum case if `self` is `.ok`. /// /// - Throws: An error if `self` is not `.ok`. @@ -6110,7 +7051,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -6119,10 +7060,12 @@ public enum Operations { } } } + public struct BadRequest: Sendable, Hashable { /// Creates a new `BadRequest`. public init() {} } + /// Missing sitemap parameter, or sitemap not linked successfully to the subscription. /// /// - Remark: Generated from `#/paths//sitemaps/events/{subscriptionid}/*/get(getSitemapEvents)/responses/400`. @@ -6137,6 +7080,7 @@ public enum Operations { public static var badRequest: Self { .badRequest(.init()) } + /// The associated value of the enum case if `self` is `.badRequest`. /// /// - Throws: An error if `self` is not `.badRequest`. @@ -6145,7 +7089,7 @@ public enum Operations { get throws { switch self { case let .badRequest(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "badRequest", @@ -6154,10 +7098,12 @@ public enum Operations { } } } + public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } + /// Subscription not found. /// /// - Remark: Generated from `#/paths//sitemaps/events/{subscriptionid}/*/get(getSitemapEvents)/responses/404`. @@ -6172,6 +7118,7 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } + /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -6180,7 +7127,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -6189,12 +7136,14 @@ public enum Operations { } } } + /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } } + /// Get sitemap events. /// /// - Remark: HTTP `GET /sitemaps/events/{subscriptionid}`. @@ -6216,6 +7165,7 @@ public enum Operations { self.subscriptionid = subscriptionid } } + public var path: Operations.getSitemapEvents_1.Input.Path /// - Remark: Generated from `#/paths/sitemaps/events/{subscriptionid}/GET/query`. public struct Query: Sendable, Hashable { @@ -6232,14 +7182,13 @@ public enum Operations { /// - Parameters: /// - sitemap: sitemap name /// - pageid: page id - public init( - sitemap: Swift.String? = nil, - pageid: Swift.String? = nil - ) { + public init(sitemap: Swift.String? = nil, + pageid: Swift.String? = nil) { self.sitemap = sitemap self.pageid = pageid } } + public var query: Operations.getSitemapEvents_1.Input.Query /// - Remark: Generated from `#/paths/sitemaps/events/{subscriptionid}/GET/header`. public struct Headers: Sendable, Hashable { @@ -6252,6 +7201,7 @@ public enum Operations { self.accept = accept } } + public var headers: Operations.getSitemapEvents_1.Input.Headers /// Creates a new `Input`. /// @@ -6259,16 +7209,15 @@ public enum Operations { /// - path: /// - query: /// - headers: - public init( - path: Operations.getSitemapEvents_1.Input.Path, - query: Operations.getSitemapEvents_1.Input.Query = .init(), - headers: Operations.getSitemapEvents_1.Input.Headers = .init() - ) { + public init(path: Operations.getSitemapEvents_1.Input.Path, + query: Operations.getSitemapEvents_1.Input.Query = .init(), + headers: Operations.getSitemapEvents_1.Input.Headers = .init()) { self.path = path self.query = query self.headers = headers } } + @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/sitemaps/events/{subscriptionid}/GET/responses/200/content`. @@ -6283,7 +7232,7 @@ public enum Operations { get throws { switch self { case let .text_event_hyphen_stream(body): - return body + body default: try throwUnexpectedResponseBody( expectedContent: "text/event-stream", @@ -6292,6 +7241,7 @@ public enum Operations { } } } + /// - Remark: Generated from `#/paths/sitemaps/events/{subscriptionid}/GET/responses/200/content/application\/json`. case json(Components.Schemas.SitemapWidgetEvent) /// The associated value of the enum case if `self` is `.json`. @@ -6302,7 +7252,7 @@ public enum Operations { get throws { switch self { case let .json(body): - return body + body default: try throwUnexpectedResponseBody( expectedContent: "application/json", @@ -6312,6 +7262,7 @@ public enum Operations { } } } + /// Received HTTP response body public var body: Operations.getSitemapEvents_1.Output.Ok.Body /// Creates a new `Ok`. @@ -6322,6 +7273,7 @@ public enum Operations { self.body = body } } + /// OK /// /// - Remark: Generated from `#/paths//sitemaps/events/{subscriptionid}/get(getSitemapEvents_1)/responses/200`. @@ -6336,7 +7288,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -6345,10 +7297,12 @@ public enum Operations { } } } + public struct BadRequest: Sendable, Hashable { /// Creates a new `BadRequest`. public init() {} } + /// Missing sitemap or page parameter, or page not linked successfully to the subscription. /// /// - Remark: Generated from `#/paths//sitemaps/events/{subscriptionid}/get(getSitemapEvents_1)/responses/400`. @@ -6363,6 +7317,7 @@ public enum Operations { public static var badRequest: Self { .badRequest(.init()) } + /// The associated value of the enum case if `self` is `.badRequest`. /// /// - Throws: An error if `self` is not `.badRequest`. @@ -6371,7 +7326,7 @@ public enum Operations { get throws { switch self { case let .badRequest(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "badRequest", @@ -6380,10 +7335,12 @@ public enum Operations { } } } + public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } + /// Subscription not found. /// /// - Remark: Generated from `#/paths//sitemaps/events/{subscriptionid}/get(getSitemapEvents_1)/responses/404`. @@ -6398,6 +7355,7 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } + /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -6406,7 +7364,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -6415,11 +7373,13 @@ public enum Operations { } } } + /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } + @frozen public enum AcceptableContentType: AcceptableProtocol { case text_event_hyphen_stream case json @@ -6434,16 +7394,18 @@ public enum Operations { self = .other(rawValue) } } + public var rawValue: Swift.String { switch self { case let .other(string): - return string + string case .text_event_hyphen_stream: - return "text/event-stream" + "text/event-stream" case .json: - return "application/json" + "application/json" } } + public static var allCases: [Self] { [ .text_event_hyphen_stream, @@ -6452,6 +7414,7 @@ public enum Operations { } } } + /// Get all available sitemaps. /// /// - Remark: HTTP `GET /sitemaps`. @@ -6470,6 +7433,7 @@ public enum Operations { self.accept = accept } } + public var headers: Operations.getSitemaps.Input.Headers /// Creates a new `Input`. /// @@ -6479,6 +7443,7 @@ public enum Operations { self.headers = headers } } + @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/sitemaps/GET/responses/200/content`. @@ -6493,11 +7458,12 @@ public enum Operations { get throws { switch self { case let .json(body): - return body + body } } } } + /// Received HTTP response body public var body: Operations.getSitemaps.Output.Ok.Body /// Creates a new `Ok`. @@ -6508,6 +7474,7 @@ public enum Operations { self.body = body } } + /// OK /// /// - Remark: Generated from `#/paths//sitemaps/get(getSitemaps)/responses/200`. @@ -6522,7 +7489,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -6531,11 +7498,13 @@ public enum Operations { } } } + /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } + @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -6547,14 +7516,16 @@ public enum Operations { self = .other(rawValue) } } + public var rawValue: Swift.String { switch self { case let .other(string): - return string + string case .json: - return "application/json" + "application/json" } } + public static var allCases: [Self] { [ .json @@ -6562,6 +7533,7 @@ public enum Operations { } } } + /// Get all registered UI components in the specified namespace. /// /// - Remark: HTTP `GET /ui/components/{namespace}`. @@ -6581,6 +7553,7 @@ public enum Operations { self.namespace = namespace } } + public var path: Operations.getRegisteredUIComponentsInNamespace.Input.Path /// - Remark: Generated from `#/paths/ui/components/{namespace}/GET/query`. public struct Query: Sendable, Hashable { @@ -6596,6 +7569,7 @@ public enum Operations { self.summary = summary } } + public var query: Operations.getRegisteredUIComponentsInNamespace.Input.Query /// - Remark: Generated from `#/paths/ui/components/{namespace}/GET/header`. public struct Headers: Sendable, Hashable { @@ -6608,6 +7582,7 @@ public enum Operations { self.accept = accept } } + public var headers: Operations.getRegisteredUIComponentsInNamespace.Input.Headers /// Creates a new `Input`. /// @@ -6615,16 +7590,15 @@ public enum Operations { /// - path: /// - query: /// - headers: - public init( - path: Operations.getRegisteredUIComponentsInNamespace.Input.Path, - query: Operations.getRegisteredUIComponentsInNamespace.Input.Query = .init(), - headers: Operations.getRegisteredUIComponentsInNamespace.Input.Headers = .init() - ) { + public init(path: Operations.getRegisteredUIComponentsInNamespace.Input.Path, + query: Operations.getRegisteredUIComponentsInNamespace.Input.Query = .init(), + headers: Operations.getRegisteredUIComponentsInNamespace.Input.Headers = .init()) { self.path = path self.query = query self.headers = headers } } + @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/ui/components/{namespace}/GET/responses/200/content`. @@ -6639,11 +7613,12 @@ public enum Operations { get throws { switch self { case let .json(body): - return body + body } } } } + /// Received HTTP response body public var body: Operations.getRegisteredUIComponentsInNamespace.Output.Ok.Body /// Creates a new `Ok`. @@ -6654,6 +7629,7 @@ public enum Operations { self.body = body } } + /// OK /// /// - Remark: Generated from `#/paths//ui/components/{namespace}/get(getRegisteredUIComponentsInNamespace)/responses/200`. @@ -6668,7 +7644,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -6677,11 +7653,13 @@ public enum Operations { } } } + /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } + @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -6693,14 +7671,16 @@ public enum Operations { self = .other(rawValue) } } + public var rawValue: Swift.String { switch self { case let .other(string): - return string + string case .json: - return "application/json" + "application/json" } } + public static var allCases: [Self] { [ .json @@ -6708,6 +7688,7 @@ public enum Operations { } } } + /// Add a UI component in the specified namespace. /// /// - Remark: HTTP `POST /ui/components/{namespace}`. @@ -6727,6 +7708,7 @@ public enum Operations { self.namespace = namespace } } + public var path: Operations.addUIComponentToNamespace.Input.Path /// - Remark: Generated from `#/paths/ui/components/{namespace}/POST/header`. public struct Headers: Sendable, Hashable { @@ -6739,12 +7721,14 @@ public enum Operations { self.accept = accept } } + public var headers: Operations.addUIComponentToNamespace.Input.Headers /// - Remark: Generated from `#/paths/ui/components/{namespace}/POST/requestBody`. @frozen public enum Body: Sendable, Hashable { /// - Remark: Generated from `#/paths/ui/components/{namespace}/POST/requestBody/content/application\/json`. case json(Components.Schemas.RootUIComponent) } + public var body: Operations.addUIComponentToNamespace.Input.Body? /// Creates a new `Input`. /// @@ -6752,16 +7736,15 @@ public enum Operations { /// - path: /// - headers: /// - body: - public init( - path: Operations.addUIComponentToNamespace.Input.Path, - headers: Operations.addUIComponentToNamespace.Input.Headers = .init(), - body: Operations.addUIComponentToNamespace.Input.Body? = nil - ) { + public init(path: Operations.addUIComponentToNamespace.Input.Path, + headers: Operations.addUIComponentToNamespace.Input.Headers = .init(), + body: Operations.addUIComponentToNamespace.Input.Body? = nil) { self.path = path self.headers = headers self.body = body } } + @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/ui/components/{namespace}/POST/responses/200/content`. @@ -6776,11 +7759,12 @@ public enum Operations { get throws { switch self { case let .json(body): - return body + body } } } } + /// Received HTTP response body public var body: Operations.addUIComponentToNamespace.Output.Ok.Body /// Creates a new `Ok`. @@ -6791,6 +7775,7 @@ public enum Operations { self.body = body } } + /// OK /// /// - Remark: Generated from `#/paths//ui/components/{namespace}/post(addUIComponentToNamespace)/responses/200`. @@ -6805,7 +7790,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -6814,11 +7799,13 @@ public enum Operations { } } } + /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } + @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -6830,14 +7817,16 @@ public enum Operations { self = .other(rawValue) } } + public var rawValue: Swift.String { switch self { case let .other(string): - return string + string case .json: - return "application/json" + "application/json" } } + public static var allCases: [Self] { [ .json @@ -6845,6 +7834,7 @@ public enum Operations { } } } + /// Get a specific UI component in the specified namespace. /// /// - Remark: HTTP `GET /ui/components/{namespace}/{componentUID}`. @@ -6863,14 +7853,13 @@ public enum Operations { /// - Parameters: /// - namespace: /// - componentUID: - public init( - namespace: Swift.String, - componentUID: Swift.String - ) { + public init(namespace: Swift.String, + componentUID: Swift.String) { self.namespace = namespace self.componentUID = componentUID } } + public var path: Operations.getUIComponentInNamespace.Input.Path /// - Remark: Generated from `#/paths/ui/components/{namespace}/{componentUID}/GET/header`. public struct Headers: Sendable, Hashable { @@ -6883,20 +7872,20 @@ public enum Operations { self.accept = accept } } + public var headers: Operations.getUIComponentInNamespace.Input.Headers /// Creates a new `Input`. /// /// - Parameters: /// - path: /// - headers: - public init( - path: Operations.getUIComponentInNamespace.Input.Path, - headers: Operations.getUIComponentInNamespace.Input.Headers = .init() - ) { + public init(path: Operations.getUIComponentInNamespace.Input.Path, + headers: Operations.getUIComponentInNamespace.Input.Headers = .init()) { self.path = path self.headers = headers } } + @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/ui/components/{namespace}/{componentUID}/GET/responses/200/content`. @@ -6911,11 +7900,12 @@ public enum Operations { get throws { switch self { case let .json(body): - return body + body } } } } + /// Received HTTP response body public var body: Operations.getUIComponentInNamespace.Output.Ok.Body /// Creates a new `Ok`. @@ -6926,6 +7916,7 @@ public enum Operations { self.body = body } } + /// OK /// /// - Remark: Generated from `#/paths//ui/components/{namespace}/{componentUID}/get(getUIComponentInNamespace)/responses/200`. @@ -6940,7 +7931,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -6949,10 +7940,12 @@ public enum Operations { } } } + public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } + /// Component not found /// /// - Remark: Generated from `#/paths//ui/components/{namespace}/{componentUID}/get(getUIComponentInNamespace)/responses/404`. @@ -6967,6 +7960,7 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } + /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -6975,7 +7969,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -6984,11 +7978,13 @@ public enum Operations { } } } + /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } + @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -7000,14 +7996,16 @@ public enum Operations { self = .other(rawValue) } } + public var rawValue: Swift.String { switch self { case let .other(string): - return string + string case .json: - return "application/json" + "application/json" } } + public static var allCases: [Self] { [ .json @@ -7015,6 +8013,7 @@ public enum Operations { } } } + /// Update a specific UI component in the specified namespace. /// /// - Remark: HTTP `PUT /ui/components/{namespace}/{componentUID}`. @@ -7033,14 +8032,13 @@ public enum Operations { /// - Parameters: /// - namespace: /// - componentUID: - public init( - namespace: Swift.String, - componentUID: Swift.String - ) { + public init(namespace: Swift.String, + componentUID: Swift.String) { self.namespace = namespace self.componentUID = componentUID } } + public var path: Operations.updateUIComponentInNamespace.Input.Path /// - Remark: Generated from `#/paths/ui/components/{namespace}/{componentUID}/PUT/header`. public struct Headers: Sendable, Hashable { @@ -7053,12 +8051,14 @@ public enum Operations { self.accept = accept } } + public var headers: Operations.updateUIComponentInNamespace.Input.Headers /// - Remark: Generated from `#/paths/ui/components/{namespace}/{componentUID}/PUT/requestBody`. @frozen public enum Body: Sendable, Hashable { /// - Remark: Generated from `#/paths/ui/components/{namespace}/{componentUID}/PUT/requestBody/content/application\/json`. case json(Components.Schemas.RootUIComponent) } + public var body: Operations.updateUIComponentInNamespace.Input.Body? /// Creates a new `Input`. /// @@ -7066,16 +8066,15 @@ public enum Operations { /// - path: /// - headers: /// - body: - public init( - path: Operations.updateUIComponentInNamespace.Input.Path, - headers: Operations.updateUIComponentInNamespace.Input.Headers = .init(), - body: Operations.updateUIComponentInNamespace.Input.Body? = nil - ) { + public init(path: Operations.updateUIComponentInNamespace.Input.Path, + headers: Operations.updateUIComponentInNamespace.Input.Headers = .init(), + body: Operations.updateUIComponentInNamespace.Input.Body? = nil) { self.path = path self.headers = headers self.body = body } } + @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/ui/components/{namespace}/{componentUID}/PUT/responses/200/content`. @@ -7090,11 +8089,12 @@ public enum Operations { get throws { switch self { case let .json(body): - return body + body } } } } + /// Received HTTP response body public var body: Operations.updateUIComponentInNamespace.Output.Ok.Body /// Creates a new `Ok`. @@ -7105,6 +8105,7 @@ public enum Operations { self.body = body } } + /// OK /// /// - Remark: Generated from `#/paths//ui/components/{namespace}/{componentUID}/put(updateUIComponentInNamespace)/responses/200`. @@ -7119,7 +8120,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -7128,10 +8129,12 @@ public enum Operations { } } } + public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } + /// Component not found /// /// - Remark: Generated from `#/paths//ui/components/{namespace}/{componentUID}/put(updateUIComponentInNamespace)/responses/404`. @@ -7146,6 +8149,7 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } + /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -7154,7 +8158,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -7163,11 +8167,13 @@ public enum Operations { } } } + /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } + @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -7179,14 +8185,16 @@ public enum Operations { self = .other(rawValue) } } + public var rawValue: Swift.String { switch self { case let .other(string): - return string + string case .json: - return "application/json" + "application/json" } } + public static var allCases: [Self] { [ .json @@ -7194,6 +8202,7 @@ public enum Operations { } } } + /// Remove a specific UI component in the specified namespace. /// /// - Remark: HTTP `DELETE /ui/components/{namespace}/{componentUID}`. @@ -7212,14 +8221,13 @@ public enum Operations { /// - Parameters: /// - namespace: /// - componentUID: - public init( - namespace: Swift.String, - componentUID: Swift.String - ) { + public init(namespace: Swift.String, + componentUID: Swift.String) { self.namespace = namespace self.componentUID = componentUID } } + public var path: Operations.removeUIComponentFromNamespace.Input.Path /// Creates a new `Input`. /// @@ -7229,11 +8237,13 @@ public enum Operations { self.path = path } } + @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// Creates a new `Ok`. public init() {} } + /// OK /// /// - Remark: Generated from `#/paths//ui/components/{namespace}/{componentUID}/delete(removeUIComponentFromNamespace)/responses/200`. @@ -7248,6 +8258,7 @@ public enum Operations { public static var ok: Self { .ok(.init()) } + /// The associated value of the enum case if `self` is `.ok`. /// /// - Throws: An error if `self` is not `.ok`. @@ -7256,7 +8267,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -7265,10 +8276,12 @@ public enum Operations { } } } + public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } + /// Component not found /// /// - Remark: Generated from `#/paths//ui/components/{namespace}/{componentUID}/delete(removeUIComponentFromNamespace)/responses/404`. @@ -7283,6 +8296,7 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } + /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -7291,7 +8305,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -7300,12 +8314,14 @@ public enum Operations { } } } + /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } } + /// Get all registered UI tiles. /// /// - Remark: HTTP `GET /ui/tiles`. @@ -7324,6 +8340,7 @@ public enum Operations { self.accept = accept } } + public var headers: Operations.getUITiles.Input.Headers /// Creates a new `Input`. /// @@ -7333,6 +8350,7 @@ public enum Operations { self.headers = headers } } + @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/ui/tiles/GET/responses/200/content`. @@ -7347,11 +8365,12 @@ public enum Operations { get throws { switch self { case let .json(body): - return body + body } } } } + /// Received HTTP response body public var body: Operations.getUITiles.Output.Ok.Body /// Creates a new `Ok`. @@ -7362,6 +8381,7 @@ public enum Operations { self.body = body } } + /// OK /// /// - Remark: Generated from `#/paths//ui/tiles/get(getUITiles)/responses/200`. @@ -7376,7 +8396,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -7385,11 +8405,13 @@ public enum Operations { } } } + /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } + @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -7401,14 +8423,16 @@ public enum Operations { self = .other(rawValue) } } + public var rawValue: Swift.String { switch self { case let .other(string): - return string + string case .json: - return "application/json" + "application/json" } } + public static var allCases: [Self] { [ .json diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABLink.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABLink.swift new file mode 100644 index 000000000..8d4c2afb3 --- /dev/null +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABLink.swift @@ -0,0 +1,32 @@ +// Copyright (c) 2010-2025 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 Foundation + +class OpenHABLink: Decodable { + public var type: String? + public var url: String? + + init(type: String?, url: String?) { + self.type = type + self.url = url + } +} + +extension OpenHABLink { + convenience init?(_ links: Components.Schemas.Links?) { + if let links { + self.init(type: links._type, url: links.url) + } else { + return nil + } + } +} diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABServerProperties.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABServerProperties.swift index ae51860c8..083806d18 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABServerProperties.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABServerProperties.swift @@ -12,18 +12,18 @@ import Foundation public class OpenHABServerProperties: Decodable { - class OpenHABLink: Decodable { - public var type = "" - public var url = "" - } - - public let version: String + public let version: String? let links: [OpenHABLink] public var habPanelUrl: String? { linkUrl(byType: "habpanel") } + init(version: String?, links: [OpenHABLink]) { + self.version = version + self.links = links + } + public func linkUrl(byType type: String?) -> String? { if let index = links.firstIndex(where: { $0.type == type }) { links[index].url @@ -32,3 +32,12 @@ public class OpenHABServerProperties: Decodable { } } } + +extension OpenHABServerProperties { + convenience init(_ rootBean: Components.Schemas.RootBean) { + self.init( + version: rootBean.version, + links: rootBean.links?.compactMap { OpenHABLink($0) } ?? [] + ) + } +} diff --git a/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift b/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift index 783a41b46..995962952 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift @@ -16,6 +16,19 @@ private let logger = Logger(subsystem: "org.openhab.core", category: "HTTPClient private enum HTTPClientError: Error { case serverTrustEvaluationFailed(reason: String) + case noDataforItem + case noDataForProperties + + var debugDescription: String { + switch self { + case .noDataforItem: + "No data for item" + case let .serverTrustEvaluationFailed(reason): + "server trust evaluation failed: \(reason)" + case .noDataForProperties: + "No data for properties" + } + } } public class HTTPClient: NSObject { @@ -121,7 +134,7 @@ public class HTTPClient: NSObject { - item: An `OpenHABItem` object returned by the server. This will be `nil` if the request fails. - error: An error object that indicates why the request failed, or `nil` if the request was successful. */ - public func getItem(baseURL: URL? = nil, itemName: String, completion: @escaping (OpenHABItem?, Error?) -> Void) -> URLSessionTask? { + public func getItem(baseURL: URL? = nil, itemName: String, completion: @escaping (OpenHABItem?, Error?) -> Void) { doGet(baseURL: baseURL, path: "/rest/items/\(itemName)") { data, _, error in if let error { completion(nil, error) @@ -133,7 +146,7 @@ public class HTTPClient: NSObject { let item = try data.decoded(as: OpenHABItem.CodingData.self, using: decoder) completion(item.openHABItem, nil) } else { - completion(nil, NSError(domain: "HTTPClient", code: -1, userInfo: [NSLocalizedDescriptionKey: "No data for item"])) + completion(nil, HTTPClientError.noDataforItem) } } catch { os_log("getItemsInternal ERROR: %{PUBLIC}@", log: .networking, type: .info, String(describing: error)) @@ -143,7 +156,7 @@ public class HTTPClient: NSObject { } } - public func getServerProperties(baseURL: URL? = nil, completion: @escaping (OpenHABServerProperties?, Error?) -> Void) -> URLSessionTask? { + public func getServerProperties(baseURL: URL? = nil, completion: @escaping (OpenHABServerProperties?, Error?) -> Void) { doGet(baseURL: baseURL, path: "/rest/") { data, _, error in if let error { completion(nil, error) @@ -155,7 +168,7 @@ public class HTTPClient: NSObject { let properties = try data.decoded(as: OpenHABServerProperties.self, using: decoder) completion(properties, nil) } else { - completion(nil, NSError(domain: "HTTPClient", code: -1, userInfo: [NSLocalizedDescriptionKey: "No data for properties"])) + completion(nil, HTTPClientError.noDataForProperties) } } catch { os_log("getServerProperties ERROR: %{PUBLIC}@", log: .networking, type: .info, String(describing: error)) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/APIActor.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift similarity index 79% rename from OpenHABCore/Sources/OpenHABCore/Util/APIActor.swift rename to OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift index 2f957433d..7a2b0e042 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/APIActor.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift @@ -20,20 +20,21 @@ public protocol OpenHABSitemapsService { } public protocol OpenHABUiTileService { - func openHABTiles() async throws -> [OpenHABUiTile] + func getUITiles() async throws -> [OpenHABUiTile] } -public enum APIActorError: Error { +public enum OpenAPIServiceError: Error { case undocumented + case noRootURL } -public actor APIActor { - var api: APIProtocol - var url: URL? - var longPolling = false - var alwaysSendBasicAuth = false - var username: String - var password: String +public actor OpenAPIService { + private var client: APIProtocol + private var url: URL? + private var longPolling = false + private var alwaysSendBasicAuth = false + private var username: String + private var password: String private let logger = Logger(subsystem: "org.openhab.app", category: "apiactor") @@ -50,7 +51,7 @@ public actor APIActor { self.alwaysSendBasicAuth = alwaysSendBasicAuth self.url = url - api = Client( + client = Client( serverURL: url.appending(path: "/rest"), transport: URLSessionTransport(configuration: .init(session: session)), middlewares: [ @@ -72,7 +73,7 @@ public actor APIActor { let config = prepareURLSessionConfiguration(longPolling: longPolling) let session = URLSession(configuration: config) url = newURL - api = Client( + client = Client( serverURL: newURL.appending(path: "/rest"), transport: URLSessionTransport(configuration: .init(session: session)), middlewares: [ @@ -89,7 +90,7 @@ public actor APIActor { let config = prepareURLSessionConfiguration(longPolling: longPolling) let session = URLSession(configuration: config) longPolling = newlongPolling - api = Client( + client = Client( serverURL: url!.appending(path: "/rest"), transport: URLSessionTransport(configuration: .init(session: session)), middlewares: [ @@ -101,29 +102,48 @@ public actor APIActor { } } -extension APIActor: OpenHABSitemapsService { +extension OpenAPIService: OpenHABSitemapsService { public func openHABSitemaps() async throws -> [OpenHABSitemap] { // swiftformat:disable:next redundantSelf - logger.log("Trying to getSitemaps for : \(self.url?.debugDescription ?? "No URL")") - switch try await api.getSitemaps(.init()) { + guard let url else { throw OpenAPIServiceError.noRootURL } + + logger.log("Trying to getSitemaps for : \(url.debugDescription)") + switch try await client.getSitemaps(.init()) { case let .ok(okresponse): return try okresponse.body.json.map(OpenHABSitemap.init) - case .undocumented: throw APIActorError.undocumented + case .undocumented: throw OpenAPIServiceError.undocumented } } } -extension APIActor: OpenHABUiTileService { - public func openHABTiles() async throws -> [OpenHABUiTile] { - try await api.getUITiles(.init()) +extension OpenAPIService: OpenHABUiTileService { + public func getUITiles() async throws -> [OpenHABUiTile] { + try await client.getUITiles(.init()) .ok.body.json .map(OpenHABUiTile.init) } } -public extension APIActor { +public extension OpenAPIService { + func getRoot() async throws -> OpenHABServerProperties { + let result = try await client.getRoot() + .ok.body.json + return OpenHABServerProperties(result) + } +} + +public extension OpenAPIService { + func getItemByName(id: String) async throws -> OpenHABItem? { + let path = Operations.getItemByName.Input.Path(itemname: id) + let result = try await client.getItemByName(path: path) + .ok.body.json + return OpenHABItem(result) + } +} + +public extension OpenAPIService { func openHABcreateSubscription() async throws -> String? { logger.info("Creating subscription") - let result = try await api.createSitemapEventSubscription() + let result = try await client.createSitemapEventSubscription() guard let urlString = try result.ok.body.json.context?.headers?.Location?.first else { return nil } return URL(string: urlString)?.lastPathComponent } @@ -132,19 +152,19 @@ public extension APIActor { func openHABSitemapWidgetEvents(subscriptionid: String, sitemap: String) async throws -> AsyncCompactMapSequence>, ServerSentEventWithJSONData>, OpenHABSitemapWidgetEvent> { let path = Operations.getSitemapEvents_1.Input.Path(subscriptionid: subscriptionid) let query = Operations.getSitemapEvents_1.Input.Query(sitemap: sitemap, pageid: sitemap) - let decodedSequence = try await api.getSitemapEvents_1(path: path, query: query) + let decodedSequence = try await client.getSitemapEvents_1(path: path, query: query) .ok.body.text_event_hyphen_stream .asDecodedServerSentEventsWithJSONData(of: Components.Schemas.SitemapWidgetEvent.self) return decodedSequence.compactMap { OpenHABSitemapWidgetEvent($0.data) } } } -extension APIActor { +extension OpenAPIService { // Internal function for pollPage func openHABpollPage(path: Operations.pollDataForPage.Input.Path, query: Operations.pollDataForPage.Input.Query = .init(), headers: Operations.pollDataForPage.Input.Headers) async throws -> OpenHABPage? { - let result = try await api.pollDataForPage(path: path, query: query, headers: headers) + let result = try await client.pollDataForPage(path: path, query: query, headers: headers) .ok.body.json return OpenHABPage(result) } @@ -170,7 +190,7 @@ extension APIActor { func openHABpollSitemap(path: Operations.pollDataForSitemap.Input.Path, query: Operations.pollDataForSitemap.Input.Query = .init(), headers: Operations.pollDataForSitemap.Input.Headers) async throws -> OpenHABSitemap? { - let result = try await api.pollDataForSitemap(path: path, query: query, headers: headers) + let result = try await client.pollDataForSitemap(path: path, query: query, headers: headers) .ok.body.json return OpenHABSitemap(result) } @@ -192,18 +212,18 @@ extension APIActor { // MARK: State changes and commands -public extension APIActor { +public extension OpenAPIService { func openHABUpdateItemState(itemname: String, with state: String) async throws { let path = Operations.updateItemState.Input.Path(itemname: itemname) let body = Operations.updateItemState.Input.Body.plainText(.init(state)) - let response = try await api.updateItemState(path: path, body: body) + let response = try await client.updateItemState(path: path, body: body) _ = try response.accepted } func openHABSendItemCommand(itemname: String, command: String) async throws { let path = Operations.sendItemCommand.Input.Path(itemname: itemname) let body = Operations.sendItemCommand.Input.Body.plainText(.init(command)) - let response = try await api.sendItemCommand(path: path, body: body) + let response = try await client.sendItemCommand(path: path, body: body) _ = try response.ok } } diff --git a/OpenHABCore/Sources/OpenHABCore/openapi/openapi-generator-config.yml b/OpenHABCore/Sources/OpenHABCore/openapi/openapi-generator-config.yml index 4d0407ff7..c720631da 100644 --- a/OpenHABCore/Sources/OpenHABCore/openapi/openapi-generator-config.yml +++ b/OpenHABCore/Sources/OpenHABCore/openapi/openapi-generator-config.yml @@ -7,3 +7,5 @@ filter: - sitemaps - ui - items + - root + - systeminfo diff --git a/openHAB/DrawerView.swift b/openHAB/DrawerView.swift index c2759f5c7..6bcc51278 100644 --- a/openHAB/DrawerView.swift +++ b/openHAB/DrawerView.swift @@ -264,13 +264,13 @@ struct DrawerView: View { } .listStyle(.inset) .task { - let apiactor = await APIActor() + let openAPIService = await OpenAPIService() Task { do { guard let url = URL(string: appData?.openHABRootUrl ?? "") else { throw DrawerViewError.noRootURL } - await apiactor.updateBaseURL(with: url) + await openAPIService.updateBaseURL(with: url) - sitemaps = try await apiactor.openHABSitemaps() + sitemaps = try await openAPIService.openHABSitemaps() if sitemaps.last?.name == "_default", sitemaps.count > 1 { sitemaps = Array(sitemaps.dropLast()) } @@ -289,9 +289,9 @@ struct DrawerView: View { Task { do { guard let url = URL(string: appData?.openHABRootUrl ?? "") else { throw DrawerViewError.noRootURL } - await apiactor.updateBaseURL(with: url) + await openAPIService.updateBaseURL(with: url) - uiTiles = try await apiactor.openHABTiles() + uiTiles = try await openAPIService.getUITiles() os_log("ui tiles response", log: .viewCycle, type: .info) } catch { os_log("%{PUBLIC}@", log: .default, type: .error, error.localizedDescription) diff --git a/openHAB/OpenHABSitemapViewController.swift b/openHAB/OpenHABSitemapViewController.swift index 6b685155e..be1c67c03 100644 --- a/openHAB/OpenHABSitemapViewController.swift +++ b/openHAB/OpenHABSitemapViewController.swift @@ -116,7 +116,7 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel search.isActive && !searchBarIsEmpty } - private var apiactor: APIActor? + private var apiactor: OpenAPIService? @IBOutlet private var widgetTableView: UITableView! @@ -128,7 +128,7 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel pageNetworkStatus = nil sitemaps = [] widgetTableView.tableFooterView = UIView() - Task { await apiactor = APIActor(username: openHABUsername, password: openHABPassword, alwaysSendBasicAuth: openHABAlwaysSendCreds, url: URL(string: openHABRootUrl) ?? URL(staticString: "about:blank")) } + Task { await apiactor = OpenAPIService(username: openHABUsername, password: openHABPassword, alwaysSendBasicAuth: openHABAlwaysSendCreds, url: URL(string: openHABRootUrl) ?? URL(staticString: "about:blank")) } registerTableViewCells() configureTableView() @@ -430,7 +430,7 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel Task { do { logger.debug("Running selectSitemap for URL: \(self.appData?.openHABRootUrl ?? "")") - apiactor = await APIActor(username: appData!.openHABUsername, password: appData!.openHABPassword, alwaysSendBasicAuth: appData!.openHABAlwaysSendCreds, url: URL(string: appData?.openHABRootUrl ?? "")!) + apiactor = await OpenAPIService(username: appData!.openHABUsername, password: appData!.openHABPassword, alwaysSendBasicAuth: appData!.openHABAlwaysSendCreds, url: URL(string: appData?.openHABRootUrl ?? "")!) sitemaps = try await apiactor?.openHABSitemaps() ?? [] switch sitemaps.count { @@ -457,7 +457,7 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel default: break } widgetTableView.reloadData() - } catch _ as APIActorError { + } catch _ as OpenAPIServiceError { logger.debug("APIActorError on OpenHABSitemapViewController") } catch { os_log("%{PUBLIC}@", log: .default, type: .error, error.localizedDescription) From fb39c97738efdeb97b2e55ad774656f65fad538d Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Mon, 24 Feb 2025 14:35:17 +0100 Subject: [PATCH 016/476] Eliminated NSError Renamed apiactor into openAPIService, renamed APICActorDelegate into OpenAPIServiceDelegate Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../Sources/OpenHABCore/Util/HTTPClient.swift | 12 +++- .../OpenHABCore/Util/OpenAPIService.swift | 69 ++++++++++--------- ...ate.swift => OpenAPIServiceDelegate.swift} | 2 +- openHAB/OpenHABSitemapViewController.swift | 53 +++++++++----- openHAB/OpenHABWebViewController.swift | 6 +- openHABTestsSwift/OpenHABSVGTests.swift | 17 +++-- 6 files changed, 96 insertions(+), 63 deletions(-) rename OpenHABCore/Sources/OpenHABCore/Util/{APIActorDelegate.swift => OpenAPIServiceDelegate.swift} (97%) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift b/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift index 995962952..f6bfb43cf 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift @@ -18,6 +18,8 @@ private enum HTTPClientError: Error { case serverTrustEvaluationFailed(reason: String) case noDataforItem case noDataForProperties + case baseURLIsNil + case httpError(Int) var debugDescription: String { switch self { @@ -27,6 +29,10 @@ private enum HTTPClientError: Error { "server trust evaluation failed: \(reason)" case .noDataForProperties: "No data for properties" + case .baseURLIsNil: + "Base URL is nil" + case let .httpError(statusCode): + "HTTP error \(statusCode)" } } } @@ -79,7 +85,7 @@ public class HTTPClient: NSObject { - response: The URL response object providing response metadata, such as HTTP headers and status code. - error: An error object that indicates why the request failed, or `nil` if the request was successful. */ - public func doGet(baseURL: URL? = nil, path: String?, completion: @escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionTask? { + public func doGet(baseURL: URL? = nil, path: String?, completion: @escaping (Data?, URLResponse?, Error?) -> Void) { doRequest(baseURL: baseURL, path: path, method: "GET") { result, response, error in let data = result as? Data completion(data, response, error) @@ -244,7 +250,7 @@ public class HTTPClient: NSObject { timeout: TimeInterval = 60.0, body: String? = nil, download: Bool = false, completion: @escaping (Any?, URLResponse?, Error?) -> Void) -> URLSessionTask? { guard var url = baseURL ?? self.baseURL else { os_log("doRequest ERROR: Base URL is nil", log: .networking, type: .info) - completion(nil, nil, NSError(domain: "HTTPClient", code: -1, userInfo: [NSLocalizedDescriptionKey: "Base URL is nil"])) + completion(nil, nil, HTTPClientError.baseURLIsNil) return nil } @@ -271,7 +277,7 @@ public class HTTPClient: NSObject { } else if let response = response as? HTTPURLResponse { if (400 ... 599).contains(response.statusCode) { os_log("HTTP error from URL %{public}@ : %{public}d", log: .networking, type: .error, url.absoluteString, response.statusCode) - completion(nil, response, NSError(domain: "HTTPClient", code: response.statusCode, userInfo: [NSLocalizedDescriptionKey: "HTTP error \(response.statusCode)"])) + completion(nil, response, HTTPClientError.httpError(response.statusCode)) } else { os_log("Response from URL %{public}@ : %{public}d", log: .networking, type: .info, url.absoluteString, response.statusCode) completion(result, response, nil) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift index 7a2b0e042..aa6a59ea1 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift @@ -24,19 +24,19 @@ public protocol OpenHABUiTileService { } public enum OpenAPIServiceError: Error { - case undocumented + case undocumented(statusCode: Swift.Int, undocumentedPayload: OpenAPIRuntime.UndocumentedPayload) case noRootURL } public actor OpenAPIService { - private var client: APIProtocol + private var client: Client private var url: URL? private var longPolling = false private var alwaysSendBasicAuth = false private var username: String private var password: String - private let logger = Logger(subsystem: "org.openhab.app", category: "apiactor") + private let logger = Logger(subsystem: "org.openhab.app", category: "OpenAPIService") public init(username: String = "", password: String = "", alwaysSendBasicAuth: Bool = true, url: URL = URL(staticString: "about:blank")) async { // TODO: Make use of prepareURLSessionConfiguration @@ -44,7 +44,7 @@ public actor OpenAPIService { // config.timeoutIntervalForRequest = if longPolling { 35.0 } else { 20.0 } // config.timeoutIntervalForResource = config.timeoutIntervalForRequest + 25 - let delegate = APIActorDelegate(username: username, password: password) + let delegate = OpenAPIServiceDelegate(username: username, password: password) let session = URLSession(configuration: config, delegate: delegate, delegateQueue: nil) self.username = username self.password = password @@ -69,36 +69,36 @@ public actor OpenAPIService { } public func updateBaseURL(with newURL: URL) async { - if newURL != url { - let config = prepareURLSessionConfiguration(longPolling: longPolling) - let session = URLSession(configuration: config) - url = newURL - client = Client( - serverURL: newURL.appending(path: "/rest"), - transport: URLSessionTransport(configuration: .init(session: session)), - middlewares: [ - AuthorisationMiddleware(username: username, password: password), - LoggingMiddleware() - ] - ) - } + guard newURL != url else { return } + url = newURL + + let config = prepareURLSessionConfiguration(longPolling: longPolling) + let session = URLSession(configuration: config) + client = Client( + serverURL: newURL.appending(path: "/rest"), + transport: URLSessionTransport(configuration: .init(session: session)), + middlewares: [ + AuthorisationMiddleware(username: username, password: password), + LoggingMiddleware() + ] + ) } // timeoutIntervalForRequest/timeoutIntervalForResource need to be passed through URLSessionConfiguration when URLSession is created. Therefore create a new APIClient to change values. public func updateForLongPolling(_ newlongPolling: Bool) async { - if newlongPolling != longPolling { - let config = prepareURLSessionConfiguration(longPolling: longPolling) - let session = URLSession(configuration: config) - longPolling = newlongPolling - client = Client( - serverURL: url!.appending(path: "/rest"), - transport: URLSessionTransport(configuration: .init(session: session)), - middlewares: [ - AuthorisationMiddleware(username: username, password: password), - LoggingMiddleware() - ] - ) - } + guard newlongPolling != longPolling else { return } + longPolling = newlongPolling + + let config = prepareURLSessionConfiguration(longPolling: longPolling) + let session = URLSession(configuration: config) + client = Client( + serverURL: url!.appending(path: "/rest"), + transport: URLSessionTransport(configuration: .init(session: session)), + middlewares: [ + AuthorisationMiddleware(username: username, password: password), + LoggingMiddleware() + ] + ) } } @@ -108,9 +108,12 @@ extension OpenAPIService: OpenHABSitemapsService { guard let url else { throw OpenAPIServiceError.noRootURL } logger.log("Trying to getSitemaps for : \(url.debugDescription)") - switch try await client.getSitemaps(.init()) { - case let .ok(okresponse): return try okresponse.body.json.map(OpenHABSitemap.init) - case .undocumented: throw OpenAPIServiceError.undocumented + let response = try await client.getSitemaps(.init()) + switch response { + case let .ok(okresponse): + return try okresponse.body.json.map(OpenHABSitemap.init) + case let .undocumented(statusCode, undocumentedPayload): + throw OpenAPIServiceError.undocumented(statusCode: statusCode, undocumentedPayload: undocumentedPayload) } } } diff --git a/OpenHABCore/Sources/OpenHABCore/Util/APIActorDelegate.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIServiceDelegate.swift similarity index 97% rename from OpenHABCore/Sources/OpenHABCore/Util/APIActorDelegate.swift rename to OpenHABCore/Sources/OpenHABCore/Util/OpenAPIServiceDelegate.swift index 42bbed422..fda05ebc2 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/APIActorDelegate.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIServiceDelegate.swift @@ -14,7 +14,7 @@ import os // MARK: - URLSessionDelegate for Client Certificates and Basic Auth -class APIActorDelegate: NSObject, URLSessionDelegate, URLSessionTaskDelegate { +class OpenAPIServiceDelegate: NSObject, URLSessionDelegate, URLSessionTaskDelegate { private let username: String private let password: String diff --git a/openHAB/OpenHABSitemapViewController.swift b/openHAB/OpenHABSitemapViewController.swift index be1c67c03..dd1673084 100644 --- a/openHAB/OpenHABSitemapViewController.swift +++ b/openHAB/OpenHABSitemapViewController.swift @@ -116,7 +116,7 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel search.isActive && !searchBarIsEmpty } - private var apiactor: OpenAPIService? + private var openAPIService: OpenAPIService? @IBOutlet private var widgetTableView: UITableView! @@ -128,7 +128,7 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel pageNetworkStatus = nil sitemaps = [] widgetTableView.tableFooterView = UIView() - Task { await apiactor = OpenAPIService(username: openHABUsername, password: openHABPassword, alwaysSendBasicAuth: openHABAlwaysSendCreds, url: URL(string: openHABRootUrl) ?? URL(staticString: "about:blank")) } + Task { await openAPIService = OpenAPIService(username: openHABUsername, password: openHABPassword, alwaysSendBasicAuth: openHABAlwaysSendCreds, url: URL(string: openHABRootUrl) ?? URL(staticString: "about:blank")) } registerTableViewCells() configureTableView() @@ -205,7 +205,7 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel } os_log("OpenHABSitemapViewController pageUrl is empty, this is first launch", log: .viewCycle, type: .info) } else { - Task { await apiactor?.updateBaseURL(with: URL(string: appData!.openHABRootUrl)!) } + Task { await openAPIService?.updateBaseURL(with: URL(string: appData!.openHABRootUrl)!) } // we only want to our watcher to notify us about changes, and not the inital value activeServerWatcher = activeServerWatcher.dropFirst().eraseToAnyPublisher() if !pageNetworkStatusChanged() { @@ -382,7 +382,7 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel // } // } - currentPage = try await apiactor?.openHABpollPage(sitemapname: defaultSitemap, longPolling: longPolling) + currentPage = try await openAPIService?.openHABpollPage(sitemapname: defaultSitemap, longPolling: longPolling) if isFiltering { filterContentForSearchText(search.searchBar.text) @@ -403,19 +403,25 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel loadPage(true) } catch let error as DecodingError { os_log("DecodingError %{PUBLIC}@", log: .default, type: .error, error.localizedDescription) - // } catch let error as NSError where error.code == -1001 { - // os_log("Timeout, restarting requests", log: OSLog.remoteAccess, type: .error) - // loadPage(false) - } catch { os_log("On LoadPage \"%{PUBLIC}@\" code: %d ", log: .remoteAccess, type: .error, error.localizedDescription) NetworkConnection.atmosphereTrackingId = "" // Error DispatchQueue.main.async { - if (error as NSError?)?.code == -1012 { - self.showPopupMessage(seconds: 5, title: NSLocalizedString("error", comment: ""), message: NSLocalizedString("ssl_certificate_error", comment: ""), theme: .error) + if let urlError = error as? URLError, urlError.code == .clientCertificateRejected { + self.showPopupMessage( + seconds: 5, + title: NSLocalizedString("error", comment: ""), + message: NSLocalizedString("ssl_certificate_error", comment: ""), + theme: .error + ) } else { - self.showPopupMessage(seconds: 5, title: NSLocalizedString("error", comment: ""), message: error.localizedDescription, theme: .error) + self.showPopupMessage( + seconds: 5, + title: NSLocalizedString("error", comment: ""), + message: error.localizedDescription, + theme: .error + ) } } } @@ -430,8 +436,8 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel Task { do { logger.debug("Running selectSitemap for URL: \(self.appData?.openHABRootUrl ?? "")") - apiactor = await OpenAPIService(username: appData!.openHABUsername, password: appData!.openHABPassword, alwaysSendBasicAuth: appData!.openHABAlwaysSendCreds, url: URL(string: appData?.openHABRootUrl ?? "")!) - sitemaps = try await apiactor?.openHABSitemaps() ?? [] + openAPIService = await OpenAPIService(username: appData!.openHABUsername, password: appData!.openHABPassword, alwaysSendBasicAuth: appData!.openHABAlwaysSendCreds, url: URL(string: appData?.openHABRootUrl ?? "")!) + sitemaps = try await openAPIService?.openHABSitemaps() ?? [] switch sitemaps.count { case 2...: @@ -458,15 +464,24 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel } widgetTableView.reloadData() } catch _ as OpenAPIServiceError { - logger.debug("APIActorError on OpenHABSitemapViewController") + logger.debug("OpenAPIService Error on OpenHABSitemapViewController") } catch { os_log("%{PUBLIC}@", log: .default, type: .error, error.localizedDescription) DispatchQueue.main.async { - // Error - if (error as NSError?)?.code == -1012 { - self.showPopupMessage(seconds: 5, title: NSLocalizedString("error", comment: ""), message: NSLocalizedString("ssl_certificate_error", comment: ""), theme: .error) + if let urlError = error as? URLError, urlError.code == .clientCertificateRejected { + self.showPopupMessage( + seconds: 5, + title: NSLocalizedString("error", comment: ""), + message: NSLocalizedString("ssl_certificate_error", comment: ""), + theme: .error + ) } else { - self.showPopupMessage(seconds: 5, title: NSLocalizedString("error", comment: ""), message: error.localizedDescription, theme: .error) + self.showPopupMessage( + seconds: 5, + title: NSLocalizedString("error", comment: ""), + message: error.localizedDescription, + theme: .error + ) } } } @@ -563,7 +578,7 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel } func sendCommand(itemname: String, command: String) { - Task { try await apiactor?.openHABSendItemCommand(itemname: itemname, command: command) } + Task { try await openAPIService?.openHABSendItemCommand(itemname: itemname, command: command) } } override func reloadView() { diff --git a/openHAB/OpenHABWebViewController.swift b/openHAB/OpenHABWebViewController.swift index 4f7a4f3a6..15377286b 100644 --- a/openHAB/OpenHABWebViewController.swift +++ b/openHAB/OpenHABWebViewController.swift @@ -349,10 +349,10 @@ extension OpenHABWebViewController: WKNavigationDelegate { func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { os_log("didFail - webView.url %{PUBLIC}@", log: .wkwebview, type: .info, String(describing: webView.url?.description)) - let nserror = error as NSError - if nserror.code != NSURLErrorCancelled { - pageLoadError(message: nserror.localizedDescription) + if let urlError = error as? URLError, urlError.code == .cancelled { + return // Ignore cancelled requests } + pageLoadError(message: error.localizedDescription) } func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { diff --git a/openHABTestsSwift/OpenHABSVGTests.swift b/openHABTestsSwift/OpenHABSVGTests.swift index 2202b8ade..3b10ceec7 100644 --- a/openHABTestsSwift/OpenHABSVGTests.swift +++ b/openHABTestsSwift/OpenHABSVGTests.swift @@ -31,11 +31,17 @@ class OpenHABSVGTests: XCTestCase { let data = try Data(contentsOf: url!) let svgkSourceNSData = SVGKSourceNSData.source(from: data, urlForRelativeLinks: nil) let parseResults = SVGKParser.parseSource(usingDefaultSVGKParser: svgkSourceNSData) + XCTAssertEqual(parseResults?.parsedDocument, nil, "parsedDocument not empty though it was expected to be because XML is invalid") XCTAssertEqual(parseResults?.errorsFatal.count ?? 0, 0, "No errorsFatal expected") - XCTAssertEqual((parseResults?.warnings[0] as! NSError).localizedDescription, "xmlns: URI &ns_svg; is not absolute\n") + + if let firstWarning = parseResults?.warnings.first as? Error { + XCTAssertEqual(firstWarning.localizedDescription, "xmlns: URI &ns_svg; is not absolute\n") + } else { + XCTFail("Expected a warning but found none or an unexpected type") + } } catch { - XCTFail("Whoops, an unexpected error occured while unit testing SVG rendering") + XCTFail("Whoops, an unexpected error occurred while unit testing SVG rendering: \(error)") } } @@ -49,8 +55,11 @@ class OpenHABSVGTests: XCTestCase { let parseResults = SVGKParser.parseSource(usingDefaultSVGKParser: svgkSourceNSData) XCTAssertNotEqual(parseResults?.parsedDocument, nil, "Non nil parsedDocument expected") XCTAssertNotEqual(parseResults?.errorsFatal.count, 0, "errorsFatal are 0") - let fatalError = parseResults?.errorsFatal[0] as! NSError - XCTAssertEqual(fatalError.localizedDescription, "Exception = Found an SVG tag that points to a non-existent element. Missing element: id = e") + if let fatalError = parseResults?.errorsFatal.first as? Error { + XCTAssertEqual(fatalError.localizedDescription, "Exception = Found an SVG tag that points to a non-existent element. Missing element: id = e") + } else { + XCTFail("Expected a fatal error but found none or an unexpected type") + } } catch { XCTFail("Whoops, an unexpected error occured while unit testing SVG rendering") } From e702a1c640be7c8eb3c183568cbff820169227d3 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Mon, 24 Feb 2025 22:40:38 +0100 Subject: [PATCH 017/476] Get rid of extension on URLSessionTask for authAttemptCount using the outdated objc_getAssociatedObject Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../Sources/OpenHABCore/Util/HTTPClient.swift | 18 +++--------------- .../Util/OpenAPIServiceDelegate.swift | 5 +++-- 2 files changed, 6 insertions(+), 17 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift b/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift index f6bfb43cf..4d180efbf 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift @@ -57,6 +57,7 @@ public class HTTPClient: NSObject { private let ignoreSSL: Bool private var evaluateContinuation: CheckedContinuation? private var trustedCertificates: [String: Data] = [:] + private var authAttemptCounts = [URLSessionTask: Int]() public init(baseURL: URL? = nil, username: String, password: String, alwaysSendBasicAuth: Bool = false, ignoreSSL: Bool = false) { self.baseURL = baseURL @@ -431,8 +432,8 @@ extension HTTPClient: URLSessionDelegate, URLSessionTaskDelegate { return await handleServerTrust(challenge: challenge) case NSURLAuthenticationMethodDefault, NSURLAuthenticationMethodHTTPBasic: if let task { - task.authAttemptCount += 1 - if task.authAttemptCount > 1 { + authAttemptCounts[task, default: 0] += 1 + if authAttemptCounts[task]! > 1 { return (.cancelAuthenticationChallenge, nil) } else { return await handleBasicAuth(challenge: challenge) @@ -547,19 +548,6 @@ extension HTTPClient: URLSessionDelegate, URLSessionTaskDelegate { } } -extension URLSessionTask { - private static var authAttemptCountKey: UInt8 = 0 - - var authAttemptCount: Int { - get { - objc_getAssociatedObject(self, &URLSessionTask.authAttemptCountKey) as? Int ?? 0 - } - set { - objc_setAssociatedObject(self, &URLSessionTask.authAttemptCountKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) - } - } -} - public extension Notification.Name { static let evaluateServerTrust = Notification.Name("evaluateServerTrust") static let evaluateCertificateMismatch = Notification.Name("evaluateCertificateMismatch") diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIServiceDelegate.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIServiceDelegate.swift index fda05ebc2..6055e80be 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIServiceDelegate.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIServiceDelegate.swift @@ -17,6 +17,7 @@ import os class OpenAPIServiceDelegate: NSObject, URLSessionDelegate, URLSessionTaskDelegate { private let username: String private let password: String + private var authAttemptCounts = [URLSessionTask: Int]() init(username: String, password: String) { self.username = username @@ -39,8 +40,8 @@ class OpenAPIServiceDelegate: NSObject, URLSessionDelegate, URLSessionTaskDelega return await handleServerTrust(challenge: challenge) case NSURLAuthenticationMethodDefault, NSURLAuthenticationMethodHTTPBasic: if let task { - task.authAttemptCount += 1 - if task.authAttemptCount > 1 { + authAttemptCounts[task, default: 0] += 1 + if authAttemptCounts[task]! > 1 { return (.cancelAuthenticationChallenge, nil) } else { return await handleBasicAuth(challenge: challenge) From de65f19447e13c9a422747f3528a9fa5117b50a8 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Tue, 25 Feb 2025 11:08:00 +0100 Subject: [PATCH 018/476] Address failed test run: Access first element of parseResults.warnings and parseResults.errorsFatal Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- openHABTestsSwift/OpenHABSVGTests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openHABTestsSwift/OpenHABSVGTests.swift b/openHABTestsSwift/OpenHABSVGTests.swift index 3b10ceec7..af10dc851 100644 --- a/openHABTestsSwift/OpenHABSVGTests.swift +++ b/openHABTestsSwift/OpenHABSVGTests.swift @@ -35,7 +35,7 @@ class OpenHABSVGTests: XCTestCase { XCTAssertEqual(parseResults?.parsedDocument, nil, "parsedDocument not empty though it was expected to be because XML is invalid") XCTAssertEqual(parseResults?.errorsFatal.count ?? 0, 0, "No errorsFatal expected") - if let firstWarning = parseResults?.warnings.first as? Error { + if let firstWarning = parseResults?.warnings[0] as? Error { XCTAssertEqual(firstWarning.localizedDescription, "xmlns: URI &ns_svg; is not absolute\n") } else { XCTFail("Expected a warning but found none or an unexpected type") @@ -55,7 +55,7 @@ class OpenHABSVGTests: XCTestCase { let parseResults = SVGKParser.parseSource(usingDefaultSVGKParser: svgkSourceNSData) XCTAssertNotEqual(parseResults?.parsedDocument, nil, "Non nil parsedDocument expected") XCTAssertNotEqual(parseResults?.errorsFatal.count, 0, "errorsFatal are 0") - if let fatalError = parseResults?.errorsFatal.first as? Error { + if let fatalError = parseResults?.errorsFatal[0] as? Error { XCTAssertEqual(fatalError.localizedDescription, "Exception = Found an SVG tag that points to a non-existent element. Missing element: id = e") } else { XCTFail("Expected a fatal error but found none or an unexpected type") From 29b1ccbfe84f24b0fa869098ba5e4523ac590190 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Tue, 25 Feb 2025 16:40:08 +0100 Subject: [PATCH 019/476] NotificationService now using internally async versions of functions Migration of functions in HTTPClient used by NotificatioService clients to async version Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- NotificationService/NotificationService.swift | 271 ++++++++++++++---- .../Sources/OpenHABCore/Util/HTTPClient.swift | 60 +++- openHAB/OpenHABSitemapViewController.swift | 9 +- 3 files changed, 277 insertions(+), 63 deletions(-) diff --git a/NotificationService/NotificationService.swift b/NotificationService/NotificationService.swift index 7b3504c56..d36ef5ad8 100644 --- a/NotificationService/NotificationService.swift +++ b/NotificationService/NotificationService.swift @@ -16,6 +16,29 @@ import os.log import UniformTypeIdentifiers import UserNotifications +enum NotificationServiceError: Error { + case unknown + case noScheme(String?) + case failedToParse + case failedToDecode + case handleNotificationCouldNotAttach + + var localizedDescription: String { + switch self { + case .unknown: + "Unknown error" + case let .noScheme(searched): + "Could not find scheme \(searched ?? "")" + case .failedToParse: + "Failed to parse JSON" + case .failedToDecode: + "Failed to decode base64 string to Data" + case .handleNotificationCouldNotAttach: + "HandleNotification could not attach" + } + } +} + class NotificationService: UNNotificationServiceExtension { var contentHandler: ((UNNotificationContent) -> Void)? var bestAttemptContent: UNMutableNotificationContent? @@ -24,79 +47,96 @@ class NotificationService: UNNotificationServiceExtension { override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { self.contentHandler = contentHandler bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent) - if let bestAttemptContent { - var notificationActions: [UNNotificationAction] = [] - let userInfo = bestAttemptContent.userInfo + guard let bestAttemptContent else { return } - os_log("didReceive userInfo %{PUBLIC}@", log: .default, type: .info, userInfo) + var notificationActions: [UNNotificationAction] = [] + let userInfo = bestAttemptContent.userInfo - if let title = userInfo["title"] as? String { - bestAttemptContent.title = title - } - if let message = userInfo["message"] as? String { - bestAttemptContent.body = message - } + os_log("didReceive userInfo %{PUBLIC}@", log: .default, type: .info, userInfo) - // Check if the user has defined custom actions in the payload - if let actionsArray = parseActions(userInfo), let category = parseCategory(userInfo) { - for actionDict in actionsArray { - if let action = actionDict["action"], - let title = actionDict["title"] { - var options: UNNotificationActionOptions = [] - // navigate/browser options need to bring the app to the foreground - if action.hasPrefix("ui") || action.hasPrefix("http") || action.hasPrefix("app") { - options = [.foreground] - } - let notificationAction = UNNotificationAction( - identifier: action, - title: title, - options: options - ) - notificationActions.append(notificationAction) + if let title = userInfo["title"] as? String { + bestAttemptContent.title = title + } + if let message = userInfo["message"] as? String { + bestAttemptContent.body = message + } + + // Check if the user has defined custom actions in the payload + if let actionsArray = parseActions(userInfo), let category = parseCategory(userInfo) { + for actionDict in actionsArray { + if let action = actionDict["action"], + let title = actionDict["title"] { + var options: UNNotificationActionOptions = [] + // navigate/browser options need to bring the app to the foreground + if action.hasPrefix("ui") || action.hasPrefix("http") || action.hasPrefix("app") { + options = [.foreground] } + let notificationAction = UNNotificationAction( + identifier: action, + title: title, + options: options + ) + notificationActions.append(notificationAction) } - if !notificationActions.isEmpty { - os_log("didReceive registering %{PUBLIC}@ for category %{PUBLIC}@", log: .default, type: .info, notificationActions, category) - let notificationCategory = - UNNotificationCategory( - identifier: category, - actions: notificationActions, - intentIdentifiers: [], - options: .customDismissAction - ) - UNUserNotificationCenter.current().getNotificationCategories { existingCategories in - var updatedCategories = existingCategories - os_log("handleNotification adding category %{PUBLIC}@", log: .default, type: .info, category) - updatedCategories.insert(notificationCategory) - UNUserNotificationCenter.current().setNotificationCategories(updatedCategories) - } + } + if !notificationActions.isEmpty { + os_log("didReceive registering %{PUBLIC}@ for category %{PUBLIC}@", log: .default, type: .info, notificationActions, category) + let notificationCategory = + UNNotificationCategory( + identifier: category, + actions: notificationActions, + intentIdentifiers: [], + options: .customDismissAction + ) + UNUserNotificationCenter.current().getNotificationCategories { existingCategories in + var updatedCategories = existingCategories + os_log("handleNotification adding category %{PUBLIC}@", log: .default, type: .info, category) + updatedCategories.insert(notificationCategory) + UNUserNotificationCenter.current().setNotificationCategories(updatedCategories) } } + } - // check if there is an attachment to put on the notification - // this should be last as we need to wait for media - // TODO: we should support relative paths and try the user's openHAB (local,remote) for content - if let attachmentURLString = userInfo["media-attachment-url"] as? String { - let isItem = attachmentURLString.starts(with: "item:") + // check if there is an attachment to put on the notification + // this should be last as we need to wait for media + // TODO: we should support relative paths and try the user's openHAB (local,remote) for content + if let attachmentURLString = userInfo["media-attachment-url"] as? String { + /// HERE we switch to async usage +// let downloadCompletionHandler: @Sendable (UNNotificationAttachment?) -> Void = { attachment in +// if let attachment { +// os_log("handleNotification attaching %{PUBLIC}@", log: .default, type: .info, attachmentURLString) +// bestAttemptContent.attachments = [attachment] +// } else { +// os_log("handleNotification could not attach %{PUBLIC}@", log: .default, type: .info, attachmentURLString) +// } +// contentHandler(bestAttemptContent) +// } - let downloadCompletionHandler: @Sendable (UNNotificationAttachment?) -> Void = { attachment in - if let attachment { - os_log("handleNotification attaching %{PUBLIC}@", log: .default, type: .info, attachmentURLString) - bestAttemptContent.attachments = [attachment] +// if attachmentURLString.starts(with: "item:") { +// downloadAndAttachItemImage(itemURI: attachmentURLString, completion: downloadCompletionHandler) +// } else { +// downloadAndAttachMedia(url: attachmentURLString, completion: downloadCompletionHandler) +// } + Task { + do { + let unNotificationAttachment = if attachmentURLString.starts(with: "item:") { + try await downloadAndAttachItemImage(itemURI: attachmentURLString) } else { - os_log("handleNotification could not attach %{PUBLIC}@", log: .default, type: .info, attachmentURLString) + try await downloadAndAttachMedia(url: attachmentURLString) } - contentHandler(bestAttemptContent) - } - - if isItem { - downloadAndAttachItemImage(itemURI: attachmentURLString, completion: downloadCompletionHandler) - } else { - downloadAndAttachMedia(url: attachmentURLString, completion: downloadCompletionHandler) + if let unNotificationAttachment { + bestAttemptContent.attachments = [unNotificationAttachment] + } else { + throw NotificationServiceError.handleNotificationCouldNotAttach + } + } catch { + os_log("Error fetching data: %{PUBLIC}@", log: .default, type: .error, error.localizedDescription) } - } else { contentHandler(bestAttemptContent) } + + } else { + contentHandler(bestAttemptContent) } } @@ -164,6 +204,35 @@ class NotificationService: UNNotificationServiceExtension { } } + // Async version + private func downloadAndAttachMedia(url: String) async throws -> UNNotificationAttachment? { + let client = HTTPClient(username: Preferences.username, password: Preferences.username, alwaysSendBasicAuth: Preferences.alwaysSendCreds) + if url.starts(with: "/") { + let connection1 = ConnectionConfiguration( + url: Preferences.localUrl, + priority: 0 + ) + let connection2 = ConnectionConfiguration( + url: Preferences.remoteUrl, + priority: 1 + ) + NetworkTracker.shared.startTracking(connectionConfigurations: [connection1, connection2], username: Preferences.username, password: Preferences.password, alwaysSendBasicAuth: Preferences.alwaysSendCreds, ignoreSSLVerification: Preferences.ignoreSSL) + NetworkTracker.shared.waitForActiveConnection { activeConnection in + if let openHABUrl = activeConnection?.configuration.url, let uurl = URL(string: openHABUrl) { + Task { + let (url, urlResponse) = try await client.downloadFile(url: uurl.appendingPathComponent(url)) + return await self.attachFile(localURL: url, mimeType: urlResponse.mimeType) + } + } + } + .store(in: &cancellables) + } else if let uurl = URL(string: url) { + let (url, urlResponse) = try await client.downloadFile(url: uurl.appendingPathComponent(url)) + return await attachFile(localURL: url, mimeType: urlResponse.mimeType) + } + return nil + } + func downloadAndAttachItemImage(itemURI: String, completion: @escaping (UNNotificationAttachment?) -> Void) { guard let itemURI = URL(string: itemURI), let scheme = itemURI.scheme else { os_log("Could not find scheme %{PUBLIC}@", log: .default, type: .info) @@ -223,6 +292,63 @@ class NotificationService: UNNotificationServiceExtension { .store(in: &cancellables) } + // Async version + func downloadAndAttachItemImage(itemURI: String) async throws -> UNNotificationAttachment? { + guard let itemURI = URL(string: itemURI), let scheme = itemURI.scheme else { + throw NotificationServiceError.noScheme(itemURI) + } + + let itemName = String(itemURI.absoluteString.dropFirst(scheme.count + 1)) + + let connection1 = ConnectionConfiguration( + url: Preferences.localUrl, + priority: 0 + ) + let connection2 = ConnectionConfiguration( + url: Preferences.remoteUrl, + priority: 1 + ) + NetworkTracker.shared.startTracking( + connectionConfigurations: [connection1, connection2], + username: Preferences.username, + password: Preferences.password, + alwaysSendBasicAuth: Preferences.alwaysSendCreds, + ignoreSSLVerification: Preferences.ignoreSSL + ) + + return try await withCheckedThrowingContinuation { _ in + NetworkTracker.shared.waitForActiveConnection { [self] activeConnection in + if let openHABUrl = activeConnection?.configuration.url, let url = URL(string: openHABUrl) { + Task { + let client = await OpenAPIService( + username: Preferences.username, + password: Preferences.password, + alwaysSendBasicAuth: Preferences.alwaysSendCreds, + url: url + ) + let item = try await client.getItemByName(id: itemName) + guard let state = item?.state else { return nil as UNNotificationAttachment? } + + // Extract MIME type and base64 string + let pattern = /^data:(.*?);base64,(.*)$/ + guard let firstMatch = state.firstMatch(of: pattern) else { throw NotificationServiceError.failedToParse } + + let mimeType = String(firstMatch.1) + let base64String = String(firstMatch.2) + guard let imageData = Data(base64Encoded: base64String) else { throw NotificationServiceError.failedToDecode } + // Create a temporary file URL + let tempDirectory = FileManager.default.temporaryDirectory + let tempFileURL = tempDirectory.appendingPathComponent(UUID().uuidString) + try imageData.write(to: tempFileURL) + os_log("Image saved to temporary file: %{PUBLIC}@", log: .default, type: .info, tempFileURL.absoluteString) + return await attachFile(localURL: tempFileURL, mimeType: mimeType) + } + } + } + .store(in: &cancellables) + } + } + func attachFile(localURL: URL, mimeType: String?, completion: @escaping (UNNotificationAttachment?) -> Void) { do { let fileManager = FileManager.default @@ -249,4 +375,31 @@ class NotificationService: UNNotificationServiceExtension { } completion(nil) } + + // Async version + func attachFile(localURL: URL, mimeType: String?) async -> UNNotificationAttachment? { + do { + let fileManager = FileManager.default + let tempDirectory = NSTemporaryDirectory() + let tempFile = URL(fileURLWithPath: tempDirectory).appendingPathComponent(UUID().uuidString) + + try fileManager.moveItem(at: localURL, to: tempFile) + let attachment: UNNotificationAttachment? + + if let mimeType, + let utType = UTType(mimeType: mimeType), + utType.conforms(to: .data) { + let newTempFile = tempFile.appendingPathExtension(utType.preferredFilenameExtension ?? "") + try fileManager.moveItem(at: tempFile, to: newTempFile) + attachment = try UNNotificationAttachment(identifier: UUID().uuidString, url: newTempFile, options: nil) + } else { + os_log("Unrecognized MIME type or file extension", log: .default, type: .error) + attachment = nil + } + return attachment + } catch { + os_log("Failed to create UNNotificationAttachment: %{PUBLIC}@", log: .default, type: .error, error.localizedDescription) + } + return nil + } } diff --git a/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift b/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift index 4d180efbf..2588ace61 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift @@ -196,13 +196,27 @@ public class HTTPClient: NSObject { - response: The URL response object providing response metadata, such as HTTP headers and status code. - error: An error object that indicates why the request failed, or `nil` if the request was successful. */ - public func downloadFile(url: URL, completionHandler: @escaping @Sendable (URL?, URLResponse?, (any Error)?) -> Void) -> URLSessionTask? { + public func downloadFile(url: URL, completionHandler: @escaping @Sendable (URL?, URLResponse?, (any Error)?) -> Void) { doRequest(baseURL: url, path: nil, method: "GET", download: true) { result, response, error in let fileURL = result as? URL completionHandler(fileURL, response, error) } } + public func downloadFile(url: URL) async throws -> (URL, URLResponse) { + let (result, response) = try await doRequest(baseURL: url, path: nil, method: "GET", download: true) + + let fileURL = result as? URL + + guard let fileURL1 = fileURL else { + fatalError("Expected non-nil result 'fileURL1' in the non-error case") + } + guard let response1 = response else { + fatalError("Expected non-nil result 'response1' in the non-error case") + } + return (fileURL1, response1) + } + public func sendCommand(url: URL? = nil, itemName: String, command: String, completion: @escaping (String?, Error?) -> Void) -> URLSessionTask? { os_log("sendCommand %{public}@ %{public}@", log: .default, type: .debug, command, itemName) return doPost(baseURL: url, path: "/rest/items/\(itemName)", body: command) { data, _, error in @@ -287,6 +301,48 @@ public class HTTPClient: NSObject { } } + public func doRequest(baseURL: URL?, + path: String?, + method: String, + headers: [String: String]? = nil, + timeout: TimeInterval = 60.0, + body: String? = nil, + download: Bool = false) async throws -> (Any?, URLResponse?) { + guard var url = baseURL ?? self.baseURL else { + os_log("doRequest ERROR: Base URL is nil", log: .networking, type: .info) + throw HTTPClientError.baseURLIsNil + } + + if let path { + url.appendPathComponent(path) + } + + var request = URLRequest(url: url) + request.httpMethod = method + request.timeoutInterval = timeout + if let headers { + for (key, value) in headers { + request.setValue(value, forHTTPHeaderField: key) + } + } + if let body { + request.httpBody = body.data(using: .utf8) + request.setValue("text/plain", forHTTPHeaderField: "Content-Type") + } + + let (result, response) = try await performRequest(request: request, download: download) + if let response = response as? HTTPURLResponse { + if (400 ... 599).contains(response.statusCode) { + os_log("HTTP error from URL %{public}@ : %{public}d", log: .networking, type: .error, url.absoluteString, response.statusCode) + throw HTTPClientError.httpError(response.statusCode) + } else { + os_log("Response from URL %{public}@ : %{public}d", log: .networking, type: .info, url.absoluteString, response.statusCode) + return (result, response) + } + } + fatalError() + } + private func performRequest(request: URLRequest, download: Bool, completion: @escaping (Any?, URLResponse?, Error?) -> Void) -> URLSessionTask? { var request = request if alwaysSendBasicAuth { @@ -306,8 +362,6 @@ public class HTTPClient: NSObject { return task } - @available(watchOS 8.0, *) - @available(iOS 15.0, *) private func performRequest(request: URLRequest, download: Bool) async throws -> (Any?, URLResponse?) { var request = request if alwaysSendBasicAuth { diff --git a/openHAB/OpenHABSitemapViewController.swift b/openHAB/OpenHABSitemapViewController.swift index dd1673084..b6f4fd9d5 100644 --- a/openHAB/OpenHABSitemapViewController.swift +++ b/openHAB/OpenHABSitemapViewController.swift @@ -128,7 +128,14 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel pageNetworkStatus = nil sitemaps = [] widgetTableView.tableFooterView = UIView() - Task { await openAPIService = OpenAPIService(username: openHABUsername, password: openHABPassword, alwaysSendBasicAuth: openHABAlwaysSendCreds, url: URL(string: openHABRootUrl) ?? URL(staticString: "about:blank")) } + Task { + await openAPIService = OpenAPIService( + username: openHABUsername, + password: openHABPassword, + alwaysSendBasicAuth: openHABAlwaysSendCreds, + url: URL(string: openHABRootUrl) ?? URL(staticString: "about:blank") + ) + } registerTableViewCells() configureTableView() From ebd1a6d093e8b2fa5ea1d72c28f5584c62338cda Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Tue, 25 Feb 2025 23:07:12 +0100 Subject: [PATCH 020/476] Migrating NetworkTracker to OpenAPIService Align init signature for OpenAPIService to HTTPClient Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- NotificationService/NotificationService.swift | 6 +- .../OpenHABCore/Util/NetworkTracker.swift | 60 ++++++++++++------- .../OpenHABCore/Util/OpenAPIService.swift | 17 ++++-- openHAB/DrawerView.swift | 3 +- openHAB/OpenHABSitemapViewController.swift | 11 +++- 5 files changed, 66 insertions(+), 31 deletions(-) diff --git a/NotificationService/NotificationService.swift b/NotificationService/NotificationService.swift index d36ef5ad8..97f1879e1 100644 --- a/NotificationService/NotificationService.swift +++ b/NotificationService/NotificationService.swift @@ -44,6 +44,8 @@ class NotificationService: UNNotificationServiceExtension { var bestAttemptContent: UNMutableNotificationContent? var cancellables = Set() + let logger = Logger(subsystem: "com.yourapp.network", category: "NotificationService") + override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { self.contentHandler = contentHandler bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent) @@ -321,10 +323,10 @@ class NotificationService: UNNotificationServiceExtension { if let openHABUrl = activeConnection?.configuration.url, let url = URL(string: openHABUrl) { Task { let client = await OpenAPIService( + baseURL: url, username: Preferences.username, password: Preferences.password, - alwaysSendBasicAuth: Preferences.alwaysSendCreds, - url: url + alwaysSendBasicAuth: Preferences.alwaysSendCreds ) let item = try await client.getItemByName(id: itemName) guard let state = item?.state else { return nil as UNNotificationAttachment? } diff --git a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift index a68679ea7..4096dca2d 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift @@ -36,12 +36,24 @@ public struct ConnectionInfo: Equatable { public let version: Int } +enum NetworkTrackerError: Error, CustomDebugStringConvertible { + case invalidServerVersion + + var debugDescription: String { + switch self { + case .invalidServerVersion: + "Invalid server version" + } + } +} + public final class NetworkTracker: ObservableObject { public static let shared = NetworkTracker() @Published public private(set) var activeConnection: ConnectionInfo? @Published public private(set) var status: NetworkStatus = .connecting public private(set) var httpClient: HTTPClient? + public private(set) var openApiService: OpenAPIService? private let monitor: NWPathMonitor private let monitorQueue = DispatchQueue.global(qos: .background) @@ -52,6 +64,8 @@ public final class NetworkTracker: ObservableObject { private let connectedRetryInterval: TimeInterval = 60 // amount of time we scan for better connections when connected private let disconnectedRetryInterval: TimeInterval = 30 // amount of time we scan when not connected + let logger = Logger(subsystem: "com.yourapp.network", category: "NetworkTracker") + private init() { monitor = NWPathMonitor() monitor.pathUpdateHandler = { [weak self] path in @@ -72,6 +86,9 @@ public final class NetworkTracker: ObservableObject { os_log("NetworkConnection: startTracking", log: OSLog.default, type: .info) self.connectionConfigurations = adjustMyOpenHABHosts(in: connectionConfigurations) httpClient = HTTPClient(username: username, password: password, alwaysSendBasicAuth: alwaysSendBasicAuth, ignoreSSL: ignoreSSLVerification) + Task { + openApiService = await OpenAPIService(username: username, password: password, alwaysSendBasicAuth: alwaysSendBasicAuth, ignoreSSL: ignoreSSLVerification) + } setActiveConnection(nil) attemptConnection() } @@ -153,33 +170,34 @@ public final class NetworkTracker: ObservableObject { checkOutstanding = true // Signal that checks are outstanding os_log("attemptConnection trying %{PUBLIC}@", log: OSLog.default, type: .info, configuration.url) if let url = URL(string: configuration.url) { - httpClient?.getServerProperties(baseURL: url) { [weak self] props, error in - guard let self else { return } + Task { defer { dispatchGroup.leave() // When each check completes, this signals the group that it's done } - if let error { - os_log("Network status: Failed to connect to %{PUBLIC}@ : %{PUBLIC}@", log: OSLog.default, type: .error, configuration.url, error.localizedDescription) - } else { - let version = Int(props?.version ?? "0") - if let version, version > 1 { + do { + await openApiService?.updateBaseURL(with: url) + let serverProperties = try await openApiService?.getRoot() + + let version = Int(serverProperties?.version ?? "0") + guard let version, version > 1 else { throw NetworkTrackerError.invalidServerVersion } + let connectionInfo = ConnectionInfo(configuration: configuration, version: version) + if configuration.priority == 0, highestPriorityConnection == nil { + // Found a high-priority (0) connection + highestPriorityConnection = connectionInfo + priorityWorkItem?.cancel() // Stop the 2-second wait if highest priority succeeds + setActiveConnection(connectionInfo) + } else if highestPriorityConnection == nil { + // Check if this connection has a higher priority than the current firstAvailableConnection let connectionInfo = ConnectionInfo(configuration: configuration, version: version) - if configuration.priority == 0, highestPriorityConnection == nil { - // Found a high-priority (0) connection - highestPriorityConnection = connectionInfo - priorityWorkItem?.cancel() // Stop the 2-second wait if highest priority succeeds - setActiveConnection(connectionInfo) - } else if highestPriorityConnection == nil { - // Check if this connection has a higher priority than the current firstAvailableConnection - let connectionInfo = ConnectionInfo(configuration: configuration, version: version) - if firstAvailableConnection == nil || configuration.priority < firstAvailableConnection!.configuration.priority { - os_log("Network status: Found a higher priority available connection: %{PUBLIC}@", log: OSLog.default, type: .info, configuration.url) - firstAvailableConnection = connectionInfo - } + if firstAvailableConnection == nil || configuration.priority < firstAvailableConnection!.configuration.priority { + logger.info("Found a higher priority available connection: \(configuration.url)") + firstAvailableConnection = connectionInfo } - } else { - os_log("Network status: Invalid server version from %{PUBLIC}@", log: OSLog.default, type: .error, configuration.url) } + } catch let error as NetworkTrackerError { + logger.error("\(error.debugDescription)") + } catch { + logger.error("Failed to connect to \(configuration.url)") } } } diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift index aa6a59ea1..842a7f8b5 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift @@ -32,13 +32,21 @@ public actor OpenAPIService { private var client: Client private var url: URL? private var longPolling = false - private var alwaysSendBasicAuth = false - private var username: String - private var password: String + + private let username: String + private let password: String + private let alwaysSendBasicAuth: Bool + private let ignoreSSL: Bool private let logger = Logger(subsystem: "org.openhab.app", category: "OpenAPIService") - public init(username: String = "", password: String = "", alwaysSendBasicAuth: Bool = true, url: URL = URL(staticString: "about:blank")) async { + public init( + baseURL url: URL = URL(staticString: "about:blank"), + username: String, + password: String, + alwaysSendBasicAuth: Bool = false, + ignoreSSL: Bool = false + ) async { // TODO: Make use of prepareURLSessionConfiguration let config = URLSessionConfiguration.default // config.timeoutIntervalForRequest = if longPolling { 35.0 } else { 20.0 } @@ -50,6 +58,7 @@ public actor OpenAPIService { self.password = password self.alwaysSendBasicAuth = alwaysSendBasicAuth self.url = url + self.ignoreSSL = ignoreSSL client = Client( serverURL: url.appending(path: "/rest"), diff --git a/openHAB/DrawerView.swift b/openHAB/DrawerView.swift index 6bcc51278..b266fcc83 100644 --- a/openHAB/DrawerView.swift +++ b/openHAB/DrawerView.swift @@ -264,7 +264,8 @@ struct DrawerView: View { } .listStyle(.inset) .task { - let openAPIService = await OpenAPIService() + let openAPIService = await OpenAPIService(username: appData?.openHABUsername ?? "", password: appData?.openHABPassword ?? "", alwaysSendBasicAuth: appData?.openHABAlwaysSendCreds ?? false, ignoreSSL: false) + Task { do { guard let url = URL(string: appData?.openHABRootUrl ?? "") else { throw DrawerViewError.noRootURL } diff --git a/openHAB/OpenHABSitemapViewController.swift b/openHAB/OpenHABSitemapViewController.swift index b6f4fd9d5..c3a2f50e0 100644 --- a/openHAB/OpenHABSitemapViewController.swift +++ b/openHAB/OpenHABSitemapViewController.swift @@ -130,10 +130,10 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel widgetTableView.tableFooterView = UIView() Task { await openAPIService = OpenAPIService( + baseURL: URL(string: openHABRootUrl) ?? URL(staticString: "about:blank"), username: openHABUsername, password: openHABPassword, - alwaysSendBasicAuth: openHABAlwaysSendCreds, - url: URL(string: openHABRootUrl) ?? URL(staticString: "about:blank") + alwaysSendBasicAuth: openHABAlwaysSendCreds ) } @@ -443,7 +443,12 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel Task { do { logger.debug("Running selectSitemap for URL: \(self.appData?.openHABRootUrl ?? "")") - openAPIService = await OpenAPIService(username: appData!.openHABUsername, password: appData!.openHABPassword, alwaysSendBasicAuth: appData!.openHABAlwaysSendCreds, url: URL(string: appData?.openHABRootUrl ?? "")!) + openAPIService = await OpenAPIService( + baseURL: URL(string: appData?.openHABRootUrl ?? "")!, + username: appData!.openHABUsername, + password: appData!.openHABPassword, + alwaysSendBasicAuth: appData!.openHABAlwaysSendCreds + ) sitemaps = try await openAPIService?.openHABSitemaps() ?? [] switch sitemaps.count { From 9184dbc0b359e5aa869720b60bab8ca35d795d2e Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Wed, 26 Feb 2025 23:30:40 +0100 Subject: [PATCH 021/476] - openapi-generator extended to tag rules - Manipulated openapi.json to generate expected requestBody - In OpenHABRootViewController migrated call to rules/uuid/runnow to OpenAPIService runNow function - Removed migrated parts in HTTPClient - Removed parts migrated to async versions in NotificationService - Migrated NetworkTracker to make use OpenAPIService.getRoot instead of httpClient.getServerProperties Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- NotificationService/NotificationService.swift | 140 +- .../GeneratedSources/openapi/Client.swift | 1234 ++- .../GeneratedSources/openapi/Types.swift | 7933 ++++++++++++----- .../Sources/OpenHABCore/Util/HTTPClient.swift | 115 +- .../OpenHABCore/Util/NetworkTracker.swift | 25 +- .../OpenHABCore/Util/OpenAPIService.swift | 12 + .../openapi/openapi-generator-config.yml | 1 + .../Sources/OpenHABCore/openapi/openapi.json | 17 +- openHAB/OpenHABRootViewController.swift | 31 +- 9 files changed, 6927 insertions(+), 2581 deletions(-) diff --git a/NotificationService/NotificationService.swift b/NotificationService/NotificationService.swift index 97f1879e1..356fa4f5e 100644 --- a/NotificationService/NotificationService.swift +++ b/NotificationService/NotificationService.swift @@ -44,7 +44,7 @@ class NotificationService: UNNotificationServiceExtension { var bestAttemptContent: UNMutableNotificationContent? var cancellables = Set() - let logger = Logger(subsystem: "com.yourapp.network", category: "NotificationService") + let logger = Logger(subsystem: "org.openhab.network", category: "NotificationService") override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { self.contentHandler = contentHandler @@ -103,22 +103,7 @@ class NotificationService: UNNotificationServiceExtension { // this should be last as we need to wait for media // TODO: we should support relative paths and try the user's openHAB (local,remote) for content if let attachmentURLString = userInfo["media-attachment-url"] as? String { - /// HERE we switch to async usage -// let downloadCompletionHandler: @Sendable (UNNotificationAttachment?) -> Void = { attachment in -// if let attachment { -// os_log("handleNotification attaching %{PUBLIC}@", log: .default, type: .info, attachmentURLString) -// bestAttemptContent.attachments = [attachment] -// } else { -// os_log("handleNotification could not attach %{PUBLIC}@", log: .default, type: .info, attachmentURLString) -// } -// contentHandler(bestAttemptContent) -// } - -// if attachmentURLString.starts(with: "item:") { -// downloadAndAttachItemImage(itemURI: attachmentURLString, completion: downloadCompletionHandler) -// } else { -// downloadAndAttachMedia(url: attachmentURLString, completion: downloadCompletionHandler) -// } + // HERE we switch to async usage Task { do { let unNotificationAttachment = if attachmentURLString.starts(with: "item:") { @@ -174,39 +159,6 @@ class NotificationService: UNNotificationServiceExtension { return nil } - private func downloadAndAttachMedia(url: String, completion: @escaping (UNNotificationAttachment?) -> Void) { - let client = HTTPClient(username: Preferences.username, password: Preferences.username, alwaysSendBasicAuth: Preferences.alwaysSendCreds) - - let downloadCompletionHandler: @Sendable (URL?, URLResponse?, Error?) -> Void = { (localURL, response, error) in - guard let localURL else { - os_log("Error downloading media %{PUBLIC}@", log: .default, type: .error, error?.localizedDescription ?? "Unknown error") - completion(nil) - return - } - self.attachFile(localURL: localURL, mimeType: response?.mimeType, completion: completion) - } - if url.starts(with: "/") { - let connection1 = ConnectionConfiguration( - url: Preferences.localUrl, - priority: 0 - ) - let connection2 = ConnectionConfiguration( - url: Preferences.remoteUrl, - priority: 1 - ) - NetworkTracker.shared.startTracking(connectionConfigurations: [connection1, connection2], username: Preferences.username, password: Preferences.password, alwaysSendBasicAuth: Preferences.alwaysSendCreds, ignoreSSLVerification: Preferences.ignoreSSL) - NetworkTracker.shared.waitForActiveConnection { activeConnection in - if let openHABUrl = activeConnection?.configuration.url, let uurl = URL(string: openHABUrl) { - client.downloadFile(url: uurl.appendingPathComponent(url), completionHandler: downloadCompletionHandler) - } - } - .store(in: &cancellables) - } else if let uurl = URL(string: url) { - client.downloadFile(url: uurl, completionHandler: downloadCompletionHandler) - } - } - - // Async version private func downloadAndAttachMedia(url: String) async throws -> UNNotificationAttachment? { let client = HTTPClient(username: Preferences.username, password: Preferences.username, alwaysSendBasicAuth: Preferences.alwaysSendCreds) if url.starts(with: "/") { @@ -235,66 +187,6 @@ class NotificationService: UNNotificationServiceExtension { return nil } - func downloadAndAttachItemImage(itemURI: String, completion: @escaping (UNNotificationAttachment?) -> Void) { - guard let itemURI = URL(string: itemURI), let scheme = itemURI.scheme else { - os_log("Could not find scheme %{PUBLIC}@", log: .default, type: .info) - completion(nil) - return - } - - let itemName = String(itemURI.absoluteString.dropFirst(scheme.count + 1)) - - let client = HTTPClient(username: Preferences.username, password: Preferences.password, alwaysSendBasicAuth: Preferences.alwaysSendCreds) - let connection1 = ConnectionConfiguration( - url: Preferences.localUrl, - priority: 0 - ) - let connection2 = ConnectionConfiguration( - url: Preferences.remoteUrl, - priority: 1 - ) - NetworkTracker.shared.startTracking(connectionConfigurations: [connection1, connection2], username: Preferences.username, password: Preferences.password, alwaysSendBasicAuth: Preferences.alwaysSendCreds, ignoreSSLVerification: Preferences.ignoreSSL) - NetworkTracker.shared.waitForActiveConnection { activeConnection in - if let openHABUrl = activeConnection?.configuration.url, let url = URL(string: openHABUrl) { - client.getItem(baseURL: url, itemName: itemName) { item, error in - guard let item else { - os_log("Could not find item %{PUBLIC}@", log: .default, type: .info, itemName) - completion(nil) - return - } - if let state = item.state { - // Extract MIME type and base64 string - let pattern = /^data:(.*?);base64,(.*)$/ - if let firstMatch = state.firstMatch(of: pattern) { - let mimeType = String(firstMatch.1) - let base64String = String(firstMatch.2) - if let imageData = Data(base64Encoded: base64String) { - // Create a temporary file URL - let tempDirectory = FileManager.default.temporaryDirectory - let tempFileURL = tempDirectory.appendingPathComponent(UUID().uuidString) - do { - try imageData.write(to: tempFileURL) - os_log("Image saved to temporary file: %{PUBLIC}@", log: .default, type: .info, tempFileURL.absoluteString) - self.attachFile(localURL: tempFileURL, mimeType: mimeType, completion: completion) - return - } catch { - os_log("Failed to write image data to file: %{PUBLIC}@", log: .default, type: .error, error.localizedDescription) - } - } else { - os_log("Failed to decode base64 string to Data", log: .default, type: .error) - } - } else { - os_log("Failed to parse data: %{PUBLIC}@", log: .default, type: .error, error?.localizedDescription ?? "") - } - } - completion(nil) - } - } - } - .store(in: &cancellables) - } - - // Async version func downloadAndAttachItemImage(itemURI: String) async throws -> UNNotificationAttachment? { guard let itemURI = URL(string: itemURI), let scheme = itemURI.scheme else { throw NotificationServiceError.noScheme(itemURI) @@ -351,34 +243,6 @@ class NotificationService: UNNotificationServiceExtension { } } - func attachFile(localURL: URL, mimeType: String?, completion: @escaping (UNNotificationAttachment?) -> Void) { - do { - let fileManager = FileManager.default - let tempDirectory = NSTemporaryDirectory() - let tempFile = URL(fileURLWithPath: tempDirectory).appendingPathComponent(UUID().uuidString) - - try fileManager.moveItem(at: localURL, to: tempFile) - let attachment: UNNotificationAttachment? - - if let mimeType, - let utType = UTType(mimeType: mimeType), - utType.conforms(to: .data) { - let newTempFile = tempFile.appendingPathExtension(utType.preferredFilenameExtension ?? "") - try fileManager.moveItem(at: tempFile, to: newTempFile) - attachment = try UNNotificationAttachment(identifier: UUID().uuidString, url: newTempFile, options: nil) - } else { - os_log("Unrecognized MIME type or file extension", log: .default, type: .error) - attachment = nil - } - completion(attachment) - return - } catch { - os_log("Failed to create UNNotificationAttachment: %{PUBLIC}@", log: .default, type: .error, error.localizedDescription) - } - completion(nil) - } - - // Async version func attachFile(localURL: URL, mimeType: String?) async -> UNNotificationAttachment? { do { let fileManager = FileManager.default diff --git a/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Client.swift b/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Client.swift index 26930d303..c2a733968 100644 --- a/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Client.swift +++ b/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Client.swift @@ -1,27 +1,15 @@ -// Copyright (c) 2010-2025 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 - // Generated by swift-openapi-generator, do not modify. @_spi(Generated) import OpenAPIRuntime #if os(Linux) +@preconcurrency import struct Foundation.URL @preconcurrency import struct Foundation.Data @preconcurrency import struct Foundation.Date -@preconcurrency import struct Foundation.URL #else +import struct Foundation.URL import struct Foundation.Data import struct Foundation.Date -import struct Foundation.URL #endif import HTTPTypes - public struct Client: APIProtocol { /// The underlying HTTP client. private let client: UniversalClient @@ -33,22 +21,1051 @@ public struct Client: APIProtocol { /// - configuration: A set of configuration values for the client. /// - transport: A transport that performs HTTP operations. /// - middlewares: A list of middlewares to call before the transport. - public init(serverURL: Foundation.URL, - configuration: Configuration = .init(), - transport: any ClientTransport, - middlewares: [any ClientMiddleware] = []) { - client = .init( + public init( + serverURL: Foundation.URL, + configuration: Configuration = .init(), + transport: any ClientTransport, + middlewares: [any ClientMiddleware] = [] + ) { + self.client = .init( serverURL: serverURL, configuration: configuration, transport: transport, middlewares: middlewares ) } - - private var converter: Converter { - client.converter + private var converter: Converter { + client.converter + } + /// Get available rules, optionally filtered by tags and/or prefix. + /// + /// - Remark: HTTP `GET /rules`. + /// - Remark: Generated from `#/paths//rules/get(getRules)`. + public func getRules(_ input: Operations.getRules.Input) async throws -> Operations.getRules.Output { + try await client.send( + input: input, + forOperation: Operations.getRules.id, + serializer: { input in + let path = try converter.renderedPath( + template: "/rules", + parameters: [] + ) + var request: HTTPTypes.HTTPRequest = .init( + soar_path: path, + method: .get + ) + suppressMutabilityWarning(&request) + try converter.setQueryItemAsURI( + in: &request, + style: .form, + explode: true, + name: "prefix", + value: input.query.prefix + ) + try converter.setQueryItemAsURI( + in: &request, + style: .form, + explode: true, + name: "tags", + value: input.query.tags + ) + try converter.setQueryItemAsURI( + in: &request, + style: .form, + explode: true, + name: "summary", + value: input.query.summary + ) + try converter.setQueryItemAsURI( + in: &request, + style: .form, + explode: true, + name: "staticDataOnly", + value: input.query.staticDataOnly + ) + converter.setAcceptHeader( + in: &request.headerFields, + contentTypes: input.headers.accept + ) + return (request, nil) + }, + deserializer: { response, responseBody in + switch response.status.code { + case 200: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Operations.getRules.Output.Ok.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + [Components.Schemas.EnrichedRuleDTO].self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .ok(.init(body: body)) + default: + return .undocumented( + statusCode: response.status.code, + .init( + headerFields: response.headerFields, + body: responseBody + ) + ) + } + } + ) + } + /// Creates a rule. + /// + /// - Remark: HTTP `POST /rules`. + /// - Remark: Generated from `#/paths//rules/post(createRule)`. + public func createRule(_ input: Operations.createRule.Input) async throws -> Operations.createRule.Output { + try await client.send( + input: input, + forOperation: Operations.createRule.id, + serializer: { input in + let path = try converter.renderedPath( + template: "/rules", + parameters: [] + ) + var request: HTTPTypes.HTTPRequest = .init( + soar_path: path, + method: .post + ) + suppressMutabilityWarning(&request) + let body: OpenAPIRuntime.HTTPBody? + switch input.body { + case let .json(value): + body = try converter.setRequiredRequestBodyAsJSON( + value, + headerFields: &request.headerFields, + contentType: "application/json; charset=utf-8" + ) + } + return (request, body) + }, + deserializer: { response, responseBody in + switch response.status.code { + case 201: + let headers: Operations.createRule.Output.Created.Headers = .init(Location: try converter.getOptionalHeaderFieldAsURI( + in: response.headerFields, + name: "Location", + as: Swift.String.self + )) + return .created(.init(headers: headers)) + case 400: + return .badRequest(.init()) + case 409: + return .conflict(.init()) + default: + return .undocumented( + statusCode: response.status.code, + .init( + headerFields: response.headerFields, + body: responseBody + ) + ) + } + } + ) + } + /// Sets the rule enabled status. + /// + /// - Remark: HTTP `POST /rules/{ruleUID}/enable`. + /// - Remark: Generated from `#/paths//rules/{ruleUID}/enable/post(enableRule)`. + public func enableRule(_ input: Operations.enableRule.Input) async throws -> Operations.enableRule.Output { + try await client.send( + input: input, + forOperation: Operations.enableRule.id, + serializer: { input in + let path = try converter.renderedPath( + template: "/rules/{}/enable", + parameters: [ + input.path.ruleUID + ] + ) + var request: HTTPTypes.HTTPRequest = .init( + soar_path: path, + method: .post + ) + suppressMutabilityWarning(&request) + let body: OpenAPIRuntime.HTTPBody? + switch input.body { + case let .plainText(value): + body = try converter.setRequiredRequestBodyAsBinary( + value, + headerFields: &request.headerFields, + contentType: "text/plain" + ) + } + return (request, body) + }, + deserializer: { response, responseBody in + switch response.status.code { + case 200: + return .ok(.init()) + case 404: + return .notFound(.init()) + default: + return .undocumented( + statusCode: response.status.code, + .init( + headerFields: response.headerFields, + body: responseBody + ) + ) + } + } + ) + } + /// Gets the rule actions. + /// + /// - Remark: HTTP `GET /rules/{ruleUID}/actions`. + /// - Remark: Generated from `#/paths//rules/{ruleUID}/actions/get(getRuleActions)`. + public func getRuleActions(_ input: Operations.getRuleActions.Input) async throws -> Operations.getRuleActions.Output { + try await client.send( + input: input, + forOperation: Operations.getRuleActions.id, + serializer: { input in + let path = try converter.renderedPath( + template: "/rules/{}/actions", + parameters: [ + input.path.ruleUID + ] + ) + var request: HTTPTypes.HTTPRequest = .init( + soar_path: path, + method: .get + ) + suppressMutabilityWarning(&request) + converter.setAcceptHeader( + in: &request.headerFields, + contentTypes: input.headers.accept + ) + return (request, nil) + }, + deserializer: { response, responseBody in + switch response.status.code { + case 200: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Operations.getRuleActions.Output.Ok.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + [Components.Schemas.ActionDTO].self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .ok(.init(body: body)) + case 404: + return .notFound(.init()) + default: + return .undocumented( + statusCode: response.status.code, + .init( + headerFields: response.headerFields, + body: responseBody + ) + ) + } + } + ) + } + /// Gets the rule corresponding to the given UID. + /// + /// - Remark: HTTP `GET /rules/{ruleUID}`. + /// - Remark: Generated from `#/paths//rules/{ruleUID}/get(getRuleById)`. + public func getRuleById(_ input: Operations.getRuleById.Input) async throws -> Operations.getRuleById.Output { + try await client.send( + input: input, + forOperation: Operations.getRuleById.id, + serializer: { input in + let path = try converter.renderedPath( + template: "/rules/{}", + parameters: [ + input.path.ruleUID + ] + ) + var request: HTTPTypes.HTTPRequest = .init( + soar_path: path, + method: .get + ) + suppressMutabilityWarning(&request) + converter.setAcceptHeader( + in: &request.headerFields, + contentTypes: input.headers.accept + ) + return (request, nil) + }, + deserializer: { response, responseBody in + switch response.status.code { + case 200: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Operations.getRuleById.Output.Ok.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Components.Schemas.EnrichedRuleDTO.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .ok(.init(body: body)) + case 404: + return .notFound(.init()) + default: + return .undocumented( + statusCode: response.status.code, + .init( + headerFields: response.headerFields, + body: responseBody + ) + ) + } + } + ) + } + /// Updates an existing rule corresponding to the given UID. + /// + /// - Remark: HTTP `PUT /rules/{ruleUID}`. + /// - Remark: Generated from `#/paths//rules/{ruleUID}/put(updateRule)`. + public func updateRule(_ input: Operations.updateRule.Input) async throws -> Operations.updateRule.Output { + try await client.send( + input: input, + forOperation: Operations.updateRule.id, + serializer: { input in + let path = try converter.renderedPath( + template: "/rules/{}", + parameters: [ + input.path.ruleUID + ] + ) + var request: HTTPTypes.HTTPRequest = .init( + soar_path: path, + method: .put + ) + suppressMutabilityWarning(&request) + let body: OpenAPIRuntime.HTTPBody? + switch input.body { + case let .json(value): + body = try converter.setRequiredRequestBodyAsJSON( + value, + headerFields: &request.headerFields, + contentType: "application/json; charset=utf-8" + ) + } + return (request, body) + }, + deserializer: { response, responseBody in + switch response.status.code { + case 200: + return .ok(.init()) + case 404: + return .notFound(.init()) + default: + return .undocumented( + statusCode: response.status.code, + .init( + headerFields: response.headerFields, + body: responseBody + ) + ) + } + } + ) + } + /// Removes an existing rule corresponding to the given UID. + /// + /// - Remark: HTTP `DELETE /rules/{ruleUID}`. + /// - Remark: Generated from `#/paths//rules/{ruleUID}/delete(deleteRule)`. + public func deleteRule(_ input: Operations.deleteRule.Input) async throws -> Operations.deleteRule.Output { + try await client.send( + input: input, + forOperation: Operations.deleteRule.id, + serializer: { input in + let path = try converter.renderedPath( + template: "/rules/{}", + parameters: [ + input.path.ruleUID + ] + ) + var request: HTTPTypes.HTTPRequest = .init( + soar_path: path, + method: .delete + ) + suppressMutabilityWarning(&request) + return (request, nil) + }, + deserializer: { response, responseBody in + switch response.status.code { + case 200: + return .ok(.init()) + case 404: + return .notFound(.init()) + default: + return .undocumented( + statusCode: response.status.code, + .init( + headerFields: response.headerFields, + body: responseBody + ) + ) + } + } + ) + } + /// Gets the rule conditions. + /// + /// - Remark: HTTP `GET /rules/{ruleUID}/conditions`. + /// - Remark: Generated from `#/paths//rules/{ruleUID}/conditions/get(getRuleConditions)`. + public func getRuleConditions(_ input: Operations.getRuleConditions.Input) async throws -> Operations.getRuleConditions.Output { + try await client.send( + input: input, + forOperation: Operations.getRuleConditions.id, + serializer: { input in + let path = try converter.renderedPath( + template: "/rules/{}/conditions", + parameters: [ + input.path.ruleUID + ] + ) + var request: HTTPTypes.HTTPRequest = .init( + soar_path: path, + method: .get + ) + suppressMutabilityWarning(&request) + converter.setAcceptHeader( + in: &request.headerFields, + contentTypes: input.headers.accept + ) + return (request, nil) + }, + deserializer: { response, responseBody in + switch response.status.code { + case 200: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Operations.getRuleConditions.Output.Ok.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + [Components.Schemas.ConditionDTO].self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .ok(.init(body: body)) + case 404: + return .notFound(.init()) + default: + return .undocumented( + statusCode: response.status.code, + .init( + headerFields: response.headerFields, + body: responseBody + ) + ) + } + } + ) + } + /// Gets the rule configuration values. + /// + /// - Remark: HTTP `GET /rules/{ruleUID}/config`. + /// - Remark: Generated from `#/paths//rules/{ruleUID}/config/get(getRuleConfiguration)`. + public func getRuleConfiguration(_ input: Operations.getRuleConfiguration.Input) async throws -> Operations.getRuleConfiguration.Output { + try await client.send( + input: input, + forOperation: Operations.getRuleConfiguration.id, + serializer: { input in + let path = try converter.renderedPath( + template: "/rules/{}/config", + parameters: [ + input.path.ruleUID + ] + ) + var request: HTTPTypes.HTTPRequest = .init( + soar_path: path, + method: .get + ) + suppressMutabilityWarning(&request) + converter.setAcceptHeader( + in: &request.headerFields, + contentTypes: input.headers.accept + ) + return (request, nil) + }, + deserializer: { response, responseBody in + switch response.status.code { + case 200: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Operations.getRuleConfiguration.Output.Ok.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Swift.String.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .ok(.init(body: body)) + case 404: + return .notFound(.init()) + default: + return .undocumented( + statusCode: response.status.code, + .init( + headerFields: response.headerFields, + body: responseBody + ) + ) + } + } + ) + } + /// Sets the rule configuration values. + /// + /// - Remark: HTTP `PUT /rules/{ruleUID}/config`. + /// - Remark: Generated from `#/paths//rules/{ruleUID}/config/put(updateRuleConfiguration)`. + public func updateRuleConfiguration(_ input: Operations.updateRuleConfiguration.Input) async throws -> Operations.updateRuleConfiguration.Output { + try await client.send( + input: input, + forOperation: Operations.updateRuleConfiguration.id, + serializer: { input in + let path = try converter.renderedPath( + template: "/rules/{}/config", + parameters: [ + input.path.ruleUID + ] + ) + var request: HTTPTypes.HTTPRequest = .init( + soar_path: path, + method: .put + ) + suppressMutabilityWarning(&request) + let body: OpenAPIRuntime.HTTPBody? + switch input.body { + case .none: + body = nil + case let .json(value): + body = try converter.setOptionalRequestBodyAsJSON( + value, + headerFields: &request.headerFields, + contentType: "application/json; charset=utf-8" + ) + } + return (request, body) + }, + deserializer: { response, responseBody in + switch response.status.code { + case 200: + return .ok(.init()) + case 404: + return .notFound(.init()) + default: + return .undocumented( + statusCode: response.status.code, + .init( + headerFields: response.headerFields, + body: responseBody + ) + ) + } + } + ) + } + /// Gets the rule's module corresponding to the given Category and ID. + /// + /// - Remark: HTTP `GET /rules/{ruleUID}/{moduleCategory}/{id}`. + /// - Remark: Generated from `#/paths//rules/{ruleUID}/{moduleCategory}/{id}/get(getRuleModuleById)`. + public func getRuleModuleById(_ input: Operations.getRuleModuleById.Input) async throws -> Operations.getRuleModuleById.Output { + try await client.send( + input: input, + forOperation: Operations.getRuleModuleById.id, + serializer: { input in + let path = try converter.renderedPath( + template: "/rules/{}/{}/{}", + parameters: [ + input.path.ruleUID, + input.path.moduleCategory, + input.path.id + ] + ) + var request: HTTPTypes.HTTPRequest = .init( + soar_path: path, + method: .get + ) + suppressMutabilityWarning(&request) + converter.setAcceptHeader( + in: &request.headerFields, + contentTypes: input.headers.accept + ) + return (request, nil) + }, + deserializer: { response, responseBody in + switch response.status.code { + case 200: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Operations.getRuleModuleById.Output.Ok.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Components.Schemas.ModuleDTO.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .ok(.init(body: body)) + case 404: + return .notFound(.init()) + default: + return .undocumented( + statusCode: response.status.code, + .init( + headerFields: response.headerFields, + body: responseBody + ) + ) + } + } + ) + } + /// Gets the module's configuration. + /// + /// - Remark: HTTP `GET /rules/{ruleUID}/{moduleCategory}/{id}/config`. + /// - Remark: Generated from `#/paths//rules/{ruleUID}/{moduleCategory}/{id}/config/get(getRuleModuleConfig)`. + public func getRuleModuleConfig(_ input: Operations.getRuleModuleConfig.Input) async throws -> Operations.getRuleModuleConfig.Output { + try await client.send( + input: input, + forOperation: Operations.getRuleModuleConfig.id, + serializer: { input in + let path = try converter.renderedPath( + template: "/rules/{}/{}/{}/config", + parameters: [ + input.path.ruleUID, + input.path.moduleCategory, + input.path.id + ] + ) + var request: HTTPTypes.HTTPRequest = .init( + soar_path: path, + method: .get + ) + suppressMutabilityWarning(&request) + converter.setAcceptHeader( + in: &request.headerFields, + contentTypes: input.headers.accept + ) + return (request, nil) + }, + deserializer: { response, responseBody in + switch response.status.code { + case 200: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Operations.getRuleModuleConfig.Output.Ok.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Swift.String.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .ok(.init(body: body)) + case 404: + return .notFound(.init()) + default: + return .undocumented( + statusCode: response.status.code, + .init( + headerFields: response.headerFields, + body: responseBody + ) + ) + } + } + ) + } + /// Gets the module's configuration parameter. + /// + /// - Remark: HTTP `GET /rules/{ruleUID}/{moduleCategory}/{id}/config/{param}`. + /// - Remark: Generated from `#/paths//rules/{ruleUID}/{moduleCategory}/{id}/config/{param}/get(getRuleModuleConfigParameter)`. + public func getRuleModuleConfigParameter(_ input: Operations.getRuleModuleConfigParameter.Input) async throws -> Operations.getRuleModuleConfigParameter.Output { + try await client.send( + input: input, + forOperation: Operations.getRuleModuleConfigParameter.id, + serializer: { input in + let path = try converter.renderedPath( + template: "/rules/{}/{}/{}/config/{}", + parameters: [ + input.path.ruleUID, + input.path.moduleCategory, + input.path.id, + input.path.param + ] + ) + var request: HTTPTypes.HTTPRequest = .init( + soar_path: path, + method: .get + ) + suppressMutabilityWarning(&request) + converter.setAcceptHeader( + in: &request.headerFields, + contentTypes: input.headers.accept + ) + return (request, nil) + }, + deserializer: { response, responseBody in + switch response.status.code { + case 200: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Operations.getRuleModuleConfigParameter.Output.Ok.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "text/plain" + ] + ) + switch chosenContentType { + case "text/plain": + body = try converter.getResponseBodyAsBinary( + OpenAPIRuntime.HTTPBody.self, + from: responseBody, + transforming: { value in + .plainText(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .ok(.init(body: body)) + case 404: + return .notFound(.init()) + default: + return .undocumented( + statusCode: response.status.code, + .init( + headerFields: response.headerFields, + body: responseBody + ) + ) + } + } + ) + } + /// Sets the module's configuration parameter value. + /// + /// - Remark: HTTP `PUT /rules/{ruleUID}/{moduleCategory}/{id}/config/{param}`. + /// - Remark: Generated from `#/paths//rules/{ruleUID}/{moduleCategory}/{id}/config/{param}/put(setRuleModuleConfigParameter)`. + public func setRuleModuleConfigParameter(_ input: Operations.setRuleModuleConfigParameter.Input) async throws -> Operations.setRuleModuleConfigParameter.Output { + try await client.send( + input: input, + forOperation: Operations.setRuleModuleConfigParameter.id, + serializer: { input in + let path = try converter.renderedPath( + template: "/rules/{}/{}/{}/config/{}", + parameters: [ + input.path.ruleUID, + input.path.moduleCategory, + input.path.id, + input.path.param + ] + ) + var request: HTTPTypes.HTTPRequest = .init( + soar_path: path, + method: .put + ) + suppressMutabilityWarning(&request) + let body: OpenAPIRuntime.HTTPBody? + switch input.body { + case let .plainText(value): + body = try converter.setRequiredRequestBodyAsBinary( + value, + headerFields: &request.headerFields, + contentType: "text/plain" + ) + } + return (request, body) + }, + deserializer: { response, responseBody in + switch response.status.code { + case 200: + return .ok(.init()) + case 404: + return .notFound(.init()) + default: + return .undocumented( + statusCode: response.status.code, + .init( + headerFields: response.headerFields, + body: responseBody + ) + ) + } + } + ) + } + /// Gets the rule triggers. + /// + /// - Remark: HTTP `GET /rules/{ruleUID}/triggers`. + /// - Remark: Generated from `#/paths//rules/{ruleUID}/triggers/get(getRuleTriggers)`. + public func getRuleTriggers(_ input: Operations.getRuleTriggers.Input) async throws -> Operations.getRuleTriggers.Output { + try await client.send( + input: input, + forOperation: Operations.getRuleTriggers.id, + serializer: { input in + let path = try converter.renderedPath( + template: "/rules/{}/triggers", + parameters: [ + input.path.ruleUID + ] + ) + var request: HTTPTypes.HTTPRequest = .init( + soar_path: path, + method: .get + ) + suppressMutabilityWarning(&request) + converter.setAcceptHeader( + in: &request.headerFields, + contentTypes: input.headers.accept + ) + return (request, nil) + }, + deserializer: { response, responseBody in + switch response.status.code { + case 200: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Operations.getRuleTriggers.Output.Ok.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + [Components.Schemas.TriggerDTO].self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .ok(.init(body: body)) + case 404: + return .notFound(.init()) + default: + return .undocumented( + statusCode: response.status.code, + .init( + headerFields: response.headerFields, + body: responseBody + ) + ) + } + } + ) + } + /// Executes actions of the rule. + /// + /// - Remark: HTTP `POST /rules/{ruleUID}/runnow`. + /// - Remark: Generated from `#/paths//rules/{ruleUID}/runnow/post(runRuleNow_1)`. + public func runRuleNow_1(_ input: Operations.runRuleNow_1.Input) async throws -> Operations.runRuleNow_1.Output { + try await client.send( + input: input, + forOperation: Operations.runRuleNow_1.id, + serializer: { input in + let path = try converter.renderedPath( + template: "/rules/{}/runnow", + parameters: [ + input.path.ruleUID + ] + ) + var request: HTTPTypes.HTTPRequest = .init( + soar_path: path, + method: .post + ) + suppressMutabilityWarning(&request) + let body: OpenAPIRuntime.HTTPBody? + switch input.body { + case .none: + body = nil + case let .json(value): + body = try converter.setOptionalRequestBodyAsJSON( + value, + headerFields: &request.headerFields, + contentType: "application/json; charset=utf-8" + ) + } + return (request, body) + }, + deserializer: { response, responseBody in + switch response.status.code { + case 200: + return .ok(.init()) + case 404: + return .notFound(.init()) + default: + return .undocumented( + statusCode: response.status.code, + .init( + headerFields: response.headerFields, + body: responseBody + ) + ) + } + } + ) + } + /// Simulates the executions of rules filtered by tag 'Schedule' within the given times. + /// + /// - Remark: HTTP `GET /rules/schedule/simulations`. + /// - Remark: Generated from `#/paths//rules/schedule/simulations/get(getScheduleRuleSimulations)`. + public func getScheduleRuleSimulations(_ input: Operations.getScheduleRuleSimulations.Input) async throws -> Operations.getScheduleRuleSimulations.Output { + try await client.send( + input: input, + forOperation: Operations.getScheduleRuleSimulations.id, + serializer: { input in + let path = try converter.renderedPath( + template: "/rules/schedule/simulations", + parameters: [] + ) + var request: HTTPTypes.HTTPRequest = .init( + soar_path: path, + method: .get + ) + suppressMutabilityWarning(&request) + try converter.setQueryItemAsURI( + in: &request, + style: .form, + explode: true, + name: "from", + value: input.query.from + ) + try converter.setQueryItemAsURI( + in: &request, + style: .form, + explode: true, + name: "until", + value: input.query.until + ) + converter.setAcceptHeader( + in: &request.headerFields, + contentTypes: input.headers.accept + ) + return (request, nil) + }, + deserializer: { response, responseBody in + switch response.status.code { + case 200: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Operations.getScheduleRuleSimulations.Output.Ok.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + [Components.Schemas.RuleExecution].self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .ok(.init(body: body)) + case 400: + return .badRequest(.init()) + default: + return .undocumented( + statusCode: response.status.code, + .init( + headerFields: response.headerFields, + body: responseBody + ) + ) + } + } + ) } - /// Adds a new member to a group item. /// /// - Remark: HTTP `PUT /items/{itemName}/members/{memberItemName}`. @@ -75,13 +1092,13 @@ public struct Client: APIProtocol { deserializer: { response, responseBody in switch response.status.code { case 200: - .ok(.init()) + return .ok(.init()) case 404: - .notFound(.init()) + return .notFound(.init()) case 405: - .methodNotAllowed(.init()) + return .methodNotAllowed(.init()) default: - .undocumented( + return .undocumented( statusCode: response.status.code, .init( headerFields: response.headerFields, @@ -92,7 +1109,6 @@ public struct Client: APIProtocol { } ) } - /// Removes an existing member from a group item. /// /// - Remark: HTTP `DELETE /items/{itemName}/members/{memberItemName}`. @@ -119,13 +1135,13 @@ public struct Client: APIProtocol { deserializer: { response, responseBody in switch response.status.code { case 200: - .ok(.init()) + return .ok(.init()) case 404: - .notFound(.init()) + return .notFound(.init()) case 405: - .methodNotAllowed(.init()) + return .methodNotAllowed(.init()) default: - .undocumented( + return .undocumented( statusCode: response.status.code, .init( headerFields: response.headerFields, @@ -136,7 +1152,6 @@ public struct Client: APIProtocol { } ) } - /// Adds metadata to an item. /// /// - Remark: HTTP `PUT /items/{itemname}/metadata/{namespace}`. @@ -158,9 +1173,10 @@ public struct Client: APIProtocol { method: .put ) suppressMutabilityWarning(&request) - let body: OpenAPIRuntime.HTTPBody? = switch input.body { + let body: OpenAPIRuntime.HTTPBody? + switch input.body { case let .json(value): - try converter.setRequiredRequestBodyAsJSON( + body = try converter.setRequiredRequestBodyAsJSON( value, headerFields: &request.headerFields, contentType: "application/json; charset=utf-8" @@ -171,17 +1187,17 @@ public struct Client: APIProtocol { deserializer: { response, responseBody in switch response.status.code { case 200: - .ok(.init()) + return .ok(.init()) case 201: - .created(.init()) + return .created(.init()) case 400: - .badRequest(.init()) + return .badRequest(.init()) case 404: - .notFound(.init()) + return .notFound(.init()) case 405: - .methodNotAllowed(.init()) + return .methodNotAllowed(.init()) default: - .undocumented( + return .undocumented( statusCode: response.status.code, .init( headerFields: response.headerFields, @@ -192,7 +1208,6 @@ public struct Client: APIProtocol { } ) } - /// Removes metadata from an item. /// /// - Remark: HTTP `DELETE /items/{itemname}/metadata/{namespace}`. @@ -219,13 +1234,13 @@ public struct Client: APIProtocol { deserializer: { response, responseBody in switch response.status.code { case 200: - .ok(.init()) + return .ok(.init()) case 404: - .notFound(.init()) + return .notFound(.init()) case 405: - .methodNotAllowed(.init()) + return .methodNotAllowed(.init()) default: - .undocumented( + return .undocumented( statusCode: response.status.code, .init( headerFields: response.headerFields, @@ -236,7 +1251,6 @@ public struct Client: APIProtocol { } ) } - /// Adds a tag to an item. /// /// - Remark: HTTP `PUT /items/{itemname}/tags/{tag}`. @@ -263,13 +1277,13 @@ public struct Client: APIProtocol { deserializer: { response, responseBody in switch response.status.code { case 200: - .ok(.init()) + return .ok(.init()) case 404: - .notFound(.init()) + return .notFound(.init()) case 405: - .methodNotAllowed(.init()) + return .methodNotAllowed(.init()) default: - .undocumented( + return .undocumented( statusCode: response.status.code, .init( headerFields: response.headerFields, @@ -280,7 +1294,6 @@ public struct Client: APIProtocol { } ) } - /// Removes a tag from an item. /// /// - Remark: HTTP `DELETE /items/{itemname}/tags/{tag}`. @@ -307,13 +1320,13 @@ public struct Client: APIProtocol { deserializer: { response, responseBody in switch response.status.code { case 200: - .ok(.init()) + return .ok(.init()) case 404: - .notFound(.init()) + return .notFound(.init()) case 405: - .methodNotAllowed(.init()) + return .methodNotAllowed(.init()) default: - .undocumented( + return .undocumented( statusCode: response.status.code, .init( headerFields: response.headerFields, @@ -324,7 +1337,6 @@ public struct Client: APIProtocol { } ) } - /// Gets a single item. /// /// - Remark: HTTP `GET /items/{itemname}`. @@ -408,7 +1420,6 @@ public struct Client: APIProtocol { } ) } - /// Sends a command to an item. /// /// - Remark: HTTP `POST /items/{itemname}`. @@ -429,9 +1440,10 @@ public struct Client: APIProtocol { method: .post ) suppressMutabilityWarning(&request) - let body: OpenAPIRuntime.HTTPBody? = switch input.body { + let body: OpenAPIRuntime.HTTPBody? + switch input.body { case let .plainText(value): - try converter.setRequiredRequestBodyAsBinary( + body = try converter.setRequiredRequestBodyAsBinary( value, headerFields: &request.headerFields, contentType: "text/plain" @@ -442,13 +1454,13 @@ public struct Client: APIProtocol { deserializer: { response, responseBody in switch response.status.code { case 200: - .ok(.init()) + return .ok(.init()) case 400: - .badRequest(.init()) + return .badRequest(.init()) case 404: - .notFound(.init()) + return .notFound(.init()) default: - .undocumented( + return .undocumented( statusCode: response.status.code, .init( headerFields: response.headerFields, @@ -459,7 +1471,6 @@ public struct Client: APIProtocol { } ) } - /// Adds a new item to the registry or updates the existing item. /// /// - Remark: HTTP `PUT /items/{itemname}`. @@ -489,9 +1500,10 @@ public struct Client: APIProtocol { in: &request.headerFields, contentTypes: input.headers.accept ) - let body: OpenAPIRuntime.HTTPBody? = switch input.body { + let body: OpenAPIRuntime.HTTPBody? + switch input.body { case let .json(value): - try converter.setRequiredRequestBodyAsJSON( + body = try converter.setRequiredRequestBodyAsJSON( value, headerFields: &request.headerFields, contentType: "application/json; charset=utf-8" @@ -543,7 +1555,6 @@ public struct Client: APIProtocol { } ) } - /// Removes an item from the registry. /// /// - Remark: HTTP `DELETE /items/{itemname}`. @@ -569,11 +1580,11 @@ public struct Client: APIProtocol { deserializer: { response, responseBody in switch response.status.code { case 200: - .ok(.init()) + return .ok(.init()) case 404: - .notFound(.init()) + return .notFound(.init()) default: - .undocumented( + return .undocumented( statusCode: response.status.code, .init( headerFields: response.headerFields, @@ -584,7 +1595,6 @@ public struct Client: APIProtocol { } ) } - /// Get all available items. /// /// - Remark: HTTP `GET /items`. @@ -692,7 +1702,6 @@ public struct Client: APIProtocol { } ) } - /// Adds a list of items to the registry or updates the existing items. /// /// - Remark: HTTP `PUT /items`. @@ -715,9 +1724,10 @@ public struct Client: APIProtocol { in: &request.headerFields, contentTypes: input.headers.accept ) - let body: OpenAPIRuntime.HTTPBody? = switch input.body { + let body: OpenAPIRuntime.HTTPBody? + switch input.body { case let .json(value): - try converter.setRequiredRequestBodyAsJSON( + body = try converter.setRequiredRequestBodyAsJSON( value, headerFields: &request.headerFields, contentType: "application/json; charset=utf-8" @@ -763,7 +1773,6 @@ public struct Client: APIProtocol { } ) } - /// Gets the state of an item. /// /// - Remark: HTTP `GET /items/{itemname}/state`. @@ -828,7 +1837,6 @@ public struct Client: APIProtocol { } ) } - /// Updates the state of an item. /// /// - Remark: HTTP `PUT /items/{itemname}/state`. @@ -854,9 +1862,10 @@ public struct Client: APIProtocol { name: "Accept-Language", value: input.headers.Accept_hyphen_Language ) - let body: OpenAPIRuntime.HTTPBody? = switch input.body { + let body: OpenAPIRuntime.HTTPBody? + switch input.body { case let .plainText(value): - try converter.setRequiredRequestBodyAsBinary( + body = try converter.setRequiredRequestBodyAsBinary( value, headerFields: &request.headerFields, contentType: "text/plain" @@ -867,13 +1876,13 @@ public struct Client: APIProtocol { deserializer: { response, responseBody in switch response.status.code { case 202: - .accepted(.init()) + return .accepted(.init()) case 400: - .badRequest(.init()) + return .badRequest(.init()) case 404: - .notFound(.init()) + return .notFound(.init()) default: - .undocumented( + return .undocumented( statusCode: response.status.code, .init( headerFields: response.headerFields, @@ -884,7 +1893,6 @@ public struct Client: APIProtocol { } ) } - /// Gets the namespace of an item. /// /// - Remark: HTTP `GET /items/{itemname}/metadata/namespaces`. @@ -954,7 +1962,6 @@ public struct Client: APIProtocol { } ) } - /// Gets the item which defines the requested semantics of an item. /// /// - Remark: HTTP `GET /items/{itemName}/semantic/{semanticClass}`. @@ -986,11 +1993,11 @@ public struct Client: APIProtocol { deserializer: { response, responseBody in switch response.status.code { case 200: - .ok(.init()) + return .ok(.init()) case 404: - .notFound(.init()) + return .notFound(.init()) default: - .undocumented( + return .undocumented( statusCode: response.status.code, .init( headerFields: response.headerFields, @@ -1001,7 +2008,6 @@ public struct Client: APIProtocol { } ) } - /// Remove unused/orphaned metadata. /// /// - Remark: HTTP `POST /items/metadata/purge`. @@ -1010,7 +2016,7 @@ public struct Client: APIProtocol { try await client.send( input: input, forOperation: Operations.purgeDatabase.id, - serializer: { _ in + serializer: { input in let path = try converter.renderedPath( template: "/items/metadata/purge", parameters: [] @@ -1025,9 +2031,9 @@ public struct Client: APIProtocol { deserializer: { response, responseBody in switch response.status.code { case 200: - .ok(.init()) + return .ok(.init()) default: - .undocumented( + return .undocumented( statusCode: response.status.code, .init( headerFields: response.headerFields, @@ -1038,7 +2044,6 @@ public struct Client: APIProtocol { } ) } - /// Gets information about the runtime, the API version and links to resources. /// /// - Remark: HTTP `GET //`. @@ -1099,7 +2104,6 @@ public struct Client: APIProtocol { } ) } - /// Gets information about the system. /// /// - Remark: HTTP `GET /systeminfo`. @@ -1160,7 +2164,6 @@ public struct Client: APIProtocol { } ) } - /// Get all supported dimensions and their system units. /// /// - Remark: HTTP `GET /systeminfo/uom`. @@ -1221,7 +2224,6 @@ public struct Client: APIProtocol { } ) } - /// Creates a sitemap event subscription. /// /// - Remark: HTTP `POST /sitemaps/events/subscribe`. @@ -1286,7 +2288,6 @@ public struct Client: APIProtocol { } ) } - /// Polls the data for one page of a sitemap. /// /// - Remark: HTTP `GET /sitemaps/{sitemapname}/{pageid}`. @@ -1378,7 +2379,6 @@ public struct Client: APIProtocol { } ) } - /// Polls the data for a whole sitemap. Not recommended due to potentially high traffic. /// /// - Remark: HTTP `GET /sitemaps/{sitemapname}/*`. @@ -1469,7 +2469,6 @@ public struct Client: APIProtocol { } ) } - /// Get sitemap by name. /// /// - Remark: HTTP `GET /sitemaps/{sitemapname}`. @@ -1558,7 +2557,6 @@ public struct Client: APIProtocol { } ) } - /// Get sitemap events for a whole sitemap. Not recommended due to potentially high traffic. /// /// - Remark: HTTP `GET /sitemaps/events/{subscriptionid}/*`. @@ -1591,13 +2589,13 @@ public struct Client: APIProtocol { deserializer: { response, responseBody in switch response.status.code { case 200: - .ok(.init()) + return .ok(.init()) case 400: - .badRequest(.init()) + return .badRequest(.init()) case 404: - .notFound(.init()) + return .notFound(.init()) default: - .undocumented( + return .undocumented( statusCode: response.status.code, .init( headerFields: response.headerFields, @@ -1608,7 +2606,6 @@ public struct Client: APIProtocol { } ) } - /// Get sitemap events. /// /// - Remark: HTTP `GET /sitemaps/events/{subscriptionid}`. @@ -1698,7 +2695,6 @@ public struct Client: APIProtocol { } ) } - /// Get all available sitemaps. /// /// - Remark: HTTP `GET /sitemaps`. @@ -1759,7 +2755,6 @@ public struct Client: APIProtocol { } ) } - /// Get all registered UI components in the specified namespace. /// /// - Remark: HTTP `GET /ui/components/{namespace}`. @@ -1829,7 +2824,6 @@ public struct Client: APIProtocol { } ) } - /// Add a UI component in the specified namespace. /// /// - Remark: HTTP `POST /ui/components/{namespace}`. @@ -1854,11 +2848,12 @@ public struct Client: APIProtocol { in: &request.headerFields, contentTypes: input.headers.accept ) - let body: OpenAPIRuntime.HTTPBody? = switch input.body { + let body: OpenAPIRuntime.HTTPBody? + switch input.body { case .none: - nil + body = nil case let .json(value): - try converter.setOptionalRequestBodyAsJSON( + body = try converter.setOptionalRequestBodyAsJSON( value, headerFields: &request.headerFields, contentType: "application/json; charset=utf-8" @@ -1902,7 +2897,6 @@ public struct Client: APIProtocol { } ) } - /// Get a specific UI component in the specified namespace. /// /// - Remark: HTTP `GET /ui/components/{namespace}/{componentUID}`. @@ -1968,7 +2962,6 @@ public struct Client: APIProtocol { } ) } - /// Update a specific UI component in the specified namespace. /// /// - Remark: HTTP `PUT /ui/components/{namespace}/{componentUID}`. @@ -1994,11 +2987,12 @@ public struct Client: APIProtocol { in: &request.headerFields, contentTypes: input.headers.accept ) - let body: OpenAPIRuntime.HTTPBody? = switch input.body { + let body: OpenAPIRuntime.HTTPBody? + switch input.body { case .none: - nil + body = nil case let .json(value): - try converter.setOptionalRequestBodyAsJSON( + body = try converter.setOptionalRequestBodyAsJSON( value, headerFields: &request.headerFields, contentType: "application/json; charset=utf-8" @@ -2044,7 +3038,6 @@ public struct Client: APIProtocol { } ) } - /// Remove a specific UI component in the specified namespace. /// /// - Remark: HTTP `DELETE /ui/components/{namespace}/{componentUID}`. @@ -2071,11 +3064,11 @@ public struct Client: APIProtocol { deserializer: { response, responseBody in switch response.status.code { case 200: - .ok(.init()) + return .ok(.init()) case 404: - .notFound(.init()) + return .notFound(.init()) default: - .undocumented( + return .undocumented( statusCode: response.status.code, .init( headerFields: response.headerFields, @@ -2086,7 +3079,6 @@ public struct Client: APIProtocol { } ) } - /// Get all registered UI tiles. /// /// - Remark: HTTP `GET /ui/tiles`. diff --git a/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Types.swift b/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Types.swift index 3111cba25..f3915e63c 100644 --- a/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Types.swift +++ b/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Types.swift @@ -1,27 +1,101 @@ -// Copyright (c) 2010-2025 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 - // Generated by swift-openapi-generator, do not modify. @_spi(Generated) import OpenAPIRuntime #if os(Linux) +@preconcurrency import struct Foundation.URL @preconcurrency import struct Foundation.Data @preconcurrency import struct Foundation.Date -@preconcurrency import struct Foundation.URL #else +import struct Foundation.URL import struct Foundation.Data import struct Foundation.Date -import struct Foundation.URL #endif /// A type that performs HTTP operations defined by the OpenAPI document. public protocol APIProtocol: Sendable { + /// Get available rules, optionally filtered by tags and/or prefix. + /// + /// - Remark: HTTP `GET /rules`. + /// - Remark: Generated from `#/paths//rules/get(getRules)`. + func getRules(_ input: Operations.getRules.Input) async throws -> Operations.getRules.Output + /// Creates a rule. + /// + /// - Remark: HTTP `POST /rules`. + /// - Remark: Generated from `#/paths//rules/post(createRule)`. + func createRule(_ input: Operations.createRule.Input) async throws -> Operations.createRule.Output + /// Sets the rule enabled status. + /// + /// - Remark: HTTP `POST /rules/{ruleUID}/enable`. + /// - Remark: Generated from `#/paths//rules/{ruleUID}/enable/post(enableRule)`. + func enableRule(_ input: Operations.enableRule.Input) async throws -> Operations.enableRule.Output + /// Gets the rule actions. + /// + /// - Remark: HTTP `GET /rules/{ruleUID}/actions`. + /// - Remark: Generated from `#/paths//rules/{ruleUID}/actions/get(getRuleActions)`. + func getRuleActions(_ input: Operations.getRuleActions.Input) async throws -> Operations.getRuleActions.Output + /// Gets the rule corresponding to the given UID. + /// + /// - Remark: HTTP `GET /rules/{ruleUID}`. + /// - Remark: Generated from `#/paths//rules/{ruleUID}/get(getRuleById)`. + func getRuleById(_ input: Operations.getRuleById.Input) async throws -> Operations.getRuleById.Output + /// Updates an existing rule corresponding to the given UID. + /// + /// - Remark: HTTP `PUT /rules/{ruleUID}`. + /// - Remark: Generated from `#/paths//rules/{ruleUID}/put(updateRule)`. + func updateRule(_ input: Operations.updateRule.Input) async throws -> Operations.updateRule.Output + /// Removes an existing rule corresponding to the given UID. + /// + /// - Remark: HTTP `DELETE /rules/{ruleUID}`. + /// - Remark: Generated from `#/paths//rules/{ruleUID}/delete(deleteRule)`. + func deleteRule(_ input: Operations.deleteRule.Input) async throws -> Operations.deleteRule.Output + /// Gets the rule conditions. + /// + /// - Remark: HTTP `GET /rules/{ruleUID}/conditions`. + /// - Remark: Generated from `#/paths//rules/{ruleUID}/conditions/get(getRuleConditions)`. + func getRuleConditions(_ input: Operations.getRuleConditions.Input) async throws -> Operations.getRuleConditions.Output + /// Gets the rule configuration values. + /// + /// - Remark: HTTP `GET /rules/{ruleUID}/config`. + /// - Remark: Generated from `#/paths//rules/{ruleUID}/config/get(getRuleConfiguration)`. + func getRuleConfiguration(_ input: Operations.getRuleConfiguration.Input) async throws -> Operations.getRuleConfiguration.Output + /// Sets the rule configuration values. + /// + /// - Remark: HTTP `PUT /rules/{ruleUID}/config`. + /// - Remark: Generated from `#/paths//rules/{ruleUID}/config/put(updateRuleConfiguration)`. + func updateRuleConfiguration(_ input: Operations.updateRuleConfiguration.Input) async throws -> Operations.updateRuleConfiguration.Output + /// Gets the rule's module corresponding to the given Category and ID. + /// + /// - Remark: HTTP `GET /rules/{ruleUID}/{moduleCategory}/{id}`. + /// - Remark: Generated from `#/paths//rules/{ruleUID}/{moduleCategory}/{id}/get(getRuleModuleById)`. + func getRuleModuleById(_ input: Operations.getRuleModuleById.Input) async throws -> Operations.getRuleModuleById.Output + /// Gets the module's configuration. + /// + /// - Remark: HTTP `GET /rules/{ruleUID}/{moduleCategory}/{id}/config`. + /// - Remark: Generated from `#/paths//rules/{ruleUID}/{moduleCategory}/{id}/config/get(getRuleModuleConfig)`. + func getRuleModuleConfig(_ input: Operations.getRuleModuleConfig.Input) async throws -> Operations.getRuleModuleConfig.Output + /// Gets the module's configuration parameter. + /// + /// - Remark: HTTP `GET /rules/{ruleUID}/{moduleCategory}/{id}/config/{param}`. + /// - Remark: Generated from `#/paths//rules/{ruleUID}/{moduleCategory}/{id}/config/{param}/get(getRuleModuleConfigParameter)`. + func getRuleModuleConfigParameter(_ input: Operations.getRuleModuleConfigParameter.Input) async throws -> Operations.getRuleModuleConfigParameter.Output + /// Sets the module's configuration parameter value. + /// + /// - Remark: HTTP `PUT /rules/{ruleUID}/{moduleCategory}/{id}/config/{param}`. + /// - Remark: Generated from `#/paths//rules/{ruleUID}/{moduleCategory}/{id}/config/{param}/put(setRuleModuleConfigParameter)`. + func setRuleModuleConfigParameter(_ input: Operations.setRuleModuleConfigParameter.Input) async throws -> Operations.setRuleModuleConfigParameter.Output + /// Gets the rule triggers. + /// + /// - Remark: HTTP `GET /rules/{ruleUID}/triggers`. + /// - Remark: Generated from `#/paths//rules/{ruleUID}/triggers/get(getRuleTriggers)`. + func getRuleTriggers(_ input: Operations.getRuleTriggers.Input) async throws -> Operations.getRuleTriggers.Output + /// Executes actions of the rule. + /// + /// - Remark: HTTP `POST /rules/{ruleUID}/runnow`. + /// - Remark: Generated from `#/paths//rules/{ruleUID}/runnow/post(runRuleNow_1)`. + func runRuleNow_1(_ input: Operations.runRuleNow_1.Input) async throws -> Operations.runRuleNow_1.Output + /// Simulates the executions of rules filtered by tag 'Schedule' within the given times. + /// + /// - Remark: HTTP `GET /rules/schedule/simulations`. + /// - Remark: Generated from `#/paths//rules/schedule/simulations/get(getScheduleRuleSimulations)`. + func getScheduleRuleSimulations(_ input: Operations.getScheduleRuleSimulations.Input) async throws -> Operations.getScheduleRuleSimulations.Output /// Adds a new member to a group item. /// /// - Remark: HTTP `PUT /items/{itemName}/members/{memberItemName}`. @@ -190,364 +264,579 @@ public protocol APIProtocol: Sendable { } /// Convenience overloads for operation inputs. -public extension APIProtocol { +extension APIProtocol { + /// Get available rules, optionally filtered by tags and/or prefix. + /// + /// - Remark: HTTP `GET /rules`. + /// - Remark: Generated from `#/paths//rules/get(getRules)`. + public func getRules( + query: Operations.getRules.Input.Query = .init(), + headers: Operations.getRules.Input.Headers = .init() + ) async throws -> Operations.getRules.Output { + try await getRules(Operations.getRules.Input( + query: query, + headers: headers + )) + } + /// Creates a rule. + /// + /// - Remark: HTTP `POST /rules`. + /// - Remark: Generated from `#/paths//rules/post(createRule)`. + public func createRule(body: Operations.createRule.Input.Body) async throws -> Operations.createRule.Output { + try await createRule(Operations.createRule.Input(body: body)) + } + /// Sets the rule enabled status. + /// + /// - Remark: HTTP `POST /rules/{ruleUID}/enable`. + /// - Remark: Generated from `#/paths//rules/{ruleUID}/enable/post(enableRule)`. + public func enableRule( + path: Operations.enableRule.Input.Path, + body: Operations.enableRule.Input.Body + ) async throws -> Operations.enableRule.Output { + try await enableRule(Operations.enableRule.Input( + path: path, + body: body + )) + } + /// Gets the rule actions. + /// + /// - Remark: HTTP `GET /rules/{ruleUID}/actions`. + /// - Remark: Generated from `#/paths//rules/{ruleUID}/actions/get(getRuleActions)`. + public func getRuleActions( + path: Operations.getRuleActions.Input.Path, + headers: Operations.getRuleActions.Input.Headers = .init() + ) async throws -> Operations.getRuleActions.Output { + try await getRuleActions(Operations.getRuleActions.Input( + path: path, + headers: headers + )) + } + /// Gets the rule corresponding to the given UID. + /// + /// - Remark: HTTP `GET /rules/{ruleUID}`. + /// - Remark: Generated from `#/paths//rules/{ruleUID}/get(getRuleById)`. + public func getRuleById( + path: Operations.getRuleById.Input.Path, + headers: Operations.getRuleById.Input.Headers = .init() + ) async throws -> Operations.getRuleById.Output { + try await getRuleById(Operations.getRuleById.Input( + path: path, + headers: headers + )) + } + /// Updates an existing rule corresponding to the given UID. + /// + /// - Remark: HTTP `PUT /rules/{ruleUID}`. + /// - Remark: Generated from `#/paths//rules/{ruleUID}/put(updateRule)`. + public func updateRule( + path: Operations.updateRule.Input.Path, + body: Operations.updateRule.Input.Body + ) async throws -> Operations.updateRule.Output { + try await updateRule(Operations.updateRule.Input( + path: path, + body: body + )) + } + /// Removes an existing rule corresponding to the given UID. + /// + /// - Remark: HTTP `DELETE /rules/{ruleUID}`. + /// - Remark: Generated from `#/paths//rules/{ruleUID}/delete(deleteRule)`. + public func deleteRule(path: Operations.deleteRule.Input.Path) async throws -> Operations.deleteRule.Output { + try await deleteRule(Operations.deleteRule.Input(path: path)) + } + /// Gets the rule conditions. + /// + /// - Remark: HTTP `GET /rules/{ruleUID}/conditions`. + /// - Remark: Generated from `#/paths//rules/{ruleUID}/conditions/get(getRuleConditions)`. + public func getRuleConditions( + path: Operations.getRuleConditions.Input.Path, + headers: Operations.getRuleConditions.Input.Headers = .init() + ) async throws -> Operations.getRuleConditions.Output { + try await getRuleConditions(Operations.getRuleConditions.Input( + path: path, + headers: headers + )) + } + /// Gets the rule configuration values. + /// + /// - Remark: HTTP `GET /rules/{ruleUID}/config`. + /// - Remark: Generated from `#/paths//rules/{ruleUID}/config/get(getRuleConfiguration)`. + public func getRuleConfiguration( + path: Operations.getRuleConfiguration.Input.Path, + headers: Operations.getRuleConfiguration.Input.Headers = .init() + ) async throws -> Operations.getRuleConfiguration.Output { + try await getRuleConfiguration(Operations.getRuleConfiguration.Input( + path: path, + headers: headers + )) + } + /// Sets the rule configuration values. + /// + /// - Remark: HTTP `PUT /rules/{ruleUID}/config`. + /// - Remark: Generated from `#/paths//rules/{ruleUID}/config/put(updateRuleConfiguration)`. + public func updateRuleConfiguration( + path: Operations.updateRuleConfiguration.Input.Path, + body: Operations.updateRuleConfiguration.Input.Body? = nil + ) async throws -> Operations.updateRuleConfiguration.Output { + try await updateRuleConfiguration(Operations.updateRuleConfiguration.Input( + path: path, + body: body + )) + } + /// Gets the rule's module corresponding to the given Category and ID. + /// + /// - Remark: HTTP `GET /rules/{ruleUID}/{moduleCategory}/{id}`. + /// - Remark: Generated from `#/paths//rules/{ruleUID}/{moduleCategory}/{id}/get(getRuleModuleById)`. + public func getRuleModuleById( + path: Operations.getRuleModuleById.Input.Path, + headers: Operations.getRuleModuleById.Input.Headers = .init() + ) async throws -> Operations.getRuleModuleById.Output { + try await getRuleModuleById(Operations.getRuleModuleById.Input( + path: path, + headers: headers + )) + } + /// Gets the module's configuration. + /// + /// - Remark: HTTP `GET /rules/{ruleUID}/{moduleCategory}/{id}/config`. + /// - Remark: Generated from `#/paths//rules/{ruleUID}/{moduleCategory}/{id}/config/get(getRuleModuleConfig)`. + public func getRuleModuleConfig( + path: Operations.getRuleModuleConfig.Input.Path, + headers: Operations.getRuleModuleConfig.Input.Headers = .init() + ) async throws -> Operations.getRuleModuleConfig.Output { + try await getRuleModuleConfig(Operations.getRuleModuleConfig.Input( + path: path, + headers: headers + )) + } + /// Gets the module's configuration parameter. + /// + /// - Remark: HTTP `GET /rules/{ruleUID}/{moduleCategory}/{id}/config/{param}`. + /// - Remark: Generated from `#/paths//rules/{ruleUID}/{moduleCategory}/{id}/config/{param}/get(getRuleModuleConfigParameter)`. + public func getRuleModuleConfigParameter( + path: Operations.getRuleModuleConfigParameter.Input.Path, + headers: Operations.getRuleModuleConfigParameter.Input.Headers = .init() + ) async throws -> Operations.getRuleModuleConfigParameter.Output { + try await getRuleModuleConfigParameter(Operations.getRuleModuleConfigParameter.Input( + path: path, + headers: headers + )) + } + /// Sets the module's configuration parameter value. + /// + /// - Remark: HTTP `PUT /rules/{ruleUID}/{moduleCategory}/{id}/config/{param}`. + /// - Remark: Generated from `#/paths//rules/{ruleUID}/{moduleCategory}/{id}/config/{param}/put(setRuleModuleConfigParameter)`. + public func setRuleModuleConfigParameter( + path: Operations.setRuleModuleConfigParameter.Input.Path, + body: Operations.setRuleModuleConfigParameter.Input.Body + ) async throws -> Operations.setRuleModuleConfigParameter.Output { + try await setRuleModuleConfigParameter(Operations.setRuleModuleConfigParameter.Input( + path: path, + body: body + )) + } + /// Gets the rule triggers. + /// + /// - Remark: HTTP `GET /rules/{ruleUID}/triggers`. + /// - Remark: Generated from `#/paths//rules/{ruleUID}/triggers/get(getRuleTriggers)`. + public func getRuleTriggers( + path: Operations.getRuleTriggers.Input.Path, + headers: Operations.getRuleTriggers.Input.Headers = .init() + ) async throws -> Operations.getRuleTriggers.Output { + try await getRuleTriggers(Operations.getRuleTriggers.Input( + path: path, + headers: headers + )) + } + /// Executes actions of the rule. + /// + /// - Remark: HTTP `POST /rules/{ruleUID}/runnow`. + /// - Remark: Generated from `#/paths//rules/{ruleUID}/runnow/post(runRuleNow_1)`. + public func runRuleNow_1( + path: Operations.runRuleNow_1.Input.Path, + body: Operations.runRuleNow_1.Input.Body? = nil + ) async throws -> Operations.runRuleNow_1.Output { + try await runRuleNow_1(Operations.runRuleNow_1.Input( + path: path, + body: body + )) + } + /// Simulates the executions of rules filtered by tag 'Schedule' within the given times. + /// + /// - Remark: HTTP `GET /rules/schedule/simulations`. + /// - Remark: Generated from `#/paths//rules/schedule/simulations/get(getScheduleRuleSimulations)`. + public func getScheduleRuleSimulations( + query: Operations.getScheduleRuleSimulations.Input.Query = .init(), + headers: Operations.getScheduleRuleSimulations.Input.Headers = .init() + ) async throws -> Operations.getScheduleRuleSimulations.Output { + try await getScheduleRuleSimulations(Operations.getScheduleRuleSimulations.Input( + query: query, + headers: headers + )) + } /// Adds a new member to a group item. /// /// - Remark: HTTP `PUT /items/{itemName}/members/{memberItemName}`. /// - Remark: Generated from `#/paths//items/{itemName}/members/{memberItemName}/put(addMemberToGroupItem)`. - func addMemberToGroupItem(path: Operations.addMemberToGroupItem.Input.Path) async throws -> Operations.addMemberToGroupItem.Output { + public func addMemberToGroupItem(path: Operations.addMemberToGroupItem.Input.Path) async throws -> Operations.addMemberToGroupItem.Output { try await addMemberToGroupItem(Operations.addMemberToGroupItem.Input(path: path)) } - /// Removes an existing member from a group item. /// /// - Remark: HTTP `DELETE /items/{itemName}/members/{memberItemName}`. /// - Remark: Generated from `#/paths//items/{itemName}/members/{memberItemName}/delete(removeMemberFromGroupItem)`. - func removeMemberFromGroupItem(path: Operations.removeMemberFromGroupItem.Input.Path) async throws -> Operations.removeMemberFromGroupItem.Output { + public func removeMemberFromGroupItem(path: Operations.removeMemberFromGroupItem.Input.Path) async throws -> Operations.removeMemberFromGroupItem.Output { try await removeMemberFromGroupItem(Operations.removeMemberFromGroupItem.Input(path: path)) } - /// Adds metadata to an item. /// /// - Remark: HTTP `PUT /items/{itemname}/metadata/{namespace}`. /// - Remark: Generated from `#/paths//items/{itemname}/metadata/{namespace}/put(addMetadataToItem)`. - func addMetadataToItem(path: Operations.addMetadataToItem.Input.Path, - body: Operations.addMetadataToItem.Input.Body) async throws -> Operations.addMetadataToItem.Output { + public func addMetadataToItem( + path: Operations.addMetadataToItem.Input.Path, + body: Operations.addMetadataToItem.Input.Body + ) async throws -> Operations.addMetadataToItem.Output { try await addMetadataToItem(Operations.addMetadataToItem.Input( path: path, body: body )) } - /// Removes metadata from an item. /// /// - Remark: HTTP `DELETE /items/{itemname}/metadata/{namespace}`. /// - Remark: Generated from `#/paths//items/{itemname}/metadata/{namespace}/delete(removeMetadataFromItem)`. - func removeMetadataFromItem(path: Operations.removeMetadataFromItem.Input.Path) async throws -> Operations.removeMetadataFromItem.Output { + public func removeMetadataFromItem(path: Operations.removeMetadataFromItem.Input.Path) async throws -> Operations.removeMetadataFromItem.Output { try await removeMetadataFromItem(Operations.removeMetadataFromItem.Input(path: path)) } - /// Adds a tag to an item. /// /// - Remark: HTTP `PUT /items/{itemname}/tags/{tag}`. /// - Remark: Generated from `#/paths//items/{itemname}/tags/{tag}/put(addTagToItem)`. - func addTagToItem(path: Operations.addTagToItem.Input.Path) async throws -> Operations.addTagToItem.Output { + public func addTagToItem(path: Operations.addTagToItem.Input.Path) async throws -> Operations.addTagToItem.Output { try await addTagToItem(Operations.addTagToItem.Input(path: path)) } - /// Removes a tag from an item. /// /// - Remark: HTTP `DELETE /items/{itemname}/tags/{tag}`. /// - Remark: Generated from `#/paths//items/{itemname}/tags/{tag}/delete(removeTagFromItem)`. - func removeTagFromItem(path: Operations.removeTagFromItem.Input.Path) async throws -> Operations.removeTagFromItem.Output { + public func removeTagFromItem(path: Operations.removeTagFromItem.Input.Path) async throws -> Operations.removeTagFromItem.Output { try await removeTagFromItem(Operations.removeTagFromItem.Input(path: path)) } - /// Gets a single item. /// /// - Remark: HTTP `GET /items/{itemname}`. /// - Remark: Generated from `#/paths//items/{itemname}/get(getItemByName)`. - func getItemByName(path: Operations.getItemByName.Input.Path, - query: Operations.getItemByName.Input.Query = .init(), - headers: Operations.getItemByName.Input.Headers = .init()) async throws -> Operations.getItemByName.Output { + public func getItemByName( + path: Operations.getItemByName.Input.Path, + query: Operations.getItemByName.Input.Query = .init(), + headers: Operations.getItemByName.Input.Headers = .init() + ) async throws -> Operations.getItemByName.Output { try await getItemByName(Operations.getItemByName.Input( path: path, query: query, headers: headers )) } - /// Sends a command to an item. /// /// - Remark: HTTP `POST /items/{itemname}`. /// - Remark: Generated from `#/paths//items/{itemname}/post(sendItemCommand)`. - func sendItemCommand(path: Operations.sendItemCommand.Input.Path, - body: Operations.sendItemCommand.Input.Body) async throws -> Operations.sendItemCommand.Output { + public func sendItemCommand( + path: Operations.sendItemCommand.Input.Path, + body: Operations.sendItemCommand.Input.Body + ) async throws -> Operations.sendItemCommand.Output { try await sendItemCommand(Operations.sendItemCommand.Input( path: path, body: body )) } - /// Adds a new item to the registry or updates the existing item. /// /// - Remark: HTTP `PUT /items/{itemname}`. /// - Remark: Generated from `#/paths//items/{itemname}/put(addOrUpdateItemInRegistry)`. - func addOrUpdateItemInRegistry(path: Operations.addOrUpdateItemInRegistry.Input.Path, - headers: Operations.addOrUpdateItemInRegistry.Input.Headers = .init(), - body: Operations.addOrUpdateItemInRegistry.Input.Body) async throws -> Operations.addOrUpdateItemInRegistry.Output { + public func addOrUpdateItemInRegistry( + path: Operations.addOrUpdateItemInRegistry.Input.Path, + headers: Operations.addOrUpdateItemInRegistry.Input.Headers = .init(), + body: Operations.addOrUpdateItemInRegistry.Input.Body + ) async throws -> Operations.addOrUpdateItemInRegistry.Output { try await addOrUpdateItemInRegistry(Operations.addOrUpdateItemInRegistry.Input( path: path, headers: headers, body: body )) } - /// Removes an item from the registry. /// /// - Remark: HTTP `DELETE /items/{itemname}`. /// - Remark: Generated from `#/paths//items/{itemname}/delete(removeItemFromRegistry)`. - func removeItemFromRegistry(path: Operations.removeItemFromRegistry.Input.Path) async throws -> Operations.removeItemFromRegistry.Output { + public func removeItemFromRegistry(path: Operations.removeItemFromRegistry.Input.Path) async throws -> Operations.removeItemFromRegistry.Output { try await removeItemFromRegistry(Operations.removeItemFromRegistry.Input(path: path)) } - /// Get all available items. /// /// - Remark: HTTP `GET /items`. /// - Remark: Generated from `#/paths//items/get(getItems)`. - func getItems(query: Operations.getItems.Input.Query = .init(), - headers: Operations.getItems.Input.Headers = .init()) async throws -> Operations.getItems.Output { + public func getItems( + query: Operations.getItems.Input.Query = .init(), + headers: Operations.getItems.Input.Headers = .init() + ) async throws -> Operations.getItems.Output { try await getItems(Operations.getItems.Input( query: query, headers: headers )) } - /// Adds a list of items to the registry or updates the existing items. /// /// - Remark: HTTP `PUT /items`. /// - Remark: Generated from `#/paths//items/put(addOrUpdateItemsInRegistry)`. - func addOrUpdateItemsInRegistry(headers: Operations.addOrUpdateItemsInRegistry.Input.Headers = .init(), - body: Operations.addOrUpdateItemsInRegistry.Input.Body) async throws -> Operations.addOrUpdateItemsInRegistry.Output { + public func addOrUpdateItemsInRegistry( + headers: Operations.addOrUpdateItemsInRegistry.Input.Headers = .init(), + body: Operations.addOrUpdateItemsInRegistry.Input.Body + ) async throws -> Operations.addOrUpdateItemsInRegistry.Output { try await addOrUpdateItemsInRegistry(Operations.addOrUpdateItemsInRegistry.Input( headers: headers, body: body )) } - /// Gets the state of an item. /// /// - Remark: HTTP `GET /items/{itemname}/state`. /// - Remark: Generated from `#/paths//items/{itemname}/state/get(getItemState_1)`. - func getItemState_1(path: Operations.getItemState_1.Input.Path, - headers: Operations.getItemState_1.Input.Headers = .init()) async throws -> Operations.getItemState_1.Output { + public func getItemState_1( + path: Operations.getItemState_1.Input.Path, + headers: Operations.getItemState_1.Input.Headers = .init() + ) async throws -> Operations.getItemState_1.Output { try await getItemState_1(Operations.getItemState_1.Input( path: path, headers: headers )) } - /// Updates the state of an item. /// /// - Remark: HTTP `PUT /items/{itemname}/state`. /// - Remark: Generated from `#/paths//items/{itemname}/state/put(updateItemState)`. - func updateItemState(path: Operations.updateItemState.Input.Path, - headers: Operations.updateItemState.Input.Headers = .init(), - body: Operations.updateItemState.Input.Body) async throws -> Operations.updateItemState.Output { + public func updateItemState( + path: Operations.updateItemState.Input.Path, + headers: Operations.updateItemState.Input.Headers = .init(), + body: Operations.updateItemState.Input.Body + ) async throws -> Operations.updateItemState.Output { try await updateItemState(Operations.updateItemState.Input( path: path, headers: headers, body: body )) } - /// Gets the namespace of an item. /// /// - Remark: HTTP `GET /items/{itemname}/metadata/namespaces`. /// - Remark: Generated from `#/paths//items/{itemname}/metadata/namespaces/get(getItemNamespaces)`. - func getItemNamespaces(path: Operations.getItemNamespaces.Input.Path, - headers: Operations.getItemNamespaces.Input.Headers = .init()) async throws -> Operations.getItemNamespaces.Output { + public func getItemNamespaces( + path: Operations.getItemNamespaces.Input.Path, + headers: Operations.getItemNamespaces.Input.Headers = .init() + ) async throws -> Operations.getItemNamespaces.Output { try await getItemNamespaces(Operations.getItemNamespaces.Input( path: path, headers: headers )) } - /// Gets the item which defines the requested semantics of an item. /// /// - Remark: HTTP `GET /items/{itemName}/semantic/{semanticClass}`. /// - Remark: Generated from `#/paths//items/{itemName}/semantic/{semanticClass}/get(getSemanticItem)`. - func getSemanticItem(path: Operations.getSemanticItem.Input.Path, - headers: Operations.getSemanticItem.Input.Headers = .init()) async throws -> Operations.getSemanticItem.Output { + public func getSemanticItem( + path: Operations.getSemanticItem.Input.Path, + headers: Operations.getSemanticItem.Input.Headers = .init() + ) async throws -> Operations.getSemanticItem.Output { try await getSemanticItem(Operations.getSemanticItem.Input( path: path, headers: headers )) } - /// Remove unused/orphaned metadata. /// /// - Remark: HTTP `POST /items/metadata/purge`. /// - Remark: Generated from `#/paths//items/metadata/purge/post(purgeDatabase)`. - func purgeDatabase() async throws -> Operations.purgeDatabase.Output { + public func purgeDatabase() async throws -> Operations.purgeDatabase.Output { try await purgeDatabase(Operations.purgeDatabase.Input()) } - /// Gets information about the runtime, the API version and links to resources. /// /// - Remark: HTTP `GET //`. /// - Remark: Generated from `#/paths////get(getRoot)`. - func getRoot(headers: Operations.getRoot.Input.Headers = .init()) async throws -> Operations.getRoot.Output { + public func getRoot(headers: Operations.getRoot.Input.Headers = .init()) async throws -> Operations.getRoot.Output { try await getRoot(Operations.getRoot.Input(headers: headers)) } - /// Gets information about the system. /// /// - Remark: HTTP `GET /systeminfo`. /// - Remark: Generated from `#/paths//systeminfo/get(getSystemInformation)`. - func getSystemInformation(headers: Operations.getSystemInformation.Input.Headers = .init()) async throws -> Operations.getSystemInformation.Output { + public func getSystemInformation(headers: Operations.getSystemInformation.Input.Headers = .init()) async throws -> Operations.getSystemInformation.Output { try await getSystemInformation(Operations.getSystemInformation.Input(headers: headers)) } - /// Get all supported dimensions and their system units. /// /// - Remark: HTTP `GET /systeminfo/uom`. /// - Remark: Generated from `#/paths//systeminfo/uom/get(getUoMInformation)`. - func getUoMInformation(headers: Operations.getUoMInformation.Input.Headers = .init()) async throws -> Operations.getUoMInformation.Output { + public func getUoMInformation(headers: Operations.getUoMInformation.Input.Headers = .init()) async throws -> Operations.getUoMInformation.Output { try await getUoMInformation(Operations.getUoMInformation.Input(headers: headers)) } - /// Creates a sitemap event subscription. /// /// - Remark: HTTP `POST /sitemaps/events/subscribe`. /// - Remark: Generated from `#/paths//sitemaps/events/subscribe/post(createSitemapEventSubscription)`. - func createSitemapEventSubscription(headers: Operations.createSitemapEventSubscription.Input.Headers = .init()) async throws -> Operations.createSitemapEventSubscription.Output { + public func createSitemapEventSubscription(headers: Operations.createSitemapEventSubscription.Input.Headers = .init()) async throws -> Operations.createSitemapEventSubscription.Output { try await createSitemapEventSubscription(Operations.createSitemapEventSubscription.Input(headers: headers)) } - /// Polls the data for one page of a sitemap. /// /// - Remark: HTTP `GET /sitemaps/{sitemapname}/{pageid}`. /// - Remark: Generated from `#/paths//sitemaps/{sitemapname}/{pageid}/get(pollDataForPage)`. - func pollDataForPage(path: Operations.pollDataForPage.Input.Path, - query: Operations.pollDataForPage.Input.Query = .init(), - headers: Operations.pollDataForPage.Input.Headers = .init()) async throws -> Operations.pollDataForPage.Output { + public func pollDataForPage( + path: Operations.pollDataForPage.Input.Path, + query: Operations.pollDataForPage.Input.Query = .init(), + headers: Operations.pollDataForPage.Input.Headers = .init() + ) async throws -> Operations.pollDataForPage.Output { try await pollDataForPage(Operations.pollDataForPage.Input( path: path, query: query, headers: headers )) } - /// Polls the data for a whole sitemap. Not recommended due to potentially high traffic. /// /// - Remark: HTTP `GET /sitemaps/{sitemapname}/*`. /// - Remark: Generated from `#/paths//sitemaps/{sitemapname}/*/get(pollDataForSitemap)`. - func pollDataForSitemap(path: Operations.pollDataForSitemap.Input.Path, - query: Operations.pollDataForSitemap.Input.Query = .init(), - headers: Operations.pollDataForSitemap.Input.Headers = .init()) async throws -> Operations.pollDataForSitemap.Output { + public func pollDataForSitemap( + path: Operations.pollDataForSitemap.Input.Path, + query: Operations.pollDataForSitemap.Input.Query = .init(), + headers: Operations.pollDataForSitemap.Input.Headers = .init() + ) async throws -> Operations.pollDataForSitemap.Output { try await pollDataForSitemap(Operations.pollDataForSitemap.Input( path: path, query: query, headers: headers )) } - /// Get sitemap by name. /// /// - Remark: HTTP `GET /sitemaps/{sitemapname}`. /// - Remark: Generated from `#/paths//sitemaps/{sitemapname}/get(getSitemapByName)`. - func getSitemapByName(path: Operations.getSitemapByName.Input.Path, - query: Operations.getSitemapByName.Input.Query = .init(), - headers: Operations.getSitemapByName.Input.Headers = .init()) async throws -> Operations.getSitemapByName.Output { + public func getSitemapByName( + path: Operations.getSitemapByName.Input.Path, + query: Operations.getSitemapByName.Input.Query = .init(), + headers: Operations.getSitemapByName.Input.Headers = .init() + ) async throws -> Operations.getSitemapByName.Output { try await getSitemapByName(Operations.getSitemapByName.Input( path: path, query: query, headers: headers )) } - /// Get sitemap events for a whole sitemap. Not recommended due to potentially high traffic. /// /// - Remark: HTTP `GET /sitemaps/events/{subscriptionid}/*`. /// - Remark: Generated from `#/paths//sitemaps/events/{subscriptionid}/*/get(getSitemapEvents)`. - func getSitemapEvents(path: Operations.getSitemapEvents.Input.Path, - query: Operations.getSitemapEvents.Input.Query = .init()) async throws -> Operations.getSitemapEvents.Output { + public func getSitemapEvents( + path: Operations.getSitemapEvents.Input.Path, + query: Operations.getSitemapEvents.Input.Query = .init() + ) async throws -> Operations.getSitemapEvents.Output { try await getSitemapEvents(Operations.getSitemapEvents.Input( path: path, query: query )) } - /// Get sitemap events. /// /// - Remark: HTTP `GET /sitemaps/events/{subscriptionid}`. /// - Remark: Generated from `#/paths//sitemaps/events/{subscriptionid}/get(getSitemapEvents_1)`. - func getSitemapEvents_1(path: Operations.getSitemapEvents_1.Input.Path, - query: Operations.getSitemapEvents_1.Input.Query = .init(), - headers: Operations.getSitemapEvents_1.Input.Headers = .init()) async throws -> Operations.getSitemapEvents_1.Output { + public func getSitemapEvents_1( + path: Operations.getSitemapEvents_1.Input.Path, + query: Operations.getSitemapEvents_1.Input.Query = .init(), + headers: Operations.getSitemapEvents_1.Input.Headers = .init() + ) async throws -> Operations.getSitemapEvents_1.Output { try await getSitemapEvents_1(Operations.getSitemapEvents_1.Input( path: path, query: query, headers: headers )) } - /// Get all available sitemaps. /// /// - Remark: HTTP `GET /sitemaps`. /// - Remark: Generated from `#/paths//sitemaps/get(getSitemaps)`. - func getSitemaps(headers: Operations.getSitemaps.Input.Headers = .init()) async throws -> Operations.getSitemaps.Output { + public func getSitemaps(headers: Operations.getSitemaps.Input.Headers = .init()) async throws -> Operations.getSitemaps.Output { try await getSitemaps(Operations.getSitemaps.Input(headers: headers)) } - /// Get all registered UI components in the specified namespace. /// /// - Remark: HTTP `GET /ui/components/{namespace}`. /// - Remark: Generated from `#/paths//ui/components/{namespace}/get(getRegisteredUIComponentsInNamespace)`. - func getRegisteredUIComponentsInNamespace(path: Operations.getRegisteredUIComponentsInNamespace.Input.Path, - query: Operations.getRegisteredUIComponentsInNamespace.Input.Query = .init(), - headers: Operations.getRegisteredUIComponentsInNamespace.Input.Headers = .init()) async throws -> Operations.getRegisteredUIComponentsInNamespace.Output { + public func getRegisteredUIComponentsInNamespace( + path: Operations.getRegisteredUIComponentsInNamespace.Input.Path, + query: Operations.getRegisteredUIComponentsInNamespace.Input.Query = .init(), + headers: Operations.getRegisteredUIComponentsInNamespace.Input.Headers = .init() + ) async throws -> Operations.getRegisteredUIComponentsInNamespace.Output { try await getRegisteredUIComponentsInNamespace(Operations.getRegisteredUIComponentsInNamespace.Input( path: path, query: query, headers: headers )) } - /// Add a UI component in the specified namespace. /// /// - Remark: HTTP `POST /ui/components/{namespace}`. /// - Remark: Generated from `#/paths//ui/components/{namespace}/post(addUIComponentToNamespace)`. - func addUIComponentToNamespace(path: Operations.addUIComponentToNamespace.Input.Path, - headers: Operations.addUIComponentToNamespace.Input.Headers = .init(), - body: Operations.addUIComponentToNamespace.Input.Body? = nil) async throws -> Operations.addUIComponentToNamespace.Output { + public func addUIComponentToNamespace( + path: Operations.addUIComponentToNamespace.Input.Path, + headers: Operations.addUIComponentToNamespace.Input.Headers = .init(), + body: Operations.addUIComponentToNamespace.Input.Body? = nil + ) async throws -> Operations.addUIComponentToNamespace.Output { try await addUIComponentToNamespace(Operations.addUIComponentToNamespace.Input( path: path, headers: headers, body: body )) } - /// Get a specific UI component in the specified namespace. /// /// - Remark: HTTP `GET /ui/components/{namespace}/{componentUID}`. /// - Remark: Generated from `#/paths//ui/components/{namespace}/{componentUID}/get(getUIComponentInNamespace)`. - func getUIComponentInNamespace(path: Operations.getUIComponentInNamespace.Input.Path, - headers: Operations.getUIComponentInNamespace.Input.Headers = .init()) async throws -> Operations.getUIComponentInNamespace.Output { + public func getUIComponentInNamespace( + path: Operations.getUIComponentInNamespace.Input.Path, + headers: Operations.getUIComponentInNamespace.Input.Headers = .init() + ) async throws -> Operations.getUIComponentInNamespace.Output { try await getUIComponentInNamespace(Operations.getUIComponentInNamespace.Input( path: path, headers: headers )) } - /// Update a specific UI component in the specified namespace. /// /// - Remark: HTTP `PUT /ui/components/{namespace}/{componentUID}`. /// - Remark: Generated from `#/paths//ui/components/{namespace}/{componentUID}/put(updateUIComponentInNamespace)`. - func updateUIComponentInNamespace(path: Operations.updateUIComponentInNamespace.Input.Path, - headers: Operations.updateUIComponentInNamespace.Input.Headers = .init(), - body: Operations.updateUIComponentInNamespace.Input.Body? = nil) async throws -> Operations.updateUIComponentInNamespace.Output { + public func updateUIComponentInNamespace( + path: Operations.updateUIComponentInNamespace.Input.Path, + headers: Operations.updateUIComponentInNamespace.Input.Headers = .init(), + body: Operations.updateUIComponentInNamespace.Input.Body? = nil + ) async throws -> Operations.updateUIComponentInNamespace.Output { try await updateUIComponentInNamespace(Operations.updateUIComponentInNamespace.Input( path: path, headers: headers, body: body )) } - /// Remove a specific UI component in the specified namespace. /// /// - Remark: HTTP `DELETE /ui/components/{namespace}/{componentUID}`. /// - Remark: Generated from `#/paths//ui/components/{namespace}/{componentUID}/delete(removeUIComponentFromNamespace)`. - func removeUIComponentFromNamespace(path: Operations.removeUIComponentFromNamespace.Input.Path) async throws -> Operations.removeUIComponentFromNamespace.Output { + public func removeUIComponentFromNamespace(path: Operations.removeUIComponentFromNamespace.Input.Path) async throws -> Operations.removeUIComponentFromNamespace.Output { try await removeUIComponentFromNamespace(Operations.removeUIComponentFromNamespace.Input(path: path)) } - /// Get all registered UI tiles. /// /// - Remark: HTTP `GET /ui/tiles`. /// - Remark: Generated from `#/paths//ui/tiles/get(getUITiles)`. - func getUITiles(headers: Operations.getUITiles.Input.Headers = .init()) async throws -> Operations.getUITiles.Output { + public func getUITiles(headers: Operations.getUITiles.Input.Headers = .init()) async throws -> Operations.getUITiles.Output { try await getUITiles(Operations.getUITiles.Input(headers: headers)) } } @@ -562,7 +851,6 @@ public enum Servers { ) } } - @available(*, deprecated, renamed: "Servers.Server1.url") public static func server1() throws -> Foundation.URL { try Foundation.URL( @@ -592,12 +880,11 @@ public enum Components { public var required: Swift.Bool? /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterDTO/type`. @frozen public enum _typePayload: String, Codable, Hashable, Sendable, CaseIterable { - case TEXT - case INTEGER - case DECIMAL - case BOOLEAN + case TEXT = "TEXT" + case INTEGER = "INTEGER" + case DECIMAL = "DECIMAL" + case BOOLEAN = "BOOLEAN" } - /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterDTO/type`. public var _type: Components.Schemas.ConfigDescriptionParameterDTO._typePayload? /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterDTO/min`. @@ -655,28 +942,30 @@ public enum Components { /// - unitLabel: /// - options: /// - filterCriteria: - public init(context: Swift.String? = nil, - defaultValue: Swift.String? = nil, - description: Swift.String? = nil, - label: Swift.String? = nil, - name: Swift.String? = nil, - required: Swift.Bool? = nil, - _type: Components.Schemas.ConfigDescriptionParameterDTO._typePayload? = nil, - min: Swift.Double? = nil, - max: Swift.Double? = nil, - stepsize: Swift.Double? = nil, - pattern: Swift.String? = nil, - readOnly: Swift.Bool? = nil, - multiple: Swift.Bool? = nil, - multipleLimit: Swift.Int32? = nil, - groupName: Swift.String? = nil, - advanced: Swift.Bool? = nil, - verify: Swift.Bool? = nil, - limitToOptions: Swift.Bool? = nil, - unit: Swift.String? = nil, - unitLabel: Swift.String? = nil, - options: [Components.Schemas.ParameterOptionDTO]? = nil, - filterCriteria: [Components.Schemas.FilterCriteriaDTO]? = nil) { + public init( + context: Swift.String? = nil, + defaultValue: Swift.String? = nil, + description: Swift.String? = nil, + label: Swift.String? = nil, + name: Swift.String? = nil, + required: Swift.Bool? = nil, + _type: Components.Schemas.ConfigDescriptionParameterDTO._typePayload? = nil, + min: Swift.Double? = nil, + max: Swift.Double? = nil, + stepsize: Swift.Double? = nil, + pattern: Swift.String? = nil, + readOnly: Swift.Bool? = nil, + multiple: Swift.Bool? = nil, + multipleLimit: Swift.Int32? = nil, + groupName: Swift.String? = nil, + advanced: Swift.Bool? = nil, + verify: Swift.Bool? = nil, + limitToOptions: Swift.Bool? = nil, + unit: Swift.String? = nil, + unitLabel: Swift.String? = nil, + options: [Components.Schemas.ParameterOptionDTO]? = nil, + filterCriteria: [Components.Schemas.FilterCriteriaDTO]? = nil + ) { self.context = context self.defaultValue = defaultValue self.description = description @@ -700,7 +989,6 @@ public enum Components { self.options = options self.filterCriteria = filterCriteria } - public enum CodingKeys: String, CodingKey { case context case defaultValue @@ -726,7 +1014,6 @@ public enum Components { case filterCriteria } } - /// - Remark: Generated from `#/components/schemas/FilterCriteriaDTO`. public struct FilterCriteriaDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/FilterCriteriaDTO/value`. @@ -738,18 +1025,18 @@ public enum Components { /// - Parameters: /// - value: /// - name: - public init(value: Swift.String? = nil, - name: Swift.String? = nil) { + public init( + value: Swift.String? = nil, + name: Swift.String? = nil + ) { self.value = value self.name = name } - public enum CodingKeys: String, CodingKey { case value case name } } - /// - Remark: Generated from `#/components/schemas/ParameterOptionDTO`. public struct ParameterOptionDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/ParameterOptionDTO/label`. @@ -761,1371 +1048,2401 @@ public enum Components { /// - Parameters: /// - label: /// - value: - public init(label: Swift.String? = nil, - value: Swift.String? = nil) { + public init( + label: Swift.String? = nil, + value: Swift.String? = nil + ) { self.label = label self.value = value } - public enum CodingKeys: String, CodingKey { case label case value } } - - /// - Remark: Generated from `#/components/schemas/CommandDescription`. - public struct CommandDescription: Codable, Hashable, Sendable { - /// - Remark: Generated from `#/components/schemas/CommandDescription/commandOptions`. - public var commandOptions: [Components.Schemas.CommandOption]? - /// Creates a new `CommandDescription`. - /// - /// - Parameters: - /// - commandOptions: - public init(commandOptions: [Components.Schemas.CommandOption]? = nil) { - self.commandOptions = commandOptions + /// - Remark: Generated from `#/components/schemas/ActionDTO`. + public struct ActionDTO: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/ActionDTO/id`. + public var id: Swift.String? + /// - Remark: Generated from `#/components/schemas/ActionDTO/label`. + public var label: Swift.String? + /// - Remark: Generated from `#/components/schemas/ActionDTO/description`. + public var description: Swift.String? + /// - Remark: Generated from `#/components/schemas/ActionDTO/configuration`. + public struct configurationPayload: Codable, Hashable, Sendable { + /// A container of undocumented properties. + public var additionalProperties: [String: OpenAPIRuntime.OpenAPIObjectContainer] + /// Creates a new `configurationPayload`. + /// + /// - Parameters: + /// - additionalProperties: A container of undocumented properties. + public init(additionalProperties: [String: OpenAPIRuntime.OpenAPIObjectContainer] = .init()) { + self.additionalProperties = additionalProperties + } + public init(from decoder: any Decoder) throws { + additionalProperties = try decoder.decodeAdditionalProperties(knownKeys: []) + } + public func encode(to encoder: any Encoder) throws { + try encoder.encodeAdditionalProperties(additionalProperties) + } } - - public enum CodingKeys: String, CodingKey { - case commandOptions + /// - Remark: Generated from `#/components/schemas/ActionDTO/configuration`. + public var configuration: Components.Schemas.ActionDTO.configurationPayload? + /// - Remark: Generated from `#/components/schemas/ActionDTO/type`. + public var _type: Swift.String? + /// - Remark: Generated from `#/components/schemas/ActionDTO/inputs`. + public struct inputsPayload: Codable, Hashable, Sendable { + /// A container of undocumented properties. + public var additionalProperties: [String: Swift.String] + /// Creates a new `inputsPayload`. + /// + /// - Parameters: + /// - additionalProperties: A container of undocumented properties. + public init(additionalProperties: [String: Swift.String] = .init()) { + self.additionalProperties = additionalProperties + } + public init(from decoder: any Decoder) throws { + additionalProperties = try decoder.decodeAdditionalProperties(knownKeys: []) + } + public func encode(to encoder: any Encoder) throws { + try encoder.encodeAdditionalProperties(additionalProperties) + } } - } - - /// - Remark: Generated from `#/components/schemas/CommandOption`. - public struct CommandOption: Codable, Hashable, Sendable { - /// - Remark: Generated from `#/components/schemas/CommandOption/command`. - public var command: Swift.String? - /// - Remark: Generated from `#/components/schemas/CommandOption/label`. - public var label: Swift.String? - /// Creates a new `CommandOption`. + /// - Remark: Generated from `#/components/schemas/ActionDTO/inputs`. + public var inputs: Components.Schemas.ActionDTO.inputsPayload? + /// Creates a new `ActionDTO`. /// /// - Parameters: - /// - command: + /// - id: /// - label: - public init(command: Swift.String? = nil, - label: Swift.String? = nil) { - self.command = command + /// - description: + /// - configuration: + /// - _type: + /// - inputs: + public init( + id: Swift.String? = nil, + label: Swift.String? = nil, + description: Swift.String? = nil, + configuration: Components.Schemas.ActionDTO.configurationPayload? = nil, + _type: Swift.String? = nil, + inputs: Components.Schemas.ActionDTO.inputsPayload? = nil + ) { + self.id = id self.label = label + self.description = description + self.configuration = configuration + self._type = _type + self.inputs = inputs } - public enum CodingKeys: String, CodingKey { - case command + case id case label + case description + case configuration + case _type = "type" + case inputs } } - - /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterGroupDTO`. - public struct ConfigDescriptionParameterGroupDTO: Codable, Hashable, Sendable { - /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterGroupDTO/name`. - public var name: Swift.String? - /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterGroupDTO/context`. - public var context: Swift.String? - /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterGroupDTO/advanced`. - public var advanced: Swift.Bool? - /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterGroupDTO/label`. + /// - Remark: Generated from `#/components/schemas/ConditionDTO`. + public struct ConditionDTO: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/ConditionDTO/id`. + public var id: Swift.String? + /// - Remark: Generated from `#/components/schemas/ConditionDTO/label`. public var label: Swift.String? - /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterGroupDTO/description`. + /// - Remark: Generated from `#/components/schemas/ConditionDTO/description`. public var description: Swift.String? - /// Creates a new `ConfigDescriptionParameterGroupDTO`. + /// - Remark: Generated from `#/components/schemas/ConditionDTO/configuration`. + public struct configurationPayload: Codable, Hashable, Sendable { + /// A container of undocumented properties. + public var additionalProperties: [String: OpenAPIRuntime.OpenAPIObjectContainer] + /// Creates a new `configurationPayload`. + /// + /// - Parameters: + /// - additionalProperties: A container of undocumented properties. + public init(additionalProperties: [String: OpenAPIRuntime.OpenAPIObjectContainer] = .init()) { + self.additionalProperties = additionalProperties + } + public init(from decoder: any Decoder) throws { + additionalProperties = try decoder.decodeAdditionalProperties(knownKeys: []) + } + public func encode(to encoder: any Encoder) throws { + try encoder.encodeAdditionalProperties(additionalProperties) + } + } + /// - Remark: Generated from `#/components/schemas/ConditionDTO/configuration`. + public var configuration: Components.Schemas.ConditionDTO.configurationPayload? + /// - Remark: Generated from `#/components/schemas/ConditionDTO/type`. + public var _type: Swift.String? + /// - Remark: Generated from `#/components/schemas/ConditionDTO/inputs`. + public struct inputsPayload: Codable, Hashable, Sendable { + /// A container of undocumented properties. + public var additionalProperties: [String: Swift.String] + /// Creates a new `inputsPayload`. + /// + /// - Parameters: + /// - additionalProperties: A container of undocumented properties. + public init(additionalProperties: [String: Swift.String] = .init()) { + self.additionalProperties = additionalProperties + } + public init(from decoder: any Decoder) throws { + additionalProperties = try decoder.decodeAdditionalProperties(knownKeys: []) + } + public func encode(to encoder: any Encoder) throws { + try encoder.encodeAdditionalProperties(additionalProperties) + } + } + /// - Remark: Generated from `#/components/schemas/ConditionDTO/inputs`. + public var inputs: Components.Schemas.ConditionDTO.inputsPayload? + /// Creates a new `ConditionDTO`. /// /// - Parameters: - /// - name: - /// - context: - /// - advanced: + /// - id: /// - label: /// - description: - public init(name: Swift.String? = nil, - context: Swift.String? = nil, - advanced: Swift.Bool? = nil, - label: Swift.String? = nil, - description: Swift.String? = nil) { - self.name = name - self.context = context - self.advanced = advanced + /// - configuration: + /// - _type: + /// - inputs: + public init( + id: Swift.String? = nil, + label: Swift.String? = nil, + description: Swift.String? = nil, + configuration: Components.Schemas.ConditionDTO.configurationPayload? = nil, + _type: Swift.String? = nil, + inputs: Components.Schemas.ConditionDTO.inputsPayload? = nil + ) { + self.id = id self.label = label self.description = description + self.configuration = configuration + self._type = _type + self.inputs = inputs } - public enum CodingKeys: String, CodingKey { - case name - case context - case advanced + case id case label case description + case configuration + case _type = "type" + case inputs } } - - /// - Remark: Generated from `#/components/schemas/StateDescription`. - public struct StateDescription: Codable, Hashable, Sendable { - /// - Remark: Generated from `#/components/schemas/StateDescription/minimum`. - public var minimum: Swift.Double? - /// - Remark: Generated from `#/components/schemas/StateDescription/maximum`. - public var maximum: Swift.Double? - /// - Remark: Generated from `#/components/schemas/StateDescription/step`. - public var step: Swift.Double? - /// - Remark: Generated from `#/components/schemas/StateDescription/pattern`. - public var pattern: Swift.String? - /// - Remark: Generated from `#/components/schemas/StateDescription/readOnly`. - public var readOnly: Swift.Bool? - /// - Remark: Generated from `#/components/schemas/StateDescription/options`. - public var options: [Components.Schemas.StateOption]? - /// Creates a new `StateDescription`. - /// - /// - Parameters: - /// - minimum: - /// - maximum: - /// - step: - /// - pattern: - /// - readOnly: - /// - options: - public init(minimum: Swift.Double? = nil, - maximum: Swift.Double? = nil, - step: Swift.Double? = nil, - pattern: Swift.String? = nil, - readOnly: Swift.Bool? = nil, - options: [Components.Schemas.StateOption]? = nil) { - self.minimum = minimum - self.maximum = maximum - self.step = step - self.pattern = pattern - self.readOnly = readOnly - self.options = options - } - - public enum CodingKeys: String, CodingKey { - case minimum - case maximum - case step - case pattern - case readOnly - case options - } - } - - /// - Remark: Generated from `#/components/schemas/StateOption`. - public struct StateOption: Codable, Hashable, Sendable { - /// - Remark: Generated from `#/components/schemas/StateOption/value`. - public var value: Swift.String? - /// - Remark: Generated from `#/components/schemas/StateOption/label`. - public var label: Swift.String? - /// Creates a new `StateOption`. - /// - /// - Parameters: - /// - value: - /// - label: - public init(value: Swift.String? = nil, - label: Swift.String? = nil) { - self.value = value - self.label = label - } - - public enum CodingKeys: String, CodingKey { - case value - case label + /// - Remark: Generated from `#/components/schemas/RuleDTO`. + public struct RuleDTO: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/RuleDTO/triggers`. + public var triggers: [Components.Schemas.TriggerDTO]? + /// - Remark: Generated from `#/components/schemas/RuleDTO/conditions`. + public var conditions: [Components.Schemas.ConditionDTO]? + /// - Remark: Generated from `#/components/schemas/RuleDTO/actions`. + public var actions: [Components.Schemas.ActionDTO]? + /// - Remark: Generated from `#/components/schemas/RuleDTO/configuration`. + public struct configurationPayload: Codable, Hashable, Sendable { + /// A container of undocumented properties. + public var additionalProperties: [String: OpenAPIRuntime.OpenAPIObjectContainer] + /// Creates a new `configurationPayload`. + /// + /// - Parameters: + /// - additionalProperties: A container of undocumented properties. + public init(additionalProperties: [String: OpenAPIRuntime.OpenAPIObjectContainer] = .init()) { + self.additionalProperties = additionalProperties + } + public init(from decoder: any Decoder) throws { + additionalProperties = try decoder.decodeAdditionalProperties(knownKeys: []) + } + public func encode(to encoder: any Encoder) throws { + try encoder.encodeAdditionalProperties(additionalProperties) + } } - } - - /// - Remark: Generated from `#/components/schemas/ConfigDescriptionDTO`. - public struct ConfigDescriptionDTO: Codable, Hashable, Sendable { - /// - Remark: Generated from `#/components/schemas/ConfigDescriptionDTO/uri`. - public var uri: Swift.String? - /// - Remark: Generated from `#/components/schemas/ConfigDescriptionDTO/parameters`. - public var parameters: [Components.Schemas.ConfigDescriptionParameterDTO]? - /// - Remark: Generated from `#/components/schemas/ConfigDescriptionDTO/parameterGroups`. - public var parameterGroups: [Components.Schemas.ConfigDescriptionParameterGroupDTO]? - /// Creates a new `ConfigDescriptionDTO`. + /// - Remark: Generated from `#/components/schemas/RuleDTO/configuration`. + public var configuration: Components.Schemas.RuleDTO.configurationPayload? + /// - Remark: Generated from `#/components/schemas/RuleDTO/configDescriptions`. + public var configDescriptions: [Components.Schemas.ConfigDescriptionParameterDTO]? + /// - Remark: Generated from `#/components/schemas/RuleDTO/templateUID`. + public var templateUID: Swift.String? + /// - Remark: Generated from `#/components/schemas/RuleDTO/uid`. + public var uid: Swift.String? + /// - Remark: Generated from `#/components/schemas/RuleDTO/name`. + public var name: Swift.String? + /// - Remark: Generated from `#/components/schemas/RuleDTO/tags`. + public var tags: [Swift.String]? + /// - Remark: Generated from `#/components/schemas/RuleDTO/visibility`. + @frozen public enum visibilityPayload: String, Codable, Hashable, Sendable, CaseIterable { + case VISIBLE = "VISIBLE" + case HIDDEN = "HIDDEN" + case EXPERT = "EXPERT" + } + /// - Remark: Generated from `#/components/schemas/RuleDTO/visibility`. + public var visibility: Components.Schemas.RuleDTO.visibilityPayload? + /// - Remark: Generated from `#/components/schemas/RuleDTO/description`. + public var description: Swift.String? + /// Creates a new `RuleDTO`. /// /// - Parameters: - /// - uri: - /// - parameters: - /// - parameterGroups: - public init(uri: Swift.String? = nil, - parameters: [Components.Schemas.ConfigDescriptionParameterDTO]? = nil, - parameterGroups: [Components.Schemas.ConfigDescriptionParameterGroupDTO]? = nil) { - self.uri = uri - self.parameters = parameters - self.parameterGroups = parameterGroups + /// - triggers: + /// - conditions: + /// - actions: + /// - configuration: + /// - configDescriptions: + /// - templateUID: + /// - uid: + /// - name: + /// - tags: + /// - visibility: + /// - description: + public init( + triggers: [Components.Schemas.TriggerDTO]? = nil, + conditions: [Components.Schemas.ConditionDTO]? = nil, + actions: [Components.Schemas.ActionDTO]? = nil, + configuration: Components.Schemas.RuleDTO.configurationPayload? = nil, + configDescriptions: [Components.Schemas.ConfigDescriptionParameterDTO]? = nil, + templateUID: Swift.String? = nil, + uid: Swift.String? = nil, + name: Swift.String? = nil, + tags: [Swift.String]? = nil, + visibility: Components.Schemas.RuleDTO.visibilityPayload? = nil, + description: Swift.String? = nil + ) { + self.triggers = triggers + self.conditions = conditions + self.actions = actions + self.configuration = configuration + self.configDescriptions = configDescriptions + self.templateUID = templateUID + self.uid = uid + self.name = name + self.tags = tags + self.visibility = visibility + self.description = description } - public enum CodingKeys: String, CodingKey { - case uri - case parameters - case parameterGroups + case triggers + case conditions + case actions + case configuration + case configDescriptions + case templateUID + case uid + case name + case tags + case visibility + case description } } - - /// - Remark: Generated from `#/components/schemas/MetadataDTO`. - public struct MetadataDTO: Codable, Hashable, Sendable { - /// - Remark: Generated from `#/components/schemas/MetadataDTO/value`. - public var value: Swift.String? - /// - Remark: Generated from `#/components/schemas/MetadataDTO/config`. - public struct configPayload: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/TriggerDTO`. + public struct TriggerDTO: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/TriggerDTO/id`. + public var id: Swift.String? + /// - Remark: Generated from `#/components/schemas/TriggerDTO/label`. + public var label: Swift.String? + /// - Remark: Generated from `#/components/schemas/TriggerDTO/description`. + public var description: Swift.String? + /// - Remark: Generated from `#/components/schemas/TriggerDTO/configuration`. + public struct configurationPayload: Codable, Hashable, Sendable { /// A container of undocumented properties. public var additionalProperties: [String: OpenAPIRuntime.OpenAPIObjectContainer] - /// Creates a new `configPayload`. + /// Creates a new `configurationPayload`. /// /// - Parameters: /// - additionalProperties: A container of undocumented properties. public init(additionalProperties: [String: OpenAPIRuntime.OpenAPIObjectContainer] = .init()) { self.additionalProperties = additionalProperties } - public init(from decoder: any Decoder) throws { additionalProperties = try decoder.decodeAdditionalProperties(knownKeys: []) } - public func encode(to encoder: any Encoder) throws { try encoder.encodeAdditionalProperties(additionalProperties) } } - - /// - Remark: Generated from `#/components/schemas/MetadataDTO/config`. - public var config: Components.Schemas.MetadataDTO.configPayload? - /// Creates a new `MetadataDTO`. + /// - Remark: Generated from `#/components/schemas/TriggerDTO/configuration`. + public var configuration: Components.Schemas.TriggerDTO.configurationPayload? + /// - Remark: Generated from `#/components/schemas/TriggerDTO/type`. + public var _type: Swift.String? + /// Creates a new `TriggerDTO`. /// /// - Parameters: - /// - value: - /// - config: - public init(value: Swift.String? = nil, - config: Components.Schemas.MetadataDTO.configPayload? = nil) { - self.value = value - self.config = config + /// - id: + /// - label: + /// - description: + /// - configuration: + /// - _type: + public init( + id: Swift.String? = nil, + label: Swift.String? = nil, + description: Swift.String? = nil, + configuration: Components.Schemas.TriggerDTO.configurationPayload? = nil, + _type: Swift.String? = nil + ) { + self.id = id + self.label = label + self.description = description + self.configuration = configuration + self._type = _type } - public enum CodingKeys: String, CodingKey { - case value - case config + case id + case label + case description + case configuration + case _type = "type" } } - - /// - Remark: Generated from `#/components/schemas/EnrichedItemDTO`. - public struct EnrichedItemDTO: Codable, Hashable, Sendable { - /// - Remark: Generated from `#/components/schemas/EnrichedItemDTO/type`. - public var _type: Swift.String? - /// - Remark: Generated from `#/components/schemas/EnrichedItemDTO/name`. - public var name: Swift.String? - /// - Remark: Generated from `#/components/schemas/EnrichedItemDTO/label`. - public var label: Swift.String? - /// - Remark: Generated from `#/components/schemas/EnrichedItemDTO/category`. - public var category: Swift.String? - /// - Remark: Generated from `#/components/schemas/EnrichedItemDTO/tags`. - public var tags: [Swift.String]? - /// - Remark: Generated from `#/components/schemas/EnrichedItemDTO/groupNames`. - public var groupNames: [Swift.String]? - /// - Remark: Generated from `#/components/schemas/EnrichedItemDTO/link`. - public var link: Swift.String? - /// - Remark: Generated from `#/components/schemas/EnrichedItemDTO/state`. - public var state: Swift.String? - /// - Remark: Generated from `#/components/schemas/EnrichedItemDTO/transformedState`. - public var transformedState: Swift.String? - /// - Remark: Generated from `#/components/schemas/EnrichedItemDTO/stateDescription`. - public var stateDescription: Components.Schemas.StateDescription? - /// - Remark: Generated from `#/components/schemas/EnrichedItemDTO/unitSymbol`. - public var unitSymbol: Swift.String? - /// - Remark: Generated from `#/components/schemas/EnrichedItemDTO/commandDescription`. - public var commandDescription: Components.Schemas.CommandDescription? - /// - Remark: Generated from `#/components/schemas/EnrichedItemDTO/metadata`. - public struct metadataPayload: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/EnrichedRuleDTO`. + public struct EnrichedRuleDTO: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/EnrichedRuleDTO/triggers`. + public var triggers: [Components.Schemas.TriggerDTO]? + /// - Remark: Generated from `#/components/schemas/EnrichedRuleDTO/conditions`. + public var conditions: [Components.Schemas.ConditionDTO]? + /// - Remark: Generated from `#/components/schemas/EnrichedRuleDTO/actions`. + public var actions: [Components.Schemas.ActionDTO]? + /// - Remark: Generated from `#/components/schemas/EnrichedRuleDTO/configuration`. + public struct configurationPayload: Codable, Hashable, Sendable { /// A container of undocumented properties. public var additionalProperties: [String: OpenAPIRuntime.OpenAPIObjectContainer] - /// Creates a new `metadataPayload`. + /// Creates a new `configurationPayload`. /// /// - Parameters: /// - additionalProperties: A container of undocumented properties. public init(additionalProperties: [String: OpenAPIRuntime.OpenAPIObjectContainer] = .init()) { self.additionalProperties = additionalProperties } - public init(from decoder: any Decoder) throws { additionalProperties = try decoder.decodeAdditionalProperties(knownKeys: []) } - public func encode(to encoder: any Encoder) throws { try encoder.encodeAdditionalProperties(additionalProperties) } } - - /// - Remark: Generated from `#/components/schemas/EnrichedItemDTO/metadata`. - public var metadata: Components.Schemas.EnrichedItemDTO.metadataPayload? - /// - Remark: Generated from `#/components/schemas/EnrichedItemDTO/editable`. + /// - Remark: Generated from `#/components/schemas/EnrichedRuleDTO/configuration`. + public var configuration: Components.Schemas.EnrichedRuleDTO.configurationPayload? + /// - Remark: Generated from `#/components/schemas/EnrichedRuleDTO/configDescriptions`. + public var configDescriptions: [Components.Schemas.ConfigDescriptionParameterDTO]? + /// - Remark: Generated from `#/components/schemas/EnrichedRuleDTO/templateUID`. + public var templateUID: Swift.String? + /// - Remark: Generated from `#/components/schemas/EnrichedRuleDTO/uid`. + public var uid: Swift.String? + /// - Remark: Generated from `#/components/schemas/EnrichedRuleDTO/name`. + public var name: Swift.String? + /// - Remark: Generated from `#/components/schemas/EnrichedRuleDTO/tags`. + public var tags: [Swift.String]? + /// - Remark: Generated from `#/components/schemas/EnrichedRuleDTO/visibility`. + @frozen public enum visibilityPayload: String, Codable, Hashable, Sendable, CaseIterable { + case VISIBLE = "VISIBLE" + case HIDDEN = "HIDDEN" + case EXPERT = "EXPERT" + } + /// - Remark: Generated from `#/components/schemas/EnrichedRuleDTO/visibility`. + public var visibility: Components.Schemas.EnrichedRuleDTO.visibilityPayload? + /// - Remark: Generated from `#/components/schemas/EnrichedRuleDTO/description`. + public var description: Swift.String? + /// - Remark: Generated from `#/components/schemas/EnrichedRuleDTO/status`. + public var status: Components.Schemas.RuleStatusInfo? + /// - Remark: Generated from `#/components/schemas/EnrichedRuleDTO/editable`. public var editable: Swift.Bool? - /// Creates a new `EnrichedItemDTO`. + /// Creates a new `EnrichedRuleDTO`. /// /// - Parameters: - /// - _type: + /// - triggers: + /// - conditions: + /// - actions: + /// - configuration: + /// - configDescriptions: + /// - templateUID: + /// - uid: /// - name: - /// - label: - /// - category: /// - tags: - /// - groupNames: - /// - link: - /// - state: - /// - transformedState: - /// - stateDescription: - /// - unitSymbol: - /// - commandDescription: - /// - metadata: + /// - visibility: + /// - description: + /// - status: /// - editable: - public init(_type: Swift.String? = nil, - name: Swift.String? = nil, - label: Swift.String? = nil, - category: Swift.String? = nil, - tags: [Swift.String]? = nil, - groupNames: [Swift.String]? = nil, - link: Swift.String? = nil, - state: Swift.String? = nil, - transformedState: Swift.String? = nil, - stateDescription: Components.Schemas.StateDescription? = nil, - unitSymbol: Swift.String? = nil, - commandDescription: Components.Schemas.CommandDescription? = nil, - metadata: Components.Schemas.EnrichedItemDTO.metadataPayload? = nil, - editable: Swift.Bool? = nil) { - self._type = _type + public init( + triggers: [Components.Schemas.TriggerDTO]? = nil, + conditions: [Components.Schemas.ConditionDTO]? = nil, + actions: [Components.Schemas.ActionDTO]? = nil, + configuration: Components.Schemas.EnrichedRuleDTO.configurationPayload? = nil, + configDescriptions: [Components.Schemas.ConfigDescriptionParameterDTO]? = nil, + templateUID: Swift.String? = nil, + uid: Swift.String? = nil, + name: Swift.String? = nil, + tags: [Swift.String]? = nil, + visibility: Components.Schemas.EnrichedRuleDTO.visibilityPayload? = nil, + description: Swift.String? = nil, + status: Components.Schemas.RuleStatusInfo? = nil, + editable: Swift.Bool? = nil + ) { + self.triggers = triggers + self.conditions = conditions + self.actions = actions + self.configuration = configuration + self.configDescriptions = configDescriptions + self.templateUID = templateUID + self.uid = uid self.name = name - self.label = label - self.category = category self.tags = tags - self.groupNames = groupNames - self.link = link - self.state = state - self.transformedState = transformedState - self.stateDescription = stateDescription - self.unitSymbol = unitSymbol - self.commandDescription = commandDescription - self.metadata = metadata + self.visibility = visibility + self.description = description + self.status = status self.editable = editable } - public enum CodingKeys: String, CodingKey { - case _type = "type" + case triggers + case conditions + case actions + case configuration + case configDescriptions + case templateUID + case uid case name - case label - case category case tags - case groupNames - case link - case state - case transformedState - case stateDescription - case unitSymbol - case commandDescription - case metadata + case visibility + case description + case status case editable } } - - /// - Remark: Generated from `#/components/schemas/GroupFunctionDTO`. - public struct GroupFunctionDTO: Codable, Hashable, Sendable { - /// - Remark: Generated from `#/components/schemas/GroupFunctionDTO/name`. - public var name: Swift.String? - /// - Remark: Generated from `#/components/schemas/GroupFunctionDTO/params`. - public var params: [Swift.String]? - /// Creates a new `GroupFunctionDTO`. + /// - Remark: Generated from `#/components/schemas/RuleStatusInfo`. + public struct RuleStatusInfo: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/RuleStatusInfo/status`. + @frozen public enum statusPayload: String, Codable, Hashable, Sendable, CaseIterable { + case UNINITIALIZED = "UNINITIALIZED" + case INITIALIZING = "INITIALIZING" + case IDLE = "IDLE" + case RUNNING = "RUNNING" + } + /// - Remark: Generated from `#/components/schemas/RuleStatusInfo/status`. + public var status: Components.Schemas.RuleStatusInfo.statusPayload? + /// - Remark: Generated from `#/components/schemas/RuleStatusInfo/statusDetail`. + @frozen public enum statusDetailPayload: String, Codable, Hashable, Sendable, CaseIterable { + case NONE = "NONE" + case HANDLER_MISSING_ERROR = "HANDLER_MISSING_ERROR" + case HANDLER_INITIALIZING_ERROR = "HANDLER_INITIALIZING_ERROR" + case CONFIGURATION_ERROR = "CONFIGURATION_ERROR" + case TEMPLATE_MISSING_ERROR = "TEMPLATE_MISSING_ERROR" + case INVALID_RULE = "INVALID_RULE" + case DISABLED = "DISABLED" + } + /// - Remark: Generated from `#/components/schemas/RuleStatusInfo/statusDetail`. + public var statusDetail: Components.Schemas.RuleStatusInfo.statusDetailPayload? + /// - Remark: Generated from `#/components/schemas/RuleStatusInfo/description`. + public var description: Swift.String? + /// Creates a new `RuleStatusInfo`. /// /// - Parameters: - /// - name: - /// - params: - public init(name: Swift.String? = nil, - params: [Swift.String]? = nil) { - self.name = name - self.params = params - } - - public enum CodingKeys: String, CodingKey { - case name - case params + /// - status: + /// - statusDetail: + /// - description: + public init( + status: Components.Schemas.RuleStatusInfo.statusPayload? = nil, + statusDetail: Components.Schemas.RuleStatusInfo.statusDetailPayload? = nil, + description: Swift.String? = nil + ) { + self.status = status + self.statusDetail = statusDetail + self.description = description + } + public enum CodingKeys: String, CodingKey { + case status + case statusDetail + case description } } - - /// - Remark: Generated from `#/components/schemas/GroupItemDTO`. - public struct GroupItemDTO: Codable, Hashable, Sendable { - /// - Remark: Generated from `#/components/schemas/GroupItemDTO/type`. - public var _type: Swift.String? - /// - Remark: Generated from `#/components/schemas/GroupItemDTO/name`. - public var name: Swift.String? - /// - Remark: Generated from `#/components/schemas/GroupItemDTO/label`. + /// - Remark: Generated from `#/components/schemas/ModuleDTO`. + public struct ModuleDTO: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/ModuleDTO/id`. + public var id: Swift.String? + /// - Remark: Generated from `#/components/schemas/ModuleDTO/label`. public var label: Swift.String? - /// - Remark: Generated from `#/components/schemas/GroupItemDTO/category`. - public var category: Swift.String? - /// - Remark: Generated from `#/components/schemas/GroupItemDTO/tags`. - public var tags: [Swift.String]? - /// - Remark: Generated from `#/components/schemas/GroupItemDTO/groupNames`. - public var groupNames: [Swift.String]? - /// - Remark: Generated from `#/components/schemas/GroupItemDTO/groupType`. - public var groupType: Swift.String? - /// - Remark: Generated from `#/components/schemas/GroupItemDTO/function`. - public var function: Components.Schemas.GroupFunctionDTO? - /// Creates a new `GroupItemDTO`. + /// - Remark: Generated from `#/components/schemas/ModuleDTO/description`. + public var description: Swift.String? + /// - Remark: Generated from `#/components/schemas/ModuleDTO/configuration`. + public struct configurationPayload: Codable, Hashable, Sendable { + /// A container of undocumented properties. + public var additionalProperties: [String: OpenAPIRuntime.OpenAPIObjectContainer] + /// Creates a new `configurationPayload`. + /// + /// - Parameters: + /// - additionalProperties: A container of undocumented properties. + public init(additionalProperties: [String: OpenAPIRuntime.OpenAPIObjectContainer] = .init()) { + self.additionalProperties = additionalProperties + } + public init(from decoder: any Decoder) throws { + additionalProperties = try decoder.decodeAdditionalProperties(knownKeys: []) + } + public func encode(to encoder: any Encoder) throws { + try encoder.encodeAdditionalProperties(additionalProperties) + } + } + /// - Remark: Generated from `#/components/schemas/ModuleDTO/configuration`. + public var configuration: Components.Schemas.ModuleDTO.configurationPayload? + /// - Remark: Generated from `#/components/schemas/ModuleDTO/type`. + public var _type: Swift.String? + /// Creates a new `ModuleDTO`. /// /// - Parameters: - /// - _type: - /// - name: + /// - id: /// - label: - /// - category: - /// - tags: - /// - groupNames: - /// - groupType: - /// - function: - public init(_type: Swift.String? = nil, - name: Swift.String? = nil, - label: Swift.String? = nil, - category: Swift.String? = nil, - tags: [Swift.String]? = nil, - groupNames: [Swift.String]? = nil, - groupType: Swift.String? = nil, - function: Components.Schemas.GroupFunctionDTO? = nil) { - self._type = _type - self.name = name + /// - description: + /// - configuration: + /// - _type: + public init( + id: Swift.String? = nil, + label: Swift.String? = nil, + description: Swift.String? = nil, + configuration: Components.Schemas.ModuleDTO.configurationPayload? = nil, + _type: Swift.String? = nil + ) { + self.id = id self.label = label - self.category = category - self.tags = tags - self.groupNames = groupNames - self.groupType = groupType - self.function = function + self.description = description + self.configuration = configuration + self._type = _type } - public enum CodingKeys: String, CodingKey { - case _type = "type" - case name + case id case label - case category - case tags - case groupNames - case groupType - case function + case description + case configuration + case _type = "type" } } - - /// - Remark: Generated from `#/components/schemas/JerseyResponseBuilderDTO`. - public struct JerseyResponseBuilderDTO: Codable, Hashable, Sendable { - /// - Remark: Generated from `#/components/schemas/JerseyResponseBuilderDTO/status`. - public var status: Swift.String? - /// - Remark: Generated from `#/components/schemas/JerseyResponseBuilderDTO/context`. - public var context: Components.Schemas.ContextDTO? - /// Creates a new `JerseyResponseBuilderDTO`. - /// - /// - Parameters: - /// - status: - /// - context: - public init(status: Swift.String? = nil, - context: Components.Schemas.ContextDTO? = nil) { - self.status = status - self.context = context - } - - public enum CodingKeys: String, CodingKey { - case status - case context + /// - Remark: Generated from `#/components/schemas/Action`. + public struct Action: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/Action/inputs`. + public struct inputsPayload: Codable, Hashable, Sendable { + /// A container of undocumented properties. + public var additionalProperties: [String: Swift.String] + /// Creates a new `inputsPayload`. + /// + /// - Parameters: + /// - additionalProperties: A container of undocumented properties. + public init(additionalProperties: [String: Swift.String] = .init()) { + self.additionalProperties = additionalProperties + } + public init(from decoder: any Decoder) throws { + additionalProperties = try decoder.decodeAdditionalProperties(knownKeys: []) + } + public func encode(to encoder: any Encoder) throws { + try encoder.encodeAdditionalProperties(additionalProperties) + } } - } - - /// - Remark: Generated from `#/components/schemas/ContextDTO`. - public struct ContextDTO: Codable, Hashable, Sendable { - /// - Remark: Generated from `#/components/schemas/ContextDTO/headers`. - public var headers: Components.Schemas.HeadersDTO? - /// Creates a new `ContextDTO`. + /// - Remark: Generated from `#/components/schemas/Action/inputs`. + public var inputs: Components.Schemas.Action.inputsPayload? + /// - Remark: Generated from `#/components/schemas/Action/description`. + public var description: Swift.String? + /// - Remark: Generated from `#/components/schemas/Action/label`. + public var label: Swift.String? + /// - Remark: Generated from `#/components/schemas/Action/typeUID`. + public var typeUID: Swift.String? + /// - Remark: Generated from `#/components/schemas/Action/configuration`. + public var configuration: Components.Schemas.Configuration? + /// - Remark: Generated from `#/components/schemas/Action/id`. + public var id: Swift.String? + /// Creates a new `Action`. /// /// - Parameters: - /// - headers: - public init(headers: Components.Schemas.HeadersDTO? = nil) { - self.headers = headers + /// - inputs: + /// - description: + /// - label: + /// - typeUID: + /// - configuration: + /// - id: + public init( + inputs: Components.Schemas.Action.inputsPayload? = nil, + description: Swift.String? = nil, + label: Swift.String? = nil, + typeUID: Swift.String? = nil, + configuration: Components.Schemas.Configuration? = nil, + id: Swift.String? = nil + ) { + self.inputs = inputs + self.description = description + self.label = label + self.typeUID = typeUID + self.configuration = configuration + self.id = id } - public enum CodingKeys: String, CodingKey { - case headers + case inputs + case description + case label + case typeUID + case configuration + case id } } - - /// - Remark: Generated from `#/components/schemas/HeadersDTO`. - public struct HeadersDTO: Codable, Hashable, Sendable { - /// - Remark: Generated from `#/components/schemas/HeadersDTO/Location`. - public var Location: [Swift.String]? - /// Creates a new `HeadersDTO`. + /// - Remark: Generated from `#/components/schemas/Condition`. + public struct Condition: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/Condition/inputs`. + public struct inputsPayload: Codable, Hashable, Sendable { + /// A container of undocumented properties. + public var additionalProperties: [String: Swift.String] + /// Creates a new `inputsPayload`. + /// + /// - Parameters: + /// - additionalProperties: A container of undocumented properties. + public init(additionalProperties: [String: Swift.String] = .init()) { + self.additionalProperties = additionalProperties + } + public init(from decoder: any Decoder) throws { + additionalProperties = try decoder.decodeAdditionalProperties(knownKeys: []) + } + public func encode(to encoder: any Encoder) throws { + try encoder.encodeAdditionalProperties(additionalProperties) + } + } + /// - Remark: Generated from `#/components/schemas/Condition/inputs`. + public var inputs: Components.Schemas.Condition.inputsPayload? + /// - Remark: Generated from `#/components/schemas/Condition/description`. + public var description: Swift.String? + /// - Remark: Generated from `#/components/schemas/Condition/label`. + public var label: Swift.String? + /// - Remark: Generated from `#/components/schemas/Condition/typeUID`. + public var typeUID: Swift.String? + /// - Remark: Generated from `#/components/schemas/Condition/configuration`. + public var configuration: Components.Schemas.Configuration? + /// - Remark: Generated from `#/components/schemas/Condition/id`. + public var id: Swift.String? + /// Creates a new `Condition`. /// /// - Parameters: - /// - Location: - public init(Location: [Swift.String]? = nil) { - self.Location = Location + /// - inputs: + /// - description: + /// - label: + /// - typeUID: + /// - configuration: + /// - id: + public init( + inputs: Components.Schemas.Condition.inputsPayload? = nil, + description: Swift.String? = nil, + label: Swift.String? = nil, + typeUID: Swift.String? = nil, + configuration: Components.Schemas.Configuration? = nil, + id: Swift.String? = nil + ) { + self.inputs = inputs + self.description = description + self.label = label + self.typeUID = typeUID + self.configuration = configuration + self.id = id } - public enum CodingKeys: String, CodingKey { - case Location + case inputs + case description + case label + case typeUID + case configuration + case id } } - - /// - Remark: Generated from `#/components/schemas/Links`. - public struct Links: Codable, Hashable, Sendable { - /// - Remark: Generated from `#/components/schemas/Links/type`. - public var _type: Swift.String? - /// - Remark: Generated from `#/components/schemas/Links/url`. - public var url: Swift.String? - /// Creates a new `Links`. + /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameter`. + public struct ConfigDescriptionParameter: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameter/name`. + public var name: Swift.String? + /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameter/type`. + @frozen public enum _typePayload: String, Codable, Hashable, Sendable, CaseIterable { + case TEXT = "TEXT" + case INTEGER = "INTEGER" + case DECIMAL = "DECIMAL" + case BOOLEAN = "BOOLEAN" + } + /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameter/type`. + public var _type: Components.Schemas.ConfigDescriptionParameter._typePayload? + /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameter/groupName`. + public var groupName: Swift.String? + /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameter/pattern`. + public var pattern: Swift.String? + /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameter/required`. + public var required: Swift.Bool? + /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameter/readOnly`. + public var readOnly: Swift.Bool? + /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameter/multiple`. + public var multiple: Swift.Bool? + /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameter/multipleLimit`. + public var multipleLimit: Swift.Int32? + /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameter/unit`. + public var unit: Swift.String? + /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameter/unitLabel`. + public var unitLabel: Swift.String? + /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameter/context`. + public var context: Swift.String? + /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameter/label`. + public var label: Swift.String? + /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameter/description`. + public var description: Swift.String? + /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameter/options`. + public var options: [Components.Schemas.ParameterOption]? + /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameter/filterCriteria`. + public var filterCriteria: [Components.Schemas.FilterCriteria]? + /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameter/limitToOptions`. + public var limitToOptions: Swift.Bool? + /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameter/advanced`. + public var advanced: Swift.Bool? + /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameter/minimum`. + public var minimum: Swift.Double? + /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameter/maximum`. + public var maximum: Swift.Double? + /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameter/stepSize`. + public var stepSize: Swift.Double? + /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameter/verifyable`. + public var verifyable: Swift.Bool? + /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameter/default`. + public var _default: Swift.String? + /// Creates a new `ConfigDescriptionParameter`. /// /// - Parameters: + /// - name: /// - _type: - /// - url: - public init(_type: Swift.String? = nil, - url: Swift.String? = nil) { + /// - groupName: + /// - pattern: + /// - required: + /// - readOnly: + /// - multiple: + /// - multipleLimit: + /// - unit: + /// - unitLabel: + /// - context: + /// - label: + /// - description: + /// - options: + /// - filterCriteria: + /// - limitToOptions: + /// - advanced: + /// - minimum: + /// - maximum: + /// - stepSize: + /// - verifyable: + /// - _default: + public init( + name: Swift.String? = nil, + _type: Components.Schemas.ConfigDescriptionParameter._typePayload? = nil, + groupName: Swift.String? = nil, + pattern: Swift.String? = nil, + required: Swift.Bool? = nil, + readOnly: Swift.Bool? = nil, + multiple: Swift.Bool? = nil, + multipleLimit: Swift.Int32? = nil, + unit: Swift.String? = nil, + unitLabel: Swift.String? = nil, + context: Swift.String? = nil, + label: Swift.String? = nil, + description: Swift.String? = nil, + options: [Components.Schemas.ParameterOption]? = nil, + filterCriteria: [Components.Schemas.FilterCriteria]? = nil, + limitToOptions: Swift.Bool? = nil, + advanced: Swift.Bool? = nil, + minimum: Swift.Double? = nil, + maximum: Swift.Double? = nil, + stepSize: Swift.Double? = nil, + verifyable: Swift.Bool? = nil, + _default: Swift.String? = nil + ) { + self.name = name self._type = _type - self.url = url + self.groupName = groupName + self.pattern = pattern + self.required = required + self.readOnly = readOnly + self.multiple = multiple + self.multipleLimit = multipleLimit + self.unit = unit + self.unitLabel = unitLabel + self.context = context + self.label = label + self.description = description + self.options = options + self.filterCriteria = filterCriteria + self.limitToOptions = limitToOptions + self.advanced = advanced + self.minimum = minimum + self.maximum = maximum + self.stepSize = stepSize + self.verifyable = verifyable + self._default = _default } - public enum CodingKeys: String, CodingKey { + case name case _type = "type" - case url + case groupName + case pattern + case required + case readOnly + case multiple + case multipleLimit + case unit + case unitLabel + case context + case label + case description + case options + case filterCriteria + case limitToOptions + case advanced + case minimum + case maximum + case stepSize + case verifyable + case _default = "default" } } - - /// - Remark: Generated from `#/components/schemas/RootBean`. - public struct RootBean: Codable, Hashable, Sendable { - /// - Remark: Generated from `#/components/schemas/RootBean/version`. - public var version: Swift.String? - /// - Remark: Generated from `#/components/schemas/RootBean/locale`. - public var locale: Swift.String? - /// - Remark: Generated from `#/components/schemas/RootBean/measurementSystem`. - public var measurementSystem: Swift.String? - /// - Remark: Generated from `#/components/schemas/RootBean/runtimeInfo`. - public var runtimeInfo: Components.Schemas.RuntimeInfo? - /// - Remark: Generated from `#/components/schemas/RootBean/links`. - public var links: [Components.Schemas.Links]? - /// Creates a new `RootBean`. - /// - /// - Parameters: - /// - version: - /// - locale: - /// - measurementSystem: - /// - runtimeInfo: - /// - links: - public init(version: Swift.String? = nil, - locale: Swift.String? = nil, - measurementSystem: Swift.String? = nil, - runtimeInfo: Components.Schemas.RuntimeInfo? = nil, - links: [Components.Schemas.Links]? = nil) { - self.version = version - self.locale = locale - self.measurementSystem = measurementSystem - self.runtimeInfo = runtimeInfo - self.links = links - } - - public enum CodingKeys: String, CodingKey { - case version - case locale - case measurementSystem - case runtimeInfo - case links + /// - Remark: Generated from `#/components/schemas/Configuration`. + public struct Configuration: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/Configuration/properties`. + public struct propertiesPayload: Codable, Hashable, Sendable { + /// A container of undocumented properties. + public var additionalProperties: [String: OpenAPIRuntime.OpenAPIObjectContainer] + /// Creates a new `propertiesPayload`. + /// + /// - Parameters: + /// - additionalProperties: A container of undocumented properties. + public init(additionalProperties: [String: OpenAPIRuntime.OpenAPIObjectContainer] = .init()) { + self.additionalProperties = additionalProperties + } + public init(from decoder: any Decoder) throws { + additionalProperties = try decoder.decodeAdditionalProperties(knownKeys: []) + } + public func encode(to encoder: any Encoder) throws { + try encoder.encodeAdditionalProperties(additionalProperties) + } } - } - - /// - Remark: Generated from `#/components/schemas/RuntimeInfo`. - public struct RuntimeInfo: Codable, Hashable, Sendable { - /// - Remark: Generated from `#/components/schemas/RuntimeInfo/version`. - public var version: Swift.String? - /// - Remark: Generated from `#/components/schemas/RuntimeInfo/buildString`. - public var buildString: Swift.String? - /// Creates a new `RuntimeInfo`. + /// - Remark: Generated from `#/components/schemas/Configuration/properties`. + public var properties: Components.Schemas.Configuration.propertiesPayload? + /// Creates a new `Configuration`. /// /// - Parameters: - /// - version: - /// - buildString: - public init(version: Swift.String? = nil, - buildString: Swift.String? = nil) { - self.version = version - self.buildString = buildString + /// - properties: + public init(properties: Components.Schemas.Configuration.propertiesPayload? = nil) { + self.properties = properties } - public enum CodingKeys: String, CodingKey { - case version - case buildString + case properties } } - - /// - Remark: Generated from `#/components/schemas/SystemInfo`. - public struct SystemInfo: Codable, Hashable, Sendable { - /// - Remark: Generated from `#/components/schemas/SystemInfo/configFolder`. - public var configFolder: Swift.String? - /// - Remark: Generated from `#/components/schemas/SystemInfo/userdataFolder`. - public var userdataFolder: Swift.String? - /// - Remark: Generated from `#/components/schemas/SystemInfo/logFolder`. - public var logFolder: Swift.String? - /// - Remark: Generated from `#/components/schemas/SystemInfo/javaVersion`. - public var javaVersion: Swift.String? - /// - Remark: Generated from `#/components/schemas/SystemInfo/javaVendor`. - public var javaVendor: Swift.String? - /// - Remark: Generated from `#/components/schemas/SystemInfo/javaVendorVersion`. - public var javaVendorVersion: Swift.String? - /// - Remark: Generated from `#/components/schemas/SystemInfo/osName`. - public var osName: Swift.String? - /// - Remark: Generated from `#/components/schemas/SystemInfo/osVersion`. - public var osVersion: Swift.String? - /// - Remark: Generated from `#/components/schemas/SystemInfo/osArchitecture`. - public var osArchitecture: Swift.String? - /// - Remark: Generated from `#/components/schemas/SystemInfo/availableProcessors`. - public var availableProcessors: Swift.Int32? - /// - Remark: Generated from `#/components/schemas/SystemInfo/freeMemory`. - public var freeMemory: Swift.Int64? - /// - Remark: Generated from `#/components/schemas/SystemInfo/totalMemory`. - public var totalMemory: Swift.Int64? - /// - Remark: Generated from `#/components/schemas/SystemInfo/uptime`. - public var uptime: Swift.Int64? - /// - Remark: Generated from `#/components/schemas/SystemInfo/startLevel`. - public var startLevel: Swift.Int32? - /// Creates a new `SystemInfo`. + /// - Remark: Generated from `#/components/schemas/FilterCriteria`. + public struct FilterCriteria: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/FilterCriteria/value`. + public var value: Swift.String? + /// - Remark: Generated from `#/components/schemas/FilterCriteria/name`. + public var name: Swift.String? + /// Creates a new `FilterCriteria`. /// /// - Parameters: - /// - configFolder: - /// - userdataFolder: - /// - logFolder: - /// - javaVersion: - /// - javaVendor: - /// - javaVendorVersion: - /// - osName: - /// - osVersion: - /// - osArchitecture: - /// - availableProcessors: - /// - freeMemory: - /// - totalMemory: - /// - uptime: - /// - startLevel: - public init(configFolder: Swift.String? = nil, - userdataFolder: Swift.String? = nil, - logFolder: Swift.String? = nil, - javaVersion: Swift.String? = nil, - javaVendor: Swift.String? = nil, - javaVendorVersion: Swift.String? = nil, - osName: Swift.String? = nil, - osVersion: Swift.String? = nil, - osArchitecture: Swift.String? = nil, - availableProcessors: Swift.Int32? = nil, - freeMemory: Swift.Int64? = nil, - totalMemory: Swift.Int64? = nil, - uptime: Swift.Int64? = nil, - startLevel: Swift.Int32? = nil) { - self.configFolder = configFolder - self.userdataFolder = userdataFolder - self.logFolder = logFolder - self.javaVersion = javaVersion - self.javaVendor = javaVendor - self.javaVendorVersion = javaVendorVersion - self.osName = osName - self.osVersion = osVersion - self.osArchitecture = osArchitecture - self.availableProcessors = availableProcessors - self.freeMemory = freeMemory - self.totalMemory = totalMemory - self.uptime = uptime - self.startLevel = startLevel + /// - value: + /// - name: + public init( + value: Swift.String? = nil, + name: Swift.String? = nil + ) { + self.value = value + self.name = name } - public enum CodingKeys: String, CodingKey { - case configFolder - case userdataFolder - case logFolder - case javaVersion - case javaVendor - case javaVendorVersion - case osName - case osVersion - case osArchitecture - case availableProcessors - case freeMemory - case totalMemory - case uptime - case startLevel + case value + case name } } - - /// - Remark: Generated from `#/components/schemas/SystemInfoBean`. - public struct SystemInfoBean: Codable, Hashable, Sendable { - /// - Remark: Generated from `#/components/schemas/SystemInfoBean/systemInfo`. - public var systemInfo: Components.Schemas.SystemInfo? - /// Creates a new `SystemInfoBean`. + /// - Remark: Generated from `#/components/schemas/Module`. + public struct Module: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/Module/description`. + public var description: Swift.String? + /// - Remark: Generated from `#/components/schemas/Module/label`. + public var label: Swift.String? + /// - Remark: Generated from `#/components/schemas/Module/typeUID`. + public var typeUID: Swift.String? + /// - Remark: Generated from `#/components/schemas/Module/configuration`. + public var configuration: Components.Schemas.Configuration? + /// - Remark: Generated from `#/components/schemas/Module/id`. + public var id: Swift.String? + /// Creates a new `Module`. /// /// - Parameters: - /// - systemInfo: - public init(systemInfo: Components.Schemas.SystemInfo? = nil) { - self.systemInfo = systemInfo + /// - description: + /// - label: + /// - typeUID: + /// - configuration: + /// - id: + public init( + description: Swift.String? = nil, + label: Swift.String? = nil, + typeUID: Swift.String? = nil, + configuration: Components.Schemas.Configuration? = nil, + id: Swift.String? = nil + ) { + self.description = description + self.label = label + self.typeUID = typeUID + self.configuration = configuration + self.id = id } - public enum CodingKeys: String, CodingKey { - case systemInfo + case description + case label + case typeUID + case configuration + case id } } - - /// - Remark: Generated from `#/components/schemas/DimensionInfo`. - public struct DimensionInfo: Codable, Hashable, Sendable { - /// - Remark: Generated from `#/components/schemas/DimensionInfo/dimension`. - public var dimension: Swift.String? - /// - Remark: Generated from `#/components/schemas/DimensionInfo/systemUnit`. - public var systemUnit: Swift.String? - /// Creates a new `DimensionInfo`. + /// - Remark: Generated from `#/components/schemas/ParameterOption`. + public struct ParameterOption: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/ParameterOption/label`. + public var label: Swift.String? + /// - Remark: Generated from `#/components/schemas/ParameterOption/value`. + public var value: Swift.String? + /// Creates a new `ParameterOption`. /// /// - Parameters: - /// - dimension: - /// - systemUnit: - public init(dimension: Swift.String? = nil, - systemUnit: Swift.String? = nil) { - self.dimension = dimension - self.systemUnit = systemUnit + /// - label: + /// - value: + public init( + label: Swift.String? = nil, + value: Swift.String? = nil + ) { + self.label = label + self.value = value } - public enum CodingKeys: String, CodingKey { - case dimension - case systemUnit + case label + case value } } - - /// - Remark: Generated from `#/components/schemas/UoMInfo`. - public struct UoMInfo: Codable, Hashable, Sendable { - /// - Remark: Generated from `#/components/schemas/UoMInfo/dimensions`. - public var dimensions: [Components.Schemas.DimensionInfo]? - /// Creates a new `UoMInfo`. + /// - Remark: Generated from `#/components/schemas/Rule`. + public struct Rule: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/Rule/description`. + public var description: Swift.String? + /// - Remark: Generated from `#/components/schemas/Rule/visibility`. + @frozen public enum visibilityPayload: String, Codable, Hashable, Sendable, CaseIterable { + case VISIBLE = "VISIBLE" + case HIDDEN = "HIDDEN" + case EXPERT = "EXPERT" + } + /// - Remark: Generated from `#/components/schemas/Rule/visibility`. + public var visibility: Components.Schemas.Rule.visibilityPayload? + /// - Remark: Generated from `#/components/schemas/Rule/configurationDescriptions`. + public var configurationDescriptions: [Components.Schemas.ConfigDescriptionParameter]? + /// - Remark: Generated from `#/components/schemas/Rule/templateUID`. + public var templateUID: Swift.String? + /// - Remark: Generated from `#/components/schemas/Rule/triggers`. + public var triggers: [Components.Schemas.Trigger]? + /// - Remark: Generated from `#/components/schemas/Rule/uid`. + public var uid: Swift.String? + /// - Remark: Generated from `#/components/schemas/Rule/tags`. + public var tags: [Swift.String]? + /// - Remark: Generated from `#/components/schemas/Rule/configuration`. + public var configuration: Components.Schemas.Configuration? + /// - Remark: Generated from `#/components/schemas/Rule/modules`. + public var modules: [Components.Schemas.Module]? + /// - Remark: Generated from `#/components/schemas/Rule/conditions`. + public var conditions: [Components.Schemas.Condition]? + /// - Remark: Generated from `#/components/schemas/Rule/name`. + public var name: Swift.String? + /// - Remark: Generated from `#/components/schemas/Rule/actions`. + public var actions: [Components.Schemas.Action]? + /// Creates a new `Rule`. /// /// - Parameters: - /// - dimensions: - public init(dimensions: [Components.Schemas.DimensionInfo]? = nil) { - self.dimensions = dimensions + /// - description: + /// - visibility: + /// - configurationDescriptions: + /// - templateUID: + /// - triggers: + /// - uid: + /// - tags: + /// - configuration: + /// - modules: + /// - conditions: + /// - name: + /// - actions: + public init( + description: Swift.String? = nil, + visibility: Components.Schemas.Rule.visibilityPayload? = nil, + configurationDescriptions: [Components.Schemas.ConfigDescriptionParameter]? = nil, + templateUID: Swift.String? = nil, + triggers: [Components.Schemas.Trigger]? = nil, + uid: Swift.String? = nil, + tags: [Swift.String]? = nil, + configuration: Components.Schemas.Configuration? = nil, + modules: [Components.Schemas.Module]? = nil, + conditions: [Components.Schemas.Condition]? = nil, + name: Swift.String? = nil, + actions: [Components.Schemas.Action]? = nil + ) { + self.description = description + self.visibility = visibility + self.configurationDescriptions = configurationDescriptions + self.templateUID = templateUID + self.triggers = triggers + self.uid = uid + self.tags = tags + self.configuration = configuration + self.modules = modules + self.conditions = conditions + self.name = name + self.actions = actions } - public enum CodingKeys: String, CodingKey { - case dimensions + case description + case visibility + case configurationDescriptions + case templateUID + case triggers + case uid + case tags + case configuration + case modules + case conditions + case name + case actions } } - - /// - Remark: Generated from `#/components/schemas/UoMInfoBean`. - public struct UoMInfoBean: Codable, Hashable, Sendable { - /// - Remark: Generated from `#/components/schemas/UoMInfoBean/uomInfo`. - public var uomInfo: Components.Schemas.UoMInfo? - /// Creates a new `UoMInfoBean`. + /// - Remark: Generated from `#/components/schemas/RuleExecution`. + public struct RuleExecution: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/RuleExecution/date`. + public var date: Foundation.Date? + /// - Remark: Generated from `#/components/schemas/RuleExecution/rule`. + public var rule: Components.Schemas.Rule? + /// Creates a new `RuleExecution`. /// /// - Parameters: - /// - uomInfo: - public init(uomInfo: Components.Schemas.UoMInfo? = nil) { - self.uomInfo = uomInfo + /// - date: + /// - rule: + public init( + date: Foundation.Date? = nil, + rule: Components.Schemas.Rule? = nil + ) { + self.date = date + self.rule = rule } - public enum CodingKeys: String, CodingKey { - case uomInfo + case date + case rule } } - - /// - Remark: Generated from `#/components/schemas/MappingDTO`. - public struct MappingDTO: Codable, Hashable, Sendable { - /// - Remark: Generated from `#/components/schemas/MappingDTO/row`. - public var row: Swift.Int32? - /// - Remark: Generated from `#/components/schemas/MappingDTO/column`. - public var column: Swift.Int32? - /// - Remark: Generated from `#/components/schemas/MappingDTO/command`. - public var command: Swift.String? - /// - Remark: Generated from `#/components/schemas/MappingDTO/releaseCommand`. - public var releaseCommand: Swift.String? - /// - Remark: Generated from `#/components/schemas/MappingDTO/label`. + /// - Remark: Generated from `#/components/schemas/Trigger`. + public struct Trigger: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/Trigger/description`. + public var description: Swift.String? + /// - Remark: Generated from `#/components/schemas/Trigger/label`. public var label: Swift.String? - /// - Remark: Generated from `#/components/schemas/MappingDTO/icon`. - public var icon: Swift.String? - /// Creates a new `MappingDTO`. + /// - Remark: Generated from `#/components/schemas/Trigger/typeUID`. + public var typeUID: Swift.String? + /// - Remark: Generated from `#/components/schemas/Trigger/configuration`. + public var configuration: Components.Schemas.Configuration? + /// - Remark: Generated from `#/components/schemas/Trigger/id`. + public var id: Swift.String? + /// Creates a new `Trigger`. /// /// - Parameters: - /// - row: - /// - column: - /// - command: - /// - releaseCommand: + /// - description: /// - label: - /// - icon: - public init(row: Swift.Int32? = nil, - column: Swift.Int32? = nil, - command: Swift.String? = nil, - releaseCommand: Swift.String? = nil, - label: Swift.String? = nil, - icon: Swift.String? = nil) { - self.row = row - self.column = column - self.command = command - self.releaseCommand = releaseCommand + /// - typeUID: + /// - configuration: + /// - id: + public init( + description: Swift.String? = nil, + label: Swift.String? = nil, + typeUID: Swift.String? = nil, + configuration: Components.Schemas.Configuration? = nil, + id: Swift.String? = nil + ) { + self.description = description self.label = label - self.icon = icon + self.typeUID = typeUID + self.configuration = configuration + self.id = id } - public enum CodingKeys: String, CodingKey { - case row - case column - case command - case releaseCommand + case description case label - case icon + case typeUID + case configuration + case id } } - - /// - Remark: Generated from `#/components/schemas/PageDTO`. - public struct PageDTO: Codable, Hashable, Sendable { - /// - Remark: Generated from `#/components/schemas/PageDTO/id`. - public var id: Swift.String? { - get { - storage.value.id - } - _modify { - yield &storage.value.id - } + /// - Remark: Generated from `#/components/schemas/CommandDescription`. + public struct CommandDescription: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/CommandDescription/commandOptions`. + public var commandOptions: [Components.Schemas.CommandOption]? + /// Creates a new `CommandDescription`. + /// + /// - Parameters: + /// - commandOptions: + public init(commandOptions: [Components.Schemas.CommandOption]? = nil) { + self.commandOptions = commandOptions } - - /// - Remark: Generated from `#/components/schemas/PageDTO/title`. - public var title: Swift.String? { - get { - storage.value.title - } - _modify { - yield &storage.value.title - } + public enum CodingKeys: String, CodingKey { + case commandOptions } - - /// - Remark: Generated from `#/components/schemas/PageDTO/icon`. - public var icon: Swift.String? { - get { - storage.value.icon - } - _modify { - yield &storage.value.icon - } + } + /// - Remark: Generated from `#/components/schemas/CommandOption`. + public struct CommandOption: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/CommandOption/command`. + public var command: Swift.String? + /// - Remark: Generated from `#/components/schemas/CommandOption/label`. + public var label: Swift.String? + /// Creates a new `CommandOption`. + /// + /// - Parameters: + /// - command: + /// - label: + public init( + command: Swift.String? = nil, + label: Swift.String? = nil + ) { + self.command = command + self.label = label } - - /// - Remark: Generated from `#/components/schemas/PageDTO/link`. - public var link: Swift.String? { - get { - storage.value.link - } - _modify { - yield &storage.value.link - } + public enum CodingKeys: String, CodingKey { + case command + case label } - - /// - Remark: Generated from `#/components/schemas/PageDTO/parent`. - public var parent: Components.Schemas.PageDTO? { - get { - storage.value.parent - } - _modify { - yield &storage.value.parent - } + } + /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterGroupDTO`. + public struct ConfigDescriptionParameterGroupDTO: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterGroupDTO/name`. + public var name: Swift.String? + /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterGroupDTO/context`. + public var context: Swift.String? + /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterGroupDTO/advanced`. + public var advanced: Swift.Bool? + /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterGroupDTO/label`. + public var label: Swift.String? + /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterGroupDTO/description`. + public var description: Swift.String? + /// Creates a new `ConfigDescriptionParameterGroupDTO`. + /// + /// - Parameters: + /// - name: + /// - context: + /// - advanced: + /// - label: + /// - description: + public init( + name: Swift.String? = nil, + context: Swift.String? = nil, + advanced: Swift.Bool? = nil, + label: Swift.String? = nil, + description: Swift.String? = nil + ) { + self.name = name + self.context = context + self.advanced = advanced + self.label = label + self.description = description } - - /// - Remark: Generated from `#/components/schemas/PageDTO/leaf`. - public var leaf: Swift.Bool? { - get { - storage.value.leaf - } - _modify { - yield &storage.value.leaf - } + public enum CodingKeys: String, CodingKey { + case name + case context + case advanced + case label + case description } - - /// - Remark: Generated from `#/components/schemas/PageDTO/timeout`. - public var timeout: Swift.Bool? { - get { - storage.value.timeout - } - _modify { - yield &storage.value.timeout - } + } + /// - Remark: Generated from `#/components/schemas/StateDescription`. + public struct StateDescription: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/StateDescription/minimum`. + public var minimum: Swift.Double? + /// - Remark: Generated from `#/components/schemas/StateDescription/maximum`. + public var maximum: Swift.Double? + /// - Remark: Generated from `#/components/schemas/StateDescription/step`. + public var step: Swift.Double? + /// - Remark: Generated from `#/components/schemas/StateDescription/pattern`. + public var pattern: Swift.String? + /// - Remark: Generated from `#/components/schemas/StateDescription/readOnly`. + public var readOnly: Swift.Bool? + /// - Remark: Generated from `#/components/schemas/StateDescription/options`. + public var options: [Components.Schemas.StateOption]? + /// Creates a new `StateDescription`. + /// + /// - Parameters: + /// - minimum: + /// - maximum: + /// - step: + /// - pattern: + /// - readOnly: + /// - options: + public init( + minimum: Swift.Double? = nil, + maximum: Swift.Double? = nil, + step: Swift.Double? = nil, + pattern: Swift.String? = nil, + readOnly: Swift.Bool? = nil, + options: [Components.Schemas.StateOption]? = nil + ) { + self.minimum = minimum + self.maximum = maximum + self.step = step + self.pattern = pattern + self.readOnly = readOnly + self.options = options } - - /// - Remark: Generated from `#/components/schemas/PageDTO/widgets`. - public var widgets: [Components.Schemas.WidgetDTO]? { - get { - storage.value.widgets - } - _modify { - yield &storage.value.widgets - } + public enum CodingKeys: String, CodingKey { + case minimum + case maximum + case step + case pattern + case readOnly + case options } - - /// Creates a new `PageDTO`. + } + /// - Remark: Generated from `#/components/schemas/StateOption`. + public struct StateOption: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/StateOption/value`. + public var value: Swift.String? + /// - Remark: Generated from `#/components/schemas/StateOption/label`. + public var label: Swift.String? + /// Creates a new `StateOption`. /// /// - Parameters: - /// - id: - /// - title: - /// - icon: - /// - link: - /// - parent: - /// - leaf: - /// - timeout: - /// - widgets: - public init(id: Swift.String? = nil, - title: Swift.String? = nil, - icon: Swift.String? = nil, - link: Swift.String? = nil, - parent: Components.Schemas.PageDTO? = nil, - leaf: Swift.Bool? = nil, - timeout: Swift.Bool? = nil, - widgets: [Components.Schemas.WidgetDTO]? = nil) { - storage = .init(value: .init( - id: id, - title: title, - icon: icon, - link: link, - parent: parent, - leaf: leaf, - timeout: timeout, - widgets: widgets - )) + /// - value: + /// - label: + public init( + value: Swift.String? = nil, + label: Swift.String? = nil + ) { + self.value = value + self.label = label } - public enum CodingKeys: String, CodingKey { - case id - case title - case icon - case link - case parent - case leaf - case timeout - case widgets - } - - public init(from decoder: any Decoder) throws { - storage = try .init(from: decoder) + case value + case label } - - public func encode(to encoder: any Encoder) throws { - try storage.encode(to: encoder) + } + /// - Remark: Generated from `#/components/schemas/ConfigDescriptionDTO`. + public struct ConfigDescriptionDTO: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/ConfigDescriptionDTO/uri`. + public var uri: Swift.String? + /// - Remark: Generated from `#/components/schemas/ConfigDescriptionDTO/parameters`. + public var parameters: [Components.Schemas.ConfigDescriptionParameterDTO]? + /// - Remark: Generated from `#/components/schemas/ConfigDescriptionDTO/parameterGroups`. + public var parameterGroups: [Components.Schemas.ConfigDescriptionParameterGroupDTO]? + /// Creates a new `ConfigDescriptionDTO`. + /// + /// - Parameters: + /// - uri: + /// - parameters: + /// - parameterGroups: + public init( + uri: Swift.String? = nil, + parameters: [Components.Schemas.ConfigDescriptionParameterDTO]? = nil, + parameterGroups: [Components.Schemas.ConfigDescriptionParameterGroupDTO]? = nil + ) { + self.uri = uri + self.parameters = parameters + self.parameterGroups = parameterGroups } - - /// Internal reference storage to allow type recursion. - private var storage: OpenAPIRuntime.CopyOnWriteBox - private struct Storage: Codable, Hashable, Sendable { - /// - Remark: Generated from `#/components/schemas/PageDTO/id`. - var id: Swift.String? - /// - Remark: Generated from `#/components/schemas/PageDTO/title`. - var title: Swift.String? - /// - Remark: Generated from `#/components/schemas/PageDTO/icon`. - var icon: Swift.String? - /// - Remark: Generated from `#/components/schemas/PageDTO/link`. - var link: Swift.String? - /// - Remark: Generated from `#/components/schemas/PageDTO/parent`. - var parent: Components.Schemas.PageDTO? - /// - Remark: Generated from `#/components/schemas/PageDTO/leaf`. - var leaf: Swift.Bool? - /// - Remark: Generated from `#/components/schemas/PageDTO/timeout`. - var timeout: Swift.Bool? - /// - Remark: Generated from `#/components/schemas/PageDTO/widgets`. - var widgets: [Components.Schemas.WidgetDTO]? - init(id: Swift.String? = nil, - title: Swift.String? = nil, - icon: Swift.String? = nil, - link: Swift.String? = nil, - parent: Components.Schemas.PageDTO? = nil, - leaf: Swift.Bool? = nil, - timeout: Swift.Bool? = nil, - widgets: [Components.Schemas.WidgetDTO]? = nil) { - self.id = id - self.title = title - self.icon = icon - self.link = link - self.parent = parent - self.leaf = leaf - self.timeout = timeout - self.widgets = widgets - } - - typealias CodingKeys = Components.Schemas.PageDTO.CodingKeys + public enum CodingKeys: String, CodingKey { + case uri + case parameters + case parameterGroups } } - - /// - Remark: Generated from `#/components/schemas/WidgetDTO`. - public struct WidgetDTO: Codable, Hashable, Sendable { - /// - Remark: Generated from `#/components/schemas/WidgetDTO/widgetId`. - public var widgetId: Swift.String? { - get { - storage.value.widgetId - } - _modify { - yield &storage.value.widgetId + /// - Remark: Generated from `#/components/schemas/MetadataDTO`. + public struct MetadataDTO: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/MetadataDTO/value`. + public var value: Swift.String? + /// - Remark: Generated from `#/components/schemas/MetadataDTO/config`. + public struct configPayload: Codable, Hashable, Sendable { + /// A container of undocumented properties. + public var additionalProperties: [String: OpenAPIRuntime.OpenAPIObjectContainer] + /// Creates a new `configPayload`. + /// + /// - Parameters: + /// - additionalProperties: A container of undocumented properties. + public init(additionalProperties: [String: OpenAPIRuntime.OpenAPIObjectContainer] = .init()) { + self.additionalProperties = additionalProperties } - } - - /// - Remark: Generated from `#/components/schemas/WidgetDTO/type`. - public var _type: Swift.String? { - get { - storage.value._type + public init(from decoder: any Decoder) throws { + additionalProperties = try decoder.decodeAdditionalProperties(knownKeys: []) } - _modify { - yield &storage.value._type + public func encode(to encoder: any Encoder) throws { + try encoder.encodeAdditionalProperties(additionalProperties) } } - - /// - Remark: Generated from `#/components/schemas/WidgetDTO/name`. - public var name: Swift.String? { - get { - storage.value.name - } - _modify { - yield &storage.value.name - } + /// - Remark: Generated from `#/components/schemas/MetadataDTO/config`. + public var config: Components.Schemas.MetadataDTO.configPayload? + /// Creates a new `MetadataDTO`. + /// + /// - Parameters: + /// - value: + /// - config: + public init( + value: Swift.String? = nil, + config: Components.Schemas.MetadataDTO.configPayload? = nil + ) { + self.value = value + self.config = config } - - /// - Remark: Generated from `#/components/schemas/WidgetDTO/visibility`. - public var visibility: Swift.Bool? { - get { - storage.value.visibility - } - _modify { - yield &storage.value.visibility - } + public enum CodingKeys: String, CodingKey { + case value + case config } - - /// - Remark: Generated from `#/components/schemas/WidgetDTO/label`. - public var label: Swift.String? { - get { - storage.value.label - } - _modify { - yield &storage.value.label + } + /// - Remark: Generated from `#/components/schemas/EnrichedItemDTO`. + public struct EnrichedItemDTO: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/EnrichedItemDTO/type`. + public var _type: Swift.String? + /// - Remark: Generated from `#/components/schemas/EnrichedItemDTO/name`. + public var name: Swift.String? + /// - Remark: Generated from `#/components/schemas/EnrichedItemDTO/label`. + public var label: Swift.String? + /// - Remark: Generated from `#/components/schemas/EnrichedItemDTO/category`. + public var category: Swift.String? + /// - Remark: Generated from `#/components/schemas/EnrichedItemDTO/tags`. + public var tags: [Swift.String]? + /// - Remark: Generated from `#/components/schemas/EnrichedItemDTO/groupNames`. + public var groupNames: [Swift.String]? + /// - Remark: Generated from `#/components/schemas/EnrichedItemDTO/link`. + public var link: Swift.String? + /// - Remark: Generated from `#/components/schemas/EnrichedItemDTO/state`. + public var state: Swift.String? + /// - Remark: Generated from `#/components/schemas/EnrichedItemDTO/transformedState`. + public var transformedState: Swift.String? + /// - Remark: Generated from `#/components/schemas/EnrichedItemDTO/stateDescription`. + public var stateDescription: Components.Schemas.StateDescription? + /// - Remark: Generated from `#/components/schemas/EnrichedItemDTO/unitSymbol`. + public var unitSymbol: Swift.String? + /// - Remark: Generated from `#/components/schemas/EnrichedItemDTO/commandDescription`. + public var commandDescription: Components.Schemas.CommandDescription? + /// - Remark: Generated from `#/components/schemas/EnrichedItemDTO/metadata`. + public struct metadataPayload: Codable, Hashable, Sendable { + /// A container of undocumented properties. + public var additionalProperties: [String: OpenAPIRuntime.OpenAPIObjectContainer] + /// Creates a new `metadataPayload`. + /// + /// - Parameters: + /// - additionalProperties: A container of undocumented properties. + public init(additionalProperties: [String: OpenAPIRuntime.OpenAPIObjectContainer] = .init()) { + self.additionalProperties = additionalProperties } - } - - /// - Remark: Generated from `#/components/schemas/WidgetDTO/labelSource`. - public var labelSource: Swift.String? { - get { - storage.value.labelSource + public init(from decoder: any Decoder) throws { + additionalProperties = try decoder.decodeAdditionalProperties(knownKeys: []) } - _modify { - yield &storage.value.labelSource + public func encode(to encoder: any Encoder) throws { + try encoder.encodeAdditionalProperties(additionalProperties) } } - - /// - Remark: Generated from `#/components/schemas/WidgetDTO/icon`. - public var icon: Swift.String? { - get { - storage.value.icon - } - _modify { - yield &storage.value.icon - } + /// - Remark: Generated from `#/components/schemas/EnrichedItemDTO/metadata`. + public var metadata: Components.Schemas.EnrichedItemDTO.metadataPayload? + /// - Remark: Generated from `#/components/schemas/EnrichedItemDTO/editable`. + public var editable: Swift.Bool? + /// Creates a new `EnrichedItemDTO`. + /// + /// - Parameters: + /// - _type: + /// - name: + /// - label: + /// - category: + /// - tags: + /// - groupNames: + /// - link: + /// - state: + /// - transformedState: + /// - stateDescription: + /// - unitSymbol: + /// - commandDescription: + /// - metadata: + /// - editable: + public init( + _type: Swift.String? = nil, + name: Swift.String? = nil, + label: Swift.String? = nil, + category: Swift.String? = nil, + tags: [Swift.String]? = nil, + groupNames: [Swift.String]? = nil, + link: Swift.String? = nil, + state: Swift.String? = nil, + transformedState: Swift.String? = nil, + stateDescription: Components.Schemas.StateDescription? = nil, + unitSymbol: Swift.String? = nil, + commandDescription: Components.Schemas.CommandDescription? = nil, + metadata: Components.Schemas.EnrichedItemDTO.metadataPayload? = nil, + editable: Swift.Bool? = nil + ) { + self._type = _type + self.name = name + self.label = label + self.category = category + self.tags = tags + self.groupNames = groupNames + self.link = link + self.state = state + self.transformedState = transformedState + self.stateDescription = stateDescription + self.unitSymbol = unitSymbol + self.commandDescription = commandDescription + self.metadata = metadata + self.editable = editable } - - /// - Remark: Generated from `#/components/schemas/WidgetDTO/staticIcon`. - public var staticIcon: Swift.Bool? { - get { - storage.value.staticIcon - } - _modify { - yield &storage.value.staticIcon - } + public enum CodingKeys: String, CodingKey { + case _type = "type" + case name + case label + case category + case tags + case groupNames + case link + case state + case transformedState + case stateDescription + case unitSymbol + case commandDescription + case metadata + case editable } - - /// - Remark: Generated from `#/components/schemas/WidgetDTO/labelcolor`. - public var labelcolor: Swift.String? { - get { - storage.value.labelcolor - } - _modify { - yield &storage.value.labelcolor - } + } + /// - Remark: Generated from `#/components/schemas/GroupFunctionDTO`. + public struct GroupFunctionDTO: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/GroupFunctionDTO/name`. + public var name: Swift.String? + /// - Remark: Generated from `#/components/schemas/GroupFunctionDTO/params`. + public var params: [Swift.String]? + /// Creates a new `GroupFunctionDTO`. + /// + /// - Parameters: + /// - name: + /// - params: + public init( + name: Swift.String? = nil, + params: [Swift.String]? = nil + ) { + self.name = name + self.params = params } - - /// - Remark: Generated from `#/components/schemas/WidgetDTO/valuecolor`. - public var valuecolor: Swift.String? { - get { - storage.value.valuecolor - } - _modify { - yield &storage.value.valuecolor - } + public enum CodingKeys: String, CodingKey { + case name + case params } - - /// - Remark: Generated from `#/components/schemas/WidgetDTO/iconcolor`. - public var iconcolor: Swift.String? { - get { - storage.value.iconcolor - } - _modify { - yield &storage.value.iconcolor - } + } + /// - Remark: Generated from `#/components/schemas/GroupItemDTO`. + public struct GroupItemDTO: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/GroupItemDTO/type`. + public var _type: Swift.String? + /// - Remark: Generated from `#/components/schemas/GroupItemDTO/name`. + public var name: Swift.String? + /// - Remark: Generated from `#/components/schemas/GroupItemDTO/label`. + public var label: Swift.String? + /// - Remark: Generated from `#/components/schemas/GroupItemDTO/category`. + public var category: Swift.String? + /// - Remark: Generated from `#/components/schemas/GroupItemDTO/tags`. + public var tags: [Swift.String]? + /// - Remark: Generated from `#/components/schemas/GroupItemDTO/groupNames`. + public var groupNames: [Swift.String]? + /// - Remark: Generated from `#/components/schemas/GroupItemDTO/groupType`. + public var groupType: Swift.String? + /// - Remark: Generated from `#/components/schemas/GroupItemDTO/function`. + public var function: Components.Schemas.GroupFunctionDTO? + /// Creates a new `GroupItemDTO`. + /// + /// - Parameters: + /// - _type: + /// - name: + /// - label: + /// - category: + /// - tags: + /// - groupNames: + /// - groupType: + /// - function: + public init( + _type: Swift.String? = nil, + name: Swift.String? = nil, + label: Swift.String? = nil, + category: Swift.String? = nil, + tags: [Swift.String]? = nil, + groupNames: [Swift.String]? = nil, + groupType: Swift.String? = nil, + function: Components.Schemas.GroupFunctionDTO? = nil + ) { + self._type = _type + self.name = name + self.label = label + self.category = category + self.tags = tags + self.groupNames = groupNames + self.groupType = groupType + self.function = function } - - /// - Remark: Generated from `#/components/schemas/WidgetDTO/pattern`. - public var pattern: Swift.String? { - get { - storage.value.pattern - } - _modify { - yield &storage.value.pattern - } + public enum CodingKeys: String, CodingKey { + case _type = "type" + case name + case label + case category + case tags + case groupNames + case groupType + case function } - - /// - Remark: Generated from `#/components/schemas/WidgetDTO/unit`. - public var unit: Swift.String? { - get { - storage.value.unit - } - _modify { - yield &storage.value.unit - } - } - - /// - Remark: Generated from `#/components/schemas/WidgetDTO/mappings`. - public var mappings: [Components.Schemas.MappingDTO]? { - get { - storage.value.mappings - } - _modify { - yield &storage.value.mappings - } + } + /// - Remark: Generated from `#/components/schemas/JerseyResponseBuilderDTO`. + public struct JerseyResponseBuilderDTO: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/JerseyResponseBuilderDTO/status`. + public var status: Swift.String? + /// - Remark: Generated from `#/components/schemas/JerseyResponseBuilderDTO/context`. + public var context: Components.Schemas.ContextDTO? + /// Creates a new `JerseyResponseBuilderDTO`. + /// + /// - Parameters: + /// - status: + /// - context: + public init( + status: Swift.String? = nil, + context: Components.Schemas.ContextDTO? = nil + ) { + self.status = status + self.context = context } - - /// - Remark: Generated from `#/components/schemas/WidgetDTO/switchSupport`. - public var switchSupport: Swift.Bool? { - get { - storage.value.switchSupport - } - _modify { - yield &storage.value.switchSupport - } + public enum CodingKeys: String, CodingKey { + case status + case context } - - /// - Remark: Generated from `#/components/schemas/WidgetDTO/releaseOnly`. - public var releaseOnly: Swift.Bool? { - get { - storage.value.releaseOnly - } - _modify { - yield &storage.value.releaseOnly - } + } + /// - Remark: Generated from `#/components/schemas/ContextDTO`. + public struct ContextDTO: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/ContextDTO/headers`. + public var headers: Components.Schemas.HeadersDTO? + /// Creates a new `ContextDTO`. + /// + /// - Parameters: + /// - headers: + public init(headers: Components.Schemas.HeadersDTO? = nil) { + self.headers = headers } - - /// - Remark: Generated from `#/components/schemas/WidgetDTO/sendFrequency`. - public var sendFrequency: Swift.Int32? { - get { - storage.value.sendFrequency - } - _modify { - yield &storage.value.sendFrequency - } + public enum CodingKeys: String, CodingKey { + case headers } - - /// - Remark: Generated from `#/components/schemas/WidgetDTO/refresh`. - public var refresh: Swift.Int32? { - get { - storage.value.refresh - } - _modify { - yield &storage.value.refresh - } + } + /// - Remark: Generated from `#/components/schemas/HeadersDTO`. + public struct HeadersDTO: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/HeadersDTO/Location`. + public var Location: [Swift.String]? + /// Creates a new `HeadersDTO`. + /// + /// - Parameters: + /// - Location: + public init(Location: [Swift.String]? = nil) { + self.Location = Location } - - /// - Remark: Generated from `#/components/schemas/WidgetDTO/height`. - public var height: Swift.Int32? { - get { - storage.value.height - } - _modify { - yield &storage.value.height - } + public enum CodingKeys: String, CodingKey { + case Location } - - /// - Remark: Generated from `#/components/schemas/WidgetDTO/minValue`. - public var minValue: Swift.Double? { - get { - storage.value.minValue - } - _modify { - yield &storage.value.minValue - } + } + /// - Remark: Generated from `#/components/schemas/Links`. + public struct Links: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/Links/type`. + public var _type: Swift.String? + /// - Remark: Generated from `#/components/schemas/Links/url`. + public var url: Swift.String? + /// Creates a new `Links`. + /// + /// - Parameters: + /// - _type: + /// - url: + public init( + _type: Swift.String? = nil, + url: Swift.String? = nil + ) { + self._type = _type + self.url = url } - - /// - Remark: Generated from `#/components/schemas/WidgetDTO/maxValue`. - public var maxValue: Swift.Double? { - get { - storage.value.maxValue - } - _modify { - yield &storage.value.maxValue - } + public enum CodingKeys: String, CodingKey { + case _type = "type" + case url } - - /// - Remark: Generated from `#/components/schemas/WidgetDTO/step`. - public var step: Swift.Double? { - get { - storage.value.step - } - _modify { - yield &storage.value.step - } + } + /// - Remark: Generated from `#/components/schemas/RootBean`. + public struct RootBean: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/RootBean/version`. + public var version: Swift.String? + /// - Remark: Generated from `#/components/schemas/RootBean/locale`. + public var locale: Swift.String? + /// - Remark: Generated from `#/components/schemas/RootBean/measurementSystem`. + public var measurementSystem: Swift.String? + /// - Remark: Generated from `#/components/schemas/RootBean/runtimeInfo`. + public var runtimeInfo: Components.Schemas.RuntimeInfo? + /// - Remark: Generated from `#/components/schemas/RootBean/links`. + public var links: [Components.Schemas.Links]? + /// Creates a new `RootBean`. + /// + /// - Parameters: + /// - version: + /// - locale: + /// - measurementSystem: + /// - runtimeInfo: + /// - links: + public init( + version: Swift.String? = nil, + locale: Swift.String? = nil, + measurementSystem: Swift.String? = nil, + runtimeInfo: Components.Schemas.RuntimeInfo? = nil, + links: [Components.Schemas.Links]? = nil + ) { + self.version = version + self.locale = locale + self.measurementSystem = measurementSystem + self.runtimeInfo = runtimeInfo + self.links = links } - - /// - Remark: Generated from `#/components/schemas/WidgetDTO/inputHint`. - public var inputHint: Swift.String? { - get { - storage.value.inputHint - } - _modify { - yield &storage.value.inputHint - } + public enum CodingKeys: String, CodingKey { + case version + case locale + case measurementSystem + case runtimeInfo + case links } - - /// - Remark: Generated from `#/components/schemas/WidgetDTO/url`. - public var url: Swift.String? { - get { - storage.value.url - } - _modify { - yield &storage.value.url - } + } + /// - Remark: Generated from `#/components/schemas/RuntimeInfo`. + public struct RuntimeInfo: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/RuntimeInfo/version`. + public var version: Swift.String? + /// - Remark: Generated from `#/components/schemas/RuntimeInfo/buildString`. + public var buildString: Swift.String? + /// Creates a new `RuntimeInfo`. + /// + /// - Parameters: + /// - version: + /// - buildString: + public init( + version: Swift.String? = nil, + buildString: Swift.String? = nil + ) { + self.version = version + self.buildString = buildString } - - /// - Remark: Generated from `#/components/schemas/WidgetDTO/encoding`. - public var encoding: Swift.String? { - get { - storage.value.encoding - } - _modify { - yield &storage.value.encoding - } + public enum CodingKeys: String, CodingKey { + case version + case buildString } - - /// - Remark: Generated from `#/components/schemas/WidgetDTO/service`. - public var service: Swift.String? { - get { - storage.value.service - } - _modify { - yield &storage.value.service - } + } + /// - Remark: Generated from `#/components/schemas/SystemInfo`. + public struct SystemInfo: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/SystemInfo/configFolder`. + public var configFolder: Swift.String? + /// - Remark: Generated from `#/components/schemas/SystemInfo/userdataFolder`. + public var userdataFolder: Swift.String? + /// - Remark: Generated from `#/components/schemas/SystemInfo/logFolder`. + public var logFolder: Swift.String? + /// - Remark: Generated from `#/components/schemas/SystemInfo/javaVersion`. + public var javaVersion: Swift.String? + /// - Remark: Generated from `#/components/schemas/SystemInfo/javaVendor`. + public var javaVendor: Swift.String? + /// - Remark: Generated from `#/components/schemas/SystemInfo/javaVendorVersion`. + public var javaVendorVersion: Swift.String? + /// - Remark: Generated from `#/components/schemas/SystemInfo/osName`. + public var osName: Swift.String? + /// - Remark: Generated from `#/components/schemas/SystemInfo/osVersion`. + public var osVersion: Swift.String? + /// - Remark: Generated from `#/components/schemas/SystemInfo/osArchitecture`. + public var osArchitecture: Swift.String? + /// - Remark: Generated from `#/components/schemas/SystemInfo/availableProcessors`. + public var availableProcessors: Swift.Int32? + /// - Remark: Generated from `#/components/schemas/SystemInfo/freeMemory`. + public var freeMemory: Swift.Int64? + /// - Remark: Generated from `#/components/schemas/SystemInfo/totalMemory`. + public var totalMemory: Swift.Int64? + /// - Remark: Generated from `#/components/schemas/SystemInfo/uptime`. + public var uptime: Swift.Int64? + /// - Remark: Generated from `#/components/schemas/SystemInfo/startLevel`. + public var startLevel: Swift.Int32? + /// Creates a new `SystemInfo`. + /// + /// - Parameters: + /// - configFolder: + /// - userdataFolder: + /// - logFolder: + /// - javaVersion: + /// - javaVendor: + /// - javaVendorVersion: + /// - osName: + /// - osVersion: + /// - osArchitecture: + /// - availableProcessors: + /// - freeMemory: + /// - totalMemory: + /// - uptime: + /// - startLevel: + public init( + configFolder: Swift.String? = nil, + userdataFolder: Swift.String? = nil, + logFolder: Swift.String? = nil, + javaVersion: Swift.String? = nil, + javaVendor: Swift.String? = nil, + javaVendorVersion: Swift.String? = nil, + osName: Swift.String? = nil, + osVersion: Swift.String? = nil, + osArchitecture: Swift.String? = nil, + availableProcessors: Swift.Int32? = nil, + freeMemory: Swift.Int64? = nil, + totalMemory: Swift.Int64? = nil, + uptime: Swift.Int64? = nil, + startLevel: Swift.Int32? = nil + ) { + self.configFolder = configFolder + self.userdataFolder = userdataFolder + self.logFolder = logFolder + self.javaVersion = javaVersion + self.javaVendor = javaVendor + self.javaVendorVersion = javaVendorVersion + self.osName = osName + self.osVersion = osVersion + self.osArchitecture = osArchitecture + self.availableProcessors = availableProcessors + self.freeMemory = freeMemory + self.totalMemory = totalMemory + self.uptime = uptime + self.startLevel = startLevel } - - /// - Remark: Generated from `#/components/schemas/WidgetDTO/period`. - public var period: Swift.String? { - get { - storage.value.period - } - _modify { - yield &storage.value.period - } + public enum CodingKeys: String, CodingKey { + case configFolder + case userdataFolder + case logFolder + case javaVersion + case javaVendor + case javaVendorVersion + case osName + case osVersion + case osArchitecture + case availableProcessors + case freeMemory + case totalMemory + case uptime + case startLevel } - - /// - Remark: Generated from `#/components/schemas/WidgetDTO/yAxisDecimalPattern`. - public var yAxisDecimalPattern: Swift.String? { - get { - storage.value.yAxisDecimalPattern + } + /// - Remark: Generated from `#/components/schemas/SystemInfoBean`. + public struct SystemInfoBean: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/SystemInfoBean/systemInfo`. + public var systemInfo: Components.Schemas.SystemInfo? + /// Creates a new `SystemInfoBean`. + /// + /// - Parameters: + /// - systemInfo: + public init(systemInfo: Components.Schemas.SystemInfo? = nil) { + self.systemInfo = systemInfo + } + public enum CodingKeys: String, CodingKey { + case systemInfo + } + } + /// - Remark: Generated from `#/components/schemas/DimensionInfo`. + public struct DimensionInfo: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/DimensionInfo/dimension`. + public var dimension: Swift.String? + /// - Remark: Generated from `#/components/schemas/DimensionInfo/systemUnit`. + public var systemUnit: Swift.String? + /// Creates a new `DimensionInfo`. + /// + /// - Parameters: + /// - dimension: + /// - systemUnit: + public init( + dimension: Swift.String? = nil, + systemUnit: Swift.String? = nil + ) { + self.dimension = dimension + self.systemUnit = systemUnit + } + public enum CodingKeys: String, CodingKey { + case dimension + case systemUnit + } + } + /// - Remark: Generated from `#/components/schemas/UoMInfo`. + public struct UoMInfo: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/UoMInfo/dimensions`. + public var dimensions: [Components.Schemas.DimensionInfo]? + /// Creates a new `UoMInfo`. + /// + /// - Parameters: + /// - dimensions: + public init(dimensions: [Components.Schemas.DimensionInfo]? = nil) { + self.dimensions = dimensions + } + public enum CodingKeys: String, CodingKey { + case dimensions + } + } + /// - Remark: Generated from `#/components/schemas/UoMInfoBean`. + public struct UoMInfoBean: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/UoMInfoBean/uomInfo`. + public var uomInfo: Components.Schemas.UoMInfo? + /// Creates a new `UoMInfoBean`. + /// + /// - Parameters: + /// - uomInfo: + public init(uomInfo: Components.Schemas.UoMInfo? = nil) { + self.uomInfo = uomInfo + } + public enum CodingKeys: String, CodingKey { + case uomInfo + } + } + /// - Remark: Generated from `#/components/schemas/MappingDTO`. + public struct MappingDTO: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/MappingDTO/row`. + public var row: Swift.Int32? + /// - Remark: Generated from `#/components/schemas/MappingDTO/column`. + public var column: Swift.Int32? + /// - Remark: Generated from `#/components/schemas/MappingDTO/command`. + public var command: Swift.String? + /// - Remark: Generated from `#/components/schemas/MappingDTO/releaseCommand`. + public var releaseCommand: Swift.String? + /// - Remark: Generated from `#/components/schemas/MappingDTO/label`. + public var label: Swift.String? + /// - Remark: Generated from `#/components/schemas/MappingDTO/icon`. + public var icon: Swift.String? + /// Creates a new `MappingDTO`. + /// + /// - Parameters: + /// - row: + /// - column: + /// - command: + /// - releaseCommand: + /// - label: + /// - icon: + public init( + row: Swift.Int32? = nil, + column: Swift.Int32? = nil, + command: Swift.String? = nil, + releaseCommand: Swift.String? = nil, + label: Swift.String? = nil, + icon: Swift.String? = nil + ) { + self.row = row + self.column = column + self.command = command + self.releaseCommand = releaseCommand + self.label = label + self.icon = icon + } + public enum CodingKeys: String, CodingKey { + case row + case column + case command + case releaseCommand + case label + case icon + } + } + /// - Remark: Generated from `#/components/schemas/PageDTO`. + public struct PageDTO: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/PageDTO/id`. + public var id: Swift.String? { + get { + self.storage.value.id } _modify { - yield &storage.value.yAxisDecimalPattern + yield &self.storage.value.id } } - - /// - Remark: Generated from `#/components/schemas/WidgetDTO/legend`. - public var legend: Swift.Bool? { - get { - storage.value.legend + /// - Remark: Generated from `#/components/schemas/PageDTO/title`. + public var title: Swift.String? { + get { + self.storage.value.title } _modify { - yield &storage.value.legend + yield &self.storage.value.title } } - - /// - Remark: Generated from `#/components/schemas/WidgetDTO/forceAsItem`. - public var forceAsItem: Swift.Bool? { - get { - storage.value.forceAsItem + /// - Remark: Generated from `#/components/schemas/PageDTO/icon`. + public var icon: Swift.String? { + get { + self.storage.value.icon } _modify { - yield &storage.value.forceAsItem + yield &self.storage.value.icon } } - - /// - Remark: Generated from `#/components/schemas/WidgetDTO/row`. - public var row: Swift.Int32? { - get { - storage.value.row + /// - Remark: Generated from `#/components/schemas/PageDTO/link`. + public var link: Swift.String? { + get { + self.storage.value.link } _modify { - yield &storage.value.row + yield &self.storage.value.link } } - - /// - Remark: Generated from `#/components/schemas/WidgetDTO/column`. - public var column: Swift.Int32? { - get { - storage.value.column + /// - Remark: Generated from `#/components/schemas/PageDTO/parent`. + public var parent: Components.Schemas.PageDTO? { + get { + self.storage.value.parent } _modify { - yield &storage.value.column + yield &self.storage.value.parent } } - - /// - Remark: Generated from `#/components/schemas/WidgetDTO/command`. - public var command: Swift.String? { - get { - storage.value.command + /// - Remark: Generated from `#/components/schemas/PageDTO/leaf`. + public var leaf: Swift.Bool? { + get { + self.storage.value.leaf } _modify { - yield &storage.value.command + yield &self.storage.value.leaf } } - - /// - Remark: Generated from `#/components/schemas/WidgetDTO/releaseCommand`. - public var releaseCommand: Swift.String? { - get { - storage.value.releaseCommand + /// - Remark: Generated from `#/components/schemas/PageDTO/timeout`. + public var timeout: Swift.Bool? { + get { + self.storage.value.timeout } _modify { - yield &storage.value.releaseCommand + yield &self.storage.value.timeout } } - - /// - Remark: Generated from `#/components/schemas/WidgetDTO/stateless`. - public var stateless: Swift.Bool? { - get { - storage.value.stateless + /// - Remark: Generated from `#/components/schemas/PageDTO/widgets`. + public var widgets: [Components.Schemas.WidgetDTO]? { + get { + self.storage.value.widgets } _modify { - yield &storage.value.stateless + yield &self.storage.value.widgets } } - - /// - Remark: Generated from `#/components/schemas/WidgetDTO/state`. - public var state: Swift.String? { - get { - storage.value.state + /// Creates a new `PageDTO`. + /// + /// - Parameters: + /// - id: + /// - title: + /// - icon: + /// - link: + /// - parent: + /// - leaf: + /// - timeout: + /// - widgets: + public init( + id: Swift.String? = nil, + title: Swift.String? = nil, + icon: Swift.String? = nil, + link: Swift.String? = nil, + parent: Components.Schemas.PageDTO? = nil, + leaf: Swift.Bool? = nil, + timeout: Swift.Bool? = nil, + widgets: [Components.Schemas.WidgetDTO]? = nil + ) { + self.storage = .init(value: .init( + id: id, + title: title, + icon: icon, + link: link, + parent: parent, + leaf: leaf, + timeout: timeout, + widgets: widgets + )) + } + public enum CodingKeys: String, CodingKey { + case id + case title + case icon + case link + case parent + case leaf + case timeout + case widgets + } + public init(from decoder: any Decoder) throws { + self.storage = try .init(from: decoder) + } + public func encode(to encoder: any Encoder) throws { + try self.storage.encode(to: encoder) + } + /// Internal reference storage to allow type recursion. + private var storage: OpenAPIRuntime.CopyOnWriteBox + private struct Storage: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/PageDTO/id`. + var id: Swift.String? + /// - Remark: Generated from `#/components/schemas/PageDTO/title`. + var title: Swift.String? + /// - Remark: Generated from `#/components/schemas/PageDTO/icon`. + var icon: Swift.String? + /// - Remark: Generated from `#/components/schemas/PageDTO/link`. + var link: Swift.String? + /// - Remark: Generated from `#/components/schemas/PageDTO/parent`. + var parent: Components.Schemas.PageDTO? + /// - Remark: Generated from `#/components/schemas/PageDTO/leaf`. + var leaf: Swift.Bool? + /// - Remark: Generated from `#/components/schemas/PageDTO/timeout`. + var timeout: Swift.Bool? + /// - Remark: Generated from `#/components/schemas/PageDTO/widgets`. + var widgets: [Components.Schemas.WidgetDTO]? + init( + id: Swift.String? = nil, + title: Swift.String? = nil, + icon: Swift.String? = nil, + link: Swift.String? = nil, + parent: Components.Schemas.PageDTO? = nil, + leaf: Swift.Bool? = nil, + timeout: Swift.Bool? = nil, + widgets: [Components.Schemas.WidgetDTO]? = nil + ) { + self.id = id + self.title = title + self.icon = icon + self.link = link + self.parent = parent + self.leaf = leaf + self.timeout = timeout + self.widgets = widgets + } + typealias CodingKeys = Components.Schemas.PageDTO.CodingKeys + } + } + /// - Remark: Generated from `#/components/schemas/WidgetDTO`. + public struct WidgetDTO: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/WidgetDTO/widgetId`. + public var widgetId: Swift.String? { + get { + self.storage.value.widgetId } _modify { - yield &storage.value.state + yield &self.storage.value.widgetId } } - - /// - Remark: Generated from `#/components/schemas/WidgetDTO/item`. - public var item: Components.Schemas.EnrichedItemDTO? { - get { - storage.value.item + /// - Remark: Generated from `#/components/schemas/WidgetDTO/type`. + public var _type: Swift.String? { + get { + self.storage.value._type } _modify { - yield &storage.value.item + yield &self.storage.value._type } } - - /// - Remark: Generated from `#/components/schemas/WidgetDTO/linkedPage`. - public var linkedPage: Components.Schemas.PageDTO? { - get { - storage.value.linkedPage + /// - Remark: Generated from `#/components/schemas/WidgetDTO/name`. + public var name: Swift.String? { + get { + self.storage.value.name } _modify { - yield &storage.value.linkedPage + yield &self.storage.value.name } } - - /// - Remark: Generated from `#/components/schemas/WidgetDTO/widgets`. - public var widgets: [Components.Schemas.WidgetDTO]? { - get { - storage.value.widgets + /// - Remark: Generated from `#/components/schemas/WidgetDTO/visibility`. + public var visibility: Swift.Bool? { + get { + self.storage.value.visibility } _modify { - yield &storage.value.widgets + yield &self.storage.value.visibility } } - - /// Creates a new `WidgetDTO`. - /// - /// - Parameters: - /// - widgetId: - /// - _type: - /// - name: + /// - Remark: Generated from `#/components/schemas/WidgetDTO/label`. + public var label: Swift.String? { + get { + self.storage.value.label + } + _modify { + yield &self.storage.value.label + } + } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/labelSource`. + public var labelSource: Swift.String? { + get { + self.storage.value.labelSource + } + _modify { + yield &self.storage.value.labelSource + } + } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/icon`. + public var icon: Swift.String? { + get { + self.storage.value.icon + } + _modify { + yield &self.storage.value.icon + } + } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/staticIcon`. + public var staticIcon: Swift.Bool? { + get { + self.storage.value.staticIcon + } + _modify { + yield &self.storage.value.staticIcon + } + } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/labelcolor`. + public var labelcolor: Swift.String? { + get { + self.storage.value.labelcolor + } + _modify { + yield &self.storage.value.labelcolor + } + } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/valuecolor`. + public var valuecolor: Swift.String? { + get { + self.storage.value.valuecolor + } + _modify { + yield &self.storage.value.valuecolor + } + } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/iconcolor`. + public var iconcolor: Swift.String? { + get { + self.storage.value.iconcolor + } + _modify { + yield &self.storage.value.iconcolor + } + } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/pattern`. + public var pattern: Swift.String? { + get { + self.storage.value.pattern + } + _modify { + yield &self.storage.value.pattern + } + } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/unit`. + public var unit: Swift.String? { + get { + self.storage.value.unit + } + _modify { + yield &self.storage.value.unit + } + } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/mappings`. + public var mappings: [Components.Schemas.MappingDTO]? { + get { + self.storage.value.mappings + } + _modify { + yield &self.storage.value.mappings + } + } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/switchSupport`. + public var switchSupport: Swift.Bool? { + get { + self.storage.value.switchSupport + } + _modify { + yield &self.storage.value.switchSupport + } + } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/releaseOnly`. + public var releaseOnly: Swift.Bool? { + get { + self.storage.value.releaseOnly + } + _modify { + yield &self.storage.value.releaseOnly + } + } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/sendFrequency`. + public var sendFrequency: Swift.Int32? { + get { + self.storage.value.sendFrequency + } + _modify { + yield &self.storage.value.sendFrequency + } + } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/refresh`. + public var refresh: Swift.Int32? { + get { + self.storage.value.refresh + } + _modify { + yield &self.storage.value.refresh + } + } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/height`. + public var height: Swift.Int32? { + get { + self.storage.value.height + } + _modify { + yield &self.storage.value.height + } + } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/minValue`. + public var minValue: Swift.Double? { + get { + self.storage.value.minValue + } + _modify { + yield &self.storage.value.minValue + } + } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/maxValue`. + public var maxValue: Swift.Double? { + get { + self.storage.value.maxValue + } + _modify { + yield &self.storage.value.maxValue + } + } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/step`. + public var step: Swift.Double? { + get { + self.storage.value.step + } + _modify { + yield &self.storage.value.step + } + } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/inputHint`. + public var inputHint: Swift.String? { + get { + self.storage.value.inputHint + } + _modify { + yield &self.storage.value.inputHint + } + } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/url`. + public var url: Swift.String? { + get { + self.storage.value.url + } + _modify { + yield &self.storage.value.url + } + } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/encoding`. + public var encoding: Swift.String? { + get { + self.storage.value.encoding + } + _modify { + yield &self.storage.value.encoding + } + } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/service`. + public var service: Swift.String? { + get { + self.storage.value.service + } + _modify { + yield &self.storage.value.service + } + } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/period`. + public var period: Swift.String? { + get { + self.storage.value.period + } + _modify { + yield &self.storage.value.period + } + } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/yAxisDecimalPattern`. + public var yAxisDecimalPattern: Swift.String? { + get { + self.storage.value.yAxisDecimalPattern + } + _modify { + yield &self.storage.value.yAxisDecimalPattern + } + } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/legend`. + public var legend: Swift.Bool? { + get { + self.storage.value.legend + } + _modify { + yield &self.storage.value.legend + } + } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/forceAsItem`. + public var forceAsItem: Swift.Bool? { + get { + self.storage.value.forceAsItem + } + _modify { + yield &self.storage.value.forceAsItem + } + } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/row`. + public var row: Swift.Int32? { + get { + self.storage.value.row + } + _modify { + yield &self.storage.value.row + } + } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/column`. + public var column: Swift.Int32? { + get { + self.storage.value.column + } + _modify { + yield &self.storage.value.column + } + } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/command`. + public var command: Swift.String? { + get { + self.storage.value.command + } + _modify { + yield &self.storage.value.command + } + } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/releaseCommand`. + public var releaseCommand: Swift.String? { + get { + self.storage.value.releaseCommand + } + _modify { + yield &self.storage.value.releaseCommand + } + } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/stateless`. + public var stateless: Swift.Bool? { + get { + self.storage.value.stateless + } + _modify { + yield &self.storage.value.stateless + } + } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/state`. + public var state: Swift.String? { + get { + self.storage.value.state + } + _modify { + yield &self.storage.value.state + } + } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/item`. + public var item: Components.Schemas.EnrichedItemDTO? { + get { + self.storage.value.item + } + _modify { + yield &self.storage.value.item + } + } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/linkedPage`. + public var linkedPage: Components.Schemas.PageDTO? { + get { + self.storage.value.linkedPage + } + _modify { + yield &self.storage.value.linkedPage + } + } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/widgets`. + public var widgets: [Components.Schemas.WidgetDTO]? { + get { + self.storage.value.widgets + } + _modify { + yield &self.storage.value.widgets + } + } + /// Creates a new `WidgetDTO`. + /// + /// - Parameters: + /// - widgetId: + /// - _type: + /// - name: /// - visibility: /// - label: /// - labelSource: @@ -2162,46 +3479,48 @@ public enum Components { /// - item: /// - linkedPage: /// - widgets: - public init(widgetId: Swift.String? = nil, - _type: Swift.String? = nil, - name: Swift.String? = nil, - visibility: Swift.Bool? = nil, - label: Swift.String? = nil, - labelSource: Swift.String? = nil, - icon: Swift.String? = nil, - staticIcon: Swift.Bool? = nil, - labelcolor: Swift.String? = nil, - valuecolor: Swift.String? = nil, - iconcolor: Swift.String? = nil, - pattern: Swift.String? = nil, - unit: Swift.String? = nil, - mappings: [Components.Schemas.MappingDTO]? = nil, - switchSupport: Swift.Bool? = nil, - releaseOnly: Swift.Bool? = nil, - sendFrequency: Swift.Int32? = nil, - refresh: Swift.Int32? = nil, - height: Swift.Int32? = nil, - minValue: Swift.Double? = nil, - maxValue: Swift.Double? = nil, - step: Swift.Double? = nil, - inputHint: Swift.String? = nil, - url: Swift.String? = nil, - encoding: Swift.String? = nil, - service: Swift.String? = nil, - period: Swift.String? = nil, - yAxisDecimalPattern: Swift.String? = nil, - legend: Swift.Bool? = nil, - forceAsItem: Swift.Bool? = nil, - row: Swift.Int32? = nil, - column: Swift.Int32? = nil, - command: Swift.String? = nil, - releaseCommand: Swift.String? = nil, - stateless: Swift.Bool? = nil, - state: Swift.String? = nil, - item: Components.Schemas.EnrichedItemDTO? = nil, - linkedPage: Components.Schemas.PageDTO? = nil, - widgets: [Components.Schemas.WidgetDTO]? = nil) { - storage = .init(value: .init( + public init( + widgetId: Swift.String? = nil, + _type: Swift.String? = nil, + name: Swift.String? = nil, + visibility: Swift.Bool? = nil, + label: Swift.String? = nil, + labelSource: Swift.String? = nil, + icon: Swift.String? = nil, + staticIcon: Swift.Bool? = nil, + labelcolor: Swift.String? = nil, + valuecolor: Swift.String? = nil, + iconcolor: Swift.String? = nil, + pattern: Swift.String? = nil, + unit: Swift.String? = nil, + mappings: [Components.Schemas.MappingDTO]? = nil, + switchSupport: Swift.Bool? = nil, + releaseOnly: Swift.Bool? = nil, + sendFrequency: Swift.Int32? = nil, + refresh: Swift.Int32? = nil, + height: Swift.Int32? = nil, + minValue: Swift.Double? = nil, + maxValue: Swift.Double? = nil, + step: Swift.Double? = nil, + inputHint: Swift.String? = nil, + url: Swift.String? = nil, + encoding: Swift.String? = nil, + service: Swift.String? = nil, + period: Swift.String? = nil, + yAxisDecimalPattern: Swift.String? = nil, + legend: Swift.Bool? = nil, + forceAsItem: Swift.Bool? = nil, + row: Swift.Int32? = nil, + column: Swift.Int32? = nil, + command: Swift.String? = nil, + releaseCommand: Swift.String? = nil, + stateless: Swift.Bool? = nil, + state: Swift.String? = nil, + item: Components.Schemas.EnrichedItemDTO? = nil, + linkedPage: Components.Schemas.PageDTO? = nil, + widgets: [Components.Schemas.WidgetDTO]? = nil + ) { + self.storage = .init(value: .init( widgetId: widgetId, _type: _type, name: name, @@ -2243,7 +3562,6 @@ public enum Components { widgets: widgets )) } - public enum CodingKeys: String, CodingKey { case widgetId case _type = "type" @@ -2285,15 +3603,12 @@ public enum Components { case linkedPage case widgets } - public init(from decoder: any Decoder) throws { - storage = try .init(from: decoder) + self.storage = try .init(from: decoder) } - public func encode(to encoder: any Encoder) throws { - try storage.encode(to: encoder) + try self.storage.encode(to: encoder) } - /// Internal reference storage to allow type recursion. private var storage: OpenAPIRuntime.CopyOnWriteBox private struct Storage: Codable, Hashable, Sendable { @@ -2375,45 +3690,47 @@ public enum Components { var linkedPage: Components.Schemas.PageDTO? /// - Remark: Generated from `#/components/schemas/WidgetDTO/widgets`. var widgets: [Components.Schemas.WidgetDTO]? - init(widgetId: Swift.String? = nil, - _type: Swift.String? = nil, - name: Swift.String? = nil, - visibility: Swift.Bool? = nil, - label: Swift.String? = nil, - labelSource: Swift.String? = nil, - icon: Swift.String? = nil, - staticIcon: Swift.Bool? = nil, - labelcolor: Swift.String? = nil, - valuecolor: Swift.String? = nil, - iconcolor: Swift.String? = nil, - pattern: Swift.String? = nil, - unit: Swift.String? = nil, - mappings: [Components.Schemas.MappingDTO]? = nil, - switchSupport: Swift.Bool? = nil, - releaseOnly: Swift.Bool? = nil, - sendFrequency: Swift.Int32? = nil, - refresh: Swift.Int32? = nil, - height: Swift.Int32? = nil, - minValue: Swift.Double? = nil, - maxValue: Swift.Double? = nil, - step: Swift.Double? = nil, - inputHint: Swift.String? = nil, - url: Swift.String? = nil, - encoding: Swift.String? = nil, - service: Swift.String? = nil, - period: Swift.String? = nil, - yAxisDecimalPattern: Swift.String? = nil, - legend: Swift.Bool? = nil, - forceAsItem: Swift.Bool? = nil, - row: Swift.Int32? = nil, - column: Swift.Int32? = nil, - command: Swift.String? = nil, - releaseCommand: Swift.String? = nil, - stateless: Swift.Bool? = nil, - state: Swift.String? = nil, - item: Components.Schemas.EnrichedItemDTO? = nil, - linkedPage: Components.Schemas.PageDTO? = nil, - widgets: [Components.Schemas.WidgetDTO]? = nil) { + init( + widgetId: Swift.String? = nil, + _type: Swift.String? = nil, + name: Swift.String? = nil, + visibility: Swift.Bool? = nil, + label: Swift.String? = nil, + labelSource: Swift.String? = nil, + icon: Swift.String? = nil, + staticIcon: Swift.Bool? = nil, + labelcolor: Swift.String? = nil, + valuecolor: Swift.String? = nil, + iconcolor: Swift.String? = nil, + pattern: Swift.String? = nil, + unit: Swift.String? = nil, + mappings: [Components.Schemas.MappingDTO]? = nil, + switchSupport: Swift.Bool? = nil, + releaseOnly: Swift.Bool? = nil, + sendFrequency: Swift.Int32? = nil, + refresh: Swift.Int32? = nil, + height: Swift.Int32? = nil, + minValue: Swift.Double? = nil, + maxValue: Swift.Double? = nil, + step: Swift.Double? = nil, + inputHint: Swift.String? = nil, + url: Swift.String? = nil, + encoding: Swift.String? = nil, + service: Swift.String? = nil, + period: Swift.String? = nil, + yAxisDecimalPattern: Swift.String? = nil, + legend: Swift.Bool? = nil, + forceAsItem: Swift.Bool? = nil, + row: Swift.Int32? = nil, + column: Swift.Int32? = nil, + command: Swift.String? = nil, + releaseCommand: Swift.String? = nil, + stateless: Swift.Bool? = nil, + state: Swift.String? = nil, + item: Components.Schemas.EnrichedItemDTO? = nil, + linkedPage: Components.Schemas.PageDTO? = nil, + widgets: [Components.Schemas.WidgetDTO]? = nil + ) { self.widgetId = widgetId self._type = _type self.name = name @@ -2454,11 +3771,9 @@ public enum Components { self.linkedPage = linkedPage self.widgets = widgets } - typealias CodingKeys = Components.Schemas.WidgetDTO.CodingKeys } } - /// - Remark: Generated from `#/components/schemas/SitemapWidgetEvent`. public struct SitemapWidgetEvent: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/SitemapWidgetEvent/widgetId`. @@ -2506,20 +3821,22 @@ public enum Components { /// - item: /// - sitemapName: /// - pageId: - public init(widgetId: Swift.String? = nil, - label: Swift.String? = nil, - labelSource: Swift.String? = nil, - icon: Swift.String? = nil, - labelcolor: Swift.String? = nil, - valuecolor: Swift.String? = nil, - iconcolor: Swift.String? = nil, - state: Swift.String? = nil, - reloadIcon: Swift.Bool? = nil, - visibility: Swift.Bool? = nil, - descriptionChanged: Swift.Bool? = nil, - item: Components.Schemas.EnrichedItemDTO? = nil, - sitemapName: Swift.String? = nil, - pageId: Swift.String? = nil) { + public init( + widgetId: Swift.String? = nil, + label: Swift.String? = nil, + labelSource: Swift.String? = nil, + icon: Swift.String? = nil, + labelcolor: Swift.String? = nil, + valuecolor: Swift.String? = nil, + iconcolor: Swift.String? = nil, + state: Swift.String? = nil, + reloadIcon: Swift.Bool? = nil, + visibility: Swift.Bool? = nil, + descriptionChanged: Swift.Bool? = nil, + item: Components.Schemas.EnrichedItemDTO? = nil, + sitemapName: Swift.String? = nil, + pageId: Swift.String? = nil + ) { self.widgetId = widgetId self.label = label self.labelSource = labelSource @@ -2535,7 +3852,6 @@ public enum Components { self.sitemapName = sitemapName self.pageId = pageId } - public enum CodingKeys: String, CodingKey { case widgetId case label @@ -2553,7 +3869,6 @@ public enum Components { case pageId } } - /// - Remark: Generated from `#/components/schemas/SitemapDTO`. public struct SitemapDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/SitemapDTO/name`. @@ -2574,226 +3889,2817 @@ public enum Components { /// - label: /// - link: /// - homepage: - public init(name: Swift.String? = nil, - icon: Swift.String? = nil, - label: Swift.String? = nil, - link: Swift.String? = nil, - homepage: Components.Schemas.PageDTO? = nil) { + public init( + name: Swift.String? = nil, + icon: Swift.String? = nil, + label: Swift.String? = nil, + link: Swift.String? = nil, + homepage: Components.Schemas.PageDTO? = nil + ) { self.name = name self.icon = icon self.label = label self.link = link self.homepage = homepage } - - public enum CodingKeys: String, CodingKey { - case name - case icon - case label - case link - case homepage + public enum CodingKeys: String, CodingKey { + case name + case icon + case label + case link + case homepage + } + } + /// - Remark: Generated from `#/components/schemas/RootUIComponent`. + public struct RootUIComponent: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/RootUIComponent/component`. + public var component: Swift.String? + /// - Remark: Generated from `#/components/schemas/RootUIComponent/config`. + public struct configPayload: Codable, Hashable, Sendable { + /// A container of undocumented properties. + public var additionalProperties: [String: OpenAPIRuntime.OpenAPIObjectContainer] + /// Creates a new `configPayload`. + /// + /// - Parameters: + /// - additionalProperties: A container of undocumented properties. + public init(additionalProperties: [String: OpenAPIRuntime.OpenAPIObjectContainer] = .init()) { + self.additionalProperties = additionalProperties + } + public init(from decoder: any Decoder) throws { + additionalProperties = try decoder.decodeAdditionalProperties(knownKeys: []) + } + public func encode(to encoder: any Encoder) throws { + try encoder.encodeAdditionalProperties(additionalProperties) + } + } + /// - Remark: Generated from `#/components/schemas/RootUIComponent/config`. + public var config: Components.Schemas.RootUIComponent.configPayload? + /// - Remark: Generated from `#/components/schemas/RootUIComponent/slots`. + public struct slotsPayload: Codable, Hashable, Sendable { + /// A container of undocumented properties. + public var additionalProperties: [String: [Components.Schemas.UIComponent]] + /// Creates a new `slotsPayload`. + /// + /// - Parameters: + /// - additionalProperties: A container of undocumented properties. + public init(additionalProperties: [String: [Components.Schemas.UIComponent]] = .init()) { + self.additionalProperties = additionalProperties + } + public init(from decoder: any Decoder) throws { + additionalProperties = try decoder.decodeAdditionalProperties(knownKeys: []) + } + public func encode(to encoder: any Encoder) throws { + try encoder.encodeAdditionalProperties(additionalProperties) + } + } + /// - Remark: Generated from `#/components/schemas/RootUIComponent/slots`. + public var slots: Components.Schemas.RootUIComponent.slotsPayload? + /// - Remark: Generated from `#/components/schemas/RootUIComponent/uid`. + public var uid: Swift.String? + /// - Remark: Generated from `#/components/schemas/RootUIComponent/tags`. + public var tags: [Swift.String]? + /// - Remark: Generated from `#/components/schemas/RootUIComponent/props`. + public var props: Components.Schemas.ConfigDescriptionDTO? + /// - Remark: Generated from `#/components/schemas/RootUIComponent/timestamp`. + public var timestamp: Foundation.Date? + /// - Remark: Generated from `#/components/schemas/RootUIComponent/type`. + public var _type: Swift.String? + /// Creates a new `RootUIComponent`. + /// + /// - Parameters: + /// - component: + /// - config: + /// - slots: + /// - uid: + /// - tags: + /// - props: + /// - timestamp: + /// - _type: + public init( + component: Swift.String? = nil, + config: Components.Schemas.RootUIComponent.configPayload? = nil, + slots: Components.Schemas.RootUIComponent.slotsPayload? = nil, + uid: Swift.String? = nil, + tags: [Swift.String]? = nil, + props: Components.Schemas.ConfigDescriptionDTO? = nil, + timestamp: Foundation.Date? = nil, + _type: Swift.String? = nil + ) { + self.component = component + self.config = config + self.slots = slots + self.uid = uid + self.tags = tags + self.props = props + self.timestamp = timestamp + self._type = _type + } + public enum CodingKeys: String, CodingKey { + case component + case config + case slots + case uid + case tags + case props + case timestamp + case _type = "type" + } + } + /// - Remark: Generated from `#/components/schemas/UIComponent`. + public struct UIComponent: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/UIComponent/component`. + public var component: Swift.String? + /// - Remark: Generated from `#/components/schemas/UIComponent/config`. + public struct configPayload: Codable, Hashable, Sendable { + /// A container of undocumented properties. + public var additionalProperties: [String: OpenAPIRuntime.OpenAPIObjectContainer] + /// Creates a new `configPayload`. + /// + /// - Parameters: + /// - additionalProperties: A container of undocumented properties. + public init(additionalProperties: [String: OpenAPIRuntime.OpenAPIObjectContainer] = .init()) { + self.additionalProperties = additionalProperties + } + public init(from decoder: any Decoder) throws { + additionalProperties = try decoder.decodeAdditionalProperties(knownKeys: []) + } + public func encode(to encoder: any Encoder) throws { + try encoder.encodeAdditionalProperties(additionalProperties) + } + } + /// - Remark: Generated from `#/components/schemas/UIComponent/config`. + public var config: Components.Schemas.UIComponent.configPayload? + /// - Remark: Generated from `#/components/schemas/UIComponent/type`. + public var _type: Swift.String? + /// Creates a new `UIComponent`. + /// + /// - Parameters: + /// - component: + /// - config: + /// - _type: + public init( + component: Swift.String? = nil, + config: Components.Schemas.UIComponent.configPayload? = nil, + _type: Swift.String? = nil + ) { + self.component = component + self.config = config + self._type = _type + } + public enum CodingKeys: String, CodingKey { + case component + case config + case _type = "type" + } + } + /// - Remark: Generated from `#/components/schemas/TileDTO`. + public struct TileDTO: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/TileDTO/name`. + public var name: Swift.String? + /// - Remark: Generated from `#/components/schemas/TileDTO/url`. + public var url: Swift.String? + /// - Remark: Generated from `#/components/schemas/TileDTO/overlay`. + public var overlay: Swift.String? + /// - Remark: Generated from `#/components/schemas/TileDTO/imageUrl`. + public var imageUrl: Swift.String? + /// Creates a new `TileDTO`. + /// + /// - Parameters: + /// - name: + /// - url: + /// - overlay: + /// - imageUrl: + public init( + name: Swift.String? = nil, + url: Swift.String? = nil, + overlay: Swift.String? = nil, + imageUrl: Swift.String? = nil + ) { + self.name = name + self.url = url + self.overlay = overlay + self.imageUrl = imageUrl + } + public enum CodingKeys: String, CodingKey { + case name + case url + case overlay + case imageUrl + } + } + } + /// Types generated from the `#/components/parameters` section of the OpenAPI document. + public enum Parameters {} + /// Types generated from the `#/components/requestBodies` section of the OpenAPI document. + public enum RequestBodies {} + /// Types generated from the `#/components/responses` section of the OpenAPI document. + public enum Responses {} + /// Types generated from the `#/components/headers` section of the OpenAPI document. + public enum Headers {} +} + +/// API operations, with input and output types, generated from `#/paths` in the OpenAPI document. +public enum Operations { + /// Get available rules, optionally filtered by tags and/or prefix. + /// + /// - Remark: HTTP `GET /rules`. + /// - Remark: Generated from `#/paths//rules/get(getRules)`. + public enum getRules { + public static let id: Swift.String = "getRules" + public struct Input: Sendable, Hashable { + /// - Remark: Generated from `#/paths/rules/GET/query`. + public struct Query: Sendable, Hashable { + /// - Remark: Generated from `#/paths/rules/GET/query/prefix`. + public var prefix: Swift.String? + /// - Remark: Generated from `#/paths/rules/GET/query/tags`. + public var tags: [Swift.String]? + /// summary fields only + /// + /// - Remark: Generated from `#/paths/rules/GET/query/summary`. + public var summary: Swift.Bool? + /// provides a cacheable list of values not expected to change regularly and honors the If-Modified-Since header, all other parameters are ignored + /// + /// - Remark: Generated from `#/paths/rules/GET/query/staticDataOnly`. + public var staticDataOnly: Swift.Bool? + /// Creates a new `Query`. + /// + /// - Parameters: + /// - prefix: + /// - tags: + /// - summary: summary fields only + /// - staticDataOnly: provides a cacheable list of values not expected to change regularly and honors the If-Modified-Since header, all other parameters are ignored + public init( + prefix: Swift.String? = nil, + tags: [Swift.String]? = nil, + summary: Swift.Bool? = nil, + staticDataOnly: Swift.Bool? = nil + ) { + self.prefix = prefix + self.tags = tags + self.summary = summary + self.staticDataOnly = staticDataOnly + } + } + public var query: Operations.getRules.Input.Query + /// - Remark: Generated from `#/paths/rules/GET/header`. + public struct Headers: Sendable, Hashable { + public var accept: [OpenAPIRuntime.AcceptHeaderContentType] + /// Creates a new `Headers`. + /// + /// - Parameters: + /// - accept: + public init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + self.accept = accept + } + } + public var headers: Operations.getRules.Input.Headers + /// Creates a new `Input`. + /// + /// - Parameters: + /// - query: + /// - headers: + public init( + query: Operations.getRules.Input.Query = .init(), + headers: Operations.getRules.Input.Headers = .init() + ) { + self.query = query + self.headers = headers + } + } + @frozen public enum Output: Sendable, Hashable { + public struct Ok: Sendable, Hashable { + /// - Remark: Generated from `#/paths/rules/GET/responses/200/content`. + @frozen public enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/rules/GET/responses/200/content/application\/json`. + case json([Components.Schemas.EnrichedRuleDTO]) + /// The associated value of the enum case if `self` is `.json`. + /// + /// - Throws: An error if `self` is not `.json`. + /// - SeeAlso: `.json`. + public var json: [Components.Schemas.EnrichedRuleDTO] { + get throws { + switch self { + case let .json(body): + return body + } + } + } + } + /// Received HTTP response body + public var body: Operations.getRules.Output.Ok.Body + /// Creates a new `Ok`. + /// + /// - Parameters: + /// - body: Received HTTP response body + public init(body: Operations.getRules.Output.Ok.Body) { + self.body = body + } + } + /// OK + /// + /// - Remark: Generated from `#/paths//rules/get(getRules)/responses/200`. + /// + /// HTTP response code: `200 ok`. + case ok(Operations.getRules.Output.Ok) + /// The associated value of the enum case if `self` is `.ok`. + /// + /// - Throws: An error if `self` is not `.ok`. + /// - SeeAlso: `.ok`. + public var ok: Operations.getRules.Output.Ok { + get throws { + switch self { + case let .ok(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "ok", + response: self + ) + } + } + } + /// Undocumented response. + /// + /// A response with a code that is not documented in the OpenAPI document. + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + @frozen public enum AcceptableContentType: AcceptableProtocol { + case json + case other(Swift.String) + public init?(rawValue: Swift.String) { + switch rawValue.lowercased() { + case "application/json": + self = .json + default: + self = .other(rawValue) + } + } + public var rawValue: Swift.String { + switch self { + case let .other(string): + return string + case .json: + return "application/json" + } + } + public static var allCases: [Self] { + [ + .json + ] + } + } + } + /// Creates a rule. + /// + /// - Remark: HTTP `POST /rules`. + /// - Remark: Generated from `#/paths//rules/post(createRule)`. + public enum createRule { + public static let id: Swift.String = "createRule" + public struct Input: Sendable, Hashable { + /// - Remark: Generated from `#/paths/rules/POST/requestBody`. + @frozen public enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/rules/POST/requestBody/content/application\/json`. + case json(Components.Schemas.RuleDTO) + } + public var body: Operations.createRule.Input.Body + /// Creates a new `Input`. + /// + /// - Parameters: + /// - body: + public init(body: Operations.createRule.Input.Body) { + self.body = body + } + } + @frozen public enum Output: Sendable, Hashable { + public struct Created: Sendable, Hashable { + /// - Remark: Generated from `#/paths/rules/POST/responses/201/headers`. + public struct Headers: Sendable, Hashable { + /// Newly created Rule + /// + /// - Remark: Generated from `#/paths/rules/POST/responses/201/headers/Location`. + public var Location: Swift.String? + /// Creates a new `Headers`. + /// + /// - Parameters: + /// - Location: Newly created Rule + public init(Location: Swift.String? = nil) { + self.Location = Location + } + } + /// Received HTTP response headers + public var headers: Operations.createRule.Output.Created.Headers + /// Creates a new `Created`. + /// + /// - Parameters: + /// - headers: Received HTTP response headers + public init(headers: Operations.createRule.Output.Created.Headers = .init()) { + self.headers = headers + } + } + /// Created + /// + /// - Remark: Generated from `#/paths//rules/post(createRule)/responses/201`. + /// + /// HTTP response code: `201 created`. + case created(Operations.createRule.Output.Created) + /// The associated value of the enum case if `self` is `.created`. + /// + /// - Throws: An error if `self` is not `.created`. + /// - SeeAlso: `.created`. + public var created: Operations.createRule.Output.Created { + get throws { + switch self { + case let .created(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "created", + response: self + ) + } + } + } + public struct BadRequest: Sendable, Hashable { + /// Creates a new `BadRequest`. + public init() {} + } + /// Creation of the rule is refused. Missing required parameter. + /// + /// - Remark: Generated from `#/paths//rules/post(createRule)/responses/400`. + /// + /// HTTP response code: `400 badRequest`. + case badRequest(Operations.createRule.Output.BadRequest) + /// Creation of the rule is refused. Missing required parameter. + /// + /// - Remark: Generated from `#/paths//rules/post(createRule)/responses/400`. + /// + /// HTTP response code: `400 badRequest`. + public static var badRequest: Self { + .badRequest(.init()) + } + /// The associated value of the enum case if `self` is `.badRequest`. + /// + /// - Throws: An error if `self` is not `.badRequest`. + /// - SeeAlso: `.badRequest`. + public var badRequest: Operations.createRule.Output.BadRequest { + get throws { + switch self { + case let .badRequest(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "badRequest", + response: self + ) + } + } + } + public struct Conflict: Sendable, Hashable { + /// Creates a new `Conflict`. + public init() {} + } + /// Creation of the rule is refused. Rule with the same UID already exists. + /// + /// - Remark: Generated from `#/paths//rules/post(createRule)/responses/409`. + /// + /// HTTP response code: `409 conflict`. + case conflict(Operations.createRule.Output.Conflict) + /// Creation of the rule is refused. Rule with the same UID already exists. + /// + /// - Remark: Generated from `#/paths//rules/post(createRule)/responses/409`. + /// + /// HTTP response code: `409 conflict`. + public static var conflict: Self { + .conflict(.init()) + } + /// The associated value of the enum case if `self` is `.conflict`. + /// + /// - Throws: An error if `self` is not `.conflict`. + /// - SeeAlso: `.conflict`. + public var conflict: Operations.createRule.Output.Conflict { + get throws { + switch self { + case let .conflict(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "conflict", + response: self + ) + } + } + } + /// Undocumented response. + /// + /// A response with a code that is not documented in the OpenAPI document. + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + } + /// Sets the rule enabled status. + /// + /// - Remark: HTTP `POST /rules/{ruleUID}/enable`. + /// - Remark: Generated from `#/paths//rules/{ruleUID}/enable/post(enableRule)`. + public enum enableRule { + public static let id: Swift.String = "enableRule" + public struct Input: Sendable, Hashable { + /// - Remark: Generated from `#/paths/rules/{ruleUID}/enable/POST/path`. + public struct Path: Sendable, Hashable { + /// ruleUID + /// + /// - Remark: Generated from `#/paths/rules/{ruleUID}/enable/POST/path/ruleUID`. + public var ruleUID: Swift.String + /// Creates a new `Path`. + /// + /// - Parameters: + /// - ruleUID: ruleUID + public init(ruleUID: Swift.String) { + self.ruleUID = ruleUID + } + } + public var path: Operations.enableRule.Input.Path + /// - Remark: Generated from `#/paths/rules/{ruleUID}/enable/POST/requestBody`. + @frozen public enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/rules/{ruleUID}/enable/POST/requestBody/content/text\/plain`. + case plainText(OpenAPIRuntime.HTTPBody) + } + public var body: Operations.enableRule.Input.Body + /// Creates a new `Input`. + /// + /// - Parameters: + /// - path: + /// - body: + public init( + path: Operations.enableRule.Input.Path, + body: Operations.enableRule.Input.Body + ) { + self.path = path + self.body = body + } + } + @frozen public enum Output: Sendable, Hashable { + public struct Ok: Sendable, Hashable { + /// Creates a new `Ok`. + public init() {} + } + /// OK + /// + /// - Remark: Generated from `#/paths//rules/{ruleUID}/enable/post(enableRule)/responses/200`. + /// + /// HTTP response code: `200 ok`. + case ok(Operations.enableRule.Output.Ok) + /// OK + /// + /// - Remark: Generated from `#/paths//rules/{ruleUID}/enable/post(enableRule)/responses/200`. + /// + /// HTTP response code: `200 ok`. + public static var ok: Self { + .ok(.init()) + } + /// The associated value of the enum case if `self` is `.ok`. + /// + /// - Throws: An error if `self` is not `.ok`. + /// - SeeAlso: `.ok`. + public var ok: Operations.enableRule.Output.Ok { + get throws { + switch self { + case let .ok(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "ok", + response: self + ) + } + } + } + public struct NotFound: Sendable, Hashable { + /// Creates a new `NotFound`. + public init() {} + } + /// Rule corresponding to the given UID does not found. + /// + /// - Remark: Generated from `#/paths//rules/{ruleUID}/enable/post(enableRule)/responses/404`. + /// + /// HTTP response code: `404 notFound`. + case notFound(Operations.enableRule.Output.NotFound) + /// Rule corresponding to the given UID does not found. + /// + /// - Remark: Generated from `#/paths//rules/{ruleUID}/enable/post(enableRule)/responses/404`. + /// + /// HTTP response code: `404 notFound`. + public static var notFound: Self { + .notFound(.init()) + } + /// The associated value of the enum case if `self` is `.notFound`. + /// + /// - Throws: An error if `self` is not `.notFound`. + /// - SeeAlso: `.notFound`. + public var notFound: Operations.enableRule.Output.NotFound { + get throws { + switch self { + case let .notFound(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "notFound", + response: self + ) + } + } + } + /// Undocumented response. + /// + /// A response with a code that is not documented in the OpenAPI document. + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + } + /// Gets the rule actions. + /// + /// - Remark: HTTP `GET /rules/{ruleUID}/actions`. + /// - Remark: Generated from `#/paths//rules/{ruleUID}/actions/get(getRuleActions)`. + public enum getRuleActions { + public static let id: Swift.String = "getRuleActions" + public struct Input: Sendable, Hashable { + /// - Remark: Generated from `#/paths/rules/{ruleUID}/actions/GET/path`. + public struct Path: Sendable, Hashable { + /// ruleUID + /// + /// - Remark: Generated from `#/paths/rules/{ruleUID}/actions/GET/path/ruleUID`. + public var ruleUID: Swift.String + /// Creates a new `Path`. + /// + /// - Parameters: + /// - ruleUID: ruleUID + public init(ruleUID: Swift.String) { + self.ruleUID = ruleUID + } + } + public var path: Operations.getRuleActions.Input.Path + /// - Remark: Generated from `#/paths/rules/{ruleUID}/actions/GET/header`. + public struct Headers: Sendable, Hashable { + public var accept: [OpenAPIRuntime.AcceptHeaderContentType] + /// Creates a new `Headers`. + /// + /// - Parameters: + /// - accept: + public init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + self.accept = accept + } + } + public var headers: Operations.getRuleActions.Input.Headers + /// Creates a new `Input`. + /// + /// - Parameters: + /// - path: + /// - headers: + public init( + path: Operations.getRuleActions.Input.Path, + headers: Operations.getRuleActions.Input.Headers = .init() + ) { + self.path = path + self.headers = headers + } + } + @frozen public enum Output: Sendable, Hashable { + public struct Ok: Sendable, Hashable { + /// - Remark: Generated from `#/paths/rules/{ruleUID}/actions/GET/responses/200/content`. + @frozen public enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/rules/{ruleUID}/actions/GET/responses/200/content/application\/json`. + case json([Components.Schemas.ActionDTO]) + /// The associated value of the enum case if `self` is `.json`. + /// + /// - Throws: An error if `self` is not `.json`. + /// - SeeAlso: `.json`. + public var json: [Components.Schemas.ActionDTO] { + get throws { + switch self { + case let .json(body): + return body + } + } + } + } + /// Received HTTP response body + public var body: Operations.getRuleActions.Output.Ok.Body + /// Creates a new `Ok`. + /// + /// - Parameters: + /// - body: Received HTTP response body + public init(body: Operations.getRuleActions.Output.Ok.Body) { + self.body = body + } + } + /// OK + /// + /// - Remark: Generated from `#/paths//rules/{ruleUID}/actions/get(getRuleActions)/responses/200`. + /// + /// HTTP response code: `200 ok`. + case ok(Operations.getRuleActions.Output.Ok) + /// The associated value of the enum case if `self` is `.ok`. + /// + /// - Throws: An error if `self` is not `.ok`. + /// - SeeAlso: `.ok`. + public var ok: Operations.getRuleActions.Output.Ok { + get throws { + switch self { + case let .ok(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "ok", + response: self + ) + } + } + } + public struct NotFound: Sendable, Hashable { + /// Creates a new `NotFound`. + public init() {} + } + /// Rule corresponding to the given UID does not found. + /// + /// - Remark: Generated from `#/paths//rules/{ruleUID}/actions/get(getRuleActions)/responses/404`. + /// + /// HTTP response code: `404 notFound`. + case notFound(Operations.getRuleActions.Output.NotFound) + /// Rule corresponding to the given UID does not found. + /// + /// - Remark: Generated from `#/paths//rules/{ruleUID}/actions/get(getRuleActions)/responses/404`. + /// + /// HTTP response code: `404 notFound`. + public static var notFound: Self { + .notFound(.init()) + } + /// The associated value of the enum case if `self` is `.notFound`. + /// + /// - Throws: An error if `self` is not `.notFound`. + /// - SeeAlso: `.notFound`. + public var notFound: Operations.getRuleActions.Output.NotFound { + get throws { + switch self { + case let .notFound(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "notFound", + response: self + ) + } + } + } + /// Undocumented response. + /// + /// A response with a code that is not documented in the OpenAPI document. + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + @frozen public enum AcceptableContentType: AcceptableProtocol { + case json + case other(Swift.String) + public init?(rawValue: Swift.String) { + switch rawValue.lowercased() { + case "application/json": + self = .json + default: + self = .other(rawValue) + } + } + public var rawValue: Swift.String { + switch self { + case let .other(string): + return string + case .json: + return "application/json" + } + } + public static var allCases: [Self] { + [ + .json + ] + } + } + } + /// Gets the rule corresponding to the given UID. + /// + /// - Remark: HTTP `GET /rules/{ruleUID}`. + /// - Remark: Generated from `#/paths//rules/{ruleUID}/get(getRuleById)`. + public enum getRuleById { + public static let id: Swift.String = "getRuleById" + public struct Input: Sendable, Hashable { + /// - Remark: Generated from `#/paths/rules/{ruleUID}/GET/path`. + public struct Path: Sendable, Hashable { + /// ruleUID + /// + /// - Remark: Generated from `#/paths/rules/{ruleUID}/GET/path/ruleUID`. + public var ruleUID: Swift.String + /// Creates a new `Path`. + /// + /// - Parameters: + /// - ruleUID: ruleUID + public init(ruleUID: Swift.String) { + self.ruleUID = ruleUID + } + } + public var path: Operations.getRuleById.Input.Path + /// - Remark: Generated from `#/paths/rules/{ruleUID}/GET/header`. + public struct Headers: Sendable, Hashable { + public var accept: [OpenAPIRuntime.AcceptHeaderContentType] + /// Creates a new `Headers`. + /// + /// - Parameters: + /// - accept: + public init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + self.accept = accept + } + } + public var headers: Operations.getRuleById.Input.Headers + /// Creates a new `Input`. + /// + /// - Parameters: + /// - path: + /// - headers: + public init( + path: Operations.getRuleById.Input.Path, + headers: Operations.getRuleById.Input.Headers = .init() + ) { + self.path = path + self.headers = headers + } + } + @frozen public enum Output: Sendable, Hashable { + public struct Ok: Sendable, Hashable { + /// - Remark: Generated from `#/paths/rules/{ruleUID}/GET/responses/200/content`. + @frozen public enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/rules/{ruleUID}/GET/responses/200/content/application\/json`. + case json(Components.Schemas.EnrichedRuleDTO) + /// The associated value of the enum case if `self` is `.json`. + /// + /// - Throws: An error if `self` is not `.json`. + /// - SeeAlso: `.json`. + public var json: Components.Schemas.EnrichedRuleDTO { + get throws { + switch self { + case let .json(body): + return body + } + } + } + } + /// Received HTTP response body + public var body: Operations.getRuleById.Output.Ok.Body + /// Creates a new `Ok`. + /// + /// - Parameters: + /// - body: Received HTTP response body + public init(body: Operations.getRuleById.Output.Ok.Body) { + self.body = body + } + } + /// OK + /// + /// - Remark: Generated from `#/paths//rules/{ruleUID}/get(getRuleById)/responses/200`. + /// + /// HTTP response code: `200 ok`. + case ok(Operations.getRuleById.Output.Ok) + /// The associated value of the enum case if `self` is `.ok`. + /// + /// - Throws: An error if `self` is not `.ok`. + /// - SeeAlso: `.ok`. + public var ok: Operations.getRuleById.Output.Ok { + get throws { + switch self { + case let .ok(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "ok", + response: self + ) + } + } + } + public struct NotFound: Sendable, Hashable { + /// Creates a new `NotFound`. + public init() {} + } + /// Rule not found + /// + /// - Remark: Generated from `#/paths//rules/{ruleUID}/get(getRuleById)/responses/404`. + /// + /// HTTP response code: `404 notFound`. + case notFound(Operations.getRuleById.Output.NotFound) + /// Rule not found + /// + /// - Remark: Generated from `#/paths//rules/{ruleUID}/get(getRuleById)/responses/404`. + /// + /// HTTP response code: `404 notFound`. + public static var notFound: Self { + .notFound(.init()) + } + /// The associated value of the enum case if `self` is `.notFound`. + /// + /// - Throws: An error if `self` is not `.notFound`. + /// - SeeAlso: `.notFound`. + public var notFound: Operations.getRuleById.Output.NotFound { + get throws { + switch self { + case let .notFound(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "notFound", + response: self + ) + } + } + } + /// Undocumented response. + /// + /// A response with a code that is not documented in the OpenAPI document. + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + @frozen public enum AcceptableContentType: AcceptableProtocol { + case json + case other(Swift.String) + public init?(rawValue: Swift.String) { + switch rawValue.lowercased() { + case "application/json": + self = .json + default: + self = .other(rawValue) + } + } + public var rawValue: Swift.String { + switch self { + case let .other(string): + return string + case .json: + return "application/json" + } + } + public static var allCases: [Self] { + [ + .json + ] + } + } + } + /// Updates an existing rule corresponding to the given UID. + /// + /// - Remark: HTTP `PUT /rules/{ruleUID}`. + /// - Remark: Generated from `#/paths//rules/{ruleUID}/put(updateRule)`. + public enum updateRule { + public static let id: Swift.String = "updateRule" + public struct Input: Sendable, Hashable { + /// - Remark: Generated from `#/paths/rules/{ruleUID}/PUT/path`. + public struct Path: Sendable, Hashable { + /// ruleUID + /// + /// - Remark: Generated from `#/paths/rules/{ruleUID}/PUT/path/ruleUID`. + public var ruleUID: Swift.String + /// Creates a new `Path`. + /// + /// - Parameters: + /// - ruleUID: ruleUID + public init(ruleUID: Swift.String) { + self.ruleUID = ruleUID + } + } + public var path: Operations.updateRule.Input.Path + /// - Remark: Generated from `#/paths/rules/{ruleUID}/PUT/requestBody`. + @frozen public enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/rules/{ruleUID}/PUT/requestBody/content/application\/json`. + case json(Components.Schemas.RuleDTO) + } + public var body: Operations.updateRule.Input.Body + /// Creates a new `Input`. + /// + /// - Parameters: + /// - path: + /// - body: + public init( + path: Operations.updateRule.Input.Path, + body: Operations.updateRule.Input.Body + ) { + self.path = path + self.body = body + } + } + @frozen public enum Output: Sendable, Hashable { + public struct Ok: Sendable, Hashable { + /// Creates a new `Ok`. + public init() {} + } + /// OK + /// + /// - Remark: Generated from `#/paths//rules/{ruleUID}/put(updateRule)/responses/200`. + /// + /// HTTP response code: `200 ok`. + case ok(Operations.updateRule.Output.Ok) + /// OK + /// + /// - Remark: Generated from `#/paths//rules/{ruleUID}/put(updateRule)/responses/200`. + /// + /// HTTP response code: `200 ok`. + public static var ok: Self { + .ok(.init()) + } + /// The associated value of the enum case if `self` is `.ok`. + /// + /// - Throws: An error if `self` is not `.ok`. + /// - SeeAlso: `.ok`. + public var ok: Operations.updateRule.Output.Ok { + get throws { + switch self { + case let .ok(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "ok", + response: self + ) + } + } + } + public struct NotFound: Sendable, Hashable { + /// Creates a new `NotFound`. + public init() {} + } + /// Rule corresponding to the given UID does not found. + /// + /// - Remark: Generated from `#/paths//rules/{ruleUID}/put(updateRule)/responses/404`. + /// + /// HTTP response code: `404 notFound`. + case notFound(Operations.updateRule.Output.NotFound) + /// Rule corresponding to the given UID does not found. + /// + /// - Remark: Generated from `#/paths//rules/{ruleUID}/put(updateRule)/responses/404`. + /// + /// HTTP response code: `404 notFound`. + public static var notFound: Self { + .notFound(.init()) + } + /// The associated value of the enum case if `self` is `.notFound`. + /// + /// - Throws: An error if `self` is not `.notFound`. + /// - SeeAlso: `.notFound`. + public var notFound: Operations.updateRule.Output.NotFound { + get throws { + switch self { + case let .notFound(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "notFound", + response: self + ) + } + } + } + /// Undocumented response. + /// + /// A response with a code that is not documented in the OpenAPI document. + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + } + /// Removes an existing rule corresponding to the given UID. + /// + /// - Remark: HTTP `DELETE /rules/{ruleUID}`. + /// - Remark: Generated from `#/paths//rules/{ruleUID}/delete(deleteRule)`. + public enum deleteRule { + public static let id: Swift.String = "deleteRule" + public struct Input: Sendable, Hashable { + /// - Remark: Generated from `#/paths/rules/{ruleUID}/DELETE/path`. + public struct Path: Sendable, Hashable { + /// ruleUID + /// + /// - Remark: Generated from `#/paths/rules/{ruleUID}/DELETE/path/ruleUID`. + public var ruleUID: Swift.String + /// Creates a new `Path`. + /// + /// - Parameters: + /// - ruleUID: ruleUID + public init(ruleUID: Swift.String) { + self.ruleUID = ruleUID + } + } + public var path: Operations.deleteRule.Input.Path + /// Creates a new `Input`. + /// + /// - Parameters: + /// - path: + public init(path: Operations.deleteRule.Input.Path) { + self.path = path + } + } + @frozen public enum Output: Sendable, Hashable { + public struct Ok: Sendable, Hashable { + /// Creates a new `Ok`. + public init() {} + } + /// OK + /// + /// - Remark: Generated from `#/paths//rules/{ruleUID}/delete(deleteRule)/responses/200`. + /// + /// HTTP response code: `200 ok`. + case ok(Operations.deleteRule.Output.Ok) + /// OK + /// + /// - Remark: Generated from `#/paths//rules/{ruleUID}/delete(deleteRule)/responses/200`. + /// + /// HTTP response code: `200 ok`. + public static var ok: Self { + .ok(.init()) + } + /// The associated value of the enum case if `self` is `.ok`. + /// + /// - Throws: An error if `self` is not `.ok`. + /// - SeeAlso: `.ok`. + public var ok: Operations.deleteRule.Output.Ok { + get throws { + switch self { + case let .ok(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "ok", + response: self + ) + } + } + } + public struct NotFound: Sendable, Hashable { + /// Creates a new `NotFound`. + public init() {} + } + /// Rule corresponding to the given UID does not found. + /// + /// - Remark: Generated from `#/paths//rules/{ruleUID}/delete(deleteRule)/responses/404`. + /// + /// HTTP response code: `404 notFound`. + case notFound(Operations.deleteRule.Output.NotFound) + /// Rule corresponding to the given UID does not found. + /// + /// - Remark: Generated from `#/paths//rules/{ruleUID}/delete(deleteRule)/responses/404`. + /// + /// HTTP response code: `404 notFound`. + public static var notFound: Self { + .notFound(.init()) + } + /// The associated value of the enum case if `self` is `.notFound`. + /// + /// - Throws: An error if `self` is not `.notFound`. + /// - SeeAlso: `.notFound`. + public var notFound: Operations.deleteRule.Output.NotFound { + get throws { + switch self { + case let .notFound(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "notFound", + response: self + ) + } + } + } + /// Undocumented response. + /// + /// A response with a code that is not documented in the OpenAPI document. + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + } + /// Gets the rule conditions. + /// + /// - Remark: HTTP `GET /rules/{ruleUID}/conditions`. + /// - Remark: Generated from `#/paths//rules/{ruleUID}/conditions/get(getRuleConditions)`. + public enum getRuleConditions { + public static let id: Swift.String = "getRuleConditions" + public struct Input: Sendable, Hashable { + /// - Remark: Generated from `#/paths/rules/{ruleUID}/conditions/GET/path`. + public struct Path: Sendable, Hashable { + /// ruleUID + /// + /// - Remark: Generated from `#/paths/rules/{ruleUID}/conditions/GET/path/ruleUID`. + public var ruleUID: Swift.String + /// Creates a new `Path`. + /// + /// - Parameters: + /// - ruleUID: ruleUID + public init(ruleUID: Swift.String) { + self.ruleUID = ruleUID + } + } + public var path: Operations.getRuleConditions.Input.Path + /// - Remark: Generated from `#/paths/rules/{ruleUID}/conditions/GET/header`. + public struct Headers: Sendable, Hashable { + public var accept: [OpenAPIRuntime.AcceptHeaderContentType] + /// Creates a new `Headers`. + /// + /// - Parameters: + /// - accept: + public init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + self.accept = accept + } + } + public var headers: Operations.getRuleConditions.Input.Headers + /// Creates a new `Input`. + /// + /// - Parameters: + /// - path: + /// - headers: + public init( + path: Operations.getRuleConditions.Input.Path, + headers: Operations.getRuleConditions.Input.Headers = .init() + ) { + self.path = path + self.headers = headers + } + } + @frozen public enum Output: Sendable, Hashable { + public struct Ok: Sendable, Hashable { + /// - Remark: Generated from `#/paths/rules/{ruleUID}/conditions/GET/responses/200/content`. + @frozen public enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/rules/{ruleUID}/conditions/GET/responses/200/content/application\/json`. + case json([Components.Schemas.ConditionDTO]) + /// The associated value of the enum case if `self` is `.json`. + /// + /// - Throws: An error if `self` is not `.json`. + /// - SeeAlso: `.json`. + public var json: [Components.Schemas.ConditionDTO] { + get throws { + switch self { + case let .json(body): + return body + } + } + } + } + /// Received HTTP response body + public var body: Operations.getRuleConditions.Output.Ok.Body + /// Creates a new `Ok`. + /// + /// - Parameters: + /// - body: Received HTTP response body + public init(body: Operations.getRuleConditions.Output.Ok.Body) { + self.body = body + } + } + /// OK + /// + /// - Remark: Generated from `#/paths//rules/{ruleUID}/conditions/get(getRuleConditions)/responses/200`. + /// + /// HTTP response code: `200 ok`. + case ok(Operations.getRuleConditions.Output.Ok) + /// The associated value of the enum case if `self` is `.ok`. + /// + /// - Throws: An error if `self` is not `.ok`. + /// - SeeAlso: `.ok`. + public var ok: Operations.getRuleConditions.Output.Ok { + get throws { + switch self { + case let .ok(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "ok", + response: self + ) + } + } + } + public struct NotFound: Sendable, Hashable { + /// Creates a new `NotFound`. + public init() {} + } + /// Rule corresponding to the given UID does not found. + /// + /// - Remark: Generated from `#/paths//rules/{ruleUID}/conditions/get(getRuleConditions)/responses/404`. + /// + /// HTTP response code: `404 notFound`. + case notFound(Operations.getRuleConditions.Output.NotFound) + /// Rule corresponding to the given UID does not found. + /// + /// - Remark: Generated from `#/paths//rules/{ruleUID}/conditions/get(getRuleConditions)/responses/404`. + /// + /// HTTP response code: `404 notFound`. + public static var notFound: Self { + .notFound(.init()) + } + /// The associated value of the enum case if `self` is `.notFound`. + /// + /// - Throws: An error if `self` is not `.notFound`. + /// - SeeAlso: `.notFound`. + public var notFound: Operations.getRuleConditions.Output.NotFound { + get throws { + switch self { + case let .notFound(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "notFound", + response: self + ) + } + } + } + /// Undocumented response. + /// + /// A response with a code that is not documented in the OpenAPI document. + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + @frozen public enum AcceptableContentType: AcceptableProtocol { + case json + case other(Swift.String) + public init?(rawValue: Swift.String) { + switch rawValue.lowercased() { + case "application/json": + self = .json + default: + self = .other(rawValue) + } + } + public var rawValue: Swift.String { + switch self { + case let .other(string): + return string + case .json: + return "application/json" + } + } + public static var allCases: [Self] { + [ + .json + ] + } + } + } + /// Gets the rule configuration values. + /// + /// - Remark: HTTP `GET /rules/{ruleUID}/config`. + /// - Remark: Generated from `#/paths//rules/{ruleUID}/config/get(getRuleConfiguration)`. + public enum getRuleConfiguration { + public static let id: Swift.String = "getRuleConfiguration" + public struct Input: Sendable, Hashable { + /// - Remark: Generated from `#/paths/rules/{ruleUID}/config/GET/path`. + public struct Path: Sendable, Hashable { + /// ruleUID + /// + /// - Remark: Generated from `#/paths/rules/{ruleUID}/config/GET/path/ruleUID`. + public var ruleUID: Swift.String + /// Creates a new `Path`. + /// + /// - Parameters: + /// - ruleUID: ruleUID + public init(ruleUID: Swift.String) { + self.ruleUID = ruleUID + } + } + public var path: Operations.getRuleConfiguration.Input.Path + /// - Remark: Generated from `#/paths/rules/{ruleUID}/config/GET/header`. + public struct Headers: Sendable, Hashable { + public var accept: [OpenAPIRuntime.AcceptHeaderContentType] + /// Creates a new `Headers`. + /// + /// - Parameters: + /// - accept: + public init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + self.accept = accept + } + } + public var headers: Operations.getRuleConfiguration.Input.Headers + /// Creates a new `Input`. + /// + /// - Parameters: + /// - path: + /// - headers: + public init( + path: Operations.getRuleConfiguration.Input.Path, + headers: Operations.getRuleConfiguration.Input.Headers = .init() + ) { + self.path = path + self.headers = headers + } + } + @frozen public enum Output: Sendable, Hashable { + public struct Ok: Sendable, Hashable { + /// - Remark: Generated from `#/paths/rules/{ruleUID}/config/GET/responses/200/content`. + @frozen public enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/rules/{ruleUID}/config/GET/responses/200/content/application\/json`. + case json(Swift.String) + /// The associated value of the enum case if `self` is `.json`. + /// + /// - Throws: An error if `self` is not `.json`. + /// - SeeAlso: `.json`. + public var json: Swift.String { + get throws { + switch self { + case let .json(body): + return body + } + } + } + } + /// Received HTTP response body + public var body: Operations.getRuleConfiguration.Output.Ok.Body + /// Creates a new `Ok`. + /// + /// - Parameters: + /// - body: Received HTTP response body + public init(body: Operations.getRuleConfiguration.Output.Ok.Body) { + self.body = body + } + } + /// OK + /// + /// - Remark: Generated from `#/paths//rules/{ruleUID}/config/get(getRuleConfiguration)/responses/200`. + /// + /// HTTP response code: `200 ok`. + case ok(Operations.getRuleConfiguration.Output.Ok) + /// The associated value of the enum case if `self` is `.ok`. + /// + /// - Throws: An error if `self` is not `.ok`. + /// - SeeAlso: `.ok`. + public var ok: Operations.getRuleConfiguration.Output.Ok { + get throws { + switch self { + case let .ok(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "ok", + response: self + ) + } + } + } + public struct NotFound: Sendable, Hashable { + /// Creates a new `NotFound`. + public init() {} + } + /// Rule corresponding to the given UID does not found. + /// + /// - Remark: Generated from `#/paths//rules/{ruleUID}/config/get(getRuleConfiguration)/responses/404`. + /// + /// HTTP response code: `404 notFound`. + case notFound(Operations.getRuleConfiguration.Output.NotFound) + /// Rule corresponding to the given UID does not found. + /// + /// - Remark: Generated from `#/paths//rules/{ruleUID}/config/get(getRuleConfiguration)/responses/404`. + /// + /// HTTP response code: `404 notFound`. + public static var notFound: Self { + .notFound(.init()) + } + /// The associated value of the enum case if `self` is `.notFound`. + /// + /// - Throws: An error if `self` is not `.notFound`. + /// - SeeAlso: `.notFound`. + public var notFound: Operations.getRuleConfiguration.Output.NotFound { + get throws { + switch self { + case let .notFound(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "notFound", + response: self + ) + } + } + } + /// Undocumented response. + /// + /// A response with a code that is not documented in the OpenAPI document. + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + @frozen public enum AcceptableContentType: AcceptableProtocol { + case json + case other(Swift.String) + public init?(rawValue: Swift.String) { + switch rawValue.lowercased() { + case "application/json": + self = .json + default: + self = .other(rawValue) + } + } + public var rawValue: Swift.String { + switch self { + case let .other(string): + return string + case .json: + return "application/json" + } + } + public static var allCases: [Self] { + [ + .json + ] + } + } + } + /// Sets the rule configuration values. + /// + /// - Remark: HTTP `PUT /rules/{ruleUID}/config`. + /// - Remark: Generated from `#/paths//rules/{ruleUID}/config/put(updateRuleConfiguration)`. + public enum updateRuleConfiguration { + public static let id: Swift.String = "updateRuleConfiguration" + public struct Input: Sendable, Hashable { + /// - Remark: Generated from `#/paths/rules/{ruleUID}/config/PUT/path`. + public struct Path: Sendable, Hashable { + /// ruleUID + /// + /// - Remark: Generated from `#/paths/rules/{ruleUID}/config/PUT/path/ruleUID`. + public var ruleUID: Swift.String + /// Creates a new `Path`. + /// + /// - Parameters: + /// - ruleUID: ruleUID + public init(ruleUID: Swift.String) { + self.ruleUID = ruleUID + } + } + public var path: Operations.updateRuleConfiguration.Input.Path + /// - Remark: Generated from `#/paths/rules/{ruleUID}/config/PUT/requestBody`. + @frozen public enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/rules/{ruleUID}/config/PUT/requestBody/json`. + public struct jsonPayload: Codable, Hashable, Sendable { + /// A container of undocumented properties. + public var additionalProperties: [String: OpenAPIRuntime.OpenAPIObjectContainer] + /// Creates a new `jsonPayload`. + /// + /// - Parameters: + /// - additionalProperties: A container of undocumented properties. + public init(additionalProperties: [String: OpenAPIRuntime.OpenAPIObjectContainer] = .init()) { + self.additionalProperties = additionalProperties + } + public init(from decoder: any Decoder) throws { + additionalProperties = try decoder.decodeAdditionalProperties(knownKeys: []) + } + public func encode(to encoder: any Encoder) throws { + try encoder.encodeAdditionalProperties(additionalProperties) + } + } + /// - Remark: Generated from `#/paths/rules/{ruleUID}/config/PUT/requestBody/content/application\/json`. + case json(Operations.updateRuleConfiguration.Input.Body.jsonPayload) + } + public var body: Operations.updateRuleConfiguration.Input.Body? + /// Creates a new `Input`. + /// + /// - Parameters: + /// - path: + /// - body: + public init( + path: Operations.updateRuleConfiguration.Input.Path, + body: Operations.updateRuleConfiguration.Input.Body? = nil + ) { + self.path = path + self.body = body + } + } + @frozen public enum Output: Sendable, Hashable { + public struct Ok: Sendable, Hashable { + /// Creates a new `Ok`. + public init() {} + } + /// OK + /// + /// - Remark: Generated from `#/paths//rules/{ruleUID}/config/put(updateRuleConfiguration)/responses/200`. + /// + /// HTTP response code: `200 ok`. + case ok(Operations.updateRuleConfiguration.Output.Ok) + /// OK + /// + /// - Remark: Generated from `#/paths//rules/{ruleUID}/config/put(updateRuleConfiguration)/responses/200`. + /// + /// HTTP response code: `200 ok`. + public static var ok: Self { + .ok(.init()) + } + /// The associated value of the enum case if `self` is `.ok`. + /// + /// - Throws: An error if `self` is not `.ok`. + /// - SeeAlso: `.ok`. + public var ok: Operations.updateRuleConfiguration.Output.Ok { + get throws { + switch self { + case let .ok(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "ok", + response: self + ) + } + } + } + public struct NotFound: Sendable, Hashable { + /// Creates a new `NotFound`. + public init() {} + } + /// Rule corresponding to the given UID does not found. + /// + /// - Remark: Generated from `#/paths//rules/{ruleUID}/config/put(updateRuleConfiguration)/responses/404`. + /// + /// HTTP response code: `404 notFound`. + case notFound(Operations.updateRuleConfiguration.Output.NotFound) + /// Rule corresponding to the given UID does not found. + /// + /// - Remark: Generated from `#/paths//rules/{ruleUID}/config/put(updateRuleConfiguration)/responses/404`. + /// + /// HTTP response code: `404 notFound`. + public static var notFound: Self { + .notFound(.init()) + } + /// The associated value of the enum case if `self` is `.notFound`. + /// + /// - Throws: An error if `self` is not `.notFound`. + /// - SeeAlso: `.notFound`. + public var notFound: Operations.updateRuleConfiguration.Output.NotFound { + get throws { + switch self { + case let .notFound(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "notFound", + response: self + ) + } + } + } + /// Undocumented response. + /// + /// A response with a code that is not documented in the OpenAPI document. + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + } + /// Gets the rule's module corresponding to the given Category and ID. + /// + /// - Remark: HTTP `GET /rules/{ruleUID}/{moduleCategory}/{id}`. + /// - Remark: Generated from `#/paths//rules/{ruleUID}/{moduleCategory}/{id}/get(getRuleModuleById)`. + public enum getRuleModuleById { + public static let id: Swift.String = "getRuleModuleById" + public struct Input: Sendable, Hashable { + /// - Remark: Generated from `#/paths/rules/{ruleUID}/{moduleCategory}/{id}/GET/path`. + public struct Path: Sendable, Hashable { + /// ruleUID + /// + /// - Remark: Generated from `#/paths/rules/{ruleUID}/{moduleCategory}/{id}/GET/path/ruleUID`. + public var ruleUID: Swift.String + /// moduleCategory + /// + /// - Remark: Generated from `#/paths/rules/{ruleUID}/{moduleCategory}/{id}/GET/path/moduleCategory`. + public var moduleCategory: Swift.String + /// id + /// + /// - Remark: Generated from `#/paths/rules/{ruleUID}/{moduleCategory}/{id}/GET/path/id`. + public var id: Swift.String + /// Creates a new `Path`. + /// + /// - Parameters: + /// - ruleUID: ruleUID + /// - moduleCategory: moduleCategory + /// - id: id + public init( + ruleUID: Swift.String, + moduleCategory: Swift.String, + id: Swift.String + ) { + self.ruleUID = ruleUID + self.moduleCategory = moduleCategory + self.id = id + } + } + public var path: Operations.getRuleModuleById.Input.Path + /// - Remark: Generated from `#/paths/rules/{ruleUID}/{moduleCategory}/{id}/GET/header`. + public struct Headers: Sendable, Hashable { + public var accept: [OpenAPIRuntime.AcceptHeaderContentType] + /// Creates a new `Headers`. + /// + /// - Parameters: + /// - accept: + public init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + self.accept = accept + } + } + public var headers: Operations.getRuleModuleById.Input.Headers + /// Creates a new `Input`. + /// + /// - Parameters: + /// - path: + /// - headers: + public init( + path: Operations.getRuleModuleById.Input.Path, + headers: Operations.getRuleModuleById.Input.Headers = .init() + ) { + self.path = path + self.headers = headers + } + } + @frozen public enum Output: Sendable, Hashable { + public struct Ok: Sendable, Hashable { + /// - Remark: Generated from `#/paths/rules/{ruleUID}/{moduleCategory}/{id}/GET/responses/200/content`. + @frozen public enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/rules/{ruleUID}/{moduleCategory}/{id}/GET/responses/200/content/application\/json`. + case json(Components.Schemas.ModuleDTO) + /// The associated value of the enum case if `self` is `.json`. + /// + /// - Throws: An error if `self` is not `.json`. + /// - SeeAlso: `.json`. + public var json: Components.Schemas.ModuleDTO { + get throws { + switch self { + case let .json(body): + return body + } + } + } + } + /// Received HTTP response body + public var body: Operations.getRuleModuleById.Output.Ok.Body + /// Creates a new `Ok`. + /// + /// - Parameters: + /// - body: Received HTTP response body + public init(body: Operations.getRuleModuleById.Output.Ok.Body) { + self.body = body + } + } + /// OK + /// + /// - Remark: Generated from `#/paths//rules/{ruleUID}/{moduleCategory}/{id}/get(getRuleModuleById)/responses/200`. + /// + /// HTTP response code: `200 ok`. + case ok(Operations.getRuleModuleById.Output.Ok) + /// The associated value of the enum case if `self` is `.ok`. + /// + /// - Throws: An error if `self` is not `.ok`. + /// - SeeAlso: `.ok`. + public var ok: Operations.getRuleModuleById.Output.Ok { + get throws { + switch self { + case let .ok(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "ok", + response: self + ) + } + } + } + public struct NotFound: Sendable, Hashable { + /// Creates a new `NotFound`. + public init() {} + } + /// Rule corresponding to the given UID does not found or does not have a module with such Category and ID. + /// + /// - Remark: Generated from `#/paths//rules/{ruleUID}/{moduleCategory}/{id}/get(getRuleModuleById)/responses/404`. + /// + /// HTTP response code: `404 notFound`. + case notFound(Operations.getRuleModuleById.Output.NotFound) + /// Rule corresponding to the given UID does not found or does not have a module with such Category and ID. + /// + /// - Remark: Generated from `#/paths//rules/{ruleUID}/{moduleCategory}/{id}/get(getRuleModuleById)/responses/404`. + /// + /// HTTP response code: `404 notFound`. + public static var notFound: Self { + .notFound(.init()) + } + /// The associated value of the enum case if `self` is `.notFound`. + /// + /// - Throws: An error if `self` is not `.notFound`. + /// - SeeAlso: `.notFound`. + public var notFound: Operations.getRuleModuleById.Output.NotFound { + get throws { + switch self { + case let .notFound(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "notFound", + response: self + ) + } + } + } + /// Undocumented response. + /// + /// A response with a code that is not documented in the OpenAPI document. + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + @frozen public enum AcceptableContentType: AcceptableProtocol { + case json + case other(Swift.String) + public init?(rawValue: Swift.String) { + switch rawValue.lowercased() { + case "application/json": + self = .json + default: + self = .other(rawValue) + } + } + public var rawValue: Swift.String { + switch self { + case let .other(string): + return string + case .json: + return "application/json" + } + } + public static var allCases: [Self] { + [ + .json + ] + } + } + } + /// Gets the module's configuration. + /// + /// - Remark: HTTP `GET /rules/{ruleUID}/{moduleCategory}/{id}/config`. + /// - Remark: Generated from `#/paths//rules/{ruleUID}/{moduleCategory}/{id}/config/get(getRuleModuleConfig)`. + public enum getRuleModuleConfig { + public static let id: Swift.String = "getRuleModuleConfig" + public struct Input: Sendable, Hashable { + /// - Remark: Generated from `#/paths/rules/{ruleUID}/{moduleCategory}/{id}/config/GET/path`. + public struct Path: Sendable, Hashable { + /// ruleUID + /// + /// - Remark: Generated from `#/paths/rules/{ruleUID}/{moduleCategory}/{id}/config/GET/path/ruleUID`. + public var ruleUID: Swift.String + /// moduleCategory + /// + /// - Remark: Generated from `#/paths/rules/{ruleUID}/{moduleCategory}/{id}/config/GET/path/moduleCategory`. + public var moduleCategory: Swift.String + /// id + /// + /// - Remark: Generated from `#/paths/rules/{ruleUID}/{moduleCategory}/{id}/config/GET/path/id`. + public var id: Swift.String + /// Creates a new `Path`. + /// + /// - Parameters: + /// - ruleUID: ruleUID + /// - moduleCategory: moduleCategory + /// - id: id + public init( + ruleUID: Swift.String, + moduleCategory: Swift.String, + id: Swift.String + ) { + self.ruleUID = ruleUID + self.moduleCategory = moduleCategory + self.id = id + } + } + public var path: Operations.getRuleModuleConfig.Input.Path + /// - Remark: Generated from `#/paths/rules/{ruleUID}/{moduleCategory}/{id}/config/GET/header`. + public struct Headers: Sendable, Hashable { + public var accept: [OpenAPIRuntime.AcceptHeaderContentType] + /// Creates a new `Headers`. + /// + /// - Parameters: + /// - accept: + public init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + self.accept = accept + } + } + public var headers: Operations.getRuleModuleConfig.Input.Headers + /// Creates a new `Input`. + /// + /// - Parameters: + /// - path: + /// - headers: + public init( + path: Operations.getRuleModuleConfig.Input.Path, + headers: Operations.getRuleModuleConfig.Input.Headers = .init() + ) { + self.path = path + self.headers = headers + } + } + @frozen public enum Output: Sendable, Hashable { + public struct Ok: Sendable, Hashable { + /// - Remark: Generated from `#/paths/rules/{ruleUID}/{moduleCategory}/{id}/config/GET/responses/200/content`. + @frozen public enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/rules/{ruleUID}/{moduleCategory}/{id}/config/GET/responses/200/content/application\/json`. + case json(Swift.String) + /// The associated value of the enum case if `self` is `.json`. + /// + /// - Throws: An error if `self` is not `.json`. + /// - SeeAlso: `.json`. + public var json: Swift.String { + get throws { + switch self { + case let .json(body): + return body + } + } + } + } + /// Received HTTP response body + public var body: Operations.getRuleModuleConfig.Output.Ok.Body + /// Creates a new `Ok`. + /// + /// - Parameters: + /// - body: Received HTTP response body + public init(body: Operations.getRuleModuleConfig.Output.Ok.Body) { + self.body = body + } + } + /// OK + /// + /// - Remark: Generated from `#/paths//rules/{ruleUID}/{moduleCategory}/{id}/config/get(getRuleModuleConfig)/responses/200`. + /// + /// HTTP response code: `200 ok`. + case ok(Operations.getRuleModuleConfig.Output.Ok) + /// The associated value of the enum case if `self` is `.ok`. + /// + /// - Throws: An error if `self` is not `.ok`. + /// - SeeAlso: `.ok`. + public var ok: Operations.getRuleModuleConfig.Output.Ok { + get throws { + switch self { + case let .ok(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "ok", + response: self + ) + } + } + } + public struct NotFound: Sendable, Hashable { + /// Creates a new `NotFound`. + public init() {} + } + /// Rule corresponding to the given UID does not found or does not have a module with such Category and ID. + /// + /// - Remark: Generated from `#/paths//rules/{ruleUID}/{moduleCategory}/{id}/config/get(getRuleModuleConfig)/responses/404`. + /// + /// HTTP response code: `404 notFound`. + case notFound(Operations.getRuleModuleConfig.Output.NotFound) + /// Rule corresponding to the given UID does not found or does not have a module with such Category and ID. + /// + /// - Remark: Generated from `#/paths//rules/{ruleUID}/{moduleCategory}/{id}/config/get(getRuleModuleConfig)/responses/404`. + /// + /// HTTP response code: `404 notFound`. + public static var notFound: Self { + .notFound(.init()) + } + /// The associated value of the enum case if `self` is `.notFound`. + /// + /// - Throws: An error if `self` is not `.notFound`. + /// - SeeAlso: `.notFound`. + public var notFound: Operations.getRuleModuleConfig.Output.NotFound { + get throws { + switch self { + case let .notFound(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "notFound", + response: self + ) + } + } + } + /// Undocumented response. + /// + /// A response with a code that is not documented in the OpenAPI document. + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + @frozen public enum AcceptableContentType: AcceptableProtocol { + case json + case other(Swift.String) + public init?(rawValue: Swift.String) { + switch rawValue.lowercased() { + case "application/json": + self = .json + default: + self = .other(rawValue) + } + } + public var rawValue: Swift.String { + switch self { + case let .other(string): + return string + case .json: + return "application/json" + } + } + public static var allCases: [Self] { + [ + .json + ] + } + } + } + /// Gets the module's configuration parameter. + /// + /// - Remark: HTTP `GET /rules/{ruleUID}/{moduleCategory}/{id}/config/{param}`. + /// - Remark: Generated from `#/paths//rules/{ruleUID}/{moduleCategory}/{id}/config/{param}/get(getRuleModuleConfigParameter)`. + public enum getRuleModuleConfigParameter { + public static let id: Swift.String = "getRuleModuleConfigParameter" + public struct Input: Sendable, Hashable { + /// - Remark: Generated from `#/paths/rules/{ruleUID}/{moduleCategory}/{id}/config/{param}/GET/path`. + public struct Path: Sendable, Hashable { + /// ruleUID + /// + /// - Remark: Generated from `#/paths/rules/{ruleUID}/{moduleCategory}/{id}/config/{param}/GET/path/ruleUID`. + public var ruleUID: Swift.String + /// moduleCategory + /// + /// - Remark: Generated from `#/paths/rules/{ruleUID}/{moduleCategory}/{id}/config/{param}/GET/path/moduleCategory`. + public var moduleCategory: Swift.String + /// id + /// + /// - Remark: Generated from `#/paths/rules/{ruleUID}/{moduleCategory}/{id}/config/{param}/GET/path/id`. + public var id: Swift.String + /// param + /// + /// - Remark: Generated from `#/paths/rules/{ruleUID}/{moduleCategory}/{id}/config/{param}/GET/path/param`. + public var param: Swift.String + /// Creates a new `Path`. + /// + /// - Parameters: + /// - ruleUID: ruleUID + /// - moduleCategory: moduleCategory + /// - id: id + /// - param: param + public init( + ruleUID: Swift.String, + moduleCategory: Swift.String, + id: Swift.String, + param: Swift.String + ) { + self.ruleUID = ruleUID + self.moduleCategory = moduleCategory + self.id = id + self.param = param + } + } + public var path: Operations.getRuleModuleConfigParameter.Input.Path + /// - Remark: Generated from `#/paths/rules/{ruleUID}/{moduleCategory}/{id}/config/{param}/GET/header`. + public struct Headers: Sendable, Hashable { + public var accept: [OpenAPIRuntime.AcceptHeaderContentType] + /// Creates a new `Headers`. + /// + /// - Parameters: + /// - accept: + public init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + self.accept = accept + } + } + public var headers: Operations.getRuleModuleConfigParameter.Input.Headers + /// Creates a new `Input`. + /// + /// - Parameters: + /// - path: + /// - headers: + public init( + path: Operations.getRuleModuleConfigParameter.Input.Path, + headers: Operations.getRuleModuleConfigParameter.Input.Headers = .init() + ) { + self.path = path + self.headers = headers + } + } + @frozen public enum Output: Sendable, Hashable { + public struct Ok: Sendable, Hashable { + /// - Remark: Generated from `#/paths/rules/{ruleUID}/{moduleCategory}/{id}/config/{param}/GET/responses/200/content`. + @frozen public enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/rules/{ruleUID}/{moduleCategory}/{id}/config/{param}/GET/responses/200/content/text\/plain`. + case plainText(OpenAPIRuntime.HTTPBody) + /// The associated value of the enum case if `self` is `.plainText`. + /// + /// - Throws: An error if `self` is not `.plainText`. + /// - SeeAlso: `.plainText`. + public var plainText: OpenAPIRuntime.HTTPBody { + get throws { + switch self { + case let .plainText(body): + return body + } + } + } + } + /// Received HTTP response body + public var body: Operations.getRuleModuleConfigParameter.Output.Ok.Body + /// Creates a new `Ok`. + /// + /// - Parameters: + /// - body: Received HTTP response body + public init(body: Operations.getRuleModuleConfigParameter.Output.Ok.Body) { + self.body = body + } + } + /// OK + /// + /// - Remark: Generated from `#/paths//rules/{ruleUID}/{moduleCategory}/{id}/config/{param}/get(getRuleModuleConfigParameter)/responses/200`. + /// + /// HTTP response code: `200 ok`. + case ok(Operations.getRuleModuleConfigParameter.Output.Ok) + /// The associated value of the enum case if `self` is `.ok`. + /// + /// - Throws: An error if `self` is not `.ok`. + /// - SeeAlso: `.ok`. + public var ok: Operations.getRuleModuleConfigParameter.Output.Ok { + get throws { + switch self { + case let .ok(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "ok", + response: self + ) + } + } + } + public struct NotFound: Sendable, Hashable { + /// Creates a new `NotFound`. + public init() {} + } + /// Rule corresponding to the given UID does not found or does not have a module with such Category and ID. + /// + /// - Remark: Generated from `#/paths//rules/{ruleUID}/{moduleCategory}/{id}/config/{param}/get(getRuleModuleConfigParameter)/responses/404`. + /// + /// HTTP response code: `404 notFound`. + case notFound(Operations.getRuleModuleConfigParameter.Output.NotFound) + /// Rule corresponding to the given UID does not found or does not have a module with such Category and ID. + /// + /// - Remark: Generated from `#/paths//rules/{ruleUID}/{moduleCategory}/{id}/config/{param}/get(getRuleModuleConfigParameter)/responses/404`. + /// + /// HTTP response code: `404 notFound`. + public static var notFound: Self { + .notFound(.init()) + } + /// The associated value of the enum case if `self` is `.notFound`. + /// + /// - Throws: An error if `self` is not `.notFound`. + /// - SeeAlso: `.notFound`. + public var notFound: Operations.getRuleModuleConfigParameter.Output.NotFound { + get throws { + switch self { + case let .notFound(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "notFound", + response: self + ) + } + } + } + /// Undocumented response. + /// + /// A response with a code that is not documented in the OpenAPI document. + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + @frozen public enum AcceptableContentType: AcceptableProtocol { + case plainText + case other(Swift.String) + public init?(rawValue: Swift.String) { + switch rawValue.lowercased() { + case "text/plain": + self = .plainText + default: + self = .other(rawValue) + } + } + public var rawValue: Swift.String { + switch self { + case let .other(string): + return string + case .plainText: + return "text/plain" + } + } + public static var allCases: [Self] { + [ + .plainText + ] + } + } + } + /// Sets the module's configuration parameter value. + /// + /// - Remark: HTTP `PUT /rules/{ruleUID}/{moduleCategory}/{id}/config/{param}`. + /// - Remark: Generated from `#/paths//rules/{ruleUID}/{moduleCategory}/{id}/config/{param}/put(setRuleModuleConfigParameter)`. + public enum setRuleModuleConfigParameter { + public static let id: Swift.String = "setRuleModuleConfigParameter" + public struct Input: Sendable, Hashable { + /// - Remark: Generated from `#/paths/rules/{ruleUID}/{moduleCategory}/{id}/config/{param}/PUT/path`. + public struct Path: Sendable, Hashable { + /// ruleUID + /// + /// - Remark: Generated from `#/paths/rules/{ruleUID}/{moduleCategory}/{id}/config/{param}/PUT/path/ruleUID`. + public var ruleUID: Swift.String + /// moduleCategory + /// + /// - Remark: Generated from `#/paths/rules/{ruleUID}/{moduleCategory}/{id}/config/{param}/PUT/path/moduleCategory`. + public var moduleCategory: Swift.String + /// id + /// + /// - Remark: Generated from `#/paths/rules/{ruleUID}/{moduleCategory}/{id}/config/{param}/PUT/path/id`. + public var id: Swift.String + /// param + /// + /// - Remark: Generated from `#/paths/rules/{ruleUID}/{moduleCategory}/{id}/config/{param}/PUT/path/param`. + public var param: Swift.String + /// Creates a new `Path`. + /// + /// - Parameters: + /// - ruleUID: ruleUID + /// - moduleCategory: moduleCategory + /// - id: id + /// - param: param + public init( + ruleUID: Swift.String, + moduleCategory: Swift.String, + id: Swift.String, + param: Swift.String + ) { + self.ruleUID = ruleUID + self.moduleCategory = moduleCategory + self.id = id + self.param = param + } + } + public var path: Operations.setRuleModuleConfigParameter.Input.Path + /// - Remark: Generated from `#/paths/rules/{ruleUID}/{moduleCategory}/{id}/config/{param}/PUT/requestBody`. + @frozen public enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/rules/{ruleUID}/{moduleCategory}/{id}/config/{param}/PUT/requestBody/content/text\/plain`. + case plainText(OpenAPIRuntime.HTTPBody) + } + public var body: Operations.setRuleModuleConfigParameter.Input.Body + /// Creates a new `Input`. + /// + /// - Parameters: + /// - path: + /// - body: + public init( + path: Operations.setRuleModuleConfigParameter.Input.Path, + body: Operations.setRuleModuleConfigParameter.Input.Body + ) { + self.path = path + self.body = body + } + } + @frozen public enum Output: Sendable, Hashable { + public struct Ok: Sendable, Hashable { + /// Creates a new `Ok`. + public init() {} + } + /// OK + /// + /// - Remark: Generated from `#/paths//rules/{ruleUID}/{moduleCategory}/{id}/config/{param}/put(setRuleModuleConfigParameter)/responses/200`. + /// + /// HTTP response code: `200 ok`. + case ok(Operations.setRuleModuleConfigParameter.Output.Ok) + /// OK + /// + /// - Remark: Generated from `#/paths//rules/{ruleUID}/{moduleCategory}/{id}/config/{param}/put(setRuleModuleConfigParameter)/responses/200`. + /// + /// HTTP response code: `200 ok`. + public static var ok: Self { + .ok(.init()) + } + /// The associated value of the enum case if `self` is `.ok`. + /// + /// - Throws: An error if `self` is not `.ok`. + /// - SeeAlso: `.ok`. + public var ok: Operations.setRuleModuleConfigParameter.Output.Ok { + get throws { + switch self { + case let .ok(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "ok", + response: self + ) + } + } + } + public struct NotFound: Sendable, Hashable { + /// Creates a new `NotFound`. + public init() {} + } + /// Rule corresponding to the given UID does not found or does not have a module with such Category and ID. + /// + /// - Remark: Generated from `#/paths//rules/{ruleUID}/{moduleCategory}/{id}/config/{param}/put(setRuleModuleConfigParameter)/responses/404`. + /// + /// HTTP response code: `404 notFound`. + case notFound(Operations.setRuleModuleConfigParameter.Output.NotFound) + /// Rule corresponding to the given UID does not found or does not have a module with such Category and ID. + /// + /// - Remark: Generated from `#/paths//rules/{ruleUID}/{moduleCategory}/{id}/config/{param}/put(setRuleModuleConfigParameter)/responses/404`. + /// + /// HTTP response code: `404 notFound`. + public static var notFound: Self { + .notFound(.init()) + } + /// The associated value of the enum case if `self` is `.notFound`. + /// + /// - Throws: An error if `self` is not `.notFound`. + /// - SeeAlso: `.notFound`. + public var notFound: Operations.setRuleModuleConfigParameter.Output.NotFound { + get throws { + switch self { + case let .notFound(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "notFound", + response: self + ) + } + } + } + /// Undocumented response. + /// + /// A response with a code that is not documented in the OpenAPI document. + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + } + /// Gets the rule triggers. + /// + /// - Remark: HTTP `GET /rules/{ruleUID}/triggers`. + /// - Remark: Generated from `#/paths//rules/{ruleUID}/triggers/get(getRuleTriggers)`. + public enum getRuleTriggers { + public static let id: Swift.String = "getRuleTriggers" + public struct Input: Sendable, Hashable { + /// - Remark: Generated from `#/paths/rules/{ruleUID}/triggers/GET/path`. + public struct Path: Sendable, Hashable { + /// ruleUID + /// + /// - Remark: Generated from `#/paths/rules/{ruleUID}/triggers/GET/path/ruleUID`. + public var ruleUID: Swift.String + /// Creates a new `Path`. + /// + /// - Parameters: + /// - ruleUID: ruleUID + public init(ruleUID: Swift.String) { + self.ruleUID = ruleUID + } + } + public var path: Operations.getRuleTriggers.Input.Path + /// - Remark: Generated from `#/paths/rules/{ruleUID}/triggers/GET/header`. + public struct Headers: Sendable, Hashable { + public var accept: [OpenAPIRuntime.AcceptHeaderContentType] + /// Creates a new `Headers`. + /// + /// - Parameters: + /// - accept: + public init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + self.accept = accept + } + } + public var headers: Operations.getRuleTriggers.Input.Headers + /// Creates a new `Input`. + /// + /// - Parameters: + /// - path: + /// - headers: + public init( + path: Operations.getRuleTriggers.Input.Path, + headers: Operations.getRuleTriggers.Input.Headers = .init() + ) { + self.path = path + self.headers = headers + } + } + @frozen public enum Output: Sendable, Hashable { + public struct Ok: Sendable, Hashable { + /// - Remark: Generated from `#/paths/rules/{ruleUID}/triggers/GET/responses/200/content`. + @frozen public enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/rules/{ruleUID}/triggers/GET/responses/200/content/application\/json`. + case json([Components.Schemas.TriggerDTO]) + /// The associated value of the enum case if `self` is `.json`. + /// + /// - Throws: An error if `self` is not `.json`. + /// - SeeAlso: `.json`. + public var json: [Components.Schemas.TriggerDTO] { + get throws { + switch self { + case let .json(body): + return body + } + } + } + } + /// Received HTTP response body + public var body: Operations.getRuleTriggers.Output.Ok.Body + /// Creates a new `Ok`. + /// + /// - Parameters: + /// - body: Received HTTP response body + public init(body: Operations.getRuleTriggers.Output.Ok.Body) { + self.body = body + } + } + /// OK + /// + /// - Remark: Generated from `#/paths//rules/{ruleUID}/triggers/get(getRuleTriggers)/responses/200`. + /// + /// HTTP response code: `200 ok`. + case ok(Operations.getRuleTriggers.Output.Ok) + /// The associated value of the enum case if `self` is `.ok`. + /// + /// - Throws: An error if `self` is not `.ok`. + /// - SeeAlso: `.ok`. + public var ok: Operations.getRuleTriggers.Output.Ok { + get throws { + switch self { + case let .ok(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "ok", + response: self + ) + } + } + } + public struct NotFound: Sendable, Hashable { + /// Creates a new `NotFound`. + public init() {} + } + /// Rule corresponding to the given UID does not found. + /// + /// - Remark: Generated from `#/paths//rules/{ruleUID}/triggers/get(getRuleTriggers)/responses/404`. + /// + /// HTTP response code: `404 notFound`. + case notFound(Operations.getRuleTriggers.Output.NotFound) + /// Rule corresponding to the given UID does not found. + /// + /// - Remark: Generated from `#/paths//rules/{ruleUID}/triggers/get(getRuleTriggers)/responses/404`. + /// + /// HTTP response code: `404 notFound`. + public static var notFound: Self { + .notFound(.init()) + } + /// The associated value of the enum case if `self` is `.notFound`. + /// + /// - Throws: An error if `self` is not `.notFound`. + /// - SeeAlso: `.notFound`. + public var notFound: Operations.getRuleTriggers.Output.NotFound { + get throws { + switch self { + case let .notFound(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "notFound", + response: self + ) + } + } + } + /// Undocumented response. + /// + /// A response with a code that is not documented in the OpenAPI document. + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + @frozen public enum AcceptableContentType: AcceptableProtocol { + case json + case other(Swift.String) + public init?(rawValue: Swift.String) { + switch rawValue.lowercased() { + case "application/json": + self = .json + default: + self = .other(rawValue) + } + } + public var rawValue: Swift.String { + switch self { + case let .other(string): + return string + case .json: + return "application/json" + } + } + public static var allCases: [Self] { + [ + .json + ] } } - - /// - Remark: Generated from `#/components/schemas/RootUIComponent`. - public struct RootUIComponent: Codable, Hashable, Sendable { - /// - Remark: Generated from `#/components/schemas/RootUIComponent/component`. - public var component: Swift.String? - /// - Remark: Generated from `#/components/schemas/RootUIComponent/config`. - public struct configPayload: Codable, Hashable, Sendable { - /// A container of undocumented properties. - public var additionalProperties: [String: OpenAPIRuntime.OpenAPIObjectContainer] - /// Creates a new `configPayload`. + } + /// Executes actions of the rule. + /// + /// - Remark: HTTP `POST /rules/{ruleUID}/runnow`. + /// - Remark: Generated from `#/paths//rules/{ruleUID}/runnow/post(runRuleNow_1)`. + public enum runRuleNow_1 { + public static let id: Swift.String = "runRuleNow_1" + public struct Input: Sendable, Hashable { + /// - Remark: Generated from `#/paths/rules/{ruleUID}/runnow/POST/path`. + public struct Path: Sendable, Hashable { + /// ruleUID + /// + /// - Remark: Generated from `#/paths/rules/{ruleUID}/runnow/POST/path/ruleUID`. + public var ruleUID: Swift.String + /// Creates a new `Path`. /// /// - Parameters: - /// - additionalProperties: A container of undocumented properties. - public init(additionalProperties: [String: OpenAPIRuntime.OpenAPIObjectContainer] = .init()) { - self.additionalProperties = additionalProperties + /// - ruleUID: ruleUID + public init(ruleUID: Swift.String) { + self.ruleUID = ruleUID } - - public init(from decoder: any Decoder) throws { - additionalProperties = try decoder.decodeAdditionalProperties(knownKeys: []) + } + public var path: Operations.runRuleNow_1.Input.Path + /// - Remark: Generated from `#/paths/rules/{ruleUID}/runnow/POST/requestBody`. + @frozen public enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/rules/{ruleUID}/runnow/POST/requestBody/json`. + public struct jsonPayload: Codable, Hashable, Sendable { + /// A container of undocumented properties. + public var additionalProperties: OpenAPIRuntime.OpenAPIObjectContainer + /// Creates a new `jsonPayload`. + /// + /// - Parameters: + /// - additionalProperties: A container of undocumented properties. + public init(additionalProperties: OpenAPIRuntime.OpenAPIObjectContainer = .init()) { + self.additionalProperties = additionalProperties + } + public init(from decoder: any Decoder) throws { + additionalProperties = try decoder.decodeAdditionalProperties(knownKeys: []) + } + public func encode(to encoder: any Encoder) throws { + try encoder.encodeAdditionalProperties(additionalProperties) + } } - - public func encode(to encoder: any Encoder) throws { - try encoder.encodeAdditionalProperties(additionalProperties) + /// - Remark: Generated from `#/paths/rules/{ruleUID}/runnow/POST/requestBody/content/application\/json`. + case json(Operations.runRuleNow_1.Input.Body.jsonPayload) + } + public var body: Operations.runRuleNow_1.Input.Body? + /// Creates a new `Input`. + /// + /// - Parameters: + /// - path: + /// - body: + public init( + path: Operations.runRuleNow_1.Input.Path, + body: Operations.runRuleNow_1.Input.Body? = nil + ) { + self.path = path + self.body = body + } + } + @frozen public enum Output: Sendable, Hashable { + public struct Ok: Sendable, Hashable { + /// Creates a new `Ok`. + public init() {} + } + /// OK + /// + /// - Remark: Generated from `#/paths//rules/{ruleUID}/runnow/post(runRuleNow_1)/responses/200`. + /// + /// HTTP response code: `200 ok`. + case ok(Operations.runRuleNow_1.Output.Ok) + /// OK + /// + /// - Remark: Generated from `#/paths//rules/{ruleUID}/runnow/post(runRuleNow_1)/responses/200`. + /// + /// HTTP response code: `200 ok`. + public static var ok: Self { + .ok(.init()) + } + /// The associated value of the enum case if `self` is `.ok`. + /// + /// - Throws: An error if `self` is not `.ok`. + /// - SeeAlso: `.ok`. + public var ok: Operations.runRuleNow_1.Output.Ok { + get throws { + switch self { + case let .ok(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "ok", + response: self + ) + } } } - - /// - Remark: Generated from `#/components/schemas/RootUIComponent/config`. - public var config: Components.Schemas.RootUIComponent.configPayload? - /// - Remark: Generated from `#/components/schemas/RootUIComponent/slots`. - public struct slotsPayload: Codable, Hashable, Sendable { - /// A container of undocumented properties. - public var additionalProperties: [String: [Components.Schemas.UIComponent]] - /// Creates a new `slotsPayload`. + public struct NotFound: Sendable, Hashable { + /// Creates a new `NotFound`. + public init() {} + } + /// Rule corresponding to the given UID does not found. + /// + /// - Remark: Generated from `#/paths//rules/{ruleUID}/runnow/post(runRuleNow_1)/responses/404`. + /// + /// HTTP response code: `404 notFound`. + case notFound(Operations.runRuleNow_1.Output.NotFound) + /// Rule corresponding to the given UID does not found. + /// + /// - Remark: Generated from `#/paths//rules/{ruleUID}/runnow/post(runRuleNow_1)/responses/404`. + /// + /// HTTP response code: `404 notFound`. + public static var notFound: Self { + .notFound(.init()) + } + /// The associated value of the enum case if `self` is `.notFound`. + /// + /// - Throws: An error if `self` is not `.notFound`. + /// - SeeAlso: `.notFound`. + public var notFound: Operations.runRuleNow_1.Output.NotFound { + get throws { + switch self { + case let .notFound(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "notFound", + response: self + ) + } + } + } + /// Undocumented response. + /// + /// A response with a code that is not documented in the OpenAPI document. + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + } + /// Simulates the executions of rules filtered by tag 'Schedule' within the given times. + /// + /// - Remark: HTTP `GET /rules/schedule/simulations`. + /// - Remark: Generated from `#/paths//rules/schedule/simulations/get(getScheduleRuleSimulations)`. + public enum getScheduleRuleSimulations { + public static let id: Swift.String = "getScheduleRuleSimulations" + public struct Input: Sendable, Hashable { + /// - Remark: Generated from `#/paths/rules/schedule/simulations/GET/query`. + public struct Query: Sendable, Hashable { + /// Start time of the simulated rule executions. Will default to the current time. [yyyy-MM-dd'T'HH:mm:ss.SSSZ] + /// + /// - Remark: Generated from `#/paths/rules/schedule/simulations/GET/query/from`. + public var from: Swift.String? + /// End time of the simulated rule executions. Will default to 30 days after the start time. Must be less than 180 days after the given start time. [yyyy-MM-dd'T'HH:mm:ss.SSSZ] + /// + /// - Remark: Generated from `#/paths/rules/schedule/simulations/GET/query/until`. + public var until: Swift.String? + /// Creates a new `Query`. /// /// - Parameters: - /// - additionalProperties: A container of undocumented properties. - public init(additionalProperties: [String: [Components.Schemas.UIComponent]] = .init()) { - self.additionalProperties = additionalProperties - } - - public init(from decoder: any Decoder) throws { - additionalProperties = try decoder.decodeAdditionalProperties(knownKeys: []) - } - - public func encode(to encoder: any Encoder) throws { - try encoder.encodeAdditionalProperties(additionalProperties) + /// - from: Start time of the simulated rule executions. Will default to the current time. [yyyy-MM-dd'T'HH:mm:ss.SSSZ] + /// - until: End time of the simulated rule executions. Will default to 30 days after the start time. Must be less than 180 days after the given start time. [yyyy-MM-dd'T'HH:mm:ss.SSSZ] + public init( + from: Swift.String? = nil, + until: Swift.String? = nil + ) { + self.from = from + self.until = until + } + } + public var query: Operations.getScheduleRuleSimulations.Input.Query + /// - Remark: Generated from `#/paths/rules/schedule/simulations/GET/header`. + public struct Headers: Sendable, Hashable { + public var accept: [OpenAPIRuntime.AcceptHeaderContentType] + /// Creates a new `Headers`. + /// + /// - Parameters: + /// - accept: + public init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + self.accept = accept } } - - /// - Remark: Generated from `#/components/schemas/RootUIComponent/slots`. - public var slots: Components.Schemas.RootUIComponent.slotsPayload? - /// - Remark: Generated from `#/components/schemas/RootUIComponent/uid`. - public var uid: Swift.String? - /// - Remark: Generated from `#/components/schemas/RootUIComponent/tags`. - public var tags: [Swift.String]? - /// - Remark: Generated from `#/components/schemas/RootUIComponent/props`. - public var props: Components.Schemas.ConfigDescriptionDTO? - /// - Remark: Generated from `#/components/schemas/RootUIComponent/timestamp`. - public var timestamp: Foundation.Date? - /// - Remark: Generated from `#/components/schemas/RootUIComponent/type`. - public var _type: Swift.String? - /// Creates a new `RootUIComponent`. + public var headers: Operations.getScheduleRuleSimulations.Input.Headers + /// Creates a new `Input`. /// /// - Parameters: - /// - component: - /// - config: - /// - slots: - /// - uid: - /// - tags: - /// - props: - /// - timestamp: - /// - _type: - public init(component: Swift.String? = nil, - config: Components.Schemas.RootUIComponent.configPayload? = nil, - slots: Components.Schemas.RootUIComponent.slotsPayload? = nil, - uid: Swift.String? = nil, - tags: [Swift.String]? = nil, - props: Components.Schemas.ConfigDescriptionDTO? = nil, - timestamp: Foundation.Date? = nil, - _type: Swift.String? = nil) { - self.component = component - self.config = config - self.slots = slots - self.uid = uid - self.tags = tags - self.props = props - self.timestamp = timestamp - self._type = _type - } - - public enum CodingKeys: String, CodingKey { - case component - case config - case slots - case uid - case tags - case props - case timestamp - case _type = "type" + /// - query: + /// - headers: + public init( + query: Operations.getScheduleRuleSimulations.Input.Query = .init(), + headers: Operations.getScheduleRuleSimulations.Input.Headers = .init() + ) { + self.query = query + self.headers = headers } } - - /// - Remark: Generated from `#/components/schemas/UIComponent`. - public struct UIComponent: Codable, Hashable, Sendable { - /// - Remark: Generated from `#/components/schemas/UIComponent/component`. - public var component: Swift.String? - /// - Remark: Generated from `#/components/schemas/UIComponent/config`. - public struct configPayload: Codable, Hashable, Sendable { - /// A container of undocumented properties. - public var additionalProperties: [String: OpenAPIRuntime.OpenAPIObjectContainer] - /// Creates a new `configPayload`. + @frozen public enum Output: Sendable, Hashable { + public struct Ok: Sendable, Hashable { + /// - Remark: Generated from `#/paths/rules/schedule/simulations/GET/responses/200/content`. + @frozen public enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/rules/schedule/simulations/GET/responses/200/content/application\/json`. + case json([Components.Schemas.RuleExecution]) + /// The associated value of the enum case if `self` is `.json`. + /// + /// - Throws: An error if `self` is not `.json`. + /// - SeeAlso: `.json`. + public var json: [Components.Schemas.RuleExecution] { + get throws { + switch self { + case let .json(body): + return body + } + } + } + } + /// Received HTTP response body + public var body: Operations.getScheduleRuleSimulations.Output.Ok.Body + /// Creates a new `Ok`. /// /// - Parameters: - /// - additionalProperties: A container of undocumented properties. - public init(additionalProperties: [String: OpenAPIRuntime.OpenAPIObjectContainer] = .init()) { - self.additionalProperties = additionalProperties - } - - public init(from decoder: any Decoder) throws { - additionalProperties = try decoder.decodeAdditionalProperties(knownKeys: []) - } - - public func encode(to encoder: any Encoder) throws { - try encoder.encodeAdditionalProperties(additionalProperties) + /// - body: Received HTTP response body + public init(body: Operations.getScheduleRuleSimulations.Output.Ok.Body) { + self.body = body } } - - /// - Remark: Generated from `#/components/schemas/UIComponent/config`. - public var config: Components.Schemas.UIComponent.configPayload? - /// - Remark: Generated from `#/components/schemas/UIComponent/type`. - public var _type: Swift.String? - /// Creates a new `UIComponent`. + /// OK + /// + /// - Remark: Generated from `#/paths//rules/schedule/simulations/get(getScheduleRuleSimulations)/responses/200`. + /// + /// HTTP response code: `200 ok`. + case ok(Operations.getScheduleRuleSimulations.Output.Ok) + /// The associated value of the enum case if `self` is `.ok`. + /// + /// - Throws: An error if `self` is not `.ok`. + /// - SeeAlso: `.ok`. + public var ok: Operations.getScheduleRuleSimulations.Output.Ok { + get throws { + switch self { + case let .ok(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "ok", + response: self + ) + } + } + } + public struct BadRequest: Sendable, Hashable { + /// Creates a new `BadRequest`. + public init() {} + } + /// The max. simulation duration of 180 days is exceeded. /// - /// - Parameters: - /// - component: - /// - config: - /// - _type: - public init(component: Swift.String? = nil, - config: Components.Schemas.UIComponent.configPayload? = nil, - _type: Swift.String? = nil) { - self.component = component - self.config = config - self._type = _type + /// - Remark: Generated from `#/paths//rules/schedule/simulations/get(getScheduleRuleSimulations)/responses/400`. + /// + /// HTTP response code: `400 badRequest`. + case badRequest(Operations.getScheduleRuleSimulations.Output.BadRequest) + /// The max. simulation duration of 180 days is exceeded. + /// + /// - Remark: Generated from `#/paths//rules/schedule/simulations/get(getScheduleRuleSimulations)/responses/400`. + /// + /// HTTP response code: `400 badRequest`. + public static var badRequest: Self { + .badRequest(.init()) } - - public enum CodingKeys: String, CodingKey { - case component - case config - case _type = "type" + /// The associated value of the enum case if `self` is `.badRequest`. + /// + /// - Throws: An error if `self` is not `.badRequest`. + /// - SeeAlso: `.badRequest`. + public var badRequest: Operations.getScheduleRuleSimulations.Output.BadRequest { + get throws { + switch self { + case let .badRequest(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "badRequest", + response: self + ) + } + } } - } - - /// - Remark: Generated from `#/components/schemas/TileDTO`. - public struct TileDTO: Codable, Hashable, Sendable { - /// - Remark: Generated from `#/components/schemas/TileDTO/name`. - public var name: Swift.String? - /// - Remark: Generated from `#/components/schemas/TileDTO/url`. - public var url: Swift.String? - /// - Remark: Generated from `#/components/schemas/TileDTO/overlay`. - public var overlay: Swift.String? - /// - Remark: Generated from `#/components/schemas/TileDTO/imageUrl`. - public var imageUrl: Swift.String? - /// Creates a new `TileDTO`. + /// Undocumented response. /// - /// - Parameters: - /// - name: - /// - url: - /// - overlay: - /// - imageUrl: - public init(name: Swift.String? = nil, - url: Swift.String? = nil, - overlay: Swift.String? = nil, - imageUrl: Swift.String? = nil) { - self.name = name - self.url = url - self.overlay = overlay - self.imageUrl = imageUrl + /// A response with a code that is not documented in the OpenAPI document. + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + @frozen public enum AcceptableContentType: AcceptableProtocol { + case json + case other(Swift.String) + public init?(rawValue: Swift.String) { + switch rawValue.lowercased() { + case "application/json": + self = .json + default: + self = .other(rawValue) + } } - - public enum CodingKeys: String, CodingKey { - case name - case url - case overlay - case imageUrl + public var rawValue: Swift.String { + switch self { + case let .other(string): + return string + case .json: + return "application/json" + } + } + public static var allCases: [Self] { + [ + .json + ] } } } - - /// Types generated from the `#/components/parameters` section of the OpenAPI document. - public enum Parameters {} - /// Types generated from the `#/components/requestBodies` section of the OpenAPI document. - public enum RequestBodies {} - /// Types generated from the `#/components/responses` section of the OpenAPI document. - public enum Responses {} - /// Types generated from the `#/components/headers` section of the OpenAPI document. - public enum Headers {} -} - -/// API operations, with input and output types, generated from `#/paths` in the OpenAPI document. -public enum Operations { /// Adds a new member to a group item. /// /// - Remark: HTTP `PUT /items/{itemName}/members/{memberItemName}`. @@ -2816,13 +6722,14 @@ public enum Operations { /// - Parameters: /// - itemName: item name /// - memberItemName: member item name - public init(itemName: Swift.String, - memberItemName: Swift.String) { + public init( + itemName: Swift.String, + memberItemName: Swift.String + ) { self.itemName = itemName self.memberItemName = memberItemName } } - public var path: Operations.addMemberToGroupItem.Input.Path /// Creates a new `Input`. /// @@ -2832,13 +6739,11 @@ public enum Operations { self.path = path } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// Creates a new `Ok`. public init() {} } - /// OK /// /// - Remark: Generated from `#/paths//items/{itemName}/members/{memberItemName}/put(addMemberToGroupItem)/responses/200`. @@ -2853,7 +6758,6 @@ public enum Operations { public static var ok: Self { .ok(.init()) } - /// The associated value of the enum case if `self` is `.ok`. /// /// - Throws: An error if `self` is not `.ok`. @@ -2862,7 +6766,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -2871,12 +6775,10 @@ public enum Operations { } } } - public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } - /// Item or member item not found or item is not of type group item. /// /// - Remark: Generated from `#/paths//items/{itemName}/members/{memberItemName}/put(addMemberToGroupItem)/responses/404`. @@ -2891,7 +6793,6 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } - /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -2900,7 +6801,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -2909,12 +6810,10 @@ public enum Operations { } } } - public struct MethodNotAllowed: Sendable, Hashable { /// Creates a new `MethodNotAllowed`. public init() {} } - /// Member item is not editable. /// /// - Remark: Generated from `#/paths//items/{itemName}/members/{memberItemName}/put(addMemberToGroupItem)/responses/405`. @@ -2929,7 +6828,6 @@ public enum Operations { public static var methodNotAllowed: Self { .methodNotAllowed(.init()) } - /// The associated value of the enum case if `self` is `.methodNotAllowed`. /// /// - Throws: An error if `self` is not `.methodNotAllowed`. @@ -2938,7 +6836,7 @@ public enum Operations { get throws { switch self { case let .methodNotAllowed(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "methodNotAllowed", @@ -2947,14 +6845,12 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } } - /// Removes an existing member from a group item. /// /// - Remark: HTTP `DELETE /items/{itemName}/members/{memberItemName}`. @@ -2977,13 +6873,14 @@ public enum Operations { /// - Parameters: /// - itemName: item name /// - memberItemName: member item name - public init(itemName: Swift.String, - memberItemName: Swift.String) { + public init( + itemName: Swift.String, + memberItemName: Swift.String + ) { self.itemName = itemName self.memberItemName = memberItemName } } - public var path: Operations.removeMemberFromGroupItem.Input.Path /// Creates a new `Input`. /// @@ -2993,13 +6890,11 @@ public enum Operations { self.path = path } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// Creates a new `Ok`. public init() {} } - /// OK /// /// - Remark: Generated from `#/paths//items/{itemName}/members/{memberItemName}/delete(removeMemberFromGroupItem)/responses/200`. @@ -3014,7 +6909,6 @@ public enum Operations { public static var ok: Self { .ok(.init()) } - /// The associated value of the enum case if `self` is `.ok`. /// /// - Throws: An error if `self` is not `.ok`. @@ -3023,7 +6917,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -3032,12 +6926,10 @@ public enum Operations { } } } - public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } - /// Item or member item not found or item is not of type group item. /// /// - Remark: Generated from `#/paths//items/{itemName}/members/{memberItemName}/delete(removeMemberFromGroupItem)/responses/404`. @@ -3052,7 +6944,6 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } - /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -3061,7 +6952,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -3070,12 +6961,10 @@ public enum Operations { } } } - public struct MethodNotAllowed: Sendable, Hashable { /// Creates a new `MethodNotAllowed`. public init() {} } - /// Member item is not editable. /// /// - Remark: Generated from `#/paths//items/{itemName}/members/{memberItemName}/delete(removeMemberFromGroupItem)/responses/405`. @@ -3090,7 +6979,6 @@ public enum Operations { public static var methodNotAllowed: Self { .methodNotAllowed(.init()) } - /// The associated value of the enum case if `self` is `.methodNotAllowed`. /// /// - Throws: An error if `self` is not `.methodNotAllowed`. @@ -3099,7 +6987,7 @@ public enum Operations { get throws { switch self { case let .methodNotAllowed(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "methodNotAllowed", @@ -3108,14 +6996,12 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } } - /// Adds metadata to an item. /// /// - Remark: HTTP `PUT /items/{itemname}/metadata/{namespace}`. @@ -3138,39 +7024,39 @@ public enum Operations { /// - Parameters: /// - itemname: item name /// - namespace: namespace - public init(itemname: Swift.String, - namespace: Swift.String) { + public init( + itemname: Swift.String, + namespace: Swift.String + ) { self.itemname = itemname self.namespace = namespace } } - public var path: Operations.addMetadataToItem.Input.Path /// - Remark: Generated from `#/paths/items/{itemname}/metadata/{namespace}/PUT/requestBody`. @frozen public enum Body: Sendable, Hashable { /// - Remark: Generated from `#/paths/items/{itemname}/metadata/{namespace}/PUT/requestBody/content/application\/json`. case json(Components.Schemas.MetadataDTO) } - public var body: Operations.addMetadataToItem.Input.Body /// Creates a new `Input`. /// /// - Parameters: /// - path: /// - body: - public init(path: Operations.addMetadataToItem.Input.Path, - body: Operations.addMetadataToItem.Input.Body) { + public init( + path: Operations.addMetadataToItem.Input.Path, + body: Operations.addMetadataToItem.Input.Body + ) { self.path = path self.body = body } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// Creates a new `Ok`. public init() {} } - /// OK /// /// - Remark: Generated from `#/paths//items/{itemname}/metadata/{namespace}/put(addMetadataToItem)/responses/200`. @@ -3185,7 +7071,6 @@ public enum Operations { public static var ok: Self { .ok(.init()) } - /// The associated value of the enum case if `self` is `.ok`. /// /// - Throws: An error if `self` is not `.ok`. @@ -3194,7 +7079,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -3203,12 +7088,10 @@ public enum Operations { } } } - public struct Created: Sendable, Hashable { /// Creates a new `Created`. public init() {} } - /// Created /// /// - Remark: Generated from `#/paths//items/{itemname}/metadata/{namespace}/put(addMetadataToItem)/responses/201`. @@ -3223,7 +7106,6 @@ public enum Operations { public static var created: Self { .created(.init()) } - /// The associated value of the enum case if `self` is `.created`. /// /// - Throws: An error if `self` is not `.created`. @@ -3232,7 +7114,7 @@ public enum Operations { get throws { switch self { case let .created(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "created", @@ -3241,12 +7123,10 @@ public enum Operations { } } } - public struct BadRequest: Sendable, Hashable { /// Creates a new `BadRequest`. public init() {} } - /// Metadata value empty. /// /// - Remark: Generated from `#/paths//items/{itemname}/metadata/{namespace}/put(addMetadataToItem)/responses/400`. @@ -3261,7 +7141,6 @@ public enum Operations { public static var badRequest: Self { .badRequest(.init()) } - /// The associated value of the enum case if `self` is `.badRequest`. /// /// - Throws: An error if `self` is not `.badRequest`. @@ -3270,7 +7149,7 @@ public enum Operations { get throws { switch self { case let .badRequest(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "badRequest", @@ -3279,12 +7158,10 @@ public enum Operations { } } } - public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } - /// Item not found. /// /// - Remark: Generated from `#/paths//items/{itemname}/metadata/{namespace}/put(addMetadataToItem)/responses/404`. @@ -3299,7 +7176,6 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } - /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -3308,7 +7184,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -3317,12 +7193,10 @@ public enum Operations { } } } - public struct MethodNotAllowed: Sendable, Hashable { /// Creates a new `MethodNotAllowed`. public init() {} } - /// Metadata not editable. /// /// - Remark: Generated from `#/paths//items/{itemname}/metadata/{namespace}/put(addMetadataToItem)/responses/405`. @@ -3337,7 +7211,6 @@ public enum Operations { public static var methodNotAllowed: Self { .methodNotAllowed(.init()) } - /// The associated value of the enum case if `self` is `.methodNotAllowed`. /// /// - Throws: An error if `self` is not `.methodNotAllowed`. @@ -3346,7 +7219,7 @@ public enum Operations { get throws { switch self { case let .methodNotAllowed(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "methodNotAllowed", @@ -3355,14 +7228,12 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } } - /// Removes metadata from an item. /// /// - Remark: HTTP `DELETE /items/{itemname}/metadata/{namespace}`. @@ -3385,13 +7256,14 @@ public enum Operations { /// - Parameters: /// - itemname: item name /// - namespace: namespace - public init(itemname: Swift.String, - namespace: Swift.String) { + public init( + itemname: Swift.String, + namespace: Swift.String + ) { self.itemname = itemname self.namespace = namespace } } - public var path: Operations.removeMetadataFromItem.Input.Path /// Creates a new `Input`. /// @@ -3401,13 +7273,11 @@ public enum Operations { self.path = path } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// Creates a new `Ok`. public init() {} } - /// OK /// /// - Remark: Generated from `#/paths//items/{itemname}/metadata/{namespace}/delete(removeMetadataFromItem)/responses/200`. @@ -3422,7 +7292,6 @@ public enum Operations { public static var ok: Self { .ok(.init()) } - /// The associated value of the enum case if `self` is `.ok`. /// /// - Throws: An error if `self` is not `.ok`. @@ -3431,7 +7300,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -3440,12 +7309,10 @@ public enum Operations { } } } - public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } - /// Item not found. /// /// - Remark: Generated from `#/paths//items/{itemname}/metadata/{namespace}/delete(removeMetadataFromItem)/responses/404`. @@ -3460,7 +7327,6 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } - /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -3469,7 +7335,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -3478,12 +7344,10 @@ public enum Operations { } } } - public struct MethodNotAllowed: Sendable, Hashable { /// Creates a new `MethodNotAllowed`. public init() {} } - /// Meta data not editable. /// /// - Remark: Generated from `#/paths//items/{itemname}/metadata/{namespace}/delete(removeMetadataFromItem)/responses/405`. @@ -3498,7 +7362,6 @@ public enum Operations { public static var methodNotAllowed: Self { .methodNotAllowed(.init()) } - /// The associated value of the enum case if `self` is `.methodNotAllowed`. /// /// - Throws: An error if `self` is not `.methodNotAllowed`. @@ -3507,7 +7370,7 @@ public enum Operations { get throws { switch self { case let .methodNotAllowed(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "methodNotAllowed", @@ -3516,14 +7379,12 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } } - /// Adds a tag to an item. /// /// - Remark: HTTP `PUT /items/{itemname}/tags/{tag}`. @@ -3546,13 +7407,14 @@ public enum Operations { /// - Parameters: /// - itemname: item name /// - tag: tag - public init(itemname: Swift.String, - tag: Swift.String) { + public init( + itemname: Swift.String, + tag: Swift.String + ) { self.itemname = itemname self.tag = tag } } - public var path: Operations.addTagToItem.Input.Path /// Creates a new `Input`. /// @@ -3562,13 +7424,11 @@ public enum Operations { self.path = path } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// Creates a new `Ok`. public init() {} } - /// OK /// /// - Remark: Generated from `#/paths//items/{itemname}/tags/{tag}/put(addTagToItem)/responses/200`. @@ -3583,7 +7443,6 @@ public enum Operations { public static var ok: Self { .ok(.init()) } - /// The associated value of the enum case if `self` is `.ok`. /// /// - Throws: An error if `self` is not `.ok`. @@ -3592,7 +7451,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -3601,12 +7460,10 @@ public enum Operations { } } } - public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } - /// Item not found. /// /// - Remark: Generated from `#/paths//items/{itemname}/tags/{tag}/put(addTagToItem)/responses/404`. @@ -3621,7 +7478,6 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } - /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -3630,7 +7486,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -3639,12 +7495,10 @@ public enum Operations { } } } - public struct MethodNotAllowed: Sendable, Hashable { /// Creates a new `MethodNotAllowed`. public init() {} } - /// Item not editable. /// /// - Remark: Generated from `#/paths//items/{itemname}/tags/{tag}/put(addTagToItem)/responses/405`. @@ -3659,7 +7513,6 @@ public enum Operations { public static var methodNotAllowed: Self { .methodNotAllowed(.init()) } - /// The associated value of the enum case if `self` is `.methodNotAllowed`. /// /// - Throws: An error if `self` is not `.methodNotAllowed`. @@ -3668,7 +7521,7 @@ public enum Operations { get throws { switch self { case let .methodNotAllowed(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "methodNotAllowed", @@ -3677,14 +7530,12 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } } - /// Removes a tag from an item. /// /// - Remark: HTTP `DELETE /items/{itemname}/tags/{tag}`. @@ -3707,13 +7558,14 @@ public enum Operations { /// - Parameters: /// - itemname: item name /// - tag: tag - public init(itemname: Swift.String, - tag: Swift.String) { + public init( + itemname: Swift.String, + tag: Swift.String + ) { self.itemname = itemname self.tag = tag } } - public var path: Operations.removeTagFromItem.Input.Path /// Creates a new `Input`. /// @@ -3723,13 +7575,11 @@ public enum Operations { self.path = path } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// Creates a new `Ok`. public init() {} } - /// OK /// /// - Remark: Generated from `#/paths//items/{itemname}/tags/{tag}/delete(removeTagFromItem)/responses/200`. @@ -3744,7 +7594,6 @@ public enum Operations { public static var ok: Self { .ok(.init()) } - /// The associated value of the enum case if `self` is `.ok`. /// /// - Throws: An error if `self` is not `.ok`. @@ -3753,7 +7602,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -3762,12 +7611,10 @@ public enum Operations { } } } - public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } - /// Item not found. /// /// - Remark: Generated from `#/paths//items/{itemname}/tags/{tag}/delete(removeTagFromItem)/responses/404`. @@ -3782,7 +7629,6 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } - /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -3791,7 +7637,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -3800,12 +7646,10 @@ public enum Operations { } } } - public struct MethodNotAllowed: Sendable, Hashable { /// Creates a new `MethodNotAllowed`. public init() {} } - /// Item not editable. /// /// - Remark: Generated from `#/paths//items/{itemname}/tags/{tag}/delete(removeTagFromItem)/responses/405`. @@ -3820,7 +7664,6 @@ public enum Operations { public static var methodNotAllowed: Self { .methodNotAllowed(.init()) } - /// The associated value of the enum case if `self` is `.methodNotAllowed`. /// /// - Throws: An error if `self` is not `.methodNotAllowed`. @@ -3829,7 +7672,7 @@ public enum Operations { get throws { switch self { case let .methodNotAllowed(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "methodNotAllowed", @@ -3838,14 +7681,12 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } } - /// Gets a single item. /// /// - Remark: HTTP `GET /items/{itemname}`. @@ -3867,7 +7708,6 @@ public enum Operations { self.itemname = itemname } } - public var path: Operations.getItemByName.Input.Path /// - Remark: Generated from `#/paths/items/{itemname}/GET/query`. public struct Query: Sendable, Hashable { @@ -3884,13 +7724,14 @@ public enum Operations { /// - Parameters: /// - metadata: metadata selector - a comma separated list or a regular expression (returns all if no value given) /// - recursive: get member items if the item is a group item - public init(metadata: Swift.String? = nil, - recursive: Swift.Bool? = nil) { + public init( + metadata: Swift.String? = nil, + recursive: Swift.Bool? = nil + ) { self.metadata = metadata self.recursive = recursive } } - public var query: Operations.getItemByName.Input.Query /// - Remark: Generated from `#/paths/items/{itemname}/GET/header`. public struct Headers: Sendable, Hashable { @@ -3904,13 +7745,14 @@ public enum Operations { /// - Parameters: /// - Accept_hyphen_Language: language /// - accept: - public init(Accept_hyphen_Language: Swift.String? = nil, - accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + public init( + Accept_hyphen_Language: Swift.String? = nil, + accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues() + ) { self.Accept_hyphen_Language = Accept_hyphen_Language self.accept = accept } } - public var headers: Operations.getItemByName.Input.Headers /// Creates a new `Input`. /// @@ -3918,15 +7760,16 @@ public enum Operations { /// - path: /// - query: /// - headers: - public init(path: Operations.getItemByName.Input.Path, - query: Operations.getItemByName.Input.Query = .init(), - headers: Operations.getItemByName.Input.Headers = .init()) { + public init( + path: Operations.getItemByName.Input.Path, + query: Operations.getItemByName.Input.Query = .init(), + headers: Operations.getItemByName.Input.Headers = .init() + ) { self.path = path self.query = query self.headers = headers } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/items/{itemname}/GET/responses/200/content`. @@ -3941,12 +7784,11 @@ public enum Operations { get throws { switch self { case let .json(body): - body + return body } } } } - /// Received HTTP response body public var body: Operations.getItemByName.Output.Ok.Body /// Creates a new `Ok`. @@ -3957,7 +7799,6 @@ public enum Operations { self.body = body } } - /// OK /// /// - Remark: Generated from `#/paths//items/{itemname}/get(getItemByName)/responses/200`. @@ -3972,7 +7813,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -3981,12 +7822,10 @@ public enum Operations { } } } - public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } - /// Item not found /// /// - Remark: Generated from `#/paths//items/{itemname}/get(getItemByName)/responses/404`. @@ -4001,7 +7840,6 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } - /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -4010,7 +7848,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -4019,13 +7857,11 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } - @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -4037,16 +7873,14 @@ public enum Operations { self = .other(rawValue) } } - public var rawValue: Swift.String { switch self { case let .other(string): - string + return string case .json: - "application/json" + return "application/json" } } - public static var allCases: [Self] { [ .json @@ -4054,7 +7888,6 @@ public enum Operations { } } } - /// Sends a command to an item. /// /// - Remark: HTTP `POST /items/{itemname}`. @@ -4076,33 +7909,31 @@ public enum Operations { self.itemname = itemname } } - public var path: Operations.sendItemCommand.Input.Path /// - Remark: Generated from `#/paths/items/{itemname}/POST/requestBody`. @frozen public enum Body: Sendable, Hashable { /// - Remark: Generated from `#/paths/items/{itemname}/POST/requestBody/content/text\/plain`. case plainText(OpenAPIRuntime.HTTPBody) } - public var body: Operations.sendItemCommand.Input.Body /// Creates a new `Input`. /// /// - Parameters: /// - path: /// - body: - public init(path: Operations.sendItemCommand.Input.Path, - body: Operations.sendItemCommand.Input.Body) { + public init( + path: Operations.sendItemCommand.Input.Path, + body: Operations.sendItemCommand.Input.Body + ) { self.path = path self.body = body } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// Creates a new `Ok`. public init() {} } - /// OK /// /// - Remark: Generated from `#/paths//items/{itemname}/post(sendItemCommand)/responses/200`. @@ -4117,7 +7948,6 @@ public enum Operations { public static var ok: Self { .ok(.init()) } - /// The associated value of the enum case if `self` is `.ok`. /// /// - Throws: An error if `self` is not `.ok`. @@ -4126,7 +7956,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -4135,12 +7965,10 @@ public enum Operations { } } } - public struct BadRequest: Sendable, Hashable { /// Creates a new `BadRequest`. public init() {} } - /// Item command null /// /// - Remark: Generated from `#/paths//items/{itemname}/post(sendItemCommand)/responses/400`. @@ -4155,7 +7983,6 @@ public enum Operations { public static var badRequest: Self { .badRequest(.init()) } - /// The associated value of the enum case if `self` is `.badRequest`. /// /// - Throws: An error if `self` is not `.badRequest`. @@ -4164,7 +7991,7 @@ public enum Operations { get throws { switch self { case let .badRequest(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "badRequest", @@ -4173,12 +8000,10 @@ public enum Operations { } } } - public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } - /// Item not found /// /// - Remark: Generated from `#/paths//items/{itemname}/post(sendItemCommand)/responses/404`. @@ -4193,7 +8018,6 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } - /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -4202,7 +8026,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -4211,14 +8035,12 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } } - /// Adds a new item to the registry or updates the existing item. /// /// - Remark: HTTP `PUT /items/{itemname}`. @@ -4240,7 +8062,6 @@ public enum Operations { self.itemname = itemname } } - public var path: Operations.addOrUpdateItemInRegistry.Input.Path /// - Remark: Generated from `#/paths/items/{itemname}/PUT/header`. public struct Headers: Sendable, Hashable { @@ -4254,20 +8075,20 @@ public enum Operations { /// - Parameters: /// - Accept_hyphen_Language: language /// - accept: - public init(Accept_hyphen_Language: Swift.String? = nil, - accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + public init( + Accept_hyphen_Language: Swift.String? = nil, + accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues() + ) { self.Accept_hyphen_Language = Accept_hyphen_Language self.accept = accept } } - public var headers: Operations.addOrUpdateItemInRegistry.Input.Headers /// - Remark: Generated from `#/paths/items/{itemname}/PUT/requestBody`. @frozen public enum Body: Sendable, Hashable { /// - Remark: Generated from `#/paths/items/{itemname}/PUT/requestBody/content/application\/json`. case json(Components.Schemas.GroupItemDTO) } - public var body: Operations.addOrUpdateItemInRegistry.Input.Body /// Creates a new `Input`. /// @@ -4275,15 +8096,16 @@ public enum Operations { /// - path: /// - headers: /// - body: - public init(path: Operations.addOrUpdateItemInRegistry.Input.Path, - headers: Operations.addOrUpdateItemInRegistry.Input.Headers = .init(), - body: Operations.addOrUpdateItemInRegistry.Input.Body) { + public init( + path: Operations.addOrUpdateItemInRegistry.Input.Path, + headers: Operations.addOrUpdateItemInRegistry.Input.Headers = .init(), + body: Operations.addOrUpdateItemInRegistry.Input.Body + ) { self.path = path self.headers = headers self.body = body } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/items/{itemname}/PUT/responses/200/content`. @@ -4298,12 +8120,11 @@ public enum Operations { get throws { switch self { case let .any(body): - body + return body } } } } - /// Received HTTP response body public var body: Operations.addOrUpdateItemInRegistry.Output.Ok.Body /// Creates a new `Ok`. @@ -4314,7 +8135,6 @@ public enum Operations { self.body = body } } - /// OK /// /// - Remark: Generated from `#/paths//items/{itemname}/put(addOrUpdateItemInRegistry)/responses/200`. @@ -4329,7 +8149,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -4338,12 +8158,10 @@ public enum Operations { } } } - public struct Created: Sendable, Hashable { /// Creates a new `Created`. public init() {} } - /// Item created. /// /// - Remark: Generated from `#/paths//items/{itemname}/put(addOrUpdateItemInRegistry)/responses/201`. @@ -4358,7 +8176,6 @@ public enum Operations { public static var created: Self { .created(.init()) } - /// The associated value of the enum case if `self` is `.created`. /// /// - Throws: An error if `self` is not `.created`. @@ -4367,7 +8184,7 @@ public enum Operations { get throws { switch self { case let .created(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "created", @@ -4376,12 +8193,10 @@ public enum Operations { } } } - public struct BadRequest: Sendable, Hashable { /// Creates a new `BadRequest`. public init() {} } - /// Payload invalid. /// /// - Remark: Generated from `#/paths//items/{itemname}/put(addOrUpdateItemInRegistry)/responses/400`. @@ -4396,7 +8211,6 @@ public enum Operations { public static var badRequest: Self { .badRequest(.init()) } - /// The associated value of the enum case if `self` is `.badRequest`. /// /// - Throws: An error if `self` is not `.badRequest`. @@ -4405,7 +8219,7 @@ public enum Operations { get throws { switch self { case let .badRequest(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "badRequest", @@ -4414,12 +8228,10 @@ public enum Operations { } } } - public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } - /// Item not found or name in path invalid. /// /// - Remark: Generated from `#/paths//items/{itemname}/put(addOrUpdateItemInRegistry)/responses/404`. @@ -4434,7 +8246,6 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } - /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -4443,7 +8254,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -4452,12 +8263,10 @@ public enum Operations { } } } - public struct MethodNotAllowed: Sendable, Hashable { /// Creates a new `MethodNotAllowed`. public init() {} } - /// Item not editable. /// /// - Remark: Generated from `#/paths//items/{itemname}/put(addOrUpdateItemInRegistry)/responses/405`. @@ -4472,7 +8281,6 @@ public enum Operations { public static var methodNotAllowed: Self { .methodNotAllowed(.init()) } - /// The associated value of the enum case if `self` is `.methodNotAllowed`. /// /// - Throws: An error if `self` is not `.methodNotAllowed`. @@ -4481,7 +8289,7 @@ public enum Operations { get throws { switch self { case let .methodNotAllowed(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "methodNotAllowed", @@ -4490,13 +8298,11 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } - @frozen public enum AcceptableContentType: AcceptableProtocol { case any case other(Swift.String) @@ -4508,16 +8314,14 @@ public enum Operations { self = .other(rawValue) } } - public var rawValue: Swift.String { switch self { case let .other(string): - string + return string case .any: - "*/*" + return "*/*" } } - public static var allCases: [Self] { [ .any @@ -4525,7 +8329,6 @@ public enum Operations { } } } - /// Removes an item from the registry. /// /// - Remark: HTTP `DELETE /items/{itemname}`. @@ -4547,7 +8350,6 @@ public enum Operations { self.itemname = itemname } } - public var path: Operations.removeItemFromRegistry.Input.Path /// Creates a new `Input`. /// @@ -4557,13 +8359,11 @@ public enum Operations { self.path = path } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// Creates a new `Ok`. public init() {} } - /// OK /// /// - Remark: Generated from `#/paths//items/{itemname}/delete(removeItemFromRegistry)/responses/200`. @@ -4578,7 +8378,6 @@ public enum Operations { public static var ok: Self { .ok(.init()) } - /// The associated value of the enum case if `self` is `.ok`. /// /// - Throws: An error if `self` is not `.ok`. @@ -4587,7 +8386,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -4596,12 +8395,10 @@ public enum Operations { } } } - public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } - /// Item not found or item is not editable. /// /// - Remark: Generated from `#/paths//items/{itemname}/delete(removeItemFromRegistry)/responses/404`. @@ -4616,7 +8413,6 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } - /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -4625,7 +8421,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -4634,14 +8430,12 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } } - /// Get all available items. /// /// - Remark: HTTP `GET /items`. @@ -4684,12 +8478,14 @@ public enum Operations { /// - recursive: get member items recursively /// - fields: limit output to the given fields (comma separated) /// - staticDataOnly: provides a cacheable list of values not expected to change regularly and checks the If-Modified-Since header, all other parameters are ignored except "metadata" - public init(_type: Swift.String? = nil, - tags: Swift.String? = nil, - metadata: Swift.String? = nil, - recursive: Swift.Bool? = nil, - fields: Swift.String? = nil, - staticDataOnly: Swift.Bool? = nil) { + public init( + _type: Swift.String? = nil, + tags: Swift.String? = nil, + metadata: Swift.String? = nil, + recursive: Swift.Bool? = nil, + fields: Swift.String? = nil, + staticDataOnly: Swift.Bool? = nil + ) { self._type = _type self.tags = tags self.metadata = metadata @@ -4698,7 +8494,6 @@ public enum Operations { self.staticDataOnly = staticDataOnly } } - public var query: Operations.getItems.Input.Query /// - Remark: Generated from `#/paths/items/GET/header`. public struct Headers: Sendable, Hashable { @@ -4712,26 +8507,28 @@ public enum Operations { /// - Parameters: /// - Accept_hyphen_Language: language /// - accept: - public init(Accept_hyphen_Language: Swift.String? = nil, - accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + public init( + Accept_hyphen_Language: Swift.String? = nil, + accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues() + ) { self.Accept_hyphen_Language = Accept_hyphen_Language self.accept = accept } } - public var headers: Operations.getItems.Input.Headers /// Creates a new `Input`. /// /// - Parameters: /// - query: /// - headers: - public init(query: Operations.getItems.Input.Query = .init(), - headers: Operations.getItems.Input.Headers = .init()) { + public init( + query: Operations.getItems.Input.Query = .init(), + headers: Operations.getItems.Input.Headers = .init() + ) { self.query = query self.headers = headers } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/items/GET/responses/200/content`. @@ -4746,12 +8543,11 @@ public enum Operations { get throws { switch self { case let .json(body): - body + return body } } } } - /// Received HTTP response body public var body: Operations.getItems.Output.Ok.Body /// Creates a new `Ok`. @@ -4762,7 +8558,6 @@ public enum Operations { self.body = body } } - /// OK /// /// - Remark: Generated from `#/paths//items/get(getItems)/responses/200`. @@ -4777,7 +8572,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -4786,13 +8581,11 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } - @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -4804,16 +8597,14 @@ public enum Operations { self = .other(rawValue) } } - public var rawValue: Swift.String { switch self { case let .other(string): - string + return string case .json: - "application/json" + return "application/json" } } - public static var allCases: [Self] { [ .json @@ -4821,7 +8612,6 @@ public enum Operations { } } } - /// Adds a list of items to the registry or updates the existing items. /// /// - Remark: HTTP `PUT /items`. @@ -4840,27 +8630,26 @@ public enum Operations { self.accept = accept } } - public var headers: Operations.addOrUpdateItemsInRegistry.Input.Headers /// - Remark: Generated from `#/paths/items/PUT/requestBody`. @frozen public enum Body: Sendable, Hashable { /// - Remark: Generated from `#/paths/items/PUT/requestBody/content/application\/json`. case json([Components.Schemas.GroupItemDTO]) } - public var body: Operations.addOrUpdateItemsInRegistry.Input.Body /// Creates a new `Input`. /// /// - Parameters: /// - headers: /// - body: - public init(headers: Operations.addOrUpdateItemsInRegistry.Input.Headers = .init(), - body: Operations.addOrUpdateItemsInRegistry.Input.Body) { + public init( + headers: Operations.addOrUpdateItemsInRegistry.Input.Headers = .init(), + body: Operations.addOrUpdateItemsInRegistry.Input.Body + ) { self.headers = headers self.body = body } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/items/PUT/responses/200/content`. @@ -4875,12 +8664,11 @@ public enum Operations { get throws { switch self { case let .any(body): - body + return body } } } } - /// Received HTTP response body public var body: Operations.addOrUpdateItemsInRegistry.Output.Ok.Body /// Creates a new `Ok`. @@ -4891,7 +8679,6 @@ public enum Operations { self.body = body } } - /// OK /// /// - Remark: Generated from `#/paths//items/put(addOrUpdateItemsInRegistry)/responses/200`. @@ -4906,7 +8693,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -4915,12 +8702,10 @@ public enum Operations { } } } - public struct BadRequest: Sendable, Hashable { /// Creates a new `BadRequest`. public init() {} } - /// Payload is invalid. /// /// - Remark: Generated from `#/paths//items/put(addOrUpdateItemsInRegistry)/responses/400`. @@ -4935,7 +8720,6 @@ public enum Operations { public static var badRequest: Self { .badRequest(.init()) } - /// The associated value of the enum case if `self` is `.badRequest`. /// /// - Throws: An error if `self` is not `.badRequest`. @@ -4944,7 +8728,7 @@ public enum Operations { get throws { switch self { case let .badRequest(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "badRequest", @@ -4953,13 +8737,11 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } - @frozen public enum AcceptableContentType: AcceptableProtocol { case any case other(Swift.String) @@ -4971,16 +8753,14 @@ public enum Operations { self = .other(rawValue) } } - public var rawValue: Swift.String { switch self { case let .other(string): - string + return string case .any: - "*/*" + return "*/*" } } - public static var allCases: [Self] { [ .any @@ -4988,7 +8768,6 @@ public enum Operations { } } } - /// Gets the state of an item. /// /// - Remark: HTTP `GET /items/{itemname}/state`. @@ -5010,7 +8789,6 @@ public enum Operations { self.itemname = itemname } } - public var path: Operations.getItemState_1.Input.Path /// - Remark: Generated from `#/paths/items/{itemname}/state/GET/header`. public struct Headers: Sendable, Hashable { @@ -5023,20 +8801,20 @@ public enum Operations { self.accept = accept } } - public var headers: Operations.getItemState_1.Input.Headers /// Creates a new `Input`. /// /// - Parameters: /// - path: /// - headers: - public init(path: Operations.getItemState_1.Input.Path, - headers: Operations.getItemState_1.Input.Headers = .init()) { + public init( + path: Operations.getItemState_1.Input.Path, + headers: Operations.getItemState_1.Input.Headers = .init() + ) { self.path = path self.headers = headers } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/items/{itemname}/state/GET/responses/200/content`. @@ -5051,12 +8829,11 @@ public enum Operations { get throws { switch self { case let .plainText(body): - body + return body } } } } - /// Received HTTP response body public var body: Operations.getItemState_1.Output.Ok.Body /// Creates a new `Ok`. @@ -5067,7 +8844,6 @@ public enum Operations { self.body = body } } - /// OK /// /// - Remark: Generated from `#/paths//items/{itemname}/state/get(getItemState_1)/responses/200`. @@ -5082,7 +8858,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -5091,12 +8867,10 @@ public enum Operations { } } } - public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } - /// Item not found /// /// - Remark: Generated from `#/paths//items/{itemname}/state/get(getItemState_1)/responses/404`. @@ -5111,7 +8885,6 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } - /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -5120,7 +8893,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -5129,13 +8902,11 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } - @frozen public enum AcceptableContentType: AcceptableProtocol { case plainText case other(Swift.String) @@ -5147,16 +8918,14 @@ public enum Operations { self = .other(rawValue) } } - public var rawValue: Swift.String { switch self { case let .other(string): - string + return string case .plainText: - "text/plain" + return "text/plain" } } - public static var allCases: [Self] { [ .plainText @@ -5164,7 +8933,6 @@ public enum Operations { } } } - /// Updates the state of an item. /// /// - Remark: HTTP `PUT /items/{itemname}/state`. @@ -5186,7 +8954,6 @@ public enum Operations { self.itemname = itemname } } - public var path: Operations.updateItemState.Input.Path /// - Remark: Generated from `#/paths/items/{itemname}/state/PUT/header`. public struct Headers: Sendable, Hashable { @@ -5202,14 +8969,12 @@ public enum Operations { self.Accept_hyphen_Language = Accept_hyphen_Language } } - public var headers: Operations.updateItemState.Input.Headers /// - Remark: Generated from `#/paths/items/{itemname}/state/PUT/requestBody`. @frozen public enum Body: Sendable, Hashable { /// - Remark: Generated from `#/paths/items/{itemname}/state/PUT/requestBody/content/text\/plain`. case plainText(OpenAPIRuntime.HTTPBody) } - public var body: Operations.updateItemState.Input.Body /// Creates a new `Input`. /// @@ -5217,21 +8982,21 @@ public enum Operations { /// - path: /// - headers: /// - body: - public init(path: Operations.updateItemState.Input.Path, - headers: Operations.updateItemState.Input.Headers = .init(), - body: Operations.updateItemState.Input.Body) { + public init( + path: Operations.updateItemState.Input.Path, + headers: Operations.updateItemState.Input.Headers = .init(), + body: Operations.updateItemState.Input.Body + ) { self.path = path self.headers = headers self.body = body } } - @frozen public enum Output: Sendable, Hashable { public struct Accepted: Sendable, Hashable { /// Creates a new `Accepted`. public init() {} } - /// Accepted /// /// - Remark: Generated from `#/paths//items/{itemname}/state/put(updateItemState)/responses/202`. @@ -5246,7 +9011,6 @@ public enum Operations { public static var accepted: Self { .accepted(.init()) } - /// The associated value of the enum case if `self` is `.accepted`. /// /// - Throws: An error if `self` is not `.accepted`. @@ -5255,7 +9019,7 @@ public enum Operations { get throws { switch self { case let .accepted(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "accepted", @@ -5264,12 +9028,10 @@ public enum Operations { } } } - public struct BadRequest: Sendable, Hashable { /// Creates a new `BadRequest`. public init() {} } - /// Item state null /// /// - Remark: Generated from `#/paths//items/{itemname}/state/put(updateItemState)/responses/400`. @@ -5284,7 +9046,6 @@ public enum Operations { public static var badRequest: Self { .badRequest(.init()) } - /// The associated value of the enum case if `self` is `.badRequest`. /// /// - Throws: An error if `self` is not `.badRequest`. @@ -5293,7 +9054,7 @@ public enum Operations { get throws { switch self { case let .badRequest(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "badRequest", @@ -5302,12 +9063,10 @@ public enum Operations { } } } - public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } - /// Item not found /// /// - Remark: Generated from `#/paths//items/{itemname}/state/put(updateItemState)/responses/404`. @@ -5322,7 +9081,6 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } - /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -5331,7 +9089,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -5340,14 +9098,12 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } } - /// Gets the namespace of an item. /// /// - Remark: HTTP `GET /items/{itemname}/metadata/namespaces`. @@ -5369,7 +9125,6 @@ public enum Operations { self.itemname = itemname } } - public var path: Operations.getItemNamespaces.Input.Path /// - Remark: Generated from `#/paths/items/{itemname}/metadata/namespaces/GET/header`. public struct Headers: Sendable, Hashable { @@ -5383,26 +9138,28 @@ public enum Operations { /// - Parameters: /// - Accept_hyphen_Language: language /// - accept: - public init(Accept_hyphen_Language: Swift.String? = nil, - accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + public init( + Accept_hyphen_Language: Swift.String? = nil, + accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues() + ) { self.Accept_hyphen_Language = Accept_hyphen_Language self.accept = accept } } - public var headers: Operations.getItemNamespaces.Input.Headers /// Creates a new `Input`. /// /// - Parameters: /// - path: /// - headers: - public init(path: Operations.getItemNamespaces.Input.Path, - headers: Operations.getItemNamespaces.Input.Headers = .init()) { + public init( + path: Operations.getItemNamespaces.Input.Path, + headers: Operations.getItemNamespaces.Input.Headers = .init() + ) { self.path = path self.headers = headers } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/items/{itemname}/metadata/namespaces/GET/responses/200/content`. @@ -5417,12 +9174,11 @@ public enum Operations { get throws { switch self { case let .json(body): - body + return body } } } } - /// Received HTTP response body public var body: Operations.getItemNamespaces.Output.Ok.Body /// Creates a new `Ok`. @@ -5433,7 +9189,6 @@ public enum Operations { self.body = body } } - /// OK /// /// - Remark: Generated from `#/paths//items/{itemname}/metadata/namespaces/get(getItemNamespaces)/responses/200`. @@ -5448,7 +9203,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -5457,12 +9212,10 @@ public enum Operations { } } } - public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } - /// Item not found /// /// - Remark: Generated from `#/paths//items/{itemname}/metadata/namespaces/get(getItemNamespaces)/responses/404`. @@ -5477,7 +9230,6 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } - /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -5486,7 +9238,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -5495,13 +9247,11 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } - @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -5513,16 +9263,14 @@ public enum Operations { self = .other(rawValue) } } - public var rawValue: Swift.String { switch self { case let .other(string): - string + return string case .json: - "application/json" + return "application/json" } } - public static var allCases: [Self] { [ .json @@ -5530,7 +9278,6 @@ public enum Operations { } } } - /// Gets the item which defines the requested semantics of an item. /// /// - Remark: HTTP `GET /items/{itemName}/semantic/{semanticClass}`. @@ -5553,13 +9300,14 @@ public enum Operations { /// - Parameters: /// - itemName: item name /// - semanticClass: semantic class - public init(itemName: Swift.String, - semanticClass: Swift.String) { + public init( + itemName: Swift.String, + semanticClass: Swift.String + ) { self.itemName = itemName self.semanticClass = semanticClass } } - public var path: Operations.getSemanticItem.Input.Path /// - Remark: Generated from `#/paths/items/{itemName}/semantic/{semanticClass}/GET/header`. public struct Headers: Sendable, Hashable { @@ -5575,26 +9323,25 @@ public enum Operations { self.Accept_hyphen_Language = Accept_hyphen_Language } } - public var headers: Operations.getSemanticItem.Input.Headers /// Creates a new `Input`. /// /// - Parameters: /// - path: /// - headers: - public init(path: Operations.getSemanticItem.Input.Path, - headers: Operations.getSemanticItem.Input.Headers = .init()) { + public init( + path: Operations.getSemanticItem.Input.Path, + headers: Operations.getSemanticItem.Input.Headers = .init() + ) { self.path = path self.headers = headers } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// Creates a new `Ok`. public init() {} } - /// OK /// /// - Remark: Generated from `#/paths//items/{itemName}/semantic/{semanticClass}/get(getSemanticItem)/responses/200`. @@ -5609,7 +9356,6 @@ public enum Operations { public static var ok: Self { .ok(.init()) } - /// The associated value of the enum case if `self` is `.ok`. /// /// - Throws: An error if `self` is not `.ok`. @@ -5618,7 +9364,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -5627,12 +9373,10 @@ public enum Operations { } } } - public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } - /// Item not found /// /// - Remark: Generated from `#/paths//items/{itemName}/semantic/{semanticClass}/get(getSemanticItem)/responses/404`. @@ -5647,7 +9391,6 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } - /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -5656,7 +9399,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -5665,14 +9408,12 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } } - /// Remove unused/orphaned metadata. /// /// - Remark: HTTP `POST /items/metadata/purge`. @@ -5683,13 +9424,11 @@ public enum Operations { /// Creates a new `Input`. public init() {} } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// Creates a new `Ok`. public init() {} } - /// OK /// /// - Remark: Generated from `#/paths//items/metadata/purge/post(purgeDatabase)/responses/200`. @@ -5704,7 +9443,6 @@ public enum Operations { public static var ok: Self { .ok(.init()) } - /// The associated value of the enum case if `self` is `.ok`. /// /// - Throws: An error if `self` is not `.ok`. @@ -5713,7 +9451,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -5722,14 +9460,12 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } } - /// Gets information about the runtime, the API version and links to resources. /// /// - Remark: HTTP `GET //`. @@ -5748,7 +9484,6 @@ public enum Operations { self.accept = accept } } - public var headers: Operations.getRoot.Input.Headers /// Creates a new `Input`. /// @@ -5758,7 +9493,6 @@ public enum Operations { self.headers = headers } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/GET/responses/200/content`. @@ -5773,12 +9507,11 @@ public enum Operations { get throws { switch self { case let .json(body): - body + return body } } } } - /// Received HTTP response body public var body: Operations.getRoot.Output.Ok.Body /// Creates a new `Ok`. @@ -5789,7 +9522,6 @@ public enum Operations { self.body = body } } - /// OK /// /// - Remark: Generated from `#/paths////get(getRoot)/responses/200`. @@ -5804,7 +9536,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -5813,13 +9545,11 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } - @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -5831,16 +9561,14 @@ public enum Operations { self = .other(rawValue) } } - public var rawValue: Swift.String { switch self { case let .other(string): - string + return string case .json: - "application/json" + return "application/json" } } - public static var allCases: [Self] { [ .json @@ -5848,7 +9576,6 @@ public enum Operations { } } } - /// Gets information about the system. /// /// - Remark: HTTP `GET /systeminfo`. @@ -5867,7 +9594,6 @@ public enum Operations { self.accept = accept } } - public var headers: Operations.getSystemInformation.Input.Headers /// Creates a new `Input`. /// @@ -5877,7 +9603,6 @@ public enum Operations { self.headers = headers } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/systeminfo/GET/responses/200/content`. @@ -5892,12 +9617,11 @@ public enum Operations { get throws { switch self { case let .json(body): - body + return body } } } } - /// Received HTTP response body public var body: Operations.getSystemInformation.Output.Ok.Body /// Creates a new `Ok`. @@ -5908,7 +9632,6 @@ public enum Operations { self.body = body } } - /// OK /// /// - Remark: Generated from `#/paths//systeminfo/get(getSystemInformation)/responses/200`. @@ -5923,7 +9646,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -5932,13 +9655,11 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } - @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -5950,16 +9671,14 @@ public enum Operations { self = .other(rawValue) } } - public var rawValue: Swift.String { switch self { case let .other(string): - string + return string case .json: - "application/json" + return "application/json" } } - public static var allCases: [Self] { [ .json @@ -5967,7 +9686,6 @@ public enum Operations { } } } - /// Get all supported dimensions and their system units. /// /// - Remark: HTTP `GET /systeminfo/uom`. @@ -5986,7 +9704,6 @@ public enum Operations { self.accept = accept } } - public var headers: Operations.getUoMInformation.Input.Headers /// Creates a new `Input`. /// @@ -5996,7 +9713,6 @@ public enum Operations { self.headers = headers } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/systeminfo/uom/GET/responses/200/content`. @@ -6011,12 +9727,11 @@ public enum Operations { get throws { switch self { case let .json(body): - body + return body } } } } - /// Received HTTP response body public var body: Operations.getUoMInformation.Output.Ok.Body /// Creates a new `Ok`. @@ -6027,7 +9742,6 @@ public enum Operations { self.body = body } } - /// OK /// /// - Remark: Generated from `#/paths//systeminfo/uom/get(getUoMInformation)/responses/200`. @@ -6042,7 +9756,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -6051,13 +9765,11 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } - @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -6069,16 +9781,14 @@ public enum Operations { self = .other(rawValue) } } - public var rawValue: Swift.String { switch self { case let .other(string): - string + return string case .json: - "application/json" + return "application/json" } } - public static var allCases: [Self] { [ .json @@ -6086,7 +9796,6 @@ public enum Operations { } } } - /// Creates a sitemap event subscription. /// /// - Remark: HTTP `POST /sitemaps/events/subscribe`. @@ -6105,7 +9814,6 @@ public enum Operations { self.accept = accept } } - public var headers: Operations.createSitemapEventSubscription.Input.Headers /// Creates a new `Input`. /// @@ -6115,13 +9823,11 @@ public enum Operations { self.headers = headers } } - @frozen public enum Output: Sendable, Hashable { public struct Created: Sendable, Hashable { /// Creates a new `Created`. public init() {} } - /// Subscription created. /// /// - Remark: Generated from `#/paths//sitemaps/events/subscribe/post(createSitemapEventSubscription)/responses/201`. @@ -6136,7 +9842,6 @@ public enum Operations { public static var created: Self { .created(.init()) } - /// The associated value of the enum case if `self` is `.created`. /// /// - Throws: An error if `self` is not `.created`. @@ -6145,7 +9850,7 @@ public enum Operations { get throws { switch self { case let .created(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "created", @@ -6154,7 +9859,6 @@ public enum Operations { } } } - public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/sitemaps/events/subscribe/POST/responses/200/content`. @frozen public enum Body: Sendable, Hashable { @@ -6168,12 +9872,11 @@ public enum Operations { get throws { switch self { case let .json(body): - body + return body } } } } - /// Received HTTP response body public var body: Operations.createSitemapEventSubscription.Output.Ok.Body /// Creates a new `Ok`. @@ -6184,7 +9887,6 @@ public enum Operations { self.body = body } } - /// OK /// /// - Remark: Generated from `#/paths//sitemaps/events/subscribe/post(createSitemapEventSubscription)/responses/200`. @@ -6199,7 +9901,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -6208,12 +9910,10 @@ public enum Operations { } } } - public struct ServiceUnavailable: Sendable, Hashable { /// Creates a new `ServiceUnavailable`. public init() {} } - /// Subscriptions limit reached. /// /// - Remark: Generated from `#/paths//sitemaps/events/subscribe/post(createSitemapEventSubscription)/responses/503`. @@ -6228,7 +9928,6 @@ public enum Operations { public static var serviceUnavailable: Self { .serviceUnavailable(.init()) } - /// The associated value of the enum case if `self` is `.serviceUnavailable`. /// /// - Throws: An error if `self` is not `.serviceUnavailable`. @@ -6237,7 +9936,7 @@ public enum Operations { get throws { switch self { case let .serviceUnavailable(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "serviceUnavailable", @@ -6246,13 +9945,11 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } - @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -6264,16 +9961,14 @@ public enum Operations { self = .other(rawValue) } } - public var rawValue: Swift.String { switch self { case let .other(string): - string + return string case .json: - "application/json" + return "application/json" } } - public static var allCases: [Self] { [ .json @@ -6281,7 +9976,6 @@ public enum Operations { } } } - /// Polls the data for one page of a sitemap. /// /// - Remark: HTTP `GET /sitemaps/{sitemapname}/{pageid}`. @@ -6304,13 +9998,14 @@ public enum Operations { /// - Parameters: /// - sitemapname: sitemap name /// - pageid: page id - public init(sitemapname: Swift.String, - pageid: Swift.String) { + public init( + sitemapname: Swift.String, + pageid: Swift.String + ) { self.sitemapname = sitemapname self.pageid = pageid } } - public var path: Operations.pollDataForPage.Input.Path /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/{pageid}/GET/query`. public struct Query: Sendable, Hashable { @@ -6327,13 +10022,14 @@ public enum Operations { /// - Parameters: /// - subscriptionid: subscriptionid /// - includeHidden: include hidden widgets - public init(subscriptionid: Swift.String? = nil, - includeHidden: Swift.Bool? = nil) { + public init( + subscriptionid: Swift.String? = nil, + includeHidden: Swift.Bool? = nil + ) { self.subscriptionid = subscriptionid self.includeHidden = includeHidden } } - public var query: Operations.pollDataForPage.Input.Query /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/{pageid}/GET/header`. public struct Headers: Sendable, Hashable { @@ -6352,15 +10048,16 @@ public enum Operations { /// - Accept_hyphen_Language: language /// - X_hyphen_Atmosphere_hyphen_Transport: X-Atmosphere-Transport for long polling /// - accept: - public init(Accept_hyphen_Language: Swift.String? = nil, - X_hyphen_Atmosphere_hyphen_Transport: Swift.String? = nil, - accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + public init( + Accept_hyphen_Language: Swift.String? = nil, + X_hyphen_Atmosphere_hyphen_Transport: Swift.String? = nil, + accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues() + ) { self.Accept_hyphen_Language = Accept_hyphen_Language self.X_hyphen_Atmosphere_hyphen_Transport = X_hyphen_Atmosphere_hyphen_Transport self.accept = accept } } - public var headers: Operations.pollDataForPage.Input.Headers /// Creates a new `Input`. /// @@ -6368,15 +10065,16 @@ public enum Operations { /// - path: /// - query: /// - headers: - public init(path: Operations.pollDataForPage.Input.Path, - query: Operations.pollDataForPage.Input.Query = .init(), - headers: Operations.pollDataForPage.Input.Headers = .init()) { + public init( + path: Operations.pollDataForPage.Input.Path, + query: Operations.pollDataForPage.Input.Query = .init(), + headers: Operations.pollDataForPage.Input.Headers = .init() + ) { self.path = path self.query = query self.headers = headers } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/{pageid}/GET/responses/200/content`. @@ -6391,12 +10089,11 @@ public enum Operations { get throws { switch self { case let .json(body): - body + return body } } } } - /// Received HTTP response body public var body: Operations.pollDataForPage.Output.Ok.Body /// Creates a new `Ok`. @@ -6407,7 +10104,6 @@ public enum Operations { self.body = body } } - /// OK /// /// - Remark: Generated from `#/paths//sitemaps/{sitemapname}/{pageid}/get(pollDataForPage)/responses/200`. @@ -6422,7 +10118,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -6431,12 +10127,10 @@ public enum Operations { } } } - public struct BadRequest: Sendable, Hashable { /// Creates a new `BadRequest`. public init() {} } - /// Invalid subscription id has been provided. /// /// - Remark: Generated from `#/paths//sitemaps/{sitemapname}/{pageid}/get(pollDataForPage)/responses/400`. @@ -6451,7 +10145,6 @@ public enum Operations { public static var badRequest: Self { .badRequest(.init()) } - /// The associated value of the enum case if `self` is `.badRequest`. /// /// - Throws: An error if `self` is not `.badRequest`. @@ -6460,7 +10153,7 @@ public enum Operations { get throws { switch self { case let .badRequest(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "badRequest", @@ -6469,12 +10162,10 @@ public enum Operations { } } } - public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } - /// Sitemap with requested name does not exist or page does not exist, or page refers to a non-linkable widget /// /// - Remark: Generated from `#/paths//sitemaps/{sitemapname}/{pageid}/get(pollDataForPage)/responses/404`. @@ -6489,7 +10180,6 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } - /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -6498,7 +10188,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -6507,13 +10197,11 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } - @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -6525,16 +10213,14 @@ public enum Operations { self = .other(rawValue) } } - public var rawValue: Swift.String { switch self { case let .other(string): - string + return string case .json: - "application/json" + return "application/json" } } - public static var allCases: [Self] { [ .json @@ -6542,7 +10228,6 @@ public enum Operations { } } } - /// Polls the data for a whole sitemap. Not recommended due to potentially high traffic. /// /// - Remark: HTTP `GET /sitemaps/{sitemapname}/*`. @@ -6564,7 +10249,6 @@ public enum Operations { self.sitemapname = sitemapname } } - public var path: Operations.pollDataForSitemap.Input.Path /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/*/GET/query`. public struct Query: Sendable, Hashable { @@ -6581,13 +10265,14 @@ public enum Operations { /// - Parameters: /// - subscriptionid: subscriptionid /// - includeHidden: include hidden widgets - public init(subscriptionid: Swift.String? = nil, - includeHidden: Swift.Bool? = nil) { + public init( + subscriptionid: Swift.String? = nil, + includeHidden: Swift.Bool? = nil + ) { self.subscriptionid = subscriptionid self.includeHidden = includeHidden } } - public var query: Operations.pollDataForSitemap.Input.Query /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/*/GET/header`. public struct Headers: Sendable, Hashable { @@ -6606,15 +10291,16 @@ public enum Operations { /// - Accept_hyphen_Language: language /// - X_hyphen_Atmosphere_hyphen_Transport: X-Atmosphere-Transport for long polling /// - accept: - public init(Accept_hyphen_Language: Swift.String? = nil, - X_hyphen_Atmosphere_hyphen_Transport: Swift.String? = nil, - accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + public init( + Accept_hyphen_Language: Swift.String? = nil, + X_hyphen_Atmosphere_hyphen_Transport: Swift.String? = nil, + accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues() + ) { self.Accept_hyphen_Language = Accept_hyphen_Language self.X_hyphen_Atmosphere_hyphen_Transport = X_hyphen_Atmosphere_hyphen_Transport self.accept = accept } } - public var headers: Operations.pollDataForSitemap.Input.Headers /// Creates a new `Input`. /// @@ -6622,15 +10308,16 @@ public enum Operations { /// - path: /// - query: /// - headers: - public init(path: Operations.pollDataForSitemap.Input.Path, - query: Operations.pollDataForSitemap.Input.Query = .init(), - headers: Operations.pollDataForSitemap.Input.Headers = .init()) { + public init( + path: Operations.pollDataForSitemap.Input.Path, + query: Operations.pollDataForSitemap.Input.Query = .init(), + headers: Operations.pollDataForSitemap.Input.Headers = .init() + ) { self.path = path self.query = query self.headers = headers } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/*/GET/responses/200/content`. @@ -6645,12 +10332,11 @@ public enum Operations { get throws { switch self { case let .json(body): - body + return body } } } } - /// Received HTTP response body public var body: Operations.pollDataForSitemap.Output.Ok.Body /// Creates a new `Ok`. @@ -6661,7 +10347,6 @@ public enum Operations { self.body = body } } - /// OK /// /// - Remark: Generated from `#/paths//sitemaps/{sitemapname}/*/get(pollDataForSitemap)/responses/200`. @@ -6676,7 +10361,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -6685,12 +10370,10 @@ public enum Operations { } } } - public struct BadRequest: Sendable, Hashable { /// Creates a new `BadRequest`. public init() {} } - /// Invalid subscription id has been provided. /// /// - Remark: Generated from `#/paths//sitemaps/{sitemapname}/*/get(pollDataForSitemap)/responses/400`. @@ -6705,7 +10388,6 @@ public enum Operations { public static var badRequest: Self { .badRequest(.init()) } - /// The associated value of the enum case if `self` is `.badRequest`. /// /// - Throws: An error if `self` is not `.badRequest`. @@ -6714,7 +10396,7 @@ public enum Operations { get throws { switch self { case let .badRequest(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "badRequest", @@ -6723,12 +10405,10 @@ public enum Operations { } } } - public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } - /// Sitemap with requested name does not exist /// /// - Remark: Generated from `#/paths//sitemaps/{sitemapname}/*/get(pollDataForSitemap)/responses/404`. @@ -6743,7 +10423,6 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } - /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -6752,7 +10431,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -6761,13 +10440,11 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } - @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -6779,16 +10456,14 @@ public enum Operations { self = .other(rawValue) } } - public var rawValue: Swift.String { switch self { case let .other(string): - string + return string case .json: - "application/json" + return "application/json" } } - public static var allCases: [Self] { [ .json @@ -6796,7 +10471,6 @@ public enum Operations { } } } - /// Get sitemap by name. /// /// - Remark: HTTP `GET /sitemaps/{sitemapname}`. @@ -6818,7 +10492,6 @@ public enum Operations { self.sitemapname = sitemapname } } - public var path: Operations.getSitemapByName.Input.Path /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/GET/query`. public struct Query: Sendable, Hashable { @@ -6836,15 +10509,16 @@ public enum Operations { /// - _type: /// - jsoncallback: /// - includeHidden: include hidden widgets - public init(_type: Swift.String? = nil, - jsoncallback: Swift.String? = nil, - includeHidden: Swift.Bool? = nil) { + public init( + _type: Swift.String? = nil, + jsoncallback: Swift.String? = nil, + includeHidden: Swift.Bool? = nil + ) { self._type = _type self.jsoncallback = jsoncallback self.includeHidden = includeHidden } } - public var query: Operations.getSitemapByName.Input.Query /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/GET/header`. public struct Headers: Sendable, Hashable { @@ -6858,13 +10532,14 @@ public enum Operations { /// - Parameters: /// - Accept_hyphen_Language: language /// - accept: - public init(Accept_hyphen_Language: Swift.String? = nil, - accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + public init( + Accept_hyphen_Language: Swift.String? = nil, + accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues() + ) { self.Accept_hyphen_Language = Accept_hyphen_Language self.accept = accept } } - public var headers: Operations.getSitemapByName.Input.Headers /// Creates a new `Input`. /// @@ -6872,15 +10547,16 @@ public enum Operations { /// - path: /// - query: /// - headers: - public init(path: Operations.getSitemapByName.Input.Path, - query: Operations.getSitemapByName.Input.Query = .init(), - headers: Operations.getSitemapByName.Input.Headers = .init()) { + public init( + path: Operations.getSitemapByName.Input.Path, + query: Operations.getSitemapByName.Input.Query = .init(), + headers: Operations.getSitemapByName.Input.Headers = .init() + ) { self.path = path self.query = query self.headers = headers } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/GET/responses/200/content`. @@ -6895,12 +10571,11 @@ public enum Operations { get throws { switch self { case let .json(body): - body + return body } } } } - /// Received HTTP response body public var body: Operations.getSitemapByName.Output.Ok.Body /// Creates a new `Ok`. @@ -6911,7 +10586,6 @@ public enum Operations { self.body = body } } - /// OK /// /// - Remark: Generated from `#/paths//sitemaps/{sitemapname}/get(getSitemapByName)/responses/200`. @@ -6926,7 +10600,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -6935,13 +10609,11 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } - @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -6953,16 +10625,14 @@ public enum Operations { self = .other(rawValue) } } - public var rawValue: Swift.String { switch self { case let .other(string): - string + return string case .json: - "application/json" + return "application/json" } } - public static var allCases: [Self] { [ .json @@ -6970,7 +10640,6 @@ public enum Operations { } } } - /// Get sitemap events for a whole sitemap. Not recommended due to potentially high traffic. /// /// - Remark: HTTP `GET /sitemaps/events/{subscriptionid}/*`. @@ -6992,7 +10661,6 @@ public enum Operations { self.subscriptionid = subscriptionid } } - public var path: Operations.getSitemapEvents.Input.Path /// - Remark: Generated from `#/paths/sitemaps/events/{subscriptionid}/*/GET/query`. public struct Query: Sendable, Hashable { @@ -7008,26 +10676,25 @@ public enum Operations { self.sitemap = sitemap } } - public var query: Operations.getSitemapEvents.Input.Query /// Creates a new `Input`. /// /// - Parameters: /// - path: /// - query: - public init(path: Operations.getSitemapEvents.Input.Path, - query: Operations.getSitemapEvents.Input.Query = .init()) { + public init( + path: Operations.getSitemapEvents.Input.Path, + query: Operations.getSitemapEvents.Input.Query = .init() + ) { self.path = path self.query = query } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// Creates a new `Ok`. public init() {} } - /// OK /// /// - Remark: Generated from `#/paths//sitemaps/events/{subscriptionid}/*/get(getSitemapEvents)/responses/200`. @@ -7042,7 +10709,6 @@ public enum Operations { public static var ok: Self { .ok(.init()) } - /// The associated value of the enum case if `self` is `.ok`. /// /// - Throws: An error if `self` is not `.ok`. @@ -7051,7 +10717,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -7060,12 +10726,10 @@ public enum Operations { } } } - public struct BadRequest: Sendable, Hashable { /// Creates a new `BadRequest`. public init() {} } - /// Missing sitemap parameter, or sitemap not linked successfully to the subscription. /// /// - Remark: Generated from `#/paths//sitemaps/events/{subscriptionid}/*/get(getSitemapEvents)/responses/400`. @@ -7080,7 +10744,6 @@ public enum Operations { public static var badRequest: Self { .badRequest(.init()) } - /// The associated value of the enum case if `self` is `.badRequest`. /// /// - Throws: An error if `self` is not `.badRequest`. @@ -7089,7 +10752,7 @@ public enum Operations { get throws { switch self { case let .badRequest(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "badRequest", @@ -7098,12 +10761,10 @@ public enum Operations { } } } - public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } - /// Subscription not found. /// /// - Remark: Generated from `#/paths//sitemaps/events/{subscriptionid}/*/get(getSitemapEvents)/responses/404`. @@ -7118,7 +10779,6 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } - /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -7127,7 +10787,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -7136,14 +10796,12 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } } - /// Get sitemap events. /// /// - Remark: HTTP `GET /sitemaps/events/{subscriptionid}`. @@ -7165,7 +10823,6 @@ public enum Operations { self.subscriptionid = subscriptionid } } - public var path: Operations.getSitemapEvents_1.Input.Path /// - Remark: Generated from `#/paths/sitemaps/events/{subscriptionid}/GET/query`. public struct Query: Sendable, Hashable { @@ -7182,13 +10839,14 @@ public enum Operations { /// - Parameters: /// - sitemap: sitemap name /// - pageid: page id - public init(sitemap: Swift.String? = nil, - pageid: Swift.String? = nil) { + public init( + sitemap: Swift.String? = nil, + pageid: Swift.String? = nil + ) { self.sitemap = sitemap self.pageid = pageid } } - public var query: Operations.getSitemapEvents_1.Input.Query /// - Remark: Generated from `#/paths/sitemaps/events/{subscriptionid}/GET/header`. public struct Headers: Sendable, Hashable { @@ -7201,7 +10859,6 @@ public enum Operations { self.accept = accept } } - public var headers: Operations.getSitemapEvents_1.Input.Headers /// Creates a new `Input`. /// @@ -7209,15 +10866,16 @@ public enum Operations { /// - path: /// - query: /// - headers: - public init(path: Operations.getSitemapEvents_1.Input.Path, - query: Operations.getSitemapEvents_1.Input.Query = .init(), - headers: Operations.getSitemapEvents_1.Input.Headers = .init()) { + public init( + path: Operations.getSitemapEvents_1.Input.Path, + query: Operations.getSitemapEvents_1.Input.Query = .init(), + headers: Operations.getSitemapEvents_1.Input.Headers = .init() + ) { self.path = path self.query = query self.headers = headers } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/sitemaps/events/{subscriptionid}/GET/responses/200/content`. @@ -7232,7 +10890,7 @@ public enum Operations { get throws { switch self { case let .text_event_hyphen_stream(body): - body + return body default: try throwUnexpectedResponseBody( expectedContent: "text/event-stream", @@ -7241,7 +10899,6 @@ public enum Operations { } } } - /// - Remark: Generated from `#/paths/sitemaps/events/{subscriptionid}/GET/responses/200/content/application\/json`. case json(Components.Schemas.SitemapWidgetEvent) /// The associated value of the enum case if `self` is `.json`. @@ -7252,7 +10909,7 @@ public enum Operations { get throws { switch self { case let .json(body): - body + return body default: try throwUnexpectedResponseBody( expectedContent: "application/json", @@ -7262,7 +10919,6 @@ public enum Operations { } } } - /// Received HTTP response body public var body: Operations.getSitemapEvents_1.Output.Ok.Body /// Creates a new `Ok`. @@ -7273,7 +10929,6 @@ public enum Operations { self.body = body } } - /// OK /// /// - Remark: Generated from `#/paths//sitemaps/events/{subscriptionid}/get(getSitemapEvents_1)/responses/200`. @@ -7288,7 +10943,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -7297,12 +10952,10 @@ public enum Operations { } } } - public struct BadRequest: Sendable, Hashable { /// Creates a new `BadRequest`. public init() {} } - /// Missing sitemap or page parameter, or page not linked successfully to the subscription. /// /// - Remark: Generated from `#/paths//sitemaps/events/{subscriptionid}/get(getSitemapEvents_1)/responses/400`. @@ -7317,7 +10970,6 @@ public enum Operations { public static var badRequest: Self { .badRequest(.init()) } - /// The associated value of the enum case if `self` is `.badRequest`. /// /// - Throws: An error if `self` is not `.badRequest`. @@ -7326,7 +10978,7 @@ public enum Operations { get throws { switch self { case let .badRequest(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "badRequest", @@ -7335,12 +10987,10 @@ public enum Operations { } } } - public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } - /// Subscription not found. /// /// - Remark: Generated from `#/paths//sitemaps/events/{subscriptionid}/get(getSitemapEvents_1)/responses/404`. @@ -7355,7 +11005,6 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } - /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -7364,7 +11013,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -7373,13 +11022,11 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } - @frozen public enum AcceptableContentType: AcceptableProtocol { case text_event_hyphen_stream case json @@ -7394,18 +11041,16 @@ public enum Operations { self = .other(rawValue) } } - public var rawValue: Swift.String { switch self { case let .other(string): - string + return string case .text_event_hyphen_stream: - "text/event-stream" + return "text/event-stream" case .json: - "application/json" + return "application/json" } } - public static var allCases: [Self] { [ .text_event_hyphen_stream, @@ -7414,7 +11059,6 @@ public enum Operations { } } } - /// Get all available sitemaps. /// /// - Remark: HTTP `GET /sitemaps`. @@ -7433,7 +11077,6 @@ public enum Operations { self.accept = accept } } - public var headers: Operations.getSitemaps.Input.Headers /// Creates a new `Input`. /// @@ -7443,7 +11086,6 @@ public enum Operations { self.headers = headers } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/sitemaps/GET/responses/200/content`. @@ -7458,12 +11100,11 @@ public enum Operations { get throws { switch self { case let .json(body): - body + return body } } } } - /// Received HTTP response body public var body: Operations.getSitemaps.Output.Ok.Body /// Creates a new `Ok`. @@ -7474,7 +11115,6 @@ public enum Operations { self.body = body } } - /// OK /// /// - Remark: Generated from `#/paths//sitemaps/get(getSitemaps)/responses/200`. @@ -7489,7 +11129,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -7498,13 +11138,11 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } - @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -7516,16 +11154,14 @@ public enum Operations { self = .other(rawValue) } } - public var rawValue: Swift.String { switch self { case let .other(string): - string + return string case .json: - "application/json" + return "application/json" } } - public static var allCases: [Self] { [ .json @@ -7533,7 +11169,6 @@ public enum Operations { } } } - /// Get all registered UI components in the specified namespace. /// /// - Remark: HTTP `GET /ui/components/{namespace}`. @@ -7553,7 +11188,6 @@ public enum Operations { self.namespace = namespace } } - public var path: Operations.getRegisteredUIComponentsInNamespace.Input.Path /// - Remark: Generated from `#/paths/ui/components/{namespace}/GET/query`. public struct Query: Sendable, Hashable { @@ -7569,7 +11203,6 @@ public enum Operations { self.summary = summary } } - public var query: Operations.getRegisteredUIComponentsInNamespace.Input.Query /// - Remark: Generated from `#/paths/ui/components/{namespace}/GET/header`. public struct Headers: Sendable, Hashable { @@ -7582,7 +11215,6 @@ public enum Operations { self.accept = accept } } - public var headers: Operations.getRegisteredUIComponentsInNamespace.Input.Headers /// Creates a new `Input`. /// @@ -7590,15 +11222,16 @@ public enum Operations { /// - path: /// - query: /// - headers: - public init(path: Operations.getRegisteredUIComponentsInNamespace.Input.Path, - query: Operations.getRegisteredUIComponentsInNamespace.Input.Query = .init(), - headers: Operations.getRegisteredUIComponentsInNamespace.Input.Headers = .init()) { + public init( + path: Operations.getRegisteredUIComponentsInNamespace.Input.Path, + query: Operations.getRegisteredUIComponentsInNamespace.Input.Query = .init(), + headers: Operations.getRegisteredUIComponentsInNamespace.Input.Headers = .init() + ) { self.path = path self.query = query self.headers = headers } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/ui/components/{namespace}/GET/responses/200/content`. @@ -7613,12 +11246,11 @@ public enum Operations { get throws { switch self { case let .json(body): - body + return body } } } } - /// Received HTTP response body public var body: Operations.getRegisteredUIComponentsInNamespace.Output.Ok.Body /// Creates a new `Ok`. @@ -7629,7 +11261,6 @@ public enum Operations { self.body = body } } - /// OK /// /// - Remark: Generated from `#/paths//ui/components/{namespace}/get(getRegisteredUIComponentsInNamespace)/responses/200`. @@ -7644,7 +11275,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -7653,13 +11284,11 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } - @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -7671,16 +11300,14 @@ public enum Operations { self = .other(rawValue) } } - public var rawValue: Swift.String { switch self { case let .other(string): - string + return string case .json: - "application/json" + return "application/json" } } - public static var allCases: [Self] { [ .json @@ -7688,7 +11315,6 @@ public enum Operations { } } } - /// Add a UI component in the specified namespace. /// /// - Remark: HTTP `POST /ui/components/{namespace}`. @@ -7708,7 +11334,6 @@ public enum Operations { self.namespace = namespace } } - public var path: Operations.addUIComponentToNamespace.Input.Path /// - Remark: Generated from `#/paths/ui/components/{namespace}/POST/header`. public struct Headers: Sendable, Hashable { @@ -7721,14 +11346,12 @@ public enum Operations { self.accept = accept } } - public var headers: Operations.addUIComponentToNamespace.Input.Headers /// - Remark: Generated from `#/paths/ui/components/{namespace}/POST/requestBody`. @frozen public enum Body: Sendable, Hashable { /// - Remark: Generated from `#/paths/ui/components/{namespace}/POST/requestBody/content/application\/json`. case json(Components.Schemas.RootUIComponent) } - public var body: Operations.addUIComponentToNamespace.Input.Body? /// Creates a new `Input`. /// @@ -7736,15 +11359,16 @@ public enum Operations { /// - path: /// - headers: /// - body: - public init(path: Operations.addUIComponentToNamespace.Input.Path, - headers: Operations.addUIComponentToNamespace.Input.Headers = .init(), - body: Operations.addUIComponentToNamespace.Input.Body? = nil) { + public init( + path: Operations.addUIComponentToNamespace.Input.Path, + headers: Operations.addUIComponentToNamespace.Input.Headers = .init(), + body: Operations.addUIComponentToNamespace.Input.Body? = nil + ) { self.path = path self.headers = headers self.body = body } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/ui/components/{namespace}/POST/responses/200/content`. @@ -7759,12 +11383,11 @@ public enum Operations { get throws { switch self { case let .json(body): - body + return body } } } } - /// Received HTTP response body public var body: Operations.addUIComponentToNamespace.Output.Ok.Body /// Creates a new `Ok`. @@ -7775,7 +11398,6 @@ public enum Operations { self.body = body } } - /// OK /// /// - Remark: Generated from `#/paths//ui/components/{namespace}/post(addUIComponentToNamespace)/responses/200`. @@ -7790,7 +11412,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -7799,13 +11421,11 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } - @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -7817,16 +11437,14 @@ public enum Operations { self = .other(rawValue) } } - public var rawValue: Swift.String { switch self { case let .other(string): - string + return string case .json: - "application/json" + return "application/json" } } - public static var allCases: [Self] { [ .json @@ -7834,7 +11452,6 @@ public enum Operations { } } } - /// Get a specific UI component in the specified namespace. /// /// - Remark: HTTP `GET /ui/components/{namespace}/{componentUID}`. @@ -7853,13 +11470,14 @@ public enum Operations { /// - Parameters: /// - namespace: /// - componentUID: - public init(namespace: Swift.String, - componentUID: Swift.String) { + public init( + namespace: Swift.String, + componentUID: Swift.String + ) { self.namespace = namespace self.componentUID = componentUID } } - public var path: Operations.getUIComponentInNamespace.Input.Path /// - Remark: Generated from `#/paths/ui/components/{namespace}/{componentUID}/GET/header`. public struct Headers: Sendable, Hashable { @@ -7872,20 +11490,20 @@ public enum Operations { self.accept = accept } } - public var headers: Operations.getUIComponentInNamespace.Input.Headers /// Creates a new `Input`. /// /// - Parameters: /// - path: /// - headers: - public init(path: Operations.getUIComponentInNamespace.Input.Path, - headers: Operations.getUIComponentInNamespace.Input.Headers = .init()) { + public init( + path: Operations.getUIComponentInNamespace.Input.Path, + headers: Operations.getUIComponentInNamespace.Input.Headers = .init() + ) { self.path = path self.headers = headers } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/ui/components/{namespace}/{componentUID}/GET/responses/200/content`. @@ -7900,12 +11518,11 @@ public enum Operations { get throws { switch self { case let .json(body): - body + return body } } } } - /// Received HTTP response body public var body: Operations.getUIComponentInNamespace.Output.Ok.Body /// Creates a new `Ok`. @@ -7916,7 +11533,6 @@ public enum Operations { self.body = body } } - /// OK /// /// - Remark: Generated from `#/paths//ui/components/{namespace}/{componentUID}/get(getUIComponentInNamespace)/responses/200`. @@ -7931,7 +11547,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -7940,12 +11556,10 @@ public enum Operations { } } } - public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } - /// Component not found /// /// - Remark: Generated from `#/paths//ui/components/{namespace}/{componentUID}/get(getUIComponentInNamespace)/responses/404`. @@ -7960,7 +11574,6 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } - /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -7969,7 +11582,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -7978,13 +11591,11 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } - @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -7996,16 +11607,14 @@ public enum Operations { self = .other(rawValue) } } - public var rawValue: Swift.String { switch self { case let .other(string): - string + return string case .json: - "application/json" + return "application/json" } } - public static var allCases: [Self] { [ .json @@ -8013,7 +11622,6 @@ public enum Operations { } } } - /// Update a specific UI component in the specified namespace. /// /// - Remark: HTTP `PUT /ui/components/{namespace}/{componentUID}`. @@ -8032,13 +11640,14 @@ public enum Operations { /// - Parameters: /// - namespace: /// - componentUID: - public init(namespace: Swift.String, - componentUID: Swift.String) { + public init( + namespace: Swift.String, + componentUID: Swift.String + ) { self.namespace = namespace self.componentUID = componentUID } } - public var path: Operations.updateUIComponentInNamespace.Input.Path /// - Remark: Generated from `#/paths/ui/components/{namespace}/{componentUID}/PUT/header`. public struct Headers: Sendable, Hashable { @@ -8051,14 +11660,12 @@ public enum Operations { self.accept = accept } } - public var headers: Operations.updateUIComponentInNamespace.Input.Headers /// - Remark: Generated from `#/paths/ui/components/{namespace}/{componentUID}/PUT/requestBody`. @frozen public enum Body: Sendable, Hashable { /// - Remark: Generated from `#/paths/ui/components/{namespace}/{componentUID}/PUT/requestBody/content/application\/json`. case json(Components.Schemas.RootUIComponent) } - public var body: Operations.updateUIComponentInNamespace.Input.Body? /// Creates a new `Input`. /// @@ -8066,15 +11673,16 @@ public enum Operations { /// - path: /// - headers: /// - body: - public init(path: Operations.updateUIComponentInNamespace.Input.Path, - headers: Operations.updateUIComponentInNamespace.Input.Headers = .init(), - body: Operations.updateUIComponentInNamespace.Input.Body? = nil) { + public init( + path: Operations.updateUIComponentInNamespace.Input.Path, + headers: Operations.updateUIComponentInNamespace.Input.Headers = .init(), + body: Operations.updateUIComponentInNamespace.Input.Body? = nil + ) { self.path = path self.headers = headers self.body = body } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/ui/components/{namespace}/{componentUID}/PUT/responses/200/content`. @@ -8089,12 +11697,11 @@ public enum Operations { get throws { switch self { case let .json(body): - body + return body } } } } - /// Received HTTP response body public var body: Operations.updateUIComponentInNamespace.Output.Ok.Body /// Creates a new `Ok`. @@ -8105,7 +11712,6 @@ public enum Operations { self.body = body } } - /// OK /// /// - Remark: Generated from `#/paths//ui/components/{namespace}/{componentUID}/put(updateUIComponentInNamespace)/responses/200`. @@ -8120,7 +11726,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -8129,12 +11735,10 @@ public enum Operations { } } } - public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } - /// Component not found /// /// - Remark: Generated from `#/paths//ui/components/{namespace}/{componentUID}/put(updateUIComponentInNamespace)/responses/404`. @@ -8149,7 +11753,6 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } - /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -8158,7 +11761,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -8167,13 +11770,11 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } - @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -8185,16 +11786,14 @@ public enum Operations { self = .other(rawValue) } } - public var rawValue: Swift.String { switch self { case let .other(string): - string + return string case .json: - "application/json" + return "application/json" } } - public static var allCases: [Self] { [ .json @@ -8202,7 +11801,6 @@ public enum Operations { } } } - /// Remove a specific UI component in the specified namespace. /// /// - Remark: HTTP `DELETE /ui/components/{namespace}/{componentUID}`. @@ -8221,13 +11819,14 @@ public enum Operations { /// - Parameters: /// - namespace: /// - componentUID: - public init(namespace: Swift.String, - componentUID: Swift.String) { + public init( + namespace: Swift.String, + componentUID: Swift.String + ) { self.namespace = namespace self.componentUID = componentUID } } - public var path: Operations.removeUIComponentFromNamespace.Input.Path /// Creates a new `Input`. /// @@ -8237,13 +11836,11 @@ public enum Operations { self.path = path } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// Creates a new `Ok`. public init() {} } - /// OK /// /// - Remark: Generated from `#/paths//ui/components/{namespace}/{componentUID}/delete(removeUIComponentFromNamespace)/responses/200`. @@ -8258,7 +11855,6 @@ public enum Operations { public static var ok: Self { .ok(.init()) } - /// The associated value of the enum case if `self` is `.ok`. /// /// - Throws: An error if `self` is not `.ok`. @@ -8267,7 +11863,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -8276,12 +11872,10 @@ public enum Operations { } } } - public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } - /// Component not found /// /// - Remark: Generated from `#/paths//ui/components/{namespace}/{componentUID}/delete(removeUIComponentFromNamespace)/responses/404`. @@ -8296,7 +11890,6 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } - /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -8305,7 +11898,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -8314,14 +11907,12 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } } - /// Get all registered UI tiles. /// /// - Remark: HTTP `GET /ui/tiles`. @@ -8340,7 +11931,6 @@ public enum Operations { self.accept = accept } } - public var headers: Operations.getUITiles.Input.Headers /// Creates a new `Input`. /// @@ -8350,7 +11940,6 @@ public enum Operations { self.headers = headers } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/ui/tiles/GET/responses/200/content`. @@ -8365,12 +11954,11 @@ public enum Operations { get throws { switch self { case let .json(body): - body + return body } } } } - /// Received HTTP response body public var body: Operations.getUITiles.Output.Ok.Body /// Creates a new `Ok`. @@ -8381,7 +11969,6 @@ public enum Operations { self.body = body } } - /// OK /// /// - Remark: Generated from `#/paths//ui/tiles/get(getUITiles)/responses/200`. @@ -8396,7 +11983,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -8405,13 +11992,11 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } - @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -8423,16 +12008,14 @@ public enum Operations { self = .other(rawValue) } } - public var rawValue: Swift.String { switch self { case let .other(string): - string + return string case .json: - "application/json" + return "application/json" } } - public static var allCases: [Self] { [ .json diff --git a/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift b/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift index 2588ace61..d63ec4537 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift @@ -75,24 +75,6 @@ public class HTTPClient: NSObject { initializeCertificatesStore() } - /** - Sends a GET request to a specified base URL for a specified path and returns the response data via a completion handler. - - - Parameters: - - baseURL: The base URL to attempt the request from. - - path: An optional path component to append to the base URL. - - completion: A closure to be executed once the request is complete. The closure takes three parameters: - - data: The data returned by the server. This will be `nil` if the request fails. - - response: The URL response object providing response metadata, such as HTTP headers and status code. - - error: An error object that indicates why the request failed, or `nil` if the request was successful. - */ - public func doGet(baseURL: URL? = nil, path: String?, completion: @escaping (Data?, URLResponse?, Error?) -> Void) { - doRequest(baseURL: baseURL, path: path, method: "GET") { result, response, error in - let data = result as? Data - completion(data, response, error) - } - } - /** Sends a POST request to a specified base URL for a specified path and returns the response data via a completion handler. @@ -113,95 +95,14 @@ public class HTTPClient: NSObject { } /** - Sends a PUT request to a specified base URL for a specified path and returns the response data via a completion handler. - - - Parameters: - - baseURL: The base URL to attempt the request from. - - path: An optional path component to append to the base URL. - - body: The string to include as the HTTP body of the request. - - completion: A closure to be executed once the request is complete. The closure takes three parameters: - - data: The data returned by the server. This will be `nil` if the request fails. - - response: The URL response object providing response metadata, such as HTTP headers and status code. - - error: An error object that indicates why the request failed, or `nil` if the request was successful. - */ - public func doPut(baseURL: URL? = nil, path: String?, body: String, completion: @escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionTask? { - doRequest(baseURL: baseURL, path: path, method: "PUT", body: body) { result, response, error in - let data = result as? Data - completion(data, response, error) - } - } - - /** - Fetches a specific OpenHAB item from a specified base URL and returns the item via a completion handler. - - - Parameters: - - baseURL: The base URL to attempt the request from. - - itemName: The name of the OpenHAB item to fetch. - - completion: A closure to be executed once the request is complete. The closure takes two parameters: - - item: An `OpenHABItem` object returned by the server. This will be `nil` if the request fails. - - error: An error object that indicates why the request failed, or `nil` if the request was successful. - */ - public func getItem(baseURL: URL? = nil, itemName: String, completion: @escaping (OpenHABItem?, Error?) -> Void) { - doGet(baseURL: baseURL, path: "/rest/items/\(itemName)") { data, _, error in - if let error { - completion(nil, error) - } else { - do { - if let data { - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .formatted(DateFormatter.iso8601Full) - let item = try data.decoded(as: OpenHABItem.CodingData.self, using: decoder) - completion(item.openHABItem, nil) - } else { - completion(nil, HTTPClientError.noDataforItem) - } - } catch { - os_log("getItemsInternal ERROR: %{PUBLIC}@", log: .networking, type: .info, String(describing: error)) - completion(nil, error) - } - } - } - } - - public func getServerProperties(baseURL: URL? = nil, completion: @escaping (OpenHABServerProperties?, Error?) -> Void) { - doGet(baseURL: baseURL, path: "/rest/") { data, _, error in - if let error { - completion(nil, error) - } else { - do { - if let data { - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .formatted(DateFormatter.iso8601Full) - let properties = try data.decoded(as: OpenHABServerProperties.self, using: decoder) - completion(properties, nil) - } else { - completion(nil, HTTPClientError.noDataForProperties) - } - } catch { - os_log("getServerProperties ERROR: %{PUBLIC}@", log: .networking, type: .info, String(describing: error)) - completion(nil, error) - } - } - } - } - - /** - Initiates a download request to a specified base URL for a specified path and returns the file URL via a completion handler. - - - Parameters: - - baseURL: The base URL to attempt the download from. - - path: The optional path component to append to the base URL. - - completionHandler: A closure to be executed once the download is complete. The closure takes three parameters: - - fileURL: The local URL where the downloaded file is stored. This will be `nil` if the download fails. - - response: The URL response object providing response metadata, such as HTTP headers and status code. - - error: An error object that indicates why the request failed, or `nil` if the request was successful. - */ - public func downloadFile(url: URL, completionHandler: @escaping @Sendable (URL?, URLResponse?, (any Error)?) -> Void) { - doRequest(baseURL: url, path: nil, method: "GET", download: true) { result, response, error in - let fileURL = result as? URL - completionHandler(fileURL, response, error) - } - } + Initiates a download request to a specified base URL for a specified path and returns the file URL via a completion handler. + + - Parameters: + - url + - Returns: + - response: The URL response object providing response metadata, such as HTTP headers and status code. + - error: An error object that indicates why the request failed, or `nil` if the request was successful. + */ public func downloadFile(url: URL) async throws -> (URL, URLResponse) { let (result, response) = try await doRequest(baseURL: url, path: nil, method: "GET", download: true) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift index 4096dca2d..2c1d6c132 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift @@ -60,16 +60,17 @@ public final class NetworkTracker: ObservableObject { private var priorityWorkItem: DispatchWorkItem? private var connectionConfigurations: [ConnectionConfiguration] = [] private var retryTimer: DispatchSourceTimer? - private let timerQueue = DispatchQueue(label: "com.openhab.networktracker.timerQueue") + private let timerQueue = DispatchQueue(label: "org.openhab.networktracker.timerQueue") private let connectedRetryInterval: TimeInterval = 60 // amount of time we scan for better connections when connected private let disconnectedRetryInterval: TimeInterval = 30 // amount of time we scan when not connected - let logger = Logger(subsystem: "com.yourapp.network", category: "NetworkTracker") + let logger = Logger(subsystem: "org.openhab.core", category: "NetworkTracker") private init() { monitor = NWPathMonitor() monitor.pathUpdateHandler = { [weak self] path in guard self?.httpClient != nil else { return } + guard self?.openApiService != nil else { return } if path.status == .satisfied { os_log("Network status: Connected", log: OSLog.default, type: .info) self?.checkActiveConnection() @@ -124,12 +125,13 @@ public final class NetworkTracker: ObservableObject { if let url = URL(string: activeConnection.configuration.url) { os_log("checkActiveConnection trying %{PUBLIC}@", log: OSLog.default, type: .info, url.absoluteString) - httpClient?.getServerProperties(baseURL: url) { [weak self] _, error in - if let error { - os_log("Network status: Active connection is not reachable: %{PUBLIC}@ %{PUBLIC}@", log: OSLog.default, type: .error, activeConnection.configuration.url, error.localizedDescription) - self?.attemptConnection() // If not reachable, run the connection logic - } else { - os_log("Network status: Active connection is reachable: %{PUBLIC}@", log: OSLog.default, type: .info, activeConnection.configuration.url) + Task { + do { + let serverProperties = try await openApiService?.getRoot() + logger.info("Network status: Active connection is reachable: \(activeConnection.configuration.url)") + } catch { + logger.error("Network status: Active connection is not reachable: \(activeConnection.configuration.url) \(error.localizedDescription)") + self.attemptConnection() // If not reachable, run the connection logic } } } @@ -255,9 +257,12 @@ public final class NetworkTracker: ObservableObject { os_log("Network status: setActiveConnection: %{PUBLIC}@", log: OSLog.default, type: .info, connection?.configuration.url ?? "no connection") guard activeConnection != connection else { return } activeConnection = connection - if activeConnection != nil { + if let activeConnection { updateStatus(.connected) - httpClient?.baseURL = URL(string: activeConnection!.configuration.url) + httpClient?.baseURL = URL(string: activeConnection.configuration.url) + Task { + await openApiService?.updateBaseURL(with: URL(string: activeConnection.configuration.url) ?? URL(staticString: "about:blank")) + } // startRetryTimer(connectedRetryInterval) } else { updateStatus(.notConnected) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift index 842a7f8b5..29609b7ec 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift @@ -152,6 +152,18 @@ public extension OpenAPIService { } } +public extension OpenAPIService { + func runNow(ruleUID: String, payload: [String: any Sendable]) async throws -> Operations.runRuleNow_1.Output { + let path = Operations.runRuleNow_1.Input.Path(ruleUID: ruleUID) + let jsonPayload = try Operations.runRuleNow_1.Input.Body.jsonPayload( + additionalProperties: OpenAPIObjectContainer(unvalidatedValue: payload)) + return try await client.runRuleNow_1( + path: Operations.runRuleNow_1.Input.Path(ruleUID: ruleUID), + body: .json(jsonPayload) + ) + } +} + public extension OpenAPIService { func openHABcreateSubscription() async throws -> String? { logger.info("Creating subscription") diff --git a/OpenHABCore/Sources/OpenHABCore/openapi/openapi-generator-config.yml b/OpenHABCore/Sources/OpenHABCore/openapi/openapi-generator-config.yml index c720631da..330297b24 100644 --- a/OpenHABCore/Sources/OpenHABCore/openapi/openapi-generator-config.yml +++ b/OpenHABCore/Sources/OpenHABCore/openapi/openapi-generator-config.yml @@ -9,3 +9,4 @@ filter: - items - root - systeminfo + - rules diff --git a/OpenHABCore/Sources/OpenHABCore/openapi/openapi.json b/OpenHABCore/Sources/OpenHABCore/openapi/openapi.json index d34f33439..72feff20c 100644 --- a/OpenHABCore/Sources/OpenHABCore/openapi/openapi.json +++ b/OpenHABCore/Sources/OpenHABCore/openapi/openapi.json @@ -885,17 +885,16 @@ } ], "requestBody": { - "description": "the context for running this rule", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": { - "type": "object" + "description": "the context for running this rule", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "nullable": true + } } - } } - } }, "responses": { "200": { diff --git a/openHAB/OpenHABRootViewController.swift b/openHAB/OpenHABRootViewController.swift index 02f979277..2de417811 100644 --- a/openHAB/OpenHABRootViewController.swift +++ b/openHAB/OpenHABRootViewController.swift @@ -36,6 +36,8 @@ struct CommandItem: CommItem { var link: String } +private let logger = Logger(subsystem: "org.openhab.UI", category: "OpenHABRootViewController") + // swiftlint:disable type_body_length class OpenHABRootViewController: UIViewController { var currentView: OpenHABViewController! @@ -483,10 +485,7 @@ class OpenHABRootViewController: UIViewController { private func ruleCommandAction(_ command: String, completionHandler: (() -> Void)? = nil) { let components = command.split(separator: ":", maxSplits: 2) - guard components.count == 3, - components[0] == "rule" else { - return - } + guard components.count == 3, components[0] == "rule" else { return } let uuid = String(components[1]) let propertiesString = String(components[2]) @@ -503,27 +502,17 @@ class OpenHABRootViewController: UIViewController { } } - var jsonString = "" - do { - let jsonData = try JSONSerialization.data(withJSONObject: properties, options: [.prettyPrinted]) - jsonString = String(data: jsonData, encoding: .utf8)! - } catch { - // nothing - } - NetworkTracker.shared.waitForActiveConnection { activeConnection in if let openHABUrl = activeConnection?.configuration.url, let url = URL(string: openHABUrl) { os_log("Sending comand", log: .default, type: .error) - let client = HTTPClient(username: Preferences.username, password: Preferences.password) - client.doPost(baseURL: url, path: "/rest/rules/rules/\(uuid)/runnow", body: jsonString) { data, _, error in - if let error { - os_log("Could not send data %{public}@", log: .default, type: .error, error.localizedDescription) + Task { + do { + let openAPIService = await OpenAPIService(username: Preferences.username, password: Preferences.password) + let data = try await openAPIService.runNow(ruleUID: uuid, payload: properties) + logger.info("Request succeeded") + } catch { + logger.error("Could not send data \(error.localizedDescription)") self.displayErrorNotification("request to \(openHABUrl) \(error.localizedDescription)") - } else { - os_log("Request succeeded", log: .default, type: .info) - if let data { - os_log("Data: %{public}@", log: .default, type: .debug, String(data: data, encoding: .utf8) ?? "") - } } if let completionHandler { DispatchQueue.main.async { From baa508762db1cf4b5f0b0234eae99a4c6892bf15 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Thu, 27 Feb 2025 15:06:18 +0100 Subject: [PATCH 022/476] Migrate 'Sending command' in OpenHABRootViewController to OpenAPIService Renamed openHABSendItemCommand into sendItemCommand Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../GeneratedSources/openapi/Client.swift | 290 +- .../GeneratedSources/openapi/Types.swift | 3100 ++++++++++------- .../OpenHABCore/Util/OpenAPIService.swift | 4 +- openHAB/OpenHABRootViewController.swift | 18 +- openHAB/OpenHABSitemapViewController.swift | 4 +- 5 files changed, 2031 insertions(+), 1385 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Client.swift b/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Client.swift index c2a733968..6304f9ac3 100644 --- a/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Client.swift +++ b/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Client.swift @@ -1,15 +1,27 @@ +// Copyright (c) 2010-2025 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 + // Generated by swift-openapi-generator, do not modify. @_spi(Generated) import OpenAPIRuntime #if os(Linux) -@preconcurrency import struct Foundation.URL @preconcurrency import struct Foundation.Data @preconcurrency import struct Foundation.Date +@preconcurrency import struct Foundation.URL #else -import struct Foundation.URL import struct Foundation.Data import struct Foundation.Date +import struct Foundation.URL #endif import HTTPTypes + public struct Client: APIProtocol { /// The underlying HTTP client. private let client: UniversalClient @@ -21,22 +33,22 @@ public struct Client: APIProtocol { /// - configuration: A set of configuration values for the client. /// - transport: A transport that performs HTTP operations. /// - middlewares: A list of middlewares to call before the transport. - public init( - serverURL: Foundation.URL, - configuration: Configuration = .init(), - transport: any ClientTransport, - middlewares: [any ClientMiddleware] = [] - ) { - self.client = .init( + public init(serverURL: Foundation.URL, + configuration: Configuration = .init(), + transport: any ClientTransport, + middlewares: [any ClientMiddleware] = []) { + client = .init( serverURL: serverURL, configuration: configuration, transport: transport, middlewares: middlewares ) } + private var converter: Converter { client.converter } + /// Get available rules, optionally filtered by tags and/or prefix. /// /// - Remark: HTTP `GET /rules`. @@ -125,6 +137,7 @@ public struct Client: APIProtocol { } ) } + /// Creates a rule. /// /// - Remark: HTTP `POST /rules`. @@ -143,10 +156,9 @@ public struct Client: APIProtocol { method: .post ) suppressMutabilityWarning(&request) - let body: OpenAPIRuntime.HTTPBody? - switch input.body { + let body: OpenAPIRuntime.HTTPBody? = switch input.body { case let .json(value): - body = try converter.setRequiredRequestBodyAsJSON( + try converter.setRequiredRequestBodyAsJSON( value, headerFields: &request.headerFields, contentType: "application/json; charset=utf-8" @@ -157,7 +169,7 @@ public struct Client: APIProtocol { deserializer: { response, responseBody in switch response.status.code { case 201: - let headers: Operations.createRule.Output.Created.Headers = .init(Location: try converter.getOptionalHeaderFieldAsURI( + let headers: Operations.createRule.Output.Created.Headers = try .init(Location: converter.getOptionalHeaderFieldAsURI( in: response.headerFields, name: "Location", as: Swift.String.self @@ -179,6 +191,7 @@ public struct Client: APIProtocol { } ) } + /// Sets the rule enabled status. /// /// - Remark: HTTP `POST /rules/{ruleUID}/enable`. @@ -199,10 +212,9 @@ public struct Client: APIProtocol { method: .post ) suppressMutabilityWarning(&request) - let body: OpenAPIRuntime.HTTPBody? - switch input.body { + let body: OpenAPIRuntime.HTTPBody? = switch input.body { case let .plainText(value): - body = try converter.setRequiredRequestBodyAsBinary( + try converter.setRequiredRequestBodyAsBinary( value, headerFields: &request.headerFields, contentType: "text/plain" @@ -213,11 +225,11 @@ public struct Client: APIProtocol { deserializer: { response, responseBody in switch response.status.code { case 200: - return .ok(.init()) + .ok(.init()) case 404: - return .notFound(.init()) + .notFound(.init()) default: - return .undocumented( + .undocumented( statusCode: response.status.code, .init( headerFields: response.headerFields, @@ -228,6 +240,7 @@ public struct Client: APIProtocol { } ) } + /// Gets the rule actions. /// /// - Remark: HTTP `GET /rules/{ruleUID}/actions`. @@ -292,6 +305,7 @@ public struct Client: APIProtocol { } ) } + /// Gets the rule corresponding to the given UID. /// /// - Remark: HTTP `GET /rules/{ruleUID}`. @@ -356,6 +370,7 @@ public struct Client: APIProtocol { } ) } + /// Updates an existing rule corresponding to the given UID. /// /// - Remark: HTTP `PUT /rules/{ruleUID}`. @@ -376,10 +391,9 @@ public struct Client: APIProtocol { method: .put ) suppressMutabilityWarning(&request) - let body: OpenAPIRuntime.HTTPBody? - switch input.body { + let body: OpenAPIRuntime.HTTPBody? = switch input.body { case let .json(value): - body = try converter.setRequiredRequestBodyAsJSON( + try converter.setRequiredRequestBodyAsJSON( value, headerFields: &request.headerFields, contentType: "application/json; charset=utf-8" @@ -390,11 +404,11 @@ public struct Client: APIProtocol { deserializer: { response, responseBody in switch response.status.code { case 200: - return .ok(.init()) + .ok(.init()) case 404: - return .notFound(.init()) + .notFound(.init()) default: - return .undocumented( + .undocumented( statusCode: response.status.code, .init( headerFields: response.headerFields, @@ -405,6 +419,7 @@ public struct Client: APIProtocol { } ) } + /// Removes an existing rule corresponding to the given UID. /// /// - Remark: HTTP `DELETE /rules/{ruleUID}`. @@ -430,11 +445,11 @@ public struct Client: APIProtocol { deserializer: { response, responseBody in switch response.status.code { case 200: - return .ok(.init()) + .ok(.init()) case 404: - return .notFound(.init()) + .notFound(.init()) default: - return .undocumented( + .undocumented( statusCode: response.status.code, .init( headerFields: response.headerFields, @@ -445,6 +460,7 @@ public struct Client: APIProtocol { } ) } + /// Gets the rule conditions. /// /// - Remark: HTTP `GET /rules/{ruleUID}/conditions`. @@ -509,6 +525,7 @@ public struct Client: APIProtocol { } ) } + /// Gets the rule configuration values. /// /// - Remark: HTTP `GET /rules/{ruleUID}/config`. @@ -573,6 +590,7 @@ public struct Client: APIProtocol { } ) } + /// Sets the rule configuration values. /// /// - Remark: HTTP `PUT /rules/{ruleUID}/config`. @@ -593,12 +611,11 @@ public struct Client: APIProtocol { method: .put ) suppressMutabilityWarning(&request) - let body: OpenAPIRuntime.HTTPBody? - switch input.body { + let body: OpenAPIRuntime.HTTPBody? = switch input.body { case .none: - body = nil + nil case let .json(value): - body = try converter.setOptionalRequestBodyAsJSON( + try converter.setOptionalRequestBodyAsJSON( value, headerFields: &request.headerFields, contentType: "application/json; charset=utf-8" @@ -609,11 +626,11 @@ public struct Client: APIProtocol { deserializer: { response, responseBody in switch response.status.code { case 200: - return .ok(.init()) + .ok(.init()) case 404: - return .notFound(.init()) + .notFound(.init()) default: - return .undocumented( + .undocumented( statusCode: response.status.code, .init( headerFields: response.headerFields, @@ -624,6 +641,7 @@ public struct Client: APIProtocol { } ) } + /// Gets the rule's module corresponding to the given Category and ID. /// /// - Remark: HTTP `GET /rules/{ruleUID}/{moduleCategory}/{id}`. @@ -690,6 +708,7 @@ public struct Client: APIProtocol { } ) } + /// Gets the module's configuration. /// /// - Remark: HTTP `GET /rules/{ruleUID}/{moduleCategory}/{id}/config`. @@ -756,6 +775,7 @@ public struct Client: APIProtocol { } ) } + /// Gets the module's configuration parameter. /// /// - Remark: HTTP `GET /rules/{ruleUID}/{moduleCategory}/{id}/config/{param}`. @@ -823,6 +843,7 @@ public struct Client: APIProtocol { } ) } + /// Sets the module's configuration parameter value. /// /// - Remark: HTTP `PUT /rules/{ruleUID}/{moduleCategory}/{id}/config/{param}`. @@ -846,10 +867,9 @@ public struct Client: APIProtocol { method: .put ) suppressMutabilityWarning(&request) - let body: OpenAPIRuntime.HTTPBody? - switch input.body { + let body: OpenAPIRuntime.HTTPBody? = switch input.body { case let .plainText(value): - body = try converter.setRequiredRequestBodyAsBinary( + try converter.setRequiredRequestBodyAsBinary( value, headerFields: &request.headerFields, contentType: "text/plain" @@ -860,11 +880,11 @@ public struct Client: APIProtocol { deserializer: { response, responseBody in switch response.status.code { case 200: - return .ok(.init()) + .ok(.init()) case 404: - return .notFound(.init()) + .notFound(.init()) default: - return .undocumented( + .undocumented( statusCode: response.status.code, .init( headerFields: response.headerFields, @@ -875,6 +895,7 @@ public struct Client: APIProtocol { } ) } + /// Gets the rule triggers. /// /// - Remark: HTTP `GET /rules/{ruleUID}/triggers`. @@ -939,6 +960,7 @@ public struct Client: APIProtocol { } ) } + /// Executes actions of the rule. /// /// - Remark: HTTP `POST /rules/{ruleUID}/runnow`. @@ -959,12 +981,11 @@ public struct Client: APIProtocol { method: .post ) suppressMutabilityWarning(&request) - let body: OpenAPIRuntime.HTTPBody? - switch input.body { + let body: OpenAPIRuntime.HTTPBody? = switch input.body { case .none: - body = nil + nil case let .json(value): - body = try converter.setOptionalRequestBodyAsJSON( + try converter.setOptionalRequestBodyAsJSON( value, headerFields: &request.headerFields, contentType: "application/json; charset=utf-8" @@ -975,11 +996,11 @@ public struct Client: APIProtocol { deserializer: { response, responseBody in switch response.status.code { case 200: - return .ok(.init()) + .ok(.init()) case 404: - return .notFound(.init()) + .notFound(.init()) default: - return .undocumented( + .undocumented( statusCode: response.status.code, .init( headerFields: response.headerFields, @@ -990,6 +1011,7 @@ public struct Client: APIProtocol { } ) } + /// Simulates the executions of rules filtered by tag 'Schedule' within the given times. /// /// - Remark: HTTP `GET /rules/schedule/simulations`. @@ -1066,6 +1088,7 @@ public struct Client: APIProtocol { } ) } + /// Adds a new member to a group item. /// /// - Remark: HTTP `PUT /items/{itemName}/members/{memberItemName}`. @@ -1092,13 +1115,13 @@ public struct Client: APIProtocol { deserializer: { response, responseBody in switch response.status.code { case 200: - return .ok(.init()) + .ok(.init()) case 404: - return .notFound(.init()) + .notFound(.init()) case 405: - return .methodNotAllowed(.init()) + .methodNotAllowed(.init()) default: - return .undocumented( + .undocumented( statusCode: response.status.code, .init( headerFields: response.headerFields, @@ -1109,6 +1132,7 @@ public struct Client: APIProtocol { } ) } + /// Removes an existing member from a group item. /// /// - Remark: HTTP `DELETE /items/{itemName}/members/{memberItemName}`. @@ -1135,13 +1159,13 @@ public struct Client: APIProtocol { deserializer: { response, responseBody in switch response.status.code { case 200: - return .ok(.init()) + .ok(.init()) case 404: - return .notFound(.init()) + .notFound(.init()) case 405: - return .methodNotAllowed(.init()) + .methodNotAllowed(.init()) default: - return .undocumented( + .undocumented( statusCode: response.status.code, .init( headerFields: response.headerFields, @@ -1152,6 +1176,7 @@ public struct Client: APIProtocol { } ) } + /// Adds metadata to an item. /// /// - Remark: HTTP `PUT /items/{itemname}/metadata/{namespace}`. @@ -1173,10 +1198,9 @@ public struct Client: APIProtocol { method: .put ) suppressMutabilityWarning(&request) - let body: OpenAPIRuntime.HTTPBody? - switch input.body { + let body: OpenAPIRuntime.HTTPBody? = switch input.body { case let .json(value): - body = try converter.setRequiredRequestBodyAsJSON( + try converter.setRequiredRequestBodyAsJSON( value, headerFields: &request.headerFields, contentType: "application/json; charset=utf-8" @@ -1187,17 +1211,17 @@ public struct Client: APIProtocol { deserializer: { response, responseBody in switch response.status.code { case 200: - return .ok(.init()) + .ok(.init()) case 201: - return .created(.init()) + .created(.init()) case 400: - return .badRequest(.init()) + .badRequest(.init()) case 404: - return .notFound(.init()) + .notFound(.init()) case 405: - return .methodNotAllowed(.init()) + .methodNotAllowed(.init()) default: - return .undocumented( + .undocumented( statusCode: response.status.code, .init( headerFields: response.headerFields, @@ -1208,6 +1232,7 @@ public struct Client: APIProtocol { } ) } + /// Removes metadata from an item. /// /// - Remark: HTTP `DELETE /items/{itemname}/metadata/{namespace}`. @@ -1234,13 +1259,13 @@ public struct Client: APIProtocol { deserializer: { response, responseBody in switch response.status.code { case 200: - return .ok(.init()) + .ok(.init()) case 404: - return .notFound(.init()) + .notFound(.init()) case 405: - return .methodNotAllowed(.init()) + .methodNotAllowed(.init()) default: - return .undocumented( + .undocumented( statusCode: response.status.code, .init( headerFields: response.headerFields, @@ -1251,6 +1276,7 @@ public struct Client: APIProtocol { } ) } + /// Adds a tag to an item. /// /// - Remark: HTTP `PUT /items/{itemname}/tags/{tag}`. @@ -1277,13 +1303,13 @@ public struct Client: APIProtocol { deserializer: { response, responseBody in switch response.status.code { case 200: - return .ok(.init()) + .ok(.init()) case 404: - return .notFound(.init()) + .notFound(.init()) case 405: - return .methodNotAllowed(.init()) + .methodNotAllowed(.init()) default: - return .undocumented( + .undocumented( statusCode: response.status.code, .init( headerFields: response.headerFields, @@ -1294,6 +1320,7 @@ public struct Client: APIProtocol { } ) } + /// Removes a tag from an item. /// /// - Remark: HTTP `DELETE /items/{itemname}/tags/{tag}`. @@ -1320,13 +1347,13 @@ public struct Client: APIProtocol { deserializer: { response, responseBody in switch response.status.code { case 200: - return .ok(.init()) + .ok(.init()) case 404: - return .notFound(.init()) + .notFound(.init()) case 405: - return .methodNotAllowed(.init()) + .methodNotAllowed(.init()) default: - return .undocumented( + .undocumented( statusCode: response.status.code, .init( headerFields: response.headerFields, @@ -1337,6 +1364,7 @@ public struct Client: APIProtocol { } ) } + /// Gets a single item. /// /// - Remark: HTTP `GET /items/{itemname}`. @@ -1420,6 +1448,7 @@ public struct Client: APIProtocol { } ) } + /// Sends a command to an item. /// /// - Remark: HTTP `POST /items/{itemname}`. @@ -1440,10 +1469,9 @@ public struct Client: APIProtocol { method: .post ) suppressMutabilityWarning(&request) - let body: OpenAPIRuntime.HTTPBody? - switch input.body { + let body: OpenAPIRuntime.HTTPBody? = switch input.body { case let .plainText(value): - body = try converter.setRequiredRequestBodyAsBinary( + try converter.setRequiredRequestBodyAsBinary( value, headerFields: &request.headerFields, contentType: "text/plain" @@ -1454,13 +1482,13 @@ public struct Client: APIProtocol { deserializer: { response, responseBody in switch response.status.code { case 200: - return .ok(.init()) + .ok(.init()) case 400: - return .badRequest(.init()) + .badRequest(.init()) case 404: - return .notFound(.init()) + .notFound(.init()) default: - return .undocumented( + .undocumented( statusCode: response.status.code, .init( headerFields: response.headerFields, @@ -1471,6 +1499,7 @@ public struct Client: APIProtocol { } ) } + /// Adds a new item to the registry or updates the existing item. /// /// - Remark: HTTP `PUT /items/{itemname}`. @@ -1500,10 +1529,9 @@ public struct Client: APIProtocol { in: &request.headerFields, contentTypes: input.headers.accept ) - let body: OpenAPIRuntime.HTTPBody? - switch input.body { + let body: OpenAPIRuntime.HTTPBody? = switch input.body { case let .json(value): - body = try converter.setRequiredRequestBodyAsJSON( + try converter.setRequiredRequestBodyAsJSON( value, headerFields: &request.headerFields, contentType: "application/json; charset=utf-8" @@ -1555,6 +1583,7 @@ public struct Client: APIProtocol { } ) } + /// Removes an item from the registry. /// /// - Remark: HTTP `DELETE /items/{itemname}`. @@ -1580,11 +1609,11 @@ public struct Client: APIProtocol { deserializer: { response, responseBody in switch response.status.code { case 200: - return .ok(.init()) + .ok(.init()) case 404: - return .notFound(.init()) + .notFound(.init()) default: - return .undocumented( + .undocumented( statusCode: response.status.code, .init( headerFields: response.headerFields, @@ -1595,6 +1624,7 @@ public struct Client: APIProtocol { } ) } + /// Get all available items. /// /// - Remark: HTTP `GET /items`. @@ -1702,6 +1732,7 @@ public struct Client: APIProtocol { } ) } + /// Adds a list of items to the registry or updates the existing items. /// /// - Remark: HTTP `PUT /items`. @@ -1724,10 +1755,9 @@ public struct Client: APIProtocol { in: &request.headerFields, contentTypes: input.headers.accept ) - let body: OpenAPIRuntime.HTTPBody? - switch input.body { + let body: OpenAPIRuntime.HTTPBody? = switch input.body { case let .json(value): - body = try converter.setRequiredRequestBodyAsJSON( + try converter.setRequiredRequestBodyAsJSON( value, headerFields: &request.headerFields, contentType: "application/json; charset=utf-8" @@ -1773,6 +1803,7 @@ public struct Client: APIProtocol { } ) } + /// Gets the state of an item. /// /// - Remark: HTTP `GET /items/{itemname}/state`. @@ -1837,6 +1868,7 @@ public struct Client: APIProtocol { } ) } + /// Updates the state of an item. /// /// - Remark: HTTP `PUT /items/{itemname}/state`. @@ -1862,10 +1894,9 @@ public struct Client: APIProtocol { name: "Accept-Language", value: input.headers.Accept_hyphen_Language ) - let body: OpenAPIRuntime.HTTPBody? - switch input.body { + let body: OpenAPIRuntime.HTTPBody? = switch input.body { case let .plainText(value): - body = try converter.setRequiredRequestBodyAsBinary( + try converter.setRequiredRequestBodyAsBinary( value, headerFields: &request.headerFields, contentType: "text/plain" @@ -1876,13 +1907,13 @@ public struct Client: APIProtocol { deserializer: { response, responseBody in switch response.status.code { case 202: - return .accepted(.init()) + .accepted(.init()) case 400: - return .badRequest(.init()) + .badRequest(.init()) case 404: - return .notFound(.init()) + .notFound(.init()) default: - return .undocumented( + .undocumented( statusCode: response.status.code, .init( headerFields: response.headerFields, @@ -1893,6 +1924,7 @@ public struct Client: APIProtocol { } ) } + /// Gets the namespace of an item. /// /// - Remark: HTTP `GET /items/{itemname}/metadata/namespaces`. @@ -1962,6 +1994,7 @@ public struct Client: APIProtocol { } ) } + /// Gets the item which defines the requested semantics of an item. /// /// - Remark: HTTP `GET /items/{itemName}/semantic/{semanticClass}`. @@ -1993,11 +2026,11 @@ public struct Client: APIProtocol { deserializer: { response, responseBody in switch response.status.code { case 200: - return .ok(.init()) + .ok(.init()) case 404: - return .notFound(.init()) + .notFound(.init()) default: - return .undocumented( + .undocumented( statusCode: response.status.code, .init( headerFields: response.headerFields, @@ -2008,6 +2041,7 @@ public struct Client: APIProtocol { } ) } + /// Remove unused/orphaned metadata. /// /// - Remark: HTTP `POST /items/metadata/purge`. @@ -2016,7 +2050,7 @@ public struct Client: APIProtocol { try await client.send( input: input, forOperation: Operations.purgeDatabase.id, - serializer: { input in + serializer: { _ in let path = try converter.renderedPath( template: "/items/metadata/purge", parameters: [] @@ -2031,9 +2065,9 @@ public struct Client: APIProtocol { deserializer: { response, responseBody in switch response.status.code { case 200: - return .ok(.init()) + .ok(.init()) default: - return .undocumented( + .undocumented( statusCode: response.status.code, .init( headerFields: response.headerFields, @@ -2044,6 +2078,7 @@ public struct Client: APIProtocol { } ) } + /// Gets information about the runtime, the API version and links to resources. /// /// - Remark: HTTP `GET //`. @@ -2104,6 +2139,7 @@ public struct Client: APIProtocol { } ) } + /// Gets information about the system. /// /// - Remark: HTTP `GET /systeminfo`. @@ -2164,6 +2200,7 @@ public struct Client: APIProtocol { } ) } + /// Get all supported dimensions and their system units. /// /// - Remark: HTTP `GET /systeminfo/uom`. @@ -2224,6 +2261,7 @@ public struct Client: APIProtocol { } ) } + /// Creates a sitemap event subscription. /// /// - Remark: HTTP `POST /sitemaps/events/subscribe`. @@ -2288,6 +2326,7 @@ public struct Client: APIProtocol { } ) } + /// Polls the data for one page of a sitemap. /// /// - Remark: HTTP `GET /sitemaps/{sitemapname}/{pageid}`. @@ -2379,6 +2418,7 @@ public struct Client: APIProtocol { } ) } + /// Polls the data for a whole sitemap. Not recommended due to potentially high traffic. /// /// - Remark: HTTP `GET /sitemaps/{sitemapname}/*`. @@ -2469,6 +2509,7 @@ public struct Client: APIProtocol { } ) } + /// Get sitemap by name. /// /// - Remark: HTTP `GET /sitemaps/{sitemapname}`. @@ -2557,6 +2598,7 @@ public struct Client: APIProtocol { } ) } + /// Get sitemap events for a whole sitemap. Not recommended due to potentially high traffic. /// /// - Remark: HTTP `GET /sitemaps/events/{subscriptionid}/*`. @@ -2589,13 +2631,13 @@ public struct Client: APIProtocol { deserializer: { response, responseBody in switch response.status.code { case 200: - return .ok(.init()) + .ok(.init()) case 400: - return .badRequest(.init()) + .badRequest(.init()) case 404: - return .notFound(.init()) + .notFound(.init()) default: - return .undocumented( + .undocumented( statusCode: response.status.code, .init( headerFields: response.headerFields, @@ -2606,6 +2648,7 @@ public struct Client: APIProtocol { } ) } + /// Get sitemap events. /// /// - Remark: HTTP `GET /sitemaps/events/{subscriptionid}`. @@ -2695,6 +2738,7 @@ public struct Client: APIProtocol { } ) } + /// Get all available sitemaps. /// /// - Remark: HTTP `GET /sitemaps`. @@ -2755,6 +2799,7 @@ public struct Client: APIProtocol { } ) } + /// Get all registered UI components in the specified namespace. /// /// - Remark: HTTP `GET /ui/components/{namespace}`. @@ -2824,6 +2869,7 @@ public struct Client: APIProtocol { } ) } + /// Add a UI component in the specified namespace. /// /// - Remark: HTTP `POST /ui/components/{namespace}`. @@ -2848,12 +2894,11 @@ public struct Client: APIProtocol { in: &request.headerFields, contentTypes: input.headers.accept ) - let body: OpenAPIRuntime.HTTPBody? - switch input.body { + let body: OpenAPIRuntime.HTTPBody? = switch input.body { case .none: - body = nil + nil case let .json(value): - body = try converter.setOptionalRequestBodyAsJSON( + try converter.setOptionalRequestBodyAsJSON( value, headerFields: &request.headerFields, contentType: "application/json; charset=utf-8" @@ -2897,6 +2942,7 @@ public struct Client: APIProtocol { } ) } + /// Get a specific UI component in the specified namespace. /// /// - Remark: HTTP `GET /ui/components/{namespace}/{componentUID}`. @@ -2962,6 +3008,7 @@ public struct Client: APIProtocol { } ) } + /// Update a specific UI component in the specified namespace. /// /// - Remark: HTTP `PUT /ui/components/{namespace}/{componentUID}`. @@ -2987,12 +3034,11 @@ public struct Client: APIProtocol { in: &request.headerFields, contentTypes: input.headers.accept ) - let body: OpenAPIRuntime.HTTPBody? - switch input.body { + let body: OpenAPIRuntime.HTTPBody? = switch input.body { case .none: - body = nil + nil case let .json(value): - body = try converter.setOptionalRequestBodyAsJSON( + try converter.setOptionalRequestBodyAsJSON( value, headerFields: &request.headerFields, contentType: "application/json; charset=utf-8" @@ -3038,6 +3084,7 @@ public struct Client: APIProtocol { } ) } + /// Remove a specific UI component in the specified namespace. /// /// - Remark: HTTP `DELETE /ui/components/{namespace}/{componentUID}`. @@ -3064,11 +3111,11 @@ public struct Client: APIProtocol { deserializer: { response, responseBody in switch response.status.code { case 200: - return .ok(.init()) + .ok(.init()) case 404: - return .notFound(.init()) + .notFound(.init()) default: - return .undocumented( + .undocumented( statusCode: response.status.code, .init( headerFields: response.headerFields, @@ -3079,6 +3126,7 @@ public struct Client: APIProtocol { } ) } + /// Get all registered UI tiles. /// /// - Remark: HTTP `GET /ui/tiles`. diff --git a/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Types.swift b/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Types.swift index f3915e63c..6f74c95fc 100644 --- a/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Types.swift +++ b/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Types.swift @@ -1,13 +1,24 @@ +// Copyright (c) 2010-2025 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 + // Generated by swift-openapi-generator, do not modify. @_spi(Generated) import OpenAPIRuntime #if os(Linux) -@preconcurrency import struct Foundation.URL @preconcurrency import struct Foundation.Data @preconcurrency import struct Foundation.Date +@preconcurrency import struct Foundation.URL #else -import struct Foundation.URL import struct Foundation.Data import struct Foundation.Date +import struct Foundation.URL #endif /// A type that performs HTTP operations defined by the OpenAPI document. public protocol APIProtocol: Sendable { @@ -264,579 +275,560 @@ public protocol APIProtocol: Sendable { } /// Convenience overloads for operation inputs. -extension APIProtocol { +public extension APIProtocol { /// Get available rules, optionally filtered by tags and/or prefix. /// /// - Remark: HTTP `GET /rules`. /// - Remark: Generated from `#/paths//rules/get(getRules)`. - public func getRules( - query: Operations.getRules.Input.Query = .init(), - headers: Operations.getRules.Input.Headers = .init() - ) async throws -> Operations.getRules.Output { + func getRules(query: Operations.getRules.Input.Query = .init(), + headers: Operations.getRules.Input.Headers = .init()) async throws -> Operations.getRules.Output { try await getRules(Operations.getRules.Input( query: query, headers: headers )) } + /// Creates a rule. /// /// - Remark: HTTP `POST /rules`. /// - Remark: Generated from `#/paths//rules/post(createRule)`. - public func createRule(body: Operations.createRule.Input.Body) async throws -> Operations.createRule.Output { + func createRule(body: Operations.createRule.Input.Body) async throws -> Operations.createRule.Output { try await createRule(Operations.createRule.Input(body: body)) } + /// Sets the rule enabled status. /// /// - Remark: HTTP `POST /rules/{ruleUID}/enable`. /// - Remark: Generated from `#/paths//rules/{ruleUID}/enable/post(enableRule)`. - public func enableRule( - path: Operations.enableRule.Input.Path, - body: Operations.enableRule.Input.Body - ) async throws -> Operations.enableRule.Output { + func enableRule(path: Operations.enableRule.Input.Path, + body: Operations.enableRule.Input.Body) async throws -> Operations.enableRule.Output { try await enableRule(Operations.enableRule.Input( path: path, body: body )) } + /// Gets the rule actions. /// /// - Remark: HTTP `GET /rules/{ruleUID}/actions`. /// - Remark: Generated from `#/paths//rules/{ruleUID}/actions/get(getRuleActions)`. - public func getRuleActions( - path: Operations.getRuleActions.Input.Path, - headers: Operations.getRuleActions.Input.Headers = .init() - ) async throws -> Operations.getRuleActions.Output { + func getRuleActions(path: Operations.getRuleActions.Input.Path, + headers: Operations.getRuleActions.Input.Headers = .init()) async throws -> Operations.getRuleActions.Output { try await getRuleActions(Operations.getRuleActions.Input( path: path, headers: headers )) } + /// Gets the rule corresponding to the given UID. /// /// - Remark: HTTP `GET /rules/{ruleUID}`. /// - Remark: Generated from `#/paths//rules/{ruleUID}/get(getRuleById)`. - public func getRuleById( - path: Operations.getRuleById.Input.Path, - headers: Operations.getRuleById.Input.Headers = .init() - ) async throws -> Operations.getRuleById.Output { + func getRuleById(path: Operations.getRuleById.Input.Path, + headers: Operations.getRuleById.Input.Headers = .init()) async throws -> Operations.getRuleById.Output { try await getRuleById(Operations.getRuleById.Input( path: path, headers: headers )) } + /// Updates an existing rule corresponding to the given UID. /// /// - Remark: HTTP `PUT /rules/{ruleUID}`. /// - Remark: Generated from `#/paths//rules/{ruleUID}/put(updateRule)`. - public func updateRule( - path: Operations.updateRule.Input.Path, - body: Operations.updateRule.Input.Body - ) async throws -> Operations.updateRule.Output { + func updateRule(path: Operations.updateRule.Input.Path, + body: Operations.updateRule.Input.Body) async throws -> Operations.updateRule.Output { try await updateRule(Operations.updateRule.Input( path: path, body: body )) } + /// Removes an existing rule corresponding to the given UID. /// /// - Remark: HTTP `DELETE /rules/{ruleUID}`. /// - Remark: Generated from `#/paths//rules/{ruleUID}/delete(deleteRule)`. - public func deleteRule(path: Operations.deleteRule.Input.Path) async throws -> Operations.deleteRule.Output { + func deleteRule(path: Operations.deleteRule.Input.Path) async throws -> Operations.deleteRule.Output { try await deleteRule(Operations.deleteRule.Input(path: path)) } + /// Gets the rule conditions. /// /// - Remark: HTTP `GET /rules/{ruleUID}/conditions`. /// - Remark: Generated from `#/paths//rules/{ruleUID}/conditions/get(getRuleConditions)`. - public func getRuleConditions( - path: Operations.getRuleConditions.Input.Path, - headers: Operations.getRuleConditions.Input.Headers = .init() - ) async throws -> Operations.getRuleConditions.Output { + func getRuleConditions(path: Operations.getRuleConditions.Input.Path, + headers: Operations.getRuleConditions.Input.Headers = .init()) async throws -> Operations.getRuleConditions.Output { try await getRuleConditions(Operations.getRuleConditions.Input( path: path, headers: headers )) } + /// Gets the rule configuration values. /// /// - Remark: HTTP `GET /rules/{ruleUID}/config`. /// - Remark: Generated from `#/paths//rules/{ruleUID}/config/get(getRuleConfiguration)`. - public func getRuleConfiguration( - path: Operations.getRuleConfiguration.Input.Path, - headers: Operations.getRuleConfiguration.Input.Headers = .init() - ) async throws -> Operations.getRuleConfiguration.Output { + func getRuleConfiguration(path: Operations.getRuleConfiguration.Input.Path, + headers: Operations.getRuleConfiguration.Input.Headers = .init()) async throws -> Operations.getRuleConfiguration.Output { try await getRuleConfiguration(Operations.getRuleConfiguration.Input( path: path, headers: headers )) } + /// Sets the rule configuration values. /// /// - Remark: HTTP `PUT /rules/{ruleUID}/config`. /// - Remark: Generated from `#/paths//rules/{ruleUID}/config/put(updateRuleConfiguration)`. - public func updateRuleConfiguration( - path: Operations.updateRuleConfiguration.Input.Path, - body: Operations.updateRuleConfiguration.Input.Body? = nil - ) async throws -> Operations.updateRuleConfiguration.Output { + func updateRuleConfiguration(path: Operations.updateRuleConfiguration.Input.Path, + body: Operations.updateRuleConfiguration.Input.Body? = nil) async throws -> Operations.updateRuleConfiguration.Output { try await updateRuleConfiguration(Operations.updateRuleConfiguration.Input( path: path, body: body )) } + /// Gets the rule's module corresponding to the given Category and ID. /// /// - Remark: HTTP `GET /rules/{ruleUID}/{moduleCategory}/{id}`. /// - Remark: Generated from `#/paths//rules/{ruleUID}/{moduleCategory}/{id}/get(getRuleModuleById)`. - public func getRuleModuleById( - path: Operations.getRuleModuleById.Input.Path, - headers: Operations.getRuleModuleById.Input.Headers = .init() - ) async throws -> Operations.getRuleModuleById.Output { + func getRuleModuleById(path: Operations.getRuleModuleById.Input.Path, + headers: Operations.getRuleModuleById.Input.Headers = .init()) async throws -> Operations.getRuleModuleById.Output { try await getRuleModuleById(Operations.getRuleModuleById.Input( path: path, headers: headers )) } + /// Gets the module's configuration. /// /// - Remark: HTTP `GET /rules/{ruleUID}/{moduleCategory}/{id}/config`. /// - Remark: Generated from `#/paths//rules/{ruleUID}/{moduleCategory}/{id}/config/get(getRuleModuleConfig)`. - public func getRuleModuleConfig( - path: Operations.getRuleModuleConfig.Input.Path, - headers: Operations.getRuleModuleConfig.Input.Headers = .init() - ) async throws -> Operations.getRuleModuleConfig.Output { + func getRuleModuleConfig(path: Operations.getRuleModuleConfig.Input.Path, + headers: Operations.getRuleModuleConfig.Input.Headers = .init()) async throws -> Operations.getRuleModuleConfig.Output { try await getRuleModuleConfig(Operations.getRuleModuleConfig.Input( path: path, headers: headers )) } + /// Gets the module's configuration parameter. /// /// - Remark: HTTP `GET /rules/{ruleUID}/{moduleCategory}/{id}/config/{param}`. /// - Remark: Generated from `#/paths//rules/{ruleUID}/{moduleCategory}/{id}/config/{param}/get(getRuleModuleConfigParameter)`. - public func getRuleModuleConfigParameter( - path: Operations.getRuleModuleConfigParameter.Input.Path, - headers: Operations.getRuleModuleConfigParameter.Input.Headers = .init() - ) async throws -> Operations.getRuleModuleConfigParameter.Output { + func getRuleModuleConfigParameter(path: Operations.getRuleModuleConfigParameter.Input.Path, + headers: Operations.getRuleModuleConfigParameter.Input.Headers = .init()) async throws -> Operations.getRuleModuleConfigParameter.Output { try await getRuleModuleConfigParameter(Operations.getRuleModuleConfigParameter.Input( path: path, headers: headers )) } + /// Sets the module's configuration parameter value. /// /// - Remark: HTTP `PUT /rules/{ruleUID}/{moduleCategory}/{id}/config/{param}`. /// - Remark: Generated from `#/paths//rules/{ruleUID}/{moduleCategory}/{id}/config/{param}/put(setRuleModuleConfigParameter)`. - public func setRuleModuleConfigParameter( - path: Operations.setRuleModuleConfigParameter.Input.Path, - body: Operations.setRuleModuleConfigParameter.Input.Body - ) async throws -> Operations.setRuleModuleConfigParameter.Output { + func setRuleModuleConfigParameter(path: Operations.setRuleModuleConfigParameter.Input.Path, + body: Operations.setRuleModuleConfigParameter.Input.Body) async throws -> Operations.setRuleModuleConfigParameter.Output { try await setRuleModuleConfigParameter(Operations.setRuleModuleConfigParameter.Input( path: path, body: body )) } + /// Gets the rule triggers. /// /// - Remark: HTTP `GET /rules/{ruleUID}/triggers`. /// - Remark: Generated from `#/paths//rules/{ruleUID}/triggers/get(getRuleTriggers)`. - public func getRuleTriggers( - path: Operations.getRuleTriggers.Input.Path, - headers: Operations.getRuleTriggers.Input.Headers = .init() - ) async throws -> Operations.getRuleTriggers.Output { + func getRuleTriggers(path: Operations.getRuleTriggers.Input.Path, + headers: Operations.getRuleTriggers.Input.Headers = .init()) async throws -> Operations.getRuleTriggers.Output { try await getRuleTriggers(Operations.getRuleTriggers.Input( path: path, headers: headers )) } + /// Executes actions of the rule. /// /// - Remark: HTTP `POST /rules/{ruleUID}/runnow`. /// - Remark: Generated from `#/paths//rules/{ruleUID}/runnow/post(runRuleNow_1)`. - public func runRuleNow_1( - path: Operations.runRuleNow_1.Input.Path, - body: Operations.runRuleNow_1.Input.Body? = nil - ) async throws -> Operations.runRuleNow_1.Output { + func runRuleNow_1(path: Operations.runRuleNow_1.Input.Path, + body: Operations.runRuleNow_1.Input.Body? = nil) async throws -> Operations.runRuleNow_1.Output { try await runRuleNow_1(Operations.runRuleNow_1.Input( path: path, body: body )) } + /// Simulates the executions of rules filtered by tag 'Schedule' within the given times. /// /// - Remark: HTTP `GET /rules/schedule/simulations`. /// - Remark: Generated from `#/paths//rules/schedule/simulations/get(getScheduleRuleSimulations)`. - public func getScheduleRuleSimulations( - query: Operations.getScheduleRuleSimulations.Input.Query = .init(), - headers: Operations.getScheduleRuleSimulations.Input.Headers = .init() - ) async throws -> Operations.getScheduleRuleSimulations.Output { + func getScheduleRuleSimulations(query: Operations.getScheduleRuleSimulations.Input.Query = .init(), + headers: Operations.getScheduleRuleSimulations.Input.Headers = .init()) async throws -> Operations.getScheduleRuleSimulations.Output { try await getScheduleRuleSimulations(Operations.getScheduleRuleSimulations.Input( query: query, headers: headers )) } + /// Adds a new member to a group item. /// /// - Remark: HTTP `PUT /items/{itemName}/members/{memberItemName}`. /// - Remark: Generated from `#/paths//items/{itemName}/members/{memberItemName}/put(addMemberToGroupItem)`. - public func addMemberToGroupItem(path: Operations.addMemberToGroupItem.Input.Path) async throws -> Operations.addMemberToGroupItem.Output { + func addMemberToGroupItem(path: Operations.addMemberToGroupItem.Input.Path) async throws -> Operations.addMemberToGroupItem.Output { try await addMemberToGroupItem(Operations.addMemberToGroupItem.Input(path: path)) } + /// Removes an existing member from a group item. /// /// - Remark: HTTP `DELETE /items/{itemName}/members/{memberItemName}`. /// - Remark: Generated from `#/paths//items/{itemName}/members/{memberItemName}/delete(removeMemberFromGroupItem)`. - public func removeMemberFromGroupItem(path: Operations.removeMemberFromGroupItem.Input.Path) async throws -> Operations.removeMemberFromGroupItem.Output { + func removeMemberFromGroupItem(path: Operations.removeMemberFromGroupItem.Input.Path) async throws -> Operations.removeMemberFromGroupItem.Output { try await removeMemberFromGroupItem(Operations.removeMemberFromGroupItem.Input(path: path)) } + /// Adds metadata to an item. /// /// - Remark: HTTP `PUT /items/{itemname}/metadata/{namespace}`. /// - Remark: Generated from `#/paths//items/{itemname}/metadata/{namespace}/put(addMetadataToItem)`. - public func addMetadataToItem( - path: Operations.addMetadataToItem.Input.Path, - body: Operations.addMetadataToItem.Input.Body - ) async throws -> Operations.addMetadataToItem.Output { + func addMetadataToItem(path: Operations.addMetadataToItem.Input.Path, + body: Operations.addMetadataToItem.Input.Body) async throws -> Operations.addMetadataToItem.Output { try await addMetadataToItem(Operations.addMetadataToItem.Input( path: path, body: body )) } + /// Removes metadata from an item. /// /// - Remark: HTTP `DELETE /items/{itemname}/metadata/{namespace}`. /// - Remark: Generated from `#/paths//items/{itemname}/metadata/{namespace}/delete(removeMetadataFromItem)`. - public func removeMetadataFromItem(path: Operations.removeMetadataFromItem.Input.Path) async throws -> Operations.removeMetadataFromItem.Output { + func removeMetadataFromItem(path: Operations.removeMetadataFromItem.Input.Path) async throws -> Operations.removeMetadataFromItem.Output { try await removeMetadataFromItem(Operations.removeMetadataFromItem.Input(path: path)) } + /// Adds a tag to an item. /// /// - Remark: HTTP `PUT /items/{itemname}/tags/{tag}`. /// - Remark: Generated from `#/paths//items/{itemname}/tags/{tag}/put(addTagToItem)`. - public func addTagToItem(path: Operations.addTagToItem.Input.Path) async throws -> Operations.addTagToItem.Output { + func addTagToItem(path: Operations.addTagToItem.Input.Path) async throws -> Operations.addTagToItem.Output { try await addTagToItem(Operations.addTagToItem.Input(path: path)) } + /// Removes a tag from an item. /// /// - Remark: HTTP `DELETE /items/{itemname}/tags/{tag}`. /// - Remark: Generated from `#/paths//items/{itemname}/tags/{tag}/delete(removeTagFromItem)`. - public func removeTagFromItem(path: Operations.removeTagFromItem.Input.Path) async throws -> Operations.removeTagFromItem.Output { + func removeTagFromItem(path: Operations.removeTagFromItem.Input.Path) async throws -> Operations.removeTagFromItem.Output { try await removeTagFromItem(Operations.removeTagFromItem.Input(path: path)) } + /// Gets a single item. /// /// - Remark: HTTP `GET /items/{itemname}`. /// - Remark: Generated from `#/paths//items/{itemname}/get(getItemByName)`. - public func getItemByName( - path: Operations.getItemByName.Input.Path, - query: Operations.getItemByName.Input.Query = .init(), - headers: Operations.getItemByName.Input.Headers = .init() - ) async throws -> Operations.getItemByName.Output { + func getItemByName(path: Operations.getItemByName.Input.Path, + query: Operations.getItemByName.Input.Query = .init(), + headers: Operations.getItemByName.Input.Headers = .init()) async throws -> Operations.getItemByName.Output { try await getItemByName(Operations.getItemByName.Input( path: path, query: query, headers: headers )) } + /// Sends a command to an item. /// /// - Remark: HTTP `POST /items/{itemname}`. /// - Remark: Generated from `#/paths//items/{itemname}/post(sendItemCommand)`. - public func sendItemCommand( - path: Operations.sendItemCommand.Input.Path, - body: Operations.sendItemCommand.Input.Body - ) async throws -> Operations.sendItemCommand.Output { + func sendItemCommand(path: Operations.sendItemCommand.Input.Path, + body: Operations.sendItemCommand.Input.Body) async throws -> Operations.sendItemCommand.Output { try await sendItemCommand(Operations.sendItemCommand.Input( path: path, body: body )) } + /// Adds a new item to the registry or updates the existing item. /// /// - Remark: HTTP `PUT /items/{itemname}`. /// - Remark: Generated from `#/paths//items/{itemname}/put(addOrUpdateItemInRegistry)`. - public func addOrUpdateItemInRegistry( - path: Operations.addOrUpdateItemInRegistry.Input.Path, - headers: Operations.addOrUpdateItemInRegistry.Input.Headers = .init(), - body: Operations.addOrUpdateItemInRegistry.Input.Body - ) async throws -> Operations.addOrUpdateItemInRegistry.Output { + func addOrUpdateItemInRegistry(path: Operations.addOrUpdateItemInRegistry.Input.Path, + headers: Operations.addOrUpdateItemInRegistry.Input.Headers = .init(), + body: Operations.addOrUpdateItemInRegistry.Input.Body) async throws -> Operations.addOrUpdateItemInRegistry.Output { try await addOrUpdateItemInRegistry(Operations.addOrUpdateItemInRegistry.Input( path: path, headers: headers, body: body )) } + /// Removes an item from the registry. /// /// - Remark: HTTP `DELETE /items/{itemname}`. /// - Remark: Generated from `#/paths//items/{itemname}/delete(removeItemFromRegistry)`. - public func removeItemFromRegistry(path: Operations.removeItemFromRegistry.Input.Path) async throws -> Operations.removeItemFromRegistry.Output { + func removeItemFromRegistry(path: Operations.removeItemFromRegistry.Input.Path) async throws -> Operations.removeItemFromRegistry.Output { try await removeItemFromRegistry(Operations.removeItemFromRegistry.Input(path: path)) } + /// Get all available items. /// /// - Remark: HTTP `GET /items`. /// - Remark: Generated from `#/paths//items/get(getItems)`. - public func getItems( - query: Operations.getItems.Input.Query = .init(), - headers: Operations.getItems.Input.Headers = .init() - ) async throws -> Operations.getItems.Output { + func getItems(query: Operations.getItems.Input.Query = .init(), + headers: Operations.getItems.Input.Headers = .init()) async throws -> Operations.getItems.Output { try await getItems(Operations.getItems.Input( query: query, headers: headers )) } + /// Adds a list of items to the registry or updates the existing items. /// /// - Remark: HTTP `PUT /items`. /// - Remark: Generated from `#/paths//items/put(addOrUpdateItemsInRegistry)`. - public func addOrUpdateItemsInRegistry( - headers: Operations.addOrUpdateItemsInRegistry.Input.Headers = .init(), - body: Operations.addOrUpdateItemsInRegistry.Input.Body - ) async throws -> Operations.addOrUpdateItemsInRegistry.Output { + func addOrUpdateItemsInRegistry(headers: Operations.addOrUpdateItemsInRegistry.Input.Headers = .init(), + body: Operations.addOrUpdateItemsInRegistry.Input.Body) async throws -> Operations.addOrUpdateItemsInRegistry.Output { try await addOrUpdateItemsInRegistry(Operations.addOrUpdateItemsInRegistry.Input( headers: headers, body: body )) } + /// Gets the state of an item. /// /// - Remark: HTTP `GET /items/{itemname}/state`. /// - Remark: Generated from `#/paths//items/{itemname}/state/get(getItemState_1)`. - public func getItemState_1( - path: Operations.getItemState_1.Input.Path, - headers: Operations.getItemState_1.Input.Headers = .init() - ) async throws -> Operations.getItemState_1.Output { + func getItemState_1(path: Operations.getItemState_1.Input.Path, + headers: Operations.getItemState_1.Input.Headers = .init()) async throws -> Operations.getItemState_1.Output { try await getItemState_1(Operations.getItemState_1.Input( path: path, headers: headers )) } + /// Updates the state of an item. /// /// - Remark: HTTP `PUT /items/{itemname}/state`. /// - Remark: Generated from `#/paths//items/{itemname}/state/put(updateItemState)`. - public func updateItemState( - path: Operations.updateItemState.Input.Path, - headers: Operations.updateItemState.Input.Headers = .init(), - body: Operations.updateItemState.Input.Body - ) async throws -> Operations.updateItemState.Output { + func updateItemState(path: Operations.updateItemState.Input.Path, + headers: Operations.updateItemState.Input.Headers = .init(), + body: Operations.updateItemState.Input.Body) async throws -> Operations.updateItemState.Output { try await updateItemState(Operations.updateItemState.Input( path: path, headers: headers, body: body )) } + /// Gets the namespace of an item. /// /// - Remark: HTTP `GET /items/{itemname}/metadata/namespaces`. /// - Remark: Generated from `#/paths//items/{itemname}/metadata/namespaces/get(getItemNamespaces)`. - public func getItemNamespaces( - path: Operations.getItemNamespaces.Input.Path, - headers: Operations.getItemNamespaces.Input.Headers = .init() - ) async throws -> Operations.getItemNamespaces.Output { + func getItemNamespaces(path: Operations.getItemNamespaces.Input.Path, + headers: Operations.getItemNamespaces.Input.Headers = .init()) async throws -> Operations.getItemNamespaces.Output { try await getItemNamespaces(Operations.getItemNamespaces.Input( path: path, headers: headers )) } + /// Gets the item which defines the requested semantics of an item. /// /// - Remark: HTTP `GET /items/{itemName}/semantic/{semanticClass}`. /// - Remark: Generated from `#/paths//items/{itemName}/semantic/{semanticClass}/get(getSemanticItem)`. - public func getSemanticItem( - path: Operations.getSemanticItem.Input.Path, - headers: Operations.getSemanticItem.Input.Headers = .init() - ) async throws -> Operations.getSemanticItem.Output { + func getSemanticItem(path: Operations.getSemanticItem.Input.Path, + headers: Operations.getSemanticItem.Input.Headers = .init()) async throws -> Operations.getSemanticItem.Output { try await getSemanticItem(Operations.getSemanticItem.Input( path: path, headers: headers )) } + /// Remove unused/orphaned metadata. /// /// - Remark: HTTP `POST /items/metadata/purge`. /// - Remark: Generated from `#/paths//items/metadata/purge/post(purgeDatabase)`. - public func purgeDatabase() async throws -> Operations.purgeDatabase.Output { + func purgeDatabase() async throws -> Operations.purgeDatabase.Output { try await purgeDatabase(Operations.purgeDatabase.Input()) } + /// Gets information about the runtime, the API version and links to resources. /// /// - Remark: HTTP `GET //`. /// - Remark: Generated from `#/paths////get(getRoot)`. - public func getRoot(headers: Operations.getRoot.Input.Headers = .init()) async throws -> Operations.getRoot.Output { + func getRoot(headers: Operations.getRoot.Input.Headers = .init()) async throws -> Operations.getRoot.Output { try await getRoot(Operations.getRoot.Input(headers: headers)) } + /// Gets information about the system. /// /// - Remark: HTTP `GET /systeminfo`. /// - Remark: Generated from `#/paths//systeminfo/get(getSystemInformation)`. - public func getSystemInformation(headers: Operations.getSystemInformation.Input.Headers = .init()) async throws -> Operations.getSystemInformation.Output { + func getSystemInformation(headers: Operations.getSystemInformation.Input.Headers = .init()) async throws -> Operations.getSystemInformation.Output { try await getSystemInformation(Operations.getSystemInformation.Input(headers: headers)) } + /// Get all supported dimensions and their system units. /// /// - Remark: HTTP `GET /systeminfo/uom`. /// - Remark: Generated from `#/paths//systeminfo/uom/get(getUoMInformation)`. - public func getUoMInformation(headers: Operations.getUoMInformation.Input.Headers = .init()) async throws -> Operations.getUoMInformation.Output { + func getUoMInformation(headers: Operations.getUoMInformation.Input.Headers = .init()) async throws -> Operations.getUoMInformation.Output { try await getUoMInformation(Operations.getUoMInformation.Input(headers: headers)) } + /// Creates a sitemap event subscription. /// /// - Remark: HTTP `POST /sitemaps/events/subscribe`. /// - Remark: Generated from `#/paths//sitemaps/events/subscribe/post(createSitemapEventSubscription)`. - public func createSitemapEventSubscription(headers: Operations.createSitemapEventSubscription.Input.Headers = .init()) async throws -> Operations.createSitemapEventSubscription.Output { + func createSitemapEventSubscription(headers: Operations.createSitemapEventSubscription.Input.Headers = .init()) async throws -> Operations.createSitemapEventSubscription.Output { try await createSitemapEventSubscription(Operations.createSitemapEventSubscription.Input(headers: headers)) } + /// Polls the data for one page of a sitemap. /// /// - Remark: HTTP `GET /sitemaps/{sitemapname}/{pageid}`. /// - Remark: Generated from `#/paths//sitemaps/{sitemapname}/{pageid}/get(pollDataForPage)`. - public func pollDataForPage( - path: Operations.pollDataForPage.Input.Path, - query: Operations.pollDataForPage.Input.Query = .init(), - headers: Operations.pollDataForPage.Input.Headers = .init() - ) async throws -> Operations.pollDataForPage.Output { + func pollDataForPage(path: Operations.pollDataForPage.Input.Path, + query: Operations.pollDataForPage.Input.Query = .init(), + headers: Operations.pollDataForPage.Input.Headers = .init()) async throws -> Operations.pollDataForPage.Output { try await pollDataForPage(Operations.pollDataForPage.Input( path: path, query: query, headers: headers )) } + /// Polls the data for a whole sitemap. Not recommended due to potentially high traffic. /// /// - Remark: HTTP `GET /sitemaps/{sitemapname}/*`. /// - Remark: Generated from `#/paths//sitemaps/{sitemapname}/*/get(pollDataForSitemap)`. - public func pollDataForSitemap( - path: Operations.pollDataForSitemap.Input.Path, - query: Operations.pollDataForSitemap.Input.Query = .init(), - headers: Operations.pollDataForSitemap.Input.Headers = .init() - ) async throws -> Operations.pollDataForSitemap.Output { + func pollDataForSitemap(path: Operations.pollDataForSitemap.Input.Path, + query: Operations.pollDataForSitemap.Input.Query = .init(), + headers: Operations.pollDataForSitemap.Input.Headers = .init()) async throws -> Operations.pollDataForSitemap.Output { try await pollDataForSitemap(Operations.pollDataForSitemap.Input( path: path, query: query, headers: headers )) } + /// Get sitemap by name. /// /// - Remark: HTTP `GET /sitemaps/{sitemapname}`. /// - Remark: Generated from `#/paths//sitemaps/{sitemapname}/get(getSitemapByName)`. - public func getSitemapByName( - path: Operations.getSitemapByName.Input.Path, - query: Operations.getSitemapByName.Input.Query = .init(), - headers: Operations.getSitemapByName.Input.Headers = .init() - ) async throws -> Operations.getSitemapByName.Output { + func getSitemapByName(path: Operations.getSitemapByName.Input.Path, + query: Operations.getSitemapByName.Input.Query = .init(), + headers: Operations.getSitemapByName.Input.Headers = .init()) async throws -> Operations.getSitemapByName.Output { try await getSitemapByName(Operations.getSitemapByName.Input( path: path, query: query, headers: headers )) } + /// Get sitemap events for a whole sitemap. Not recommended due to potentially high traffic. /// /// - Remark: HTTP `GET /sitemaps/events/{subscriptionid}/*`. /// - Remark: Generated from `#/paths//sitemaps/events/{subscriptionid}/*/get(getSitemapEvents)`. - public func getSitemapEvents( - path: Operations.getSitemapEvents.Input.Path, - query: Operations.getSitemapEvents.Input.Query = .init() - ) async throws -> Operations.getSitemapEvents.Output { + func getSitemapEvents(path: Operations.getSitemapEvents.Input.Path, + query: Operations.getSitemapEvents.Input.Query = .init()) async throws -> Operations.getSitemapEvents.Output { try await getSitemapEvents(Operations.getSitemapEvents.Input( path: path, query: query )) } + /// Get sitemap events. /// /// - Remark: HTTP `GET /sitemaps/events/{subscriptionid}`. /// - Remark: Generated from `#/paths//sitemaps/events/{subscriptionid}/get(getSitemapEvents_1)`. - public func getSitemapEvents_1( - path: Operations.getSitemapEvents_1.Input.Path, - query: Operations.getSitemapEvents_1.Input.Query = .init(), - headers: Operations.getSitemapEvents_1.Input.Headers = .init() - ) async throws -> Operations.getSitemapEvents_1.Output { + func getSitemapEvents_1(path: Operations.getSitemapEvents_1.Input.Path, + query: Operations.getSitemapEvents_1.Input.Query = .init(), + headers: Operations.getSitemapEvents_1.Input.Headers = .init()) async throws -> Operations.getSitemapEvents_1.Output { try await getSitemapEvents_1(Operations.getSitemapEvents_1.Input( path: path, query: query, headers: headers )) } + /// Get all available sitemaps. /// /// - Remark: HTTP `GET /sitemaps`. /// - Remark: Generated from `#/paths//sitemaps/get(getSitemaps)`. - public func getSitemaps(headers: Operations.getSitemaps.Input.Headers = .init()) async throws -> Operations.getSitemaps.Output { + func getSitemaps(headers: Operations.getSitemaps.Input.Headers = .init()) async throws -> Operations.getSitemaps.Output { try await getSitemaps(Operations.getSitemaps.Input(headers: headers)) } + /// Get all registered UI components in the specified namespace. /// /// - Remark: HTTP `GET /ui/components/{namespace}`. /// - Remark: Generated from `#/paths//ui/components/{namespace}/get(getRegisteredUIComponentsInNamespace)`. - public func getRegisteredUIComponentsInNamespace( - path: Operations.getRegisteredUIComponentsInNamespace.Input.Path, - query: Operations.getRegisteredUIComponentsInNamespace.Input.Query = .init(), - headers: Operations.getRegisteredUIComponentsInNamespace.Input.Headers = .init() - ) async throws -> Operations.getRegisteredUIComponentsInNamespace.Output { + func getRegisteredUIComponentsInNamespace(path: Operations.getRegisteredUIComponentsInNamespace.Input.Path, + query: Operations.getRegisteredUIComponentsInNamespace.Input.Query = .init(), + headers: Operations.getRegisteredUIComponentsInNamespace.Input.Headers = .init()) async throws -> Operations.getRegisteredUIComponentsInNamespace.Output { try await getRegisteredUIComponentsInNamespace(Operations.getRegisteredUIComponentsInNamespace.Input( path: path, query: query, headers: headers )) } + /// Add a UI component in the specified namespace. /// /// - Remark: HTTP `POST /ui/components/{namespace}`. /// - Remark: Generated from `#/paths//ui/components/{namespace}/post(addUIComponentToNamespace)`. - public func addUIComponentToNamespace( - path: Operations.addUIComponentToNamespace.Input.Path, - headers: Operations.addUIComponentToNamespace.Input.Headers = .init(), - body: Operations.addUIComponentToNamespace.Input.Body? = nil - ) async throws -> Operations.addUIComponentToNamespace.Output { + func addUIComponentToNamespace(path: Operations.addUIComponentToNamespace.Input.Path, + headers: Operations.addUIComponentToNamespace.Input.Headers = .init(), + body: Operations.addUIComponentToNamespace.Input.Body? = nil) async throws -> Operations.addUIComponentToNamespace.Output { try await addUIComponentToNamespace(Operations.addUIComponentToNamespace.Input( path: path, headers: headers, body: body )) } + /// Get a specific UI component in the specified namespace. /// /// - Remark: HTTP `GET /ui/components/{namespace}/{componentUID}`. /// - Remark: Generated from `#/paths//ui/components/{namespace}/{componentUID}/get(getUIComponentInNamespace)`. - public func getUIComponentInNamespace( - path: Operations.getUIComponentInNamespace.Input.Path, - headers: Operations.getUIComponentInNamespace.Input.Headers = .init() - ) async throws -> Operations.getUIComponentInNamespace.Output { + func getUIComponentInNamespace(path: Operations.getUIComponentInNamespace.Input.Path, + headers: Operations.getUIComponentInNamespace.Input.Headers = .init()) async throws -> Operations.getUIComponentInNamespace.Output { try await getUIComponentInNamespace(Operations.getUIComponentInNamespace.Input( path: path, headers: headers )) } + /// Update a specific UI component in the specified namespace. /// /// - Remark: HTTP `PUT /ui/components/{namespace}/{componentUID}`. /// - Remark: Generated from `#/paths//ui/components/{namespace}/{componentUID}/put(updateUIComponentInNamespace)`. - public func updateUIComponentInNamespace( - path: Operations.updateUIComponentInNamespace.Input.Path, - headers: Operations.updateUIComponentInNamespace.Input.Headers = .init(), - body: Operations.updateUIComponentInNamespace.Input.Body? = nil - ) async throws -> Operations.updateUIComponentInNamespace.Output { + func updateUIComponentInNamespace(path: Operations.updateUIComponentInNamespace.Input.Path, + headers: Operations.updateUIComponentInNamespace.Input.Headers = .init(), + body: Operations.updateUIComponentInNamespace.Input.Body? = nil) async throws -> Operations.updateUIComponentInNamespace.Output { try await updateUIComponentInNamespace(Operations.updateUIComponentInNamespace.Input( path: path, headers: headers, body: body )) } + /// Remove a specific UI component in the specified namespace. /// /// - Remark: HTTP `DELETE /ui/components/{namespace}/{componentUID}`. /// - Remark: Generated from `#/paths//ui/components/{namespace}/{componentUID}/delete(removeUIComponentFromNamespace)`. - public func removeUIComponentFromNamespace(path: Operations.removeUIComponentFromNamespace.Input.Path) async throws -> Operations.removeUIComponentFromNamespace.Output { + func removeUIComponentFromNamespace(path: Operations.removeUIComponentFromNamespace.Input.Path) async throws -> Operations.removeUIComponentFromNamespace.Output { try await removeUIComponentFromNamespace(Operations.removeUIComponentFromNamespace.Input(path: path)) } + /// Get all registered UI tiles. /// /// - Remark: HTTP `GET /ui/tiles`. /// - Remark: Generated from `#/paths//ui/tiles/get(getUITiles)`. - public func getUITiles(headers: Operations.getUITiles.Input.Headers = .init()) async throws -> Operations.getUITiles.Output { + func getUITiles(headers: Operations.getUITiles.Input.Headers = .init()) async throws -> Operations.getUITiles.Output { try await getUITiles(Operations.getUITiles.Input(headers: headers)) } } @@ -851,6 +843,7 @@ public enum Servers { ) } } + @available(*, deprecated, renamed: "Servers.Server1.url") public static func server1() throws -> Foundation.URL { try Foundation.URL( @@ -880,11 +873,12 @@ public enum Components { public var required: Swift.Bool? /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterDTO/type`. @frozen public enum _typePayload: String, Codable, Hashable, Sendable, CaseIterable { - case TEXT = "TEXT" - case INTEGER = "INTEGER" - case DECIMAL = "DECIMAL" - case BOOLEAN = "BOOLEAN" + case TEXT + case INTEGER + case DECIMAL + case BOOLEAN } + /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterDTO/type`. public var _type: Components.Schemas.ConfigDescriptionParameterDTO._typePayload? /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterDTO/min`. @@ -942,30 +936,28 @@ public enum Components { /// - unitLabel: /// - options: /// - filterCriteria: - public init( - context: Swift.String? = nil, - defaultValue: Swift.String? = nil, - description: Swift.String? = nil, - label: Swift.String? = nil, - name: Swift.String? = nil, - required: Swift.Bool? = nil, - _type: Components.Schemas.ConfigDescriptionParameterDTO._typePayload? = nil, - min: Swift.Double? = nil, - max: Swift.Double? = nil, - stepsize: Swift.Double? = nil, - pattern: Swift.String? = nil, - readOnly: Swift.Bool? = nil, - multiple: Swift.Bool? = nil, - multipleLimit: Swift.Int32? = nil, - groupName: Swift.String? = nil, - advanced: Swift.Bool? = nil, - verify: Swift.Bool? = nil, - limitToOptions: Swift.Bool? = nil, - unit: Swift.String? = nil, - unitLabel: Swift.String? = nil, - options: [Components.Schemas.ParameterOptionDTO]? = nil, - filterCriteria: [Components.Schemas.FilterCriteriaDTO]? = nil - ) { + public init(context: Swift.String? = nil, + defaultValue: Swift.String? = nil, + description: Swift.String? = nil, + label: Swift.String? = nil, + name: Swift.String? = nil, + required: Swift.Bool? = nil, + _type: Components.Schemas.ConfigDescriptionParameterDTO._typePayload? = nil, + min: Swift.Double? = nil, + max: Swift.Double? = nil, + stepsize: Swift.Double? = nil, + pattern: Swift.String? = nil, + readOnly: Swift.Bool? = nil, + multiple: Swift.Bool? = nil, + multipleLimit: Swift.Int32? = nil, + groupName: Swift.String? = nil, + advanced: Swift.Bool? = nil, + verify: Swift.Bool? = nil, + limitToOptions: Swift.Bool? = nil, + unit: Swift.String? = nil, + unitLabel: Swift.String? = nil, + options: [Components.Schemas.ParameterOptionDTO]? = nil, + filterCriteria: [Components.Schemas.FilterCriteriaDTO]? = nil) { self.context = context self.defaultValue = defaultValue self.description = description @@ -989,6 +981,7 @@ public enum Components { self.options = options self.filterCriteria = filterCriteria } + public enum CodingKeys: String, CodingKey { case context case defaultValue @@ -1014,6 +1007,7 @@ public enum Components { case filterCriteria } } + /// - Remark: Generated from `#/components/schemas/FilterCriteriaDTO`. public struct FilterCriteriaDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/FilterCriteriaDTO/value`. @@ -1025,18 +1019,18 @@ public enum Components { /// - Parameters: /// - value: /// - name: - public init( - value: Swift.String? = nil, - name: Swift.String? = nil - ) { + public init(value: Swift.String? = nil, + name: Swift.String? = nil) { self.value = value self.name = name } + public enum CodingKeys: String, CodingKey { case value case name } } + /// - Remark: Generated from `#/components/schemas/ParameterOptionDTO`. public struct ParameterOptionDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/ParameterOptionDTO/label`. @@ -1048,18 +1042,18 @@ public enum Components { /// - Parameters: /// - label: /// - value: - public init( - label: Swift.String? = nil, - value: Swift.String? = nil - ) { + public init(label: Swift.String? = nil, + value: Swift.String? = nil) { self.label = label self.value = value } + public enum CodingKeys: String, CodingKey { case label case value } } + /// - Remark: Generated from `#/components/schemas/ActionDTO`. public struct ActionDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/ActionDTO/id`. @@ -1079,13 +1073,16 @@ public enum Components { public init(additionalProperties: [String: OpenAPIRuntime.OpenAPIObjectContainer] = .init()) { self.additionalProperties = additionalProperties } + public init(from decoder: any Decoder) throws { additionalProperties = try decoder.decodeAdditionalProperties(knownKeys: []) } + public func encode(to encoder: any Encoder) throws { try encoder.encodeAdditionalProperties(additionalProperties) } } + /// - Remark: Generated from `#/components/schemas/ActionDTO/configuration`. public var configuration: Components.Schemas.ActionDTO.configurationPayload? /// - Remark: Generated from `#/components/schemas/ActionDTO/type`. @@ -1101,13 +1098,16 @@ public enum Components { public init(additionalProperties: [String: Swift.String] = .init()) { self.additionalProperties = additionalProperties } + public init(from decoder: any Decoder) throws { additionalProperties = try decoder.decodeAdditionalProperties(knownKeys: []) } + public func encode(to encoder: any Encoder) throws { try encoder.encodeAdditionalProperties(additionalProperties) } } + /// - Remark: Generated from `#/components/schemas/ActionDTO/inputs`. public var inputs: Components.Schemas.ActionDTO.inputsPayload? /// Creates a new `ActionDTO`. @@ -1119,14 +1119,12 @@ public enum Components { /// - configuration: /// - _type: /// - inputs: - public init( - id: Swift.String? = nil, - label: Swift.String? = nil, - description: Swift.String? = nil, - configuration: Components.Schemas.ActionDTO.configurationPayload? = nil, - _type: Swift.String? = nil, - inputs: Components.Schemas.ActionDTO.inputsPayload? = nil - ) { + public init(id: Swift.String? = nil, + label: Swift.String? = nil, + description: Swift.String? = nil, + configuration: Components.Schemas.ActionDTO.configurationPayload? = nil, + _type: Swift.String? = nil, + inputs: Components.Schemas.ActionDTO.inputsPayload? = nil) { self.id = id self.label = label self.description = description @@ -1134,6 +1132,7 @@ public enum Components { self._type = _type self.inputs = inputs } + public enum CodingKeys: String, CodingKey { case id case label @@ -1143,6 +1142,7 @@ public enum Components { case inputs } } + /// - Remark: Generated from `#/components/schemas/ConditionDTO`. public struct ConditionDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/ConditionDTO/id`. @@ -1162,13 +1162,16 @@ public enum Components { public init(additionalProperties: [String: OpenAPIRuntime.OpenAPIObjectContainer] = .init()) { self.additionalProperties = additionalProperties } + public init(from decoder: any Decoder) throws { additionalProperties = try decoder.decodeAdditionalProperties(knownKeys: []) } + public func encode(to encoder: any Encoder) throws { try encoder.encodeAdditionalProperties(additionalProperties) } } + /// - Remark: Generated from `#/components/schemas/ConditionDTO/configuration`. public var configuration: Components.Schemas.ConditionDTO.configurationPayload? /// - Remark: Generated from `#/components/schemas/ConditionDTO/type`. @@ -1184,13 +1187,16 @@ public enum Components { public init(additionalProperties: [String: Swift.String] = .init()) { self.additionalProperties = additionalProperties } + public init(from decoder: any Decoder) throws { additionalProperties = try decoder.decodeAdditionalProperties(knownKeys: []) } + public func encode(to encoder: any Encoder) throws { try encoder.encodeAdditionalProperties(additionalProperties) } } + /// - Remark: Generated from `#/components/schemas/ConditionDTO/inputs`. public var inputs: Components.Schemas.ConditionDTO.inputsPayload? /// Creates a new `ConditionDTO`. @@ -1202,14 +1208,12 @@ public enum Components { /// - configuration: /// - _type: /// - inputs: - public init( - id: Swift.String? = nil, - label: Swift.String? = nil, - description: Swift.String? = nil, - configuration: Components.Schemas.ConditionDTO.configurationPayload? = nil, - _type: Swift.String? = nil, - inputs: Components.Schemas.ConditionDTO.inputsPayload? = nil - ) { + public init(id: Swift.String? = nil, + label: Swift.String? = nil, + description: Swift.String? = nil, + configuration: Components.Schemas.ConditionDTO.configurationPayload? = nil, + _type: Swift.String? = nil, + inputs: Components.Schemas.ConditionDTO.inputsPayload? = nil) { self.id = id self.label = label self.description = description @@ -1217,6 +1221,7 @@ public enum Components { self._type = _type self.inputs = inputs } + public enum CodingKeys: String, CodingKey { case id case label @@ -1226,6 +1231,7 @@ public enum Components { case inputs } } + /// - Remark: Generated from `#/components/schemas/RuleDTO`. public struct RuleDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/RuleDTO/triggers`. @@ -1245,13 +1251,16 @@ public enum Components { public init(additionalProperties: [String: OpenAPIRuntime.OpenAPIObjectContainer] = .init()) { self.additionalProperties = additionalProperties } + public init(from decoder: any Decoder) throws { additionalProperties = try decoder.decodeAdditionalProperties(knownKeys: []) } + public func encode(to encoder: any Encoder) throws { try encoder.encodeAdditionalProperties(additionalProperties) } } + /// - Remark: Generated from `#/components/schemas/RuleDTO/configuration`. public var configuration: Components.Schemas.RuleDTO.configurationPayload? /// - Remark: Generated from `#/components/schemas/RuleDTO/configDescriptions`. @@ -1266,10 +1275,11 @@ public enum Components { public var tags: [Swift.String]? /// - Remark: Generated from `#/components/schemas/RuleDTO/visibility`. @frozen public enum visibilityPayload: String, Codable, Hashable, Sendable, CaseIterable { - case VISIBLE = "VISIBLE" - case HIDDEN = "HIDDEN" - case EXPERT = "EXPERT" + case VISIBLE + case HIDDEN + case EXPERT } + /// - Remark: Generated from `#/components/schemas/RuleDTO/visibility`. public var visibility: Components.Schemas.RuleDTO.visibilityPayload? /// - Remark: Generated from `#/components/schemas/RuleDTO/description`. @@ -1288,19 +1298,17 @@ public enum Components { /// - tags: /// - visibility: /// - description: - public init( - triggers: [Components.Schemas.TriggerDTO]? = nil, - conditions: [Components.Schemas.ConditionDTO]? = nil, - actions: [Components.Schemas.ActionDTO]? = nil, - configuration: Components.Schemas.RuleDTO.configurationPayload? = nil, - configDescriptions: [Components.Schemas.ConfigDescriptionParameterDTO]? = nil, - templateUID: Swift.String? = nil, - uid: Swift.String? = nil, - name: Swift.String? = nil, - tags: [Swift.String]? = nil, - visibility: Components.Schemas.RuleDTO.visibilityPayload? = nil, - description: Swift.String? = nil - ) { + public init(triggers: [Components.Schemas.TriggerDTO]? = nil, + conditions: [Components.Schemas.ConditionDTO]? = nil, + actions: [Components.Schemas.ActionDTO]? = nil, + configuration: Components.Schemas.RuleDTO.configurationPayload? = nil, + configDescriptions: [Components.Schemas.ConfigDescriptionParameterDTO]? = nil, + templateUID: Swift.String? = nil, + uid: Swift.String? = nil, + name: Swift.String? = nil, + tags: [Swift.String]? = nil, + visibility: Components.Schemas.RuleDTO.visibilityPayload? = nil, + description: Swift.String? = nil) { self.triggers = triggers self.conditions = conditions self.actions = actions @@ -1313,6 +1321,7 @@ public enum Components { self.visibility = visibility self.description = description } + public enum CodingKeys: String, CodingKey { case triggers case conditions @@ -1327,6 +1336,7 @@ public enum Components { case description } } + /// - Remark: Generated from `#/components/schemas/TriggerDTO`. public struct TriggerDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/TriggerDTO/id`. @@ -1346,13 +1356,16 @@ public enum Components { public init(additionalProperties: [String: OpenAPIRuntime.OpenAPIObjectContainer] = .init()) { self.additionalProperties = additionalProperties } + public init(from decoder: any Decoder) throws { additionalProperties = try decoder.decodeAdditionalProperties(knownKeys: []) } + public func encode(to encoder: any Encoder) throws { try encoder.encodeAdditionalProperties(additionalProperties) } } + /// - Remark: Generated from `#/components/schemas/TriggerDTO/configuration`. public var configuration: Components.Schemas.TriggerDTO.configurationPayload? /// - Remark: Generated from `#/components/schemas/TriggerDTO/type`. @@ -1365,19 +1378,18 @@ public enum Components { /// - description: /// - configuration: /// - _type: - public init( - id: Swift.String? = nil, - label: Swift.String? = nil, - description: Swift.String? = nil, - configuration: Components.Schemas.TriggerDTO.configurationPayload? = nil, - _type: Swift.String? = nil - ) { + public init(id: Swift.String? = nil, + label: Swift.String? = nil, + description: Swift.String? = nil, + configuration: Components.Schemas.TriggerDTO.configurationPayload? = nil, + _type: Swift.String? = nil) { self.id = id self.label = label self.description = description self.configuration = configuration self._type = _type } + public enum CodingKeys: String, CodingKey { case id case label @@ -1386,6 +1398,7 @@ public enum Components { case _type = "type" } } + /// - Remark: Generated from `#/components/schemas/EnrichedRuleDTO`. public struct EnrichedRuleDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/EnrichedRuleDTO/triggers`. @@ -1405,13 +1418,16 @@ public enum Components { public init(additionalProperties: [String: OpenAPIRuntime.OpenAPIObjectContainer] = .init()) { self.additionalProperties = additionalProperties } + public init(from decoder: any Decoder) throws { additionalProperties = try decoder.decodeAdditionalProperties(knownKeys: []) } + public func encode(to encoder: any Encoder) throws { try encoder.encodeAdditionalProperties(additionalProperties) } } + /// - Remark: Generated from `#/components/schemas/EnrichedRuleDTO/configuration`. public var configuration: Components.Schemas.EnrichedRuleDTO.configurationPayload? /// - Remark: Generated from `#/components/schemas/EnrichedRuleDTO/configDescriptions`. @@ -1426,10 +1442,11 @@ public enum Components { public var tags: [Swift.String]? /// - Remark: Generated from `#/components/schemas/EnrichedRuleDTO/visibility`. @frozen public enum visibilityPayload: String, Codable, Hashable, Sendable, CaseIterable { - case VISIBLE = "VISIBLE" - case HIDDEN = "HIDDEN" - case EXPERT = "EXPERT" + case VISIBLE + case HIDDEN + case EXPERT } + /// - Remark: Generated from `#/components/schemas/EnrichedRuleDTO/visibility`. public var visibility: Components.Schemas.EnrichedRuleDTO.visibilityPayload? /// - Remark: Generated from `#/components/schemas/EnrichedRuleDTO/description`. @@ -1454,21 +1471,19 @@ public enum Components { /// - description: /// - status: /// - editable: - public init( - triggers: [Components.Schemas.TriggerDTO]? = nil, - conditions: [Components.Schemas.ConditionDTO]? = nil, - actions: [Components.Schemas.ActionDTO]? = nil, - configuration: Components.Schemas.EnrichedRuleDTO.configurationPayload? = nil, - configDescriptions: [Components.Schemas.ConfigDescriptionParameterDTO]? = nil, - templateUID: Swift.String? = nil, - uid: Swift.String? = nil, - name: Swift.String? = nil, - tags: [Swift.String]? = nil, - visibility: Components.Schemas.EnrichedRuleDTO.visibilityPayload? = nil, - description: Swift.String? = nil, - status: Components.Schemas.RuleStatusInfo? = nil, - editable: Swift.Bool? = nil - ) { + public init(triggers: [Components.Schemas.TriggerDTO]? = nil, + conditions: [Components.Schemas.ConditionDTO]? = nil, + actions: [Components.Schemas.ActionDTO]? = nil, + configuration: Components.Schemas.EnrichedRuleDTO.configurationPayload? = nil, + configDescriptions: [Components.Schemas.ConfigDescriptionParameterDTO]? = nil, + templateUID: Swift.String? = nil, + uid: Swift.String? = nil, + name: Swift.String? = nil, + tags: [Swift.String]? = nil, + visibility: Components.Schemas.EnrichedRuleDTO.visibilityPayload? = nil, + description: Swift.String? = nil, + status: Components.Schemas.RuleStatusInfo? = nil, + editable: Swift.Bool? = nil) { self.triggers = triggers self.conditions = conditions self.actions = actions @@ -1483,6 +1498,7 @@ public enum Components { self.status = status self.editable = editable } + public enum CodingKeys: String, CodingKey { case triggers case conditions @@ -1499,27 +1515,30 @@ public enum Components { case editable } } + /// - Remark: Generated from `#/components/schemas/RuleStatusInfo`. public struct RuleStatusInfo: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/RuleStatusInfo/status`. @frozen public enum statusPayload: String, Codable, Hashable, Sendable, CaseIterable { - case UNINITIALIZED = "UNINITIALIZED" - case INITIALIZING = "INITIALIZING" - case IDLE = "IDLE" - case RUNNING = "RUNNING" + case UNINITIALIZED + case INITIALIZING + case IDLE + case RUNNING } + /// - Remark: Generated from `#/components/schemas/RuleStatusInfo/status`. public var status: Components.Schemas.RuleStatusInfo.statusPayload? /// - Remark: Generated from `#/components/schemas/RuleStatusInfo/statusDetail`. @frozen public enum statusDetailPayload: String, Codable, Hashable, Sendable, CaseIterable { - case NONE = "NONE" - case HANDLER_MISSING_ERROR = "HANDLER_MISSING_ERROR" - case HANDLER_INITIALIZING_ERROR = "HANDLER_INITIALIZING_ERROR" - case CONFIGURATION_ERROR = "CONFIGURATION_ERROR" - case TEMPLATE_MISSING_ERROR = "TEMPLATE_MISSING_ERROR" - case INVALID_RULE = "INVALID_RULE" - case DISABLED = "DISABLED" + case NONE + case HANDLER_MISSING_ERROR + case HANDLER_INITIALIZING_ERROR + case CONFIGURATION_ERROR + case TEMPLATE_MISSING_ERROR + case INVALID_RULE + case DISABLED } + /// - Remark: Generated from `#/components/schemas/RuleStatusInfo/statusDetail`. public var statusDetail: Components.Schemas.RuleStatusInfo.statusDetailPayload? /// - Remark: Generated from `#/components/schemas/RuleStatusInfo/description`. @@ -1530,21 +1549,21 @@ public enum Components { /// - status: /// - statusDetail: /// - description: - public init( - status: Components.Schemas.RuleStatusInfo.statusPayload? = nil, - statusDetail: Components.Schemas.RuleStatusInfo.statusDetailPayload? = nil, - description: Swift.String? = nil - ) { + public init(status: Components.Schemas.RuleStatusInfo.statusPayload? = nil, + statusDetail: Components.Schemas.RuleStatusInfo.statusDetailPayload? = nil, + description: Swift.String? = nil) { self.status = status self.statusDetail = statusDetail self.description = description } + public enum CodingKeys: String, CodingKey { case status case statusDetail case description } } + /// - Remark: Generated from `#/components/schemas/ModuleDTO`. public struct ModuleDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/ModuleDTO/id`. @@ -1564,13 +1583,16 @@ public enum Components { public init(additionalProperties: [String: OpenAPIRuntime.OpenAPIObjectContainer] = .init()) { self.additionalProperties = additionalProperties } + public init(from decoder: any Decoder) throws { additionalProperties = try decoder.decodeAdditionalProperties(knownKeys: []) } + public func encode(to encoder: any Encoder) throws { try encoder.encodeAdditionalProperties(additionalProperties) } } + /// - Remark: Generated from `#/components/schemas/ModuleDTO/configuration`. public var configuration: Components.Schemas.ModuleDTO.configurationPayload? /// - Remark: Generated from `#/components/schemas/ModuleDTO/type`. @@ -1583,19 +1605,18 @@ public enum Components { /// - description: /// - configuration: /// - _type: - public init( - id: Swift.String? = nil, - label: Swift.String? = nil, - description: Swift.String? = nil, - configuration: Components.Schemas.ModuleDTO.configurationPayload? = nil, - _type: Swift.String? = nil - ) { + public init(id: Swift.String? = nil, + label: Swift.String? = nil, + description: Swift.String? = nil, + configuration: Components.Schemas.ModuleDTO.configurationPayload? = nil, + _type: Swift.String? = nil) { self.id = id self.label = label self.description = description self.configuration = configuration self._type = _type } + public enum CodingKeys: String, CodingKey { case id case label @@ -1604,6 +1625,7 @@ public enum Components { case _type = "type" } } + /// - Remark: Generated from `#/components/schemas/Action`. public struct Action: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/Action/inputs`. @@ -1617,13 +1639,16 @@ public enum Components { public init(additionalProperties: [String: Swift.String] = .init()) { self.additionalProperties = additionalProperties } + public init(from decoder: any Decoder) throws { additionalProperties = try decoder.decodeAdditionalProperties(knownKeys: []) } + public func encode(to encoder: any Encoder) throws { try encoder.encodeAdditionalProperties(additionalProperties) } } + /// - Remark: Generated from `#/components/schemas/Action/inputs`. public var inputs: Components.Schemas.Action.inputsPayload? /// - Remark: Generated from `#/components/schemas/Action/description`. @@ -1645,14 +1670,12 @@ public enum Components { /// - typeUID: /// - configuration: /// - id: - public init( - inputs: Components.Schemas.Action.inputsPayload? = nil, - description: Swift.String? = nil, - label: Swift.String? = nil, - typeUID: Swift.String? = nil, - configuration: Components.Schemas.Configuration? = nil, - id: Swift.String? = nil - ) { + public init(inputs: Components.Schemas.Action.inputsPayload? = nil, + description: Swift.String? = nil, + label: Swift.String? = nil, + typeUID: Swift.String? = nil, + configuration: Components.Schemas.Configuration? = nil, + id: Swift.String? = nil) { self.inputs = inputs self.description = description self.label = label @@ -1660,6 +1683,7 @@ public enum Components { self.configuration = configuration self.id = id } + public enum CodingKeys: String, CodingKey { case inputs case description @@ -1669,6 +1693,7 @@ public enum Components { case id } } + /// - Remark: Generated from `#/components/schemas/Condition`. public struct Condition: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/Condition/inputs`. @@ -1682,13 +1707,16 @@ public enum Components { public init(additionalProperties: [String: Swift.String] = .init()) { self.additionalProperties = additionalProperties } + public init(from decoder: any Decoder) throws { additionalProperties = try decoder.decodeAdditionalProperties(knownKeys: []) } + public func encode(to encoder: any Encoder) throws { try encoder.encodeAdditionalProperties(additionalProperties) } } + /// - Remark: Generated from `#/components/schemas/Condition/inputs`. public var inputs: Components.Schemas.Condition.inputsPayload? /// - Remark: Generated from `#/components/schemas/Condition/description`. @@ -1710,14 +1738,12 @@ public enum Components { /// - typeUID: /// - configuration: /// - id: - public init( - inputs: Components.Schemas.Condition.inputsPayload? = nil, - description: Swift.String? = nil, - label: Swift.String? = nil, - typeUID: Swift.String? = nil, - configuration: Components.Schemas.Configuration? = nil, - id: Swift.String? = nil - ) { + public init(inputs: Components.Schemas.Condition.inputsPayload? = nil, + description: Swift.String? = nil, + label: Swift.String? = nil, + typeUID: Swift.String? = nil, + configuration: Components.Schemas.Configuration? = nil, + id: Swift.String? = nil) { self.inputs = inputs self.description = description self.label = label @@ -1725,6 +1751,7 @@ public enum Components { self.configuration = configuration self.id = id } + public enum CodingKeys: String, CodingKey { case inputs case description @@ -1734,17 +1761,19 @@ public enum Components { case id } } + /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameter`. public struct ConfigDescriptionParameter: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameter/name`. public var name: Swift.String? /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameter/type`. @frozen public enum _typePayload: String, Codable, Hashable, Sendable, CaseIterable { - case TEXT = "TEXT" - case INTEGER = "INTEGER" - case DECIMAL = "DECIMAL" - case BOOLEAN = "BOOLEAN" + case TEXT + case INTEGER + case DECIMAL + case BOOLEAN } + /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameter/type`. public var _type: Components.Schemas.ConfigDescriptionParameter._typePayload? /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameter/groupName`. @@ -1812,30 +1841,28 @@ public enum Components { /// - stepSize: /// - verifyable: /// - _default: - public init( - name: Swift.String? = nil, - _type: Components.Schemas.ConfigDescriptionParameter._typePayload? = nil, - groupName: Swift.String? = nil, - pattern: Swift.String? = nil, - required: Swift.Bool? = nil, - readOnly: Swift.Bool? = nil, - multiple: Swift.Bool? = nil, - multipleLimit: Swift.Int32? = nil, - unit: Swift.String? = nil, - unitLabel: Swift.String? = nil, - context: Swift.String? = nil, - label: Swift.String? = nil, - description: Swift.String? = nil, - options: [Components.Schemas.ParameterOption]? = nil, - filterCriteria: [Components.Schemas.FilterCriteria]? = nil, - limitToOptions: Swift.Bool? = nil, - advanced: Swift.Bool? = nil, - minimum: Swift.Double? = nil, - maximum: Swift.Double? = nil, - stepSize: Swift.Double? = nil, - verifyable: Swift.Bool? = nil, - _default: Swift.String? = nil - ) { + public init(name: Swift.String? = nil, + _type: Components.Schemas.ConfigDescriptionParameter._typePayload? = nil, + groupName: Swift.String? = nil, + pattern: Swift.String? = nil, + required: Swift.Bool? = nil, + readOnly: Swift.Bool? = nil, + multiple: Swift.Bool? = nil, + multipleLimit: Swift.Int32? = nil, + unit: Swift.String? = nil, + unitLabel: Swift.String? = nil, + context: Swift.String? = nil, + label: Swift.String? = nil, + description: Swift.String? = nil, + options: [Components.Schemas.ParameterOption]? = nil, + filterCriteria: [Components.Schemas.FilterCriteria]? = nil, + limitToOptions: Swift.Bool? = nil, + advanced: Swift.Bool? = nil, + minimum: Swift.Double? = nil, + maximum: Swift.Double? = nil, + stepSize: Swift.Double? = nil, + verifyable: Swift.Bool? = nil, + _default: Swift.String? = nil) { self.name = name self._type = _type self.groupName = groupName @@ -1859,6 +1886,7 @@ public enum Components { self.verifyable = verifyable self._default = _default } + public enum CodingKeys: String, CodingKey { case name case _type = "type" @@ -1884,6 +1912,7 @@ public enum Components { case _default = "default" } } + /// - Remark: Generated from `#/components/schemas/Configuration`. public struct Configuration: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/Configuration/properties`. @@ -1897,13 +1926,16 @@ public enum Components { public init(additionalProperties: [String: OpenAPIRuntime.OpenAPIObjectContainer] = .init()) { self.additionalProperties = additionalProperties } + public init(from decoder: any Decoder) throws { additionalProperties = try decoder.decodeAdditionalProperties(knownKeys: []) } + public func encode(to encoder: any Encoder) throws { try encoder.encodeAdditionalProperties(additionalProperties) } } + /// - Remark: Generated from `#/components/schemas/Configuration/properties`. public var properties: Components.Schemas.Configuration.propertiesPayload? /// Creates a new `Configuration`. @@ -1913,10 +1945,12 @@ public enum Components { public init(properties: Components.Schemas.Configuration.propertiesPayload? = nil) { self.properties = properties } + public enum CodingKeys: String, CodingKey { case properties } } + /// - Remark: Generated from `#/components/schemas/FilterCriteria`. public struct FilterCriteria: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/FilterCriteria/value`. @@ -1928,18 +1962,18 @@ public enum Components { /// - Parameters: /// - value: /// - name: - public init( - value: Swift.String? = nil, - name: Swift.String? = nil - ) { + public init(value: Swift.String? = nil, + name: Swift.String? = nil) { self.value = value self.name = name } + public enum CodingKeys: String, CodingKey { case value case name } } + /// - Remark: Generated from `#/components/schemas/Module`. public struct Module: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/Module/description`. @@ -1960,19 +1994,18 @@ public enum Components { /// - typeUID: /// - configuration: /// - id: - public init( - description: Swift.String? = nil, - label: Swift.String? = nil, - typeUID: Swift.String? = nil, - configuration: Components.Schemas.Configuration? = nil, - id: Swift.String? = nil - ) { + public init(description: Swift.String? = nil, + label: Swift.String? = nil, + typeUID: Swift.String? = nil, + configuration: Components.Schemas.Configuration? = nil, + id: Swift.String? = nil) { self.description = description self.label = label self.typeUID = typeUID self.configuration = configuration self.id = id } + public enum CodingKeys: String, CodingKey { case description case label @@ -1981,6 +2014,7 @@ public enum Components { case id } } + /// - Remark: Generated from `#/components/schemas/ParameterOption`. public struct ParameterOption: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/ParameterOption/label`. @@ -1992,28 +2026,29 @@ public enum Components { /// - Parameters: /// - label: /// - value: - public init( - label: Swift.String? = nil, - value: Swift.String? = nil - ) { + public init(label: Swift.String? = nil, + value: Swift.String? = nil) { self.label = label self.value = value } + public enum CodingKeys: String, CodingKey { case label case value } } + /// - Remark: Generated from `#/components/schemas/Rule`. public struct Rule: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/Rule/description`. public var description: Swift.String? /// - Remark: Generated from `#/components/schemas/Rule/visibility`. @frozen public enum visibilityPayload: String, Codable, Hashable, Sendable, CaseIterable { - case VISIBLE = "VISIBLE" - case HIDDEN = "HIDDEN" - case EXPERT = "EXPERT" + case VISIBLE + case HIDDEN + case EXPERT } + /// - Remark: Generated from `#/components/schemas/Rule/visibility`. public var visibility: Components.Schemas.Rule.visibilityPayload? /// - Remark: Generated from `#/components/schemas/Rule/configurationDescriptions`. @@ -2051,20 +2086,18 @@ public enum Components { /// - conditions: /// - name: /// - actions: - public init( - description: Swift.String? = nil, - visibility: Components.Schemas.Rule.visibilityPayload? = nil, - configurationDescriptions: [Components.Schemas.ConfigDescriptionParameter]? = nil, - templateUID: Swift.String? = nil, - triggers: [Components.Schemas.Trigger]? = nil, - uid: Swift.String? = nil, - tags: [Swift.String]? = nil, - configuration: Components.Schemas.Configuration? = nil, - modules: [Components.Schemas.Module]? = nil, - conditions: [Components.Schemas.Condition]? = nil, - name: Swift.String? = nil, - actions: [Components.Schemas.Action]? = nil - ) { + public init(description: Swift.String? = nil, + visibility: Components.Schemas.Rule.visibilityPayload? = nil, + configurationDescriptions: [Components.Schemas.ConfigDescriptionParameter]? = nil, + templateUID: Swift.String? = nil, + triggers: [Components.Schemas.Trigger]? = nil, + uid: Swift.String? = nil, + tags: [Swift.String]? = nil, + configuration: Components.Schemas.Configuration? = nil, + modules: [Components.Schemas.Module]? = nil, + conditions: [Components.Schemas.Condition]? = nil, + name: Swift.String? = nil, + actions: [Components.Schemas.Action]? = nil) { self.description = description self.visibility = visibility self.configurationDescriptions = configurationDescriptions @@ -2078,6 +2111,7 @@ public enum Components { self.name = name self.actions = actions } + public enum CodingKeys: String, CodingKey { case description case visibility @@ -2093,6 +2127,7 @@ public enum Components { case actions } } + /// - Remark: Generated from `#/components/schemas/RuleExecution`. public struct RuleExecution: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/RuleExecution/date`. @@ -2104,18 +2139,18 @@ public enum Components { /// - Parameters: /// - date: /// - rule: - public init( - date: Foundation.Date? = nil, - rule: Components.Schemas.Rule? = nil - ) { + public init(date: Foundation.Date? = nil, + rule: Components.Schemas.Rule? = nil) { self.date = date self.rule = rule } + public enum CodingKeys: String, CodingKey { case date case rule } } + /// - Remark: Generated from `#/components/schemas/Trigger`. public struct Trigger: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/Trigger/description`. @@ -2136,19 +2171,18 @@ public enum Components { /// - typeUID: /// - configuration: /// - id: - public init( - description: Swift.String? = nil, - label: Swift.String? = nil, - typeUID: Swift.String? = nil, - configuration: Components.Schemas.Configuration? = nil, - id: Swift.String? = nil - ) { + public init(description: Swift.String? = nil, + label: Swift.String? = nil, + typeUID: Swift.String? = nil, + configuration: Components.Schemas.Configuration? = nil, + id: Swift.String? = nil) { self.description = description self.label = label self.typeUID = typeUID self.configuration = configuration self.id = id } + public enum CodingKeys: String, CodingKey { case description case label @@ -2157,6 +2191,7 @@ public enum Components { case id } } + /// - Remark: Generated from `#/components/schemas/CommandDescription`. public struct CommandDescription: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/CommandDescription/commandOptions`. @@ -2168,10 +2203,12 @@ public enum Components { public init(commandOptions: [Components.Schemas.CommandOption]? = nil) { self.commandOptions = commandOptions } + public enum CodingKeys: String, CodingKey { case commandOptions } } + /// - Remark: Generated from `#/components/schemas/CommandOption`. public struct CommandOption: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/CommandOption/command`. @@ -2183,18 +2220,18 @@ public enum Components { /// - Parameters: /// - command: /// - label: - public init( - command: Swift.String? = nil, - label: Swift.String? = nil - ) { + public init(command: Swift.String? = nil, + label: Swift.String? = nil) { self.command = command self.label = label } + public enum CodingKeys: String, CodingKey { case command case label } } + /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterGroupDTO`. public struct ConfigDescriptionParameterGroupDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterGroupDTO/name`. @@ -2215,19 +2252,18 @@ public enum Components { /// - advanced: /// - label: /// - description: - public init( - name: Swift.String? = nil, - context: Swift.String? = nil, - advanced: Swift.Bool? = nil, - label: Swift.String? = nil, - description: Swift.String? = nil - ) { + public init(name: Swift.String? = nil, + context: Swift.String? = nil, + advanced: Swift.Bool? = nil, + label: Swift.String? = nil, + description: Swift.String? = nil) { self.name = name self.context = context self.advanced = advanced self.label = label self.description = description } + public enum CodingKeys: String, CodingKey { case name case context @@ -2236,6 +2272,7 @@ public enum Components { case description } } + /// - Remark: Generated from `#/components/schemas/StateDescription`. public struct StateDescription: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/StateDescription/minimum`. @@ -2259,14 +2296,12 @@ public enum Components { /// - pattern: /// - readOnly: /// - options: - public init( - minimum: Swift.Double? = nil, - maximum: Swift.Double? = nil, - step: Swift.Double? = nil, - pattern: Swift.String? = nil, - readOnly: Swift.Bool? = nil, - options: [Components.Schemas.StateOption]? = nil - ) { + public init(minimum: Swift.Double? = nil, + maximum: Swift.Double? = nil, + step: Swift.Double? = nil, + pattern: Swift.String? = nil, + readOnly: Swift.Bool? = nil, + options: [Components.Schemas.StateOption]? = nil) { self.minimum = minimum self.maximum = maximum self.step = step @@ -2274,6 +2309,7 @@ public enum Components { self.readOnly = readOnly self.options = options } + public enum CodingKeys: String, CodingKey { case minimum case maximum @@ -2283,6 +2319,7 @@ public enum Components { case options } } + /// - Remark: Generated from `#/components/schemas/StateOption`. public struct StateOption: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/StateOption/value`. @@ -2294,18 +2331,18 @@ public enum Components { /// - Parameters: /// - value: /// - label: - public init( - value: Swift.String? = nil, - label: Swift.String? = nil - ) { + public init(value: Swift.String? = nil, + label: Swift.String? = nil) { self.value = value self.label = label } + public enum CodingKeys: String, CodingKey { case value case label } } + /// - Remark: Generated from `#/components/schemas/ConfigDescriptionDTO`. public struct ConfigDescriptionDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/ConfigDescriptionDTO/uri`. @@ -2320,21 +2357,21 @@ public enum Components { /// - uri: /// - parameters: /// - parameterGroups: - public init( - uri: Swift.String? = nil, - parameters: [Components.Schemas.ConfigDescriptionParameterDTO]? = nil, - parameterGroups: [Components.Schemas.ConfigDescriptionParameterGroupDTO]? = nil - ) { + public init(uri: Swift.String? = nil, + parameters: [Components.Schemas.ConfigDescriptionParameterDTO]? = nil, + parameterGroups: [Components.Schemas.ConfigDescriptionParameterGroupDTO]? = nil) { self.uri = uri self.parameters = parameters self.parameterGroups = parameterGroups } + public enum CodingKeys: String, CodingKey { case uri case parameters case parameterGroups } } + /// - Remark: Generated from `#/components/schemas/MetadataDTO`. public struct MetadataDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/MetadataDTO/value`. @@ -2350,13 +2387,16 @@ public enum Components { public init(additionalProperties: [String: OpenAPIRuntime.OpenAPIObjectContainer] = .init()) { self.additionalProperties = additionalProperties } + public init(from decoder: any Decoder) throws { additionalProperties = try decoder.decodeAdditionalProperties(knownKeys: []) } + public func encode(to encoder: any Encoder) throws { try encoder.encodeAdditionalProperties(additionalProperties) } } + /// - Remark: Generated from `#/components/schemas/MetadataDTO/config`. public var config: Components.Schemas.MetadataDTO.configPayload? /// Creates a new `MetadataDTO`. @@ -2364,18 +2404,18 @@ public enum Components { /// - Parameters: /// - value: /// - config: - public init( - value: Swift.String? = nil, - config: Components.Schemas.MetadataDTO.configPayload? = nil - ) { + public init(value: Swift.String? = nil, + config: Components.Schemas.MetadataDTO.configPayload? = nil) { self.value = value self.config = config } + public enum CodingKeys: String, CodingKey { case value case config } } + /// - Remark: Generated from `#/components/schemas/EnrichedItemDTO`. public struct EnrichedItemDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/EnrichedItemDTO/type`. @@ -2413,13 +2453,16 @@ public enum Components { public init(additionalProperties: [String: OpenAPIRuntime.OpenAPIObjectContainer] = .init()) { self.additionalProperties = additionalProperties } + public init(from decoder: any Decoder) throws { additionalProperties = try decoder.decodeAdditionalProperties(knownKeys: []) } + public func encode(to encoder: any Encoder) throws { try encoder.encodeAdditionalProperties(additionalProperties) } } + /// - Remark: Generated from `#/components/schemas/EnrichedItemDTO/metadata`. public var metadata: Components.Schemas.EnrichedItemDTO.metadataPayload? /// - Remark: Generated from `#/components/schemas/EnrichedItemDTO/editable`. @@ -2441,22 +2484,20 @@ public enum Components { /// - commandDescription: /// - metadata: /// - editable: - public init( - _type: Swift.String? = nil, - name: Swift.String? = nil, - label: Swift.String? = nil, - category: Swift.String? = nil, - tags: [Swift.String]? = nil, - groupNames: [Swift.String]? = nil, - link: Swift.String? = nil, - state: Swift.String? = nil, - transformedState: Swift.String? = nil, - stateDescription: Components.Schemas.StateDescription? = nil, - unitSymbol: Swift.String? = nil, - commandDescription: Components.Schemas.CommandDescription? = nil, - metadata: Components.Schemas.EnrichedItemDTO.metadataPayload? = nil, - editable: Swift.Bool? = nil - ) { + public init(_type: Swift.String? = nil, + name: Swift.String? = nil, + label: Swift.String? = nil, + category: Swift.String? = nil, + tags: [Swift.String]? = nil, + groupNames: [Swift.String]? = nil, + link: Swift.String? = nil, + state: Swift.String? = nil, + transformedState: Swift.String? = nil, + stateDescription: Components.Schemas.StateDescription? = nil, + unitSymbol: Swift.String? = nil, + commandDescription: Components.Schemas.CommandDescription? = nil, + metadata: Components.Schemas.EnrichedItemDTO.metadataPayload? = nil, + editable: Swift.Bool? = nil) { self._type = _type self.name = name self.label = label @@ -2472,6 +2513,7 @@ public enum Components { self.metadata = metadata self.editable = editable } + public enum CodingKeys: String, CodingKey { case _type = "type" case name @@ -2489,6 +2531,7 @@ public enum Components { case editable } } + /// - Remark: Generated from `#/components/schemas/GroupFunctionDTO`. public struct GroupFunctionDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/GroupFunctionDTO/name`. @@ -2500,18 +2543,18 @@ public enum Components { /// - Parameters: /// - name: /// - params: - public init( - name: Swift.String? = nil, - params: [Swift.String]? = nil - ) { + public init(name: Swift.String? = nil, + params: [Swift.String]? = nil) { self.name = name self.params = params } + public enum CodingKeys: String, CodingKey { case name case params } } + /// - Remark: Generated from `#/components/schemas/GroupItemDTO`. public struct GroupItemDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/GroupItemDTO/type`. @@ -2541,16 +2584,14 @@ public enum Components { /// - groupNames: /// - groupType: /// - function: - public init( - _type: Swift.String? = nil, - name: Swift.String? = nil, - label: Swift.String? = nil, - category: Swift.String? = nil, - tags: [Swift.String]? = nil, - groupNames: [Swift.String]? = nil, - groupType: Swift.String? = nil, - function: Components.Schemas.GroupFunctionDTO? = nil - ) { + public init(_type: Swift.String? = nil, + name: Swift.String? = nil, + label: Swift.String? = nil, + category: Swift.String? = nil, + tags: [Swift.String]? = nil, + groupNames: [Swift.String]? = nil, + groupType: Swift.String? = nil, + function: Components.Schemas.GroupFunctionDTO? = nil) { self._type = _type self.name = name self.label = label @@ -2560,6 +2601,7 @@ public enum Components { self.groupType = groupType self.function = function } + public enum CodingKeys: String, CodingKey { case _type = "type" case name @@ -2571,6 +2613,7 @@ public enum Components { case function } } + /// - Remark: Generated from `#/components/schemas/JerseyResponseBuilderDTO`. public struct JerseyResponseBuilderDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/JerseyResponseBuilderDTO/status`. @@ -2582,18 +2625,18 @@ public enum Components { /// - Parameters: /// - status: /// - context: - public init( - status: Swift.String? = nil, - context: Components.Schemas.ContextDTO? = nil - ) { + public init(status: Swift.String? = nil, + context: Components.Schemas.ContextDTO? = nil) { self.status = status self.context = context } + public enum CodingKeys: String, CodingKey { case status case context } } + /// - Remark: Generated from `#/components/schemas/ContextDTO`. public struct ContextDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/ContextDTO/headers`. @@ -2605,10 +2648,12 @@ public enum Components { public init(headers: Components.Schemas.HeadersDTO? = nil) { self.headers = headers } + public enum CodingKeys: String, CodingKey { case headers } } + /// - Remark: Generated from `#/components/schemas/HeadersDTO`. public struct HeadersDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/HeadersDTO/Location`. @@ -2620,10 +2665,12 @@ public enum Components { public init(Location: [Swift.String]? = nil) { self.Location = Location } + public enum CodingKeys: String, CodingKey { case Location } } + /// - Remark: Generated from `#/components/schemas/Links`. public struct Links: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/Links/type`. @@ -2635,18 +2682,18 @@ public enum Components { /// - Parameters: /// - _type: /// - url: - public init( - _type: Swift.String? = nil, - url: Swift.String? = nil - ) { + public init(_type: Swift.String? = nil, + url: Swift.String? = nil) { self._type = _type self.url = url } + public enum CodingKeys: String, CodingKey { case _type = "type" case url } } + /// - Remark: Generated from `#/components/schemas/RootBean`. public struct RootBean: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/RootBean/version`. @@ -2667,19 +2714,18 @@ public enum Components { /// - measurementSystem: /// - runtimeInfo: /// - links: - public init( - version: Swift.String? = nil, - locale: Swift.String? = nil, - measurementSystem: Swift.String? = nil, - runtimeInfo: Components.Schemas.RuntimeInfo? = nil, - links: [Components.Schemas.Links]? = nil - ) { + public init(version: Swift.String? = nil, + locale: Swift.String? = nil, + measurementSystem: Swift.String? = nil, + runtimeInfo: Components.Schemas.RuntimeInfo? = nil, + links: [Components.Schemas.Links]? = nil) { self.version = version self.locale = locale self.measurementSystem = measurementSystem self.runtimeInfo = runtimeInfo self.links = links } + public enum CodingKeys: String, CodingKey { case version case locale @@ -2688,6 +2734,7 @@ public enum Components { case links } } + /// - Remark: Generated from `#/components/schemas/RuntimeInfo`. public struct RuntimeInfo: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/RuntimeInfo/version`. @@ -2699,18 +2746,18 @@ public enum Components { /// - Parameters: /// - version: /// - buildString: - public init( - version: Swift.String? = nil, - buildString: Swift.String? = nil - ) { + public init(version: Swift.String? = nil, + buildString: Swift.String? = nil) { self.version = version self.buildString = buildString } + public enum CodingKeys: String, CodingKey { case version case buildString } } + /// - Remark: Generated from `#/components/schemas/SystemInfo`. public struct SystemInfo: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/SystemInfo/configFolder`. @@ -2758,22 +2805,20 @@ public enum Components { /// - totalMemory: /// - uptime: /// - startLevel: - public init( - configFolder: Swift.String? = nil, - userdataFolder: Swift.String? = nil, - logFolder: Swift.String? = nil, - javaVersion: Swift.String? = nil, - javaVendor: Swift.String? = nil, - javaVendorVersion: Swift.String? = nil, - osName: Swift.String? = nil, - osVersion: Swift.String? = nil, - osArchitecture: Swift.String? = nil, - availableProcessors: Swift.Int32? = nil, - freeMemory: Swift.Int64? = nil, - totalMemory: Swift.Int64? = nil, - uptime: Swift.Int64? = nil, - startLevel: Swift.Int32? = nil - ) { + public init(configFolder: Swift.String? = nil, + userdataFolder: Swift.String? = nil, + logFolder: Swift.String? = nil, + javaVersion: Swift.String? = nil, + javaVendor: Swift.String? = nil, + javaVendorVersion: Swift.String? = nil, + osName: Swift.String? = nil, + osVersion: Swift.String? = nil, + osArchitecture: Swift.String? = nil, + availableProcessors: Swift.Int32? = nil, + freeMemory: Swift.Int64? = nil, + totalMemory: Swift.Int64? = nil, + uptime: Swift.Int64? = nil, + startLevel: Swift.Int32? = nil) { self.configFolder = configFolder self.userdataFolder = userdataFolder self.logFolder = logFolder @@ -2789,6 +2834,7 @@ public enum Components { self.uptime = uptime self.startLevel = startLevel } + public enum CodingKeys: String, CodingKey { case configFolder case userdataFolder @@ -2806,6 +2852,7 @@ public enum Components { case startLevel } } + /// - Remark: Generated from `#/components/schemas/SystemInfoBean`. public struct SystemInfoBean: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/SystemInfoBean/systemInfo`. @@ -2817,10 +2864,12 @@ public enum Components { public init(systemInfo: Components.Schemas.SystemInfo? = nil) { self.systemInfo = systemInfo } + public enum CodingKeys: String, CodingKey { case systemInfo } } + /// - Remark: Generated from `#/components/schemas/DimensionInfo`. public struct DimensionInfo: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/DimensionInfo/dimension`. @@ -2832,18 +2881,18 @@ public enum Components { /// - Parameters: /// - dimension: /// - systemUnit: - public init( - dimension: Swift.String? = nil, - systemUnit: Swift.String? = nil - ) { + public init(dimension: Swift.String? = nil, + systemUnit: Swift.String? = nil) { self.dimension = dimension self.systemUnit = systemUnit } + public enum CodingKeys: String, CodingKey { case dimension case systemUnit } } + /// - Remark: Generated from `#/components/schemas/UoMInfo`. public struct UoMInfo: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/UoMInfo/dimensions`. @@ -2855,10 +2904,12 @@ public enum Components { public init(dimensions: [Components.Schemas.DimensionInfo]? = nil) { self.dimensions = dimensions } + public enum CodingKeys: String, CodingKey { case dimensions } } + /// - Remark: Generated from `#/components/schemas/UoMInfoBean`. public struct UoMInfoBean: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/UoMInfoBean/uomInfo`. @@ -2870,10 +2921,12 @@ public enum Components { public init(uomInfo: Components.Schemas.UoMInfo? = nil) { self.uomInfo = uomInfo } + public enum CodingKeys: String, CodingKey { case uomInfo } } + /// - Remark: Generated from `#/components/schemas/MappingDTO`. public struct MappingDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/MappingDTO/row`. @@ -2897,14 +2950,12 @@ public enum Components { /// - releaseCommand: /// - label: /// - icon: - public init( - row: Swift.Int32? = nil, - column: Swift.Int32? = nil, - command: Swift.String? = nil, - releaseCommand: Swift.String? = nil, - label: Swift.String? = nil, - icon: Swift.String? = nil - ) { + public init(row: Swift.Int32? = nil, + column: Swift.Int32? = nil, + command: Swift.String? = nil, + releaseCommand: Swift.String? = nil, + label: Swift.String? = nil, + icon: Swift.String? = nil) { self.row = row self.column = column self.command = command @@ -2912,6 +2963,7 @@ public enum Components { self.label = label self.icon = icon } + public enum CodingKeys: String, CodingKey { case row case column @@ -2921,80 +2973,89 @@ public enum Components { case icon } } + /// - Remark: Generated from `#/components/schemas/PageDTO`. public struct PageDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/PageDTO/id`. public var id: Swift.String? { - get { - self.storage.value.id + get { + storage.value.id } _modify { - yield &self.storage.value.id + yield &storage.value.id } } + /// - Remark: Generated from `#/components/schemas/PageDTO/title`. public var title: Swift.String? { - get { - self.storage.value.title + get { + storage.value.title } _modify { - yield &self.storage.value.title + yield &storage.value.title } } + /// - Remark: Generated from `#/components/schemas/PageDTO/icon`. public var icon: Swift.String? { - get { - self.storage.value.icon + get { + storage.value.icon } _modify { - yield &self.storage.value.icon + yield &storage.value.icon } } + /// - Remark: Generated from `#/components/schemas/PageDTO/link`. public var link: Swift.String? { - get { - self.storage.value.link + get { + storage.value.link } _modify { - yield &self.storage.value.link + yield &storage.value.link } } + /// - Remark: Generated from `#/components/schemas/PageDTO/parent`. public var parent: Components.Schemas.PageDTO? { - get { - self.storage.value.parent + get { + storage.value.parent } _modify { - yield &self.storage.value.parent + yield &storage.value.parent } } + /// - Remark: Generated from `#/components/schemas/PageDTO/leaf`. public var leaf: Swift.Bool? { - get { - self.storage.value.leaf + get { + storage.value.leaf } _modify { - yield &self.storage.value.leaf + yield &storage.value.leaf } } + /// - Remark: Generated from `#/components/schemas/PageDTO/timeout`. public var timeout: Swift.Bool? { - get { - self.storage.value.timeout + get { + storage.value.timeout } _modify { - yield &self.storage.value.timeout + yield &storage.value.timeout } } + /// - Remark: Generated from `#/components/schemas/PageDTO/widgets`. public var widgets: [Components.Schemas.WidgetDTO]? { - get { - self.storage.value.widgets + get { + storage.value.widgets } _modify { - yield &self.storage.value.widgets + yield &storage.value.widgets } } + /// Creates a new `PageDTO`. /// /// - Parameters: @@ -3006,17 +3067,15 @@ public enum Components { /// - leaf: /// - timeout: /// - widgets: - public init( - id: Swift.String? = nil, - title: Swift.String? = nil, - icon: Swift.String? = nil, - link: Swift.String? = nil, - parent: Components.Schemas.PageDTO? = nil, - leaf: Swift.Bool? = nil, - timeout: Swift.Bool? = nil, - widgets: [Components.Schemas.WidgetDTO]? = nil - ) { - self.storage = .init(value: .init( + public init(id: Swift.String? = nil, + title: Swift.String? = nil, + icon: Swift.String? = nil, + link: Swift.String? = nil, + parent: Components.Schemas.PageDTO? = nil, + leaf: Swift.Bool? = nil, + timeout: Swift.Bool? = nil, + widgets: [Components.Schemas.WidgetDTO]? = nil) { + storage = .init(value: .init( id: id, title: title, icon: icon, @@ -3027,6 +3086,7 @@ public enum Components { widgets: widgets )) } + public enum CodingKeys: String, CodingKey { case id case title @@ -3037,12 +3097,15 @@ public enum Components { case timeout case widgets } + public init(from decoder: any Decoder) throws { - self.storage = try .init(from: decoder) + storage = try .init(from: decoder) } + public func encode(to encoder: any Encoder) throws { - try self.storage.encode(to: encoder) + try storage.encode(to: encoder) } + /// Internal reference storage to allow type recursion. private var storage: OpenAPIRuntime.CopyOnWriteBox private struct Storage: Codable, Hashable, Sendable { @@ -3062,16 +3125,14 @@ public enum Components { var timeout: Swift.Bool? /// - Remark: Generated from `#/components/schemas/PageDTO/widgets`. var widgets: [Components.Schemas.WidgetDTO]? - init( - id: Swift.String? = nil, - title: Swift.String? = nil, - icon: Swift.String? = nil, - link: Swift.String? = nil, - parent: Components.Schemas.PageDTO? = nil, - leaf: Swift.Bool? = nil, - timeout: Swift.Bool? = nil, - widgets: [Components.Schemas.WidgetDTO]? = nil - ) { + init(id: Swift.String? = nil, + title: Swift.String? = nil, + icon: Swift.String? = nil, + link: Swift.String? = nil, + parent: Components.Schemas.PageDTO? = nil, + leaf: Swift.Bool? = nil, + timeout: Swift.Bool? = nil, + widgets: [Components.Schemas.WidgetDTO]? = nil) { self.id = id self.title = title self.icon = icon @@ -3081,362 +3142,403 @@ public enum Components { self.timeout = timeout self.widgets = widgets } + typealias CodingKeys = Components.Schemas.PageDTO.CodingKeys } } + /// - Remark: Generated from `#/components/schemas/WidgetDTO`. public struct WidgetDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/WidgetDTO/widgetId`. public var widgetId: Swift.String? { - get { - self.storage.value.widgetId + get { + storage.value.widgetId } _modify { - yield &self.storage.value.widgetId + yield &storage.value.widgetId } } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/type`. public var _type: Swift.String? { - get { - self.storage.value._type + get { + storage.value._type } _modify { - yield &self.storage.value._type + yield &storage.value._type } } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/name`. public var name: Swift.String? { - get { - self.storage.value.name + get { + storage.value.name } _modify { - yield &self.storage.value.name + yield &storage.value.name } } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/visibility`. public var visibility: Swift.Bool? { - get { - self.storage.value.visibility + get { + storage.value.visibility } _modify { - yield &self.storage.value.visibility + yield &storage.value.visibility } } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/label`. public var label: Swift.String? { - get { - self.storage.value.label + get { + storage.value.label } _modify { - yield &self.storage.value.label + yield &storage.value.label } } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/labelSource`. public var labelSource: Swift.String? { - get { - self.storage.value.labelSource + get { + storage.value.labelSource } _modify { - yield &self.storage.value.labelSource + yield &storage.value.labelSource } } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/icon`. public var icon: Swift.String? { - get { - self.storage.value.icon + get { + storage.value.icon } _modify { - yield &self.storage.value.icon + yield &storage.value.icon } } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/staticIcon`. public var staticIcon: Swift.Bool? { - get { - self.storage.value.staticIcon + get { + storage.value.staticIcon } _modify { - yield &self.storage.value.staticIcon + yield &storage.value.staticIcon } } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/labelcolor`. public var labelcolor: Swift.String? { - get { - self.storage.value.labelcolor + get { + storage.value.labelcolor } _modify { - yield &self.storage.value.labelcolor + yield &storage.value.labelcolor } } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/valuecolor`. public var valuecolor: Swift.String? { - get { - self.storage.value.valuecolor + get { + storage.value.valuecolor } _modify { - yield &self.storage.value.valuecolor + yield &storage.value.valuecolor } } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/iconcolor`. public var iconcolor: Swift.String? { - get { - self.storage.value.iconcolor + get { + storage.value.iconcolor } _modify { - yield &self.storage.value.iconcolor + yield &storage.value.iconcolor } } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/pattern`. public var pattern: Swift.String? { - get { - self.storage.value.pattern + get { + storage.value.pattern } _modify { - yield &self.storage.value.pattern + yield &storage.value.pattern } } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/unit`. public var unit: Swift.String? { - get { - self.storage.value.unit + get { + storage.value.unit } _modify { - yield &self.storage.value.unit + yield &storage.value.unit } } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/mappings`. public var mappings: [Components.Schemas.MappingDTO]? { - get { - self.storage.value.mappings + get { + storage.value.mappings } _modify { - yield &self.storage.value.mappings + yield &storage.value.mappings } } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/switchSupport`. public var switchSupport: Swift.Bool? { - get { - self.storage.value.switchSupport + get { + storage.value.switchSupport } _modify { - yield &self.storage.value.switchSupport + yield &storage.value.switchSupport } } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/releaseOnly`. public var releaseOnly: Swift.Bool? { - get { - self.storage.value.releaseOnly + get { + storage.value.releaseOnly } _modify { - yield &self.storage.value.releaseOnly + yield &storage.value.releaseOnly } } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/sendFrequency`. public var sendFrequency: Swift.Int32? { - get { - self.storage.value.sendFrequency + get { + storage.value.sendFrequency } _modify { - yield &self.storage.value.sendFrequency + yield &storage.value.sendFrequency } } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/refresh`. public var refresh: Swift.Int32? { - get { - self.storage.value.refresh + get { + storage.value.refresh } _modify { - yield &self.storage.value.refresh + yield &storage.value.refresh } } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/height`. public var height: Swift.Int32? { - get { - self.storage.value.height + get { + storage.value.height } _modify { - yield &self.storage.value.height + yield &storage.value.height } } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/minValue`. public var minValue: Swift.Double? { - get { - self.storage.value.minValue + get { + storage.value.minValue } _modify { - yield &self.storage.value.minValue + yield &storage.value.minValue } } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/maxValue`. public var maxValue: Swift.Double? { - get { - self.storage.value.maxValue + get { + storage.value.maxValue } _modify { - yield &self.storage.value.maxValue + yield &storage.value.maxValue } } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/step`. public var step: Swift.Double? { - get { - self.storage.value.step + get { + storage.value.step } _modify { - yield &self.storage.value.step + yield &storage.value.step } } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/inputHint`. public var inputHint: Swift.String? { - get { - self.storage.value.inputHint + get { + storage.value.inputHint } _modify { - yield &self.storage.value.inputHint + yield &storage.value.inputHint } } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/url`. public var url: Swift.String? { - get { - self.storage.value.url + get { + storage.value.url } _modify { - yield &self.storage.value.url + yield &storage.value.url } } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/encoding`. public var encoding: Swift.String? { - get { - self.storage.value.encoding + get { + storage.value.encoding } _modify { - yield &self.storage.value.encoding + yield &storage.value.encoding } } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/service`. public var service: Swift.String? { - get { - self.storage.value.service + get { + storage.value.service } _modify { - yield &self.storage.value.service + yield &storage.value.service } } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/period`. public var period: Swift.String? { - get { - self.storage.value.period + get { + storage.value.period } _modify { - yield &self.storage.value.period + yield &storage.value.period } } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/yAxisDecimalPattern`. public var yAxisDecimalPattern: Swift.String? { - get { - self.storage.value.yAxisDecimalPattern + get { + storage.value.yAxisDecimalPattern } _modify { - yield &self.storage.value.yAxisDecimalPattern + yield &storage.value.yAxisDecimalPattern } } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/legend`. public var legend: Swift.Bool? { - get { - self.storage.value.legend + get { + storage.value.legend } _modify { - yield &self.storage.value.legend + yield &storage.value.legend } } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/forceAsItem`. public var forceAsItem: Swift.Bool? { - get { - self.storage.value.forceAsItem + get { + storage.value.forceAsItem } _modify { - yield &self.storage.value.forceAsItem + yield &storage.value.forceAsItem } } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/row`. public var row: Swift.Int32? { - get { - self.storage.value.row + get { + storage.value.row } _modify { - yield &self.storage.value.row + yield &storage.value.row } } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/column`. public var column: Swift.Int32? { - get { - self.storage.value.column + get { + storage.value.column } _modify { - yield &self.storage.value.column + yield &storage.value.column } } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/command`. public var command: Swift.String? { - get { - self.storage.value.command + get { + storage.value.command } _modify { - yield &self.storage.value.command + yield &storage.value.command } } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/releaseCommand`. public var releaseCommand: Swift.String? { - get { - self.storage.value.releaseCommand + get { + storage.value.releaseCommand } _modify { - yield &self.storage.value.releaseCommand + yield &storage.value.releaseCommand } } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/stateless`. public var stateless: Swift.Bool? { - get { - self.storage.value.stateless + get { + storage.value.stateless } _modify { - yield &self.storage.value.stateless + yield &storage.value.stateless } } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/state`. public var state: Swift.String? { - get { - self.storage.value.state + get { + storage.value.state } _modify { - yield &self.storage.value.state + yield &storage.value.state } } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/item`. public var item: Components.Schemas.EnrichedItemDTO? { - get { - self.storage.value.item + get { + storage.value.item } _modify { - yield &self.storage.value.item + yield &storage.value.item } } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/linkedPage`. public var linkedPage: Components.Schemas.PageDTO? { - get { - self.storage.value.linkedPage + get { + storage.value.linkedPage } _modify { - yield &self.storage.value.linkedPage + yield &storage.value.linkedPage } } + /// - Remark: Generated from `#/components/schemas/WidgetDTO/widgets`. public var widgets: [Components.Schemas.WidgetDTO]? { - get { - self.storage.value.widgets + get { + storage.value.widgets } _modify { - yield &self.storage.value.widgets + yield &storage.value.widgets } } + /// Creates a new `WidgetDTO`. /// /// - Parameters: @@ -3479,48 +3581,46 @@ public enum Components { /// - item: /// - linkedPage: /// - widgets: - public init( - widgetId: Swift.String? = nil, - _type: Swift.String? = nil, - name: Swift.String? = nil, - visibility: Swift.Bool? = nil, - label: Swift.String? = nil, - labelSource: Swift.String? = nil, - icon: Swift.String? = nil, - staticIcon: Swift.Bool? = nil, - labelcolor: Swift.String? = nil, - valuecolor: Swift.String? = nil, - iconcolor: Swift.String? = nil, - pattern: Swift.String? = nil, - unit: Swift.String? = nil, - mappings: [Components.Schemas.MappingDTO]? = nil, - switchSupport: Swift.Bool? = nil, - releaseOnly: Swift.Bool? = nil, - sendFrequency: Swift.Int32? = nil, - refresh: Swift.Int32? = nil, - height: Swift.Int32? = nil, - minValue: Swift.Double? = nil, - maxValue: Swift.Double? = nil, - step: Swift.Double? = nil, - inputHint: Swift.String? = nil, - url: Swift.String? = nil, - encoding: Swift.String? = nil, - service: Swift.String? = nil, - period: Swift.String? = nil, - yAxisDecimalPattern: Swift.String? = nil, - legend: Swift.Bool? = nil, - forceAsItem: Swift.Bool? = nil, - row: Swift.Int32? = nil, - column: Swift.Int32? = nil, - command: Swift.String? = nil, - releaseCommand: Swift.String? = nil, - stateless: Swift.Bool? = nil, - state: Swift.String? = nil, - item: Components.Schemas.EnrichedItemDTO? = nil, - linkedPage: Components.Schemas.PageDTO? = nil, - widgets: [Components.Schemas.WidgetDTO]? = nil - ) { - self.storage = .init(value: .init( + public init(widgetId: Swift.String? = nil, + _type: Swift.String? = nil, + name: Swift.String? = nil, + visibility: Swift.Bool? = nil, + label: Swift.String? = nil, + labelSource: Swift.String? = nil, + icon: Swift.String? = nil, + staticIcon: Swift.Bool? = nil, + labelcolor: Swift.String? = nil, + valuecolor: Swift.String? = nil, + iconcolor: Swift.String? = nil, + pattern: Swift.String? = nil, + unit: Swift.String? = nil, + mappings: [Components.Schemas.MappingDTO]? = nil, + switchSupport: Swift.Bool? = nil, + releaseOnly: Swift.Bool? = nil, + sendFrequency: Swift.Int32? = nil, + refresh: Swift.Int32? = nil, + height: Swift.Int32? = nil, + minValue: Swift.Double? = nil, + maxValue: Swift.Double? = nil, + step: Swift.Double? = nil, + inputHint: Swift.String? = nil, + url: Swift.String? = nil, + encoding: Swift.String? = nil, + service: Swift.String? = nil, + period: Swift.String? = nil, + yAxisDecimalPattern: Swift.String? = nil, + legend: Swift.Bool? = nil, + forceAsItem: Swift.Bool? = nil, + row: Swift.Int32? = nil, + column: Swift.Int32? = nil, + command: Swift.String? = nil, + releaseCommand: Swift.String? = nil, + stateless: Swift.Bool? = nil, + state: Swift.String? = nil, + item: Components.Schemas.EnrichedItemDTO? = nil, + linkedPage: Components.Schemas.PageDTO? = nil, + widgets: [Components.Schemas.WidgetDTO]? = nil) { + storage = .init(value: .init( widgetId: widgetId, _type: _type, name: name, @@ -3562,6 +3662,7 @@ public enum Components { widgets: widgets )) } + public enum CodingKeys: String, CodingKey { case widgetId case _type = "type" @@ -3603,12 +3704,15 @@ public enum Components { case linkedPage case widgets } + public init(from decoder: any Decoder) throws { - self.storage = try .init(from: decoder) + storage = try .init(from: decoder) } + public func encode(to encoder: any Encoder) throws { - try self.storage.encode(to: encoder) + try storage.encode(to: encoder) } + /// Internal reference storage to allow type recursion. private var storage: OpenAPIRuntime.CopyOnWriteBox private struct Storage: Codable, Hashable, Sendable { @@ -3690,47 +3794,45 @@ public enum Components { var linkedPage: Components.Schemas.PageDTO? /// - Remark: Generated from `#/components/schemas/WidgetDTO/widgets`. var widgets: [Components.Schemas.WidgetDTO]? - init( - widgetId: Swift.String? = nil, - _type: Swift.String? = nil, - name: Swift.String? = nil, - visibility: Swift.Bool? = nil, - label: Swift.String? = nil, - labelSource: Swift.String? = nil, - icon: Swift.String? = nil, - staticIcon: Swift.Bool? = nil, - labelcolor: Swift.String? = nil, - valuecolor: Swift.String? = nil, - iconcolor: Swift.String? = nil, - pattern: Swift.String? = nil, - unit: Swift.String? = nil, - mappings: [Components.Schemas.MappingDTO]? = nil, - switchSupport: Swift.Bool? = nil, - releaseOnly: Swift.Bool? = nil, - sendFrequency: Swift.Int32? = nil, - refresh: Swift.Int32? = nil, - height: Swift.Int32? = nil, - minValue: Swift.Double? = nil, - maxValue: Swift.Double? = nil, - step: Swift.Double? = nil, - inputHint: Swift.String? = nil, - url: Swift.String? = nil, - encoding: Swift.String? = nil, - service: Swift.String? = nil, - period: Swift.String? = nil, - yAxisDecimalPattern: Swift.String? = nil, - legend: Swift.Bool? = nil, - forceAsItem: Swift.Bool? = nil, - row: Swift.Int32? = nil, - column: Swift.Int32? = nil, - command: Swift.String? = nil, - releaseCommand: Swift.String? = nil, - stateless: Swift.Bool? = nil, - state: Swift.String? = nil, - item: Components.Schemas.EnrichedItemDTO? = nil, - linkedPage: Components.Schemas.PageDTO? = nil, - widgets: [Components.Schemas.WidgetDTO]? = nil - ) { + init(widgetId: Swift.String? = nil, + _type: Swift.String? = nil, + name: Swift.String? = nil, + visibility: Swift.Bool? = nil, + label: Swift.String? = nil, + labelSource: Swift.String? = nil, + icon: Swift.String? = nil, + staticIcon: Swift.Bool? = nil, + labelcolor: Swift.String? = nil, + valuecolor: Swift.String? = nil, + iconcolor: Swift.String? = nil, + pattern: Swift.String? = nil, + unit: Swift.String? = nil, + mappings: [Components.Schemas.MappingDTO]? = nil, + switchSupport: Swift.Bool? = nil, + releaseOnly: Swift.Bool? = nil, + sendFrequency: Swift.Int32? = nil, + refresh: Swift.Int32? = nil, + height: Swift.Int32? = nil, + minValue: Swift.Double? = nil, + maxValue: Swift.Double? = nil, + step: Swift.Double? = nil, + inputHint: Swift.String? = nil, + url: Swift.String? = nil, + encoding: Swift.String? = nil, + service: Swift.String? = nil, + period: Swift.String? = nil, + yAxisDecimalPattern: Swift.String? = nil, + legend: Swift.Bool? = nil, + forceAsItem: Swift.Bool? = nil, + row: Swift.Int32? = nil, + column: Swift.Int32? = nil, + command: Swift.String? = nil, + releaseCommand: Swift.String? = nil, + stateless: Swift.Bool? = nil, + state: Swift.String? = nil, + item: Components.Schemas.EnrichedItemDTO? = nil, + linkedPage: Components.Schemas.PageDTO? = nil, + widgets: [Components.Schemas.WidgetDTO]? = nil) { self.widgetId = widgetId self._type = _type self.name = name @@ -3771,9 +3873,11 @@ public enum Components { self.linkedPage = linkedPage self.widgets = widgets } + typealias CodingKeys = Components.Schemas.WidgetDTO.CodingKeys } } + /// - Remark: Generated from `#/components/schemas/SitemapWidgetEvent`. public struct SitemapWidgetEvent: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/SitemapWidgetEvent/widgetId`. @@ -3821,22 +3925,20 @@ public enum Components { /// - item: /// - sitemapName: /// - pageId: - public init( - widgetId: Swift.String? = nil, - label: Swift.String? = nil, - labelSource: Swift.String? = nil, - icon: Swift.String? = nil, - labelcolor: Swift.String? = nil, - valuecolor: Swift.String? = nil, - iconcolor: Swift.String? = nil, - state: Swift.String? = nil, - reloadIcon: Swift.Bool? = nil, - visibility: Swift.Bool? = nil, - descriptionChanged: Swift.Bool? = nil, - item: Components.Schemas.EnrichedItemDTO? = nil, - sitemapName: Swift.String? = nil, - pageId: Swift.String? = nil - ) { + public init(widgetId: Swift.String? = nil, + label: Swift.String? = nil, + labelSource: Swift.String? = nil, + icon: Swift.String? = nil, + labelcolor: Swift.String? = nil, + valuecolor: Swift.String? = nil, + iconcolor: Swift.String? = nil, + state: Swift.String? = nil, + reloadIcon: Swift.Bool? = nil, + visibility: Swift.Bool? = nil, + descriptionChanged: Swift.Bool? = nil, + item: Components.Schemas.EnrichedItemDTO? = nil, + sitemapName: Swift.String? = nil, + pageId: Swift.String? = nil) { self.widgetId = widgetId self.label = label self.labelSource = labelSource @@ -3852,6 +3954,7 @@ public enum Components { self.sitemapName = sitemapName self.pageId = pageId } + public enum CodingKeys: String, CodingKey { case widgetId case label @@ -3869,6 +3972,7 @@ public enum Components { case pageId } } + /// - Remark: Generated from `#/components/schemas/SitemapDTO`. public struct SitemapDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/SitemapDTO/name`. @@ -3889,19 +3993,18 @@ public enum Components { /// - label: /// - link: /// - homepage: - public init( - name: Swift.String? = nil, - icon: Swift.String? = nil, - label: Swift.String? = nil, - link: Swift.String? = nil, - homepage: Components.Schemas.PageDTO? = nil - ) { + public init(name: Swift.String? = nil, + icon: Swift.String? = nil, + label: Swift.String? = nil, + link: Swift.String? = nil, + homepage: Components.Schemas.PageDTO? = nil) { self.name = name self.icon = icon self.label = label self.link = link self.homepage = homepage } + public enum CodingKeys: String, CodingKey { case name case icon @@ -3910,6 +4013,7 @@ public enum Components { case homepage } } + /// - Remark: Generated from `#/components/schemas/RootUIComponent`. public struct RootUIComponent: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/RootUIComponent/component`. @@ -3925,13 +4029,16 @@ public enum Components { public init(additionalProperties: [String: OpenAPIRuntime.OpenAPIObjectContainer] = .init()) { self.additionalProperties = additionalProperties } + public init(from decoder: any Decoder) throws { additionalProperties = try decoder.decodeAdditionalProperties(knownKeys: []) } + public func encode(to encoder: any Encoder) throws { try encoder.encodeAdditionalProperties(additionalProperties) } } + /// - Remark: Generated from `#/components/schemas/RootUIComponent/config`. public var config: Components.Schemas.RootUIComponent.configPayload? /// - Remark: Generated from `#/components/schemas/RootUIComponent/slots`. @@ -3945,13 +4052,16 @@ public enum Components { public init(additionalProperties: [String: [Components.Schemas.UIComponent]] = .init()) { self.additionalProperties = additionalProperties } + public init(from decoder: any Decoder) throws { additionalProperties = try decoder.decodeAdditionalProperties(knownKeys: []) } + public func encode(to encoder: any Encoder) throws { try encoder.encodeAdditionalProperties(additionalProperties) } } + /// - Remark: Generated from `#/components/schemas/RootUIComponent/slots`. public var slots: Components.Schemas.RootUIComponent.slotsPayload? /// - Remark: Generated from `#/components/schemas/RootUIComponent/uid`. @@ -3975,16 +4085,14 @@ public enum Components { /// - props: /// - timestamp: /// - _type: - public init( - component: Swift.String? = nil, - config: Components.Schemas.RootUIComponent.configPayload? = nil, - slots: Components.Schemas.RootUIComponent.slotsPayload? = nil, - uid: Swift.String? = nil, - tags: [Swift.String]? = nil, - props: Components.Schemas.ConfigDescriptionDTO? = nil, - timestamp: Foundation.Date? = nil, - _type: Swift.String? = nil - ) { + public init(component: Swift.String? = nil, + config: Components.Schemas.RootUIComponent.configPayload? = nil, + slots: Components.Schemas.RootUIComponent.slotsPayload? = nil, + uid: Swift.String? = nil, + tags: [Swift.String]? = nil, + props: Components.Schemas.ConfigDescriptionDTO? = nil, + timestamp: Foundation.Date? = nil, + _type: Swift.String? = nil) { self.component = component self.config = config self.slots = slots @@ -3994,6 +4102,7 @@ public enum Components { self.timestamp = timestamp self._type = _type } + public enum CodingKeys: String, CodingKey { case component case config @@ -4005,6 +4114,7 @@ public enum Components { case _type = "type" } } + /// - Remark: Generated from `#/components/schemas/UIComponent`. public struct UIComponent: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/UIComponent/component`. @@ -4020,13 +4130,16 @@ public enum Components { public init(additionalProperties: [String: OpenAPIRuntime.OpenAPIObjectContainer] = .init()) { self.additionalProperties = additionalProperties } + public init(from decoder: any Decoder) throws { additionalProperties = try decoder.decodeAdditionalProperties(knownKeys: []) } + public func encode(to encoder: any Encoder) throws { try encoder.encodeAdditionalProperties(additionalProperties) } } + /// - Remark: Generated from `#/components/schemas/UIComponent/config`. public var config: Components.Schemas.UIComponent.configPayload? /// - Remark: Generated from `#/components/schemas/UIComponent/type`. @@ -4037,21 +4150,21 @@ public enum Components { /// - component: /// - config: /// - _type: - public init( - component: Swift.String? = nil, - config: Components.Schemas.UIComponent.configPayload? = nil, - _type: Swift.String? = nil - ) { + public init(component: Swift.String? = nil, + config: Components.Schemas.UIComponent.configPayload? = nil, + _type: Swift.String? = nil) { self.component = component self.config = config self._type = _type } + public enum CodingKeys: String, CodingKey { case component case config case _type = "type" } } + /// - Remark: Generated from `#/components/schemas/TileDTO`. public struct TileDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/TileDTO/name`. @@ -4069,17 +4182,16 @@ public enum Components { /// - url: /// - overlay: /// - imageUrl: - public init( - name: Swift.String? = nil, - url: Swift.String? = nil, - overlay: Swift.String? = nil, - imageUrl: Swift.String? = nil - ) { + public init(name: Swift.String? = nil, + url: Swift.String? = nil, + overlay: Swift.String? = nil, + imageUrl: Swift.String? = nil) { self.name = name self.url = url self.overlay = overlay self.imageUrl = imageUrl } + public enum CodingKeys: String, CodingKey { case name case url @@ -4088,6 +4200,7 @@ public enum Components { } } } + /// Types generated from the `#/components/parameters` section of the OpenAPI document. public enum Parameters {} /// Types generated from the `#/components/requestBodies` section of the OpenAPI document. @@ -4128,18 +4241,17 @@ public enum Operations { /// - tags: /// - summary: summary fields only /// - staticDataOnly: provides a cacheable list of values not expected to change regularly and honors the If-Modified-Since header, all other parameters are ignored - public init( - prefix: Swift.String? = nil, - tags: [Swift.String]? = nil, - summary: Swift.Bool? = nil, - staticDataOnly: Swift.Bool? = nil - ) { + public init(prefix: Swift.String? = nil, + tags: [Swift.String]? = nil, + summary: Swift.Bool? = nil, + staticDataOnly: Swift.Bool? = nil) { self.prefix = prefix self.tags = tags self.summary = summary self.staticDataOnly = staticDataOnly } } + public var query: Operations.getRules.Input.Query /// - Remark: Generated from `#/paths/rules/GET/header`. public struct Headers: Sendable, Hashable { @@ -4152,20 +4264,20 @@ public enum Operations { self.accept = accept } } + public var headers: Operations.getRules.Input.Headers /// Creates a new `Input`. /// /// - Parameters: /// - query: /// - headers: - public init( - query: Operations.getRules.Input.Query = .init(), - headers: Operations.getRules.Input.Headers = .init() - ) { + public init(query: Operations.getRules.Input.Query = .init(), + headers: Operations.getRules.Input.Headers = .init()) { self.query = query self.headers = headers } } + @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/rules/GET/responses/200/content`. @@ -4180,11 +4292,12 @@ public enum Operations { get throws { switch self { case let .json(body): - return body + body } } } } + /// Received HTTP response body public var body: Operations.getRules.Output.Ok.Body /// Creates a new `Ok`. @@ -4195,6 +4308,7 @@ public enum Operations { self.body = body } } + /// OK /// /// - Remark: Generated from `#/paths//rules/get(getRules)/responses/200`. @@ -4209,7 +4323,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -4218,11 +4332,13 @@ public enum Operations { } } } + /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } + @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -4234,14 +4350,16 @@ public enum Operations { self = .other(rawValue) } } + public var rawValue: Swift.String { switch self { case let .other(string): - return string + string case .json: - return "application/json" + "application/json" } } + public static var allCases: [Self] { [ .json @@ -4249,6 +4367,7 @@ public enum Operations { } } } + /// Creates a rule. /// /// - Remark: HTTP `POST /rules`. @@ -4261,6 +4380,7 @@ public enum Operations { /// - Remark: Generated from `#/paths/rules/POST/requestBody/content/application\/json`. case json(Components.Schemas.RuleDTO) } + public var body: Operations.createRule.Input.Body /// Creates a new `Input`. /// @@ -4270,6 +4390,7 @@ public enum Operations { self.body = body } } + @frozen public enum Output: Sendable, Hashable { public struct Created: Sendable, Hashable { /// - Remark: Generated from `#/paths/rules/POST/responses/201/headers`. @@ -4286,6 +4407,7 @@ public enum Operations { self.Location = Location } } + /// Received HTTP response headers public var headers: Operations.createRule.Output.Created.Headers /// Creates a new `Created`. @@ -4296,6 +4418,7 @@ public enum Operations { self.headers = headers } } + /// Created /// /// - Remark: Generated from `#/paths//rules/post(createRule)/responses/201`. @@ -4310,7 +4433,7 @@ public enum Operations { get throws { switch self { case let .created(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "created", @@ -4319,10 +4442,12 @@ public enum Operations { } } } + public struct BadRequest: Sendable, Hashable { /// Creates a new `BadRequest`. public init() {} } + /// Creation of the rule is refused. Missing required parameter. /// /// - Remark: Generated from `#/paths//rules/post(createRule)/responses/400`. @@ -4337,6 +4462,7 @@ public enum Operations { public static var badRequest: Self { .badRequest(.init()) } + /// The associated value of the enum case if `self` is `.badRequest`. /// /// - Throws: An error if `self` is not `.badRequest`. @@ -4345,7 +4471,7 @@ public enum Operations { get throws { switch self { case let .badRequest(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "badRequest", @@ -4354,10 +4480,12 @@ public enum Operations { } } } + public struct Conflict: Sendable, Hashable { /// Creates a new `Conflict`. public init() {} } + /// Creation of the rule is refused. Rule with the same UID already exists. /// /// - Remark: Generated from `#/paths//rules/post(createRule)/responses/409`. @@ -4372,6 +4500,7 @@ public enum Operations { public static var conflict: Self { .conflict(.init()) } + /// The associated value of the enum case if `self` is `.conflict`. /// /// - Throws: An error if `self` is not `.conflict`. @@ -4380,7 +4509,7 @@ public enum Operations { get throws { switch self { case let .conflict(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "conflict", @@ -4389,12 +4518,14 @@ public enum Operations { } } } + /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } } + /// Sets the rule enabled status. /// /// - Remark: HTTP `POST /rules/{ruleUID}/enable`. @@ -4416,31 +4547,33 @@ public enum Operations { self.ruleUID = ruleUID } } + public var path: Operations.enableRule.Input.Path /// - Remark: Generated from `#/paths/rules/{ruleUID}/enable/POST/requestBody`. @frozen public enum Body: Sendable, Hashable { /// - Remark: Generated from `#/paths/rules/{ruleUID}/enable/POST/requestBody/content/text\/plain`. case plainText(OpenAPIRuntime.HTTPBody) } + public var body: Operations.enableRule.Input.Body /// Creates a new `Input`. /// /// - Parameters: /// - path: /// - body: - public init( - path: Operations.enableRule.Input.Path, - body: Operations.enableRule.Input.Body - ) { + public init(path: Operations.enableRule.Input.Path, + body: Operations.enableRule.Input.Body) { self.path = path self.body = body } } + @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// Creates a new `Ok`. public init() {} } + /// OK /// /// - Remark: Generated from `#/paths//rules/{ruleUID}/enable/post(enableRule)/responses/200`. @@ -4455,6 +4588,7 @@ public enum Operations { public static var ok: Self { .ok(.init()) } + /// The associated value of the enum case if `self` is `.ok`. /// /// - Throws: An error if `self` is not `.ok`. @@ -4463,7 +4597,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -4472,10 +4606,12 @@ public enum Operations { } } } + public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } + /// Rule corresponding to the given UID does not found. /// /// - Remark: Generated from `#/paths//rules/{ruleUID}/enable/post(enableRule)/responses/404`. @@ -4490,6 +4626,7 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } + /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -4498,7 +4635,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -4507,12 +4644,14 @@ public enum Operations { } } } + /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } } + /// Gets the rule actions. /// /// - Remark: HTTP `GET /rules/{ruleUID}/actions`. @@ -4534,6 +4673,7 @@ public enum Operations { self.ruleUID = ruleUID } } + public var path: Operations.getRuleActions.Input.Path /// - Remark: Generated from `#/paths/rules/{ruleUID}/actions/GET/header`. public struct Headers: Sendable, Hashable { @@ -4546,20 +4686,20 @@ public enum Operations { self.accept = accept } } + public var headers: Operations.getRuleActions.Input.Headers /// Creates a new `Input`. /// /// - Parameters: /// - path: /// - headers: - public init( - path: Operations.getRuleActions.Input.Path, - headers: Operations.getRuleActions.Input.Headers = .init() - ) { + public init(path: Operations.getRuleActions.Input.Path, + headers: Operations.getRuleActions.Input.Headers = .init()) { self.path = path self.headers = headers } } + @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/rules/{ruleUID}/actions/GET/responses/200/content`. @@ -4574,11 +4714,12 @@ public enum Operations { get throws { switch self { case let .json(body): - return body + body } } } } + /// Received HTTP response body public var body: Operations.getRuleActions.Output.Ok.Body /// Creates a new `Ok`. @@ -4589,6 +4730,7 @@ public enum Operations { self.body = body } } + /// OK /// /// - Remark: Generated from `#/paths//rules/{ruleUID}/actions/get(getRuleActions)/responses/200`. @@ -4603,7 +4745,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -4612,10 +4754,12 @@ public enum Operations { } } } + public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } + /// Rule corresponding to the given UID does not found. /// /// - Remark: Generated from `#/paths//rules/{ruleUID}/actions/get(getRuleActions)/responses/404`. @@ -4630,6 +4774,7 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } + /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -4638,7 +4783,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -4647,11 +4792,13 @@ public enum Operations { } } } + /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } + @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -4663,14 +4810,16 @@ public enum Operations { self = .other(rawValue) } } + public var rawValue: Swift.String { switch self { case let .other(string): - return string + string case .json: - return "application/json" + "application/json" } } + public static var allCases: [Self] { [ .json @@ -4678,6 +4827,7 @@ public enum Operations { } } } + /// Gets the rule corresponding to the given UID. /// /// - Remark: HTTP `GET /rules/{ruleUID}`. @@ -4699,6 +4849,7 @@ public enum Operations { self.ruleUID = ruleUID } } + public var path: Operations.getRuleById.Input.Path /// - Remark: Generated from `#/paths/rules/{ruleUID}/GET/header`. public struct Headers: Sendable, Hashable { @@ -4711,20 +4862,20 @@ public enum Operations { self.accept = accept } } + public var headers: Operations.getRuleById.Input.Headers /// Creates a new `Input`. /// /// - Parameters: /// - path: /// - headers: - public init( - path: Operations.getRuleById.Input.Path, - headers: Operations.getRuleById.Input.Headers = .init() - ) { + public init(path: Operations.getRuleById.Input.Path, + headers: Operations.getRuleById.Input.Headers = .init()) { self.path = path self.headers = headers } } + @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/rules/{ruleUID}/GET/responses/200/content`. @@ -4739,11 +4890,12 @@ public enum Operations { get throws { switch self { case let .json(body): - return body + body } } } } + /// Received HTTP response body public var body: Operations.getRuleById.Output.Ok.Body /// Creates a new `Ok`. @@ -4754,6 +4906,7 @@ public enum Operations { self.body = body } } + /// OK /// /// - Remark: Generated from `#/paths//rules/{ruleUID}/get(getRuleById)/responses/200`. @@ -4768,7 +4921,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -4777,10 +4930,12 @@ public enum Operations { } } } + public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } + /// Rule not found /// /// - Remark: Generated from `#/paths//rules/{ruleUID}/get(getRuleById)/responses/404`. @@ -4795,6 +4950,7 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } + /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -4803,7 +4959,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -4812,11 +4968,13 @@ public enum Operations { } } } + /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } + @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -4828,14 +4986,16 @@ public enum Operations { self = .other(rawValue) } } + public var rawValue: Swift.String { switch self { case let .other(string): - return string + string case .json: - return "application/json" + "application/json" } } + public static var allCases: [Self] { [ .json @@ -4843,6 +5003,7 @@ public enum Operations { } } } + /// Updates an existing rule corresponding to the given UID. /// /// - Remark: HTTP `PUT /rules/{ruleUID}`. @@ -4864,31 +5025,33 @@ public enum Operations { self.ruleUID = ruleUID } } + public var path: Operations.updateRule.Input.Path /// - Remark: Generated from `#/paths/rules/{ruleUID}/PUT/requestBody`. @frozen public enum Body: Sendable, Hashable { /// - Remark: Generated from `#/paths/rules/{ruleUID}/PUT/requestBody/content/application\/json`. case json(Components.Schemas.RuleDTO) } + public var body: Operations.updateRule.Input.Body /// Creates a new `Input`. /// /// - Parameters: /// - path: /// - body: - public init( - path: Operations.updateRule.Input.Path, - body: Operations.updateRule.Input.Body - ) { + public init(path: Operations.updateRule.Input.Path, + body: Operations.updateRule.Input.Body) { self.path = path self.body = body } } + @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// Creates a new `Ok`. public init() {} } + /// OK /// /// - Remark: Generated from `#/paths//rules/{ruleUID}/put(updateRule)/responses/200`. @@ -4903,6 +5066,7 @@ public enum Operations { public static var ok: Self { .ok(.init()) } + /// The associated value of the enum case if `self` is `.ok`. /// /// - Throws: An error if `self` is not `.ok`. @@ -4911,7 +5075,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -4920,10 +5084,12 @@ public enum Operations { } } } + public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } + /// Rule corresponding to the given UID does not found. /// /// - Remark: Generated from `#/paths//rules/{ruleUID}/put(updateRule)/responses/404`. @@ -4938,6 +5104,7 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } + /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -4946,7 +5113,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -4955,12 +5122,14 @@ public enum Operations { } } } + /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } } + /// Removes an existing rule corresponding to the given UID. /// /// - Remark: HTTP `DELETE /rules/{ruleUID}`. @@ -4982,6 +5151,7 @@ public enum Operations { self.ruleUID = ruleUID } } + public var path: Operations.deleteRule.Input.Path /// Creates a new `Input`. /// @@ -4991,11 +5161,13 @@ public enum Operations { self.path = path } } + @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// Creates a new `Ok`. public init() {} } + /// OK /// /// - Remark: Generated from `#/paths//rules/{ruleUID}/delete(deleteRule)/responses/200`. @@ -5010,6 +5182,7 @@ public enum Operations { public static var ok: Self { .ok(.init()) } + /// The associated value of the enum case if `self` is `.ok`. /// /// - Throws: An error if `self` is not `.ok`. @@ -5018,7 +5191,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -5027,10 +5200,12 @@ public enum Operations { } } } + public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } + /// Rule corresponding to the given UID does not found. /// /// - Remark: Generated from `#/paths//rules/{ruleUID}/delete(deleteRule)/responses/404`. @@ -5045,6 +5220,7 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } + /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -5053,7 +5229,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -5062,12 +5238,14 @@ public enum Operations { } } } + /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } } + /// Gets the rule conditions. /// /// - Remark: HTTP `GET /rules/{ruleUID}/conditions`. @@ -5089,6 +5267,7 @@ public enum Operations { self.ruleUID = ruleUID } } + public var path: Operations.getRuleConditions.Input.Path /// - Remark: Generated from `#/paths/rules/{ruleUID}/conditions/GET/header`. public struct Headers: Sendable, Hashable { @@ -5101,20 +5280,20 @@ public enum Operations { self.accept = accept } } + public var headers: Operations.getRuleConditions.Input.Headers /// Creates a new `Input`. /// /// - Parameters: /// - path: /// - headers: - public init( - path: Operations.getRuleConditions.Input.Path, - headers: Operations.getRuleConditions.Input.Headers = .init() - ) { + public init(path: Operations.getRuleConditions.Input.Path, + headers: Operations.getRuleConditions.Input.Headers = .init()) { self.path = path self.headers = headers } } + @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/rules/{ruleUID}/conditions/GET/responses/200/content`. @@ -5129,11 +5308,12 @@ public enum Operations { get throws { switch self { case let .json(body): - return body + body } } } } + /// Received HTTP response body public var body: Operations.getRuleConditions.Output.Ok.Body /// Creates a new `Ok`. @@ -5144,6 +5324,7 @@ public enum Operations { self.body = body } } + /// OK /// /// - Remark: Generated from `#/paths//rules/{ruleUID}/conditions/get(getRuleConditions)/responses/200`. @@ -5158,7 +5339,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -5167,10 +5348,12 @@ public enum Operations { } } } + public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } + /// Rule corresponding to the given UID does not found. /// /// - Remark: Generated from `#/paths//rules/{ruleUID}/conditions/get(getRuleConditions)/responses/404`. @@ -5185,6 +5368,7 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } + /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -5193,7 +5377,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -5202,11 +5386,13 @@ public enum Operations { } } } + /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } + @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -5218,14 +5404,16 @@ public enum Operations { self = .other(rawValue) } } + public var rawValue: Swift.String { switch self { case let .other(string): - return string + string case .json: - return "application/json" + "application/json" } } + public static var allCases: [Self] { [ .json @@ -5233,6 +5421,7 @@ public enum Operations { } } } + /// Gets the rule configuration values. /// /// - Remark: HTTP `GET /rules/{ruleUID}/config`. @@ -5254,6 +5443,7 @@ public enum Operations { self.ruleUID = ruleUID } } + public var path: Operations.getRuleConfiguration.Input.Path /// - Remark: Generated from `#/paths/rules/{ruleUID}/config/GET/header`. public struct Headers: Sendable, Hashable { @@ -5266,20 +5456,20 @@ public enum Operations { self.accept = accept } } + public var headers: Operations.getRuleConfiguration.Input.Headers /// Creates a new `Input`. /// /// - Parameters: /// - path: /// - headers: - public init( - path: Operations.getRuleConfiguration.Input.Path, - headers: Operations.getRuleConfiguration.Input.Headers = .init() - ) { + public init(path: Operations.getRuleConfiguration.Input.Path, + headers: Operations.getRuleConfiguration.Input.Headers = .init()) { self.path = path self.headers = headers } } + @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/rules/{ruleUID}/config/GET/responses/200/content`. @@ -5294,11 +5484,12 @@ public enum Operations { get throws { switch self { case let .json(body): - return body + body } } } } + /// Received HTTP response body public var body: Operations.getRuleConfiguration.Output.Ok.Body /// Creates a new `Ok`. @@ -5309,6 +5500,7 @@ public enum Operations { self.body = body } } + /// OK /// /// - Remark: Generated from `#/paths//rules/{ruleUID}/config/get(getRuleConfiguration)/responses/200`. @@ -5323,7 +5515,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -5332,10 +5524,12 @@ public enum Operations { } } } + public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } + /// Rule corresponding to the given UID does not found. /// /// - Remark: Generated from `#/paths//rules/{ruleUID}/config/get(getRuleConfiguration)/responses/404`. @@ -5350,6 +5544,7 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } + /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -5358,7 +5553,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -5367,11 +5562,13 @@ public enum Operations { } } } + /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } + @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -5383,14 +5580,16 @@ public enum Operations { self = .other(rawValue) } } + public var rawValue: Swift.String { switch self { case let .other(string): - return string + string case .json: - return "application/json" + "application/json" } } + public static var allCases: [Self] { [ .json @@ -5398,6 +5597,7 @@ public enum Operations { } } } + /// Sets the rule configuration values. /// /// - Remark: HTTP `PUT /rules/{ruleUID}/config`. @@ -5419,6 +5619,7 @@ public enum Operations { self.ruleUID = ruleUID } } + public var path: Operations.updateRuleConfiguration.Input.Path /// - Remark: Generated from `#/paths/rules/{ruleUID}/config/PUT/requestBody`. @frozen public enum Body: Sendable, Hashable { @@ -5433,35 +5634,39 @@ public enum Operations { public init(additionalProperties: [String: OpenAPIRuntime.OpenAPIObjectContainer] = .init()) { self.additionalProperties = additionalProperties } + public init(from decoder: any Decoder) throws { additionalProperties = try decoder.decodeAdditionalProperties(knownKeys: []) } + public func encode(to encoder: any Encoder) throws { try encoder.encodeAdditionalProperties(additionalProperties) } } + /// - Remark: Generated from `#/paths/rules/{ruleUID}/config/PUT/requestBody/content/application\/json`. case json(Operations.updateRuleConfiguration.Input.Body.jsonPayload) } + public var body: Operations.updateRuleConfiguration.Input.Body? /// Creates a new `Input`. /// /// - Parameters: /// - path: /// - body: - public init( - path: Operations.updateRuleConfiguration.Input.Path, - body: Operations.updateRuleConfiguration.Input.Body? = nil - ) { + public init(path: Operations.updateRuleConfiguration.Input.Path, + body: Operations.updateRuleConfiguration.Input.Body? = nil) { self.path = path self.body = body } } + @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// Creates a new `Ok`. public init() {} } + /// OK /// /// - Remark: Generated from `#/paths//rules/{ruleUID}/config/put(updateRuleConfiguration)/responses/200`. @@ -5476,6 +5681,7 @@ public enum Operations { public static var ok: Self { .ok(.init()) } + /// The associated value of the enum case if `self` is `.ok`. /// /// - Throws: An error if `self` is not `.ok`. @@ -5484,7 +5690,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -5493,10 +5699,12 @@ public enum Operations { } } } + public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } + /// Rule corresponding to the given UID does not found. /// /// - Remark: Generated from `#/paths//rules/{ruleUID}/config/put(updateRuleConfiguration)/responses/404`. @@ -5511,6 +5719,7 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } + /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -5519,7 +5728,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -5528,12 +5737,14 @@ public enum Operations { } } } + /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } } + /// Gets the rule's module corresponding to the given Category and ID. /// /// - Remark: HTTP `GET /rules/{ruleUID}/{moduleCategory}/{id}`. @@ -5561,16 +5772,15 @@ public enum Operations { /// - ruleUID: ruleUID /// - moduleCategory: moduleCategory /// - id: id - public init( - ruleUID: Swift.String, - moduleCategory: Swift.String, - id: Swift.String - ) { + public init(ruleUID: Swift.String, + moduleCategory: Swift.String, + id: Swift.String) { self.ruleUID = ruleUID self.moduleCategory = moduleCategory self.id = id } } + public var path: Operations.getRuleModuleById.Input.Path /// - Remark: Generated from `#/paths/rules/{ruleUID}/{moduleCategory}/{id}/GET/header`. public struct Headers: Sendable, Hashable { @@ -5583,20 +5793,20 @@ public enum Operations { self.accept = accept } } + public var headers: Operations.getRuleModuleById.Input.Headers /// Creates a new `Input`. /// /// - Parameters: /// - path: /// - headers: - public init( - path: Operations.getRuleModuleById.Input.Path, - headers: Operations.getRuleModuleById.Input.Headers = .init() - ) { + public init(path: Operations.getRuleModuleById.Input.Path, + headers: Operations.getRuleModuleById.Input.Headers = .init()) { self.path = path self.headers = headers } } + @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/rules/{ruleUID}/{moduleCategory}/{id}/GET/responses/200/content`. @@ -5611,11 +5821,12 @@ public enum Operations { get throws { switch self { case let .json(body): - return body + body } } } } + /// Received HTTP response body public var body: Operations.getRuleModuleById.Output.Ok.Body /// Creates a new `Ok`. @@ -5626,6 +5837,7 @@ public enum Operations { self.body = body } } + /// OK /// /// - Remark: Generated from `#/paths//rules/{ruleUID}/{moduleCategory}/{id}/get(getRuleModuleById)/responses/200`. @@ -5640,7 +5852,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -5649,10 +5861,12 @@ public enum Operations { } } } + public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } + /// Rule corresponding to the given UID does not found or does not have a module with such Category and ID. /// /// - Remark: Generated from `#/paths//rules/{ruleUID}/{moduleCategory}/{id}/get(getRuleModuleById)/responses/404`. @@ -5667,6 +5881,7 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } + /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -5675,7 +5890,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -5684,11 +5899,13 @@ public enum Operations { } } } + /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } + @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -5700,14 +5917,16 @@ public enum Operations { self = .other(rawValue) } } + public var rawValue: Swift.String { switch self { case let .other(string): - return string + string case .json: - return "application/json" + "application/json" } } + public static var allCases: [Self] { [ .json @@ -5715,6 +5934,7 @@ public enum Operations { } } } + /// Gets the module's configuration. /// /// - Remark: HTTP `GET /rules/{ruleUID}/{moduleCategory}/{id}/config`. @@ -5742,16 +5962,15 @@ public enum Operations { /// - ruleUID: ruleUID /// - moduleCategory: moduleCategory /// - id: id - public init( - ruleUID: Swift.String, - moduleCategory: Swift.String, - id: Swift.String - ) { + public init(ruleUID: Swift.String, + moduleCategory: Swift.String, + id: Swift.String) { self.ruleUID = ruleUID self.moduleCategory = moduleCategory self.id = id } } + public var path: Operations.getRuleModuleConfig.Input.Path /// - Remark: Generated from `#/paths/rules/{ruleUID}/{moduleCategory}/{id}/config/GET/header`. public struct Headers: Sendable, Hashable { @@ -5764,20 +5983,20 @@ public enum Operations { self.accept = accept } } + public var headers: Operations.getRuleModuleConfig.Input.Headers /// Creates a new `Input`. /// /// - Parameters: /// - path: /// - headers: - public init( - path: Operations.getRuleModuleConfig.Input.Path, - headers: Operations.getRuleModuleConfig.Input.Headers = .init() - ) { + public init(path: Operations.getRuleModuleConfig.Input.Path, + headers: Operations.getRuleModuleConfig.Input.Headers = .init()) { self.path = path self.headers = headers } } + @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/rules/{ruleUID}/{moduleCategory}/{id}/config/GET/responses/200/content`. @@ -5792,11 +6011,12 @@ public enum Operations { get throws { switch self { case let .json(body): - return body + body } } } } + /// Received HTTP response body public var body: Operations.getRuleModuleConfig.Output.Ok.Body /// Creates a new `Ok`. @@ -5807,6 +6027,7 @@ public enum Operations { self.body = body } } + /// OK /// /// - Remark: Generated from `#/paths//rules/{ruleUID}/{moduleCategory}/{id}/config/get(getRuleModuleConfig)/responses/200`. @@ -5821,7 +6042,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -5830,10 +6051,12 @@ public enum Operations { } } } + public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } + /// Rule corresponding to the given UID does not found or does not have a module with such Category and ID. /// /// - Remark: Generated from `#/paths//rules/{ruleUID}/{moduleCategory}/{id}/config/get(getRuleModuleConfig)/responses/404`. @@ -5848,6 +6071,7 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } + /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -5856,7 +6080,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -5865,11 +6089,13 @@ public enum Operations { } } } + /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } + @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -5881,14 +6107,16 @@ public enum Operations { self = .other(rawValue) } } + public var rawValue: Swift.String { switch self { case let .other(string): - return string + string case .json: - return "application/json" + "application/json" } } + public static var allCases: [Self] { [ .json @@ -5896,6 +6124,7 @@ public enum Operations { } } } + /// Gets the module's configuration parameter. /// /// - Remark: HTTP `GET /rules/{ruleUID}/{moduleCategory}/{id}/config/{param}`. @@ -5928,18 +6157,17 @@ public enum Operations { /// - moduleCategory: moduleCategory /// - id: id /// - param: param - public init( - ruleUID: Swift.String, - moduleCategory: Swift.String, - id: Swift.String, - param: Swift.String - ) { + public init(ruleUID: Swift.String, + moduleCategory: Swift.String, + id: Swift.String, + param: Swift.String) { self.ruleUID = ruleUID self.moduleCategory = moduleCategory self.id = id self.param = param } } + public var path: Operations.getRuleModuleConfigParameter.Input.Path /// - Remark: Generated from `#/paths/rules/{ruleUID}/{moduleCategory}/{id}/config/{param}/GET/header`. public struct Headers: Sendable, Hashable { @@ -5952,20 +6180,20 @@ public enum Operations { self.accept = accept } } + public var headers: Operations.getRuleModuleConfigParameter.Input.Headers /// Creates a new `Input`. /// /// - Parameters: /// - path: /// - headers: - public init( - path: Operations.getRuleModuleConfigParameter.Input.Path, - headers: Operations.getRuleModuleConfigParameter.Input.Headers = .init() - ) { + public init(path: Operations.getRuleModuleConfigParameter.Input.Path, + headers: Operations.getRuleModuleConfigParameter.Input.Headers = .init()) { self.path = path self.headers = headers } } + @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/rules/{ruleUID}/{moduleCategory}/{id}/config/{param}/GET/responses/200/content`. @@ -5980,11 +6208,12 @@ public enum Operations { get throws { switch self { case let .plainText(body): - return body + body } } } } + /// Received HTTP response body public var body: Operations.getRuleModuleConfigParameter.Output.Ok.Body /// Creates a new `Ok`. @@ -5995,6 +6224,7 @@ public enum Operations { self.body = body } } + /// OK /// /// - Remark: Generated from `#/paths//rules/{ruleUID}/{moduleCategory}/{id}/config/{param}/get(getRuleModuleConfigParameter)/responses/200`. @@ -6009,7 +6239,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -6018,10 +6248,12 @@ public enum Operations { } } } + public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } + /// Rule corresponding to the given UID does not found or does not have a module with such Category and ID. /// /// - Remark: Generated from `#/paths//rules/{ruleUID}/{moduleCategory}/{id}/config/{param}/get(getRuleModuleConfigParameter)/responses/404`. @@ -6036,6 +6268,7 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } + /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -6044,7 +6277,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -6053,11 +6286,13 @@ public enum Operations { } } } + /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } + @frozen public enum AcceptableContentType: AcceptableProtocol { case plainText case other(Swift.String) @@ -6069,14 +6304,16 @@ public enum Operations { self = .other(rawValue) } } + public var rawValue: Swift.String { switch self { case let .other(string): - return string + string case .plainText: - return "text/plain" + "text/plain" } } + public static var allCases: [Self] { [ .plainText @@ -6084,6 +6321,7 @@ public enum Operations { } } } + /// Sets the module's configuration parameter value. /// /// - Remark: HTTP `PUT /rules/{ruleUID}/{moduleCategory}/{id}/config/{param}`. @@ -6116,43 +6354,43 @@ public enum Operations { /// - moduleCategory: moduleCategory /// - id: id /// - param: param - public init( - ruleUID: Swift.String, - moduleCategory: Swift.String, - id: Swift.String, - param: Swift.String - ) { + public init(ruleUID: Swift.String, + moduleCategory: Swift.String, + id: Swift.String, + param: Swift.String) { self.ruleUID = ruleUID self.moduleCategory = moduleCategory self.id = id self.param = param } } + public var path: Operations.setRuleModuleConfigParameter.Input.Path /// - Remark: Generated from `#/paths/rules/{ruleUID}/{moduleCategory}/{id}/config/{param}/PUT/requestBody`. @frozen public enum Body: Sendable, Hashable { /// - Remark: Generated from `#/paths/rules/{ruleUID}/{moduleCategory}/{id}/config/{param}/PUT/requestBody/content/text\/plain`. case plainText(OpenAPIRuntime.HTTPBody) } + public var body: Operations.setRuleModuleConfigParameter.Input.Body /// Creates a new `Input`. /// /// - Parameters: /// - path: /// - body: - public init( - path: Operations.setRuleModuleConfigParameter.Input.Path, - body: Operations.setRuleModuleConfigParameter.Input.Body - ) { + public init(path: Operations.setRuleModuleConfigParameter.Input.Path, + body: Operations.setRuleModuleConfigParameter.Input.Body) { self.path = path self.body = body } } + @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// Creates a new `Ok`. public init() {} } + /// OK /// /// - Remark: Generated from `#/paths//rules/{ruleUID}/{moduleCategory}/{id}/config/{param}/put(setRuleModuleConfigParameter)/responses/200`. @@ -6167,6 +6405,7 @@ public enum Operations { public static var ok: Self { .ok(.init()) } + /// The associated value of the enum case if `self` is `.ok`. /// /// - Throws: An error if `self` is not `.ok`. @@ -6175,7 +6414,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -6184,10 +6423,12 @@ public enum Operations { } } } + public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } + /// Rule corresponding to the given UID does not found or does not have a module with such Category and ID. /// /// - Remark: Generated from `#/paths//rules/{ruleUID}/{moduleCategory}/{id}/config/{param}/put(setRuleModuleConfigParameter)/responses/404`. @@ -6202,6 +6443,7 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } + /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -6210,7 +6452,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -6219,12 +6461,14 @@ public enum Operations { } } } + /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } } + /// Gets the rule triggers. /// /// - Remark: HTTP `GET /rules/{ruleUID}/triggers`. @@ -6246,6 +6490,7 @@ public enum Operations { self.ruleUID = ruleUID } } + public var path: Operations.getRuleTriggers.Input.Path /// - Remark: Generated from `#/paths/rules/{ruleUID}/triggers/GET/header`. public struct Headers: Sendable, Hashable { @@ -6258,20 +6503,20 @@ public enum Operations { self.accept = accept } } + public var headers: Operations.getRuleTriggers.Input.Headers /// Creates a new `Input`. /// /// - Parameters: /// - path: /// - headers: - public init( - path: Operations.getRuleTriggers.Input.Path, - headers: Operations.getRuleTriggers.Input.Headers = .init() - ) { + public init(path: Operations.getRuleTriggers.Input.Path, + headers: Operations.getRuleTriggers.Input.Headers = .init()) { self.path = path self.headers = headers } } + @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/rules/{ruleUID}/triggers/GET/responses/200/content`. @@ -6286,11 +6531,12 @@ public enum Operations { get throws { switch self { case let .json(body): - return body + body } } } } + /// Received HTTP response body public var body: Operations.getRuleTriggers.Output.Ok.Body /// Creates a new `Ok`. @@ -6301,6 +6547,7 @@ public enum Operations { self.body = body } } + /// OK /// /// - Remark: Generated from `#/paths//rules/{ruleUID}/triggers/get(getRuleTriggers)/responses/200`. @@ -6315,7 +6562,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -6324,10 +6571,12 @@ public enum Operations { } } } + public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } + /// Rule corresponding to the given UID does not found. /// /// - Remark: Generated from `#/paths//rules/{ruleUID}/triggers/get(getRuleTriggers)/responses/404`. @@ -6342,6 +6591,7 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } + /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -6350,7 +6600,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -6359,11 +6609,13 @@ public enum Operations { } } } + /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } + @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -6375,14 +6627,16 @@ public enum Operations { self = .other(rawValue) } } + public var rawValue: Swift.String { switch self { case let .other(string): - return string + string case .json: - return "application/json" + "application/json" } } + public static var allCases: [Self] { [ .json @@ -6390,6 +6644,7 @@ public enum Operations { } } } + /// Executes actions of the rule. /// /// - Remark: HTTP `POST /rules/{ruleUID}/runnow`. @@ -6411,6 +6666,7 @@ public enum Operations { self.ruleUID = ruleUID } } + public var path: Operations.runRuleNow_1.Input.Path /// - Remark: Generated from `#/paths/rules/{ruleUID}/runnow/POST/requestBody`. @frozen public enum Body: Sendable, Hashable { @@ -6425,35 +6681,39 @@ public enum Operations { public init(additionalProperties: OpenAPIRuntime.OpenAPIObjectContainer = .init()) { self.additionalProperties = additionalProperties } + public init(from decoder: any Decoder) throws { additionalProperties = try decoder.decodeAdditionalProperties(knownKeys: []) } + public func encode(to encoder: any Encoder) throws { try encoder.encodeAdditionalProperties(additionalProperties) } } + /// - Remark: Generated from `#/paths/rules/{ruleUID}/runnow/POST/requestBody/content/application\/json`. case json(Operations.runRuleNow_1.Input.Body.jsonPayload) } + public var body: Operations.runRuleNow_1.Input.Body? /// Creates a new `Input`. /// /// - Parameters: /// - path: /// - body: - public init( - path: Operations.runRuleNow_1.Input.Path, - body: Operations.runRuleNow_1.Input.Body? = nil - ) { + public init(path: Operations.runRuleNow_1.Input.Path, + body: Operations.runRuleNow_1.Input.Body? = nil) { self.path = path self.body = body } } + @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// Creates a new `Ok`. public init() {} } + /// OK /// /// - Remark: Generated from `#/paths//rules/{ruleUID}/runnow/post(runRuleNow_1)/responses/200`. @@ -6468,6 +6728,7 @@ public enum Operations { public static var ok: Self { .ok(.init()) } + /// The associated value of the enum case if `self` is `.ok`. /// /// - Throws: An error if `self` is not `.ok`. @@ -6476,7 +6737,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -6485,10 +6746,12 @@ public enum Operations { } } } + public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } + /// Rule corresponding to the given UID does not found. /// /// - Remark: Generated from `#/paths//rules/{ruleUID}/runnow/post(runRuleNow_1)/responses/404`. @@ -6503,6 +6766,7 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } + /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -6511,7 +6775,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -6520,12 +6784,14 @@ public enum Operations { } } } + /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } } + /// Simulates the executions of rules filtered by tag 'Schedule' within the given times. /// /// - Remark: HTTP `GET /rules/schedule/simulations`. @@ -6548,14 +6814,13 @@ public enum Operations { /// - Parameters: /// - from: Start time of the simulated rule executions. Will default to the current time. [yyyy-MM-dd'T'HH:mm:ss.SSSZ] /// - until: End time of the simulated rule executions. Will default to 30 days after the start time. Must be less than 180 days after the given start time. [yyyy-MM-dd'T'HH:mm:ss.SSSZ] - public init( - from: Swift.String? = nil, - until: Swift.String? = nil - ) { + public init(from: Swift.String? = nil, + until: Swift.String? = nil) { self.from = from self.until = until } } + public var query: Operations.getScheduleRuleSimulations.Input.Query /// - Remark: Generated from `#/paths/rules/schedule/simulations/GET/header`. public struct Headers: Sendable, Hashable { @@ -6568,20 +6833,20 @@ public enum Operations { self.accept = accept } } + public var headers: Operations.getScheduleRuleSimulations.Input.Headers /// Creates a new `Input`. /// /// - Parameters: /// - query: /// - headers: - public init( - query: Operations.getScheduleRuleSimulations.Input.Query = .init(), - headers: Operations.getScheduleRuleSimulations.Input.Headers = .init() - ) { + public init(query: Operations.getScheduleRuleSimulations.Input.Query = .init(), + headers: Operations.getScheduleRuleSimulations.Input.Headers = .init()) { self.query = query self.headers = headers } } + @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/rules/schedule/simulations/GET/responses/200/content`. @@ -6596,11 +6861,12 @@ public enum Operations { get throws { switch self { case let .json(body): - return body + body } } } } + /// Received HTTP response body public var body: Operations.getScheduleRuleSimulations.Output.Ok.Body /// Creates a new `Ok`. @@ -6611,6 +6877,7 @@ public enum Operations { self.body = body } } + /// OK /// /// - Remark: Generated from `#/paths//rules/schedule/simulations/get(getScheduleRuleSimulations)/responses/200`. @@ -6625,7 +6892,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -6634,10 +6901,12 @@ public enum Operations { } } } + public struct BadRequest: Sendable, Hashable { /// Creates a new `BadRequest`. public init() {} } + /// The max. simulation duration of 180 days is exceeded. /// /// - Remark: Generated from `#/paths//rules/schedule/simulations/get(getScheduleRuleSimulations)/responses/400`. @@ -6652,6 +6921,7 @@ public enum Operations { public static var badRequest: Self { .badRequest(.init()) } + /// The associated value of the enum case if `self` is `.badRequest`. /// /// - Throws: An error if `self` is not `.badRequest`. @@ -6660,7 +6930,7 @@ public enum Operations { get throws { switch self { case let .badRequest(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "badRequest", @@ -6669,11 +6939,13 @@ public enum Operations { } } } + /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } + @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -6685,14 +6957,16 @@ public enum Operations { self = .other(rawValue) } } + public var rawValue: Swift.String { switch self { case let .other(string): - return string + string case .json: - return "application/json" + "application/json" } } + public static var allCases: [Self] { [ .json @@ -6700,6 +6974,7 @@ public enum Operations { } } } + /// Adds a new member to a group item. /// /// - Remark: HTTP `PUT /items/{itemName}/members/{memberItemName}`. @@ -6722,14 +6997,13 @@ public enum Operations { /// - Parameters: /// - itemName: item name /// - memberItemName: member item name - public init( - itemName: Swift.String, - memberItemName: Swift.String - ) { + public init(itemName: Swift.String, + memberItemName: Swift.String) { self.itemName = itemName self.memberItemName = memberItemName } } + public var path: Operations.addMemberToGroupItem.Input.Path /// Creates a new `Input`. /// @@ -6739,11 +7013,13 @@ public enum Operations { self.path = path } } + @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// Creates a new `Ok`. public init() {} } + /// OK /// /// - Remark: Generated from `#/paths//items/{itemName}/members/{memberItemName}/put(addMemberToGroupItem)/responses/200`. @@ -6758,6 +7034,7 @@ public enum Operations { public static var ok: Self { .ok(.init()) } + /// The associated value of the enum case if `self` is `.ok`. /// /// - Throws: An error if `self` is not `.ok`. @@ -6766,7 +7043,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -6775,10 +7052,12 @@ public enum Operations { } } } + public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } + /// Item or member item not found or item is not of type group item. /// /// - Remark: Generated from `#/paths//items/{itemName}/members/{memberItemName}/put(addMemberToGroupItem)/responses/404`. @@ -6793,6 +7072,7 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } + /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -6801,7 +7081,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -6810,10 +7090,12 @@ public enum Operations { } } } + public struct MethodNotAllowed: Sendable, Hashable { /// Creates a new `MethodNotAllowed`. public init() {} } + /// Member item is not editable. /// /// - Remark: Generated from `#/paths//items/{itemName}/members/{memberItemName}/put(addMemberToGroupItem)/responses/405`. @@ -6828,6 +7110,7 @@ public enum Operations { public static var methodNotAllowed: Self { .methodNotAllowed(.init()) } + /// The associated value of the enum case if `self` is `.methodNotAllowed`. /// /// - Throws: An error if `self` is not `.methodNotAllowed`. @@ -6836,7 +7119,7 @@ public enum Operations { get throws { switch self { case let .methodNotAllowed(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "methodNotAllowed", @@ -6845,12 +7128,14 @@ public enum Operations { } } } + /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } } + /// Removes an existing member from a group item. /// /// - Remark: HTTP `DELETE /items/{itemName}/members/{memberItemName}`. @@ -6873,14 +7158,13 @@ public enum Operations { /// - Parameters: /// - itemName: item name /// - memberItemName: member item name - public init( - itemName: Swift.String, - memberItemName: Swift.String - ) { + public init(itemName: Swift.String, + memberItemName: Swift.String) { self.itemName = itemName self.memberItemName = memberItemName } } + public var path: Operations.removeMemberFromGroupItem.Input.Path /// Creates a new `Input`. /// @@ -6890,11 +7174,13 @@ public enum Operations { self.path = path } } + @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// Creates a new `Ok`. public init() {} } + /// OK /// /// - Remark: Generated from `#/paths//items/{itemName}/members/{memberItemName}/delete(removeMemberFromGroupItem)/responses/200`. @@ -6909,6 +7195,7 @@ public enum Operations { public static var ok: Self { .ok(.init()) } + /// The associated value of the enum case if `self` is `.ok`. /// /// - Throws: An error if `self` is not `.ok`. @@ -6917,7 +7204,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -6926,10 +7213,12 @@ public enum Operations { } } } + public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } + /// Item or member item not found or item is not of type group item. /// /// - Remark: Generated from `#/paths//items/{itemName}/members/{memberItemName}/delete(removeMemberFromGroupItem)/responses/404`. @@ -6944,6 +7233,7 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } + /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -6952,7 +7242,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -6961,10 +7251,12 @@ public enum Operations { } } } + public struct MethodNotAllowed: Sendable, Hashable { /// Creates a new `MethodNotAllowed`. public init() {} } + /// Member item is not editable. /// /// - Remark: Generated from `#/paths//items/{itemName}/members/{memberItemName}/delete(removeMemberFromGroupItem)/responses/405`. @@ -6979,6 +7271,7 @@ public enum Operations { public static var methodNotAllowed: Self { .methodNotAllowed(.init()) } + /// The associated value of the enum case if `self` is `.methodNotAllowed`. /// /// - Throws: An error if `self` is not `.methodNotAllowed`. @@ -6987,7 +7280,7 @@ public enum Operations { get throws { switch self { case let .methodNotAllowed(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "methodNotAllowed", @@ -6996,12 +7289,14 @@ public enum Operations { } } } + /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } } + /// Adds metadata to an item. /// /// - Remark: HTTP `PUT /items/{itemname}/metadata/{namespace}`. @@ -7024,39 +7319,39 @@ public enum Operations { /// - Parameters: /// - itemname: item name /// - namespace: namespace - public init( - itemname: Swift.String, - namespace: Swift.String - ) { + public init(itemname: Swift.String, + namespace: Swift.String) { self.itemname = itemname self.namespace = namespace } } + public var path: Operations.addMetadataToItem.Input.Path /// - Remark: Generated from `#/paths/items/{itemname}/metadata/{namespace}/PUT/requestBody`. @frozen public enum Body: Sendable, Hashable { /// - Remark: Generated from `#/paths/items/{itemname}/metadata/{namespace}/PUT/requestBody/content/application\/json`. case json(Components.Schemas.MetadataDTO) } + public var body: Operations.addMetadataToItem.Input.Body /// Creates a new `Input`. /// /// - Parameters: /// - path: /// - body: - public init( - path: Operations.addMetadataToItem.Input.Path, - body: Operations.addMetadataToItem.Input.Body - ) { + public init(path: Operations.addMetadataToItem.Input.Path, + body: Operations.addMetadataToItem.Input.Body) { self.path = path self.body = body } } + @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// Creates a new `Ok`. public init() {} } + /// OK /// /// - Remark: Generated from `#/paths//items/{itemname}/metadata/{namespace}/put(addMetadataToItem)/responses/200`. @@ -7071,6 +7366,7 @@ public enum Operations { public static var ok: Self { .ok(.init()) } + /// The associated value of the enum case if `self` is `.ok`. /// /// - Throws: An error if `self` is not `.ok`. @@ -7079,7 +7375,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -7088,10 +7384,12 @@ public enum Operations { } } } + public struct Created: Sendable, Hashable { /// Creates a new `Created`. public init() {} } + /// Created /// /// - Remark: Generated from `#/paths//items/{itemname}/metadata/{namespace}/put(addMetadataToItem)/responses/201`. @@ -7106,6 +7404,7 @@ public enum Operations { public static var created: Self { .created(.init()) } + /// The associated value of the enum case if `self` is `.created`. /// /// - Throws: An error if `self` is not `.created`. @@ -7114,7 +7413,7 @@ public enum Operations { get throws { switch self { case let .created(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "created", @@ -7123,10 +7422,12 @@ public enum Operations { } } } + public struct BadRequest: Sendable, Hashable { /// Creates a new `BadRequest`. public init() {} } + /// Metadata value empty. /// /// - Remark: Generated from `#/paths//items/{itemname}/metadata/{namespace}/put(addMetadataToItem)/responses/400`. @@ -7141,6 +7442,7 @@ public enum Operations { public static var badRequest: Self { .badRequest(.init()) } + /// The associated value of the enum case if `self` is `.badRequest`. /// /// - Throws: An error if `self` is not `.badRequest`. @@ -7149,7 +7451,7 @@ public enum Operations { get throws { switch self { case let .badRequest(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "badRequest", @@ -7158,10 +7460,12 @@ public enum Operations { } } } + public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } + /// Item not found. /// /// - Remark: Generated from `#/paths//items/{itemname}/metadata/{namespace}/put(addMetadataToItem)/responses/404`. @@ -7176,6 +7480,7 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } + /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -7184,7 +7489,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -7193,10 +7498,12 @@ public enum Operations { } } } + public struct MethodNotAllowed: Sendable, Hashable { /// Creates a new `MethodNotAllowed`. public init() {} } + /// Metadata not editable. /// /// - Remark: Generated from `#/paths//items/{itemname}/metadata/{namespace}/put(addMetadataToItem)/responses/405`. @@ -7211,6 +7518,7 @@ public enum Operations { public static var methodNotAllowed: Self { .methodNotAllowed(.init()) } + /// The associated value of the enum case if `self` is `.methodNotAllowed`. /// /// - Throws: An error if `self` is not `.methodNotAllowed`. @@ -7219,7 +7527,7 @@ public enum Operations { get throws { switch self { case let .methodNotAllowed(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "methodNotAllowed", @@ -7228,12 +7536,14 @@ public enum Operations { } } } + /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } } + /// Removes metadata from an item. /// /// - Remark: HTTP `DELETE /items/{itemname}/metadata/{namespace}`. @@ -7256,14 +7566,13 @@ public enum Operations { /// - Parameters: /// - itemname: item name /// - namespace: namespace - public init( - itemname: Swift.String, - namespace: Swift.String - ) { + public init(itemname: Swift.String, + namespace: Swift.String) { self.itemname = itemname self.namespace = namespace } } + public var path: Operations.removeMetadataFromItem.Input.Path /// Creates a new `Input`. /// @@ -7273,11 +7582,13 @@ public enum Operations { self.path = path } } + @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// Creates a new `Ok`. public init() {} } + /// OK /// /// - Remark: Generated from `#/paths//items/{itemname}/metadata/{namespace}/delete(removeMetadataFromItem)/responses/200`. @@ -7292,6 +7603,7 @@ public enum Operations { public static var ok: Self { .ok(.init()) } + /// The associated value of the enum case if `self` is `.ok`. /// /// - Throws: An error if `self` is not `.ok`. @@ -7300,7 +7612,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -7309,10 +7621,12 @@ public enum Operations { } } } + public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } + /// Item not found. /// /// - Remark: Generated from `#/paths//items/{itemname}/metadata/{namespace}/delete(removeMetadataFromItem)/responses/404`. @@ -7327,6 +7641,7 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } + /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -7335,7 +7650,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -7344,10 +7659,12 @@ public enum Operations { } } } + public struct MethodNotAllowed: Sendable, Hashable { /// Creates a new `MethodNotAllowed`. public init() {} } + /// Meta data not editable. /// /// - Remark: Generated from `#/paths//items/{itemname}/metadata/{namespace}/delete(removeMetadataFromItem)/responses/405`. @@ -7362,6 +7679,7 @@ public enum Operations { public static var methodNotAllowed: Self { .methodNotAllowed(.init()) } + /// The associated value of the enum case if `self` is `.methodNotAllowed`. /// /// - Throws: An error if `self` is not `.methodNotAllowed`. @@ -7370,7 +7688,7 @@ public enum Operations { get throws { switch self { case let .methodNotAllowed(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "methodNotAllowed", @@ -7379,12 +7697,14 @@ public enum Operations { } } } + /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } } + /// Adds a tag to an item. /// /// - Remark: HTTP `PUT /items/{itemname}/tags/{tag}`. @@ -7407,14 +7727,13 @@ public enum Operations { /// - Parameters: /// - itemname: item name /// - tag: tag - public init( - itemname: Swift.String, - tag: Swift.String - ) { + public init(itemname: Swift.String, + tag: Swift.String) { self.itemname = itemname self.tag = tag } } + public var path: Operations.addTagToItem.Input.Path /// Creates a new `Input`. /// @@ -7424,11 +7743,13 @@ public enum Operations { self.path = path } } + @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// Creates a new `Ok`. public init() {} } + /// OK /// /// - Remark: Generated from `#/paths//items/{itemname}/tags/{tag}/put(addTagToItem)/responses/200`. @@ -7443,6 +7764,7 @@ public enum Operations { public static var ok: Self { .ok(.init()) } + /// The associated value of the enum case if `self` is `.ok`. /// /// - Throws: An error if `self` is not `.ok`. @@ -7451,7 +7773,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -7460,10 +7782,12 @@ public enum Operations { } } } + public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } + /// Item not found. /// /// - Remark: Generated from `#/paths//items/{itemname}/tags/{tag}/put(addTagToItem)/responses/404`. @@ -7478,6 +7802,7 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } + /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -7486,7 +7811,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -7495,10 +7820,12 @@ public enum Operations { } } } + public struct MethodNotAllowed: Sendable, Hashable { /// Creates a new `MethodNotAllowed`. public init() {} } + /// Item not editable. /// /// - Remark: Generated from `#/paths//items/{itemname}/tags/{tag}/put(addTagToItem)/responses/405`. @@ -7513,6 +7840,7 @@ public enum Operations { public static var methodNotAllowed: Self { .methodNotAllowed(.init()) } + /// The associated value of the enum case if `self` is `.methodNotAllowed`. /// /// - Throws: An error if `self` is not `.methodNotAllowed`. @@ -7521,7 +7849,7 @@ public enum Operations { get throws { switch self { case let .methodNotAllowed(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "methodNotAllowed", @@ -7530,12 +7858,14 @@ public enum Operations { } } } + /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } } + /// Removes a tag from an item. /// /// - Remark: HTTP `DELETE /items/{itemname}/tags/{tag}`. @@ -7558,14 +7888,13 @@ public enum Operations { /// - Parameters: /// - itemname: item name /// - tag: tag - public init( - itemname: Swift.String, - tag: Swift.String - ) { + public init(itemname: Swift.String, + tag: Swift.String) { self.itemname = itemname self.tag = tag } } + public var path: Operations.removeTagFromItem.Input.Path /// Creates a new `Input`. /// @@ -7575,11 +7904,13 @@ public enum Operations { self.path = path } } + @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// Creates a new `Ok`. public init() {} } + /// OK /// /// - Remark: Generated from `#/paths//items/{itemname}/tags/{tag}/delete(removeTagFromItem)/responses/200`. @@ -7594,6 +7925,7 @@ public enum Operations { public static var ok: Self { .ok(.init()) } + /// The associated value of the enum case if `self` is `.ok`. /// /// - Throws: An error if `self` is not `.ok`. @@ -7602,7 +7934,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -7611,10 +7943,12 @@ public enum Operations { } } } + public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } + /// Item not found. /// /// - Remark: Generated from `#/paths//items/{itemname}/tags/{tag}/delete(removeTagFromItem)/responses/404`. @@ -7629,6 +7963,7 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } + /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -7637,7 +7972,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -7646,10 +7981,12 @@ public enum Operations { } } } + public struct MethodNotAllowed: Sendable, Hashable { /// Creates a new `MethodNotAllowed`. public init() {} } + /// Item not editable. /// /// - Remark: Generated from `#/paths//items/{itemname}/tags/{tag}/delete(removeTagFromItem)/responses/405`. @@ -7664,6 +8001,7 @@ public enum Operations { public static var methodNotAllowed: Self { .methodNotAllowed(.init()) } + /// The associated value of the enum case if `self` is `.methodNotAllowed`. /// /// - Throws: An error if `self` is not `.methodNotAllowed`. @@ -7672,7 +8010,7 @@ public enum Operations { get throws { switch self { case let .methodNotAllowed(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "methodNotAllowed", @@ -7681,12 +8019,14 @@ public enum Operations { } } } + /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } } + /// Gets a single item. /// /// - Remark: HTTP `GET /items/{itemname}`. @@ -7708,6 +8048,7 @@ public enum Operations { self.itemname = itemname } } + public var path: Operations.getItemByName.Input.Path /// - Remark: Generated from `#/paths/items/{itemname}/GET/query`. public struct Query: Sendable, Hashable { @@ -7724,14 +8065,13 @@ public enum Operations { /// - Parameters: /// - metadata: metadata selector - a comma separated list or a regular expression (returns all if no value given) /// - recursive: get member items if the item is a group item - public init( - metadata: Swift.String? = nil, - recursive: Swift.Bool? = nil - ) { + public init(metadata: Swift.String? = nil, + recursive: Swift.Bool? = nil) { self.metadata = metadata self.recursive = recursive } } + public var query: Operations.getItemByName.Input.Query /// - Remark: Generated from `#/paths/items/{itemname}/GET/header`. public struct Headers: Sendable, Hashable { @@ -7745,14 +8085,13 @@ public enum Operations { /// - Parameters: /// - Accept_hyphen_Language: language /// - accept: - public init( - Accept_hyphen_Language: Swift.String? = nil, - accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues() - ) { + public init(Accept_hyphen_Language: Swift.String? = nil, + accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { self.Accept_hyphen_Language = Accept_hyphen_Language self.accept = accept } } + public var headers: Operations.getItemByName.Input.Headers /// Creates a new `Input`. /// @@ -7760,16 +8099,15 @@ public enum Operations { /// - path: /// - query: /// - headers: - public init( - path: Operations.getItemByName.Input.Path, - query: Operations.getItemByName.Input.Query = .init(), - headers: Operations.getItemByName.Input.Headers = .init() - ) { + public init(path: Operations.getItemByName.Input.Path, + query: Operations.getItemByName.Input.Query = .init(), + headers: Operations.getItemByName.Input.Headers = .init()) { self.path = path self.query = query self.headers = headers } } + @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/items/{itemname}/GET/responses/200/content`. @@ -7784,11 +8122,12 @@ public enum Operations { get throws { switch self { case let .json(body): - return body + body } } } } + /// Received HTTP response body public var body: Operations.getItemByName.Output.Ok.Body /// Creates a new `Ok`. @@ -7799,6 +8138,7 @@ public enum Operations { self.body = body } } + /// OK /// /// - Remark: Generated from `#/paths//items/{itemname}/get(getItemByName)/responses/200`. @@ -7813,7 +8153,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -7822,10 +8162,12 @@ public enum Operations { } } } + public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } + /// Item not found /// /// - Remark: Generated from `#/paths//items/{itemname}/get(getItemByName)/responses/404`. @@ -7840,6 +8182,7 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } + /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -7848,7 +8191,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -7857,11 +8200,13 @@ public enum Operations { } } } + /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } + @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -7873,14 +8218,16 @@ public enum Operations { self = .other(rawValue) } } + public var rawValue: Swift.String { switch self { case let .other(string): - return string + string case .json: - return "application/json" + "application/json" } } + public static var allCases: [Self] { [ .json @@ -7888,6 +8235,7 @@ public enum Operations { } } } + /// Sends a command to an item. /// /// - Remark: HTTP `POST /items/{itemname}`. @@ -7909,31 +8257,33 @@ public enum Operations { self.itemname = itemname } } + public var path: Operations.sendItemCommand.Input.Path /// - Remark: Generated from `#/paths/items/{itemname}/POST/requestBody`. @frozen public enum Body: Sendable, Hashable { /// - Remark: Generated from `#/paths/items/{itemname}/POST/requestBody/content/text\/plain`. case plainText(OpenAPIRuntime.HTTPBody) } + public var body: Operations.sendItemCommand.Input.Body /// Creates a new `Input`. /// /// - Parameters: /// - path: /// - body: - public init( - path: Operations.sendItemCommand.Input.Path, - body: Operations.sendItemCommand.Input.Body - ) { + public init(path: Operations.sendItemCommand.Input.Path, + body: Operations.sendItemCommand.Input.Body) { self.path = path self.body = body } } + @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// Creates a new `Ok`. public init() {} } + /// OK /// /// - Remark: Generated from `#/paths//items/{itemname}/post(sendItemCommand)/responses/200`. @@ -7948,6 +8298,7 @@ public enum Operations { public static var ok: Self { .ok(.init()) } + /// The associated value of the enum case if `self` is `.ok`. /// /// - Throws: An error if `self` is not `.ok`. @@ -7956,7 +8307,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -7965,10 +8316,12 @@ public enum Operations { } } } + public struct BadRequest: Sendable, Hashable { /// Creates a new `BadRequest`. public init() {} } + /// Item command null /// /// - Remark: Generated from `#/paths//items/{itemname}/post(sendItemCommand)/responses/400`. @@ -7983,6 +8336,7 @@ public enum Operations { public static var badRequest: Self { .badRequest(.init()) } + /// The associated value of the enum case if `self` is `.badRequest`. /// /// - Throws: An error if `self` is not `.badRequest`. @@ -7991,7 +8345,7 @@ public enum Operations { get throws { switch self { case let .badRequest(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "badRequest", @@ -8000,10 +8354,12 @@ public enum Operations { } } } + public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } + /// Item not found /// /// - Remark: Generated from `#/paths//items/{itemname}/post(sendItemCommand)/responses/404`. @@ -8018,6 +8374,7 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } + /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -8026,7 +8383,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -8035,12 +8392,14 @@ public enum Operations { } } } + /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } } + /// Adds a new item to the registry or updates the existing item. /// /// - Remark: HTTP `PUT /items/{itemname}`. @@ -8062,6 +8421,7 @@ public enum Operations { self.itemname = itemname } } + public var path: Operations.addOrUpdateItemInRegistry.Input.Path /// - Remark: Generated from `#/paths/items/{itemname}/PUT/header`. public struct Headers: Sendable, Hashable { @@ -8075,20 +8435,20 @@ public enum Operations { /// - Parameters: /// - Accept_hyphen_Language: language /// - accept: - public init( - Accept_hyphen_Language: Swift.String? = nil, - accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues() - ) { + public init(Accept_hyphen_Language: Swift.String? = nil, + accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { self.Accept_hyphen_Language = Accept_hyphen_Language self.accept = accept } } + public var headers: Operations.addOrUpdateItemInRegistry.Input.Headers /// - Remark: Generated from `#/paths/items/{itemname}/PUT/requestBody`. @frozen public enum Body: Sendable, Hashable { /// - Remark: Generated from `#/paths/items/{itemname}/PUT/requestBody/content/application\/json`. case json(Components.Schemas.GroupItemDTO) } + public var body: Operations.addOrUpdateItemInRegistry.Input.Body /// Creates a new `Input`. /// @@ -8096,16 +8456,15 @@ public enum Operations { /// - path: /// - headers: /// - body: - public init( - path: Operations.addOrUpdateItemInRegistry.Input.Path, - headers: Operations.addOrUpdateItemInRegistry.Input.Headers = .init(), - body: Operations.addOrUpdateItemInRegistry.Input.Body - ) { + public init(path: Operations.addOrUpdateItemInRegistry.Input.Path, + headers: Operations.addOrUpdateItemInRegistry.Input.Headers = .init(), + body: Operations.addOrUpdateItemInRegistry.Input.Body) { self.path = path self.headers = headers self.body = body } } + @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/items/{itemname}/PUT/responses/200/content`. @@ -8120,11 +8479,12 @@ public enum Operations { get throws { switch self { case let .any(body): - return body + body } } } } + /// Received HTTP response body public var body: Operations.addOrUpdateItemInRegistry.Output.Ok.Body /// Creates a new `Ok`. @@ -8135,6 +8495,7 @@ public enum Operations { self.body = body } } + /// OK /// /// - Remark: Generated from `#/paths//items/{itemname}/put(addOrUpdateItemInRegistry)/responses/200`. @@ -8149,7 +8510,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -8158,10 +8519,12 @@ public enum Operations { } } } + public struct Created: Sendable, Hashable { /// Creates a new `Created`. public init() {} } + /// Item created. /// /// - Remark: Generated from `#/paths//items/{itemname}/put(addOrUpdateItemInRegistry)/responses/201`. @@ -8176,6 +8539,7 @@ public enum Operations { public static var created: Self { .created(.init()) } + /// The associated value of the enum case if `self` is `.created`. /// /// - Throws: An error if `self` is not `.created`. @@ -8184,7 +8548,7 @@ public enum Operations { get throws { switch self { case let .created(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "created", @@ -8193,10 +8557,12 @@ public enum Operations { } } } + public struct BadRequest: Sendable, Hashable { /// Creates a new `BadRequest`. public init() {} } + /// Payload invalid. /// /// - Remark: Generated from `#/paths//items/{itemname}/put(addOrUpdateItemInRegistry)/responses/400`. @@ -8211,6 +8577,7 @@ public enum Operations { public static var badRequest: Self { .badRequest(.init()) } + /// The associated value of the enum case if `self` is `.badRequest`. /// /// - Throws: An error if `self` is not `.badRequest`. @@ -8219,7 +8586,7 @@ public enum Operations { get throws { switch self { case let .badRequest(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "badRequest", @@ -8228,10 +8595,12 @@ public enum Operations { } } } + public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } + /// Item not found or name in path invalid. /// /// - Remark: Generated from `#/paths//items/{itemname}/put(addOrUpdateItemInRegistry)/responses/404`. @@ -8246,6 +8615,7 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } + /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -8254,7 +8624,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -8263,10 +8633,12 @@ public enum Operations { } } } + public struct MethodNotAllowed: Sendable, Hashable { /// Creates a new `MethodNotAllowed`. public init() {} } + /// Item not editable. /// /// - Remark: Generated from `#/paths//items/{itemname}/put(addOrUpdateItemInRegistry)/responses/405`. @@ -8281,6 +8653,7 @@ public enum Operations { public static var methodNotAllowed: Self { .methodNotAllowed(.init()) } + /// The associated value of the enum case if `self` is `.methodNotAllowed`. /// /// - Throws: An error if `self` is not `.methodNotAllowed`. @@ -8289,7 +8662,7 @@ public enum Operations { get throws { switch self { case let .methodNotAllowed(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "methodNotAllowed", @@ -8298,11 +8671,13 @@ public enum Operations { } } } + /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } + @frozen public enum AcceptableContentType: AcceptableProtocol { case any case other(Swift.String) @@ -8314,14 +8689,16 @@ public enum Operations { self = .other(rawValue) } } + public var rawValue: Swift.String { switch self { case let .other(string): - return string + string case .any: - return "*/*" + "*/*" } } + public static var allCases: [Self] { [ .any @@ -8329,6 +8706,7 @@ public enum Operations { } } } + /// Removes an item from the registry. /// /// - Remark: HTTP `DELETE /items/{itemname}`. @@ -8350,6 +8728,7 @@ public enum Operations { self.itemname = itemname } } + public var path: Operations.removeItemFromRegistry.Input.Path /// Creates a new `Input`. /// @@ -8359,11 +8738,13 @@ public enum Operations { self.path = path } } + @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// Creates a new `Ok`. public init() {} } + /// OK /// /// - Remark: Generated from `#/paths//items/{itemname}/delete(removeItemFromRegistry)/responses/200`. @@ -8378,6 +8759,7 @@ public enum Operations { public static var ok: Self { .ok(.init()) } + /// The associated value of the enum case if `self` is `.ok`. /// /// - Throws: An error if `self` is not `.ok`. @@ -8386,7 +8768,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -8395,10 +8777,12 @@ public enum Operations { } } } + public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } + /// Item not found or item is not editable. /// /// - Remark: Generated from `#/paths//items/{itemname}/delete(removeItemFromRegistry)/responses/404`. @@ -8413,6 +8797,7 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } + /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -8421,7 +8806,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -8430,12 +8815,14 @@ public enum Operations { } } } + /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } } + /// Get all available items. /// /// - Remark: HTTP `GET /items`. @@ -8478,14 +8865,12 @@ public enum Operations { /// - recursive: get member items recursively /// - fields: limit output to the given fields (comma separated) /// - staticDataOnly: provides a cacheable list of values not expected to change regularly and checks the If-Modified-Since header, all other parameters are ignored except "metadata" - public init( - _type: Swift.String? = nil, - tags: Swift.String? = nil, - metadata: Swift.String? = nil, - recursive: Swift.Bool? = nil, - fields: Swift.String? = nil, - staticDataOnly: Swift.Bool? = nil - ) { + public init(_type: Swift.String? = nil, + tags: Swift.String? = nil, + metadata: Swift.String? = nil, + recursive: Swift.Bool? = nil, + fields: Swift.String? = nil, + staticDataOnly: Swift.Bool? = nil) { self._type = _type self.tags = tags self.metadata = metadata @@ -8494,6 +8879,7 @@ public enum Operations { self.staticDataOnly = staticDataOnly } } + public var query: Operations.getItems.Input.Query /// - Remark: Generated from `#/paths/items/GET/header`. public struct Headers: Sendable, Hashable { @@ -8507,28 +8893,26 @@ public enum Operations { /// - Parameters: /// - Accept_hyphen_Language: language /// - accept: - public init( - Accept_hyphen_Language: Swift.String? = nil, - accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues() - ) { + public init(Accept_hyphen_Language: Swift.String? = nil, + accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { self.Accept_hyphen_Language = Accept_hyphen_Language self.accept = accept } } + public var headers: Operations.getItems.Input.Headers /// Creates a new `Input`. /// /// - Parameters: /// - query: /// - headers: - public init( - query: Operations.getItems.Input.Query = .init(), - headers: Operations.getItems.Input.Headers = .init() - ) { + public init(query: Operations.getItems.Input.Query = .init(), + headers: Operations.getItems.Input.Headers = .init()) { self.query = query self.headers = headers } } + @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/items/GET/responses/200/content`. @@ -8543,11 +8927,12 @@ public enum Operations { get throws { switch self { case let .json(body): - return body + body } } } } + /// Received HTTP response body public var body: Operations.getItems.Output.Ok.Body /// Creates a new `Ok`. @@ -8558,6 +8943,7 @@ public enum Operations { self.body = body } } + /// OK /// /// - Remark: Generated from `#/paths//items/get(getItems)/responses/200`. @@ -8572,7 +8958,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -8581,11 +8967,13 @@ public enum Operations { } } } + /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } + @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -8597,14 +8985,16 @@ public enum Operations { self = .other(rawValue) } } + public var rawValue: Swift.String { switch self { case let .other(string): - return string + string case .json: - return "application/json" + "application/json" } } + public static var allCases: [Self] { [ .json @@ -8612,6 +9002,7 @@ public enum Operations { } } } + /// Adds a list of items to the registry or updates the existing items. /// /// - Remark: HTTP `PUT /items`. @@ -8630,26 +9021,27 @@ public enum Operations { self.accept = accept } } + public var headers: Operations.addOrUpdateItemsInRegistry.Input.Headers /// - Remark: Generated from `#/paths/items/PUT/requestBody`. @frozen public enum Body: Sendable, Hashable { /// - Remark: Generated from `#/paths/items/PUT/requestBody/content/application\/json`. case json([Components.Schemas.GroupItemDTO]) } + public var body: Operations.addOrUpdateItemsInRegistry.Input.Body /// Creates a new `Input`. /// /// - Parameters: /// - headers: /// - body: - public init( - headers: Operations.addOrUpdateItemsInRegistry.Input.Headers = .init(), - body: Operations.addOrUpdateItemsInRegistry.Input.Body - ) { + public init(headers: Operations.addOrUpdateItemsInRegistry.Input.Headers = .init(), + body: Operations.addOrUpdateItemsInRegistry.Input.Body) { self.headers = headers self.body = body } } + @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/items/PUT/responses/200/content`. @@ -8664,11 +9056,12 @@ public enum Operations { get throws { switch self { case let .any(body): - return body + body } } } } + /// Received HTTP response body public var body: Operations.addOrUpdateItemsInRegistry.Output.Ok.Body /// Creates a new `Ok`. @@ -8679,6 +9072,7 @@ public enum Operations { self.body = body } } + /// OK /// /// - Remark: Generated from `#/paths//items/put(addOrUpdateItemsInRegistry)/responses/200`. @@ -8693,7 +9087,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -8702,10 +9096,12 @@ public enum Operations { } } } + public struct BadRequest: Sendable, Hashable { /// Creates a new `BadRequest`. public init() {} } + /// Payload is invalid. /// /// - Remark: Generated from `#/paths//items/put(addOrUpdateItemsInRegistry)/responses/400`. @@ -8720,6 +9116,7 @@ public enum Operations { public static var badRequest: Self { .badRequest(.init()) } + /// The associated value of the enum case if `self` is `.badRequest`. /// /// - Throws: An error if `self` is not `.badRequest`. @@ -8728,7 +9125,7 @@ public enum Operations { get throws { switch self { case let .badRequest(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "badRequest", @@ -8737,11 +9134,13 @@ public enum Operations { } } } + /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } + @frozen public enum AcceptableContentType: AcceptableProtocol { case any case other(Swift.String) @@ -8753,14 +9152,16 @@ public enum Operations { self = .other(rawValue) } } + public var rawValue: Swift.String { switch self { case let .other(string): - return string + string case .any: - return "*/*" + "*/*" } } + public static var allCases: [Self] { [ .any @@ -8768,6 +9169,7 @@ public enum Operations { } } } + /// Gets the state of an item. /// /// - Remark: HTTP `GET /items/{itemname}/state`. @@ -8789,6 +9191,7 @@ public enum Operations { self.itemname = itemname } } + public var path: Operations.getItemState_1.Input.Path /// - Remark: Generated from `#/paths/items/{itemname}/state/GET/header`. public struct Headers: Sendable, Hashable { @@ -8801,20 +9204,20 @@ public enum Operations { self.accept = accept } } + public var headers: Operations.getItemState_1.Input.Headers /// Creates a new `Input`. /// /// - Parameters: /// - path: /// - headers: - public init( - path: Operations.getItemState_1.Input.Path, - headers: Operations.getItemState_1.Input.Headers = .init() - ) { + public init(path: Operations.getItemState_1.Input.Path, + headers: Operations.getItemState_1.Input.Headers = .init()) { self.path = path self.headers = headers } } + @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/items/{itemname}/state/GET/responses/200/content`. @@ -8829,11 +9232,12 @@ public enum Operations { get throws { switch self { case let .plainText(body): - return body + body } } } } + /// Received HTTP response body public var body: Operations.getItemState_1.Output.Ok.Body /// Creates a new `Ok`. @@ -8844,6 +9248,7 @@ public enum Operations { self.body = body } } + /// OK /// /// - Remark: Generated from `#/paths//items/{itemname}/state/get(getItemState_1)/responses/200`. @@ -8858,7 +9263,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -8867,10 +9272,12 @@ public enum Operations { } } } + public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } + /// Item not found /// /// - Remark: Generated from `#/paths//items/{itemname}/state/get(getItemState_1)/responses/404`. @@ -8885,6 +9292,7 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } + /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -8893,7 +9301,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -8902,11 +9310,13 @@ public enum Operations { } } } + /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } + @frozen public enum AcceptableContentType: AcceptableProtocol { case plainText case other(Swift.String) @@ -8918,14 +9328,16 @@ public enum Operations { self = .other(rawValue) } } + public var rawValue: Swift.String { switch self { case let .other(string): - return string + string case .plainText: - return "text/plain" + "text/plain" } } + public static var allCases: [Self] { [ .plainText @@ -8933,6 +9345,7 @@ public enum Operations { } } } + /// Updates the state of an item. /// /// - Remark: HTTP `PUT /items/{itemname}/state`. @@ -8954,6 +9367,7 @@ public enum Operations { self.itemname = itemname } } + public var path: Operations.updateItemState.Input.Path /// - Remark: Generated from `#/paths/items/{itemname}/state/PUT/header`. public struct Headers: Sendable, Hashable { @@ -8969,12 +9383,14 @@ public enum Operations { self.Accept_hyphen_Language = Accept_hyphen_Language } } + public var headers: Operations.updateItemState.Input.Headers /// - Remark: Generated from `#/paths/items/{itemname}/state/PUT/requestBody`. @frozen public enum Body: Sendable, Hashable { /// - Remark: Generated from `#/paths/items/{itemname}/state/PUT/requestBody/content/text\/plain`. case plainText(OpenAPIRuntime.HTTPBody) } + public var body: Operations.updateItemState.Input.Body /// Creates a new `Input`. /// @@ -8982,21 +9398,21 @@ public enum Operations { /// - path: /// - headers: /// - body: - public init( - path: Operations.updateItemState.Input.Path, - headers: Operations.updateItemState.Input.Headers = .init(), - body: Operations.updateItemState.Input.Body - ) { + public init(path: Operations.updateItemState.Input.Path, + headers: Operations.updateItemState.Input.Headers = .init(), + body: Operations.updateItemState.Input.Body) { self.path = path self.headers = headers self.body = body } } + @frozen public enum Output: Sendable, Hashable { public struct Accepted: Sendable, Hashable { /// Creates a new `Accepted`. public init() {} } + /// Accepted /// /// - Remark: Generated from `#/paths//items/{itemname}/state/put(updateItemState)/responses/202`. @@ -9011,6 +9427,7 @@ public enum Operations { public static var accepted: Self { .accepted(.init()) } + /// The associated value of the enum case if `self` is `.accepted`. /// /// - Throws: An error if `self` is not `.accepted`. @@ -9019,7 +9436,7 @@ public enum Operations { get throws { switch self { case let .accepted(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "accepted", @@ -9028,10 +9445,12 @@ public enum Operations { } } } + public struct BadRequest: Sendable, Hashable { /// Creates a new `BadRequest`. public init() {} } + /// Item state null /// /// - Remark: Generated from `#/paths//items/{itemname}/state/put(updateItemState)/responses/400`. @@ -9046,6 +9465,7 @@ public enum Operations { public static var badRequest: Self { .badRequest(.init()) } + /// The associated value of the enum case if `self` is `.badRequest`. /// /// - Throws: An error if `self` is not `.badRequest`. @@ -9054,7 +9474,7 @@ public enum Operations { get throws { switch self { case let .badRequest(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "badRequest", @@ -9063,10 +9483,12 @@ public enum Operations { } } } + public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } + /// Item not found /// /// - Remark: Generated from `#/paths//items/{itemname}/state/put(updateItemState)/responses/404`. @@ -9081,6 +9503,7 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } + /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -9089,7 +9512,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -9098,12 +9521,14 @@ public enum Operations { } } } + /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } } + /// Gets the namespace of an item. /// /// - Remark: HTTP `GET /items/{itemname}/metadata/namespaces`. @@ -9125,6 +9550,7 @@ public enum Operations { self.itemname = itemname } } + public var path: Operations.getItemNamespaces.Input.Path /// - Remark: Generated from `#/paths/items/{itemname}/metadata/namespaces/GET/header`. public struct Headers: Sendable, Hashable { @@ -9138,28 +9564,26 @@ public enum Operations { /// - Parameters: /// - Accept_hyphen_Language: language /// - accept: - public init( - Accept_hyphen_Language: Swift.String? = nil, - accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues() - ) { + public init(Accept_hyphen_Language: Swift.String? = nil, + accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { self.Accept_hyphen_Language = Accept_hyphen_Language self.accept = accept } } + public var headers: Operations.getItemNamespaces.Input.Headers /// Creates a new `Input`. /// /// - Parameters: /// - path: /// - headers: - public init( - path: Operations.getItemNamespaces.Input.Path, - headers: Operations.getItemNamespaces.Input.Headers = .init() - ) { + public init(path: Operations.getItemNamespaces.Input.Path, + headers: Operations.getItemNamespaces.Input.Headers = .init()) { self.path = path self.headers = headers } } + @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/items/{itemname}/metadata/namespaces/GET/responses/200/content`. @@ -9174,11 +9598,12 @@ public enum Operations { get throws { switch self { case let .json(body): - return body + body } } } } + /// Received HTTP response body public var body: Operations.getItemNamespaces.Output.Ok.Body /// Creates a new `Ok`. @@ -9189,6 +9614,7 @@ public enum Operations { self.body = body } } + /// OK /// /// - Remark: Generated from `#/paths//items/{itemname}/metadata/namespaces/get(getItemNamespaces)/responses/200`. @@ -9203,7 +9629,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -9212,10 +9638,12 @@ public enum Operations { } } } + public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } + /// Item not found /// /// - Remark: Generated from `#/paths//items/{itemname}/metadata/namespaces/get(getItemNamespaces)/responses/404`. @@ -9230,6 +9658,7 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } + /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -9238,7 +9667,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -9247,11 +9676,13 @@ public enum Operations { } } } + /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } + @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -9263,14 +9694,16 @@ public enum Operations { self = .other(rawValue) } } + public var rawValue: Swift.String { switch self { case let .other(string): - return string + string case .json: - return "application/json" + "application/json" } } + public static var allCases: [Self] { [ .json @@ -9278,6 +9711,7 @@ public enum Operations { } } } + /// Gets the item which defines the requested semantics of an item. /// /// - Remark: HTTP `GET /items/{itemName}/semantic/{semanticClass}`. @@ -9300,14 +9734,13 @@ public enum Operations { /// - Parameters: /// - itemName: item name /// - semanticClass: semantic class - public init( - itemName: Swift.String, - semanticClass: Swift.String - ) { + public init(itemName: Swift.String, + semanticClass: Swift.String) { self.itemName = itemName self.semanticClass = semanticClass } } + public var path: Operations.getSemanticItem.Input.Path /// - Remark: Generated from `#/paths/items/{itemName}/semantic/{semanticClass}/GET/header`. public struct Headers: Sendable, Hashable { @@ -9323,25 +9756,26 @@ public enum Operations { self.Accept_hyphen_Language = Accept_hyphen_Language } } + public var headers: Operations.getSemanticItem.Input.Headers /// Creates a new `Input`. /// /// - Parameters: /// - path: /// - headers: - public init( - path: Operations.getSemanticItem.Input.Path, - headers: Operations.getSemanticItem.Input.Headers = .init() - ) { + public init(path: Operations.getSemanticItem.Input.Path, + headers: Operations.getSemanticItem.Input.Headers = .init()) { self.path = path self.headers = headers } } + @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// Creates a new `Ok`. public init() {} } + /// OK /// /// - Remark: Generated from `#/paths//items/{itemName}/semantic/{semanticClass}/get(getSemanticItem)/responses/200`. @@ -9356,6 +9790,7 @@ public enum Operations { public static var ok: Self { .ok(.init()) } + /// The associated value of the enum case if `self` is `.ok`. /// /// - Throws: An error if `self` is not `.ok`. @@ -9364,7 +9799,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -9373,10 +9808,12 @@ public enum Operations { } } } + public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } + /// Item not found /// /// - Remark: Generated from `#/paths//items/{itemName}/semantic/{semanticClass}/get(getSemanticItem)/responses/404`. @@ -9391,6 +9828,7 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } + /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -9399,7 +9837,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -9408,12 +9846,14 @@ public enum Operations { } } } + /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } } + /// Remove unused/orphaned metadata. /// /// - Remark: HTTP `POST /items/metadata/purge`. @@ -9424,11 +9864,13 @@ public enum Operations { /// Creates a new `Input`. public init() {} } + @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// Creates a new `Ok`. public init() {} } + /// OK /// /// - Remark: Generated from `#/paths//items/metadata/purge/post(purgeDatabase)/responses/200`. @@ -9443,6 +9885,7 @@ public enum Operations { public static var ok: Self { .ok(.init()) } + /// The associated value of the enum case if `self` is `.ok`. /// /// - Throws: An error if `self` is not `.ok`. @@ -9451,7 +9894,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -9460,12 +9903,14 @@ public enum Operations { } } } + /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } } + /// Gets information about the runtime, the API version and links to resources. /// /// - Remark: HTTP `GET //`. @@ -9484,6 +9929,7 @@ public enum Operations { self.accept = accept } } + public var headers: Operations.getRoot.Input.Headers /// Creates a new `Input`. /// @@ -9493,6 +9939,7 @@ public enum Operations { self.headers = headers } } + @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/GET/responses/200/content`. @@ -9507,11 +9954,12 @@ public enum Operations { get throws { switch self { case let .json(body): - return body + body } } } } + /// Received HTTP response body public var body: Operations.getRoot.Output.Ok.Body /// Creates a new `Ok`. @@ -9522,6 +9970,7 @@ public enum Operations { self.body = body } } + /// OK /// /// - Remark: Generated from `#/paths////get(getRoot)/responses/200`. @@ -9536,7 +9985,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -9545,11 +9994,13 @@ public enum Operations { } } } + /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } + @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -9561,14 +10012,16 @@ public enum Operations { self = .other(rawValue) } } + public var rawValue: Swift.String { switch self { case let .other(string): - return string + string case .json: - return "application/json" + "application/json" } } + public static var allCases: [Self] { [ .json @@ -9576,6 +10029,7 @@ public enum Operations { } } } + /// Gets information about the system. /// /// - Remark: HTTP `GET /systeminfo`. @@ -9594,6 +10048,7 @@ public enum Operations { self.accept = accept } } + public var headers: Operations.getSystemInformation.Input.Headers /// Creates a new `Input`. /// @@ -9603,6 +10058,7 @@ public enum Operations { self.headers = headers } } + @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/systeminfo/GET/responses/200/content`. @@ -9617,11 +10073,12 @@ public enum Operations { get throws { switch self { case let .json(body): - return body + body } } } } + /// Received HTTP response body public var body: Operations.getSystemInformation.Output.Ok.Body /// Creates a new `Ok`. @@ -9632,6 +10089,7 @@ public enum Operations { self.body = body } } + /// OK /// /// - Remark: Generated from `#/paths//systeminfo/get(getSystemInformation)/responses/200`. @@ -9646,7 +10104,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -9655,11 +10113,13 @@ public enum Operations { } } } + /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } + @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -9671,14 +10131,16 @@ public enum Operations { self = .other(rawValue) } } + public var rawValue: Swift.String { switch self { case let .other(string): - return string + string case .json: - return "application/json" + "application/json" } } + public static var allCases: [Self] { [ .json @@ -9686,6 +10148,7 @@ public enum Operations { } } } + /// Get all supported dimensions and their system units. /// /// - Remark: HTTP `GET /systeminfo/uom`. @@ -9704,6 +10167,7 @@ public enum Operations { self.accept = accept } } + public var headers: Operations.getUoMInformation.Input.Headers /// Creates a new `Input`. /// @@ -9713,6 +10177,7 @@ public enum Operations { self.headers = headers } } + @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/systeminfo/uom/GET/responses/200/content`. @@ -9727,11 +10192,12 @@ public enum Operations { get throws { switch self { case let .json(body): - return body + body } } } } + /// Received HTTP response body public var body: Operations.getUoMInformation.Output.Ok.Body /// Creates a new `Ok`. @@ -9742,6 +10208,7 @@ public enum Operations { self.body = body } } + /// OK /// /// - Remark: Generated from `#/paths//systeminfo/uom/get(getUoMInformation)/responses/200`. @@ -9756,7 +10223,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -9765,11 +10232,13 @@ public enum Operations { } } } + /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } + @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -9781,14 +10250,16 @@ public enum Operations { self = .other(rawValue) } } + public var rawValue: Swift.String { switch self { case let .other(string): - return string + string case .json: - return "application/json" + "application/json" } } + public static var allCases: [Self] { [ .json @@ -9796,6 +10267,7 @@ public enum Operations { } } } + /// Creates a sitemap event subscription. /// /// - Remark: HTTP `POST /sitemaps/events/subscribe`. @@ -9814,6 +10286,7 @@ public enum Operations { self.accept = accept } } + public var headers: Operations.createSitemapEventSubscription.Input.Headers /// Creates a new `Input`. /// @@ -9823,11 +10296,13 @@ public enum Operations { self.headers = headers } } + @frozen public enum Output: Sendable, Hashable { public struct Created: Sendable, Hashable { /// Creates a new `Created`. public init() {} } + /// Subscription created. /// /// - Remark: Generated from `#/paths//sitemaps/events/subscribe/post(createSitemapEventSubscription)/responses/201`. @@ -9842,6 +10317,7 @@ public enum Operations { public static var created: Self { .created(.init()) } + /// The associated value of the enum case if `self` is `.created`. /// /// - Throws: An error if `self` is not `.created`. @@ -9850,7 +10326,7 @@ public enum Operations { get throws { switch self { case let .created(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "created", @@ -9859,6 +10335,7 @@ public enum Operations { } } } + public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/sitemaps/events/subscribe/POST/responses/200/content`. @frozen public enum Body: Sendable, Hashable { @@ -9872,11 +10349,12 @@ public enum Operations { get throws { switch self { case let .json(body): - return body + body } } } } + /// Received HTTP response body public var body: Operations.createSitemapEventSubscription.Output.Ok.Body /// Creates a new `Ok`. @@ -9887,6 +10365,7 @@ public enum Operations { self.body = body } } + /// OK /// /// - Remark: Generated from `#/paths//sitemaps/events/subscribe/post(createSitemapEventSubscription)/responses/200`. @@ -9901,7 +10380,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -9910,10 +10389,12 @@ public enum Operations { } } } + public struct ServiceUnavailable: Sendable, Hashable { /// Creates a new `ServiceUnavailable`. public init() {} } + /// Subscriptions limit reached. /// /// - Remark: Generated from `#/paths//sitemaps/events/subscribe/post(createSitemapEventSubscription)/responses/503`. @@ -9928,6 +10409,7 @@ public enum Operations { public static var serviceUnavailable: Self { .serviceUnavailable(.init()) } + /// The associated value of the enum case if `self` is `.serviceUnavailable`. /// /// - Throws: An error if `self` is not `.serviceUnavailable`. @@ -9936,7 +10418,7 @@ public enum Operations { get throws { switch self { case let .serviceUnavailable(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "serviceUnavailable", @@ -9945,11 +10427,13 @@ public enum Operations { } } } + /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } + @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -9961,14 +10445,16 @@ public enum Operations { self = .other(rawValue) } } + public var rawValue: Swift.String { switch self { case let .other(string): - return string + string case .json: - return "application/json" + "application/json" } } + public static var allCases: [Self] { [ .json @@ -9976,6 +10462,7 @@ public enum Operations { } } } + /// Polls the data for one page of a sitemap. /// /// - Remark: HTTP `GET /sitemaps/{sitemapname}/{pageid}`. @@ -9998,14 +10485,13 @@ public enum Operations { /// - Parameters: /// - sitemapname: sitemap name /// - pageid: page id - public init( - sitemapname: Swift.String, - pageid: Swift.String - ) { + public init(sitemapname: Swift.String, + pageid: Swift.String) { self.sitemapname = sitemapname self.pageid = pageid } } + public var path: Operations.pollDataForPage.Input.Path /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/{pageid}/GET/query`. public struct Query: Sendable, Hashable { @@ -10022,14 +10508,13 @@ public enum Operations { /// - Parameters: /// - subscriptionid: subscriptionid /// - includeHidden: include hidden widgets - public init( - subscriptionid: Swift.String? = nil, - includeHidden: Swift.Bool? = nil - ) { + public init(subscriptionid: Swift.String? = nil, + includeHidden: Swift.Bool? = nil) { self.subscriptionid = subscriptionid self.includeHidden = includeHidden } } + public var query: Operations.pollDataForPage.Input.Query /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/{pageid}/GET/header`. public struct Headers: Sendable, Hashable { @@ -10048,16 +10533,15 @@ public enum Operations { /// - Accept_hyphen_Language: language /// - X_hyphen_Atmosphere_hyphen_Transport: X-Atmosphere-Transport for long polling /// - accept: - public init( - Accept_hyphen_Language: Swift.String? = nil, - X_hyphen_Atmosphere_hyphen_Transport: Swift.String? = nil, - accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues() - ) { + public init(Accept_hyphen_Language: Swift.String? = nil, + X_hyphen_Atmosphere_hyphen_Transport: Swift.String? = nil, + accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { self.Accept_hyphen_Language = Accept_hyphen_Language self.X_hyphen_Atmosphere_hyphen_Transport = X_hyphen_Atmosphere_hyphen_Transport self.accept = accept } } + public var headers: Operations.pollDataForPage.Input.Headers /// Creates a new `Input`. /// @@ -10065,16 +10549,15 @@ public enum Operations { /// - path: /// - query: /// - headers: - public init( - path: Operations.pollDataForPage.Input.Path, - query: Operations.pollDataForPage.Input.Query = .init(), - headers: Operations.pollDataForPage.Input.Headers = .init() - ) { + public init(path: Operations.pollDataForPage.Input.Path, + query: Operations.pollDataForPage.Input.Query = .init(), + headers: Operations.pollDataForPage.Input.Headers = .init()) { self.path = path self.query = query self.headers = headers } } + @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/{pageid}/GET/responses/200/content`. @@ -10089,11 +10572,12 @@ public enum Operations { get throws { switch self { case let .json(body): - return body + body } } } } + /// Received HTTP response body public var body: Operations.pollDataForPage.Output.Ok.Body /// Creates a new `Ok`. @@ -10104,6 +10588,7 @@ public enum Operations { self.body = body } } + /// OK /// /// - Remark: Generated from `#/paths//sitemaps/{sitemapname}/{pageid}/get(pollDataForPage)/responses/200`. @@ -10118,7 +10603,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -10127,10 +10612,12 @@ public enum Operations { } } } + public struct BadRequest: Sendable, Hashable { /// Creates a new `BadRequest`. public init() {} } + /// Invalid subscription id has been provided. /// /// - Remark: Generated from `#/paths//sitemaps/{sitemapname}/{pageid}/get(pollDataForPage)/responses/400`. @@ -10145,6 +10632,7 @@ public enum Operations { public static var badRequest: Self { .badRequest(.init()) } + /// The associated value of the enum case if `self` is `.badRequest`. /// /// - Throws: An error if `self` is not `.badRequest`. @@ -10153,7 +10641,7 @@ public enum Operations { get throws { switch self { case let .badRequest(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "badRequest", @@ -10162,10 +10650,12 @@ public enum Operations { } } } + public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } + /// Sitemap with requested name does not exist or page does not exist, or page refers to a non-linkable widget /// /// - Remark: Generated from `#/paths//sitemaps/{sitemapname}/{pageid}/get(pollDataForPage)/responses/404`. @@ -10180,6 +10670,7 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } + /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -10188,7 +10679,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -10197,11 +10688,13 @@ public enum Operations { } } } + /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } + @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -10213,14 +10706,16 @@ public enum Operations { self = .other(rawValue) } } + public var rawValue: Swift.String { switch self { case let .other(string): - return string + string case .json: - return "application/json" + "application/json" } } + public static var allCases: [Self] { [ .json @@ -10228,6 +10723,7 @@ public enum Operations { } } } + /// Polls the data for a whole sitemap. Not recommended due to potentially high traffic. /// /// - Remark: HTTP `GET /sitemaps/{sitemapname}/*`. @@ -10249,6 +10745,7 @@ public enum Operations { self.sitemapname = sitemapname } } + public var path: Operations.pollDataForSitemap.Input.Path /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/*/GET/query`. public struct Query: Sendable, Hashable { @@ -10265,14 +10762,13 @@ public enum Operations { /// - Parameters: /// - subscriptionid: subscriptionid /// - includeHidden: include hidden widgets - public init( - subscriptionid: Swift.String? = nil, - includeHidden: Swift.Bool? = nil - ) { + public init(subscriptionid: Swift.String? = nil, + includeHidden: Swift.Bool? = nil) { self.subscriptionid = subscriptionid self.includeHidden = includeHidden } } + public var query: Operations.pollDataForSitemap.Input.Query /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/*/GET/header`. public struct Headers: Sendable, Hashable { @@ -10291,16 +10787,15 @@ public enum Operations { /// - Accept_hyphen_Language: language /// - X_hyphen_Atmosphere_hyphen_Transport: X-Atmosphere-Transport for long polling /// - accept: - public init( - Accept_hyphen_Language: Swift.String? = nil, - X_hyphen_Atmosphere_hyphen_Transport: Swift.String? = nil, - accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues() - ) { + public init(Accept_hyphen_Language: Swift.String? = nil, + X_hyphen_Atmosphere_hyphen_Transport: Swift.String? = nil, + accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { self.Accept_hyphen_Language = Accept_hyphen_Language self.X_hyphen_Atmosphere_hyphen_Transport = X_hyphen_Atmosphere_hyphen_Transport self.accept = accept } } + public var headers: Operations.pollDataForSitemap.Input.Headers /// Creates a new `Input`. /// @@ -10308,16 +10803,15 @@ public enum Operations { /// - path: /// - query: /// - headers: - public init( - path: Operations.pollDataForSitemap.Input.Path, - query: Operations.pollDataForSitemap.Input.Query = .init(), - headers: Operations.pollDataForSitemap.Input.Headers = .init() - ) { + public init(path: Operations.pollDataForSitemap.Input.Path, + query: Operations.pollDataForSitemap.Input.Query = .init(), + headers: Operations.pollDataForSitemap.Input.Headers = .init()) { self.path = path self.query = query self.headers = headers } } + @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/*/GET/responses/200/content`. @@ -10332,11 +10826,12 @@ public enum Operations { get throws { switch self { case let .json(body): - return body + body } } } } + /// Received HTTP response body public var body: Operations.pollDataForSitemap.Output.Ok.Body /// Creates a new `Ok`. @@ -10347,6 +10842,7 @@ public enum Operations { self.body = body } } + /// OK /// /// - Remark: Generated from `#/paths//sitemaps/{sitemapname}/*/get(pollDataForSitemap)/responses/200`. @@ -10361,7 +10857,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -10370,10 +10866,12 @@ public enum Operations { } } } + public struct BadRequest: Sendable, Hashable { /// Creates a new `BadRequest`. public init() {} } + /// Invalid subscription id has been provided. /// /// - Remark: Generated from `#/paths//sitemaps/{sitemapname}/*/get(pollDataForSitemap)/responses/400`. @@ -10388,6 +10886,7 @@ public enum Operations { public static var badRequest: Self { .badRequest(.init()) } + /// The associated value of the enum case if `self` is `.badRequest`. /// /// - Throws: An error if `self` is not `.badRequest`. @@ -10396,7 +10895,7 @@ public enum Operations { get throws { switch self { case let .badRequest(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "badRequest", @@ -10405,10 +10904,12 @@ public enum Operations { } } } + public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } + /// Sitemap with requested name does not exist /// /// - Remark: Generated from `#/paths//sitemaps/{sitemapname}/*/get(pollDataForSitemap)/responses/404`. @@ -10423,6 +10924,7 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } + /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -10431,7 +10933,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -10440,11 +10942,13 @@ public enum Operations { } } } + /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } + @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -10456,14 +10960,16 @@ public enum Operations { self = .other(rawValue) } } + public var rawValue: Swift.String { switch self { case let .other(string): - return string + string case .json: - return "application/json" + "application/json" } } + public static var allCases: [Self] { [ .json @@ -10471,6 +10977,7 @@ public enum Operations { } } } + /// Get sitemap by name. /// /// - Remark: HTTP `GET /sitemaps/{sitemapname}`. @@ -10492,6 +10999,7 @@ public enum Operations { self.sitemapname = sitemapname } } + public var path: Operations.getSitemapByName.Input.Path /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/GET/query`. public struct Query: Sendable, Hashable { @@ -10509,16 +11017,15 @@ public enum Operations { /// - _type: /// - jsoncallback: /// - includeHidden: include hidden widgets - public init( - _type: Swift.String? = nil, - jsoncallback: Swift.String? = nil, - includeHidden: Swift.Bool? = nil - ) { + public init(_type: Swift.String? = nil, + jsoncallback: Swift.String? = nil, + includeHidden: Swift.Bool? = nil) { self._type = _type self.jsoncallback = jsoncallback self.includeHidden = includeHidden } } + public var query: Operations.getSitemapByName.Input.Query /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/GET/header`. public struct Headers: Sendable, Hashable { @@ -10532,14 +11039,13 @@ public enum Operations { /// - Parameters: /// - Accept_hyphen_Language: language /// - accept: - public init( - Accept_hyphen_Language: Swift.String? = nil, - accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues() - ) { + public init(Accept_hyphen_Language: Swift.String? = nil, + accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { self.Accept_hyphen_Language = Accept_hyphen_Language self.accept = accept } } + public var headers: Operations.getSitemapByName.Input.Headers /// Creates a new `Input`. /// @@ -10547,16 +11053,15 @@ public enum Operations { /// - path: /// - query: /// - headers: - public init( - path: Operations.getSitemapByName.Input.Path, - query: Operations.getSitemapByName.Input.Query = .init(), - headers: Operations.getSitemapByName.Input.Headers = .init() - ) { + public init(path: Operations.getSitemapByName.Input.Path, + query: Operations.getSitemapByName.Input.Query = .init(), + headers: Operations.getSitemapByName.Input.Headers = .init()) { self.path = path self.query = query self.headers = headers } } + @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/GET/responses/200/content`. @@ -10571,11 +11076,12 @@ public enum Operations { get throws { switch self { case let .json(body): - return body + body } } } } + /// Received HTTP response body public var body: Operations.getSitemapByName.Output.Ok.Body /// Creates a new `Ok`. @@ -10586,6 +11092,7 @@ public enum Operations { self.body = body } } + /// OK /// /// - Remark: Generated from `#/paths//sitemaps/{sitemapname}/get(getSitemapByName)/responses/200`. @@ -10600,7 +11107,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -10609,11 +11116,13 @@ public enum Operations { } } } + /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } + @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -10625,14 +11134,16 @@ public enum Operations { self = .other(rawValue) } } + public var rawValue: Swift.String { switch self { case let .other(string): - return string + string case .json: - return "application/json" + "application/json" } } + public static var allCases: [Self] { [ .json @@ -10640,6 +11151,7 @@ public enum Operations { } } } + /// Get sitemap events for a whole sitemap. Not recommended due to potentially high traffic. /// /// - Remark: HTTP `GET /sitemaps/events/{subscriptionid}/*`. @@ -10661,6 +11173,7 @@ public enum Operations { self.subscriptionid = subscriptionid } } + public var path: Operations.getSitemapEvents.Input.Path /// - Remark: Generated from `#/paths/sitemaps/events/{subscriptionid}/*/GET/query`. public struct Query: Sendable, Hashable { @@ -10676,25 +11189,26 @@ public enum Operations { self.sitemap = sitemap } } + public var query: Operations.getSitemapEvents.Input.Query /// Creates a new `Input`. /// /// - Parameters: /// - path: /// - query: - public init( - path: Operations.getSitemapEvents.Input.Path, - query: Operations.getSitemapEvents.Input.Query = .init() - ) { + public init(path: Operations.getSitemapEvents.Input.Path, + query: Operations.getSitemapEvents.Input.Query = .init()) { self.path = path self.query = query } } + @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// Creates a new `Ok`. public init() {} } + /// OK /// /// - Remark: Generated from `#/paths//sitemaps/events/{subscriptionid}/*/get(getSitemapEvents)/responses/200`. @@ -10709,6 +11223,7 @@ public enum Operations { public static var ok: Self { .ok(.init()) } + /// The associated value of the enum case if `self` is `.ok`. /// /// - Throws: An error if `self` is not `.ok`. @@ -10717,7 +11232,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -10726,10 +11241,12 @@ public enum Operations { } } } + public struct BadRequest: Sendable, Hashable { /// Creates a new `BadRequest`. public init() {} } + /// Missing sitemap parameter, or sitemap not linked successfully to the subscription. /// /// - Remark: Generated from `#/paths//sitemaps/events/{subscriptionid}/*/get(getSitemapEvents)/responses/400`. @@ -10744,6 +11261,7 @@ public enum Operations { public static var badRequest: Self { .badRequest(.init()) } + /// The associated value of the enum case if `self` is `.badRequest`. /// /// - Throws: An error if `self` is not `.badRequest`. @@ -10752,7 +11270,7 @@ public enum Operations { get throws { switch self { case let .badRequest(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "badRequest", @@ -10761,10 +11279,12 @@ public enum Operations { } } } + public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } + /// Subscription not found. /// /// - Remark: Generated from `#/paths//sitemaps/events/{subscriptionid}/*/get(getSitemapEvents)/responses/404`. @@ -10779,6 +11299,7 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } + /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -10787,7 +11308,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -10796,12 +11317,14 @@ public enum Operations { } } } + /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } } + /// Get sitemap events. /// /// - Remark: HTTP `GET /sitemaps/events/{subscriptionid}`. @@ -10823,6 +11346,7 @@ public enum Operations { self.subscriptionid = subscriptionid } } + public var path: Operations.getSitemapEvents_1.Input.Path /// - Remark: Generated from `#/paths/sitemaps/events/{subscriptionid}/GET/query`. public struct Query: Sendable, Hashable { @@ -10839,14 +11363,13 @@ public enum Operations { /// - Parameters: /// - sitemap: sitemap name /// - pageid: page id - public init( - sitemap: Swift.String? = nil, - pageid: Swift.String? = nil - ) { + public init(sitemap: Swift.String? = nil, + pageid: Swift.String? = nil) { self.sitemap = sitemap self.pageid = pageid } } + public var query: Operations.getSitemapEvents_1.Input.Query /// - Remark: Generated from `#/paths/sitemaps/events/{subscriptionid}/GET/header`. public struct Headers: Sendable, Hashable { @@ -10859,6 +11382,7 @@ public enum Operations { self.accept = accept } } + public var headers: Operations.getSitemapEvents_1.Input.Headers /// Creates a new `Input`. /// @@ -10866,16 +11390,15 @@ public enum Operations { /// - path: /// - query: /// - headers: - public init( - path: Operations.getSitemapEvents_1.Input.Path, - query: Operations.getSitemapEvents_1.Input.Query = .init(), - headers: Operations.getSitemapEvents_1.Input.Headers = .init() - ) { + public init(path: Operations.getSitemapEvents_1.Input.Path, + query: Operations.getSitemapEvents_1.Input.Query = .init(), + headers: Operations.getSitemapEvents_1.Input.Headers = .init()) { self.path = path self.query = query self.headers = headers } } + @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/sitemaps/events/{subscriptionid}/GET/responses/200/content`. @@ -10890,7 +11413,7 @@ public enum Operations { get throws { switch self { case let .text_event_hyphen_stream(body): - return body + body default: try throwUnexpectedResponseBody( expectedContent: "text/event-stream", @@ -10899,6 +11422,7 @@ public enum Operations { } } } + /// - Remark: Generated from `#/paths/sitemaps/events/{subscriptionid}/GET/responses/200/content/application\/json`. case json(Components.Schemas.SitemapWidgetEvent) /// The associated value of the enum case if `self` is `.json`. @@ -10909,7 +11433,7 @@ public enum Operations { get throws { switch self { case let .json(body): - return body + body default: try throwUnexpectedResponseBody( expectedContent: "application/json", @@ -10919,6 +11443,7 @@ public enum Operations { } } } + /// Received HTTP response body public var body: Operations.getSitemapEvents_1.Output.Ok.Body /// Creates a new `Ok`. @@ -10929,6 +11454,7 @@ public enum Operations { self.body = body } } + /// OK /// /// - Remark: Generated from `#/paths//sitemaps/events/{subscriptionid}/get(getSitemapEvents_1)/responses/200`. @@ -10943,7 +11469,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -10952,10 +11478,12 @@ public enum Operations { } } } + public struct BadRequest: Sendable, Hashable { /// Creates a new `BadRequest`. public init() {} } + /// Missing sitemap or page parameter, or page not linked successfully to the subscription. /// /// - Remark: Generated from `#/paths//sitemaps/events/{subscriptionid}/get(getSitemapEvents_1)/responses/400`. @@ -10970,6 +11498,7 @@ public enum Operations { public static var badRequest: Self { .badRequest(.init()) } + /// The associated value of the enum case if `self` is `.badRequest`. /// /// - Throws: An error if `self` is not `.badRequest`. @@ -10978,7 +11507,7 @@ public enum Operations { get throws { switch self { case let .badRequest(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "badRequest", @@ -10987,10 +11516,12 @@ public enum Operations { } } } + public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } + /// Subscription not found. /// /// - Remark: Generated from `#/paths//sitemaps/events/{subscriptionid}/get(getSitemapEvents_1)/responses/404`. @@ -11005,6 +11536,7 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } + /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -11013,7 +11545,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -11022,11 +11554,13 @@ public enum Operations { } } } + /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } + @frozen public enum AcceptableContentType: AcceptableProtocol { case text_event_hyphen_stream case json @@ -11041,16 +11575,18 @@ public enum Operations { self = .other(rawValue) } } + public var rawValue: Swift.String { switch self { case let .other(string): - return string + string case .text_event_hyphen_stream: - return "text/event-stream" + "text/event-stream" case .json: - return "application/json" + "application/json" } } + public static var allCases: [Self] { [ .text_event_hyphen_stream, @@ -11059,6 +11595,7 @@ public enum Operations { } } } + /// Get all available sitemaps. /// /// - Remark: HTTP `GET /sitemaps`. @@ -11077,6 +11614,7 @@ public enum Operations { self.accept = accept } } + public var headers: Operations.getSitemaps.Input.Headers /// Creates a new `Input`. /// @@ -11086,6 +11624,7 @@ public enum Operations { self.headers = headers } } + @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/sitemaps/GET/responses/200/content`. @@ -11100,11 +11639,12 @@ public enum Operations { get throws { switch self { case let .json(body): - return body + body } } } } + /// Received HTTP response body public var body: Operations.getSitemaps.Output.Ok.Body /// Creates a new `Ok`. @@ -11115,6 +11655,7 @@ public enum Operations { self.body = body } } + /// OK /// /// - Remark: Generated from `#/paths//sitemaps/get(getSitemaps)/responses/200`. @@ -11129,7 +11670,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -11138,11 +11679,13 @@ public enum Operations { } } } + /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } + @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -11154,14 +11697,16 @@ public enum Operations { self = .other(rawValue) } } + public var rawValue: Swift.String { switch self { case let .other(string): - return string + string case .json: - return "application/json" + "application/json" } } + public static var allCases: [Self] { [ .json @@ -11169,6 +11714,7 @@ public enum Operations { } } } + /// Get all registered UI components in the specified namespace. /// /// - Remark: HTTP `GET /ui/components/{namespace}`. @@ -11188,6 +11734,7 @@ public enum Operations { self.namespace = namespace } } + public var path: Operations.getRegisteredUIComponentsInNamespace.Input.Path /// - Remark: Generated from `#/paths/ui/components/{namespace}/GET/query`. public struct Query: Sendable, Hashable { @@ -11203,6 +11750,7 @@ public enum Operations { self.summary = summary } } + public var query: Operations.getRegisteredUIComponentsInNamespace.Input.Query /// - Remark: Generated from `#/paths/ui/components/{namespace}/GET/header`. public struct Headers: Sendable, Hashable { @@ -11215,6 +11763,7 @@ public enum Operations { self.accept = accept } } + public var headers: Operations.getRegisteredUIComponentsInNamespace.Input.Headers /// Creates a new `Input`. /// @@ -11222,16 +11771,15 @@ public enum Operations { /// - path: /// - query: /// - headers: - public init( - path: Operations.getRegisteredUIComponentsInNamespace.Input.Path, - query: Operations.getRegisteredUIComponentsInNamespace.Input.Query = .init(), - headers: Operations.getRegisteredUIComponentsInNamespace.Input.Headers = .init() - ) { + public init(path: Operations.getRegisteredUIComponentsInNamespace.Input.Path, + query: Operations.getRegisteredUIComponentsInNamespace.Input.Query = .init(), + headers: Operations.getRegisteredUIComponentsInNamespace.Input.Headers = .init()) { self.path = path self.query = query self.headers = headers } } + @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/ui/components/{namespace}/GET/responses/200/content`. @@ -11246,11 +11794,12 @@ public enum Operations { get throws { switch self { case let .json(body): - return body + body } } } } + /// Received HTTP response body public var body: Operations.getRegisteredUIComponentsInNamespace.Output.Ok.Body /// Creates a new `Ok`. @@ -11261,6 +11810,7 @@ public enum Operations { self.body = body } } + /// OK /// /// - Remark: Generated from `#/paths//ui/components/{namespace}/get(getRegisteredUIComponentsInNamespace)/responses/200`. @@ -11275,7 +11825,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -11284,11 +11834,13 @@ public enum Operations { } } } + /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } + @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -11300,14 +11852,16 @@ public enum Operations { self = .other(rawValue) } } + public var rawValue: Swift.String { switch self { case let .other(string): - return string + string case .json: - return "application/json" + "application/json" } } + public static var allCases: [Self] { [ .json @@ -11315,6 +11869,7 @@ public enum Operations { } } } + /// Add a UI component in the specified namespace. /// /// - Remark: HTTP `POST /ui/components/{namespace}`. @@ -11334,6 +11889,7 @@ public enum Operations { self.namespace = namespace } } + public var path: Operations.addUIComponentToNamespace.Input.Path /// - Remark: Generated from `#/paths/ui/components/{namespace}/POST/header`. public struct Headers: Sendable, Hashable { @@ -11346,12 +11902,14 @@ public enum Operations { self.accept = accept } } + public var headers: Operations.addUIComponentToNamespace.Input.Headers /// - Remark: Generated from `#/paths/ui/components/{namespace}/POST/requestBody`. @frozen public enum Body: Sendable, Hashable { /// - Remark: Generated from `#/paths/ui/components/{namespace}/POST/requestBody/content/application\/json`. case json(Components.Schemas.RootUIComponent) } + public var body: Operations.addUIComponentToNamespace.Input.Body? /// Creates a new `Input`. /// @@ -11359,16 +11917,15 @@ public enum Operations { /// - path: /// - headers: /// - body: - public init( - path: Operations.addUIComponentToNamespace.Input.Path, - headers: Operations.addUIComponentToNamespace.Input.Headers = .init(), - body: Operations.addUIComponentToNamespace.Input.Body? = nil - ) { + public init(path: Operations.addUIComponentToNamespace.Input.Path, + headers: Operations.addUIComponentToNamespace.Input.Headers = .init(), + body: Operations.addUIComponentToNamespace.Input.Body? = nil) { self.path = path self.headers = headers self.body = body } } + @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/ui/components/{namespace}/POST/responses/200/content`. @@ -11383,11 +11940,12 @@ public enum Operations { get throws { switch self { case let .json(body): - return body + body } } } } + /// Received HTTP response body public var body: Operations.addUIComponentToNamespace.Output.Ok.Body /// Creates a new `Ok`. @@ -11398,6 +11956,7 @@ public enum Operations { self.body = body } } + /// OK /// /// - Remark: Generated from `#/paths//ui/components/{namespace}/post(addUIComponentToNamespace)/responses/200`. @@ -11412,7 +11971,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -11421,11 +11980,13 @@ public enum Operations { } } } + /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } + @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -11437,14 +11998,16 @@ public enum Operations { self = .other(rawValue) } } + public var rawValue: Swift.String { switch self { case let .other(string): - return string + string case .json: - return "application/json" + "application/json" } } + public static var allCases: [Self] { [ .json @@ -11452,6 +12015,7 @@ public enum Operations { } } } + /// Get a specific UI component in the specified namespace. /// /// - Remark: HTTP `GET /ui/components/{namespace}/{componentUID}`. @@ -11470,14 +12034,13 @@ public enum Operations { /// - Parameters: /// - namespace: /// - componentUID: - public init( - namespace: Swift.String, - componentUID: Swift.String - ) { + public init(namespace: Swift.String, + componentUID: Swift.String) { self.namespace = namespace self.componentUID = componentUID } } + public var path: Operations.getUIComponentInNamespace.Input.Path /// - Remark: Generated from `#/paths/ui/components/{namespace}/{componentUID}/GET/header`. public struct Headers: Sendable, Hashable { @@ -11490,20 +12053,20 @@ public enum Operations { self.accept = accept } } + public var headers: Operations.getUIComponentInNamespace.Input.Headers /// Creates a new `Input`. /// /// - Parameters: /// - path: /// - headers: - public init( - path: Operations.getUIComponentInNamespace.Input.Path, - headers: Operations.getUIComponentInNamespace.Input.Headers = .init() - ) { + public init(path: Operations.getUIComponentInNamespace.Input.Path, + headers: Operations.getUIComponentInNamespace.Input.Headers = .init()) { self.path = path self.headers = headers } } + @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/ui/components/{namespace}/{componentUID}/GET/responses/200/content`. @@ -11518,11 +12081,12 @@ public enum Operations { get throws { switch self { case let .json(body): - return body + body } } } } + /// Received HTTP response body public var body: Operations.getUIComponentInNamespace.Output.Ok.Body /// Creates a new `Ok`. @@ -11533,6 +12097,7 @@ public enum Operations { self.body = body } } + /// OK /// /// - Remark: Generated from `#/paths//ui/components/{namespace}/{componentUID}/get(getUIComponentInNamespace)/responses/200`. @@ -11547,7 +12112,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -11556,10 +12121,12 @@ public enum Operations { } } } + public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } + /// Component not found /// /// - Remark: Generated from `#/paths//ui/components/{namespace}/{componentUID}/get(getUIComponentInNamespace)/responses/404`. @@ -11574,6 +12141,7 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } + /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -11582,7 +12150,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -11591,11 +12159,13 @@ public enum Operations { } } } + /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } + @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -11607,14 +12177,16 @@ public enum Operations { self = .other(rawValue) } } + public var rawValue: Swift.String { switch self { case let .other(string): - return string + string case .json: - return "application/json" + "application/json" } } + public static var allCases: [Self] { [ .json @@ -11622,6 +12194,7 @@ public enum Operations { } } } + /// Update a specific UI component in the specified namespace. /// /// - Remark: HTTP `PUT /ui/components/{namespace}/{componentUID}`. @@ -11640,14 +12213,13 @@ public enum Operations { /// - Parameters: /// - namespace: /// - componentUID: - public init( - namespace: Swift.String, - componentUID: Swift.String - ) { + public init(namespace: Swift.String, + componentUID: Swift.String) { self.namespace = namespace self.componentUID = componentUID } } + public var path: Operations.updateUIComponentInNamespace.Input.Path /// - Remark: Generated from `#/paths/ui/components/{namespace}/{componentUID}/PUT/header`. public struct Headers: Sendable, Hashable { @@ -11660,12 +12232,14 @@ public enum Operations { self.accept = accept } } + public var headers: Operations.updateUIComponentInNamespace.Input.Headers /// - Remark: Generated from `#/paths/ui/components/{namespace}/{componentUID}/PUT/requestBody`. @frozen public enum Body: Sendable, Hashable { /// - Remark: Generated from `#/paths/ui/components/{namespace}/{componentUID}/PUT/requestBody/content/application\/json`. case json(Components.Schemas.RootUIComponent) } + public var body: Operations.updateUIComponentInNamespace.Input.Body? /// Creates a new `Input`. /// @@ -11673,16 +12247,15 @@ public enum Operations { /// - path: /// - headers: /// - body: - public init( - path: Operations.updateUIComponentInNamespace.Input.Path, - headers: Operations.updateUIComponentInNamespace.Input.Headers = .init(), - body: Operations.updateUIComponentInNamespace.Input.Body? = nil - ) { + public init(path: Operations.updateUIComponentInNamespace.Input.Path, + headers: Operations.updateUIComponentInNamespace.Input.Headers = .init(), + body: Operations.updateUIComponentInNamespace.Input.Body? = nil) { self.path = path self.headers = headers self.body = body } } + @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/ui/components/{namespace}/{componentUID}/PUT/responses/200/content`. @@ -11697,11 +12270,12 @@ public enum Operations { get throws { switch self { case let .json(body): - return body + body } } } } + /// Received HTTP response body public var body: Operations.updateUIComponentInNamespace.Output.Ok.Body /// Creates a new `Ok`. @@ -11712,6 +12286,7 @@ public enum Operations { self.body = body } } + /// OK /// /// - Remark: Generated from `#/paths//ui/components/{namespace}/{componentUID}/put(updateUIComponentInNamespace)/responses/200`. @@ -11726,7 +12301,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -11735,10 +12310,12 @@ public enum Operations { } } } + public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } + /// Component not found /// /// - Remark: Generated from `#/paths//ui/components/{namespace}/{componentUID}/put(updateUIComponentInNamespace)/responses/404`. @@ -11753,6 +12330,7 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } + /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -11761,7 +12339,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -11770,11 +12348,13 @@ public enum Operations { } } } + /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } + @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -11786,14 +12366,16 @@ public enum Operations { self = .other(rawValue) } } + public var rawValue: Swift.String { switch self { case let .other(string): - return string + string case .json: - return "application/json" + "application/json" } } + public static var allCases: [Self] { [ .json @@ -11801,6 +12383,7 @@ public enum Operations { } } } + /// Remove a specific UI component in the specified namespace. /// /// - Remark: HTTP `DELETE /ui/components/{namespace}/{componentUID}`. @@ -11819,14 +12402,13 @@ public enum Operations { /// - Parameters: /// - namespace: /// - componentUID: - public init( - namespace: Swift.String, - componentUID: Swift.String - ) { + public init(namespace: Swift.String, + componentUID: Swift.String) { self.namespace = namespace self.componentUID = componentUID } } + public var path: Operations.removeUIComponentFromNamespace.Input.Path /// Creates a new `Input`. /// @@ -11836,11 +12418,13 @@ public enum Operations { self.path = path } } + @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// Creates a new `Ok`. public init() {} } + /// OK /// /// - Remark: Generated from `#/paths//ui/components/{namespace}/{componentUID}/delete(removeUIComponentFromNamespace)/responses/200`. @@ -11855,6 +12439,7 @@ public enum Operations { public static var ok: Self { .ok(.init()) } + /// The associated value of the enum case if `self` is `.ok`. /// /// - Throws: An error if `self` is not `.ok`. @@ -11863,7 +12448,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -11872,10 +12457,12 @@ public enum Operations { } } } + public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } + /// Component not found /// /// - Remark: Generated from `#/paths//ui/components/{namespace}/{componentUID}/delete(removeUIComponentFromNamespace)/responses/404`. @@ -11890,6 +12477,7 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } + /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -11898,7 +12486,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -11907,12 +12495,14 @@ public enum Operations { } } } + /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } } + /// Get all registered UI tiles. /// /// - Remark: HTTP `GET /ui/tiles`. @@ -11931,6 +12521,7 @@ public enum Operations { self.accept = accept } } + public var headers: Operations.getUITiles.Input.Headers /// Creates a new `Input`. /// @@ -11940,6 +12531,7 @@ public enum Operations { self.headers = headers } } + @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/ui/tiles/GET/responses/200/content`. @@ -11954,11 +12546,12 @@ public enum Operations { get throws { switch self { case let .json(body): - return body + body } } } } + /// Received HTTP response body public var body: Operations.getUITiles.Output.Ok.Body /// Creates a new `Ok`. @@ -11969,6 +12562,7 @@ public enum Operations { self.body = body } } + /// OK /// /// - Remark: Generated from `#/paths//ui/tiles/get(getUITiles)/responses/200`. @@ -11983,7 +12577,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - return response + response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -11992,11 +12586,13 @@ public enum Operations { } } } + /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } + @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -12008,14 +12604,16 @@ public enum Operations { self = .other(rawValue) } } + public var rawValue: Swift.String { switch self { case let .other(string): - return string + string case .json: - return "application/json" + "application/json" } } + public static var allCases: [Self] { [ .json diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift index 29609b7ec..b80c7287c 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift @@ -158,7 +158,7 @@ public extension OpenAPIService { let jsonPayload = try Operations.runRuleNow_1.Input.Body.jsonPayload( additionalProperties: OpenAPIObjectContainer(unvalidatedValue: payload)) return try await client.runRuleNow_1( - path: Operations.runRuleNow_1.Input.Path(ruleUID: ruleUID), + path: path, body: .json(jsonPayload) ) } @@ -244,7 +244,7 @@ public extension OpenAPIService { _ = try response.accepted } - func openHABSendItemCommand(itemname: String, command: String) async throws { + func sendItemCommand(itemname: String, command: String) async throws { let path = Operations.sendItemCommand.Input.Path(itemname: itemname) let body = Operations.sendItemCommand.Input.Body.plainText(.init(command)) let response = try await client.sendItemCommand(path: path, body: body) diff --git a/openHAB/OpenHABRootViewController.swift b/openHAB/OpenHABRootViewController.swift index 2de417811..b19313c77 100644 --- a/openHAB/OpenHABRootViewController.swift +++ b/openHAB/OpenHABRootViewController.swift @@ -396,16 +396,14 @@ class OpenHABRootViewController: UIViewController { NetworkTracker.shared.waitForActiveConnection { activeConnection in if let openHABUrl = activeConnection?.configuration.url, let url = URL(string: openHABUrl) { os_log("Sending comand", log: .default, type: .error) - let client = HTTPClient(username: Preferences.username, password: Preferences.password) - client.doPost(baseURL: url, path: "/rest/items/\(itemName)", body: itemCommand) { data, _, error in - if let error { - os_log("Could not send data %{public}@", log: .default, type: .error, error.localizedDescription) + Task { + do { + let openAPIService = await OpenAPIService(username: Preferences.username, password: Preferences.password) + await openAPIService.updateBaseURL(with: url) + try await openAPIService.sendItemCommand(itemname: itemName, command: itemCommand) + } catch { + logger.error("Could not send data \(error.localizedDescription)") self.displayErrorNotification("request to \(openHABUrl) \(error.localizedDescription)") - } else { - os_log("Request succeeded", log: .default, type: .info) - if let data { - os_log("Data: %{public}@", log: .default, type: .debug, String(data: data, encoding: .utf8) ?? "") - } } if let completionHandler { DispatchQueue.main.async { @@ -629,6 +627,8 @@ class OpenHABRootViewController: UIViewController { } } +// swiftlint:enable type_body_length + // MARK: - UISideMenuNavigationControllerDelegate extension OpenHABRootViewController: SideMenuNavigationControllerDelegate { diff --git a/openHAB/OpenHABSitemapViewController.swift b/openHAB/OpenHABSitemapViewController.swift index c3a2f50e0..bda986fb0 100644 --- a/openHAB/OpenHABSitemapViewController.swift +++ b/openHAB/OpenHABSitemapViewController.swift @@ -444,7 +444,7 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel do { logger.debug("Running selectSitemap for URL: \(self.appData?.openHABRootUrl ?? "")") openAPIService = await OpenAPIService( - baseURL: URL(string: appData?.openHABRootUrl ?? "")!, + baseURL: URL(string: appData!.openHABRootUrl) ?? URL(staticString: "about:blank"), username: appData!.openHABUsername, password: appData!.openHABPassword, alwaysSendBasicAuth: appData!.openHABAlwaysSendCreds @@ -590,7 +590,7 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel } func sendCommand(itemname: String, command: String) { - Task { try await openAPIService?.openHABSendItemCommand(itemname: itemname, command: command) } + Task { try await openAPIService?.sendItemCommand(itemname: itemname, command: command) } } override func reloadView() { From a6c2514577e12f51603819b4abf1e3bc36064511 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Fri, 28 Feb 2025 08:19:30 +0100 Subject: [PATCH 023/476] Migrating NetworkTracker to OpenAPIService Renamed OpenAPIService function to align with generated function names Migrating UserData in watch to OpenAPIService Removing unnnecessary functions from HTTPClient Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../Sources/OpenHABCore/Util/HTTPClient.swift | 53 ++++------- .../OpenHABCore/Util/NetworkTracker.swift | 4 - .../OpenHABCore/Util/OpenAPIService.swift | 18 ++-- openHAB/OpenHABSitemapViewController.swift | 20 +--- openHABWatch/Domain/UserData.swift | 95 ++++++++----------- 5 files changed, 64 insertions(+), 126 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift b/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift index d63ec4537..d10feae9f 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift @@ -75,25 +75,6 @@ public class HTTPClient: NSObject { initializeCertificatesStore() } - /** - Sends a POST request to a specified base URL for a specified path and returns the response data via a completion handler. - - - Parameters: - - baseURL: The base URL to attempt the request from. - - path: An optional path component to append to the base URL. - - body: The string to include as the HTTP body of the request. - - completion: A closure to be executed once the request is complete. The closure takes three parameters: - - data: The data returned by the server. This will be `nil` if the request fails. - - response: The URL response object providing response metadata, such as HTTP headers and status code. - - error: An error object that indicates why the request failed, or `nil` if the request was successful. - */ - public func doPost(baseURL: URL? = nil, path: String?, body: String, completion: @escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionTask? { - doRequest(baseURL: baseURL, path: path, method: "POST", body: body) { result, response, error in - let data = result as? Data - completion(data, response, error) - } - } - /** Initiates a download request to a specified base URL for a specified path and returns the file URL via a completion handler. @@ -118,24 +99,6 @@ public class HTTPClient: NSObject { return (fileURL1, response1) } - public func sendCommand(url: URL? = nil, itemName: String, command: String, completion: @escaping (String?, Error?) -> Void) -> URLSessionTask? { - os_log("sendCommand %{public}@ %{public}@", log: .default, type: .debug, command, itemName) - return doPost(baseURL: url, path: "/rest/items/\(itemName)", body: command) { data, _, error in - if let error { - os_log("Could not send data %{public}@", log: .default, type: .error, error.localizedDescription) - completion(nil, error) - } else { - os_log("Request succeeded", log: .default, type: .info) - var returnValue = "" - if let data { - returnValue = String(data: data, encoding: .utf8) ?? "" - os_log("Data: %{public}@", log: .default, type: .debug, returnValue) - } - completion(returnValue, nil) - } - } - } - public func loadSitemapData(url: URL? = nil, longPolling: Bool, refresh: Bool, @@ -148,6 +111,22 @@ public class HTTPClient: NSObject { os_log("Fetching page from URL %{public}@", log: .networking, type: .info, url?.absoluteString ?? "") + Task { + do { + let (data, _) = try await doRequest(baseURL: url, path: nil, method: "GET", headers: headers, timeout: timeout) + if let data = data as? Data { + logger.info("Finsihed Fetching page from URL \(url?.absoluteString ?? "")") + completion(data, nil) + } else { + logger.error("No data from URL \(url?.absoluteString ?? "")") + completion(nil, URLError(.unknown, userInfo: [NSLocalizedDescriptionKey: "No valid data received from server."])) + } + } catch { + logger.error("error fetching page from URL \(url?.absoluteString ?? "")") + completion(nil, error) + } + } + return doRequest(baseURL: url, path: nil, method: "GET", headers: headers, timeout: timeout) { result, _, error in if let error { os_log("error fetching page from URL %{public}@ %{public}@", log: .networking, type: .error, url?.absoluteString ?? "", error.localizedDescription) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift index 2c1d6c132..dc2417734 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift @@ -52,7 +52,6 @@ public final class NetworkTracker: ObservableObject { @Published public private(set) var activeConnection: ConnectionInfo? @Published public private(set) var status: NetworkStatus = .connecting - public private(set) var httpClient: HTTPClient? public private(set) var openApiService: OpenAPIService? private let monitor: NWPathMonitor @@ -69,7 +68,6 @@ public final class NetworkTracker: ObservableObject { private init() { monitor = NWPathMonitor() monitor.pathUpdateHandler = { [weak self] path in - guard self?.httpClient != nil else { return } guard self?.openApiService != nil else { return } if path.status == .satisfied { os_log("Network status: Connected", log: OSLog.default, type: .info) @@ -86,7 +84,6 @@ public final class NetworkTracker: ObservableObject { public func startTracking(connectionConfigurations: [ConnectionConfiguration], username: String, password: String, alwaysSendBasicAuth: Bool, ignoreSSLVerification: Bool) { os_log("NetworkConnection: startTracking", log: OSLog.default, type: .info) self.connectionConfigurations = adjustMyOpenHABHosts(in: connectionConfigurations) - httpClient = HTTPClient(username: username, password: password, alwaysSendBasicAuth: alwaysSendBasicAuth, ignoreSSL: ignoreSSLVerification) Task { openApiService = await OpenAPIService(username: username, password: password, alwaysSendBasicAuth: alwaysSendBasicAuth, ignoreSSL: ignoreSSLVerification) } @@ -259,7 +256,6 @@ public final class NetworkTracker: ObservableObject { activeConnection = connection if let activeConnection { updateStatus(.connected) - httpClient?.baseURL = URL(string: activeConnection.configuration.url) Task { await openApiService?.updateBaseURL(with: URL(string: activeConnection.configuration.url) ?? URL(staticString: "about:blank")) } diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift index b80c7287c..7f5297b43 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift @@ -183,11 +183,11 @@ public extension OpenAPIService { } } -extension OpenAPIService { +public extension OpenAPIService { // Internal function for pollPage - func openHABpollPage(path: Operations.pollDataForPage.Input.Path, - query: Operations.pollDataForPage.Input.Query = .init(), - headers: Operations.pollDataForPage.Input.Headers) async throws -> OpenHABPage? { + internal func pollDataForPage(path: Operations.pollDataForPage.Input.Path, + query: Operations.pollDataForPage.Input.Query = .init(), + headers: Operations.pollDataForPage.Input.Headers) async throws -> OpenHABPage? { let result = try await client.pollDataForPage(path: path, query: query, headers: headers) .ok.body.json return OpenHABPage(result) @@ -197,7 +197,7 @@ extension OpenAPIService { /// - Parameters: /// - sitemapname: name of sitemap /// - longPolling: set to true for long-polling - public func openHABpollPage(sitemapname: String, longPolling: Bool) async throws -> OpenHABPage? { + func pollDataForPage(sitemapname: String, longPolling: Bool) async throws -> OpenHABPage? { var headers = Operations.pollDataForPage.Input.Headers() if longPolling { logger.info("Long-polling, setting X-Atmosphere-Transport") @@ -207,11 +207,11 @@ extension OpenAPIService { } let path = Operations.pollDataForPage.Input.Path(sitemapname: sitemapname, pageid: sitemapname) await updateForLongPolling(longPolling) - return try await openHABpollPage(path: path, headers: headers) + return try await pollDataForPage(path: path, headers: headers) } // Internal function for pollSitemap - func openHABpollSitemap(path: Operations.pollDataForSitemap.Input.Path, + internal func pollDataForSitemap(path: Operations.pollDataForSitemap.Input.Path, query: Operations.pollDataForSitemap.Input.Query = .init(), headers: Operations.pollDataForSitemap.Input.Headers) async throws -> OpenHABSitemap? { let result = try await client.pollDataForSitemap(path: path, query: query, headers: headers) @@ -219,7 +219,7 @@ extension OpenAPIService { return OpenHABSitemap(result) } - public func openHABpollSitemap(sitemapname: String, longPolling: Bool, subscriptionId: String? = nil) async throws -> OpenHABSitemap? { + func pollDataForSitemap(sitemapname: String, longPolling: Bool, subscriptionId: String? = nil) async throws -> OpenHABSitemap? { var headers = Operations.pollDataForSitemap.Input.Headers() if longPolling { logger.info("Long-polling, setting X-Atmosphere-Transport") @@ -230,7 +230,7 @@ extension OpenAPIService { let query = Operations.pollDataForSitemap.Input.Query(subscriptionid: subscriptionId) let path = Operations.pollDataForSitemap.Input.Path(sitemapname: sitemapname) await updateForLongPolling(longPolling) - return try await openHABpollSitemap(path: path, query: query, headers: headers) + return try await pollDataForSitemap(path: path, query: query, headers: headers) } } diff --git a/openHAB/OpenHABSitemapViewController.swift b/openHAB/OpenHABSitemapViewController.swift index bda986fb0..daa9d0f3c 100644 --- a/openHAB/OpenHABSitemapViewController.swift +++ b/openHAB/OpenHABSitemapViewController.swift @@ -240,10 +240,6 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel override func viewWillDisappear(_ animated: Bool) { os_log("OpenHABSitemapViewController viewWillDisappear", log: .viewCycle, type: .info) - if currentPageOperation != nil { - currentPageOperation?.cancel() - currentPageOperation = nil - } trackerCancellables.removeAll() @@ -271,10 +267,6 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel override func didEnterBackground(_ notification: Notification?) { super.didEnterBackground(notification) os_log("OpenHABSitemapViewController didEnterBackground", log: .viewCycle, type: .info) - if currentPageOperation != nil { - currentPageOperation?.cancel() - currentPageOperation = nil - } } @objc @@ -354,16 +346,6 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel // load our page and show it into UITableView func loadPage(_ longPolling: Bool) { - if currentPageOperation != nil { - currentPageOperation?.cancel() - currentPageOperation = nil - } - - // if asyncOperation != nil { - // asyncOperation?.cancel() - // asyncOperation = nil - // } - if pageUrl == "" { return } @@ -389,7 +371,7 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel // } // } - currentPage = try await openAPIService?.openHABpollPage(sitemapname: defaultSitemap, longPolling: longPolling) + currentPage = try await openAPIService?.pollDataForPage(sitemapname: defaultSitemap, longPolling: longPolling) if isFiltering { filterContentForSearchText(search.searchBar.text) diff --git a/openHABWatch/Domain/UserData.swift b/openHABWatch/Domain/UserData.swift index a5c19b6c5..f64609449 100644 --- a/openHABWatch/Domain/UserData.swift +++ b/openHABWatch/Domain/UserData.swift @@ -28,6 +28,7 @@ final class UserData: ObservableObject { private var commandOperation: URLSessionTask? private var currentPageOperation: URLSessionTask? + private var activePageTask: Task? private var cancellables = Set() private let logger = Logger(subsystem: "org.openhab.app.watchkitapp", category: "UserData") @@ -119,8 +120,8 @@ final class UserData: ObservableObject { ObservableOpenHABDataObject.shared.openHABRootUrl = activeConnection.configuration.url ObservableOpenHABDataObject.shared.openHABVersion = activeConnection.version - let url = Endpoint.watchSitemap(openHABRootUrl: activeConnection.configuration.url, sitemapName: ObservableOpenHABDataObject.shared.sitemapForWatch).url - self?.loadPage(url: url, longPolling: false, refresh: true) + // TODE Update RootURL + self?.loadPage(sitemapName: ObservableOpenHABDataObject.shared.sitemapForWatch, longPolling: false, refresh: true) } } .store(in: &cancellables) @@ -147,60 +148,39 @@ final class UserData: ObservableObject { } } - func loadPage(url: URL? = nil, longPolling: Bool, refresh: Bool) { - logger.info("Loading page \(url?.absoluteString ?? "") longPolling \(longPolling) refresh \(refresh)") + func loadPage(sitemapName: String, longPolling: Bool, refresh: Bool) { + logger.info("Loading page \(sitemapName) longPolling \(longPolling) refresh \(refresh)") - // Cancel any running operation - if let currentPageOperation, currentPageOperation.state == .running { - currentPageOperation.cancel() - } + // Cancel the active task if it is running + activePageTask?.cancel() - currentPageOperation = NetworkTracker.shared.httpClient?.loadSitemapData(url: url, longPolling: longPolling, refresh: refresh) { [weak self] data, error in + activePageTask = Task { [weak self] in guard let self else { return } - currentPageOperation = nil - - if let error = error as? URLError, error.code == .cancelled { - logger.info("Task was canceled") - return - } - - var errorString: String? - - if error != nil || data == nil { - errorString = error?.localizedDescription ?? "No data received" - } - - if errorString == nil { - do { - let sitemapPageCodingData = try data!.decoded(as: OpenHABPage.CodingData.self) - openHABSitemapPage = sitemapPageCodingData.openHABSitemapPage - } catch { - logger.error("Decoding error: \(error.localizedDescription)") - errorString = error.localizedDescription + do { + guard let openAPIService = NetworkTracker.shared.openApiService else { return } + openHABSitemapPage = try await openAPIService.pollDataForPage(sitemapname: sitemapName, longPolling: longPolling) + // Configures then sendCommand closure (existing logic) + openHABSitemapPage?.sendCommand = { [weak self] item, command in + self?.sendCommand(item, command: command) } - } - - if let errorString { - DispatchQueue.main.async { - self.logger.error("On LoadPage \"\(errorString)\"") - self.errorDescription = errorString - self.widgets = [] - self.showAlert = true + // Always update UI on the main thread + await MainActor.run { + self.widgets = self.openHABSitemapPage?.widgets ?? [] + self.showAlert = self.widgets.isEmpty + if refresh { + self.loadPage(sitemapName: sitemapName, longPolling: true, refresh: true) + } } - return - } - - // Configures then sendCommand closure (existing logic) - openHABSitemapPage?.sendCommand = { [weak self] item, command in - self?.sendCommand(item, command: command) - } - - // Always update UI on the main thread - DispatchQueue.main.async { - self.widgets = self.openHABSitemapPage?.widgets ?? [] - self.showAlert = self.widgets.isEmpty - if refresh { - self.loadPage(url: url, longPolling: true, refresh: true) + } catch { + if Task.isCancelled { + logger.info("Task was canceled") + } else { + logger.error("Polling failed with error \(error)") + await MainActor.run { + self.logger.error("On LoadPage \"\(error.localizedDescription)\"") + self.widgets = [] + self.showAlert = true + } } } } @@ -211,11 +191,12 @@ final class UserData: ObservableObject { commandOperation.cancel() } if let item, let command { - commandOperation = NetworkTracker.shared.httpClient?.sendCommand(itemName: item.name, command: command) { _, error in - if error != nil { - self.logger.error("Error sending command \(command) to \(item.name): \(error!.localizedDescription)") + Task { + do { + try await NetworkTracker.shared.openApiService?.sendItemCommand(itemname: item.name, command: command) + } catch { + logger.error("Error sending command \(command) to \(item.name): \(error.localizedDescription)") } - self.commandOperation = nil } } } @@ -223,8 +204,8 @@ final class UserData: ObservableObject { func refreshUrl() { if ObservableOpenHABDataObject.shared.haveReceivedAppContext, !ObservableOpenHABDataObject.shared.openHABRootUrl.isEmpty { showAlert = false - let url = Endpoint.watchSitemap(openHABRootUrl: ObservableOpenHABDataObject.shared.openHABRootUrl, sitemapName: ObservableOpenHABDataObject.shared.sitemapForWatch).url - loadPage(url: url, longPolling: false, refresh: true) + // TODO: Update + loadPage(sitemapName: ObservableOpenHABDataObject.shared.sitemapForWatch, longPolling: false, refresh: true) } } } From fba7d803e8ebc198c5280144e1f02d59009ead6e Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Fri, 28 Feb 2025 14:52:50 +0100 Subject: [PATCH 024/476] Eliminate #available(iOS < 16) Eliminate unused parts in NetworkConnection - sitemaps, uiTiles, tracker, page Eliminate usage of NetworkConnection - step 1 in OpenHABItemCache Eliminate unused parts in Endpoint / watchSitemap, tracker, sitemaps, uiTiles Eliminate unused parts in HTTPClient - loadSitemap, doRequest, performRequest Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../Util/ClientCertificateManager.swift | 24 +--- .../Sources/OpenHABCore/Util/Endpoint.swift | 32 ----- .../Sources/OpenHABCore/Util/HTTPClient.swift | 122 ++---------------- .../OpenHABCore/Util/NetworkConnection.swift | 59 --------- .../OpenHABCore/Util/NetworkTracker.swift | 2 +- .../OpenHABCore/Util/OpenAPIService.swift | 16 ++- .../OpenHABCore/Util/OpenHABItemCache.swift | 77 +++++------ .../Util/ServerCertificateManager.swift | 18 +-- .../OpenHABCore/Util/UIColorExtension.swift | 7 +- .../Tests/OpenHABCoreTests/RESTAPITests.swift | 30 ----- openHAB/AppDelegate.swift | 4 +- openHAB/OpenHABWebViewController.swift | 5 +- 12 files changed, 74 insertions(+), 322 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/ClientCertificateManager.swift b/OpenHABCore/Sources/OpenHABCore/Util/ClientCertificateManager.swift index 33ff8d49e..40ae0f932 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/ClientCertificateManager.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/ClientCertificateManager.swift @@ -283,13 +283,9 @@ public class ClientCertificateManager { guard status == errSecSuccess else { return nil } let trust = optionalTrust! var trustResult = SecTrustResultType.proceed - if #available(iOS 12.0, *) { - var trustError: CFError? - if SecTrustEvaluateWithError(trust, &trustError) != true { - SecTrustGetTrustResult(trust, &trustResult) - } - } else { - SecTrustEvaluate(trust, &trustResult) + var trustError: CFError? + if SecTrustEvaluateWithError(trust, &trustError) != true { + SecTrustGetTrustResult(trust, &trustResult) } let chainSize = SecTrustGetCertificateCount(trust) @@ -301,16 +297,10 @@ public class ClientCertificateManager { os_log("Setting anchor for trust evaluation to %s", log: .default, type: .info, SecCertificateCopySubjectSummary(rootCA)! as String) SecTrustSetAnchorCertificates(trust, anchors as CFArray) trustResult = SecTrustResultType.proceed - if #available(iOS 12.0, *) { - var trustError: CFError? - if SecTrustEvaluateWithError(trust, &trustError) != true { - os_log("Trust evaluation failed building client certificate chain after anchor has been set: %s", log: .default, type: .info, trustError.debugDescription) - SecTrustGetTrustResult(trust, &trustResult) - } - } else { - if SecTrustEvaluate(trust, &trustResult) != errSecSuccess { - os_log("Trust evaluation failed building client certificate chain after anchor has been set: SecTrustResultType=%u", log: .default, type: .info, trustResult.rawValue) - } + var trustError: CFError? + if SecTrustEvaluateWithError(trust, &trustError) != true { + os_log("Trust evaluation failed building client certificate chain after anchor has been set: %s", log: .default, type: .info, trustError.debugDescription) + SecTrustGetTrustResult(trust, &trustResult) } } if trustResult != .proceed { diff --git a/OpenHABCore/Sources/OpenHABCore/Util/Endpoint.swift b/OpenHABCore/Sources/OpenHABCore/Util/Endpoint.swift index 58d7d2f31..fb686e89b 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/Endpoint.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/Endpoint.swift @@ -62,14 +62,6 @@ public extension Endpoint { return components?.url } - static func watchSitemap(openHABRootUrl: String, sitemapName: String) -> Endpoint { - Endpoint( - baseURL: openHABRootUrl, - path: "/rest/sitemaps/\(sitemapName)/\(sitemapName)", - queryItems: [URLQueryItem(name: "jsoncallback", value: "callback")] - ) - } - static func appleRegistration(prefsURL: String, deviceToken: String, deviceId: String, @@ -93,22 +85,6 @@ public extension Endpoint { ) } - static func tracker(openHABRootUrl: String) -> Endpoint { - Endpoint( - baseURL: openHABRootUrl, - path: "/rest/", - queryItems: [] - ) - } - - static func sitemaps(openHABRootUrl: String) -> Endpoint { - Endpoint( - baseURL: openHABRootUrl, - path: "/rest/sitemaps", - queryItems: [URLQueryItem(name: "limit", value: "20")] - ) - } - static func items(openHABRootUrl: String) -> Endpoint { Endpoint( baseURL: openHABRootUrl, @@ -117,14 +93,6 @@ public extension Endpoint { ) } - static func uiTiles(openHABRootUrl: String) -> Endpoint { - Endpoint( - baseURL: openHABRootUrl, - path: "/rest/ui/tiles", - queryItems: [] - ) - } - static func resource(openHABRootUrl: String, path: String) -> Endpoint { Endpoint( baseURL: openHABRootUrl, diff --git a/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift b/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift index d10feae9f..6a4e7e3fc 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift @@ -99,88 +99,6 @@ public class HTTPClient: NSObject { return (fileURL1, response1) } - public func loadSitemapData(url: URL? = nil, - longPolling: Bool, - refresh: Bool, - completion: @escaping (Data?, Error?) -> Void) -> URLSessionTask? { - let timeout: TimeInterval = longPolling ? 35.0 : 10.0 // for long polling, the server will return in 30 seconds - var headers: [String: String] = [:] - if longPolling { - headers["X-Atmosphere-Transport"] = "0" - } - - os_log("Fetching page from URL %{public}@", log: .networking, type: .info, url?.absoluteString ?? "") - - Task { - do { - let (data, _) = try await doRequest(baseURL: url, path: nil, method: "GET", headers: headers, timeout: timeout) - if let data = data as? Data { - logger.info("Finsihed Fetching page from URL \(url?.absoluteString ?? "")") - completion(data, nil) - } else { - logger.error("No data from URL \(url?.absoluteString ?? "")") - completion(nil, URLError(.unknown, userInfo: [NSLocalizedDescriptionKey: "No valid data received from server."])) - } - } catch { - logger.error("error fetching page from URL \(url?.absoluteString ?? "")") - completion(nil, error) - } - } - - return doRequest(baseURL: url, path: nil, method: "GET", headers: headers, timeout: timeout) { result, _, error in - if let error { - os_log("error fetching page from URL %{public}@ %{public}@", log: .networking, type: .error, url?.absoluteString ?? "", error.localizedDescription) - completion(nil, error) - } else if let data = result as? Data { - os_log("Finsihed Fetching page from URL %{public}@", log: .networking, type: .info, url?.absoluteString ?? "") - completion(data, nil) - } else { - os_log("No data from URL %{public}@", log: .networking, type: .error, url?.absoluteString ?? "") - completion(nil, URLError(.unknown, userInfo: [NSLocalizedDescriptionKey: "No valid data received from server."])) - } - } - } - - public func doRequest(baseURL: URL?, path: String?, method: String, headers: [String: String]? = nil, - timeout: TimeInterval = 60.0, body: String? = nil, download: Bool = false, completion: @escaping (Any?, URLResponse?, Error?) -> Void) -> URLSessionTask? { - guard var url = baseURL ?? self.baseURL else { - os_log("doRequest ERROR: Base URL is nil", log: .networking, type: .info) - completion(nil, nil, HTTPClientError.baseURLIsNil) - return nil - } - - if let path { - url.appendPathComponent(path) - } - - var request = URLRequest(url: url) - request.httpMethod = method - request.timeoutInterval = timeout - if let headers { - for (key, value) in headers { - request.setValue(value, forHTTPHeaderField: key) - } - } - if let body { - request.httpBody = body.data(using: .utf8) - request.setValue("text/plain", forHTTPHeaderField: "Content-Type") - } - return performRequest(request: request, download: download) { result, response, error in - if let error { - os_log("Error with URL %{public}@ : %{public}@", log: .networking, type: .error, url.absoluteString, error.localizedDescription) - completion(nil, response, error) - } else if let response = response as? HTTPURLResponse { - if (400 ... 599).contains(response.statusCode) { - os_log("HTTP error from URL %{public}@ : %{public}d", log: .networking, type: .error, url.absoluteString, response.statusCode) - completion(nil, response, HTTPClientError.httpError(response.statusCode)) - } else { - os_log("Response from URL %{public}@ : %{public}d", log: .networking, type: .info, url.absoluteString, response.statusCode) - completion(result, response, nil) - } - } - } - } - public func doRequest(baseURL: URL?, path: String?, method: String, @@ -223,25 +141,6 @@ public class HTTPClient: NSObject { fatalError() } - private func performRequest(request: URLRequest, download: Bool, completion: @escaping (Any?, URLResponse?, Error?) -> Void) -> URLSessionTask? { - var request = request - if alwaysSendBasicAuth { - request.setValue(basicAuthHeader(), forHTTPHeaderField: "Authorization") - } - - let task: URLSessionTask = if download { - session.downloadTask(with: request) { url, response, error in - completion(url, response, error) - } - } else { - session.dataTask(with: request) { data, response, error in - completion(data, response, error) - } - } - task.resume() - return task - } - private func performRequest(request: URLRequest, download: Bool) async throws -> (Any?, URLResponse?) { var request = request if alwaysSendBasicAuth { @@ -276,7 +175,7 @@ public class HTTPClient: NSObject { } } - private func getPersistensePath() -> URL { + private func getPersistencePath() -> URL { #if os(watchOS) let documentsDirectory = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true)[0] return URL(fileURLWithPath: documentsDirectory).appendingPathComponent("trustedCertificates") @@ -288,7 +187,7 @@ public class HTTPClient: NSObject { private func saveTrustedCertificates() { do { let data = try PropertyListEncoder().encode(trustedCertificates) - try data.write(to: getPersistensePath()) + try data.write(to: getPersistencePath()) } catch { os_log("Could not save trusted certificates", log: .default) } @@ -297,14 +196,14 @@ public class HTTPClient: NSObject { private func loadTrustedCertificates() { var decodableTrustedCertificates: [String: Data] = [:] do { - let rawdata = try Data(contentsOf: getPersistensePath()) + let rawdata = try Data(contentsOf: getPersistencePath()) let decoder = PropertyListDecoder() decodableTrustedCertificates = try decoder.decode([String: Data].self, from: rawdata) trustedCertificates = decodableTrustedCertificates } catch { // if Decodable fails, fall back to NSKeyedArchiver do { - let rawdata = try Data(contentsOf: getPersistensePath()) + let rawdata = try Data(contentsOf: getPersistencePath()) if let unarchivedTrustedCertificates = try? NSKeyedUnarchiver.unarchivedObject(ofClasses: [NSDictionary.self, NSString.self, NSData.self], from: rawdata) as? [String: Data] { trustedCertificates = unarchivedTrustedCertificates saveTrustedCertificates() // Ensure that data is written in new format @@ -392,15 +291,10 @@ extension HTTPClient: URLSessionDelegate, URLSessionTaskDelegate { } var result: SecTrustResultType = .invalid - if #available(iOS 12.0, *) { - var error: CFError? - _ = SecTrustEvaluateWithError(serverTrust, &error) - SecTrustGetTrustResult(serverTrust, &result) - logger.info("Trust evaluation result: \(result.rawValue), error: \(String(describing: error))") - } else { - SecTrustEvaluate(serverTrust, &result) - logger.info("Trust evaluation result: \(result.rawValue)") - } + var error: CFError? + _ = SecTrustEvaluateWithError(serverTrust, &error) + SecTrustGetTrustResult(serverTrust, &result) + logger.info("Trust evaluation result: \(result.rawValue), error: \(String(describing: error))") if result.isAny(of: .unspecified, .proceed) || ignoreSSL { logger.info("Certificate is trusted or SSL verification ignored") diff --git a/OpenHABCore/Sources/OpenHABCore/Util/NetworkConnection.swift b/OpenHABCore/Sources/OpenHABCore/Util/NetworkConnection.swift index 7f91145a9..272b01fea 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/NetworkConnection.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/NetworkConnection.swift @@ -100,27 +100,6 @@ public class NetworkConnection { } } - public static func sitemaps(openHABRootUrl: String, - completionHandler: @escaping (DataResponse) -> Void) { - if let url = Endpoint.sitemaps(openHABRootUrl: openHABRootUrl).url { - load(from: url, completionHandler: completionHandler) - } - } - - public static func uiTiles(openHABRootUrl: String, - completionHandler: @escaping (DataResponse) -> Void) { - if let url = Endpoint.uiTiles(openHABRootUrl: openHABRootUrl).url { - load(from: url, completionHandler: completionHandler) - } - } - - public static func tracker(openHABRootUrl: String, - completionHandler: @escaping (DataResponse) -> Void) { - if let url = Endpoint.tracker(openHABRootUrl: openHABRootUrl).url { - load(from: url, completionHandler: completionHandler) - } - } - public static func notification(urlString: String, completionHandler: @escaping (DataResponse) -> Void) { if let notificationsUrl = Endpoint.notification(prefsURL: urlString).url { @@ -173,44 +152,6 @@ public class NetworkConnection { return nil } - public static func page(url: URL?, - longPolling: Bool, - completionHandler: @escaping (DataResponse) -> Void) -> DataRequest? { - guard let url else { return nil } - - var pageRequest = URLRequest(url: url) - - pageRequest.setValue("1.0", forHTTPHeaderField: "X-Atmosphere-Framework") - if longPolling { - os_log("long polling, so setting atmosphere transport", log: OSLog.remoteAccess, type: .info) - pageRequest.setValue("long-polling", forHTTPHeaderField: "X-Atmosphere-Transport") - pageRequest.timeoutInterval = 300.0 - } else { - atmosphereTrackingId = "0" - pageRequest.timeoutInterval = 10.0 - } - pageRequest.setValue(atmosphereTrackingId, forHTTPHeaderField: "X-Atmosphere-tracking-id") - - os_log("OpenHABViewController sending new request", log: .remoteAccess, type: .error) - - return NetworkConnection.shared.manager.request(pageRequest) - .validate() - .responseData(completionHandler: completionHandler) - } - - public static func page(pageUrl: String, - longPolling: Bool, - completionHandler: @escaping (DataResponse) -> Void) -> DataRequest? { - if pageUrl == "" { - return nil - } - os_log("pageUrl = %{PUBLIC}@", log: OSLog.remoteAccess, type: .info, pageUrl) - - guard let pageToLoadUrl = URL(string: pageUrl) else { return nil } - - return page(url: pageToLoadUrl, longPolling: longPolling, completionHandler: completionHandler) - } - static func load(from url: URL, timeout: Double? = nil, completionHandler: @escaping (DataResponse) -> Void) { var request = URLRequest(url: url) request.timeoutInterval = timeout ?? 10.0 diff --git a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift index dc2417734..a231066fd 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift @@ -124,7 +124,7 @@ public final class NetworkTracker: ObservableObject { Task { do { - let serverProperties = try await openApiService?.getRoot() + try await openApiService?.getRoot() logger.info("Network status: Active connection is reachable: \(activeConnection.configuration.url)") } catch { logger.error("Network status: Active connection is not reachable: \(activeConnection.configuration.url) \(error.localizedDescription)") diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift index 7f5297b43..7101e83f0 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift @@ -136,6 +136,7 @@ extension OpenAPIService: OpenHABUiTileService { } public extension OpenAPIService { + @discardableResult func getRoot() async throws -> OpenHABServerProperties { let result = try await client.getRoot() .ok.body.json @@ -212,8 +213,8 @@ public extension OpenAPIService { // Internal function for pollSitemap internal func pollDataForSitemap(path: Operations.pollDataForSitemap.Input.Path, - query: Operations.pollDataForSitemap.Input.Query = .init(), - headers: Operations.pollDataForSitemap.Input.Headers) async throws -> OpenHABSitemap? { + query: Operations.pollDataForSitemap.Input.Query = .init(), + headers: Operations.pollDataForSitemap.Input.Headers) async throws -> OpenHABSitemap? { let result = try await client.pollDataForSitemap(path: path, query: query, headers: headers) .ok.body.json return OpenHABSitemap(result) @@ -234,10 +235,19 @@ public extension OpenAPIService { } } +// Array of items +public extension OpenAPIService { + func getItems() async throws -> [OpenHABItem] { + try await client.getItems() + .ok.body.json + .compactMap(OpenHABItem.init) + } +} + // MARK: State changes and commands public extension OpenAPIService { - func openHABUpdateItemState(itemname: String, with state: String) async throws { + func updateItemState(itemname: String, with state: String) async throws { let path = Operations.updateItemState.Input.Path(itemname: itemname) let body = Operations.updateItemState.Input.Body.plainText(.init(state)) let response = try await client.updateItemState(path: path, body: body) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift index 6cc686340..b6322c5c8 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift @@ -43,12 +43,17 @@ public class OpenHABItemCache { return } - ret.append(contentsOf: items.filter { (searchTerm == nil || $0.name.contains(searchTerm.orEmpty)) && (types == nil || ($0.type != nil && types!.contains($0.type!))) }.sorted(by: \.name).map { NSString(string: $0.name) }) + ret.append(contentsOf: items + .filter { + (searchTerm == nil || $0.name.contains(searchTerm.orEmpty)) && + (types == nil || ($0.type != nil && types!.contains($0.type!))) + } + .sorted(by: \.name) + .map { NSString(string: $0.name) }) completion(ret) } - @available(iOS 12.0, *) public func getItem(name: String, completion: @escaping (OpenHABItem?) -> Void) { let now = Date().timeIntervalSince1970 @@ -73,24 +78,26 @@ public class OpenHABItemCache { commandOperation?.resume() } - @available(iOS 12.0, *) public func reload(searchTerm: String?, types: [OpenHABItem.ItemType]?, completion: @escaping ([NSString]) -> Void) { NetworkTracker.shared.waitForActiveConnection { activeConnection in - if let urlString = activeConnection?.configuration.url, let url = Endpoint.items(openHABRootUrl: urlString).url { - os_log("OpenHABItemCache Loading items from %{PUBLIC}@", log: .default, type: .info, urlString) + if (activeConnection?.configuration.url) != nil { + os_log("OpenHABItemCache Loading items ") self.lastLoad = Date().timeIntervalSince1970 - NetworkConnection.load(from: url, timeout: self.timeout) { response in - switch response.result { - case let .success(data): - do { - try self.decodeItemsData(data) - let ret = self.items?.filter { (searchTerm == nil || $0.name.contains(searchTerm.orEmpty)) && (types == nil || ($0.type != nil && types!.contains($0.type!))) }.sorted(by: \.name).map { NSString(string: $0.name) } ?? [] - completion(ret) - } catch { - print(error) - os_log("OpenHABItemCache %{PUBLIC}@ ", log: .default, type: .error, error.localizedDescription) - } - case let .failure(error): + Task { + do { + self.items = try await NetworkTracker.shared.openApiService?.getItems() + os_log("Loaded items to cache: %{PUBLIC}d", log: .default, type: .info, self.items?.count ?? 0) + + let ret = self.items? + .filter { + $0.type != .group && + (searchTerm == nil || $0.name.contains(searchTerm.orEmpty)) && + (types == nil || ($0.type != nil && types!.contains($0.type!))) + } + .sorted(by: \.name) + .map { NSString(string: $0.name) } ?? [] + completion(ret) + } catch { os_log("OpenHABItemCache %{PUBLIC}@ ", log: .default, type: .error, error.localizedDescription) } } @@ -99,24 +106,19 @@ public class OpenHABItemCache { .store(in: &cancellables) } - @available(iOS 12.0, *) public func reload(name: String, completion: @escaping (OpenHABItem?) -> Void) { NetworkTracker.shared.waitForActiveConnection { activeConnection in - if let urlString = activeConnection?.configuration.url, let url = Endpoint.items(openHABRootUrl: urlString).url { - os_log("OpenHABItemCache Loading items from %{PUBLIC}@", log: .default, type: .info, urlString) - self.lastLoad = Date().timeIntervalSince1970 - NetworkConnection.load(from: url, timeout: self.timeout) { response in - switch response.result { - case let .success(data): - do { - try self.decodeItemsData(data) - let item = self.getItem(name) - completion(item) - } catch { - os_log("OpenHABItemCache %{PUBLIC}@ ", log: .default, type: .error, error.localizedDescription) + if (activeConnection?.configuration.url) != nil { + Task { + do { + self.items = try await NetworkTracker.shared.openApiService?.getItems() + os_log("Loaded items to cache: %{PUBLIC}d", log: .default, type: .info, self.items?.count ?? 0) + let ret = self.items?.filter { + $0.type != .group } - case let .failure(error): - print(error) + .first { $0.name == name } + completion(ret) + } catch { os_log("OpenHABItemCache %{PUBLIC}@ ", log: .default, type: .error, error.localizedDescription) } } @@ -124,15 +126,4 @@ public class OpenHABItemCache { } .store(in: &cancellables) } - - private func decodeItemsData(_ data: Data) throws { - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .formatted(DateFormatter.iso8601Full) - let codingDatas = try data.decoded(as: [OpenHABItem.CodingData].self, using: decoder) - items = [OpenHABItem]() - for codingDatum in codingDatas where codingDatum.openHABItem.type != OpenHABItem.ItemType.group { - self.items?.append(codingDatum.openHABItem) - } - os_log("Loaded items to cache: %{PUBLIC}d", log: .default, type: .info, items?.count ?? 0) - } } diff --git a/OpenHABCore/Sources/OpenHABCore/Util/ServerCertificateManager.swift b/OpenHABCore/Sources/OpenHABCore/Util/ServerCertificateManager.swift index d2dc56fd8..227d3828e 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/ServerCertificateManager.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/ServerCertificateManager.swift @@ -131,19 +131,13 @@ public class ServerCertificateManager: ServerTrustManager, ServerTrustEvaluating func wrapperSecTrustEvaluate(serverTrust: SecTrust) -> SecTrustResultType { var result: SecTrustResultType = .invalid - if #available(iOS 12.0, *) { - // SecTrustEvaluate is deprecated. - // Wrap new API to have same calling pattern as we had prior to deprecation. + // SecTrustEvaluate is deprecated. + // Wrap new API to have same calling pattern as we had prior to deprecation. - var error: CFError? - _ = SecTrustEvaluateWithError(serverTrust, &error) - SecTrustGetTrustResult(serverTrust, &result) - return result - - } else { - SecTrustEvaluate(serverTrust, &result) - return result - } + var error: CFError? + _ = SecTrustEvaluateWithError(serverTrust, &error) + SecTrustGetTrustResult(serverTrust, &result) + return result } public func evaluate(_ serverTrust: SecTrust, forHost domain: String) throws { diff --git a/OpenHABCore/Sources/OpenHABCore/Util/UIColorExtension.swift b/OpenHABCore/Sources/OpenHABCore/Util/UIColorExtension.swift index ba8db0653..b991a93b5 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/UIColorExtension.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/UIColorExtension.swift @@ -16,13 +16,10 @@ public enum OHInterfaceStyle: Int { public static var current: OHInterfaceStyle { #if os(iOS) - if #available(iOS 13.0, *) { - if UITraitCollection.current.userInterfaceStyle == .dark { - return .dark - } + if UITraitCollection.current.userInterfaceStyle == .dark { + return .dark } #endif - return .light } } diff --git a/OpenHABCore/Tests/OpenHABCoreTests/RESTAPITests.swift b/OpenHABCore/Tests/OpenHABCoreTests/RESTAPITests.swift index e04473e93..4ee5275e0 100644 --- a/OpenHABCore/Tests/OpenHABCoreTests/RESTAPITests.swift +++ b/OpenHABCore/Tests/OpenHABCoreTests/RESTAPITests.swift @@ -79,34 +79,4 @@ final class RESTAPITests: XCTestCase { // then wait(for: [expectation], timeout: 3) } - - func testSitemap() { - // given - MockURLProtocol.responseWithStatusCode(code: 200) - - let expectation = XCTestExpectation(description: "Register App") - - // when - NetworkConnection.sitemaps(openHABRootUrl: "") { response in - XCTAssertEqual(response.response?.statusCode, 200) - expectation.fulfill() - } - // then - wait(for: [expectation], timeout: 3) - } - - func testTracker() { - // given - MockURLProtocol.responseWithStatusCode(code: 200) - - let expectation = XCTestExpectation(description: "Register App") - - // when - NetworkConnection.tracker(openHABRootUrl: "") { response in - XCTAssertEqual(response.response?.statusCode, 200) - expectation.fulfill() - } - // then - wait(for: [expectation], timeout: 3) - } } diff --git a/openHAB/AppDelegate.swift b/openHAB/AppDelegate.swift index a88e497b1..0f46a2704 100644 --- a/openHAB/AppDelegate.swift +++ b/openHAB/AppDelegate.swift @@ -69,9 +69,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD let audioSession = AVAudioSession.sharedInstance() do { - if #available(iOS 10.0, *) { - try audioSession.setCategory(.playback, mode: .default, options: []) - } + try audioSession.setCategory(.playback, mode: .default, options: []) } catch { os_log("Setting category to AVAudioSessionCategoryPlayback failed.", log: .default, type: .info) } diff --git a/openHAB/OpenHABWebViewController.swift b/openHAB/OpenHABWebViewController.swift index 15377286b..d4ec2c0de 100644 --- a/openHAB/OpenHABWebViewController.swift +++ b/openHAB/OpenHABWebViewController.swift @@ -60,9 +60,8 @@ class OpenHABWebViewController: OpenHABViewController { activityIndicator = UIActivityIndicatorView() activityIndicator.center = view.center activityIndicator.hidesWhenStopped = true - if #available(iOS 13.0, *) { - activityIndicator.style = UIActivityIndicatorView.Style.large - } + activityIndicator.style = UIActivityIndicatorView.Style.large + view.addSubview(activityIndicator) } From 0fb24e4b04a82b374c1e7fda44a4e4798fe88c07 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Fri, 28 Feb 2025 19:08:15 +0100 Subject: [PATCH 025/476] Reworked .swiftformat to properly handle ignore Rerun of openapi generator to get Client.swift and Types.swift ignored by swiftformat Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- BuildTools/.swiftformat | 84 +- .../GeneratedSources/openapi/Client.swift | 290 +- .../GeneratedSources/openapi/Types.swift | 3100 +++++++---------- 3 files changed, 1412 insertions(+), 2062 deletions(-) diff --git a/BuildTools/.swiftformat b/BuildTools/.swiftformat index 39e42a458..5b397b017 100644 --- a/BuildTools/.swiftformat +++ b/BuildTools/.swiftformat @@ -1,51 +1,47 @@ # file options ---exclude ../fastlane ---exclude ../OpenHABCore/.build ---exclude ./build ---exclude ../OpenHABCore/swift-openapi-generator ---symlinks ignore +ignore: ../fastlane, ../OpenHABCore/.build, ./build, ../OpenHABCore/swift-openapi-generator +symlinks: ignore # disabled rules ---disable specifiers # see https://github.com/nicklockwood/SwiftFormat/issues/364 ---disable redundantParens ---disable wrapMultilineStatementBraces +disable: specifiers, redundantParens, wrapMultilineStatementBraces # see https://github.com/nicklockwood/SwiftFormat/issues/364 # opt-in rules ---enable isEmpty ---enable typeSugar +enable: isEmpty, typeSugar # format options ---allman false ---binarygrouping none ---closingparen balanced ---commas inline ---conflictmarkers reject ---decimalgrouping 3,6 ---elseposition same-line ---empty void ---exponentcase lowercase ---exponentgrouping disabled ---fractiongrouping disabled ---fragment false ---header ignore ---hexgrouping none ---ifdef no-indent ---importgrouping alphabetized ---indent 4 ---indentcase false ---linebreaks lf ---octalgrouping none ---operatorfunc space ---patternlet hoist ---ranges spaced ---self remove ---selfrequired ---semicolons inline ---stripunusedargs closure-only ---trailingclosures ---trimwhitespace always ---wraparguments before-first ---wrapcollections before-first ---wrapparameters after-first ---xcodeindentation disabled ---header "// Copyright (c) 2010-{year} Contributors to the openHAB project\n//\n// See the NOTICE file(s) distributed with this work for additional\n// information.\n//\n// This program and the accompanying materials are made available under the\n// terms of the Eclipse Public License 2.0 which is available at\n// http://www.eclipse.org/legal/epl-2.0\n//\n// SPDX-License-Identifier: EPL-2.0" +allman: false +binarygrouping: none +closingparen: balanced +commas: inline +conflictmarkers: reject +decimalgrouping: 3,6 +elseposition: same-line +empty: void +exponentcase: lowercase +exponentgrouping: disabled +fractiongrouping: disabled +fragment: false +header: ignore +hexgrouping: none +ifdef: no-indent +importgrouping: alphabetized +indent: 4 +indentcase: false +linebreaks: lf +octalgrouping: none +operatorfunc: space +patternlet: hoist +ranges: spaced +self: remove +selfrequired: true +semicolons: inline +stripunusedargs: closure-only +trailingclosures: true +trimwhitespace: always +wraparguments: before-first +wrapcollections: before-first +wrapparameters: after-first +xcodeindentation: disabled + +# Copyright header +header: "// Copyright (c) 2010-{year} Contributors to the openHAB project\n//\n// See the NOTICE file(s) distributed with this work for additional\n// information.\n//\n// This program and the accompanying materials are made available under the\n// terms of the Eclipse Public License 2.0 which is available at\n// http://www.eclipse.org/legal/epl-2.0\n//\n// SPDX-License-Identifier: EPL-2.0" diff --git a/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Client.swift b/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Client.swift index 6304f9ac3..c2a733968 100644 --- a/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Client.swift +++ b/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Client.swift @@ -1,27 +1,15 @@ -// Copyright (c) 2010-2025 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 - // Generated by swift-openapi-generator, do not modify. @_spi(Generated) import OpenAPIRuntime #if os(Linux) +@preconcurrency import struct Foundation.URL @preconcurrency import struct Foundation.Data @preconcurrency import struct Foundation.Date -@preconcurrency import struct Foundation.URL #else +import struct Foundation.URL import struct Foundation.Data import struct Foundation.Date -import struct Foundation.URL #endif import HTTPTypes - public struct Client: APIProtocol { /// The underlying HTTP client. private let client: UniversalClient @@ -33,22 +21,22 @@ public struct Client: APIProtocol { /// - configuration: A set of configuration values for the client. /// - transport: A transport that performs HTTP operations. /// - middlewares: A list of middlewares to call before the transport. - public init(serverURL: Foundation.URL, - configuration: Configuration = .init(), - transport: any ClientTransport, - middlewares: [any ClientMiddleware] = []) { - client = .init( + public init( + serverURL: Foundation.URL, + configuration: Configuration = .init(), + transport: any ClientTransport, + middlewares: [any ClientMiddleware] = [] + ) { + self.client = .init( serverURL: serverURL, configuration: configuration, transport: transport, middlewares: middlewares ) } - private var converter: Converter { client.converter } - /// Get available rules, optionally filtered by tags and/or prefix. /// /// - Remark: HTTP `GET /rules`. @@ -137,7 +125,6 @@ public struct Client: APIProtocol { } ) } - /// Creates a rule. /// /// - Remark: HTTP `POST /rules`. @@ -156,9 +143,10 @@ public struct Client: APIProtocol { method: .post ) suppressMutabilityWarning(&request) - let body: OpenAPIRuntime.HTTPBody? = switch input.body { + let body: OpenAPIRuntime.HTTPBody? + switch input.body { case let .json(value): - try converter.setRequiredRequestBodyAsJSON( + body = try converter.setRequiredRequestBodyAsJSON( value, headerFields: &request.headerFields, contentType: "application/json; charset=utf-8" @@ -169,7 +157,7 @@ public struct Client: APIProtocol { deserializer: { response, responseBody in switch response.status.code { case 201: - let headers: Operations.createRule.Output.Created.Headers = try .init(Location: converter.getOptionalHeaderFieldAsURI( + let headers: Operations.createRule.Output.Created.Headers = .init(Location: try converter.getOptionalHeaderFieldAsURI( in: response.headerFields, name: "Location", as: Swift.String.self @@ -191,7 +179,6 @@ public struct Client: APIProtocol { } ) } - /// Sets the rule enabled status. /// /// - Remark: HTTP `POST /rules/{ruleUID}/enable`. @@ -212,9 +199,10 @@ public struct Client: APIProtocol { method: .post ) suppressMutabilityWarning(&request) - let body: OpenAPIRuntime.HTTPBody? = switch input.body { + let body: OpenAPIRuntime.HTTPBody? + switch input.body { case let .plainText(value): - try converter.setRequiredRequestBodyAsBinary( + body = try converter.setRequiredRequestBodyAsBinary( value, headerFields: &request.headerFields, contentType: "text/plain" @@ -225,11 +213,11 @@ public struct Client: APIProtocol { deserializer: { response, responseBody in switch response.status.code { case 200: - .ok(.init()) + return .ok(.init()) case 404: - .notFound(.init()) + return .notFound(.init()) default: - .undocumented( + return .undocumented( statusCode: response.status.code, .init( headerFields: response.headerFields, @@ -240,7 +228,6 @@ public struct Client: APIProtocol { } ) } - /// Gets the rule actions. /// /// - Remark: HTTP `GET /rules/{ruleUID}/actions`. @@ -305,7 +292,6 @@ public struct Client: APIProtocol { } ) } - /// Gets the rule corresponding to the given UID. /// /// - Remark: HTTP `GET /rules/{ruleUID}`. @@ -370,7 +356,6 @@ public struct Client: APIProtocol { } ) } - /// Updates an existing rule corresponding to the given UID. /// /// - Remark: HTTP `PUT /rules/{ruleUID}`. @@ -391,9 +376,10 @@ public struct Client: APIProtocol { method: .put ) suppressMutabilityWarning(&request) - let body: OpenAPIRuntime.HTTPBody? = switch input.body { + let body: OpenAPIRuntime.HTTPBody? + switch input.body { case let .json(value): - try converter.setRequiredRequestBodyAsJSON( + body = try converter.setRequiredRequestBodyAsJSON( value, headerFields: &request.headerFields, contentType: "application/json; charset=utf-8" @@ -404,11 +390,11 @@ public struct Client: APIProtocol { deserializer: { response, responseBody in switch response.status.code { case 200: - .ok(.init()) + return .ok(.init()) case 404: - .notFound(.init()) + return .notFound(.init()) default: - .undocumented( + return .undocumented( statusCode: response.status.code, .init( headerFields: response.headerFields, @@ -419,7 +405,6 @@ public struct Client: APIProtocol { } ) } - /// Removes an existing rule corresponding to the given UID. /// /// - Remark: HTTP `DELETE /rules/{ruleUID}`. @@ -445,11 +430,11 @@ public struct Client: APIProtocol { deserializer: { response, responseBody in switch response.status.code { case 200: - .ok(.init()) + return .ok(.init()) case 404: - .notFound(.init()) + return .notFound(.init()) default: - .undocumented( + return .undocumented( statusCode: response.status.code, .init( headerFields: response.headerFields, @@ -460,7 +445,6 @@ public struct Client: APIProtocol { } ) } - /// Gets the rule conditions. /// /// - Remark: HTTP `GET /rules/{ruleUID}/conditions`. @@ -525,7 +509,6 @@ public struct Client: APIProtocol { } ) } - /// Gets the rule configuration values. /// /// - Remark: HTTP `GET /rules/{ruleUID}/config`. @@ -590,7 +573,6 @@ public struct Client: APIProtocol { } ) } - /// Sets the rule configuration values. /// /// - Remark: HTTP `PUT /rules/{ruleUID}/config`. @@ -611,11 +593,12 @@ public struct Client: APIProtocol { method: .put ) suppressMutabilityWarning(&request) - let body: OpenAPIRuntime.HTTPBody? = switch input.body { + let body: OpenAPIRuntime.HTTPBody? + switch input.body { case .none: - nil + body = nil case let .json(value): - try converter.setOptionalRequestBodyAsJSON( + body = try converter.setOptionalRequestBodyAsJSON( value, headerFields: &request.headerFields, contentType: "application/json; charset=utf-8" @@ -626,11 +609,11 @@ public struct Client: APIProtocol { deserializer: { response, responseBody in switch response.status.code { case 200: - .ok(.init()) + return .ok(.init()) case 404: - .notFound(.init()) + return .notFound(.init()) default: - .undocumented( + return .undocumented( statusCode: response.status.code, .init( headerFields: response.headerFields, @@ -641,7 +624,6 @@ public struct Client: APIProtocol { } ) } - /// Gets the rule's module corresponding to the given Category and ID. /// /// - Remark: HTTP `GET /rules/{ruleUID}/{moduleCategory}/{id}`. @@ -708,7 +690,6 @@ public struct Client: APIProtocol { } ) } - /// Gets the module's configuration. /// /// - Remark: HTTP `GET /rules/{ruleUID}/{moduleCategory}/{id}/config`. @@ -775,7 +756,6 @@ public struct Client: APIProtocol { } ) } - /// Gets the module's configuration parameter. /// /// - Remark: HTTP `GET /rules/{ruleUID}/{moduleCategory}/{id}/config/{param}`. @@ -843,7 +823,6 @@ public struct Client: APIProtocol { } ) } - /// Sets the module's configuration parameter value. /// /// - Remark: HTTP `PUT /rules/{ruleUID}/{moduleCategory}/{id}/config/{param}`. @@ -867,9 +846,10 @@ public struct Client: APIProtocol { method: .put ) suppressMutabilityWarning(&request) - let body: OpenAPIRuntime.HTTPBody? = switch input.body { + let body: OpenAPIRuntime.HTTPBody? + switch input.body { case let .plainText(value): - try converter.setRequiredRequestBodyAsBinary( + body = try converter.setRequiredRequestBodyAsBinary( value, headerFields: &request.headerFields, contentType: "text/plain" @@ -880,11 +860,11 @@ public struct Client: APIProtocol { deserializer: { response, responseBody in switch response.status.code { case 200: - .ok(.init()) + return .ok(.init()) case 404: - .notFound(.init()) + return .notFound(.init()) default: - .undocumented( + return .undocumented( statusCode: response.status.code, .init( headerFields: response.headerFields, @@ -895,7 +875,6 @@ public struct Client: APIProtocol { } ) } - /// Gets the rule triggers. /// /// - Remark: HTTP `GET /rules/{ruleUID}/triggers`. @@ -960,7 +939,6 @@ public struct Client: APIProtocol { } ) } - /// Executes actions of the rule. /// /// - Remark: HTTP `POST /rules/{ruleUID}/runnow`. @@ -981,11 +959,12 @@ public struct Client: APIProtocol { method: .post ) suppressMutabilityWarning(&request) - let body: OpenAPIRuntime.HTTPBody? = switch input.body { + let body: OpenAPIRuntime.HTTPBody? + switch input.body { case .none: - nil + body = nil case let .json(value): - try converter.setOptionalRequestBodyAsJSON( + body = try converter.setOptionalRequestBodyAsJSON( value, headerFields: &request.headerFields, contentType: "application/json; charset=utf-8" @@ -996,11 +975,11 @@ public struct Client: APIProtocol { deserializer: { response, responseBody in switch response.status.code { case 200: - .ok(.init()) + return .ok(.init()) case 404: - .notFound(.init()) + return .notFound(.init()) default: - .undocumented( + return .undocumented( statusCode: response.status.code, .init( headerFields: response.headerFields, @@ -1011,7 +990,6 @@ public struct Client: APIProtocol { } ) } - /// Simulates the executions of rules filtered by tag 'Schedule' within the given times. /// /// - Remark: HTTP `GET /rules/schedule/simulations`. @@ -1088,7 +1066,6 @@ public struct Client: APIProtocol { } ) } - /// Adds a new member to a group item. /// /// - Remark: HTTP `PUT /items/{itemName}/members/{memberItemName}`. @@ -1115,13 +1092,13 @@ public struct Client: APIProtocol { deserializer: { response, responseBody in switch response.status.code { case 200: - .ok(.init()) + return .ok(.init()) case 404: - .notFound(.init()) + return .notFound(.init()) case 405: - .methodNotAllowed(.init()) + return .methodNotAllowed(.init()) default: - .undocumented( + return .undocumented( statusCode: response.status.code, .init( headerFields: response.headerFields, @@ -1132,7 +1109,6 @@ public struct Client: APIProtocol { } ) } - /// Removes an existing member from a group item. /// /// - Remark: HTTP `DELETE /items/{itemName}/members/{memberItemName}`. @@ -1159,13 +1135,13 @@ public struct Client: APIProtocol { deserializer: { response, responseBody in switch response.status.code { case 200: - .ok(.init()) + return .ok(.init()) case 404: - .notFound(.init()) + return .notFound(.init()) case 405: - .methodNotAllowed(.init()) + return .methodNotAllowed(.init()) default: - .undocumented( + return .undocumented( statusCode: response.status.code, .init( headerFields: response.headerFields, @@ -1176,7 +1152,6 @@ public struct Client: APIProtocol { } ) } - /// Adds metadata to an item. /// /// - Remark: HTTP `PUT /items/{itemname}/metadata/{namespace}`. @@ -1198,9 +1173,10 @@ public struct Client: APIProtocol { method: .put ) suppressMutabilityWarning(&request) - let body: OpenAPIRuntime.HTTPBody? = switch input.body { + let body: OpenAPIRuntime.HTTPBody? + switch input.body { case let .json(value): - try converter.setRequiredRequestBodyAsJSON( + body = try converter.setRequiredRequestBodyAsJSON( value, headerFields: &request.headerFields, contentType: "application/json; charset=utf-8" @@ -1211,17 +1187,17 @@ public struct Client: APIProtocol { deserializer: { response, responseBody in switch response.status.code { case 200: - .ok(.init()) + return .ok(.init()) case 201: - .created(.init()) + return .created(.init()) case 400: - .badRequest(.init()) + return .badRequest(.init()) case 404: - .notFound(.init()) + return .notFound(.init()) case 405: - .methodNotAllowed(.init()) + return .methodNotAllowed(.init()) default: - .undocumented( + return .undocumented( statusCode: response.status.code, .init( headerFields: response.headerFields, @@ -1232,7 +1208,6 @@ public struct Client: APIProtocol { } ) } - /// Removes metadata from an item. /// /// - Remark: HTTP `DELETE /items/{itemname}/metadata/{namespace}`. @@ -1259,13 +1234,13 @@ public struct Client: APIProtocol { deserializer: { response, responseBody in switch response.status.code { case 200: - .ok(.init()) + return .ok(.init()) case 404: - .notFound(.init()) + return .notFound(.init()) case 405: - .methodNotAllowed(.init()) + return .methodNotAllowed(.init()) default: - .undocumented( + return .undocumented( statusCode: response.status.code, .init( headerFields: response.headerFields, @@ -1276,7 +1251,6 @@ public struct Client: APIProtocol { } ) } - /// Adds a tag to an item. /// /// - Remark: HTTP `PUT /items/{itemname}/tags/{tag}`. @@ -1303,13 +1277,13 @@ public struct Client: APIProtocol { deserializer: { response, responseBody in switch response.status.code { case 200: - .ok(.init()) + return .ok(.init()) case 404: - .notFound(.init()) + return .notFound(.init()) case 405: - .methodNotAllowed(.init()) + return .methodNotAllowed(.init()) default: - .undocumented( + return .undocumented( statusCode: response.status.code, .init( headerFields: response.headerFields, @@ -1320,7 +1294,6 @@ public struct Client: APIProtocol { } ) } - /// Removes a tag from an item. /// /// - Remark: HTTP `DELETE /items/{itemname}/tags/{tag}`. @@ -1347,13 +1320,13 @@ public struct Client: APIProtocol { deserializer: { response, responseBody in switch response.status.code { case 200: - .ok(.init()) + return .ok(.init()) case 404: - .notFound(.init()) + return .notFound(.init()) case 405: - .methodNotAllowed(.init()) + return .methodNotAllowed(.init()) default: - .undocumented( + return .undocumented( statusCode: response.status.code, .init( headerFields: response.headerFields, @@ -1364,7 +1337,6 @@ public struct Client: APIProtocol { } ) } - /// Gets a single item. /// /// - Remark: HTTP `GET /items/{itemname}`. @@ -1448,7 +1420,6 @@ public struct Client: APIProtocol { } ) } - /// Sends a command to an item. /// /// - Remark: HTTP `POST /items/{itemname}`. @@ -1469,9 +1440,10 @@ public struct Client: APIProtocol { method: .post ) suppressMutabilityWarning(&request) - let body: OpenAPIRuntime.HTTPBody? = switch input.body { + let body: OpenAPIRuntime.HTTPBody? + switch input.body { case let .plainText(value): - try converter.setRequiredRequestBodyAsBinary( + body = try converter.setRequiredRequestBodyAsBinary( value, headerFields: &request.headerFields, contentType: "text/plain" @@ -1482,13 +1454,13 @@ public struct Client: APIProtocol { deserializer: { response, responseBody in switch response.status.code { case 200: - .ok(.init()) + return .ok(.init()) case 400: - .badRequest(.init()) + return .badRequest(.init()) case 404: - .notFound(.init()) + return .notFound(.init()) default: - .undocumented( + return .undocumented( statusCode: response.status.code, .init( headerFields: response.headerFields, @@ -1499,7 +1471,6 @@ public struct Client: APIProtocol { } ) } - /// Adds a new item to the registry or updates the existing item. /// /// - Remark: HTTP `PUT /items/{itemname}`. @@ -1529,9 +1500,10 @@ public struct Client: APIProtocol { in: &request.headerFields, contentTypes: input.headers.accept ) - let body: OpenAPIRuntime.HTTPBody? = switch input.body { + let body: OpenAPIRuntime.HTTPBody? + switch input.body { case let .json(value): - try converter.setRequiredRequestBodyAsJSON( + body = try converter.setRequiredRequestBodyAsJSON( value, headerFields: &request.headerFields, contentType: "application/json; charset=utf-8" @@ -1583,7 +1555,6 @@ public struct Client: APIProtocol { } ) } - /// Removes an item from the registry. /// /// - Remark: HTTP `DELETE /items/{itemname}`. @@ -1609,11 +1580,11 @@ public struct Client: APIProtocol { deserializer: { response, responseBody in switch response.status.code { case 200: - .ok(.init()) + return .ok(.init()) case 404: - .notFound(.init()) + return .notFound(.init()) default: - .undocumented( + return .undocumented( statusCode: response.status.code, .init( headerFields: response.headerFields, @@ -1624,7 +1595,6 @@ public struct Client: APIProtocol { } ) } - /// Get all available items. /// /// - Remark: HTTP `GET /items`. @@ -1732,7 +1702,6 @@ public struct Client: APIProtocol { } ) } - /// Adds a list of items to the registry or updates the existing items. /// /// - Remark: HTTP `PUT /items`. @@ -1755,9 +1724,10 @@ public struct Client: APIProtocol { in: &request.headerFields, contentTypes: input.headers.accept ) - let body: OpenAPIRuntime.HTTPBody? = switch input.body { + let body: OpenAPIRuntime.HTTPBody? + switch input.body { case let .json(value): - try converter.setRequiredRequestBodyAsJSON( + body = try converter.setRequiredRequestBodyAsJSON( value, headerFields: &request.headerFields, contentType: "application/json; charset=utf-8" @@ -1803,7 +1773,6 @@ public struct Client: APIProtocol { } ) } - /// Gets the state of an item. /// /// - Remark: HTTP `GET /items/{itemname}/state`. @@ -1868,7 +1837,6 @@ public struct Client: APIProtocol { } ) } - /// Updates the state of an item. /// /// - Remark: HTTP `PUT /items/{itemname}/state`. @@ -1894,9 +1862,10 @@ public struct Client: APIProtocol { name: "Accept-Language", value: input.headers.Accept_hyphen_Language ) - let body: OpenAPIRuntime.HTTPBody? = switch input.body { + let body: OpenAPIRuntime.HTTPBody? + switch input.body { case let .plainText(value): - try converter.setRequiredRequestBodyAsBinary( + body = try converter.setRequiredRequestBodyAsBinary( value, headerFields: &request.headerFields, contentType: "text/plain" @@ -1907,13 +1876,13 @@ public struct Client: APIProtocol { deserializer: { response, responseBody in switch response.status.code { case 202: - .accepted(.init()) + return .accepted(.init()) case 400: - .badRequest(.init()) + return .badRequest(.init()) case 404: - .notFound(.init()) + return .notFound(.init()) default: - .undocumented( + return .undocumented( statusCode: response.status.code, .init( headerFields: response.headerFields, @@ -1924,7 +1893,6 @@ public struct Client: APIProtocol { } ) } - /// Gets the namespace of an item. /// /// - Remark: HTTP `GET /items/{itemname}/metadata/namespaces`. @@ -1994,7 +1962,6 @@ public struct Client: APIProtocol { } ) } - /// Gets the item which defines the requested semantics of an item. /// /// - Remark: HTTP `GET /items/{itemName}/semantic/{semanticClass}`. @@ -2026,11 +1993,11 @@ public struct Client: APIProtocol { deserializer: { response, responseBody in switch response.status.code { case 200: - .ok(.init()) + return .ok(.init()) case 404: - .notFound(.init()) + return .notFound(.init()) default: - .undocumented( + return .undocumented( statusCode: response.status.code, .init( headerFields: response.headerFields, @@ -2041,7 +2008,6 @@ public struct Client: APIProtocol { } ) } - /// Remove unused/orphaned metadata. /// /// - Remark: HTTP `POST /items/metadata/purge`. @@ -2050,7 +2016,7 @@ public struct Client: APIProtocol { try await client.send( input: input, forOperation: Operations.purgeDatabase.id, - serializer: { _ in + serializer: { input in let path = try converter.renderedPath( template: "/items/metadata/purge", parameters: [] @@ -2065,9 +2031,9 @@ public struct Client: APIProtocol { deserializer: { response, responseBody in switch response.status.code { case 200: - .ok(.init()) + return .ok(.init()) default: - .undocumented( + return .undocumented( statusCode: response.status.code, .init( headerFields: response.headerFields, @@ -2078,7 +2044,6 @@ public struct Client: APIProtocol { } ) } - /// Gets information about the runtime, the API version and links to resources. /// /// - Remark: HTTP `GET //`. @@ -2139,7 +2104,6 @@ public struct Client: APIProtocol { } ) } - /// Gets information about the system. /// /// - Remark: HTTP `GET /systeminfo`. @@ -2200,7 +2164,6 @@ public struct Client: APIProtocol { } ) } - /// Get all supported dimensions and their system units. /// /// - Remark: HTTP `GET /systeminfo/uom`. @@ -2261,7 +2224,6 @@ public struct Client: APIProtocol { } ) } - /// Creates a sitemap event subscription. /// /// - Remark: HTTP `POST /sitemaps/events/subscribe`. @@ -2326,7 +2288,6 @@ public struct Client: APIProtocol { } ) } - /// Polls the data for one page of a sitemap. /// /// - Remark: HTTP `GET /sitemaps/{sitemapname}/{pageid}`. @@ -2418,7 +2379,6 @@ public struct Client: APIProtocol { } ) } - /// Polls the data for a whole sitemap. Not recommended due to potentially high traffic. /// /// - Remark: HTTP `GET /sitemaps/{sitemapname}/*`. @@ -2509,7 +2469,6 @@ public struct Client: APIProtocol { } ) } - /// Get sitemap by name. /// /// - Remark: HTTP `GET /sitemaps/{sitemapname}`. @@ -2598,7 +2557,6 @@ public struct Client: APIProtocol { } ) } - /// Get sitemap events for a whole sitemap. Not recommended due to potentially high traffic. /// /// - Remark: HTTP `GET /sitemaps/events/{subscriptionid}/*`. @@ -2631,13 +2589,13 @@ public struct Client: APIProtocol { deserializer: { response, responseBody in switch response.status.code { case 200: - .ok(.init()) + return .ok(.init()) case 400: - .badRequest(.init()) + return .badRequest(.init()) case 404: - .notFound(.init()) + return .notFound(.init()) default: - .undocumented( + return .undocumented( statusCode: response.status.code, .init( headerFields: response.headerFields, @@ -2648,7 +2606,6 @@ public struct Client: APIProtocol { } ) } - /// Get sitemap events. /// /// - Remark: HTTP `GET /sitemaps/events/{subscriptionid}`. @@ -2738,7 +2695,6 @@ public struct Client: APIProtocol { } ) } - /// Get all available sitemaps. /// /// - Remark: HTTP `GET /sitemaps`. @@ -2799,7 +2755,6 @@ public struct Client: APIProtocol { } ) } - /// Get all registered UI components in the specified namespace. /// /// - Remark: HTTP `GET /ui/components/{namespace}`. @@ -2869,7 +2824,6 @@ public struct Client: APIProtocol { } ) } - /// Add a UI component in the specified namespace. /// /// - Remark: HTTP `POST /ui/components/{namespace}`. @@ -2894,11 +2848,12 @@ public struct Client: APIProtocol { in: &request.headerFields, contentTypes: input.headers.accept ) - let body: OpenAPIRuntime.HTTPBody? = switch input.body { + let body: OpenAPIRuntime.HTTPBody? + switch input.body { case .none: - nil + body = nil case let .json(value): - try converter.setOptionalRequestBodyAsJSON( + body = try converter.setOptionalRequestBodyAsJSON( value, headerFields: &request.headerFields, contentType: "application/json; charset=utf-8" @@ -2942,7 +2897,6 @@ public struct Client: APIProtocol { } ) } - /// Get a specific UI component in the specified namespace. /// /// - Remark: HTTP `GET /ui/components/{namespace}/{componentUID}`. @@ -3008,7 +2962,6 @@ public struct Client: APIProtocol { } ) } - /// Update a specific UI component in the specified namespace. /// /// - Remark: HTTP `PUT /ui/components/{namespace}/{componentUID}`. @@ -3034,11 +2987,12 @@ public struct Client: APIProtocol { in: &request.headerFields, contentTypes: input.headers.accept ) - let body: OpenAPIRuntime.HTTPBody? = switch input.body { + let body: OpenAPIRuntime.HTTPBody? + switch input.body { case .none: - nil + body = nil case let .json(value): - try converter.setOptionalRequestBodyAsJSON( + body = try converter.setOptionalRequestBodyAsJSON( value, headerFields: &request.headerFields, contentType: "application/json; charset=utf-8" @@ -3084,7 +3038,6 @@ public struct Client: APIProtocol { } ) } - /// Remove a specific UI component in the specified namespace. /// /// - Remark: HTTP `DELETE /ui/components/{namespace}/{componentUID}`. @@ -3111,11 +3064,11 @@ public struct Client: APIProtocol { deserializer: { response, responseBody in switch response.status.code { case 200: - .ok(.init()) + return .ok(.init()) case 404: - .notFound(.init()) + return .notFound(.init()) default: - .undocumented( + return .undocumented( statusCode: response.status.code, .init( headerFields: response.headerFields, @@ -3126,7 +3079,6 @@ public struct Client: APIProtocol { } ) } - /// Get all registered UI tiles. /// /// - Remark: HTTP `GET /ui/tiles`. diff --git a/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Types.swift b/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Types.swift index 6f74c95fc..f3915e63c 100644 --- a/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Types.swift +++ b/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Types.swift @@ -1,24 +1,13 @@ -// Copyright (c) 2010-2025 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 - // Generated by swift-openapi-generator, do not modify. @_spi(Generated) import OpenAPIRuntime #if os(Linux) +@preconcurrency import struct Foundation.URL @preconcurrency import struct Foundation.Data @preconcurrency import struct Foundation.Date -@preconcurrency import struct Foundation.URL #else +import struct Foundation.URL import struct Foundation.Data import struct Foundation.Date -import struct Foundation.URL #endif /// A type that performs HTTP operations defined by the OpenAPI document. public protocol APIProtocol: Sendable { @@ -275,560 +264,579 @@ public protocol APIProtocol: Sendable { } /// Convenience overloads for operation inputs. -public extension APIProtocol { +extension APIProtocol { /// Get available rules, optionally filtered by tags and/or prefix. /// /// - Remark: HTTP `GET /rules`. /// - Remark: Generated from `#/paths//rules/get(getRules)`. - func getRules(query: Operations.getRules.Input.Query = .init(), - headers: Operations.getRules.Input.Headers = .init()) async throws -> Operations.getRules.Output { + public func getRules( + query: Operations.getRules.Input.Query = .init(), + headers: Operations.getRules.Input.Headers = .init() + ) async throws -> Operations.getRules.Output { try await getRules(Operations.getRules.Input( query: query, headers: headers )) } - /// Creates a rule. /// /// - Remark: HTTP `POST /rules`. /// - Remark: Generated from `#/paths//rules/post(createRule)`. - func createRule(body: Operations.createRule.Input.Body) async throws -> Operations.createRule.Output { + public func createRule(body: Operations.createRule.Input.Body) async throws -> Operations.createRule.Output { try await createRule(Operations.createRule.Input(body: body)) } - /// Sets the rule enabled status. /// /// - Remark: HTTP `POST /rules/{ruleUID}/enable`. /// - Remark: Generated from `#/paths//rules/{ruleUID}/enable/post(enableRule)`. - func enableRule(path: Operations.enableRule.Input.Path, - body: Operations.enableRule.Input.Body) async throws -> Operations.enableRule.Output { + public func enableRule( + path: Operations.enableRule.Input.Path, + body: Operations.enableRule.Input.Body + ) async throws -> Operations.enableRule.Output { try await enableRule(Operations.enableRule.Input( path: path, body: body )) } - /// Gets the rule actions. /// /// - Remark: HTTP `GET /rules/{ruleUID}/actions`. /// - Remark: Generated from `#/paths//rules/{ruleUID}/actions/get(getRuleActions)`. - func getRuleActions(path: Operations.getRuleActions.Input.Path, - headers: Operations.getRuleActions.Input.Headers = .init()) async throws -> Operations.getRuleActions.Output { + public func getRuleActions( + path: Operations.getRuleActions.Input.Path, + headers: Operations.getRuleActions.Input.Headers = .init() + ) async throws -> Operations.getRuleActions.Output { try await getRuleActions(Operations.getRuleActions.Input( path: path, headers: headers )) } - /// Gets the rule corresponding to the given UID. /// /// - Remark: HTTP `GET /rules/{ruleUID}`. /// - Remark: Generated from `#/paths//rules/{ruleUID}/get(getRuleById)`. - func getRuleById(path: Operations.getRuleById.Input.Path, - headers: Operations.getRuleById.Input.Headers = .init()) async throws -> Operations.getRuleById.Output { + public func getRuleById( + path: Operations.getRuleById.Input.Path, + headers: Operations.getRuleById.Input.Headers = .init() + ) async throws -> Operations.getRuleById.Output { try await getRuleById(Operations.getRuleById.Input( path: path, headers: headers )) } - /// Updates an existing rule corresponding to the given UID. /// /// - Remark: HTTP `PUT /rules/{ruleUID}`. /// - Remark: Generated from `#/paths//rules/{ruleUID}/put(updateRule)`. - func updateRule(path: Operations.updateRule.Input.Path, - body: Operations.updateRule.Input.Body) async throws -> Operations.updateRule.Output { + public func updateRule( + path: Operations.updateRule.Input.Path, + body: Operations.updateRule.Input.Body + ) async throws -> Operations.updateRule.Output { try await updateRule(Operations.updateRule.Input( path: path, body: body )) } - /// Removes an existing rule corresponding to the given UID. /// /// - Remark: HTTP `DELETE /rules/{ruleUID}`. /// - Remark: Generated from `#/paths//rules/{ruleUID}/delete(deleteRule)`. - func deleteRule(path: Operations.deleteRule.Input.Path) async throws -> Operations.deleteRule.Output { + public func deleteRule(path: Operations.deleteRule.Input.Path) async throws -> Operations.deleteRule.Output { try await deleteRule(Operations.deleteRule.Input(path: path)) } - /// Gets the rule conditions. /// /// - Remark: HTTP `GET /rules/{ruleUID}/conditions`. /// - Remark: Generated from `#/paths//rules/{ruleUID}/conditions/get(getRuleConditions)`. - func getRuleConditions(path: Operations.getRuleConditions.Input.Path, - headers: Operations.getRuleConditions.Input.Headers = .init()) async throws -> Operations.getRuleConditions.Output { + public func getRuleConditions( + path: Operations.getRuleConditions.Input.Path, + headers: Operations.getRuleConditions.Input.Headers = .init() + ) async throws -> Operations.getRuleConditions.Output { try await getRuleConditions(Operations.getRuleConditions.Input( path: path, headers: headers )) } - /// Gets the rule configuration values. /// /// - Remark: HTTP `GET /rules/{ruleUID}/config`. /// - Remark: Generated from `#/paths//rules/{ruleUID}/config/get(getRuleConfiguration)`. - func getRuleConfiguration(path: Operations.getRuleConfiguration.Input.Path, - headers: Operations.getRuleConfiguration.Input.Headers = .init()) async throws -> Operations.getRuleConfiguration.Output { + public func getRuleConfiguration( + path: Operations.getRuleConfiguration.Input.Path, + headers: Operations.getRuleConfiguration.Input.Headers = .init() + ) async throws -> Operations.getRuleConfiguration.Output { try await getRuleConfiguration(Operations.getRuleConfiguration.Input( path: path, headers: headers )) } - /// Sets the rule configuration values. /// /// - Remark: HTTP `PUT /rules/{ruleUID}/config`. /// - Remark: Generated from `#/paths//rules/{ruleUID}/config/put(updateRuleConfiguration)`. - func updateRuleConfiguration(path: Operations.updateRuleConfiguration.Input.Path, - body: Operations.updateRuleConfiguration.Input.Body? = nil) async throws -> Operations.updateRuleConfiguration.Output { + public func updateRuleConfiguration( + path: Operations.updateRuleConfiguration.Input.Path, + body: Operations.updateRuleConfiguration.Input.Body? = nil + ) async throws -> Operations.updateRuleConfiguration.Output { try await updateRuleConfiguration(Operations.updateRuleConfiguration.Input( path: path, body: body )) } - /// Gets the rule's module corresponding to the given Category and ID. /// /// - Remark: HTTP `GET /rules/{ruleUID}/{moduleCategory}/{id}`. /// - Remark: Generated from `#/paths//rules/{ruleUID}/{moduleCategory}/{id}/get(getRuleModuleById)`. - func getRuleModuleById(path: Operations.getRuleModuleById.Input.Path, - headers: Operations.getRuleModuleById.Input.Headers = .init()) async throws -> Operations.getRuleModuleById.Output { + public func getRuleModuleById( + path: Operations.getRuleModuleById.Input.Path, + headers: Operations.getRuleModuleById.Input.Headers = .init() + ) async throws -> Operations.getRuleModuleById.Output { try await getRuleModuleById(Operations.getRuleModuleById.Input( path: path, headers: headers )) } - /// Gets the module's configuration. /// /// - Remark: HTTP `GET /rules/{ruleUID}/{moduleCategory}/{id}/config`. /// - Remark: Generated from `#/paths//rules/{ruleUID}/{moduleCategory}/{id}/config/get(getRuleModuleConfig)`. - func getRuleModuleConfig(path: Operations.getRuleModuleConfig.Input.Path, - headers: Operations.getRuleModuleConfig.Input.Headers = .init()) async throws -> Operations.getRuleModuleConfig.Output { + public func getRuleModuleConfig( + path: Operations.getRuleModuleConfig.Input.Path, + headers: Operations.getRuleModuleConfig.Input.Headers = .init() + ) async throws -> Operations.getRuleModuleConfig.Output { try await getRuleModuleConfig(Operations.getRuleModuleConfig.Input( path: path, headers: headers )) } - /// Gets the module's configuration parameter. /// /// - Remark: HTTP `GET /rules/{ruleUID}/{moduleCategory}/{id}/config/{param}`. /// - Remark: Generated from `#/paths//rules/{ruleUID}/{moduleCategory}/{id}/config/{param}/get(getRuleModuleConfigParameter)`. - func getRuleModuleConfigParameter(path: Operations.getRuleModuleConfigParameter.Input.Path, - headers: Operations.getRuleModuleConfigParameter.Input.Headers = .init()) async throws -> Operations.getRuleModuleConfigParameter.Output { + public func getRuleModuleConfigParameter( + path: Operations.getRuleModuleConfigParameter.Input.Path, + headers: Operations.getRuleModuleConfigParameter.Input.Headers = .init() + ) async throws -> Operations.getRuleModuleConfigParameter.Output { try await getRuleModuleConfigParameter(Operations.getRuleModuleConfigParameter.Input( path: path, headers: headers )) } - /// Sets the module's configuration parameter value. /// /// - Remark: HTTP `PUT /rules/{ruleUID}/{moduleCategory}/{id}/config/{param}`. /// - Remark: Generated from `#/paths//rules/{ruleUID}/{moduleCategory}/{id}/config/{param}/put(setRuleModuleConfigParameter)`. - func setRuleModuleConfigParameter(path: Operations.setRuleModuleConfigParameter.Input.Path, - body: Operations.setRuleModuleConfigParameter.Input.Body) async throws -> Operations.setRuleModuleConfigParameter.Output { + public func setRuleModuleConfigParameter( + path: Operations.setRuleModuleConfigParameter.Input.Path, + body: Operations.setRuleModuleConfigParameter.Input.Body + ) async throws -> Operations.setRuleModuleConfigParameter.Output { try await setRuleModuleConfigParameter(Operations.setRuleModuleConfigParameter.Input( path: path, body: body )) } - /// Gets the rule triggers. /// /// - Remark: HTTP `GET /rules/{ruleUID}/triggers`. /// - Remark: Generated from `#/paths//rules/{ruleUID}/triggers/get(getRuleTriggers)`. - func getRuleTriggers(path: Operations.getRuleTriggers.Input.Path, - headers: Operations.getRuleTriggers.Input.Headers = .init()) async throws -> Operations.getRuleTriggers.Output { + public func getRuleTriggers( + path: Operations.getRuleTriggers.Input.Path, + headers: Operations.getRuleTriggers.Input.Headers = .init() + ) async throws -> Operations.getRuleTriggers.Output { try await getRuleTriggers(Operations.getRuleTriggers.Input( path: path, headers: headers )) } - /// Executes actions of the rule. /// /// - Remark: HTTP `POST /rules/{ruleUID}/runnow`. /// - Remark: Generated from `#/paths//rules/{ruleUID}/runnow/post(runRuleNow_1)`. - func runRuleNow_1(path: Operations.runRuleNow_1.Input.Path, - body: Operations.runRuleNow_1.Input.Body? = nil) async throws -> Operations.runRuleNow_1.Output { + public func runRuleNow_1( + path: Operations.runRuleNow_1.Input.Path, + body: Operations.runRuleNow_1.Input.Body? = nil + ) async throws -> Operations.runRuleNow_1.Output { try await runRuleNow_1(Operations.runRuleNow_1.Input( path: path, body: body )) } - /// Simulates the executions of rules filtered by tag 'Schedule' within the given times. /// /// - Remark: HTTP `GET /rules/schedule/simulations`. /// - Remark: Generated from `#/paths//rules/schedule/simulations/get(getScheduleRuleSimulations)`. - func getScheduleRuleSimulations(query: Operations.getScheduleRuleSimulations.Input.Query = .init(), - headers: Operations.getScheduleRuleSimulations.Input.Headers = .init()) async throws -> Operations.getScheduleRuleSimulations.Output { + public func getScheduleRuleSimulations( + query: Operations.getScheduleRuleSimulations.Input.Query = .init(), + headers: Operations.getScheduleRuleSimulations.Input.Headers = .init() + ) async throws -> Operations.getScheduleRuleSimulations.Output { try await getScheduleRuleSimulations(Operations.getScheduleRuleSimulations.Input( query: query, headers: headers )) } - /// Adds a new member to a group item. /// /// - Remark: HTTP `PUT /items/{itemName}/members/{memberItemName}`. /// - Remark: Generated from `#/paths//items/{itemName}/members/{memberItemName}/put(addMemberToGroupItem)`. - func addMemberToGroupItem(path: Operations.addMemberToGroupItem.Input.Path) async throws -> Operations.addMemberToGroupItem.Output { + public func addMemberToGroupItem(path: Operations.addMemberToGroupItem.Input.Path) async throws -> Operations.addMemberToGroupItem.Output { try await addMemberToGroupItem(Operations.addMemberToGroupItem.Input(path: path)) } - /// Removes an existing member from a group item. /// /// - Remark: HTTP `DELETE /items/{itemName}/members/{memberItemName}`. /// - Remark: Generated from `#/paths//items/{itemName}/members/{memberItemName}/delete(removeMemberFromGroupItem)`. - func removeMemberFromGroupItem(path: Operations.removeMemberFromGroupItem.Input.Path) async throws -> Operations.removeMemberFromGroupItem.Output { + public func removeMemberFromGroupItem(path: Operations.removeMemberFromGroupItem.Input.Path) async throws -> Operations.removeMemberFromGroupItem.Output { try await removeMemberFromGroupItem(Operations.removeMemberFromGroupItem.Input(path: path)) } - /// Adds metadata to an item. /// /// - Remark: HTTP `PUT /items/{itemname}/metadata/{namespace}`. /// - Remark: Generated from `#/paths//items/{itemname}/metadata/{namespace}/put(addMetadataToItem)`. - func addMetadataToItem(path: Operations.addMetadataToItem.Input.Path, - body: Operations.addMetadataToItem.Input.Body) async throws -> Operations.addMetadataToItem.Output { + public func addMetadataToItem( + path: Operations.addMetadataToItem.Input.Path, + body: Operations.addMetadataToItem.Input.Body + ) async throws -> Operations.addMetadataToItem.Output { try await addMetadataToItem(Operations.addMetadataToItem.Input( path: path, body: body )) } - /// Removes metadata from an item. /// /// - Remark: HTTP `DELETE /items/{itemname}/metadata/{namespace}`. /// - Remark: Generated from `#/paths//items/{itemname}/metadata/{namespace}/delete(removeMetadataFromItem)`. - func removeMetadataFromItem(path: Operations.removeMetadataFromItem.Input.Path) async throws -> Operations.removeMetadataFromItem.Output { + public func removeMetadataFromItem(path: Operations.removeMetadataFromItem.Input.Path) async throws -> Operations.removeMetadataFromItem.Output { try await removeMetadataFromItem(Operations.removeMetadataFromItem.Input(path: path)) } - /// Adds a tag to an item. /// /// - Remark: HTTP `PUT /items/{itemname}/tags/{tag}`. /// - Remark: Generated from `#/paths//items/{itemname}/tags/{tag}/put(addTagToItem)`. - func addTagToItem(path: Operations.addTagToItem.Input.Path) async throws -> Operations.addTagToItem.Output { + public func addTagToItem(path: Operations.addTagToItem.Input.Path) async throws -> Operations.addTagToItem.Output { try await addTagToItem(Operations.addTagToItem.Input(path: path)) } - /// Removes a tag from an item. /// /// - Remark: HTTP `DELETE /items/{itemname}/tags/{tag}`. /// - Remark: Generated from `#/paths//items/{itemname}/tags/{tag}/delete(removeTagFromItem)`. - func removeTagFromItem(path: Operations.removeTagFromItem.Input.Path) async throws -> Operations.removeTagFromItem.Output { + public func removeTagFromItem(path: Operations.removeTagFromItem.Input.Path) async throws -> Operations.removeTagFromItem.Output { try await removeTagFromItem(Operations.removeTagFromItem.Input(path: path)) } - /// Gets a single item. /// /// - Remark: HTTP `GET /items/{itemname}`. /// - Remark: Generated from `#/paths//items/{itemname}/get(getItemByName)`. - func getItemByName(path: Operations.getItemByName.Input.Path, - query: Operations.getItemByName.Input.Query = .init(), - headers: Operations.getItemByName.Input.Headers = .init()) async throws -> Operations.getItemByName.Output { + public func getItemByName( + path: Operations.getItemByName.Input.Path, + query: Operations.getItemByName.Input.Query = .init(), + headers: Operations.getItemByName.Input.Headers = .init() + ) async throws -> Operations.getItemByName.Output { try await getItemByName(Operations.getItemByName.Input( path: path, query: query, headers: headers )) } - /// Sends a command to an item. /// /// - Remark: HTTP `POST /items/{itemname}`. /// - Remark: Generated from `#/paths//items/{itemname}/post(sendItemCommand)`. - func sendItemCommand(path: Operations.sendItemCommand.Input.Path, - body: Operations.sendItemCommand.Input.Body) async throws -> Operations.sendItemCommand.Output { + public func sendItemCommand( + path: Operations.sendItemCommand.Input.Path, + body: Operations.sendItemCommand.Input.Body + ) async throws -> Operations.sendItemCommand.Output { try await sendItemCommand(Operations.sendItemCommand.Input( path: path, body: body )) } - /// Adds a new item to the registry or updates the existing item. /// /// - Remark: HTTP `PUT /items/{itemname}`. /// - Remark: Generated from `#/paths//items/{itemname}/put(addOrUpdateItemInRegistry)`. - func addOrUpdateItemInRegistry(path: Operations.addOrUpdateItemInRegistry.Input.Path, - headers: Operations.addOrUpdateItemInRegistry.Input.Headers = .init(), - body: Operations.addOrUpdateItemInRegistry.Input.Body) async throws -> Operations.addOrUpdateItemInRegistry.Output { + public func addOrUpdateItemInRegistry( + path: Operations.addOrUpdateItemInRegistry.Input.Path, + headers: Operations.addOrUpdateItemInRegistry.Input.Headers = .init(), + body: Operations.addOrUpdateItemInRegistry.Input.Body + ) async throws -> Operations.addOrUpdateItemInRegistry.Output { try await addOrUpdateItemInRegistry(Operations.addOrUpdateItemInRegistry.Input( path: path, headers: headers, body: body )) } - /// Removes an item from the registry. /// /// - Remark: HTTP `DELETE /items/{itemname}`. /// - Remark: Generated from `#/paths//items/{itemname}/delete(removeItemFromRegistry)`. - func removeItemFromRegistry(path: Operations.removeItemFromRegistry.Input.Path) async throws -> Operations.removeItemFromRegistry.Output { + public func removeItemFromRegistry(path: Operations.removeItemFromRegistry.Input.Path) async throws -> Operations.removeItemFromRegistry.Output { try await removeItemFromRegistry(Operations.removeItemFromRegistry.Input(path: path)) } - /// Get all available items. /// /// - Remark: HTTP `GET /items`. /// - Remark: Generated from `#/paths//items/get(getItems)`. - func getItems(query: Operations.getItems.Input.Query = .init(), - headers: Operations.getItems.Input.Headers = .init()) async throws -> Operations.getItems.Output { + public func getItems( + query: Operations.getItems.Input.Query = .init(), + headers: Operations.getItems.Input.Headers = .init() + ) async throws -> Operations.getItems.Output { try await getItems(Operations.getItems.Input( query: query, headers: headers )) } - /// Adds a list of items to the registry or updates the existing items. /// /// - Remark: HTTP `PUT /items`. /// - Remark: Generated from `#/paths//items/put(addOrUpdateItemsInRegistry)`. - func addOrUpdateItemsInRegistry(headers: Operations.addOrUpdateItemsInRegistry.Input.Headers = .init(), - body: Operations.addOrUpdateItemsInRegistry.Input.Body) async throws -> Operations.addOrUpdateItemsInRegistry.Output { + public func addOrUpdateItemsInRegistry( + headers: Operations.addOrUpdateItemsInRegistry.Input.Headers = .init(), + body: Operations.addOrUpdateItemsInRegistry.Input.Body + ) async throws -> Operations.addOrUpdateItemsInRegistry.Output { try await addOrUpdateItemsInRegistry(Operations.addOrUpdateItemsInRegistry.Input( headers: headers, body: body )) } - /// Gets the state of an item. /// /// - Remark: HTTP `GET /items/{itemname}/state`. /// - Remark: Generated from `#/paths//items/{itemname}/state/get(getItemState_1)`. - func getItemState_1(path: Operations.getItemState_1.Input.Path, - headers: Operations.getItemState_1.Input.Headers = .init()) async throws -> Operations.getItemState_1.Output { + public func getItemState_1( + path: Operations.getItemState_1.Input.Path, + headers: Operations.getItemState_1.Input.Headers = .init() + ) async throws -> Operations.getItemState_1.Output { try await getItemState_1(Operations.getItemState_1.Input( path: path, headers: headers )) } - /// Updates the state of an item. /// /// - Remark: HTTP `PUT /items/{itemname}/state`. /// - Remark: Generated from `#/paths//items/{itemname}/state/put(updateItemState)`. - func updateItemState(path: Operations.updateItemState.Input.Path, - headers: Operations.updateItemState.Input.Headers = .init(), - body: Operations.updateItemState.Input.Body) async throws -> Operations.updateItemState.Output { + public func updateItemState( + path: Operations.updateItemState.Input.Path, + headers: Operations.updateItemState.Input.Headers = .init(), + body: Operations.updateItemState.Input.Body + ) async throws -> Operations.updateItemState.Output { try await updateItemState(Operations.updateItemState.Input( path: path, headers: headers, body: body )) } - /// Gets the namespace of an item. /// /// - Remark: HTTP `GET /items/{itemname}/metadata/namespaces`. /// - Remark: Generated from `#/paths//items/{itemname}/metadata/namespaces/get(getItemNamespaces)`. - func getItemNamespaces(path: Operations.getItemNamespaces.Input.Path, - headers: Operations.getItemNamespaces.Input.Headers = .init()) async throws -> Operations.getItemNamespaces.Output { + public func getItemNamespaces( + path: Operations.getItemNamespaces.Input.Path, + headers: Operations.getItemNamespaces.Input.Headers = .init() + ) async throws -> Operations.getItemNamespaces.Output { try await getItemNamespaces(Operations.getItemNamespaces.Input( path: path, headers: headers )) } - /// Gets the item which defines the requested semantics of an item. /// /// - Remark: HTTP `GET /items/{itemName}/semantic/{semanticClass}`. /// - Remark: Generated from `#/paths//items/{itemName}/semantic/{semanticClass}/get(getSemanticItem)`. - func getSemanticItem(path: Operations.getSemanticItem.Input.Path, - headers: Operations.getSemanticItem.Input.Headers = .init()) async throws -> Operations.getSemanticItem.Output { + public func getSemanticItem( + path: Operations.getSemanticItem.Input.Path, + headers: Operations.getSemanticItem.Input.Headers = .init() + ) async throws -> Operations.getSemanticItem.Output { try await getSemanticItem(Operations.getSemanticItem.Input( path: path, headers: headers )) } - /// Remove unused/orphaned metadata. /// /// - Remark: HTTP `POST /items/metadata/purge`. /// - Remark: Generated from `#/paths//items/metadata/purge/post(purgeDatabase)`. - func purgeDatabase() async throws -> Operations.purgeDatabase.Output { + public func purgeDatabase() async throws -> Operations.purgeDatabase.Output { try await purgeDatabase(Operations.purgeDatabase.Input()) } - /// Gets information about the runtime, the API version and links to resources. /// /// - Remark: HTTP `GET //`. /// - Remark: Generated from `#/paths////get(getRoot)`. - func getRoot(headers: Operations.getRoot.Input.Headers = .init()) async throws -> Operations.getRoot.Output { + public func getRoot(headers: Operations.getRoot.Input.Headers = .init()) async throws -> Operations.getRoot.Output { try await getRoot(Operations.getRoot.Input(headers: headers)) } - /// Gets information about the system. /// /// - Remark: HTTP `GET /systeminfo`. /// - Remark: Generated from `#/paths//systeminfo/get(getSystemInformation)`. - func getSystemInformation(headers: Operations.getSystemInformation.Input.Headers = .init()) async throws -> Operations.getSystemInformation.Output { + public func getSystemInformation(headers: Operations.getSystemInformation.Input.Headers = .init()) async throws -> Operations.getSystemInformation.Output { try await getSystemInformation(Operations.getSystemInformation.Input(headers: headers)) } - /// Get all supported dimensions and their system units. /// /// - Remark: HTTP `GET /systeminfo/uom`. /// - Remark: Generated from `#/paths//systeminfo/uom/get(getUoMInformation)`. - func getUoMInformation(headers: Operations.getUoMInformation.Input.Headers = .init()) async throws -> Operations.getUoMInformation.Output { + public func getUoMInformation(headers: Operations.getUoMInformation.Input.Headers = .init()) async throws -> Operations.getUoMInformation.Output { try await getUoMInformation(Operations.getUoMInformation.Input(headers: headers)) } - /// Creates a sitemap event subscription. /// /// - Remark: HTTP `POST /sitemaps/events/subscribe`. /// - Remark: Generated from `#/paths//sitemaps/events/subscribe/post(createSitemapEventSubscription)`. - func createSitemapEventSubscription(headers: Operations.createSitemapEventSubscription.Input.Headers = .init()) async throws -> Operations.createSitemapEventSubscription.Output { + public func createSitemapEventSubscription(headers: Operations.createSitemapEventSubscription.Input.Headers = .init()) async throws -> Operations.createSitemapEventSubscription.Output { try await createSitemapEventSubscription(Operations.createSitemapEventSubscription.Input(headers: headers)) } - /// Polls the data for one page of a sitemap. /// /// - Remark: HTTP `GET /sitemaps/{sitemapname}/{pageid}`. /// - Remark: Generated from `#/paths//sitemaps/{sitemapname}/{pageid}/get(pollDataForPage)`. - func pollDataForPage(path: Operations.pollDataForPage.Input.Path, - query: Operations.pollDataForPage.Input.Query = .init(), - headers: Operations.pollDataForPage.Input.Headers = .init()) async throws -> Operations.pollDataForPage.Output { + public func pollDataForPage( + path: Operations.pollDataForPage.Input.Path, + query: Operations.pollDataForPage.Input.Query = .init(), + headers: Operations.pollDataForPage.Input.Headers = .init() + ) async throws -> Operations.pollDataForPage.Output { try await pollDataForPage(Operations.pollDataForPage.Input( path: path, query: query, headers: headers )) } - /// Polls the data for a whole sitemap. Not recommended due to potentially high traffic. /// /// - Remark: HTTP `GET /sitemaps/{sitemapname}/*`. /// - Remark: Generated from `#/paths//sitemaps/{sitemapname}/*/get(pollDataForSitemap)`. - func pollDataForSitemap(path: Operations.pollDataForSitemap.Input.Path, - query: Operations.pollDataForSitemap.Input.Query = .init(), - headers: Operations.pollDataForSitemap.Input.Headers = .init()) async throws -> Operations.pollDataForSitemap.Output { + public func pollDataForSitemap( + path: Operations.pollDataForSitemap.Input.Path, + query: Operations.pollDataForSitemap.Input.Query = .init(), + headers: Operations.pollDataForSitemap.Input.Headers = .init() + ) async throws -> Operations.pollDataForSitemap.Output { try await pollDataForSitemap(Operations.pollDataForSitemap.Input( path: path, query: query, headers: headers )) } - /// Get sitemap by name. /// /// - Remark: HTTP `GET /sitemaps/{sitemapname}`. /// - Remark: Generated from `#/paths//sitemaps/{sitemapname}/get(getSitemapByName)`. - func getSitemapByName(path: Operations.getSitemapByName.Input.Path, - query: Operations.getSitemapByName.Input.Query = .init(), - headers: Operations.getSitemapByName.Input.Headers = .init()) async throws -> Operations.getSitemapByName.Output { + public func getSitemapByName( + path: Operations.getSitemapByName.Input.Path, + query: Operations.getSitemapByName.Input.Query = .init(), + headers: Operations.getSitemapByName.Input.Headers = .init() + ) async throws -> Operations.getSitemapByName.Output { try await getSitemapByName(Operations.getSitemapByName.Input( path: path, query: query, headers: headers )) } - /// Get sitemap events for a whole sitemap. Not recommended due to potentially high traffic. /// /// - Remark: HTTP `GET /sitemaps/events/{subscriptionid}/*`. /// - Remark: Generated from `#/paths//sitemaps/events/{subscriptionid}/*/get(getSitemapEvents)`. - func getSitemapEvents(path: Operations.getSitemapEvents.Input.Path, - query: Operations.getSitemapEvents.Input.Query = .init()) async throws -> Operations.getSitemapEvents.Output { + public func getSitemapEvents( + path: Operations.getSitemapEvents.Input.Path, + query: Operations.getSitemapEvents.Input.Query = .init() + ) async throws -> Operations.getSitemapEvents.Output { try await getSitemapEvents(Operations.getSitemapEvents.Input( path: path, query: query )) } - /// Get sitemap events. /// /// - Remark: HTTP `GET /sitemaps/events/{subscriptionid}`. /// - Remark: Generated from `#/paths//sitemaps/events/{subscriptionid}/get(getSitemapEvents_1)`. - func getSitemapEvents_1(path: Operations.getSitemapEvents_1.Input.Path, - query: Operations.getSitemapEvents_1.Input.Query = .init(), - headers: Operations.getSitemapEvents_1.Input.Headers = .init()) async throws -> Operations.getSitemapEvents_1.Output { + public func getSitemapEvents_1( + path: Operations.getSitemapEvents_1.Input.Path, + query: Operations.getSitemapEvents_1.Input.Query = .init(), + headers: Operations.getSitemapEvents_1.Input.Headers = .init() + ) async throws -> Operations.getSitemapEvents_1.Output { try await getSitemapEvents_1(Operations.getSitemapEvents_1.Input( path: path, query: query, headers: headers )) } - /// Get all available sitemaps. /// /// - Remark: HTTP `GET /sitemaps`. /// - Remark: Generated from `#/paths//sitemaps/get(getSitemaps)`. - func getSitemaps(headers: Operations.getSitemaps.Input.Headers = .init()) async throws -> Operations.getSitemaps.Output { + public func getSitemaps(headers: Operations.getSitemaps.Input.Headers = .init()) async throws -> Operations.getSitemaps.Output { try await getSitemaps(Operations.getSitemaps.Input(headers: headers)) } - /// Get all registered UI components in the specified namespace. /// /// - Remark: HTTP `GET /ui/components/{namespace}`. /// - Remark: Generated from `#/paths//ui/components/{namespace}/get(getRegisteredUIComponentsInNamespace)`. - func getRegisteredUIComponentsInNamespace(path: Operations.getRegisteredUIComponentsInNamespace.Input.Path, - query: Operations.getRegisteredUIComponentsInNamespace.Input.Query = .init(), - headers: Operations.getRegisteredUIComponentsInNamespace.Input.Headers = .init()) async throws -> Operations.getRegisteredUIComponentsInNamespace.Output { + public func getRegisteredUIComponentsInNamespace( + path: Operations.getRegisteredUIComponentsInNamespace.Input.Path, + query: Operations.getRegisteredUIComponentsInNamespace.Input.Query = .init(), + headers: Operations.getRegisteredUIComponentsInNamespace.Input.Headers = .init() + ) async throws -> Operations.getRegisteredUIComponentsInNamespace.Output { try await getRegisteredUIComponentsInNamespace(Operations.getRegisteredUIComponentsInNamespace.Input( path: path, query: query, headers: headers )) } - /// Add a UI component in the specified namespace. /// /// - Remark: HTTP `POST /ui/components/{namespace}`. /// - Remark: Generated from `#/paths//ui/components/{namespace}/post(addUIComponentToNamespace)`. - func addUIComponentToNamespace(path: Operations.addUIComponentToNamespace.Input.Path, - headers: Operations.addUIComponentToNamespace.Input.Headers = .init(), - body: Operations.addUIComponentToNamespace.Input.Body? = nil) async throws -> Operations.addUIComponentToNamespace.Output { + public func addUIComponentToNamespace( + path: Operations.addUIComponentToNamespace.Input.Path, + headers: Operations.addUIComponentToNamespace.Input.Headers = .init(), + body: Operations.addUIComponentToNamespace.Input.Body? = nil + ) async throws -> Operations.addUIComponentToNamespace.Output { try await addUIComponentToNamespace(Operations.addUIComponentToNamespace.Input( path: path, headers: headers, body: body )) } - /// Get a specific UI component in the specified namespace. /// /// - Remark: HTTP `GET /ui/components/{namespace}/{componentUID}`. /// - Remark: Generated from `#/paths//ui/components/{namespace}/{componentUID}/get(getUIComponentInNamespace)`. - func getUIComponentInNamespace(path: Operations.getUIComponentInNamespace.Input.Path, - headers: Operations.getUIComponentInNamespace.Input.Headers = .init()) async throws -> Operations.getUIComponentInNamespace.Output { + public func getUIComponentInNamespace( + path: Operations.getUIComponentInNamespace.Input.Path, + headers: Operations.getUIComponentInNamespace.Input.Headers = .init() + ) async throws -> Operations.getUIComponentInNamespace.Output { try await getUIComponentInNamespace(Operations.getUIComponentInNamespace.Input( path: path, headers: headers )) } - /// Update a specific UI component in the specified namespace. /// /// - Remark: HTTP `PUT /ui/components/{namespace}/{componentUID}`. /// - Remark: Generated from `#/paths//ui/components/{namespace}/{componentUID}/put(updateUIComponentInNamespace)`. - func updateUIComponentInNamespace(path: Operations.updateUIComponentInNamespace.Input.Path, - headers: Operations.updateUIComponentInNamespace.Input.Headers = .init(), - body: Operations.updateUIComponentInNamespace.Input.Body? = nil) async throws -> Operations.updateUIComponentInNamespace.Output { + public func updateUIComponentInNamespace( + path: Operations.updateUIComponentInNamespace.Input.Path, + headers: Operations.updateUIComponentInNamespace.Input.Headers = .init(), + body: Operations.updateUIComponentInNamespace.Input.Body? = nil + ) async throws -> Operations.updateUIComponentInNamespace.Output { try await updateUIComponentInNamespace(Operations.updateUIComponentInNamespace.Input( path: path, headers: headers, body: body )) } - /// Remove a specific UI component in the specified namespace. /// /// - Remark: HTTP `DELETE /ui/components/{namespace}/{componentUID}`. /// - Remark: Generated from `#/paths//ui/components/{namespace}/{componentUID}/delete(removeUIComponentFromNamespace)`. - func removeUIComponentFromNamespace(path: Operations.removeUIComponentFromNamespace.Input.Path) async throws -> Operations.removeUIComponentFromNamespace.Output { + public func removeUIComponentFromNamespace(path: Operations.removeUIComponentFromNamespace.Input.Path) async throws -> Operations.removeUIComponentFromNamespace.Output { try await removeUIComponentFromNamespace(Operations.removeUIComponentFromNamespace.Input(path: path)) } - /// Get all registered UI tiles. /// /// - Remark: HTTP `GET /ui/tiles`. /// - Remark: Generated from `#/paths//ui/tiles/get(getUITiles)`. - func getUITiles(headers: Operations.getUITiles.Input.Headers = .init()) async throws -> Operations.getUITiles.Output { + public func getUITiles(headers: Operations.getUITiles.Input.Headers = .init()) async throws -> Operations.getUITiles.Output { try await getUITiles(Operations.getUITiles.Input(headers: headers)) } } @@ -843,7 +851,6 @@ public enum Servers { ) } } - @available(*, deprecated, renamed: "Servers.Server1.url") public static func server1() throws -> Foundation.URL { try Foundation.URL( @@ -873,12 +880,11 @@ public enum Components { public var required: Swift.Bool? /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterDTO/type`. @frozen public enum _typePayload: String, Codable, Hashable, Sendable, CaseIterable { - case TEXT - case INTEGER - case DECIMAL - case BOOLEAN + case TEXT = "TEXT" + case INTEGER = "INTEGER" + case DECIMAL = "DECIMAL" + case BOOLEAN = "BOOLEAN" } - /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterDTO/type`. public var _type: Components.Schemas.ConfigDescriptionParameterDTO._typePayload? /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterDTO/min`. @@ -936,28 +942,30 @@ public enum Components { /// - unitLabel: /// - options: /// - filterCriteria: - public init(context: Swift.String? = nil, - defaultValue: Swift.String? = nil, - description: Swift.String? = nil, - label: Swift.String? = nil, - name: Swift.String? = nil, - required: Swift.Bool? = nil, - _type: Components.Schemas.ConfigDescriptionParameterDTO._typePayload? = nil, - min: Swift.Double? = nil, - max: Swift.Double? = nil, - stepsize: Swift.Double? = nil, - pattern: Swift.String? = nil, - readOnly: Swift.Bool? = nil, - multiple: Swift.Bool? = nil, - multipleLimit: Swift.Int32? = nil, - groupName: Swift.String? = nil, - advanced: Swift.Bool? = nil, - verify: Swift.Bool? = nil, - limitToOptions: Swift.Bool? = nil, - unit: Swift.String? = nil, - unitLabel: Swift.String? = nil, - options: [Components.Schemas.ParameterOptionDTO]? = nil, - filterCriteria: [Components.Schemas.FilterCriteriaDTO]? = nil) { + public init( + context: Swift.String? = nil, + defaultValue: Swift.String? = nil, + description: Swift.String? = nil, + label: Swift.String? = nil, + name: Swift.String? = nil, + required: Swift.Bool? = nil, + _type: Components.Schemas.ConfigDescriptionParameterDTO._typePayload? = nil, + min: Swift.Double? = nil, + max: Swift.Double? = nil, + stepsize: Swift.Double? = nil, + pattern: Swift.String? = nil, + readOnly: Swift.Bool? = nil, + multiple: Swift.Bool? = nil, + multipleLimit: Swift.Int32? = nil, + groupName: Swift.String? = nil, + advanced: Swift.Bool? = nil, + verify: Swift.Bool? = nil, + limitToOptions: Swift.Bool? = nil, + unit: Swift.String? = nil, + unitLabel: Swift.String? = nil, + options: [Components.Schemas.ParameterOptionDTO]? = nil, + filterCriteria: [Components.Schemas.FilterCriteriaDTO]? = nil + ) { self.context = context self.defaultValue = defaultValue self.description = description @@ -981,7 +989,6 @@ public enum Components { self.options = options self.filterCriteria = filterCriteria } - public enum CodingKeys: String, CodingKey { case context case defaultValue @@ -1007,7 +1014,6 @@ public enum Components { case filterCriteria } } - /// - Remark: Generated from `#/components/schemas/FilterCriteriaDTO`. public struct FilterCriteriaDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/FilterCriteriaDTO/value`. @@ -1019,18 +1025,18 @@ public enum Components { /// - Parameters: /// - value: /// - name: - public init(value: Swift.String? = nil, - name: Swift.String? = nil) { + public init( + value: Swift.String? = nil, + name: Swift.String? = nil + ) { self.value = value self.name = name } - public enum CodingKeys: String, CodingKey { case value case name } } - /// - Remark: Generated from `#/components/schemas/ParameterOptionDTO`. public struct ParameterOptionDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/ParameterOptionDTO/label`. @@ -1042,18 +1048,18 @@ public enum Components { /// - Parameters: /// - label: /// - value: - public init(label: Swift.String? = nil, - value: Swift.String? = nil) { + public init( + label: Swift.String? = nil, + value: Swift.String? = nil + ) { self.label = label self.value = value } - public enum CodingKeys: String, CodingKey { case label case value } } - /// - Remark: Generated from `#/components/schemas/ActionDTO`. public struct ActionDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/ActionDTO/id`. @@ -1073,16 +1079,13 @@ public enum Components { public init(additionalProperties: [String: OpenAPIRuntime.OpenAPIObjectContainer] = .init()) { self.additionalProperties = additionalProperties } - public init(from decoder: any Decoder) throws { additionalProperties = try decoder.decodeAdditionalProperties(knownKeys: []) } - public func encode(to encoder: any Encoder) throws { try encoder.encodeAdditionalProperties(additionalProperties) } } - /// - Remark: Generated from `#/components/schemas/ActionDTO/configuration`. public var configuration: Components.Schemas.ActionDTO.configurationPayload? /// - Remark: Generated from `#/components/schemas/ActionDTO/type`. @@ -1098,16 +1101,13 @@ public enum Components { public init(additionalProperties: [String: Swift.String] = .init()) { self.additionalProperties = additionalProperties } - public init(from decoder: any Decoder) throws { additionalProperties = try decoder.decodeAdditionalProperties(knownKeys: []) } - public func encode(to encoder: any Encoder) throws { try encoder.encodeAdditionalProperties(additionalProperties) } } - /// - Remark: Generated from `#/components/schemas/ActionDTO/inputs`. public var inputs: Components.Schemas.ActionDTO.inputsPayload? /// Creates a new `ActionDTO`. @@ -1119,12 +1119,14 @@ public enum Components { /// - configuration: /// - _type: /// - inputs: - public init(id: Swift.String? = nil, - label: Swift.String? = nil, - description: Swift.String? = nil, - configuration: Components.Schemas.ActionDTO.configurationPayload? = nil, - _type: Swift.String? = nil, - inputs: Components.Schemas.ActionDTO.inputsPayload? = nil) { + public init( + id: Swift.String? = nil, + label: Swift.String? = nil, + description: Swift.String? = nil, + configuration: Components.Schemas.ActionDTO.configurationPayload? = nil, + _type: Swift.String? = nil, + inputs: Components.Schemas.ActionDTO.inputsPayload? = nil + ) { self.id = id self.label = label self.description = description @@ -1132,7 +1134,6 @@ public enum Components { self._type = _type self.inputs = inputs } - public enum CodingKeys: String, CodingKey { case id case label @@ -1142,7 +1143,6 @@ public enum Components { case inputs } } - /// - Remark: Generated from `#/components/schemas/ConditionDTO`. public struct ConditionDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/ConditionDTO/id`. @@ -1162,16 +1162,13 @@ public enum Components { public init(additionalProperties: [String: OpenAPIRuntime.OpenAPIObjectContainer] = .init()) { self.additionalProperties = additionalProperties } - public init(from decoder: any Decoder) throws { additionalProperties = try decoder.decodeAdditionalProperties(knownKeys: []) } - public func encode(to encoder: any Encoder) throws { try encoder.encodeAdditionalProperties(additionalProperties) } } - /// - Remark: Generated from `#/components/schemas/ConditionDTO/configuration`. public var configuration: Components.Schemas.ConditionDTO.configurationPayload? /// - Remark: Generated from `#/components/schemas/ConditionDTO/type`. @@ -1187,16 +1184,13 @@ public enum Components { public init(additionalProperties: [String: Swift.String] = .init()) { self.additionalProperties = additionalProperties } - public init(from decoder: any Decoder) throws { additionalProperties = try decoder.decodeAdditionalProperties(knownKeys: []) } - public func encode(to encoder: any Encoder) throws { try encoder.encodeAdditionalProperties(additionalProperties) } } - /// - Remark: Generated from `#/components/schemas/ConditionDTO/inputs`. public var inputs: Components.Schemas.ConditionDTO.inputsPayload? /// Creates a new `ConditionDTO`. @@ -1208,12 +1202,14 @@ public enum Components { /// - configuration: /// - _type: /// - inputs: - public init(id: Swift.String? = nil, - label: Swift.String? = nil, - description: Swift.String? = nil, - configuration: Components.Schemas.ConditionDTO.configurationPayload? = nil, - _type: Swift.String? = nil, - inputs: Components.Schemas.ConditionDTO.inputsPayload? = nil) { + public init( + id: Swift.String? = nil, + label: Swift.String? = nil, + description: Swift.String? = nil, + configuration: Components.Schemas.ConditionDTO.configurationPayload? = nil, + _type: Swift.String? = nil, + inputs: Components.Schemas.ConditionDTO.inputsPayload? = nil + ) { self.id = id self.label = label self.description = description @@ -1221,7 +1217,6 @@ public enum Components { self._type = _type self.inputs = inputs } - public enum CodingKeys: String, CodingKey { case id case label @@ -1231,7 +1226,6 @@ public enum Components { case inputs } } - /// - Remark: Generated from `#/components/schemas/RuleDTO`. public struct RuleDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/RuleDTO/triggers`. @@ -1251,16 +1245,13 @@ public enum Components { public init(additionalProperties: [String: OpenAPIRuntime.OpenAPIObjectContainer] = .init()) { self.additionalProperties = additionalProperties } - public init(from decoder: any Decoder) throws { additionalProperties = try decoder.decodeAdditionalProperties(knownKeys: []) } - public func encode(to encoder: any Encoder) throws { try encoder.encodeAdditionalProperties(additionalProperties) } } - /// - Remark: Generated from `#/components/schemas/RuleDTO/configuration`. public var configuration: Components.Schemas.RuleDTO.configurationPayload? /// - Remark: Generated from `#/components/schemas/RuleDTO/configDescriptions`. @@ -1275,11 +1266,10 @@ public enum Components { public var tags: [Swift.String]? /// - Remark: Generated from `#/components/schemas/RuleDTO/visibility`. @frozen public enum visibilityPayload: String, Codable, Hashable, Sendable, CaseIterable { - case VISIBLE - case HIDDEN - case EXPERT + case VISIBLE = "VISIBLE" + case HIDDEN = "HIDDEN" + case EXPERT = "EXPERT" } - /// - Remark: Generated from `#/components/schemas/RuleDTO/visibility`. public var visibility: Components.Schemas.RuleDTO.visibilityPayload? /// - Remark: Generated from `#/components/schemas/RuleDTO/description`. @@ -1298,17 +1288,19 @@ public enum Components { /// - tags: /// - visibility: /// - description: - public init(triggers: [Components.Schemas.TriggerDTO]? = nil, - conditions: [Components.Schemas.ConditionDTO]? = nil, - actions: [Components.Schemas.ActionDTO]? = nil, - configuration: Components.Schemas.RuleDTO.configurationPayload? = nil, - configDescriptions: [Components.Schemas.ConfigDescriptionParameterDTO]? = nil, - templateUID: Swift.String? = nil, - uid: Swift.String? = nil, - name: Swift.String? = nil, - tags: [Swift.String]? = nil, - visibility: Components.Schemas.RuleDTO.visibilityPayload? = nil, - description: Swift.String? = nil) { + public init( + triggers: [Components.Schemas.TriggerDTO]? = nil, + conditions: [Components.Schemas.ConditionDTO]? = nil, + actions: [Components.Schemas.ActionDTO]? = nil, + configuration: Components.Schemas.RuleDTO.configurationPayload? = nil, + configDescriptions: [Components.Schemas.ConfigDescriptionParameterDTO]? = nil, + templateUID: Swift.String? = nil, + uid: Swift.String? = nil, + name: Swift.String? = nil, + tags: [Swift.String]? = nil, + visibility: Components.Schemas.RuleDTO.visibilityPayload? = nil, + description: Swift.String? = nil + ) { self.triggers = triggers self.conditions = conditions self.actions = actions @@ -1321,7 +1313,6 @@ public enum Components { self.visibility = visibility self.description = description } - public enum CodingKeys: String, CodingKey { case triggers case conditions @@ -1336,7 +1327,6 @@ public enum Components { case description } } - /// - Remark: Generated from `#/components/schemas/TriggerDTO`. public struct TriggerDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/TriggerDTO/id`. @@ -1356,16 +1346,13 @@ public enum Components { public init(additionalProperties: [String: OpenAPIRuntime.OpenAPIObjectContainer] = .init()) { self.additionalProperties = additionalProperties } - public init(from decoder: any Decoder) throws { additionalProperties = try decoder.decodeAdditionalProperties(knownKeys: []) } - public func encode(to encoder: any Encoder) throws { try encoder.encodeAdditionalProperties(additionalProperties) } } - /// - Remark: Generated from `#/components/schemas/TriggerDTO/configuration`. public var configuration: Components.Schemas.TriggerDTO.configurationPayload? /// - Remark: Generated from `#/components/schemas/TriggerDTO/type`. @@ -1378,18 +1365,19 @@ public enum Components { /// - description: /// - configuration: /// - _type: - public init(id: Swift.String? = nil, - label: Swift.String? = nil, - description: Swift.String? = nil, - configuration: Components.Schemas.TriggerDTO.configurationPayload? = nil, - _type: Swift.String? = nil) { + public init( + id: Swift.String? = nil, + label: Swift.String? = nil, + description: Swift.String? = nil, + configuration: Components.Schemas.TriggerDTO.configurationPayload? = nil, + _type: Swift.String? = nil + ) { self.id = id self.label = label self.description = description self.configuration = configuration self._type = _type } - public enum CodingKeys: String, CodingKey { case id case label @@ -1398,7 +1386,6 @@ public enum Components { case _type = "type" } } - /// - Remark: Generated from `#/components/schemas/EnrichedRuleDTO`. public struct EnrichedRuleDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/EnrichedRuleDTO/triggers`. @@ -1418,16 +1405,13 @@ public enum Components { public init(additionalProperties: [String: OpenAPIRuntime.OpenAPIObjectContainer] = .init()) { self.additionalProperties = additionalProperties } - public init(from decoder: any Decoder) throws { additionalProperties = try decoder.decodeAdditionalProperties(knownKeys: []) } - public func encode(to encoder: any Encoder) throws { try encoder.encodeAdditionalProperties(additionalProperties) } } - /// - Remark: Generated from `#/components/schemas/EnrichedRuleDTO/configuration`. public var configuration: Components.Schemas.EnrichedRuleDTO.configurationPayload? /// - Remark: Generated from `#/components/schemas/EnrichedRuleDTO/configDescriptions`. @@ -1442,11 +1426,10 @@ public enum Components { public var tags: [Swift.String]? /// - Remark: Generated from `#/components/schemas/EnrichedRuleDTO/visibility`. @frozen public enum visibilityPayload: String, Codable, Hashable, Sendable, CaseIterable { - case VISIBLE - case HIDDEN - case EXPERT + case VISIBLE = "VISIBLE" + case HIDDEN = "HIDDEN" + case EXPERT = "EXPERT" } - /// - Remark: Generated from `#/components/schemas/EnrichedRuleDTO/visibility`. public var visibility: Components.Schemas.EnrichedRuleDTO.visibilityPayload? /// - Remark: Generated from `#/components/schemas/EnrichedRuleDTO/description`. @@ -1471,19 +1454,21 @@ public enum Components { /// - description: /// - status: /// - editable: - public init(triggers: [Components.Schemas.TriggerDTO]? = nil, - conditions: [Components.Schemas.ConditionDTO]? = nil, - actions: [Components.Schemas.ActionDTO]? = nil, - configuration: Components.Schemas.EnrichedRuleDTO.configurationPayload? = nil, - configDescriptions: [Components.Schemas.ConfigDescriptionParameterDTO]? = nil, - templateUID: Swift.String? = nil, - uid: Swift.String? = nil, - name: Swift.String? = nil, - tags: [Swift.String]? = nil, - visibility: Components.Schemas.EnrichedRuleDTO.visibilityPayload? = nil, - description: Swift.String? = nil, - status: Components.Schemas.RuleStatusInfo? = nil, - editable: Swift.Bool? = nil) { + public init( + triggers: [Components.Schemas.TriggerDTO]? = nil, + conditions: [Components.Schemas.ConditionDTO]? = nil, + actions: [Components.Schemas.ActionDTO]? = nil, + configuration: Components.Schemas.EnrichedRuleDTO.configurationPayload? = nil, + configDescriptions: [Components.Schemas.ConfigDescriptionParameterDTO]? = nil, + templateUID: Swift.String? = nil, + uid: Swift.String? = nil, + name: Swift.String? = nil, + tags: [Swift.String]? = nil, + visibility: Components.Schemas.EnrichedRuleDTO.visibilityPayload? = nil, + description: Swift.String? = nil, + status: Components.Schemas.RuleStatusInfo? = nil, + editable: Swift.Bool? = nil + ) { self.triggers = triggers self.conditions = conditions self.actions = actions @@ -1498,7 +1483,6 @@ public enum Components { self.status = status self.editable = editable } - public enum CodingKeys: String, CodingKey { case triggers case conditions @@ -1515,30 +1499,27 @@ public enum Components { case editable } } - /// - Remark: Generated from `#/components/schemas/RuleStatusInfo`. public struct RuleStatusInfo: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/RuleStatusInfo/status`. @frozen public enum statusPayload: String, Codable, Hashable, Sendable, CaseIterable { - case UNINITIALIZED - case INITIALIZING - case IDLE - case RUNNING + case UNINITIALIZED = "UNINITIALIZED" + case INITIALIZING = "INITIALIZING" + case IDLE = "IDLE" + case RUNNING = "RUNNING" } - /// - Remark: Generated from `#/components/schemas/RuleStatusInfo/status`. public var status: Components.Schemas.RuleStatusInfo.statusPayload? /// - Remark: Generated from `#/components/schemas/RuleStatusInfo/statusDetail`. @frozen public enum statusDetailPayload: String, Codable, Hashable, Sendable, CaseIterable { - case NONE - case HANDLER_MISSING_ERROR - case HANDLER_INITIALIZING_ERROR - case CONFIGURATION_ERROR - case TEMPLATE_MISSING_ERROR - case INVALID_RULE - case DISABLED + case NONE = "NONE" + case HANDLER_MISSING_ERROR = "HANDLER_MISSING_ERROR" + case HANDLER_INITIALIZING_ERROR = "HANDLER_INITIALIZING_ERROR" + case CONFIGURATION_ERROR = "CONFIGURATION_ERROR" + case TEMPLATE_MISSING_ERROR = "TEMPLATE_MISSING_ERROR" + case INVALID_RULE = "INVALID_RULE" + case DISABLED = "DISABLED" } - /// - Remark: Generated from `#/components/schemas/RuleStatusInfo/statusDetail`. public var statusDetail: Components.Schemas.RuleStatusInfo.statusDetailPayload? /// - Remark: Generated from `#/components/schemas/RuleStatusInfo/description`. @@ -1549,21 +1530,21 @@ public enum Components { /// - status: /// - statusDetail: /// - description: - public init(status: Components.Schemas.RuleStatusInfo.statusPayload? = nil, - statusDetail: Components.Schemas.RuleStatusInfo.statusDetailPayload? = nil, - description: Swift.String? = nil) { + public init( + status: Components.Schemas.RuleStatusInfo.statusPayload? = nil, + statusDetail: Components.Schemas.RuleStatusInfo.statusDetailPayload? = nil, + description: Swift.String? = nil + ) { self.status = status self.statusDetail = statusDetail self.description = description } - public enum CodingKeys: String, CodingKey { case status case statusDetail case description } } - /// - Remark: Generated from `#/components/schemas/ModuleDTO`. public struct ModuleDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/ModuleDTO/id`. @@ -1583,16 +1564,13 @@ public enum Components { public init(additionalProperties: [String: OpenAPIRuntime.OpenAPIObjectContainer] = .init()) { self.additionalProperties = additionalProperties } - public init(from decoder: any Decoder) throws { additionalProperties = try decoder.decodeAdditionalProperties(knownKeys: []) } - public func encode(to encoder: any Encoder) throws { try encoder.encodeAdditionalProperties(additionalProperties) } } - /// - Remark: Generated from `#/components/schemas/ModuleDTO/configuration`. public var configuration: Components.Schemas.ModuleDTO.configurationPayload? /// - Remark: Generated from `#/components/schemas/ModuleDTO/type`. @@ -1605,18 +1583,19 @@ public enum Components { /// - description: /// - configuration: /// - _type: - public init(id: Swift.String? = nil, - label: Swift.String? = nil, - description: Swift.String? = nil, - configuration: Components.Schemas.ModuleDTO.configurationPayload? = nil, - _type: Swift.String? = nil) { + public init( + id: Swift.String? = nil, + label: Swift.String? = nil, + description: Swift.String? = nil, + configuration: Components.Schemas.ModuleDTO.configurationPayload? = nil, + _type: Swift.String? = nil + ) { self.id = id self.label = label self.description = description self.configuration = configuration self._type = _type } - public enum CodingKeys: String, CodingKey { case id case label @@ -1625,7 +1604,6 @@ public enum Components { case _type = "type" } } - /// - Remark: Generated from `#/components/schemas/Action`. public struct Action: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/Action/inputs`. @@ -1639,16 +1617,13 @@ public enum Components { public init(additionalProperties: [String: Swift.String] = .init()) { self.additionalProperties = additionalProperties } - public init(from decoder: any Decoder) throws { additionalProperties = try decoder.decodeAdditionalProperties(knownKeys: []) } - public func encode(to encoder: any Encoder) throws { try encoder.encodeAdditionalProperties(additionalProperties) } } - /// - Remark: Generated from `#/components/schemas/Action/inputs`. public var inputs: Components.Schemas.Action.inputsPayload? /// - Remark: Generated from `#/components/schemas/Action/description`. @@ -1670,12 +1645,14 @@ public enum Components { /// - typeUID: /// - configuration: /// - id: - public init(inputs: Components.Schemas.Action.inputsPayload? = nil, - description: Swift.String? = nil, - label: Swift.String? = nil, - typeUID: Swift.String? = nil, - configuration: Components.Schemas.Configuration? = nil, - id: Swift.String? = nil) { + public init( + inputs: Components.Schemas.Action.inputsPayload? = nil, + description: Swift.String? = nil, + label: Swift.String? = nil, + typeUID: Swift.String? = nil, + configuration: Components.Schemas.Configuration? = nil, + id: Swift.String? = nil + ) { self.inputs = inputs self.description = description self.label = label @@ -1683,7 +1660,6 @@ public enum Components { self.configuration = configuration self.id = id } - public enum CodingKeys: String, CodingKey { case inputs case description @@ -1693,7 +1669,6 @@ public enum Components { case id } } - /// - Remark: Generated from `#/components/schemas/Condition`. public struct Condition: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/Condition/inputs`. @@ -1707,16 +1682,13 @@ public enum Components { public init(additionalProperties: [String: Swift.String] = .init()) { self.additionalProperties = additionalProperties } - public init(from decoder: any Decoder) throws { additionalProperties = try decoder.decodeAdditionalProperties(knownKeys: []) } - public func encode(to encoder: any Encoder) throws { try encoder.encodeAdditionalProperties(additionalProperties) } } - /// - Remark: Generated from `#/components/schemas/Condition/inputs`. public var inputs: Components.Schemas.Condition.inputsPayload? /// - Remark: Generated from `#/components/schemas/Condition/description`. @@ -1738,12 +1710,14 @@ public enum Components { /// - typeUID: /// - configuration: /// - id: - public init(inputs: Components.Schemas.Condition.inputsPayload? = nil, - description: Swift.String? = nil, - label: Swift.String? = nil, - typeUID: Swift.String? = nil, - configuration: Components.Schemas.Configuration? = nil, - id: Swift.String? = nil) { + public init( + inputs: Components.Schemas.Condition.inputsPayload? = nil, + description: Swift.String? = nil, + label: Swift.String? = nil, + typeUID: Swift.String? = nil, + configuration: Components.Schemas.Configuration? = nil, + id: Swift.String? = nil + ) { self.inputs = inputs self.description = description self.label = label @@ -1751,7 +1725,6 @@ public enum Components { self.configuration = configuration self.id = id } - public enum CodingKeys: String, CodingKey { case inputs case description @@ -1761,19 +1734,17 @@ public enum Components { case id } } - /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameter`. public struct ConfigDescriptionParameter: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameter/name`. public var name: Swift.String? /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameter/type`. @frozen public enum _typePayload: String, Codable, Hashable, Sendable, CaseIterable { - case TEXT - case INTEGER - case DECIMAL - case BOOLEAN + case TEXT = "TEXT" + case INTEGER = "INTEGER" + case DECIMAL = "DECIMAL" + case BOOLEAN = "BOOLEAN" } - /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameter/type`. public var _type: Components.Schemas.ConfigDescriptionParameter._typePayload? /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameter/groupName`. @@ -1841,28 +1812,30 @@ public enum Components { /// - stepSize: /// - verifyable: /// - _default: - public init(name: Swift.String? = nil, - _type: Components.Schemas.ConfigDescriptionParameter._typePayload? = nil, - groupName: Swift.String? = nil, - pattern: Swift.String? = nil, - required: Swift.Bool? = nil, - readOnly: Swift.Bool? = nil, - multiple: Swift.Bool? = nil, - multipleLimit: Swift.Int32? = nil, - unit: Swift.String? = nil, - unitLabel: Swift.String? = nil, - context: Swift.String? = nil, - label: Swift.String? = nil, - description: Swift.String? = nil, - options: [Components.Schemas.ParameterOption]? = nil, - filterCriteria: [Components.Schemas.FilterCriteria]? = nil, - limitToOptions: Swift.Bool? = nil, - advanced: Swift.Bool? = nil, - minimum: Swift.Double? = nil, - maximum: Swift.Double? = nil, - stepSize: Swift.Double? = nil, - verifyable: Swift.Bool? = nil, - _default: Swift.String? = nil) { + public init( + name: Swift.String? = nil, + _type: Components.Schemas.ConfigDescriptionParameter._typePayload? = nil, + groupName: Swift.String? = nil, + pattern: Swift.String? = nil, + required: Swift.Bool? = nil, + readOnly: Swift.Bool? = nil, + multiple: Swift.Bool? = nil, + multipleLimit: Swift.Int32? = nil, + unit: Swift.String? = nil, + unitLabel: Swift.String? = nil, + context: Swift.String? = nil, + label: Swift.String? = nil, + description: Swift.String? = nil, + options: [Components.Schemas.ParameterOption]? = nil, + filterCriteria: [Components.Schemas.FilterCriteria]? = nil, + limitToOptions: Swift.Bool? = nil, + advanced: Swift.Bool? = nil, + minimum: Swift.Double? = nil, + maximum: Swift.Double? = nil, + stepSize: Swift.Double? = nil, + verifyable: Swift.Bool? = nil, + _default: Swift.String? = nil + ) { self.name = name self._type = _type self.groupName = groupName @@ -1886,7 +1859,6 @@ public enum Components { self.verifyable = verifyable self._default = _default } - public enum CodingKeys: String, CodingKey { case name case _type = "type" @@ -1912,7 +1884,6 @@ public enum Components { case _default = "default" } } - /// - Remark: Generated from `#/components/schemas/Configuration`. public struct Configuration: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/Configuration/properties`. @@ -1926,16 +1897,13 @@ public enum Components { public init(additionalProperties: [String: OpenAPIRuntime.OpenAPIObjectContainer] = .init()) { self.additionalProperties = additionalProperties } - public init(from decoder: any Decoder) throws { additionalProperties = try decoder.decodeAdditionalProperties(knownKeys: []) } - public func encode(to encoder: any Encoder) throws { try encoder.encodeAdditionalProperties(additionalProperties) } } - /// - Remark: Generated from `#/components/schemas/Configuration/properties`. public var properties: Components.Schemas.Configuration.propertiesPayload? /// Creates a new `Configuration`. @@ -1945,12 +1913,10 @@ public enum Components { public init(properties: Components.Schemas.Configuration.propertiesPayload? = nil) { self.properties = properties } - public enum CodingKeys: String, CodingKey { case properties } } - /// - Remark: Generated from `#/components/schemas/FilterCriteria`. public struct FilterCriteria: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/FilterCriteria/value`. @@ -1962,18 +1928,18 @@ public enum Components { /// - Parameters: /// - value: /// - name: - public init(value: Swift.String? = nil, - name: Swift.String? = nil) { + public init( + value: Swift.String? = nil, + name: Swift.String? = nil + ) { self.value = value self.name = name } - public enum CodingKeys: String, CodingKey { case value case name } } - /// - Remark: Generated from `#/components/schemas/Module`. public struct Module: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/Module/description`. @@ -1994,18 +1960,19 @@ public enum Components { /// - typeUID: /// - configuration: /// - id: - public init(description: Swift.String? = nil, - label: Swift.String? = nil, - typeUID: Swift.String? = nil, - configuration: Components.Schemas.Configuration? = nil, - id: Swift.String? = nil) { + public init( + description: Swift.String? = nil, + label: Swift.String? = nil, + typeUID: Swift.String? = nil, + configuration: Components.Schemas.Configuration? = nil, + id: Swift.String? = nil + ) { self.description = description self.label = label self.typeUID = typeUID self.configuration = configuration self.id = id } - public enum CodingKeys: String, CodingKey { case description case label @@ -2014,7 +1981,6 @@ public enum Components { case id } } - /// - Remark: Generated from `#/components/schemas/ParameterOption`. public struct ParameterOption: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/ParameterOption/label`. @@ -2026,29 +1992,28 @@ public enum Components { /// - Parameters: /// - label: /// - value: - public init(label: Swift.String? = nil, - value: Swift.String? = nil) { + public init( + label: Swift.String? = nil, + value: Swift.String? = nil + ) { self.label = label self.value = value } - public enum CodingKeys: String, CodingKey { case label case value } } - /// - Remark: Generated from `#/components/schemas/Rule`. public struct Rule: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/Rule/description`. public var description: Swift.String? /// - Remark: Generated from `#/components/schemas/Rule/visibility`. @frozen public enum visibilityPayload: String, Codable, Hashable, Sendable, CaseIterable { - case VISIBLE - case HIDDEN - case EXPERT + case VISIBLE = "VISIBLE" + case HIDDEN = "HIDDEN" + case EXPERT = "EXPERT" } - /// - Remark: Generated from `#/components/schemas/Rule/visibility`. public var visibility: Components.Schemas.Rule.visibilityPayload? /// - Remark: Generated from `#/components/schemas/Rule/configurationDescriptions`. @@ -2086,18 +2051,20 @@ public enum Components { /// - conditions: /// - name: /// - actions: - public init(description: Swift.String? = nil, - visibility: Components.Schemas.Rule.visibilityPayload? = nil, - configurationDescriptions: [Components.Schemas.ConfigDescriptionParameter]? = nil, - templateUID: Swift.String? = nil, - triggers: [Components.Schemas.Trigger]? = nil, - uid: Swift.String? = nil, - tags: [Swift.String]? = nil, - configuration: Components.Schemas.Configuration? = nil, - modules: [Components.Schemas.Module]? = nil, - conditions: [Components.Schemas.Condition]? = nil, - name: Swift.String? = nil, - actions: [Components.Schemas.Action]? = nil) { + public init( + description: Swift.String? = nil, + visibility: Components.Schemas.Rule.visibilityPayload? = nil, + configurationDescriptions: [Components.Schemas.ConfigDescriptionParameter]? = nil, + templateUID: Swift.String? = nil, + triggers: [Components.Schemas.Trigger]? = nil, + uid: Swift.String? = nil, + tags: [Swift.String]? = nil, + configuration: Components.Schemas.Configuration? = nil, + modules: [Components.Schemas.Module]? = nil, + conditions: [Components.Schemas.Condition]? = nil, + name: Swift.String? = nil, + actions: [Components.Schemas.Action]? = nil + ) { self.description = description self.visibility = visibility self.configurationDescriptions = configurationDescriptions @@ -2111,7 +2078,6 @@ public enum Components { self.name = name self.actions = actions } - public enum CodingKeys: String, CodingKey { case description case visibility @@ -2127,7 +2093,6 @@ public enum Components { case actions } } - /// - Remark: Generated from `#/components/schemas/RuleExecution`. public struct RuleExecution: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/RuleExecution/date`. @@ -2139,18 +2104,18 @@ public enum Components { /// - Parameters: /// - date: /// - rule: - public init(date: Foundation.Date? = nil, - rule: Components.Schemas.Rule? = nil) { + public init( + date: Foundation.Date? = nil, + rule: Components.Schemas.Rule? = nil + ) { self.date = date self.rule = rule } - public enum CodingKeys: String, CodingKey { case date case rule } } - /// - Remark: Generated from `#/components/schemas/Trigger`. public struct Trigger: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/Trigger/description`. @@ -2171,18 +2136,19 @@ public enum Components { /// - typeUID: /// - configuration: /// - id: - public init(description: Swift.String? = nil, - label: Swift.String? = nil, - typeUID: Swift.String? = nil, - configuration: Components.Schemas.Configuration? = nil, - id: Swift.String? = nil) { + public init( + description: Swift.String? = nil, + label: Swift.String? = nil, + typeUID: Swift.String? = nil, + configuration: Components.Schemas.Configuration? = nil, + id: Swift.String? = nil + ) { self.description = description self.label = label self.typeUID = typeUID self.configuration = configuration self.id = id } - public enum CodingKeys: String, CodingKey { case description case label @@ -2191,7 +2157,6 @@ public enum Components { case id } } - /// - Remark: Generated from `#/components/schemas/CommandDescription`. public struct CommandDescription: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/CommandDescription/commandOptions`. @@ -2203,12 +2168,10 @@ public enum Components { public init(commandOptions: [Components.Schemas.CommandOption]? = nil) { self.commandOptions = commandOptions } - public enum CodingKeys: String, CodingKey { case commandOptions } } - /// - Remark: Generated from `#/components/schemas/CommandOption`. public struct CommandOption: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/CommandOption/command`. @@ -2220,18 +2183,18 @@ public enum Components { /// - Parameters: /// - command: /// - label: - public init(command: Swift.String? = nil, - label: Swift.String? = nil) { + public init( + command: Swift.String? = nil, + label: Swift.String? = nil + ) { self.command = command self.label = label } - public enum CodingKeys: String, CodingKey { case command case label } } - /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterGroupDTO`. public struct ConfigDescriptionParameterGroupDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/ConfigDescriptionParameterGroupDTO/name`. @@ -2252,18 +2215,19 @@ public enum Components { /// - advanced: /// - label: /// - description: - public init(name: Swift.String? = nil, - context: Swift.String? = nil, - advanced: Swift.Bool? = nil, - label: Swift.String? = nil, - description: Swift.String? = nil) { + public init( + name: Swift.String? = nil, + context: Swift.String? = nil, + advanced: Swift.Bool? = nil, + label: Swift.String? = nil, + description: Swift.String? = nil + ) { self.name = name self.context = context self.advanced = advanced self.label = label self.description = description } - public enum CodingKeys: String, CodingKey { case name case context @@ -2272,7 +2236,6 @@ public enum Components { case description } } - /// - Remark: Generated from `#/components/schemas/StateDescription`. public struct StateDescription: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/StateDescription/minimum`. @@ -2296,12 +2259,14 @@ public enum Components { /// - pattern: /// - readOnly: /// - options: - public init(minimum: Swift.Double? = nil, - maximum: Swift.Double? = nil, - step: Swift.Double? = nil, - pattern: Swift.String? = nil, - readOnly: Swift.Bool? = nil, - options: [Components.Schemas.StateOption]? = nil) { + public init( + minimum: Swift.Double? = nil, + maximum: Swift.Double? = nil, + step: Swift.Double? = nil, + pattern: Swift.String? = nil, + readOnly: Swift.Bool? = nil, + options: [Components.Schemas.StateOption]? = nil + ) { self.minimum = minimum self.maximum = maximum self.step = step @@ -2309,7 +2274,6 @@ public enum Components { self.readOnly = readOnly self.options = options } - public enum CodingKeys: String, CodingKey { case minimum case maximum @@ -2319,7 +2283,6 @@ public enum Components { case options } } - /// - Remark: Generated from `#/components/schemas/StateOption`. public struct StateOption: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/StateOption/value`. @@ -2331,18 +2294,18 @@ public enum Components { /// - Parameters: /// - value: /// - label: - public init(value: Swift.String? = nil, - label: Swift.String? = nil) { + public init( + value: Swift.String? = nil, + label: Swift.String? = nil + ) { self.value = value self.label = label } - public enum CodingKeys: String, CodingKey { case value case label } } - /// - Remark: Generated from `#/components/schemas/ConfigDescriptionDTO`. public struct ConfigDescriptionDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/ConfigDescriptionDTO/uri`. @@ -2357,21 +2320,21 @@ public enum Components { /// - uri: /// - parameters: /// - parameterGroups: - public init(uri: Swift.String? = nil, - parameters: [Components.Schemas.ConfigDescriptionParameterDTO]? = nil, - parameterGroups: [Components.Schemas.ConfigDescriptionParameterGroupDTO]? = nil) { + public init( + uri: Swift.String? = nil, + parameters: [Components.Schemas.ConfigDescriptionParameterDTO]? = nil, + parameterGroups: [Components.Schemas.ConfigDescriptionParameterGroupDTO]? = nil + ) { self.uri = uri self.parameters = parameters self.parameterGroups = parameterGroups } - public enum CodingKeys: String, CodingKey { case uri case parameters case parameterGroups } } - /// - Remark: Generated from `#/components/schemas/MetadataDTO`. public struct MetadataDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/MetadataDTO/value`. @@ -2387,16 +2350,13 @@ public enum Components { public init(additionalProperties: [String: OpenAPIRuntime.OpenAPIObjectContainer] = .init()) { self.additionalProperties = additionalProperties } - public init(from decoder: any Decoder) throws { additionalProperties = try decoder.decodeAdditionalProperties(knownKeys: []) } - public func encode(to encoder: any Encoder) throws { try encoder.encodeAdditionalProperties(additionalProperties) } } - /// - Remark: Generated from `#/components/schemas/MetadataDTO/config`. public var config: Components.Schemas.MetadataDTO.configPayload? /// Creates a new `MetadataDTO`. @@ -2404,18 +2364,18 @@ public enum Components { /// - Parameters: /// - value: /// - config: - public init(value: Swift.String? = nil, - config: Components.Schemas.MetadataDTO.configPayload? = nil) { + public init( + value: Swift.String? = nil, + config: Components.Schemas.MetadataDTO.configPayload? = nil + ) { self.value = value self.config = config } - public enum CodingKeys: String, CodingKey { case value case config } } - /// - Remark: Generated from `#/components/schemas/EnrichedItemDTO`. public struct EnrichedItemDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/EnrichedItemDTO/type`. @@ -2453,16 +2413,13 @@ public enum Components { public init(additionalProperties: [String: OpenAPIRuntime.OpenAPIObjectContainer] = .init()) { self.additionalProperties = additionalProperties } - public init(from decoder: any Decoder) throws { additionalProperties = try decoder.decodeAdditionalProperties(knownKeys: []) } - public func encode(to encoder: any Encoder) throws { try encoder.encodeAdditionalProperties(additionalProperties) } } - /// - Remark: Generated from `#/components/schemas/EnrichedItemDTO/metadata`. public var metadata: Components.Schemas.EnrichedItemDTO.metadataPayload? /// - Remark: Generated from `#/components/schemas/EnrichedItemDTO/editable`. @@ -2484,20 +2441,22 @@ public enum Components { /// - commandDescription: /// - metadata: /// - editable: - public init(_type: Swift.String? = nil, - name: Swift.String? = nil, - label: Swift.String? = nil, - category: Swift.String? = nil, - tags: [Swift.String]? = nil, - groupNames: [Swift.String]? = nil, - link: Swift.String? = nil, - state: Swift.String? = nil, - transformedState: Swift.String? = nil, - stateDescription: Components.Schemas.StateDescription? = nil, - unitSymbol: Swift.String? = nil, - commandDescription: Components.Schemas.CommandDescription? = nil, - metadata: Components.Schemas.EnrichedItemDTO.metadataPayload? = nil, - editable: Swift.Bool? = nil) { + public init( + _type: Swift.String? = nil, + name: Swift.String? = nil, + label: Swift.String? = nil, + category: Swift.String? = nil, + tags: [Swift.String]? = nil, + groupNames: [Swift.String]? = nil, + link: Swift.String? = nil, + state: Swift.String? = nil, + transformedState: Swift.String? = nil, + stateDescription: Components.Schemas.StateDescription? = nil, + unitSymbol: Swift.String? = nil, + commandDescription: Components.Schemas.CommandDescription? = nil, + metadata: Components.Schemas.EnrichedItemDTO.metadataPayload? = nil, + editable: Swift.Bool? = nil + ) { self._type = _type self.name = name self.label = label @@ -2513,7 +2472,6 @@ public enum Components { self.metadata = metadata self.editable = editable } - public enum CodingKeys: String, CodingKey { case _type = "type" case name @@ -2531,7 +2489,6 @@ public enum Components { case editable } } - /// - Remark: Generated from `#/components/schemas/GroupFunctionDTO`. public struct GroupFunctionDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/GroupFunctionDTO/name`. @@ -2543,18 +2500,18 @@ public enum Components { /// - Parameters: /// - name: /// - params: - public init(name: Swift.String? = nil, - params: [Swift.String]? = nil) { + public init( + name: Swift.String? = nil, + params: [Swift.String]? = nil + ) { self.name = name self.params = params } - public enum CodingKeys: String, CodingKey { case name case params } } - /// - Remark: Generated from `#/components/schemas/GroupItemDTO`. public struct GroupItemDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/GroupItemDTO/type`. @@ -2584,14 +2541,16 @@ public enum Components { /// - groupNames: /// - groupType: /// - function: - public init(_type: Swift.String? = nil, - name: Swift.String? = nil, - label: Swift.String? = nil, - category: Swift.String? = nil, - tags: [Swift.String]? = nil, - groupNames: [Swift.String]? = nil, - groupType: Swift.String? = nil, - function: Components.Schemas.GroupFunctionDTO? = nil) { + public init( + _type: Swift.String? = nil, + name: Swift.String? = nil, + label: Swift.String? = nil, + category: Swift.String? = nil, + tags: [Swift.String]? = nil, + groupNames: [Swift.String]? = nil, + groupType: Swift.String? = nil, + function: Components.Schemas.GroupFunctionDTO? = nil + ) { self._type = _type self.name = name self.label = label @@ -2601,7 +2560,6 @@ public enum Components { self.groupType = groupType self.function = function } - public enum CodingKeys: String, CodingKey { case _type = "type" case name @@ -2613,7 +2571,6 @@ public enum Components { case function } } - /// - Remark: Generated from `#/components/schemas/JerseyResponseBuilderDTO`. public struct JerseyResponseBuilderDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/JerseyResponseBuilderDTO/status`. @@ -2625,18 +2582,18 @@ public enum Components { /// - Parameters: /// - status: /// - context: - public init(status: Swift.String? = nil, - context: Components.Schemas.ContextDTO? = nil) { + public init( + status: Swift.String? = nil, + context: Components.Schemas.ContextDTO? = nil + ) { self.status = status self.context = context } - public enum CodingKeys: String, CodingKey { case status case context } } - /// - Remark: Generated from `#/components/schemas/ContextDTO`. public struct ContextDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/ContextDTO/headers`. @@ -2648,12 +2605,10 @@ public enum Components { public init(headers: Components.Schemas.HeadersDTO? = nil) { self.headers = headers } - public enum CodingKeys: String, CodingKey { case headers } } - /// - Remark: Generated from `#/components/schemas/HeadersDTO`. public struct HeadersDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/HeadersDTO/Location`. @@ -2665,12 +2620,10 @@ public enum Components { public init(Location: [Swift.String]? = nil) { self.Location = Location } - public enum CodingKeys: String, CodingKey { case Location } } - /// - Remark: Generated from `#/components/schemas/Links`. public struct Links: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/Links/type`. @@ -2682,18 +2635,18 @@ public enum Components { /// - Parameters: /// - _type: /// - url: - public init(_type: Swift.String? = nil, - url: Swift.String? = nil) { + public init( + _type: Swift.String? = nil, + url: Swift.String? = nil + ) { self._type = _type self.url = url } - public enum CodingKeys: String, CodingKey { case _type = "type" case url } } - /// - Remark: Generated from `#/components/schemas/RootBean`. public struct RootBean: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/RootBean/version`. @@ -2714,18 +2667,19 @@ public enum Components { /// - measurementSystem: /// - runtimeInfo: /// - links: - public init(version: Swift.String? = nil, - locale: Swift.String? = nil, - measurementSystem: Swift.String? = nil, - runtimeInfo: Components.Schemas.RuntimeInfo? = nil, - links: [Components.Schemas.Links]? = nil) { + public init( + version: Swift.String? = nil, + locale: Swift.String? = nil, + measurementSystem: Swift.String? = nil, + runtimeInfo: Components.Schemas.RuntimeInfo? = nil, + links: [Components.Schemas.Links]? = nil + ) { self.version = version self.locale = locale self.measurementSystem = measurementSystem self.runtimeInfo = runtimeInfo self.links = links } - public enum CodingKeys: String, CodingKey { case version case locale @@ -2734,7 +2688,6 @@ public enum Components { case links } } - /// - Remark: Generated from `#/components/schemas/RuntimeInfo`. public struct RuntimeInfo: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/RuntimeInfo/version`. @@ -2746,18 +2699,18 @@ public enum Components { /// - Parameters: /// - version: /// - buildString: - public init(version: Swift.String? = nil, - buildString: Swift.String? = nil) { + public init( + version: Swift.String? = nil, + buildString: Swift.String? = nil + ) { self.version = version self.buildString = buildString } - public enum CodingKeys: String, CodingKey { case version case buildString } } - /// - Remark: Generated from `#/components/schemas/SystemInfo`. public struct SystemInfo: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/SystemInfo/configFolder`. @@ -2805,20 +2758,22 @@ public enum Components { /// - totalMemory: /// - uptime: /// - startLevel: - public init(configFolder: Swift.String? = nil, - userdataFolder: Swift.String? = nil, - logFolder: Swift.String? = nil, - javaVersion: Swift.String? = nil, - javaVendor: Swift.String? = nil, - javaVendorVersion: Swift.String? = nil, - osName: Swift.String? = nil, - osVersion: Swift.String? = nil, - osArchitecture: Swift.String? = nil, - availableProcessors: Swift.Int32? = nil, - freeMemory: Swift.Int64? = nil, - totalMemory: Swift.Int64? = nil, - uptime: Swift.Int64? = nil, - startLevel: Swift.Int32? = nil) { + public init( + configFolder: Swift.String? = nil, + userdataFolder: Swift.String? = nil, + logFolder: Swift.String? = nil, + javaVersion: Swift.String? = nil, + javaVendor: Swift.String? = nil, + javaVendorVersion: Swift.String? = nil, + osName: Swift.String? = nil, + osVersion: Swift.String? = nil, + osArchitecture: Swift.String? = nil, + availableProcessors: Swift.Int32? = nil, + freeMemory: Swift.Int64? = nil, + totalMemory: Swift.Int64? = nil, + uptime: Swift.Int64? = nil, + startLevel: Swift.Int32? = nil + ) { self.configFolder = configFolder self.userdataFolder = userdataFolder self.logFolder = logFolder @@ -2834,7 +2789,6 @@ public enum Components { self.uptime = uptime self.startLevel = startLevel } - public enum CodingKeys: String, CodingKey { case configFolder case userdataFolder @@ -2852,7 +2806,6 @@ public enum Components { case startLevel } } - /// - Remark: Generated from `#/components/schemas/SystemInfoBean`. public struct SystemInfoBean: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/SystemInfoBean/systemInfo`. @@ -2864,12 +2817,10 @@ public enum Components { public init(systemInfo: Components.Schemas.SystemInfo? = nil) { self.systemInfo = systemInfo } - public enum CodingKeys: String, CodingKey { case systemInfo } } - /// - Remark: Generated from `#/components/schemas/DimensionInfo`. public struct DimensionInfo: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/DimensionInfo/dimension`. @@ -2881,18 +2832,18 @@ public enum Components { /// - Parameters: /// - dimension: /// - systemUnit: - public init(dimension: Swift.String? = nil, - systemUnit: Swift.String? = nil) { + public init( + dimension: Swift.String? = nil, + systemUnit: Swift.String? = nil + ) { self.dimension = dimension self.systemUnit = systemUnit } - public enum CodingKeys: String, CodingKey { case dimension case systemUnit } } - /// - Remark: Generated from `#/components/schemas/UoMInfo`. public struct UoMInfo: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/UoMInfo/dimensions`. @@ -2904,12 +2855,10 @@ public enum Components { public init(dimensions: [Components.Schemas.DimensionInfo]? = nil) { self.dimensions = dimensions } - public enum CodingKeys: String, CodingKey { case dimensions } } - /// - Remark: Generated from `#/components/schemas/UoMInfoBean`. public struct UoMInfoBean: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/UoMInfoBean/uomInfo`. @@ -2921,12 +2870,10 @@ public enum Components { public init(uomInfo: Components.Schemas.UoMInfo? = nil) { self.uomInfo = uomInfo } - public enum CodingKeys: String, CodingKey { case uomInfo } } - /// - Remark: Generated from `#/components/schemas/MappingDTO`. public struct MappingDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/MappingDTO/row`. @@ -2950,12 +2897,14 @@ public enum Components { /// - releaseCommand: /// - label: /// - icon: - public init(row: Swift.Int32? = nil, - column: Swift.Int32? = nil, - command: Swift.String? = nil, - releaseCommand: Swift.String? = nil, - label: Swift.String? = nil, - icon: Swift.String? = nil) { + public init( + row: Swift.Int32? = nil, + column: Swift.Int32? = nil, + command: Swift.String? = nil, + releaseCommand: Swift.String? = nil, + label: Swift.String? = nil, + icon: Swift.String? = nil + ) { self.row = row self.column = column self.command = command @@ -2963,7 +2912,6 @@ public enum Components { self.label = label self.icon = icon } - public enum CodingKeys: String, CodingKey { case row case column @@ -2973,89 +2921,80 @@ public enum Components { case icon } } - /// - Remark: Generated from `#/components/schemas/PageDTO`. public struct PageDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/PageDTO/id`. public var id: Swift.String? { - get { - storage.value.id + get { + self.storage.value.id } _modify { - yield &storage.value.id + yield &self.storage.value.id } } - /// - Remark: Generated from `#/components/schemas/PageDTO/title`. public var title: Swift.String? { - get { - storage.value.title + get { + self.storage.value.title } _modify { - yield &storage.value.title + yield &self.storage.value.title } } - /// - Remark: Generated from `#/components/schemas/PageDTO/icon`. public var icon: Swift.String? { - get { - storage.value.icon + get { + self.storage.value.icon } _modify { - yield &storage.value.icon + yield &self.storage.value.icon } } - /// - Remark: Generated from `#/components/schemas/PageDTO/link`. public var link: Swift.String? { - get { - storage.value.link + get { + self.storage.value.link } _modify { - yield &storage.value.link + yield &self.storage.value.link } } - /// - Remark: Generated from `#/components/schemas/PageDTO/parent`. public var parent: Components.Schemas.PageDTO? { - get { - storage.value.parent + get { + self.storage.value.parent } _modify { - yield &storage.value.parent + yield &self.storage.value.parent } } - /// - Remark: Generated from `#/components/schemas/PageDTO/leaf`. public var leaf: Swift.Bool? { - get { - storage.value.leaf + get { + self.storage.value.leaf } _modify { - yield &storage.value.leaf + yield &self.storage.value.leaf } } - /// - Remark: Generated from `#/components/schemas/PageDTO/timeout`. public var timeout: Swift.Bool? { - get { - storage.value.timeout + get { + self.storage.value.timeout } _modify { - yield &storage.value.timeout + yield &self.storage.value.timeout } } - /// - Remark: Generated from `#/components/schemas/PageDTO/widgets`. public var widgets: [Components.Schemas.WidgetDTO]? { - get { - storage.value.widgets + get { + self.storage.value.widgets } _modify { - yield &storage.value.widgets + yield &self.storage.value.widgets } } - /// Creates a new `PageDTO`. /// /// - Parameters: @@ -3067,15 +3006,17 @@ public enum Components { /// - leaf: /// - timeout: /// - widgets: - public init(id: Swift.String? = nil, - title: Swift.String? = nil, - icon: Swift.String? = nil, - link: Swift.String? = nil, - parent: Components.Schemas.PageDTO? = nil, - leaf: Swift.Bool? = nil, - timeout: Swift.Bool? = nil, - widgets: [Components.Schemas.WidgetDTO]? = nil) { - storage = .init(value: .init( + public init( + id: Swift.String? = nil, + title: Swift.String? = nil, + icon: Swift.String? = nil, + link: Swift.String? = nil, + parent: Components.Schemas.PageDTO? = nil, + leaf: Swift.Bool? = nil, + timeout: Swift.Bool? = nil, + widgets: [Components.Schemas.WidgetDTO]? = nil + ) { + self.storage = .init(value: .init( id: id, title: title, icon: icon, @@ -3086,7 +3027,6 @@ public enum Components { widgets: widgets )) } - public enum CodingKeys: String, CodingKey { case id case title @@ -3097,15 +3037,12 @@ public enum Components { case timeout case widgets } - public init(from decoder: any Decoder) throws { - storage = try .init(from: decoder) + self.storage = try .init(from: decoder) } - public func encode(to encoder: any Encoder) throws { - try storage.encode(to: encoder) + try self.storage.encode(to: encoder) } - /// Internal reference storage to allow type recursion. private var storage: OpenAPIRuntime.CopyOnWriteBox private struct Storage: Codable, Hashable, Sendable { @@ -3125,14 +3062,16 @@ public enum Components { var timeout: Swift.Bool? /// - Remark: Generated from `#/components/schemas/PageDTO/widgets`. var widgets: [Components.Schemas.WidgetDTO]? - init(id: Swift.String? = nil, - title: Swift.String? = nil, - icon: Swift.String? = nil, - link: Swift.String? = nil, - parent: Components.Schemas.PageDTO? = nil, - leaf: Swift.Bool? = nil, - timeout: Swift.Bool? = nil, - widgets: [Components.Schemas.WidgetDTO]? = nil) { + init( + id: Swift.String? = nil, + title: Swift.String? = nil, + icon: Swift.String? = nil, + link: Swift.String? = nil, + parent: Components.Schemas.PageDTO? = nil, + leaf: Swift.Bool? = nil, + timeout: Swift.Bool? = nil, + widgets: [Components.Schemas.WidgetDTO]? = nil + ) { self.id = id self.title = title self.icon = icon @@ -3142,403 +3081,362 @@ public enum Components { self.timeout = timeout self.widgets = widgets } - typealias CodingKeys = Components.Schemas.PageDTO.CodingKeys } } - /// - Remark: Generated from `#/components/schemas/WidgetDTO`. public struct WidgetDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/WidgetDTO/widgetId`. public var widgetId: Swift.String? { - get { - storage.value.widgetId + get { + self.storage.value.widgetId } _modify { - yield &storage.value.widgetId + yield &self.storage.value.widgetId } } - /// - Remark: Generated from `#/components/schemas/WidgetDTO/type`. public var _type: Swift.String? { - get { - storage.value._type + get { + self.storage.value._type } _modify { - yield &storage.value._type + yield &self.storage.value._type } } - /// - Remark: Generated from `#/components/schemas/WidgetDTO/name`. public var name: Swift.String? { - get { - storage.value.name + get { + self.storage.value.name } _modify { - yield &storage.value.name + yield &self.storage.value.name } } - /// - Remark: Generated from `#/components/schemas/WidgetDTO/visibility`. public var visibility: Swift.Bool? { - get { - storage.value.visibility + get { + self.storage.value.visibility } _modify { - yield &storage.value.visibility + yield &self.storage.value.visibility } } - /// - Remark: Generated from `#/components/schemas/WidgetDTO/label`. public var label: Swift.String? { - get { - storage.value.label + get { + self.storage.value.label } _modify { - yield &storage.value.label + yield &self.storage.value.label } } - /// - Remark: Generated from `#/components/schemas/WidgetDTO/labelSource`. public var labelSource: Swift.String? { - get { - storage.value.labelSource + get { + self.storage.value.labelSource } _modify { - yield &storage.value.labelSource + yield &self.storage.value.labelSource } } - /// - Remark: Generated from `#/components/schemas/WidgetDTO/icon`. public var icon: Swift.String? { - get { - storage.value.icon + get { + self.storage.value.icon } _modify { - yield &storage.value.icon + yield &self.storage.value.icon } } - /// - Remark: Generated from `#/components/schemas/WidgetDTO/staticIcon`. public var staticIcon: Swift.Bool? { - get { - storage.value.staticIcon + get { + self.storage.value.staticIcon } _modify { - yield &storage.value.staticIcon + yield &self.storage.value.staticIcon } } - /// - Remark: Generated from `#/components/schemas/WidgetDTO/labelcolor`. public var labelcolor: Swift.String? { - get { - storage.value.labelcolor + get { + self.storage.value.labelcolor } _modify { - yield &storage.value.labelcolor + yield &self.storage.value.labelcolor } } - /// - Remark: Generated from `#/components/schemas/WidgetDTO/valuecolor`. public var valuecolor: Swift.String? { - get { - storage.value.valuecolor + get { + self.storage.value.valuecolor } _modify { - yield &storage.value.valuecolor + yield &self.storage.value.valuecolor } } - /// - Remark: Generated from `#/components/schemas/WidgetDTO/iconcolor`. public var iconcolor: Swift.String? { - get { - storage.value.iconcolor + get { + self.storage.value.iconcolor } _modify { - yield &storage.value.iconcolor + yield &self.storage.value.iconcolor } } - /// - Remark: Generated from `#/components/schemas/WidgetDTO/pattern`. public var pattern: Swift.String? { - get { - storage.value.pattern + get { + self.storage.value.pattern } _modify { - yield &storage.value.pattern + yield &self.storage.value.pattern } } - /// - Remark: Generated from `#/components/schemas/WidgetDTO/unit`. public var unit: Swift.String? { - get { - storage.value.unit + get { + self.storage.value.unit } _modify { - yield &storage.value.unit + yield &self.storage.value.unit } } - /// - Remark: Generated from `#/components/schemas/WidgetDTO/mappings`. public var mappings: [Components.Schemas.MappingDTO]? { - get { - storage.value.mappings + get { + self.storage.value.mappings } _modify { - yield &storage.value.mappings + yield &self.storage.value.mappings } } - /// - Remark: Generated from `#/components/schemas/WidgetDTO/switchSupport`. public var switchSupport: Swift.Bool? { - get { - storage.value.switchSupport + get { + self.storage.value.switchSupport } _modify { - yield &storage.value.switchSupport + yield &self.storage.value.switchSupport } } - /// - Remark: Generated from `#/components/schemas/WidgetDTO/releaseOnly`. public var releaseOnly: Swift.Bool? { - get { - storage.value.releaseOnly + get { + self.storage.value.releaseOnly } _modify { - yield &storage.value.releaseOnly + yield &self.storage.value.releaseOnly } } - /// - Remark: Generated from `#/components/schemas/WidgetDTO/sendFrequency`. public var sendFrequency: Swift.Int32? { - get { - storage.value.sendFrequency + get { + self.storage.value.sendFrequency } _modify { - yield &storage.value.sendFrequency + yield &self.storage.value.sendFrequency } } - /// - Remark: Generated from `#/components/schemas/WidgetDTO/refresh`. public var refresh: Swift.Int32? { - get { - storage.value.refresh + get { + self.storage.value.refresh } _modify { - yield &storage.value.refresh + yield &self.storage.value.refresh } } - /// - Remark: Generated from `#/components/schemas/WidgetDTO/height`. public var height: Swift.Int32? { - get { - storage.value.height + get { + self.storage.value.height } _modify { - yield &storage.value.height + yield &self.storage.value.height } } - /// - Remark: Generated from `#/components/schemas/WidgetDTO/minValue`. public var minValue: Swift.Double? { - get { - storage.value.minValue + get { + self.storage.value.minValue } _modify { - yield &storage.value.minValue + yield &self.storage.value.minValue } } - /// - Remark: Generated from `#/components/schemas/WidgetDTO/maxValue`. public var maxValue: Swift.Double? { - get { - storage.value.maxValue + get { + self.storage.value.maxValue } _modify { - yield &storage.value.maxValue + yield &self.storage.value.maxValue } } - /// - Remark: Generated from `#/components/schemas/WidgetDTO/step`. public var step: Swift.Double? { - get { - storage.value.step + get { + self.storage.value.step } _modify { - yield &storage.value.step + yield &self.storage.value.step } } - /// - Remark: Generated from `#/components/schemas/WidgetDTO/inputHint`. public var inputHint: Swift.String? { - get { - storage.value.inputHint + get { + self.storage.value.inputHint } _modify { - yield &storage.value.inputHint + yield &self.storage.value.inputHint } } - /// - Remark: Generated from `#/components/schemas/WidgetDTO/url`. public var url: Swift.String? { - get { - storage.value.url + get { + self.storage.value.url } _modify { - yield &storage.value.url + yield &self.storage.value.url } } - /// - Remark: Generated from `#/components/schemas/WidgetDTO/encoding`. public var encoding: Swift.String? { - get { - storage.value.encoding + get { + self.storage.value.encoding } _modify { - yield &storage.value.encoding + yield &self.storage.value.encoding } } - /// - Remark: Generated from `#/components/schemas/WidgetDTO/service`. public var service: Swift.String? { - get { - storage.value.service + get { + self.storage.value.service } _modify { - yield &storage.value.service + yield &self.storage.value.service } } - /// - Remark: Generated from `#/components/schemas/WidgetDTO/period`. public var period: Swift.String? { - get { - storage.value.period + get { + self.storage.value.period } _modify { - yield &storage.value.period + yield &self.storage.value.period } } - /// - Remark: Generated from `#/components/schemas/WidgetDTO/yAxisDecimalPattern`. public var yAxisDecimalPattern: Swift.String? { - get { - storage.value.yAxisDecimalPattern + get { + self.storage.value.yAxisDecimalPattern } _modify { - yield &storage.value.yAxisDecimalPattern + yield &self.storage.value.yAxisDecimalPattern } } - /// - Remark: Generated from `#/components/schemas/WidgetDTO/legend`. public var legend: Swift.Bool? { - get { - storage.value.legend + get { + self.storage.value.legend } _modify { - yield &storage.value.legend + yield &self.storage.value.legend } } - /// - Remark: Generated from `#/components/schemas/WidgetDTO/forceAsItem`. public var forceAsItem: Swift.Bool? { - get { - storage.value.forceAsItem + get { + self.storage.value.forceAsItem } _modify { - yield &storage.value.forceAsItem + yield &self.storage.value.forceAsItem } } - /// - Remark: Generated from `#/components/schemas/WidgetDTO/row`. public var row: Swift.Int32? { - get { - storage.value.row + get { + self.storage.value.row } _modify { - yield &storage.value.row + yield &self.storage.value.row } } - /// - Remark: Generated from `#/components/schemas/WidgetDTO/column`. public var column: Swift.Int32? { - get { - storage.value.column + get { + self.storage.value.column } _modify { - yield &storage.value.column + yield &self.storage.value.column } } - /// - Remark: Generated from `#/components/schemas/WidgetDTO/command`. public var command: Swift.String? { - get { - storage.value.command + get { + self.storage.value.command } _modify { - yield &storage.value.command + yield &self.storage.value.command } } - /// - Remark: Generated from `#/components/schemas/WidgetDTO/releaseCommand`. public var releaseCommand: Swift.String? { - get { - storage.value.releaseCommand + get { + self.storage.value.releaseCommand } _modify { - yield &storage.value.releaseCommand + yield &self.storage.value.releaseCommand } } - /// - Remark: Generated from `#/components/schemas/WidgetDTO/stateless`. public var stateless: Swift.Bool? { - get { - storage.value.stateless + get { + self.storage.value.stateless } _modify { - yield &storage.value.stateless + yield &self.storage.value.stateless } } - /// - Remark: Generated from `#/components/schemas/WidgetDTO/state`. public var state: Swift.String? { - get { - storage.value.state + get { + self.storage.value.state } _modify { - yield &storage.value.state + yield &self.storage.value.state } } - /// - Remark: Generated from `#/components/schemas/WidgetDTO/item`. public var item: Components.Schemas.EnrichedItemDTO? { - get { - storage.value.item + get { + self.storage.value.item } _modify { - yield &storage.value.item + yield &self.storage.value.item } } - /// - Remark: Generated from `#/components/schemas/WidgetDTO/linkedPage`. public var linkedPage: Components.Schemas.PageDTO? { - get { - storage.value.linkedPage + get { + self.storage.value.linkedPage } _modify { - yield &storage.value.linkedPage + yield &self.storage.value.linkedPage } } - /// - Remark: Generated from `#/components/schemas/WidgetDTO/widgets`. public var widgets: [Components.Schemas.WidgetDTO]? { - get { - storage.value.widgets + get { + self.storage.value.widgets } _modify { - yield &storage.value.widgets + yield &self.storage.value.widgets } } - /// Creates a new `WidgetDTO`. /// /// - Parameters: @@ -3581,46 +3479,48 @@ public enum Components { /// - item: /// - linkedPage: /// - widgets: - public init(widgetId: Swift.String? = nil, - _type: Swift.String? = nil, - name: Swift.String? = nil, - visibility: Swift.Bool? = nil, - label: Swift.String? = nil, - labelSource: Swift.String? = nil, - icon: Swift.String? = nil, - staticIcon: Swift.Bool? = nil, - labelcolor: Swift.String? = nil, - valuecolor: Swift.String? = nil, - iconcolor: Swift.String? = nil, - pattern: Swift.String? = nil, - unit: Swift.String? = nil, - mappings: [Components.Schemas.MappingDTO]? = nil, - switchSupport: Swift.Bool? = nil, - releaseOnly: Swift.Bool? = nil, - sendFrequency: Swift.Int32? = nil, - refresh: Swift.Int32? = nil, - height: Swift.Int32? = nil, - minValue: Swift.Double? = nil, - maxValue: Swift.Double? = nil, - step: Swift.Double? = nil, - inputHint: Swift.String? = nil, - url: Swift.String? = nil, - encoding: Swift.String? = nil, - service: Swift.String? = nil, - period: Swift.String? = nil, - yAxisDecimalPattern: Swift.String? = nil, - legend: Swift.Bool? = nil, - forceAsItem: Swift.Bool? = nil, - row: Swift.Int32? = nil, - column: Swift.Int32? = nil, - command: Swift.String? = nil, - releaseCommand: Swift.String? = nil, - stateless: Swift.Bool? = nil, - state: Swift.String? = nil, - item: Components.Schemas.EnrichedItemDTO? = nil, - linkedPage: Components.Schemas.PageDTO? = nil, - widgets: [Components.Schemas.WidgetDTO]? = nil) { - storage = .init(value: .init( + public init( + widgetId: Swift.String? = nil, + _type: Swift.String? = nil, + name: Swift.String? = nil, + visibility: Swift.Bool? = nil, + label: Swift.String? = nil, + labelSource: Swift.String? = nil, + icon: Swift.String? = nil, + staticIcon: Swift.Bool? = nil, + labelcolor: Swift.String? = nil, + valuecolor: Swift.String? = nil, + iconcolor: Swift.String? = nil, + pattern: Swift.String? = nil, + unit: Swift.String? = nil, + mappings: [Components.Schemas.MappingDTO]? = nil, + switchSupport: Swift.Bool? = nil, + releaseOnly: Swift.Bool? = nil, + sendFrequency: Swift.Int32? = nil, + refresh: Swift.Int32? = nil, + height: Swift.Int32? = nil, + minValue: Swift.Double? = nil, + maxValue: Swift.Double? = nil, + step: Swift.Double? = nil, + inputHint: Swift.String? = nil, + url: Swift.String? = nil, + encoding: Swift.String? = nil, + service: Swift.String? = nil, + period: Swift.String? = nil, + yAxisDecimalPattern: Swift.String? = nil, + legend: Swift.Bool? = nil, + forceAsItem: Swift.Bool? = nil, + row: Swift.Int32? = nil, + column: Swift.Int32? = nil, + command: Swift.String? = nil, + releaseCommand: Swift.String? = nil, + stateless: Swift.Bool? = nil, + state: Swift.String? = nil, + item: Components.Schemas.EnrichedItemDTO? = nil, + linkedPage: Components.Schemas.PageDTO? = nil, + widgets: [Components.Schemas.WidgetDTO]? = nil + ) { + self.storage = .init(value: .init( widgetId: widgetId, _type: _type, name: name, @@ -3662,7 +3562,6 @@ public enum Components { widgets: widgets )) } - public enum CodingKeys: String, CodingKey { case widgetId case _type = "type" @@ -3704,15 +3603,12 @@ public enum Components { case linkedPage case widgets } - public init(from decoder: any Decoder) throws { - storage = try .init(from: decoder) + self.storage = try .init(from: decoder) } - public func encode(to encoder: any Encoder) throws { - try storage.encode(to: encoder) + try self.storage.encode(to: encoder) } - /// Internal reference storage to allow type recursion. private var storage: OpenAPIRuntime.CopyOnWriteBox private struct Storage: Codable, Hashable, Sendable { @@ -3794,45 +3690,47 @@ public enum Components { var linkedPage: Components.Schemas.PageDTO? /// - Remark: Generated from `#/components/schemas/WidgetDTO/widgets`. var widgets: [Components.Schemas.WidgetDTO]? - init(widgetId: Swift.String? = nil, - _type: Swift.String? = nil, - name: Swift.String? = nil, - visibility: Swift.Bool? = nil, - label: Swift.String? = nil, - labelSource: Swift.String? = nil, - icon: Swift.String? = nil, - staticIcon: Swift.Bool? = nil, - labelcolor: Swift.String? = nil, - valuecolor: Swift.String? = nil, - iconcolor: Swift.String? = nil, - pattern: Swift.String? = nil, - unit: Swift.String? = nil, - mappings: [Components.Schemas.MappingDTO]? = nil, - switchSupport: Swift.Bool? = nil, - releaseOnly: Swift.Bool? = nil, - sendFrequency: Swift.Int32? = nil, - refresh: Swift.Int32? = nil, - height: Swift.Int32? = nil, - minValue: Swift.Double? = nil, - maxValue: Swift.Double? = nil, - step: Swift.Double? = nil, - inputHint: Swift.String? = nil, - url: Swift.String? = nil, - encoding: Swift.String? = nil, - service: Swift.String? = nil, - period: Swift.String? = nil, - yAxisDecimalPattern: Swift.String? = nil, - legend: Swift.Bool? = nil, - forceAsItem: Swift.Bool? = nil, - row: Swift.Int32? = nil, - column: Swift.Int32? = nil, - command: Swift.String? = nil, - releaseCommand: Swift.String? = nil, - stateless: Swift.Bool? = nil, - state: Swift.String? = nil, - item: Components.Schemas.EnrichedItemDTO? = nil, - linkedPage: Components.Schemas.PageDTO? = nil, - widgets: [Components.Schemas.WidgetDTO]? = nil) { + init( + widgetId: Swift.String? = nil, + _type: Swift.String? = nil, + name: Swift.String? = nil, + visibility: Swift.Bool? = nil, + label: Swift.String? = nil, + labelSource: Swift.String? = nil, + icon: Swift.String? = nil, + staticIcon: Swift.Bool? = nil, + labelcolor: Swift.String? = nil, + valuecolor: Swift.String? = nil, + iconcolor: Swift.String? = nil, + pattern: Swift.String? = nil, + unit: Swift.String? = nil, + mappings: [Components.Schemas.MappingDTO]? = nil, + switchSupport: Swift.Bool? = nil, + releaseOnly: Swift.Bool? = nil, + sendFrequency: Swift.Int32? = nil, + refresh: Swift.Int32? = nil, + height: Swift.Int32? = nil, + minValue: Swift.Double? = nil, + maxValue: Swift.Double? = nil, + step: Swift.Double? = nil, + inputHint: Swift.String? = nil, + url: Swift.String? = nil, + encoding: Swift.String? = nil, + service: Swift.String? = nil, + period: Swift.String? = nil, + yAxisDecimalPattern: Swift.String? = nil, + legend: Swift.Bool? = nil, + forceAsItem: Swift.Bool? = nil, + row: Swift.Int32? = nil, + column: Swift.Int32? = nil, + command: Swift.String? = nil, + releaseCommand: Swift.String? = nil, + stateless: Swift.Bool? = nil, + state: Swift.String? = nil, + item: Components.Schemas.EnrichedItemDTO? = nil, + linkedPage: Components.Schemas.PageDTO? = nil, + widgets: [Components.Schemas.WidgetDTO]? = nil + ) { self.widgetId = widgetId self._type = _type self.name = name @@ -3873,11 +3771,9 @@ public enum Components { self.linkedPage = linkedPage self.widgets = widgets } - typealias CodingKeys = Components.Schemas.WidgetDTO.CodingKeys } } - /// - Remark: Generated from `#/components/schemas/SitemapWidgetEvent`. public struct SitemapWidgetEvent: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/SitemapWidgetEvent/widgetId`. @@ -3925,20 +3821,22 @@ public enum Components { /// - item: /// - sitemapName: /// - pageId: - public init(widgetId: Swift.String? = nil, - label: Swift.String? = nil, - labelSource: Swift.String? = nil, - icon: Swift.String? = nil, - labelcolor: Swift.String? = nil, - valuecolor: Swift.String? = nil, - iconcolor: Swift.String? = nil, - state: Swift.String? = nil, - reloadIcon: Swift.Bool? = nil, - visibility: Swift.Bool? = nil, - descriptionChanged: Swift.Bool? = nil, - item: Components.Schemas.EnrichedItemDTO? = nil, - sitemapName: Swift.String? = nil, - pageId: Swift.String? = nil) { + public init( + widgetId: Swift.String? = nil, + label: Swift.String? = nil, + labelSource: Swift.String? = nil, + icon: Swift.String? = nil, + labelcolor: Swift.String? = nil, + valuecolor: Swift.String? = nil, + iconcolor: Swift.String? = nil, + state: Swift.String? = nil, + reloadIcon: Swift.Bool? = nil, + visibility: Swift.Bool? = nil, + descriptionChanged: Swift.Bool? = nil, + item: Components.Schemas.EnrichedItemDTO? = nil, + sitemapName: Swift.String? = nil, + pageId: Swift.String? = nil + ) { self.widgetId = widgetId self.label = label self.labelSource = labelSource @@ -3954,7 +3852,6 @@ public enum Components { self.sitemapName = sitemapName self.pageId = pageId } - public enum CodingKeys: String, CodingKey { case widgetId case label @@ -3972,7 +3869,6 @@ public enum Components { case pageId } } - /// - Remark: Generated from `#/components/schemas/SitemapDTO`. public struct SitemapDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/SitemapDTO/name`. @@ -3993,18 +3889,19 @@ public enum Components { /// - label: /// - link: /// - homepage: - public init(name: Swift.String? = nil, - icon: Swift.String? = nil, - label: Swift.String? = nil, - link: Swift.String? = nil, - homepage: Components.Schemas.PageDTO? = nil) { + public init( + name: Swift.String? = nil, + icon: Swift.String? = nil, + label: Swift.String? = nil, + link: Swift.String? = nil, + homepage: Components.Schemas.PageDTO? = nil + ) { self.name = name self.icon = icon self.label = label self.link = link self.homepage = homepage } - public enum CodingKeys: String, CodingKey { case name case icon @@ -4013,7 +3910,6 @@ public enum Components { case homepage } } - /// - Remark: Generated from `#/components/schemas/RootUIComponent`. public struct RootUIComponent: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/RootUIComponent/component`. @@ -4029,16 +3925,13 @@ public enum Components { public init(additionalProperties: [String: OpenAPIRuntime.OpenAPIObjectContainer] = .init()) { self.additionalProperties = additionalProperties } - public init(from decoder: any Decoder) throws { additionalProperties = try decoder.decodeAdditionalProperties(knownKeys: []) } - public func encode(to encoder: any Encoder) throws { try encoder.encodeAdditionalProperties(additionalProperties) } } - /// - Remark: Generated from `#/components/schemas/RootUIComponent/config`. public var config: Components.Schemas.RootUIComponent.configPayload? /// - Remark: Generated from `#/components/schemas/RootUIComponent/slots`. @@ -4052,16 +3945,13 @@ public enum Components { public init(additionalProperties: [String: [Components.Schemas.UIComponent]] = .init()) { self.additionalProperties = additionalProperties } - public init(from decoder: any Decoder) throws { additionalProperties = try decoder.decodeAdditionalProperties(knownKeys: []) } - public func encode(to encoder: any Encoder) throws { try encoder.encodeAdditionalProperties(additionalProperties) } } - /// - Remark: Generated from `#/components/schemas/RootUIComponent/slots`. public var slots: Components.Schemas.RootUIComponent.slotsPayload? /// - Remark: Generated from `#/components/schemas/RootUIComponent/uid`. @@ -4085,14 +3975,16 @@ public enum Components { /// - props: /// - timestamp: /// - _type: - public init(component: Swift.String? = nil, - config: Components.Schemas.RootUIComponent.configPayload? = nil, - slots: Components.Schemas.RootUIComponent.slotsPayload? = nil, - uid: Swift.String? = nil, - tags: [Swift.String]? = nil, - props: Components.Schemas.ConfigDescriptionDTO? = nil, - timestamp: Foundation.Date? = nil, - _type: Swift.String? = nil) { + public init( + component: Swift.String? = nil, + config: Components.Schemas.RootUIComponent.configPayload? = nil, + slots: Components.Schemas.RootUIComponent.slotsPayload? = nil, + uid: Swift.String? = nil, + tags: [Swift.String]? = nil, + props: Components.Schemas.ConfigDescriptionDTO? = nil, + timestamp: Foundation.Date? = nil, + _type: Swift.String? = nil + ) { self.component = component self.config = config self.slots = slots @@ -4102,7 +3994,6 @@ public enum Components { self.timestamp = timestamp self._type = _type } - public enum CodingKeys: String, CodingKey { case component case config @@ -4114,7 +4005,6 @@ public enum Components { case _type = "type" } } - /// - Remark: Generated from `#/components/schemas/UIComponent`. public struct UIComponent: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/UIComponent/component`. @@ -4130,16 +4020,13 @@ public enum Components { public init(additionalProperties: [String: OpenAPIRuntime.OpenAPIObjectContainer] = .init()) { self.additionalProperties = additionalProperties } - public init(from decoder: any Decoder) throws { additionalProperties = try decoder.decodeAdditionalProperties(knownKeys: []) } - public func encode(to encoder: any Encoder) throws { try encoder.encodeAdditionalProperties(additionalProperties) } } - /// - Remark: Generated from `#/components/schemas/UIComponent/config`. public var config: Components.Schemas.UIComponent.configPayload? /// - Remark: Generated from `#/components/schemas/UIComponent/type`. @@ -4150,21 +4037,21 @@ public enum Components { /// - component: /// - config: /// - _type: - public init(component: Swift.String? = nil, - config: Components.Schemas.UIComponent.configPayload? = nil, - _type: Swift.String? = nil) { + public init( + component: Swift.String? = nil, + config: Components.Schemas.UIComponent.configPayload? = nil, + _type: Swift.String? = nil + ) { self.component = component self.config = config self._type = _type } - public enum CodingKeys: String, CodingKey { case component case config case _type = "type" } } - /// - Remark: Generated from `#/components/schemas/TileDTO`. public struct TileDTO: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/TileDTO/name`. @@ -4182,16 +4069,17 @@ public enum Components { /// - url: /// - overlay: /// - imageUrl: - public init(name: Swift.String? = nil, - url: Swift.String? = nil, - overlay: Swift.String? = nil, - imageUrl: Swift.String? = nil) { + public init( + name: Swift.String? = nil, + url: Swift.String? = nil, + overlay: Swift.String? = nil, + imageUrl: Swift.String? = nil + ) { self.name = name self.url = url self.overlay = overlay self.imageUrl = imageUrl } - public enum CodingKeys: String, CodingKey { case name case url @@ -4200,7 +4088,6 @@ public enum Components { } } } - /// Types generated from the `#/components/parameters` section of the OpenAPI document. public enum Parameters {} /// Types generated from the `#/components/requestBodies` section of the OpenAPI document. @@ -4241,17 +4128,18 @@ public enum Operations { /// - tags: /// - summary: summary fields only /// - staticDataOnly: provides a cacheable list of values not expected to change regularly and honors the If-Modified-Since header, all other parameters are ignored - public init(prefix: Swift.String? = nil, - tags: [Swift.String]? = nil, - summary: Swift.Bool? = nil, - staticDataOnly: Swift.Bool? = nil) { + public init( + prefix: Swift.String? = nil, + tags: [Swift.String]? = nil, + summary: Swift.Bool? = nil, + staticDataOnly: Swift.Bool? = nil + ) { self.prefix = prefix self.tags = tags self.summary = summary self.staticDataOnly = staticDataOnly } } - public var query: Operations.getRules.Input.Query /// - Remark: Generated from `#/paths/rules/GET/header`. public struct Headers: Sendable, Hashable { @@ -4264,20 +4152,20 @@ public enum Operations { self.accept = accept } } - public var headers: Operations.getRules.Input.Headers /// Creates a new `Input`. /// /// - Parameters: /// - query: /// - headers: - public init(query: Operations.getRules.Input.Query = .init(), - headers: Operations.getRules.Input.Headers = .init()) { + public init( + query: Operations.getRules.Input.Query = .init(), + headers: Operations.getRules.Input.Headers = .init() + ) { self.query = query self.headers = headers } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/rules/GET/responses/200/content`. @@ -4292,12 +4180,11 @@ public enum Operations { get throws { switch self { case let .json(body): - body + return body } } } } - /// Received HTTP response body public var body: Operations.getRules.Output.Ok.Body /// Creates a new `Ok`. @@ -4308,7 +4195,6 @@ public enum Operations { self.body = body } } - /// OK /// /// - Remark: Generated from `#/paths//rules/get(getRules)/responses/200`. @@ -4323,7 +4209,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -4332,13 +4218,11 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } - @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -4350,16 +4234,14 @@ public enum Operations { self = .other(rawValue) } } - public var rawValue: Swift.String { switch self { case let .other(string): - string + return string case .json: - "application/json" + return "application/json" } } - public static var allCases: [Self] { [ .json @@ -4367,7 +4249,6 @@ public enum Operations { } } } - /// Creates a rule. /// /// - Remark: HTTP `POST /rules`. @@ -4380,7 +4261,6 @@ public enum Operations { /// - Remark: Generated from `#/paths/rules/POST/requestBody/content/application\/json`. case json(Components.Schemas.RuleDTO) } - public var body: Operations.createRule.Input.Body /// Creates a new `Input`. /// @@ -4390,7 +4270,6 @@ public enum Operations { self.body = body } } - @frozen public enum Output: Sendable, Hashable { public struct Created: Sendable, Hashable { /// - Remark: Generated from `#/paths/rules/POST/responses/201/headers`. @@ -4407,7 +4286,6 @@ public enum Operations { self.Location = Location } } - /// Received HTTP response headers public var headers: Operations.createRule.Output.Created.Headers /// Creates a new `Created`. @@ -4418,7 +4296,6 @@ public enum Operations { self.headers = headers } } - /// Created /// /// - Remark: Generated from `#/paths//rules/post(createRule)/responses/201`. @@ -4433,7 +4310,7 @@ public enum Operations { get throws { switch self { case let .created(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "created", @@ -4442,12 +4319,10 @@ public enum Operations { } } } - public struct BadRequest: Sendable, Hashable { /// Creates a new `BadRequest`. public init() {} } - /// Creation of the rule is refused. Missing required parameter. /// /// - Remark: Generated from `#/paths//rules/post(createRule)/responses/400`. @@ -4462,7 +4337,6 @@ public enum Operations { public static var badRequest: Self { .badRequest(.init()) } - /// The associated value of the enum case if `self` is `.badRequest`. /// /// - Throws: An error if `self` is not `.badRequest`. @@ -4471,7 +4345,7 @@ public enum Operations { get throws { switch self { case let .badRequest(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "badRequest", @@ -4480,12 +4354,10 @@ public enum Operations { } } } - public struct Conflict: Sendable, Hashable { /// Creates a new `Conflict`. public init() {} } - /// Creation of the rule is refused. Rule with the same UID already exists. /// /// - Remark: Generated from `#/paths//rules/post(createRule)/responses/409`. @@ -4500,7 +4372,6 @@ public enum Operations { public static var conflict: Self { .conflict(.init()) } - /// The associated value of the enum case if `self` is `.conflict`. /// /// - Throws: An error if `self` is not `.conflict`. @@ -4509,7 +4380,7 @@ public enum Operations { get throws { switch self { case let .conflict(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "conflict", @@ -4518,14 +4389,12 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } } - /// Sets the rule enabled status. /// /// - Remark: HTTP `POST /rules/{ruleUID}/enable`. @@ -4547,33 +4416,31 @@ public enum Operations { self.ruleUID = ruleUID } } - public var path: Operations.enableRule.Input.Path /// - Remark: Generated from `#/paths/rules/{ruleUID}/enable/POST/requestBody`. @frozen public enum Body: Sendable, Hashable { /// - Remark: Generated from `#/paths/rules/{ruleUID}/enable/POST/requestBody/content/text\/plain`. case plainText(OpenAPIRuntime.HTTPBody) } - public var body: Operations.enableRule.Input.Body /// Creates a new `Input`. /// /// - Parameters: /// - path: /// - body: - public init(path: Operations.enableRule.Input.Path, - body: Operations.enableRule.Input.Body) { + public init( + path: Operations.enableRule.Input.Path, + body: Operations.enableRule.Input.Body + ) { self.path = path self.body = body } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// Creates a new `Ok`. public init() {} } - /// OK /// /// - Remark: Generated from `#/paths//rules/{ruleUID}/enable/post(enableRule)/responses/200`. @@ -4588,7 +4455,6 @@ public enum Operations { public static var ok: Self { .ok(.init()) } - /// The associated value of the enum case if `self` is `.ok`. /// /// - Throws: An error if `self` is not `.ok`. @@ -4597,7 +4463,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -4606,12 +4472,10 @@ public enum Operations { } } } - public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } - /// Rule corresponding to the given UID does not found. /// /// - Remark: Generated from `#/paths//rules/{ruleUID}/enable/post(enableRule)/responses/404`. @@ -4626,7 +4490,6 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } - /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -4635,7 +4498,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -4644,14 +4507,12 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } } - /// Gets the rule actions. /// /// - Remark: HTTP `GET /rules/{ruleUID}/actions`. @@ -4673,7 +4534,6 @@ public enum Operations { self.ruleUID = ruleUID } } - public var path: Operations.getRuleActions.Input.Path /// - Remark: Generated from `#/paths/rules/{ruleUID}/actions/GET/header`. public struct Headers: Sendable, Hashable { @@ -4686,20 +4546,20 @@ public enum Operations { self.accept = accept } } - public var headers: Operations.getRuleActions.Input.Headers /// Creates a new `Input`. /// /// - Parameters: /// - path: /// - headers: - public init(path: Operations.getRuleActions.Input.Path, - headers: Operations.getRuleActions.Input.Headers = .init()) { + public init( + path: Operations.getRuleActions.Input.Path, + headers: Operations.getRuleActions.Input.Headers = .init() + ) { self.path = path self.headers = headers } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/rules/{ruleUID}/actions/GET/responses/200/content`. @@ -4714,12 +4574,11 @@ public enum Operations { get throws { switch self { case let .json(body): - body + return body } } } } - /// Received HTTP response body public var body: Operations.getRuleActions.Output.Ok.Body /// Creates a new `Ok`. @@ -4730,7 +4589,6 @@ public enum Operations { self.body = body } } - /// OK /// /// - Remark: Generated from `#/paths//rules/{ruleUID}/actions/get(getRuleActions)/responses/200`. @@ -4745,7 +4603,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -4754,12 +4612,10 @@ public enum Operations { } } } - public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } - /// Rule corresponding to the given UID does not found. /// /// - Remark: Generated from `#/paths//rules/{ruleUID}/actions/get(getRuleActions)/responses/404`. @@ -4774,7 +4630,6 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } - /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -4783,7 +4638,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -4792,13 +4647,11 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } - @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -4810,16 +4663,14 @@ public enum Operations { self = .other(rawValue) } } - public var rawValue: Swift.String { switch self { case let .other(string): - string + return string case .json: - "application/json" + return "application/json" } } - public static var allCases: [Self] { [ .json @@ -4827,7 +4678,6 @@ public enum Operations { } } } - /// Gets the rule corresponding to the given UID. /// /// - Remark: HTTP `GET /rules/{ruleUID}`. @@ -4849,7 +4699,6 @@ public enum Operations { self.ruleUID = ruleUID } } - public var path: Operations.getRuleById.Input.Path /// - Remark: Generated from `#/paths/rules/{ruleUID}/GET/header`. public struct Headers: Sendable, Hashable { @@ -4862,20 +4711,20 @@ public enum Operations { self.accept = accept } } - public var headers: Operations.getRuleById.Input.Headers /// Creates a new `Input`. /// /// - Parameters: /// - path: /// - headers: - public init(path: Operations.getRuleById.Input.Path, - headers: Operations.getRuleById.Input.Headers = .init()) { + public init( + path: Operations.getRuleById.Input.Path, + headers: Operations.getRuleById.Input.Headers = .init() + ) { self.path = path self.headers = headers } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/rules/{ruleUID}/GET/responses/200/content`. @@ -4890,12 +4739,11 @@ public enum Operations { get throws { switch self { case let .json(body): - body + return body } } } } - /// Received HTTP response body public var body: Operations.getRuleById.Output.Ok.Body /// Creates a new `Ok`. @@ -4906,7 +4754,6 @@ public enum Operations { self.body = body } } - /// OK /// /// - Remark: Generated from `#/paths//rules/{ruleUID}/get(getRuleById)/responses/200`. @@ -4921,7 +4768,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -4930,12 +4777,10 @@ public enum Operations { } } } - public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } - /// Rule not found /// /// - Remark: Generated from `#/paths//rules/{ruleUID}/get(getRuleById)/responses/404`. @@ -4950,7 +4795,6 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } - /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -4959,7 +4803,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -4968,13 +4812,11 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } - @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -4986,16 +4828,14 @@ public enum Operations { self = .other(rawValue) } } - public var rawValue: Swift.String { switch self { case let .other(string): - string + return string case .json: - "application/json" + return "application/json" } } - public static var allCases: [Self] { [ .json @@ -5003,7 +4843,6 @@ public enum Operations { } } } - /// Updates an existing rule corresponding to the given UID. /// /// - Remark: HTTP `PUT /rules/{ruleUID}`. @@ -5025,33 +4864,31 @@ public enum Operations { self.ruleUID = ruleUID } } - public var path: Operations.updateRule.Input.Path /// - Remark: Generated from `#/paths/rules/{ruleUID}/PUT/requestBody`. @frozen public enum Body: Sendable, Hashable { /// - Remark: Generated from `#/paths/rules/{ruleUID}/PUT/requestBody/content/application\/json`. case json(Components.Schemas.RuleDTO) } - public var body: Operations.updateRule.Input.Body /// Creates a new `Input`. /// /// - Parameters: /// - path: /// - body: - public init(path: Operations.updateRule.Input.Path, - body: Operations.updateRule.Input.Body) { + public init( + path: Operations.updateRule.Input.Path, + body: Operations.updateRule.Input.Body + ) { self.path = path self.body = body } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// Creates a new `Ok`. public init() {} } - /// OK /// /// - Remark: Generated from `#/paths//rules/{ruleUID}/put(updateRule)/responses/200`. @@ -5066,7 +4903,6 @@ public enum Operations { public static var ok: Self { .ok(.init()) } - /// The associated value of the enum case if `self` is `.ok`. /// /// - Throws: An error if `self` is not `.ok`. @@ -5075,7 +4911,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -5084,12 +4920,10 @@ public enum Operations { } } } - public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } - /// Rule corresponding to the given UID does not found. /// /// - Remark: Generated from `#/paths//rules/{ruleUID}/put(updateRule)/responses/404`. @@ -5104,7 +4938,6 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } - /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -5113,7 +4946,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -5122,14 +4955,12 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } } - /// Removes an existing rule corresponding to the given UID. /// /// - Remark: HTTP `DELETE /rules/{ruleUID}`. @@ -5151,7 +4982,6 @@ public enum Operations { self.ruleUID = ruleUID } } - public var path: Operations.deleteRule.Input.Path /// Creates a new `Input`. /// @@ -5161,13 +4991,11 @@ public enum Operations { self.path = path } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// Creates a new `Ok`. public init() {} } - /// OK /// /// - Remark: Generated from `#/paths//rules/{ruleUID}/delete(deleteRule)/responses/200`. @@ -5182,7 +5010,6 @@ public enum Operations { public static var ok: Self { .ok(.init()) } - /// The associated value of the enum case if `self` is `.ok`. /// /// - Throws: An error if `self` is not `.ok`. @@ -5191,7 +5018,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -5200,12 +5027,10 @@ public enum Operations { } } } - public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } - /// Rule corresponding to the given UID does not found. /// /// - Remark: Generated from `#/paths//rules/{ruleUID}/delete(deleteRule)/responses/404`. @@ -5220,7 +5045,6 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } - /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -5229,7 +5053,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -5238,14 +5062,12 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } } - /// Gets the rule conditions. /// /// - Remark: HTTP `GET /rules/{ruleUID}/conditions`. @@ -5267,7 +5089,6 @@ public enum Operations { self.ruleUID = ruleUID } } - public var path: Operations.getRuleConditions.Input.Path /// - Remark: Generated from `#/paths/rules/{ruleUID}/conditions/GET/header`. public struct Headers: Sendable, Hashable { @@ -5280,20 +5101,20 @@ public enum Operations { self.accept = accept } } - public var headers: Operations.getRuleConditions.Input.Headers /// Creates a new `Input`. /// /// - Parameters: /// - path: /// - headers: - public init(path: Operations.getRuleConditions.Input.Path, - headers: Operations.getRuleConditions.Input.Headers = .init()) { + public init( + path: Operations.getRuleConditions.Input.Path, + headers: Operations.getRuleConditions.Input.Headers = .init() + ) { self.path = path self.headers = headers } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/rules/{ruleUID}/conditions/GET/responses/200/content`. @@ -5308,12 +5129,11 @@ public enum Operations { get throws { switch self { case let .json(body): - body + return body } } } } - /// Received HTTP response body public var body: Operations.getRuleConditions.Output.Ok.Body /// Creates a new `Ok`. @@ -5324,7 +5144,6 @@ public enum Operations { self.body = body } } - /// OK /// /// - Remark: Generated from `#/paths//rules/{ruleUID}/conditions/get(getRuleConditions)/responses/200`. @@ -5339,7 +5158,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -5348,12 +5167,10 @@ public enum Operations { } } } - public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } - /// Rule corresponding to the given UID does not found. /// /// - Remark: Generated from `#/paths//rules/{ruleUID}/conditions/get(getRuleConditions)/responses/404`. @@ -5368,7 +5185,6 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } - /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -5377,7 +5193,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -5386,13 +5202,11 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } - @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -5404,16 +5218,14 @@ public enum Operations { self = .other(rawValue) } } - public var rawValue: Swift.String { switch self { case let .other(string): - string + return string case .json: - "application/json" + return "application/json" } } - public static var allCases: [Self] { [ .json @@ -5421,7 +5233,6 @@ public enum Operations { } } } - /// Gets the rule configuration values. /// /// - Remark: HTTP `GET /rules/{ruleUID}/config`. @@ -5443,7 +5254,6 @@ public enum Operations { self.ruleUID = ruleUID } } - public var path: Operations.getRuleConfiguration.Input.Path /// - Remark: Generated from `#/paths/rules/{ruleUID}/config/GET/header`. public struct Headers: Sendable, Hashable { @@ -5456,20 +5266,20 @@ public enum Operations { self.accept = accept } } - public var headers: Operations.getRuleConfiguration.Input.Headers /// Creates a new `Input`. /// /// - Parameters: /// - path: /// - headers: - public init(path: Operations.getRuleConfiguration.Input.Path, - headers: Operations.getRuleConfiguration.Input.Headers = .init()) { + public init( + path: Operations.getRuleConfiguration.Input.Path, + headers: Operations.getRuleConfiguration.Input.Headers = .init() + ) { self.path = path self.headers = headers } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/rules/{ruleUID}/config/GET/responses/200/content`. @@ -5484,12 +5294,11 @@ public enum Operations { get throws { switch self { case let .json(body): - body + return body } } } } - /// Received HTTP response body public var body: Operations.getRuleConfiguration.Output.Ok.Body /// Creates a new `Ok`. @@ -5500,7 +5309,6 @@ public enum Operations { self.body = body } } - /// OK /// /// - Remark: Generated from `#/paths//rules/{ruleUID}/config/get(getRuleConfiguration)/responses/200`. @@ -5515,7 +5323,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -5524,12 +5332,10 @@ public enum Operations { } } } - public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } - /// Rule corresponding to the given UID does not found. /// /// - Remark: Generated from `#/paths//rules/{ruleUID}/config/get(getRuleConfiguration)/responses/404`. @@ -5544,7 +5350,6 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } - /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -5553,7 +5358,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -5562,13 +5367,11 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } - @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -5580,16 +5383,14 @@ public enum Operations { self = .other(rawValue) } } - public var rawValue: Swift.String { switch self { case let .other(string): - string + return string case .json: - "application/json" + return "application/json" } } - public static var allCases: [Self] { [ .json @@ -5597,7 +5398,6 @@ public enum Operations { } } } - /// Sets the rule configuration values. /// /// - Remark: HTTP `PUT /rules/{ruleUID}/config`. @@ -5619,7 +5419,6 @@ public enum Operations { self.ruleUID = ruleUID } } - public var path: Operations.updateRuleConfiguration.Input.Path /// - Remark: Generated from `#/paths/rules/{ruleUID}/config/PUT/requestBody`. @frozen public enum Body: Sendable, Hashable { @@ -5634,39 +5433,35 @@ public enum Operations { public init(additionalProperties: [String: OpenAPIRuntime.OpenAPIObjectContainer] = .init()) { self.additionalProperties = additionalProperties } - public init(from decoder: any Decoder) throws { additionalProperties = try decoder.decodeAdditionalProperties(knownKeys: []) } - public func encode(to encoder: any Encoder) throws { try encoder.encodeAdditionalProperties(additionalProperties) } } - /// - Remark: Generated from `#/paths/rules/{ruleUID}/config/PUT/requestBody/content/application\/json`. case json(Operations.updateRuleConfiguration.Input.Body.jsonPayload) } - public var body: Operations.updateRuleConfiguration.Input.Body? /// Creates a new `Input`. /// /// - Parameters: /// - path: /// - body: - public init(path: Operations.updateRuleConfiguration.Input.Path, - body: Operations.updateRuleConfiguration.Input.Body? = nil) { + public init( + path: Operations.updateRuleConfiguration.Input.Path, + body: Operations.updateRuleConfiguration.Input.Body? = nil + ) { self.path = path self.body = body } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// Creates a new `Ok`. public init() {} } - /// OK /// /// - Remark: Generated from `#/paths//rules/{ruleUID}/config/put(updateRuleConfiguration)/responses/200`. @@ -5681,7 +5476,6 @@ public enum Operations { public static var ok: Self { .ok(.init()) } - /// The associated value of the enum case if `self` is `.ok`. /// /// - Throws: An error if `self` is not `.ok`. @@ -5690,7 +5484,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -5699,12 +5493,10 @@ public enum Operations { } } } - public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } - /// Rule corresponding to the given UID does not found. /// /// - Remark: Generated from `#/paths//rules/{ruleUID}/config/put(updateRuleConfiguration)/responses/404`. @@ -5719,7 +5511,6 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } - /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -5728,7 +5519,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -5737,14 +5528,12 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } } - /// Gets the rule's module corresponding to the given Category and ID. /// /// - Remark: HTTP `GET /rules/{ruleUID}/{moduleCategory}/{id}`. @@ -5772,15 +5561,16 @@ public enum Operations { /// - ruleUID: ruleUID /// - moduleCategory: moduleCategory /// - id: id - public init(ruleUID: Swift.String, - moduleCategory: Swift.String, - id: Swift.String) { + public init( + ruleUID: Swift.String, + moduleCategory: Swift.String, + id: Swift.String + ) { self.ruleUID = ruleUID self.moduleCategory = moduleCategory self.id = id } } - public var path: Operations.getRuleModuleById.Input.Path /// - Remark: Generated from `#/paths/rules/{ruleUID}/{moduleCategory}/{id}/GET/header`. public struct Headers: Sendable, Hashable { @@ -5793,20 +5583,20 @@ public enum Operations { self.accept = accept } } - public var headers: Operations.getRuleModuleById.Input.Headers /// Creates a new `Input`. /// /// - Parameters: /// - path: /// - headers: - public init(path: Operations.getRuleModuleById.Input.Path, - headers: Operations.getRuleModuleById.Input.Headers = .init()) { + public init( + path: Operations.getRuleModuleById.Input.Path, + headers: Operations.getRuleModuleById.Input.Headers = .init() + ) { self.path = path self.headers = headers } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/rules/{ruleUID}/{moduleCategory}/{id}/GET/responses/200/content`. @@ -5821,12 +5611,11 @@ public enum Operations { get throws { switch self { case let .json(body): - body + return body } } } } - /// Received HTTP response body public var body: Operations.getRuleModuleById.Output.Ok.Body /// Creates a new `Ok`. @@ -5837,7 +5626,6 @@ public enum Operations { self.body = body } } - /// OK /// /// - Remark: Generated from `#/paths//rules/{ruleUID}/{moduleCategory}/{id}/get(getRuleModuleById)/responses/200`. @@ -5852,7 +5640,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -5861,12 +5649,10 @@ public enum Operations { } } } - public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } - /// Rule corresponding to the given UID does not found or does not have a module with such Category and ID. /// /// - Remark: Generated from `#/paths//rules/{ruleUID}/{moduleCategory}/{id}/get(getRuleModuleById)/responses/404`. @@ -5881,7 +5667,6 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } - /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -5890,7 +5675,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -5899,13 +5684,11 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } - @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -5917,16 +5700,14 @@ public enum Operations { self = .other(rawValue) } } - public var rawValue: Swift.String { switch self { case let .other(string): - string + return string case .json: - "application/json" + return "application/json" } } - public static var allCases: [Self] { [ .json @@ -5934,7 +5715,6 @@ public enum Operations { } } } - /// Gets the module's configuration. /// /// - Remark: HTTP `GET /rules/{ruleUID}/{moduleCategory}/{id}/config`. @@ -5962,15 +5742,16 @@ public enum Operations { /// - ruleUID: ruleUID /// - moduleCategory: moduleCategory /// - id: id - public init(ruleUID: Swift.String, - moduleCategory: Swift.String, - id: Swift.String) { + public init( + ruleUID: Swift.String, + moduleCategory: Swift.String, + id: Swift.String + ) { self.ruleUID = ruleUID self.moduleCategory = moduleCategory self.id = id } } - public var path: Operations.getRuleModuleConfig.Input.Path /// - Remark: Generated from `#/paths/rules/{ruleUID}/{moduleCategory}/{id}/config/GET/header`. public struct Headers: Sendable, Hashable { @@ -5983,20 +5764,20 @@ public enum Operations { self.accept = accept } } - public var headers: Operations.getRuleModuleConfig.Input.Headers /// Creates a new `Input`. /// /// - Parameters: /// - path: /// - headers: - public init(path: Operations.getRuleModuleConfig.Input.Path, - headers: Operations.getRuleModuleConfig.Input.Headers = .init()) { + public init( + path: Operations.getRuleModuleConfig.Input.Path, + headers: Operations.getRuleModuleConfig.Input.Headers = .init() + ) { self.path = path self.headers = headers } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/rules/{ruleUID}/{moduleCategory}/{id}/config/GET/responses/200/content`. @@ -6011,12 +5792,11 @@ public enum Operations { get throws { switch self { case let .json(body): - body + return body } } } } - /// Received HTTP response body public var body: Operations.getRuleModuleConfig.Output.Ok.Body /// Creates a new `Ok`. @@ -6027,7 +5807,6 @@ public enum Operations { self.body = body } } - /// OK /// /// - Remark: Generated from `#/paths//rules/{ruleUID}/{moduleCategory}/{id}/config/get(getRuleModuleConfig)/responses/200`. @@ -6042,7 +5821,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -6051,12 +5830,10 @@ public enum Operations { } } } - public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } - /// Rule corresponding to the given UID does not found or does not have a module with such Category and ID. /// /// - Remark: Generated from `#/paths//rules/{ruleUID}/{moduleCategory}/{id}/config/get(getRuleModuleConfig)/responses/404`. @@ -6071,7 +5848,6 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } - /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -6080,7 +5856,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -6089,13 +5865,11 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } - @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -6107,16 +5881,14 @@ public enum Operations { self = .other(rawValue) } } - public var rawValue: Swift.String { switch self { case let .other(string): - string + return string case .json: - "application/json" + return "application/json" } } - public static var allCases: [Self] { [ .json @@ -6124,7 +5896,6 @@ public enum Operations { } } } - /// Gets the module's configuration parameter. /// /// - Remark: HTTP `GET /rules/{ruleUID}/{moduleCategory}/{id}/config/{param}`. @@ -6157,17 +5928,18 @@ public enum Operations { /// - moduleCategory: moduleCategory /// - id: id /// - param: param - public init(ruleUID: Swift.String, - moduleCategory: Swift.String, - id: Swift.String, - param: Swift.String) { + public init( + ruleUID: Swift.String, + moduleCategory: Swift.String, + id: Swift.String, + param: Swift.String + ) { self.ruleUID = ruleUID self.moduleCategory = moduleCategory self.id = id self.param = param } } - public var path: Operations.getRuleModuleConfigParameter.Input.Path /// - Remark: Generated from `#/paths/rules/{ruleUID}/{moduleCategory}/{id}/config/{param}/GET/header`. public struct Headers: Sendable, Hashable { @@ -6180,20 +5952,20 @@ public enum Operations { self.accept = accept } } - public var headers: Operations.getRuleModuleConfigParameter.Input.Headers /// Creates a new `Input`. /// /// - Parameters: /// - path: /// - headers: - public init(path: Operations.getRuleModuleConfigParameter.Input.Path, - headers: Operations.getRuleModuleConfigParameter.Input.Headers = .init()) { + public init( + path: Operations.getRuleModuleConfigParameter.Input.Path, + headers: Operations.getRuleModuleConfigParameter.Input.Headers = .init() + ) { self.path = path self.headers = headers } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/rules/{ruleUID}/{moduleCategory}/{id}/config/{param}/GET/responses/200/content`. @@ -6208,12 +5980,11 @@ public enum Operations { get throws { switch self { case let .plainText(body): - body + return body } } } } - /// Received HTTP response body public var body: Operations.getRuleModuleConfigParameter.Output.Ok.Body /// Creates a new `Ok`. @@ -6224,7 +5995,6 @@ public enum Operations { self.body = body } } - /// OK /// /// - Remark: Generated from `#/paths//rules/{ruleUID}/{moduleCategory}/{id}/config/{param}/get(getRuleModuleConfigParameter)/responses/200`. @@ -6239,7 +6009,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -6248,12 +6018,10 @@ public enum Operations { } } } - public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } - /// Rule corresponding to the given UID does not found or does not have a module with such Category and ID. /// /// - Remark: Generated from `#/paths//rules/{ruleUID}/{moduleCategory}/{id}/config/{param}/get(getRuleModuleConfigParameter)/responses/404`. @@ -6268,7 +6036,6 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } - /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -6277,7 +6044,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -6286,13 +6053,11 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } - @frozen public enum AcceptableContentType: AcceptableProtocol { case plainText case other(Swift.String) @@ -6304,16 +6069,14 @@ public enum Operations { self = .other(rawValue) } } - public var rawValue: Swift.String { switch self { case let .other(string): - string + return string case .plainText: - "text/plain" + return "text/plain" } } - public static var allCases: [Self] { [ .plainText @@ -6321,7 +6084,6 @@ public enum Operations { } } } - /// Sets the module's configuration parameter value. /// /// - Remark: HTTP `PUT /rules/{ruleUID}/{moduleCategory}/{id}/config/{param}`. @@ -6354,43 +6116,43 @@ public enum Operations { /// - moduleCategory: moduleCategory /// - id: id /// - param: param - public init(ruleUID: Swift.String, - moduleCategory: Swift.String, - id: Swift.String, - param: Swift.String) { + public init( + ruleUID: Swift.String, + moduleCategory: Swift.String, + id: Swift.String, + param: Swift.String + ) { self.ruleUID = ruleUID self.moduleCategory = moduleCategory self.id = id self.param = param } } - public var path: Operations.setRuleModuleConfigParameter.Input.Path /// - Remark: Generated from `#/paths/rules/{ruleUID}/{moduleCategory}/{id}/config/{param}/PUT/requestBody`. @frozen public enum Body: Sendable, Hashable { /// - Remark: Generated from `#/paths/rules/{ruleUID}/{moduleCategory}/{id}/config/{param}/PUT/requestBody/content/text\/plain`. case plainText(OpenAPIRuntime.HTTPBody) } - public var body: Operations.setRuleModuleConfigParameter.Input.Body /// Creates a new `Input`. /// /// - Parameters: /// - path: /// - body: - public init(path: Operations.setRuleModuleConfigParameter.Input.Path, - body: Operations.setRuleModuleConfigParameter.Input.Body) { + public init( + path: Operations.setRuleModuleConfigParameter.Input.Path, + body: Operations.setRuleModuleConfigParameter.Input.Body + ) { self.path = path self.body = body } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// Creates a new `Ok`. public init() {} } - /// OK /// /// - Remark: Generated from `#/paths//rules/{ruleUID}/{moduleCategory}/{id}/config/{param}/put(setRuleModuleConfigParameter)/responses/200`. @@ -6405,7 +6167,6 @@ public enum Operations { public static var ok: Self { .ok(.init()) } - /// The associated value of the enum case if `self` is `.ok`. /// /// - Throws: An error if `self` is not `.ok`. @@ -6414,7 +6175,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -6423,12 +6184,10 @@ public enum Operations { } } } - public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } - /// Rule corresponding to the given UID does not found or does not have a module with such Category and ID. /// /// - Remark: Generated from `#/paths//rules/{ruleUID}/{moduleCategory}/{id}/config/{param}/put(setRuleModuleConfigParameter)/responses/404`. @@ -6443,7 +6202,6 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } - /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -6452,7 +6210,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -6461,14 +6219,12 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } } - /// Gets the rule triggers. /// /// - Remark: HTTP `GET /rules/{ruleUID}/triggers`. @@ -6490,7 +6246,6 @@ public enum Operations { self.ruleUID = ruleUID } } - public var path: Operations.getRuleTriggers.Input.Path /// - Remark: Generated from `#/paths/rules/{ruleUID}/triggers/GET/header`. public struct Headers: Sendable, Hashable { @@ -6503,20 +6258,20 @@ public enum Operations { self.accept = accept } } - public var headers: Operations.getRuleTriggers.Input.Headers /// Creates a new `Input`. /// /// - Parameters: /// - path: /// - headers: - public init(path: Operations.getRuleTriggers.Input.Path, - headers: Operations.getRuleTriggers.Input.Headers = .init()) { + public init( + path: Operations.getRuleTriggers.Input.Path, + headers: Operations.getRuleTriggers.Input.Headers = .init() + ) { self.path = path self.headers = headers } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/rules/{ruleUID}/triggers/GET/responses/200/content`. @@ -6531,12 +6286,11 @@ public enum Operations { get throws { switch self { case let .json(body): - body + return body } } } } - /// Received HTTP response body public var body: Operations.getRuleTriggers.Output.Ok.Body /// Creates a new `Ok`. @@ -6547,7 +6301,6 @@ public enum Operations { self.body = body } } - /// OK /// /// - Remark: Generated from `#/paths//rules/{ruleUID}/triggers/get(getRuleTriggers)/responses/200`. @@ -6562,7 +6315,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -6571,12 +6324,10 @@ public enum Operations { } } } - public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } - /// Rule corresponding to the given UID does not found. /// /// - Remark: Generated from `#/paths//rules/{ruleUID}/triggers/get(getRuleTriggers)/responses/404`. @@ -6591,7 +6342,6 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } - /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -6600,7 +6350,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -6609,13 +6359,11 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } - @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -6627,16 +6375,14 @@ public enum Operations { self = .other(rawValue) } } - public var rawValue: Swift.String { switch self { case let .other(string): - string + return string case .json: - "application/json" + return "application/json" } } - public static var allCases: [Self] { [ .json @@ -6644,7 +6390,6 @@ public enum Operations { } } } - /// Executes actions of the rule. /// /// - Remark: HTTP `POST /rules/{ruleUID}/runnow`. @@ -6666,7 +6411,6 @@ public enum Operations { self.ruleUID = ruleUID } } - public var path: Operations.runRuleNow_1.Input.Path /// - Remark: Generated from `#/paths/rules/{ruleUID}/runnow/POST/requestBody`. @frozen public enum Body: Sendable, Hashable { @@ -6681,39 +6425,35 @@ public enum Operations { public init(additionalProperties: OpenAPIRuntime.OpenAPIObjectContainer = .init()) { self.additionalProperties = additionalProperties } - public init(from decoder: any Decoder) throws { additionalProperties = try decoder.decodeAdditionalProperties(knownKeys: []) } - public func encode(to encoder: any Encoder) throws { try encoder.encodeAdditionalProperties(additionalProperties) } } - /// - Remark: Generated from `#/paths/rules/{ruleUID}/runnow/POST/requestBody/content/application\/json`. case json(Operations.runRuleNow_1.Input.Body.jsonPayload) } - public var body: Operations.runRuleNow_1.Input.Body? /// Creates a new `Input`. /// /// - Parameters: /// - path: /// - body: - public init(path: Operations.runRuleNow_1.Input.Path, - body: Operations.runRuleNow_1.Input.Body? = nil) { + public init( + path: Operations.runRuleNow_1.Input.Path, + body: Operations.runRuleNow_1.Input.Body? = nil + ) { self.path = path self.body = body } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// Creates a new `Ok`. public init() {} } - /// OK /// /// - Remark: Generated from `#/paths//rules/{ruleUID}/runnow/post(runRuleNow_1)/responses/200`. @@ -6728,7 +6468,6 @@ public enum Operations { public static var ok: Self { .ok(.init()) } - /// The associated value of the enum case if `self` is `.ok`. /// /// - Throws: An error if `self` is not `.ok`. @@ -6737,7 +6476,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -6746,12 +6485,10 @@ public enum Operations { } } } - public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } - /// Rule corresponding to the given UID does not found. /// /// - Remark: Generated from `#/paths//rules/{ruleUID}/runnow/post(runRuleNow_1)/responses/404`. @@ -6766,7 +6503,6 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } - /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -6775,7 +6511,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -6784,14 +6520,12 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } } - /// Simulates the executions of rules filtered by tag 'Schedule' within the given times. /// /// - Remark: HTTP `GET /rules/schedule/simulations`. @@ -6814,13 +6548,14 @@ public enum Operations { /// - Parameters: /// - from: Start time of the simulated rule executions. Will default to the current time. [yyyy-MM-dd'T'HH:mm:ss.SSSZ] /// - until: End time of the simulated rule executions. Will default to 30 days after the start time. Must be less than 180 days after the given start time. [yyyy-MM-dd'T'HH:mm:ss.SSSZ] - public init(from: Swift.String? = nil, - until: Swift.String? = nil) { + public init( + from: Swift.String? = nil, + until: Swift.String? = nil + ) { self.from = from self.until = until } } - public var query: Operations.getScheduleRuleSimulations.Input.Query /// - Remark: Generated from `#/paths/rules/schedule/simulations/GET/header`. public struct Headers: Sendable, Hashable { @@ -6833,20 +6568,20 @@ public enum Operations { self.accept = accept } } - public var headers: Operations.getScheduleRuleSimulations.Input.Headers /// Creates a new `Input`. /// /// - Parameters: /// - query: /// - headers: - public init(query: Operations.getScheduleRuleSimulations.Input.Query = .init(), - headers: Operations.getScheduleRuleSimulations.Input.Headers = .init()) { + public init( + query: Operations.getScheduleRuleSimulations.Input.Query = .init(), + headers: Operations.getScheduleRuleSimulations.Input.Headers = .init() + ) { self.query = query self.headers = headers } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/rules/schedule/simulations/GET/responses/200/content`. @@ -6861,12 +6596,11 @@ public enum Operations { get throws { switch self { case let .json(body): - body + return body } } } } - /// Received HTTP response body public var body: Operations.getScheduleRuleSimulations.Output.Ok.Body /// Creates a new `Ok`. @@ -6877,7 +6611,6 @@ public enum Operations { self.body = body } } - /// OK /// /// - Remark: Generated from `#/paths//rules/schedule/simulations/get(getScheduleRuleSimulations)/responses/200`. @@ -6892,7 +6625,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -6901,12 +6634,10 @@ public enum Operations { } } } - public struct BadRequest: Sendable, Hashable { /// Creates a new `BadRequest`. public init() {} } - /// The max. simulation duration of 180 days is exceeded. /// /// - Remark: Generated from `#/paths//rules/schedule/simulations/get(getScheduleRuleSimulations)/responses/400`. @@ -6921,7 +6652,6 @@ public enum Operations { public static var badRequest: Self { .badRequest(.init()) } - /// The associated value of the enum case if `self` is `.badRequest`. /// /// - Throws: An error if `self` is not `.badRequest`. @@ -6930,7 +6660,7 @@ public enum Operations { get throws { switch self { case let .badRequest(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "badRequest", @@ -6939,13 +6669,11 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } - @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -6957,16 +6685,14 @@ public enum Operations { self = .other(rawValue) } } - public var rawValue: Swift.String { switch self { case let .other(string): - string + return string case .json: - "application/json" + return "application/json" } } - public static var allCases: [Self] { [ .json @@ -6974,7 +6700,6 @@ public enum Operations { } } } - /// Adds a new member to a group item. /// /// - Remark: HTTP `PUT /items/{itemName}/members/{memberItemName}`. @@ -6997,13 +6722,14 @@ public enum Operations { /// - Parameters: /// - itemName: item name /// - memberItemName: member item name - public init(itemName: Swift.String, - memberItemName: Swift.String) { + public init( + itemName: Swift.String, + memberItemName: Swift.String + ) { self.itemName = itemName self.memberItemName = memberItemName } } - public var path: Operations.addMemberToGroupItem.Input.Path /// Creates a new `Input`. /// @@ -7013,13 +6739,11 @@ public enum Operations { self.path = path } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// Creates a new `Ok`. public init() {} } - /// OK /// /// - Remark: Generated from `#/paths//items/{itemName}/members/{memberItemName}/put(addMemberToGroupItem)/responses/200`. @@ -7034,7 +6758,6 @@ public enum Operations { public static var ok: Self { .ok(.init()) } - /// The associated value of the enum case if `self` is `.ok`. /// /// - Throws: An error if `self` is not `.ok`. @@ -7043,7 +6766,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -7052,12 +6775,10 @@ public enum Operations { } } } - public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } - /// Item or member item not found or item is not of type group item. /// /// - Remark: Generated from `#/paths//items/{itemName}/members/{memberItemName}/put(addMemberToGroupItem)/responses/404`. @@ -7072,7 +6793,6 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } - /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -7081,7 +6801,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -7090,12 +6810,10 @@ public enum Operations { } } } - public struct MethodNotAllowed: Sendable, Hashable { /// Creates a new `MethodNotAllowed`. public init() {} } - /// Member item is not editable. /// /// - Remark: Generated from `#/paths//items/{itemName}/members/{memberItemName}/put(addMemberToGroupItem)/responses/405`. @@ -7110,7 +6828,6 @@ public enum Operations { public static var methodNotAllowed: Self { .methodNotAllowed(.init()) } - /// The associated value of the enum case if `self` is `.methodNotAllowed`. /// /// - Throws: An error if `self` is not `.methodNotAllowed`. @@ -7119,7 +6836,7 @@ public enum Operations { get throws { switch self { case let .methodNotAllowed(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "methodNotAllowed", @@ -7128,14 +6845,12 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } } - /// Removes an existing member from a group item. /// /// - Remark: HTTP `DELETE /items/{itemName}/members/{memberItemName}`. @@ -7158,13 +6873,14 @@ public enum Operations { /// - Parameters: /// - itemName: item name /// - memberItemName: member item name - public init(itemName: Swift.String, - memberItemName: Swift.String) { + public init( + itemName: Swift.String, + memberItemName: Swift.String + ) { self.itemName = itemName self.memberItemName = memberItemName } } - public var path: Operations.removeMemberFromGroupItem.Input.Path /// Creates a new `Input`. /// @@ -7174,13 +6890,11 @@ public enum Operations { self.path = path } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// Creates a new `Ok`. public init() {} } - /// OK /// /// - Remark: Generated from `#/paths//items/{itemName}/members/{memberItemName}/delete(removeMemberFromGroupItem)/responses/200`. @@ -7195,7 +6909,6 @@ public enum Operations { public static var ok: Self { .ok(.init()) } - /// The associated value of the enum case if `self` is `.ok`. /// /// - Throws: An error if `self` is not `.ok`. @@ -7204,7 +6917,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -7213,12 +6926,10 @@ public enum Operations { } } } - public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } - /// Item or member item not found or item is not of type group item. /// /// - Remark: Generated from `#/paths//items/{itemName}/members/{memberItemName}/delete(removeMemberFromGroupItem)/responses/404`. @@ -7233,7 +6944,6 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } - /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -7242,7 +6952,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -7251,12 +6961,10 @@ public enum Operations { } } } - public struct MethodNotAllowed: Sendable, Hashable { /// Creates a new `MethodNotAllowed`. public init() {} } - /// Member item is not editable. /// /// - Remark: Generated from `#/paths//items/{itemName}/members/{memberItemName}/delete(removeMemberFromGroupItem)/responses/405`. @@ -7271,7 +6979,6 @@ public enum Operations { public static var methodNotAllowed: Self { .methodNotAllowed(.init()) } - /// The associated value of the enum case if `self` is `.methodNotAllowed`. /// /// - Throws: An error if `self` is not `.methodNotAllowed`. @@ -7280,7 +6987,7 @@ public enum Operations { get throws { switch self { case let .methodNotAllowed(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "methodNotAllowed", @@ -7289,14 +6996,12 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } } - /// Adds metadata to an item. /// /// - Remark: HTTP `PUT /items/{itemname}/metadata/{namespace}`. @@ -7319,39 +7024,39 @@ public enum Operations { /// - Parameters: /// - itemname: item name /// - namespace: namespace - public init(itemname: Swift.String, - namespace: Swift.String) { + public init( + itemname: Swift.String, + namespace: Swift.String + ) { self.itemname = itemname self.namespace = namespace } } - public var path: Operations.addMetadataToItem.Input.Path /// - Remark: Generated from `#/paths/items/{itemname}/metadata/{namespace}/PUT/requestBody`. @frozen public enum Body: Sendable, Hashable { /// - Remark: Generated from `#/paths/items/{itemname}/metadata/{namespace}/PUT/requestBody/content/application\/json`. case json(Components.Schemas.MetadataDTO) } - public var body: Operations.addMetadataToItem.Input.Body /// Creates a new `Input`. /// /// - Parameters: /// - path: /// - body: - public init(path: Operations.addMetadataToItem.Input.Path, - body: Operations.addMetadataToItem.Input.Body) { + public init( + path: Operations.addMetadataToItem.Input.Path, + body: Operations.addMetadataToItem.Input.Body + ) { self.path = path self.body = body } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// Creates a new `Ok`. public init() {} } - /// OK /// /// - Remark: Generated from `#/paths//items/{itemname}/metadata/{namespace}/put(addMetadataToItem)/responses/200`. @@ -7366,7 +7071,6 @@ public enum Operations { public static var ok: Self { .ok(.init()) } - /// The associated value of the enum case if `self` is `.ok`. /// /// - Throws: An error if `self` is not `.ok`. @@ -7375,7 +7079,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -7384,12 +7088,10 @@ public enum Operations { } } } - public struct Created: Sendable, Hashable { /// Creates a new `Created`. public init() {} } - /// Created /// /// - Remark: Generated from `#/paths//items/{itemname}/metadata/{namespace}/put(addMetadataToItem)/responses/201`. @@ -7404,7 +7106,6 @@ public enum Operations { public static var created: Self { .created(.init()) } - /// The associated value of the enum case if `self` is `.created`. /// /// - Throws: An error if `self` is not `.created`. @@ -7413,7 +7114,7 @@ public enum Operations { get throws { switch self { case let .created(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "created", @@ -7422,12 +7123,10 @@ public enum Operations { } } } - public struct BadRequest: Sendable, Hashable { /// Creates a new `BadRequest`. public init() {} } - /// Metadata value empty. /// /// - Remark: Generated from `#/paths//items/{itemname}/metadata/{namespace}/put(addMetadataToItem)/responses/400`. @@ -7442,7 +7141,6 @@ public enum Operations { public static var badRequest: Self { .badRequest(.init()) } - /// The associated value of the enum case if `self` is `.badRequest`. /// /// - Throws: An error if `self` is not `.badRequest`. @@ -7451,7 +7149,7 @@ public enum Operations { get throws { switch self { case let .badRequest(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "badRequest", @@ -7460,12 +7158,10 @@ public enum Operations { } } } - public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } - /// Item not found. /// /// - Remark: Generated from `#/paths//items/{itemname}/metadata/{namespace}/put(addMetadataToItem)/responses/404`. @@ -7480,7 +7176,6 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } - /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -7489,7 +7184,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -7498,12 +7193,10 @@ public enum Operations { } } } - public struct MethodNotAllowed: Sendable, Hashable { /// Creates a new `MethodNotAllowed`. public init() {} } - /// Metadata not editable. /// /// - Remark: Generated from `#/paths//items/{itemname}/metadata/{namespace}/put(addMetadataToItem)/responses/405`. @@ -7518,7 +7211,6 @@ public enum Operations { public static var methodNotAllowed: Self { .methodNotAllowed(.init()) } - /// The associated value of the enum case if `self` is `.methodNotAllowed`. /// /// - Throws: An error if `self` is not `.methodNotAllowed`. @@ -7527,7 +7219,7 @@ public enum Operations { get throws { switch self { case let .methodNotAllowed(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "methodNotAllowed", @@ -7536,14 +7228,12 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } } - /// Removes metadata from an item. /// /// - Remark: HTTP `DELETE /items/{itemname}/metadata/{namespace}`. @@ -7566,13 +7256,14 @@ public enum Operations { /// - Parameters: /// - itemname: item name /// - namespace: namespace - public init(itemname: Swift.String, - namespace: Swift.String) { + public init( + itemname: Swift.String, + namespace: Swift.String + ) { self.itemname = itemname self.namespace = namespace } } - public var path: Operations.removeMetadataFromItem.Input.Path /// Creates a new `Input`. /// @@ -7582,13 +7273,11 @@ public enum Operations { self.path = path } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// Creates a new `Ok`. public init() {} } - /// OK /// /// - Remark: Generated from `#/paths//items/{itemname}/metadata/{namespace}/delete(removeMetadataFromItem)/responses/200`. @@ -7603,7 +7292,6 @@ public enum Operations { public static var ok: Self { .ok(.init()) } - /// The associated value of the enum case if `self` is `.ok`. /// /// - Throws: An error if `self` is not `.ok`. @@ -7612,7 +7300,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -7621,12 +7309,10 @@ public enum Operations { } } } - public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } - /// Item not found. /// /// - Remark: Generated from `#/paths//items/{itemname}/metadata/{namespace}/delete(removeMetadataFromItem)/responses/404`. @@ -7641,7 +7327,6 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } - /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -7650,7 +7335,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -7659,12 +7344,10 @@ public enum Operations { } } } - public struct MethodNotAllowed: Sendable, Hashable { /// Creates a new `MethodNotAllowed`. public init() {} } - /// Meta data not editable. /// /// - Remark: Generated from `#/paths//items/{itemname}/metadata/{namespace}/delete(removeMetadataFromItem)/responses/405`. @@ -7679,7 +7362,6 @@ public enum Operations { public static var methodNotAllowed: Self { .methodNotAllowed(.init()) } - /// The associated value of the enum case if `self` is `.methodNotAllowed`. /// /// - Throws: An error if `self` is not `.methodNotAllowed`. @@ -7688,7 +7370,7 @@ public enum Operations { get throws { switch self { case let .methodNotAllowed(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "methodNotAllowed", @@ -7697,14 +7379,12 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } } - /// Adds a tag to an item. /// /// - Remark: HTTP `PUT /items/{itemname}/tags/{tag}`. @@ -7727,13 +7407,14 @@ public enum Operations { /// - Parameters: /// - itemname: item name /// - tag: tag - public init(itemname: Swift.String, - tag: Swift.String) { + public init( + itemname: Swift.String, + tag: Swift.String + ) { self.itemname = itemname self.tag = tag } } - public var path: Operations.addTagToItem.Input.Path /// Creates a new `Input`. /// @@ -7743,13 +7424,11 @@ public enum Operations { self.path = path } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// Creates a new `Ok`. public init() {} } - /// OK /// /// - Remark: Generated from `#/paths//items/{itemname}/tags/{tag}/put(addTagToItem)/responses/200`. @@ -7764,7 +7443,6 @@ public enum Operations { public static var ok: Self { .ok(.init()) } - /// The associated value of the enum case if `self` is `.ok`. /// /// - Throws: An error if `self` is not `.ok`. @@ -7773,7 +7451,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -7782,12 +7460,10 @@ public enum Operations { } } } - public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } - /// Item not found. /// /// - Remark: Generated from `#/paths//items/{itemname}/tags/{tag}/put(addTagToItem)/responses/404`. @@ -7802,7 +7478,6 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } - /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -7811,7 +7486,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -7820,12 +7495,10 @@ public enum Operations { } } } - public struct MethodNotAllowed: Sendable, Hashable { /// Creates a new `MethodNotAllowed`. public init() {} } - /// Item not editable. /// /// - Remark: Generated from `#/paths//items/{itemname}/tags/{tag}/put(addTagToItem)/responses/405`. @@ -7840,7 +7513,6 @@ public enum Operations { public static var methodNotAllowed: Self { .methodNotAllowed(.init()) } - /// The associated value of the enum case if `self` is `.methodNotAllowed`. /// /// - Throws: An error if `self` is not `.methodNotAllowed`. @@ -7849,7 +7521,7 @@ public enum Operations { get throws { switch self { case let .methodNotAllowed(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "methodNotAllowed", @@ -7858,14 +7530,12 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } } - /// Removes a tag from an item. /// /// - Remark: HTTP `DELETE /items/{itemname}/tags/{tag}`. @@ -7888,13 +7558,14 @@ public enum Operations { /// - Parameters: /// - itemname: item name /// - tag: tag - public init(itemname: Swift.String, - tag: Swift.String) { + public init( + itemname: Swift.String, + tag: Swift.String + ) { self.itemname = itemname self.tag = tag } } - public var path: Operations.removeTagFromItem.Input.Path /// Creates a new `Input`. /// @@ -7904,13 +7575,11 @@ public enum Operations { self.path = path } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// Creates a new `Ok`. public init() {} } - /// OK /// /// - Remark: Generated from `#/paths//items/{itemname}/tags/{tag}/delete(removeTagFromItem)/responses/200`. @@ -7925,7 +7594,6 @@ public enum Operations { public static var ok: Self { .ok(.init()) } - /// The associated value of the enum case if `self` is `.ok`. /// /// - Throws: An error if `self` is not `.ok`. @@ -7934,7 +7602,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -7943,12 +7611,10 @@ public enum Operations { } } } - public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } - /// Item not found. /// /// - Remark: Generated from `#/paths//items/{itemname}/tags/{tag}/delete(removeTagFromItem)/responses/404`. @@ -7963,7 +7629,6 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } - /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -7972,7 +7637,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -7981,12 +7646,10 @@ public enum Operations { } } } - public struct MethodNotAllowed: Sendable, Hashable { /// Creates a new `MethodNotAllowed`. public init() {} } - /// Item not editable. /// /// - Remark: Generated from `#/paths//items/{itemname}/tags/{tag}/delete(removeTagFromItem)/responses/405`. @@ -8001,7 +7664,6 @@ public enum Operations { public static var methodNotAllowed: Self { .methodNotAllowed(.init()) } - /// The associated value of the enum case if `self` is `.methodNotAllowed`. /// /// - Throws: An error if `self` is not `.methodNotAllowed`. @@ -8010,7 +7672,7 @@ public enum Operations { get throws { switch self { case let .methodNotAllowed(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "methodNotAllowed", @@ -8019,14 +7681,12 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } } - /// Gets a single item. /// /// - Remark: HTTP `GET /items/{itemname}`. @@ -8048,7 +7708,6 @@ public enum Operations { self.itemname = itemname } } - public var path: Operations.getItemByName.Input.Path /// - Remark: Generated from `#/paths/items/{itemname}/GET/query`. public struct Query: Sendable, Hashable { @@ -8065,13 +7724,14 @@ public enum Operations { /// - Parameters: /// - metadata: metadata selector - a comma separated list or a regular expression (returns all if no value given) /// - recursive: get member items if the item is a group item - public init(metadata: Swift.String? = nil, - recursive: Swift.Bool? = nil) { + public init( + metadata: Swift.String? = nil, + recursive: Swift.Bool? = nil + ) { self.metadata = metadata self.recursive = recursive } } - public var query: Operations.getItemByName.Input.Query /// - Remark: Generated from `#/paths/items/{itemname}/GET/header`. public struct Headers: Sendable, Hashable { @@ -8085,13 +7745,14 @@ public enum Operations { /// - Parameters: /// - Accept_hyphen_Language: language /// - accept: - public init(Accept_hyphen_Language: Swift.String? = nil, - accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + public init( + Accept_hyphen_Language: Swift.String? = nil, + accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues() + ) { self.Accept_hyphen_Language = Accept_hyphen_Language self.accept = accept } } - public var headers: Operations.getItemByName.Input.Headers /// Creates a new `Input`. /// @@ -8099,15 +7760,16 @@ public enum Operations { /// - path: /// - query: /// - headers: - public init(path: Operations.getItemByName.Input.Path, - query: Operations.getItemByName.Input.Query = .init(), - headers: Operations.getItemByName.Input.Headers = .init()) { + public init( + path: Operations.getItemByName.Input.Path, + query: Operations.getItemByName.Input.Query = .init(), + headers: Operations.getItemByName.Input.Headers = .init() + ) { self.path = path self.query = query self.headers = headers } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/items/{itemname}/GET/responses/200/content`. @@ -8122,12 +7784,11 @@ public enum Operations { get throws { switch self { case let .json(body): - body + return body } } } } - /// Received HTTP response body public var body: Operations.getItemByName.Output.Ok.Body /// Creates a new `Ok`. @@ -8138,7 +7799,6 @@ public enum Operations { self.body = body } } - /// OK /// /// - Remark: Generated from `#/paths//items/{itemname}/get(getItemByName)/responses/200`. @@ -8153,7 +7813,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -8162,12 +7822,10 @@ public enum Operations { } } } - public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } - /// Item not found /// /// - Remark: Generated from `#/paths//items/{itemname}/get(getItemByName)/responses/404`. @@ -8182,7 +7840,6 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } - /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -8191,7 +7848,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -8200,13 +7857,11 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } - @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -8218,16 +7873,14 @@ public enum Operations { self = .other(rawValue) } } - public var rawValue: Swift.String { switch self { case let .other(string): - string + return string case .json: - "application/json" + return "application/json" } } - public static var allCases: [Self] { [ .json @@ -8235,7 +7888,6 @@ public enum Operations { } } } - /// Sends a command to an item. /// /// - Remark: HTTP `POST /items/{itemname}`. @@ -8257,33 +7909,31 @@ public enum Operations { self.itemname = itemname } } - public var path: Operations.sendItemCommand.Input.Path /// - Remark: Generated from `#/paths/items/{itemname}/POST/requestBody`. @frozen public enum Body: Sendable, Hashable { /// - Remark: Generated from `#/paths/items/{itemname}/POST/requestBody/content/text\/plain`. case plainText(OpenAPIRuntime.HTTPBody) } - public var body: Operations.sendItemCommand.Input.Body /// Creates a new `Input`. /// /// - Parameters: /// - path: /// - body: - public init(path: Operations.sendItemCommand.Input.Path, - body: Operations.sendItemCommand.Input.Body) { + public init( + path: Operations.sendItemCommand.Input.Path, + body: Operations.sendItemCommand.Input.Body + ) { self.path = path self.body = body } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// Creates a new `Ok`. public init() {} } - /// OK /// /// - Remark: Generated from `#/paths//items/{itemname}/post(sendItemCommand)/responses/200`. @@ -8298,7 +7948,6 @@ public enum Operations { public static var ok: Self { .ok(.init()) } - /// The associated value of the enum case if `self` is `.ok`. /// /// - Throws: An error if `self` is not `.ok`. @@ -8307,7 +7956,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -8316,12 +7965,10 @@ public enum Operations { } } } - public struct BadRequest: Sendable, Hashable { /// Creates a new `BadRequest`. public init() {} } - /// Item command null /// /// - Remark: Generated from `#/paths//items/{itemname}/post(sendItemCommand)/responses/400`. @@ -8336,7 +7983,6 @@ public enum Operations { public static var badRequest: Self { .badRequest(.init()) } - /// The associated value of the enum case if `self` is `.badRequest`. /// /// - Throws: An error if `self` is not `.badRequest`. @@ -8345,7 +7991,7 @@ public enum Operations { get throws { switch self { case let .badRequest(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "badRequest", @@ -8354,12 +8000,10 @@ public enum Operations { } } } - public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } - /// Item not found /// /// - Remark: Generated from `#/paths//items/{itemname}/post(sendItemCommand)/responses/404`. @@ -8374,7 +8018,6 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } - /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -8383,7 +8026,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -8392,14 +8035,12 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } } - /// Adds a new item to the registry or updates the existing item. /// /// - Remark: HTTP `PUT /items/{itemname}`. @@ -8421,7 +8062,6 @@ public enum Operations { self.itemname = itemname } } - public var path: Operations.addOrUpdateItemInRegistry.Input.Path /// - Remark: Generated from `#/paths/items/{itemname}/PUT/header`. public struct Headers: Sendable, Hashable { @@ -8435,20 +8075,20 @@ public enum Operations { /// - Parameters: /// - Accept_hyphen_Language: language /// - accept: - public init(Accept_hyphen_Language: Swift.String? = nil, - accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + public init( + Accept_hyphen_Language: Swift.String? = nil, + accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues() + ) { self.Accept_hyphen_Language = Accept_hyphen_Language self.accept = accept } } - public var headers: Operations.addOrUpdateItemInRegistry.Input.Headers /// - Remark: Generated from `#/paths/items/{itemname}/PUT/requestBody`. @frozen public enum Body: Sendable, Hashable { /// - Remark: Generated from `#/paths/items/{itemname}/PUT/requestBody/content/application\/json`. case json(Components.Schemas.GroupItemDTO) } - public var body: Operations.addOrUpdateItemInRegistry.Input.Body /// Creates a new `Input`. /// @@ -8456,15 +8096,16 @@ public enum Operations { /// - path: /// - headers: /// - body: - public init(path: Operations.addOrUpdateItemInRegistry.Input.Path, - headers: Operations.addOrUpdateItemInRegistry.Input.Headers = .init(), - body: Operations.addOrUpdateItemInRegistry.Input.Body) { + public init( + path: Operations.addOrUpdateItemInRegistry.Input.Path, + headers: Operations.addOrUpdateItemInRegistry.Input.Headers = .init(), + body: Operations.addOrUpdateItemInRegistry.Input.Body + ) { self.path = path self.headers = headers self.body = body } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/items/{itemname}/PUT/responses/200/content`. @@ -8479,12 +8120,11 @@ public enum Operations { get throws { switch self { case let .any(body): - body + return body } } } } - /// Received HTTP response body public var body: Operations.addOrUpdateItemInRegistry.Output.Ok.Body /// Creates a new `Ok`. @@ -8495,7 +8135,6 @@ public enum Operations { self.body = body } } - /// OK /// /// - Remark: Generated from `#/paths//items/{itemname}/put(addOrUpdateItemInRegistry)/responses/200`. @@ -8510,7 +8149,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -8519,12 +8158,10 @@ public enum Operations { } } } - public struct Created: Sendable, Hashable { /// Creates a new `Created`. public init() {} } - /// Item created. /// /// - Remark: Generated from `#/paths//items/{itemname}/put(addOrUpdateItemInRegistry)/responses/201`. @@ -8539,7 +8176,6 @@ public enum Operations { public static var created: Self { .created(.init()) } - /// The associated value of the enum case if `self` is `.created`. /// /// - Throws: An error if `self` is not `.created`. @@ -8548,7 +8184,7 @@ public enum Operations { get throws { switch self { case let .created(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "created", @@ -8557,12 +8193,10 @@ public enum Operations { } } } - public struct BadRequest: Sendable, Hashable { /// Creates a new `BadRequest`. public init() {} } - /// Payload invalid. /// /// - Remark: Generated from `#/paths//items/{itemname}/put(addOrUpdateItemInRegistry)/responses/400`. @@ -8577,7 +8211,6 @@ public enum Operations { public static var badRequest: Self { .badRequest(.init()) } - /// The associated value of the enum case if `self` is `.badRequest`. /// /// - Throws: An error if `self` is not `.badRequest`. @@ -8586,7 +8219,7 @@ public enum Operations { get throws { switch self { case let .badRequest(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "badRequest", @@ -8595,12 +8228,10 @@ public enum Operations { } } } - public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } - /// Item not found or name in path invalid. /// /// - Remark: Generated from `#/paths//items/{itemname}/put(addOrUpdateItemInRegistry)/responses/404`. @@ -8615,7 +8246,6 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } - /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -8624,7 +8254,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -8633,12 +8263,10 @@ public enum Operations { } } } - public struct MethodNotAllowed: Sendable, Hashable { /// Creates a new `MethodNotAllowed`. public init() {} } - /// Item not editable. /// /// - Remark: Generated from `#/paths//items/{itemname}/put(addOrUpdateItemInRegistry)/responses/405`. @@ -8653,7 +8281,6 @@ public enum Operations { public static var methodNotAllowed: Self { .methodNotAllowed(.init()) } - /// The associated value of the enum case if `self` is `.methodNotAllowed`. /// /// - Throws: An error if `self` is not `.methodNotAllowed`. @@ -8662,7 +8289,7 @@ public enum Operations { get throws { switch self { case let .methodNotAllowed(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "methodNotAllowed", @@ -8671,13 +8298,11 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } - @frozen public enum AcceptableContentType: AcceptableProtocol { case any case other(Swift.String) @@ -8689,16 +8314,14 @@ public enum Operations { self = .other(rawValue) } } - public var rawValue: Swift.String { switch self { case let .other(string): - string + return string case .any: - "*/*" + return "*/*" } } - public static var allCases: [Self] { [ .any @@ -8706,7 +8329,6 @@ public enum Operations { } } } - /// Removes an item from the registry. /// /// - Remark: HTTP `DELETE /items/{itemname}`. @@ -8728,7 +8350,6 @@ public enum Operations { self.itemname = itemname } } - public var path: Operations.removeItemFromRegistry.Input.Path /// Creates a new `Input`. /// @@ -8738,13 +8359,11 @@ public enum Operations { self.path = path } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// Creates a new `Ok`. public init() {} } - /// OK /// /// - Remark: Generated from `#/paths//items/{itemname}/delete(removeItemFromRegistry)/responses/200`. @@ -8759,7 +8378,6 @@ public enum Operations { public static var ok: Self { .ok(.init()) } - /// The associated value of the enum case if `self` is `.ok`. /// /// - Throws: An error if `self` is not `.ok`. @@ -8768,7 +8386,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -8777,12 +8395,10 @@ public enum Operations { } } } - public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } - /// Item not found or item is not editable. /// /// - Remark: Generated from `#/paths//items/{itemname}/delete(removeItemFromRegistry)/responses/404`. @@ -8797,7 +8413,6 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } - /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -8806,7 +8421,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -8815,14 +8430,12 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } } - /// Get all available items. /// /// - Remark: HTTP `GET /items`. @@ -8865,12 +8478,14 @@ public enum Operations { /// - recursive: get member items recursively /// - fields: limit output to the given fields (comma separated) /// - staticDataOnly: provides a cacheable list of values not expected to change regularly and checks the If-Modified-Since header, all other parameters are ignored except "metadata" - public init(_type: Swift.String? = nil, - tags: Swift.String? = nil, - metadata: Swift.String? = nil, - recursive: Swift.Bool? = nil, - fields: Swift.String? = nil, - staticDataOnly: Swift.Bool? = nil) { + public init( + _type: Swift.String? = nil, + tags: Swift.String? = nil, + metadata: Swift.String? = nil, + recursive: Swift.Bool? = nil, + fields: Swift.String? = nil, + staticDataOnly: Swift.Bool? = nil + ) { self._type = _type self.tags = tags self.metadata = metadata @@ -8879,7 +8494,6 @@ public enum Operations { self.staticDataOnly = staticDataOnly } } - public var query: Operations.getItems.Input.Query /// - Remark: Generated from `#/paths/items/GET/header`. public struct Headers: Sendable, Hashable { @@ -8893,26 +8507,28 @@ public enum Operations { /// - Parameters: /// - Accept_hyphen_Language: language /// - accept: - public init(Accept_hyphen_Language: Swift.String? = nil, - accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + public init( + Accept_hyphen_Language: Swift.String? = nil, + accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues() + ) { self.Accept_hyphen_Language = Accept_hyphen_Language self.accept = accept } } - public var headers: Operations.getItems.Input.Headers /// Creates a new `Input`. /// /// - Parameters: /// - query: /// - headers: - public init(query: Operations.getItems.Input.Query = .init(), - headers: Operations.getItems.Input.Headers = .init()) { + public init( + query: Operations.getItems.Input.Query = .init(), + headers: Operations.getItems.Input.Headers = .init() + ) { self.query = query self.headers = headers } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/items/GET/responses/200/content`. @@ -8927,12 +8543,11 @@ public enum Operations { get throws { switch self { case let .json(body): - body + return body } } } } - /// Received HTTP response body public var body: Operations.getItems.Output.Ok.Body /// Creates a new `Ok`. @@ -8943,7 +8558,6 @@ public enum Operations { self.body = body } } - /// OK /// /// - Remark: Generated from `#/paths//items/get(getItems)/responses/200`. @@ -8958,7 +8572,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -8967,13 +8581,11 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } - @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -8985,16 +8597,14 @@ public enum Operations { self = .other(rawValue) } } - public var rawValue: Swift.String { switch self { case let .other(string): - string + return string case .json: - "application/json" + return "application/json" } } - public static var allCases: [Self] { [ .json @@ -9002,7 +8612,6 @@ public enum Operations { } } } - /// Adds a list of items to the registry or updates the existing items. /// /// - Remark: HTTP `PUT /items`. @@ -9021,27 +8630,26 @@ public enum Operations { self.accept = accept } } - public var headers: Operations.addOrUpdateItemsInRegistry.Input.Headers /// - Remark: Generated from `#/paths/items/PUT/requestBody`. @frozen public enum Body: Sendable, Hashable { /// - Remark: Generated from `#/paths/items/PUT/requestBody/content/application\/json`. case json([Components.Schemas.GroupItemDTO]) } - public var body: Operations.addOrUpdateItemsInRegistry.Input.Body /// Creates a new `Input`. /// /// - Parameters: /// - headers: /// - body: - public init(headers: Operations.addOrUpdateItemsInRegistry.Input.Headers = .init(), - body: Operations.addOrUpdateItemsInRegistry.Input.Body) { + public init( + headers: Operations.addOrUpdateItemsInRegistry.Input.Headers = .init(), + body: Operations.addOrUpdateItemsInRegistry.Input.Body + ) { self.headers = headers self.body = body } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/items/PUT/responses/200/content`. @@ -9056,12 +8664,11 @@ public enum Operations { get throws { switch self { case let .any(body): - body + return body } } } } - /// Received HTTP response body public var body: Operations.addOrUpdateItemsInRegistry.Output.Ok.Body /// Creates a new `Ok`. @@ -9072,7 +8679,6 @@ public enum Operations { self.body = body } } - /// OK /// /// - Remark: Generated from `#/paths//items/put(addOrUpdateItemsInRegistry)/responses/200`. @@ -9087,7 +8693,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -9096,12 +8702,10 @@ public enum Operations { } } } - public struct BadRequest: Sendable, Hashable { /// Creates a new `BadRequest`. public init() {} } - /// Payload is invalid. /// /// - Remark: Generated from `#/paths//items/put(addOrUpdateItemsInRegistry)/responses/400`. @@ -9116,7 +8720,6 @@ public enum Operations { public static var badRequest: Self { .badRequest(.init()) } - /// The associated value of the enum case if `self` is `.badRequest`. /// /// - Throws: An error if `self` is not `.badRequest`. @@ -9125,7 +8728,7 @@ public enum Operations { get throws { switch self { case let .badRequest(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "badRequest", @@ -9134,13 +8737,11 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } - @frozen public enum AcceptableContentType: AcceptableProtocol { case any case other(Swift.String) @@ -9152,16 +8753,14 @@ public enum Operations { self = .other(rawValue) } } - public var rawValue: Swift.String { switch self { case let .other(string): - string + return string case .any: - "*/*" + return "*/*" } } - public static var allCases: [Self] { [ .any @@ -9169,7 +8768,6 @@ public enum Operations { } } } - /// Gets the state of an item. /// /// - Remark: HTTP `GET /items/{itemname}/state`. @@ -9191,7 +8789,6 @@ public enum Operations { self.itemname = itemname } } - public var path: Operations.getItemState_1.Input.Path /// - Remark: Generated from `#/paths/items/{itemname}/state/GET/header`. public struct Headers: Sendable, Hashable { @@ -9204,20 +8801,20 @@ public enum Operations { self.accept = accept } } - public var headers: Operations.getItemState_1.Input.Headers /// Creates a new `Input`. /// /// - Parameters: /// - path: /// - headers: - public init(path: Operations.getItemState_1.Input.Path, - headers: Operations.getItemState_1.Input.Headers = .init()) { + public init( + path: Operations.getItemState_1.Input.Path, + headers: Operations.getItemState_1.Input.Headers = .init() + ) { self.path = path self.headers = headers } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/items/{itemname}/state/GET/responses/200/content`. @@ -9232,12 +8829,11 @@ public enum Operations { get throws { switch self { case let .plainText(body): - body + return body } } } } - /// Received HTTP response body public var body: Operations.getItemState_1.Output.Ok.Body /// Creates a new `Ok`. @@ -9248,7 +8844,6 @@ public enum Operations { self.body = body } } - /// OK /// /// - Remark: Generated from `#/paths//items/{itemname}/state/get(getItemState_1)/responses/200`. @@ -9263,7 +8858,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -9272,12 +8867,10 @@ public enum Operations { } } } - public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } - /// Item not found /// /// - Remark: Generated from `#/paths//items/{itemname}/state/get(getItemState_1)/responses/404`. @@ -9292,7 +8885,6 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } - /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -9301,7 +8893,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -9310,13 +8902,11 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } - @frozen public enum AcceptableContentType: AcceptableProtocol { case plainText case other(Swift.String) @@ -9328,16 +8918,14 @@ public enum Operations { self = .other(rawValue) } } - public var rawValue: Swift.String { switch self { case let .other(string): - string + return string case .plainText: - "text/plain" + return "text/plain" } } - public static var allCases: [Self] { [ .plainText @@ -9345,7 +8933,6 @@ public enum Operations { } } } - /// Updates the state of an item. /// /// - Remark: HTTP `PUT /items/{itemname}/state`. @@ -9367,7 +8954,6 @@ public enum Operations { self.itemname = itemname } } - public var path: Operations.updateItemState.Input.Path /// - Remark: Generated from `#/paths/items/{itemname}/state/PUT/header`. public struct Headers: Sendable, Hashable { @@ -9383,14 +8969,12 @@ public enum Operations { self.Accept_hyphen_Language = Accept_hyphen_Language } } - public var headers: Operations.updateItemState.Input.Headers /// - Remark: Generated from `#/paths/items/{itemname}/state/PUT/requestBody`. @frozen public enum Body: Sendable, Hashable { /// - Remark: Generated from `#/paths/items/{itemname}/state/PUT/requestBody/content/text\/plain`. case plainText(OpenAPIRuntime.HTTPBody) } - public var body: Operations.updateItemState.Input.Body /// Creates a new `Input`. /// @@ -9398,21 +8982,21 @@ public enum Operations { /// - path: /// - headers: /// - body: - public init(path: Operations.updateItemState.Input.Path, - headers: Operations.updateItemState.Input.Headers = .init(), - body: Operations.updateItemState.Input.Body) { + public init( + path: Operations.updateItemState.Input.Path, + headers: Operations.updateItemState.Input.Headers = .init(), + body: Operations.updateItemState.Input.Body + ) { self.path = path self.headers = headers self.body = body } } - @frozen public enum Output: Sendable, Hashable { public struct Accepted: Sendable, Hashable { /// Creates a new `Accepted`. public init() {} } - /// Accepted /// /// - Remark: Generated from `#/paths//items/{itemname}/state/put(updateItemState)/responses/202`. @@ -9427,7 +9011,6 @@ public enum Operations { public static var accepted: Self { .accepted(.init()) } - /// The associated value of the enum case if `self` is `.accepted`. /// /// - Throws: An error if `self` is not `.accepted`. @@ -9436,7 +9019,7 @@ public enum Operations { get throws { switch self { case let .accepted(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "accepted", @@ -9445,12 +9028,10 @@ public enum Operations { } } } - public struct BadRequest: Sendable, Hashable { /// Creates a new `BadRequest`. public init() {} } - /// Item state null /// /// - Remark: Generated from `#/paths//items/{itemname}/state/put(updateItemState)/responses/400`. @@ -9465,7 +9046,6 @@ public enum Operations { public static var badRequest: Self { .badRequest(.init()) } - /// The associated value of the enum case if `self` is `.badRequest`. /// /// - Throws: An error if `self` is not `.badRequest`. @@ -9474,7 +9054,7 @@ public enum Operations { get throws { switch self { case let .badRequest(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "badRequest", @@ -9483,12 +9063,10 @@ public enum Operations { } } } - public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } - /// Item not found /// /// - Remark: Generated from `#/paths//items/{itemname}/state/put(updateItemState)/responses/404`. @@ -9503,7 +9081,6 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } - /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -9512,7 +9089,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -9521,14 +9098,12 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } } - /// Gets the namespace of an item. /// /// - Remark: HTTP `GET /items/{itemname}/metadata/namespaces`. @@ -9550,7 +9125,6 @@ public enum Operations { self.itemname = itemname } } - public var path: Operations.getItemNamespaces.Input.Path /// - Remark: Generated from `#/paths/items/{itemname}/metadata/namespaces/GET/header`. public struct Headers: Sendable, Hashable { @@ -9564,26 +9138,28 @@ public enum Operations { /// - Parameters: /// - Accept_hyphen_Language: language /// - accept: - public init(Accept_hyphen_Language: Swift.String? = nil, - accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + public init( + Accept_hyphen_Language: Swift.String? = nil, + accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues() + ) { self.Accept_hyphen_Language = Accept_hyphen_Language self.accept = accept } } - public var headers: Operations.getItemNamespaces.Input.Headers /// Creates a new `Input`. /// /// - Parameters: /// - path: /// - headers: - public init(path: Operations.getItemNamespaces.Input.Path, - headers: Operations.getItemNamespaces.Input.Headers = .init()) { + public init( + path: Operations.getItemNamespaces.Input.Path, + headers: Operations.getItemNamespaces.Input.Headers = .init() + ) { self.path = path self.headers = headers } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/items/{itemname}/metadata/namespaces/GET/responses/200/content`. @@ -9598,12 +9174,11 @@ public enum Operations { get throws { switch self { case let .json(body): - body + return body } } } } - /// Received HTTP response body public var body: Operations.getItemNamespaces.Output.Ok.Body /// Creates a new `Ok`. @@ -9614,7 +9189,6 @@ public enum Operations { self.body = body } } - /// OK /// /// - Remark: Generated from `#/paths//items/{itemname}/metadata/namespaces/get(getItemNamespaces)/responses/200`. @@ -9629,7 +9203,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -9638,12 +9212,10 @@ public enum Operations { } } } - public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } - /// Item not found /// /// - Remark: Generated from `#/paths//items/{itemname}/metadata/namespaces/get(getItemNamespaces)/responses/404`. @@ -9658,7 +9230,6 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } - /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -9667,7 +9238,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -9676,13 +9247,11 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } - @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -9694,16 +9263,14 @@ public enum Operations { self = .other(rawValue) } } - public var rawValue: Swift.String { switch self { case let .other(string): - string + return string case .json: - "application/json" + return "application/json" } } - public static var allCases: [Self] { [ .json @@ -9711,7 +9278,6 @@ public enum Operations { } } } - /// Gets the item which defines the requested semantics of an item. /// /// - Remark: HTTP `GET /items/{itemName}/semantic/{semanticClass}`. @@ -9734,13 +9300,14 @@ public enum Operations { /// - Parameters: /// - itemName: item name /// - semanticClass: semantic class - public init(itemName: Swift.String, - semanticClass: Swift.String) { + public init( + itemName: Swift.String, + semanticClass: Swift.String + ) { self.itemName = itemName self.semanticClass = semanticClass } } - public var path: Operations.getSemanticItem.Input.Path /// - Remark: Generated from `#/paths/items/{itemName}/semantic/{semanticClass}/GET/header`. public struct Headers: Sendable, Hashable { @@ -9756,26 +9323,25 @@ public enum Operations { self.Accept_hyphen_Language = Accept_hyphen_Language } } - public var headers: Operations.getSemanticItem.Input.Headers /// Creates a new `Input`. /// /// - Parameters: /// - path: /// - headers: - public init(path: Operations.getSemanticItem.Input.Path, - headers: Operations.getSemanticItem.Input.Headers = .init()) { + public init( + path: Operations.getSemanticItem.Input.Path, + headers: Operations.getSemanticItem.Input.Headers = .init() + ) { self.path = path self.headers = headers } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// Creates a new `Ok`. public init() {} } - /// OK /// /// - Remark: Generated from `#/paths//items/{itemName}/semantic/{semanticClass}/get(getSemanticItem)/responses/200`. @@ -9790,7 +9356,6 @@ public enum Operations { public static var ok: Self { .ok(.init()) } - /// The associated value of the enum case if `self` is `.ok`. /// /// - Throws: An error if `self` is not `.ok`. @@ -9799,7 +9364,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -9808,12 +9373,10 @@ public enum Operations { } } } - public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } - /// Item not found /// /// - Remark: Generated from `#/paths//items/{itemName}/semantic/{semanticClass}/get(getSemanticItem)/responses/404`. @@ -9828,7 +9391,6 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } - /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -9837,7 +9399,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -9846,14 +9408,12 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } } - /// Remove unused/orphaned metadata. /// /// - Remark: HTTP `POST /items/metadata/purge`. @@ -9864,13 +9424,11 @@ public enum Operations { /// Creates a new `Input`. public init() {} } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// Creates a new `Ok`. public init() {} } - /// OK /// /// - Remark: Generated from `#/paths//items/metadata/purge/post(purgeDatabase)/responses/200`. @@ -9885,7 +9443,6 @@ public enum Operations { public static var ok: Self { .ok(.init()) } - /// The associated value of the enum case if `self` is `.ok`. /// /// - Throws: An error if `self` is not `.ok`. @@ -9894,7 +9451,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -9903,14 +9460,12 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } } - /// Gets information about the runtime, the API version and links to resources. /// /// - Remark: HTTP `GET //`. @@ -9929,7 +9484,6 @@ public enum Operations { self.accept = accept } } - public var headers: Operations.getRoot.Input.Headers /// Creates a new `Input`. /// @@ -9939,7 +9493,6 @@ public enum Operations { self.headers = headers } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/GET/responses/200/content`. @@ -9954,12 +9507,11 @@ public enum Operations { get throws { switch self { case let .json(body): - body + return body } } } } - /// Received HTTP response body public var body: Operations.getRoot.Output.Ok.Body /// Creates a new `Ok`. @@ -9970,7 +9522,6 @@ public enum Operations { self.body = body } } - /// OK /// /// - Remark: Generated from `#/paths////get(getRoot)/responses/200`. @@ -9985,7 +9536,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -9994,13 +9545,11 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } - @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -10012,16 +9561,14 @@ public enum Operations { self = .other(rawValue) } } - public var rawValue: Swift.String { switch self { case let .other(string): - string + return string case .json: - "application/json" + return "application/json" } } - public static var allCases: [Self] { [ .json @@ -10029,7 +9576,6 @@ public enum Operations { } } } - /// Gets information about the system. /// /// - Remark: HTTP `GET /systeminfo`. @@ -10048,7 +9594,6 @@ public enum Operations { self.accept = accept } } - public var headers: Operations.getSystemInformation.Input.Headers /// Creates a new `Input`. /// @@ -10058,7 +9603,6 @@ public enum Operations { self.headers = headers } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/systeminfo/GET/responses/200/content`. @@ -10073,12 +9617,11 @@ public enum Operations { get throws { switch self { case let .json(body): - body + return body } } } } - /// Received HTTP response body public var body: Operations.getSystemInformation.Output.Ok.Body /// Creates a new `Ok`. @@ -10089,7 +9632,6 @@ public enum Operations { self.body = body } } - /// OK /// /// - Remark: Generated from `#/paths//systeminfo/get(getSystemInformation)/responses/200`. @@ -10104,7 +9646,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -10113,13 +9655,11 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } - @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -10131,16 +9671,14 @@ public enum Operations { self = .other(rawValue) } } - public var rawValue: Swift.String { switch self { case let .other(string): - string + return string case .json: - "application/json" + return "application/json" } } - public static var allCases: [Self] { [ .json @@ -10148,7 +9686,6 @@ public enum Operations { } } } - /// Get all supported dimensions and their system units. /// /// - Remark: HTTP `GET /systeminfo/uom`. @@ -10167,7 +9704,6 @@ public enum Operations { self.accept = accept } } - public var headers: Operations.getUoMInformation.Input.Headers /// Creates a new `Input`. /// @@ -10177,7 +9713,6 @@ public enum Operations { self.headers = headers } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/systeminfo/uom/GET/responses/200/content`. @@ -10192,12 +9727,11 @@ public enum Operations { get throws { switch self { case let .json(body): - body + return body } } } } - /// Received HTTP response body public var body: Operations.getUoMInformation.Output.Ok.Body /// Creates a new `Ok`. @@ -10208,7 +9742,6 @@ public enum Operations { self.body = body } } - /// OK /// /// - Remark: Generated from `#/paths//systeminfo/uom/get(getUoMInformation)/responses/200`. @@ -10223,7 +9756,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -10232,13 +9765,11 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } - @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -10250,16 +9781,14 @@ public enum Operations { self = .other(rawValue) } } - public var rawValue: Swift.String { switch self { case let .other(string): - string + return string case .json: - "application/json" + return "application/json" } } - public static var allCases: [Self] { [ .json @@ -10267,7 +9796,6 @@ public enum Operations { } } } - /// Creates a sitemap event subscription. /// /// - Remark: HTTP `POST /sitemaps/events/subscribe`. @@ -10286,7 +9814,6 @@ public enum Operations { self.accept = accept } } - public var headers: Operations.createSitemapEventSubscription.Input.Headers /// Creates a new `Input`. /// @@ -10296,13 +9823,11 @@ public enum Operations { self.headers = headers } } - @frozen public enum Output: Sendable, Hashable { public struct Created: Sendable, Hashable { /// Creates a new `Created`. public init() {} } - /// Subscription created. /// /// - Remark: Generated from `#/paths//sitemaps/events/subscribe/post(createSitemapEventSubscription)/responses/201`. @@ -10317,7 +9842,6 @@ public enum Operations { public static var created: Self { .created(.init()) } - /// The associated value of the enum case if `self` is `.created`. /// /// - Throws: An error if `self` is not `.created`. @@ -10326,7 +9850,7 @@ public enum Operations { get throws { switch self { case let .created(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "created", @@ -10335,7 +9859,6 @@ public enum Operations { } } } - public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/sitemaps/events/subscribe/POST/responses/200/content`. @frozen public enum Body: Sendable, Hashable { @@ -10349,12 +9872,11 @@ public enum Operations { get throws { switch self { case let .json(body): - body + return body } } } } - /// Received HTTP response body public var body: Operations.createSitemapEventSubscription.Output.Ok.Body /// Creates a new `Ok`. @@ -10365,7 +9887,6 @@ public enum Operations { self.body = body } } - /// OK /// /// - Remark: Generated from `#/paths//sitemaps/events/subscribe/post(createSitemapEventSubscription)/responses/200`. @@ -10380,7 +9901,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -10389,12 +9910,10 @@ public enum Operations { } } } - public struct ServiceUnavailable: Sendable, Hashable { /// Creates a new `ServiceUnavailable`. public init() {} } - /// Subscriptions limit reached. /// /// - Remark: Generated from `#/paths//sitemaps/events/subscribe/post(createSitemapEventSubscription)/responses/503`. @@ -10409,7 +9928,6 @@ public enum Operations { public static var serviceUnavailable: Self { .serviceUnavailable(.init()) } - /// The associated value of the enum case if `self` is `.serviceUnavailable`. /// /// - Throws: An error if `self` is not `.serviceUnavailable`. @@ -10418,7 +9936,7 @@ public enum Operations { get throws { switch self { case let .serviceUnavailable(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "serviceUnavailable", @@ -10427,13 +9945,11 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } - @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -10445,16 +9961,14 @@ public enum Operations { self = .other(rawValue) } } - public var rawValue: Swift.String { switch self { case let .other(string): - string + return string case .json: - "application/json" + return "application/json" } } - public static var allCases: [Self] { [ .json @@ -10462,7 +9976,6 @@ public enum Operations { } } } - /// Polls the data for one page of a sitemap. /// /// - Remark: HTTP `GET /sitemaps/{sitemapname}/{pageid}`. @@ -10485,13 +9998,14 @@ public enum Operations { /// - Parameters: /// - sitemapname: sitemap name /// - pageid: page id - public init(sitemapname: Swift.String, - pageid: Swift.String) { + public init( + sitemapname: Swift.String, + pageid: Swift.String + ) { self.sitemapname = sitemapname self.pageid = pageid } } - public var path: Operations.pollDataForPage.Input.Path /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/{pageid}/GET/query`. public struct Query: Sendable, Hashable { @@ -10508,13 +10022,14 @@ public enum Operations { /// - Parameters: /// - subscriptionid: subscriptionid /// - includeHidden: include hidden widgets - public init(subscriptionid: Swift.String? = nil, - includeHidden: Swift.Bool? = nil) { + public init( + subscriptionid: Swift.String? = nil, + includeHidden: Swift.Bool? = nil + ) { self.subscriptionid = subscriptionid self.includeHidden = includeHidden } } - public var query: Operations.pollDataForPage.Input.Query /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/{pageid}/GET/header`. public struct Headers: Sendable, Hashable { @@ -10533,15 +10048,16 @@ public enum Operations { /// - Accept_hyphen_Language: language /// - X_hyphen_Atmosphere_hyphen_Transport: X-Atmosphere-Transport for long polling /// - accept: - public init(Accept_hyphen_Language: Swift.String? = nil, - X_hyphen_Atmosphere_hyphen_Transport: Swift.String? = nil, - accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + public init( + Accept_hyphen_Language: Swift.String? = nil, + X_hyphen_Atmosphere_hyphen_Transport: Swift.String? = nil, + accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues() + ) { self.Accept_hyphen_Language = Accept_hyphen_Language self.X_hyphen_Atmosphere_hyphen_Transport = X_hyphen_Atmosphere_hyphen_Transport self.accept = accept } } - public var headers: Operations.pollDataForPage.Input.Headers /// Creates a new `Input`. /// @@ -10549,15 +10065,16 @@ public enum Operations { /// - path: /// - query: /// - headers: - public init(path: Operations.pollDataForPage.Input.Path, - query: Operations.pollDataForPage.Input.Query = .init(), - headers: Operations.pollDataForPage.Input.Headers = .init()) { + public init( + path: Operations.pollDataForPage.Input.Path, + query: Operations.pollDataForPage.Input.Query = .init(), + headers: Operations.pollDataForPage.Input.Headers = .init() + ) { self.path = path self.query = query self.headers = headers } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/{pageid}/GET/responses/200/content`. @@ -10572,12 +10089,11 @@ public enum Operations { get throws { switch self { case let .json(body): - body + return body } } } } - /// Received HTTP response body public var body: Operations.pollDataForPage.Output.Ok.Body /// Creates a new `Ok`. @@ -10588,7 +10104,6 @@ public enum Operations { self.body = body } } - /// OK /// /// - Remark: Generated from `#/paths//sitemaps/{sitemapname}/{pageid}/get(pollDataForPage)/responses/200`. @@ -10603,7 +10118,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -10612,12 +10127,10 @@ public enum Operations { } } } - public struct BadRequest: Sendable, Hashable { /// Creates a new `BadRequest`. public init() {} } - /// Invalid subscription id has been provided. /// /// - Remark: Generated from `#/paths//sitemaps/{sitemapname}/{pageid}/get(pollDataForPage)/responses/400`. @@ -10632,7 +10145,6 @@ public enum Operations { public static var badRequest: Self { .badRequest(.init()) } - /// The associated value of the enum case if `self` is `.badRequest`. /// /// - Throws: An error if `self` is not `.badRequest`. @@ -10641,7 +10153,7 @@ public enum Operations { get throws { switch self { case let .badRequest(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "badRequest", @@ -10650,12 +10162,10 @@ public enum Operations { } } } - public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } - /// Sitemap with requested name does not exist or page does not exist, or page refers to a non-linkable widget /// /// - Remark: Generated from `#/paths//sitemaps/{sitemapname}/{pageid}/get(pollDataForPage)/responses/404`. @@ -10670,7 +10180,6 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } - /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -10679,7 +10188,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -10688,13 +10197,11 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } - @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -10706,16 +10213,14 @@ public enum Operations { self = .other(rawValue) } } - public var rawValue: Swift.String { switch self { case let .other(string): - string + return string case .json: - "application/json" + return "application/json" } } - public static var allCases: [Self] { [ .json @@ -10723,7 +10228,6 @@ public enum Operations { } } } - /// Polls the data for a whole sitemap. Not recommended due to potentially high traffic. /// /// - Remark: HTTP `GET /sitemaps/{sitemapname}/*`. @@ -10745,7 +10249,6 @@ public enum Operations { self.sitemapname = sitemapname } } - public var path: Operations.pollDataForSitemap.Input.Path /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/*/GET/query`. public struct Query: Sendable, Hashable { @@ -10762,13 +10265,14 @@ public enum Operations { /// - Parameters: /// - subscriptionid: subscriptionid /// - includeHidden: include hidden widgets - public init(subscriptionid: Swift.String? = nil, - includeHidden: Swift.Bool? = nil) { + public init( + subscriptionid: Swift.String? = nil, + includeHidden: Swift.Bool? = nil + ) { self.subscriptionid = subscriptionid self.includeHidden = includeHidden } } - public var query: Operations.pollDataForSitemap.Input.Query /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/*/GET/header`. public struct Headers: Sendable, Hashable { @@ -10787,15 +10291,16 @@ public enum Operations { /// - Accept_hyphen_Language: language /// - X_hyphen_Atmosphere_hyphen_Transport: X-Atmosphere-Transport for long polling /// - accept: - public init(Accept_hyphen_Language: Swift.String? = nil, - X_hyphen_Atmosphere_hyphen_Transport: Swift.String? = nil, - accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + public init( + Accept_hyphen_Language: Swift.String? = nil, + X_hyphen_Atmosphere_hyphen_Transport: Swift.String? = nil, + accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues() + ) { self.Accept_hyphen_Language = Accept_hyphen_Language self.X_hyphen_Atmosphere_hyphen_Transport = X_hyphen_Atmosphere_hyphen_Transport self.accept = accept } } - public var headers: Operations.pollDataForSitemap.Input.Headers /// Creates a new `Input`. /// @@ -10803,15 +10308,16 @@ public enum Operations { /// - path: /// - query: /// - headers: - public init(path: Operations.pollDataForSitemap.Input.Path, - query: Operations.pollDataForSitemap.Input.Query = .init(), - headers: Operations.pollDataForSitemap.Input.Headers = .init()) { + public init( + path: Operations.pollDataForSitemap.Input.Path, + query: Operations.pollDataForSitemap.Input.Query = .init(), + headers: Operations.pollDataForSitemap.Input.Headers = .init() + ) { self.path = path self.query = query self.headers = headers } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/*/GET/responses/200/content`. @@ -10826,12 +10332,11 @@ public enum Operations { get throws { switch self { case let .json(body): - body + return body } } } } - /// Received HTTP response body public var body: Operations.pollDataForSitemap.Output.Ok.Body /// Creates a new `Ok`. @@ -10842,7 +10347,6 @@ public enum Operations { self.body = body } } - /// OK /// /// - Remark: Generated from `#/paths//sitemaps/{sitemapname}/*/get(pollDataForSitemap)/responses/200`. @@ -10857,7 +10361,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -10866,12 +10370,10 @@ public enum Operations { } } } - public struct BadRequest: Sendable, Hashable { /// Creates a new `BadRequest`. public init() {} } - /// Invalid subscription id has been provided. /// /// - Remark: Generated from `#/paths//sitemaps/{sitemapname}/*/get(pollDataForSitemap)/responses/400`. @@ -10886,7 +10388,6 @@ public enum Operations { public static var badRequest: Self { .badRequest(.init()) } - /// The associated value of the enum case if `self` is `.badRequest`. /// /// - Throws: An error if `self` is not `.badRequest`. @@ -10895,7 +10396,7 @@ public enum Operations { get throws { switch self { case let .badRequest(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "badRequest", @@ -10904,12 +10405,10 @@ public enum Operations { } } } - public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } - /// Sitemap with requested name does not exist /// /// - Remark: Generated from `#/paths//sitemaps/{sitemapname}/*/get(pollDataForSitemap)/responses/404`. @@ -10924,7 +10423,6 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } - /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -10933,7 +10431,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -10942,13 +10440,11 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } - @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -10960,16 +10456,14 @@ public enum Operations { self = .other(rawValue) } } - public var rawValue: Swift.String { switch self { case let .other(string): - string + return string case .json: - "application/json" + return "application/json" } } - public static var allCases: [Self] { [ .json @@ -10977,7 +10471,6 @@ public enum Operations { } } } - /// Get sitemap by name. /// /// - Remark: HTTP `GET /sitemaps/{sitemapname}`. @@ -10999,7 +10492,6 @@ public enum Operations { self.sitemapname = sitemapname } } - public var path: Operations.getSitemapByName.Input.Path /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/GET/query`. public struct Query: Sendable, Hashable { @@ -11017,15 +10509,16 @@ public enum Operations { /// - _type: /// - jsoncallback: /// - includeHidden: include hidden widgets - public init(_type: Swift.String? = nil, - jsoncallback: Swift.String? = nil, - includeHidden: Swift.Bool? = nil) { + public init( + _type: Swift.String? = nil, + jsoncallback: Swift.String? = nil, + includeHidden: Swift.Bool? = nil + ) { self._type = _type self.jsoncallback = jsoncallback self.includeHidden = includeHidden } } - public var query: Operations.getSitemapByName.Input.Query /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/GET/header`. public struct Headers: Sendable, Hashable { @@ -11039,13 +10532,14 @@ public enum Operations { /// - Parameters: /// - Accept_hyphen_Language: language /// - accept: - public init(Accept_hyphen_Language: Swift.String? = nil, - accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + public init( + Accept_hyphen_Language: Swift.String? = nil, + accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues() + ) { self.Accept_hyphen_Language = Accept_hyphen_Language self.accept = accept } } - public var headers: Operations.getSitemapByName.Input.Headers /// Creates a new `Input`. /// @@ -11053,15 +10547,16 @@ public enum Operations { /// - path: /// - query: /// - headers: - public init(path: Operations.getSitemapByName.Input.Path, - query: Operations.getSitemapByName.Input.Query = .init(), - headers: Operations.getSitemapByName.Input.Headers = .init()) { + public init( + path: Operations.getSitemapByName.Input.Path, + query: Operations.getSitemapByName.Input.Query = .init(), + headers: Operations.getSitemapByName.Input.Headers = .init() + ) { self.path = path self.query = query self.headers = headers } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/sitemaps/{sitemapname}/GET/responses/200/content`. @@ -11076,12 +10571,11 @@ public enum Operations { get throws { switch self { case let .json(body): - body + return body } } } } - /// Received HTTP response body public var body: Operations.getSitemapByName.Output.Ok.Body /// Creates a new `Ok`. @@ -11092,7 +10586,6 @@ public enum Operations { self.body = body } } - /// OK /// /// - Remark: Generated from `#/paths//sitemaps/{sitemapname}/get(getSitemapByName)/responses/200`. @@ -11107,7 +10600,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -11116,13 +10609,11 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } - @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -11134,16 +10625,14 @@ public enum Operations { self = .other(rawValue) } } - public var rawValue: Swift.String { switch self { case let .other(string): - string + return string case .json: - "application/json" + return "application/json" } } - public static var allCases: [Self] { [ .json @@ -11151,7 +10640,6 @@ public enum Operations { } } } - /// Get sitemap events for a whole sitemap. Not recommended due to potentially high traffic. /// /// - Remark: HTTP `GET /sitemaps/events/{subscriptionid}/*`. @@ -11173,7 +10661,6 @@ public enum Operations { self.subscriptionid = subscriptionid } } - public var path: Operations.getSitemapEvents.Input.Path /// - Remark: Generated from `#/paths/sitemaps/events/{subscriptionid}/*/GET/query`. public struct Query: Sendable, Hashable { @@ -11189,26 +10676,25 @@ public enum Operations { self.sitemap = sitemap } } - public var query: Operations.getSitemapEvents.Input.Query /// Creates a new `Input`. /// /// - Parameters: /// - path: /// - query: - public init(path: Operations.getSitemapEvents.Input.Path, - query: Operations.getSitemapEvents.Input.Query = .init()) { + public init( + path: Operations.getSitemapEvents.Input.Path, + query: Operations.getSitemapEvents.Input.Query = .init() + ) { self.path = path self.query = query } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// Creates a new `Ok`. public init() {} } - /// OK /// /// - Remark: Generated from `#/paths//sitemaps/events/{subscriptionid}/*/get(getSitemapEvents)/responses/200`. @@ -11223,7 +10709,6 @@ public enum Operations { public static var ok: Self { .ok(.init()) } - /// The associated value of the enum case if `self` is `.ok`. /// /// - Throws: An error if `self` is not `.ok`. @@ -11232,7 +10717,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -11241,12 +10726,10 @@ public enum Operations { } } } - public struct BadRequest: Sendable, Hashable { /// Creates a new `BadRequest`. public init() {} } - /// Missing sitemap parameter, or sitemap not linked successfully to the subscription. /// /// - Remark: Generated from `#/paths//sitemaps/events/{subscriptionid}/*/get(getSitemapEvents)/responses/400`. @@ -11261,7 +10744,6 @@ public enum Operations { public static var badRequest: Self { .badRequest(.init()) } - /// The associated value of the enum case if `self` is `.badRequest`. /// /// - Throws: An error if `self` is not `.badRequest`. @@ -11270,7 +10752,7 @@ public enum Operations { get throws { switch self { case let .badRequest(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "badRequest", @@ -11279,12 +10761,10 @@ public enum Operations { } } } - public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } - /// Subscription not found. /// /// - Remark: Generated from `#/paths//sitemaps/events/{subscriptionid}/*/get(getSitemapEvents)/responses/404`. @@ -11299,7 +10779,6 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } - /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -11308,7 +10787,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -11317,14 +10796,12 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } } - /// Get sitemap events. /// /// - Remark: HTTP `GET /sitemaps/events/{subscriptionid}`. @@ -11346,7 +10823,6 @@ public enum Operations { self.subscriptionid = subscriptionid } } - public var path: Operations.getSitemapEvents_1.Input.Path /// - Remark: Generated from `#/paths/sitemaps/events/{subscriptionid}/GET/query`. public struct Query: Sendable, Hashable { @@ -11363,13 +10839,14 @@ public enum Operations { /// - Parameters: /// - sitemap: sitemap name /// - pageid: page id - public init(sitemap: Swift.String? = nil, - pageid: Swift.String? = nil) { + public init( + sitemap: Swift.String? = nil, + pageid: Swift.String? = nil + ) { self.sitemap = sitemap self.pageid = pageid } } - public var query: Operations.getSitemapEvents_1.Input.Query /// - Remark: Generated from `#/paths/sitemaps/events/{subscriptionid}/GET/header`. public struct Headers: Sendable, Hashable { @@ -11382,7 +10859,6 @@ public enum Operations { self.accept = accept } } - public var headers: Operations.getSitemapEvents_1.Input.Headers /// Creates a new `Input`. /// @@ -11390,15 +10866,16 @@ public enum Operations { /// - path: /// - query: /// - headers: - public init(path: Operations.getSitemapEvents_1.Input.Path, - query: Operations.getSitemapEvents_1.Input.Query = .init(), - headers: Operations.getSitemapEvents_1.Input.Headers = .init()) { + public init( + path: Operations.getSitemapEvents_1.Input.Path, + query: Operations.getSitemapEvents_1.Input.Query = .init(), + headers: Operations.getSitemapEvents_1.Input.Headers = .init() + ) { self.path = path self.query = query self.headers = headers } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/sitemaps/events/{subscriptionid}/GET/responses/200/content`. @@ -11413,7 +10890,7 @@ public enum Operations { get throws { switch self { case let .text_event_hyphen_stream(body): - body + return body default: try throwUnexpectedResponseBody( expectedContent: "text/event-stream", @@ -11422,7 +10899,6 @@ public enum Operations { } } } - /// - Remark: Generated from `#/paths/sitemaps/events/{subscriptionid}/GET/responses/200/content/application\/json`. case json(Components.Schemas.SitemapWidgetEvent) /// The associated value of the enum case if `self` is `.json`. @@ -11433,7 +10909,7 @@ public enum Operations { get throws { switch self { case let .json(body): - body + return body default: try throwUnexpectedResponseBody( expectedContent: "application/json", @@ -11443,7 +10919,6 @@ public enum Operations { } } } - /// Received HTTP response body public var body: Operations.getSitemapEvents_1.Output.Ok.Body /// Creates a new `Ok`. @@ -11454,7 +10929,6 @@ public enum Operations { self.body = body } } - /// OK /// /// - Remark: Generated from `#/paths//sitemaps/events/{subscriptionid}/get(getSitemapEvents_1)/responses/200`. @@ -11469,7 +10943,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -11478,12 +10952,10 @@ public enum Operations { } } } - public struct BadRequest: Sendable, Hashable { /// Creates a new `BadRequest`. public init() {} } - /// Missing sitemap or page parameter, or page not linked successfully to the subscription. /// /// - Remark: Generated from `#/paths//sitemaps/events/{subscriptionid}/get(getSitemapEvents_1)/responses/400`. @@ -11498,7 +10970,6 @@ public enum Operations { public static var badRequest: Self { .badRequest(.init()) } - /// The associated value of the enum case if `self` is `.badRequest`. /// /// - Throws: An error if `self` is not `.badRequest`. @@ -11507,7 +10978,7 @@ public enum Operations { get throws { switch self { case let .badRequest(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "badRequest", @@ -11516,12 +10987,10 @@ public enum Operations { } } } - public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } - /// Subscription not found. /// /// - Remark: Generated from `#/paths//sitemaps/events/{subscriptionid}/get(getSitemapEvents_1)/responses/404`. @@ -11536,7 +11005,6 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } - /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -11545,7 +11013,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -11554,13 +11022,11 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } - @frozen public enum AcceptableContentType: AcceptableProtocol { case text_event_hyphen_stream case json @@ -11575,18 +11041,16 @@ public enum Operations { self = .other(rawValue) } } - public var rawValue: Swift.String { switch self { case let .other(string): - string + return string case .text_event_hyphen_stream: - "text/event-stream" + return "text/event-stream" case .json: - "application/json" + return "application/json" } } - public static var allCases: [Self] { [ .text_event_hyphen_stream, @@ -11595,7 +11059,6 @@ public enum Operations { } } } - /// Get all available sitemaps. /// /// - Remark: HTTP `GET /sitemaps`. @@ -11614,7 +11077,6 @@ public enum Operations { self.accept = accept } } - public var headers: Operations.getSitemaps.Input.Headers /// Creates a new `Input`. /// @@ -11624,7 +11086,6 @@ public enum Operations { self.headers = headers } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/sitemaps/GET/responses/200/content`. @@ -11639,12 +11100,11 @@ public enum Operations { get throws { switch self { case let .json(body): - body + return body } } } } - /// Received HTTP response body public var body: Operations.getSitemaps.Output.Ok.Body /// Creates a new `Ok`. @@ -11655,7 +11115,6 @@ public enum Operations { self.body = body } } - /// OK /// /// - Remark: Generated from `#/paths//sitemaps/get(getSitemaps)/responses/200`. @@ -11670,7 +11129,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -11679,13 +11138,11 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } - @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -11697,16 +11154,14 @@ public enum Operations { self = .other(rawValue) } } - public var rawValue: Swift.String { switch self { case let .other(string): - string + return string case .json: - "application/json" + return "application/json" } } - public static var allCases: [Self] { [ .json @@ -11714,7 +11169,6 @@ public enum Operations { } } } - /// Get all registered UI components in the specified namespace. /// /// - Remark: HTTP `GET /ui/components/{namespace}`. @@ -11734,7 +11188,6 @@ public enum Operations { self.namespace = namespace } } - public var path: Operations.getRegisteredUIComponentsInNamespace.Input.Path /// - Remark: Generated from `#/paths/ui/components/{namespace}/GET/query`. public struct Query: Sendable, Hashable { @@ -11750,7 +11203,6 @@ public enum Operations { self.summary = summary } } - public var query: Operations.getRegisteredUIComponentsInNamespace.Input.Query /// - Remark: Generated from `#/paths/ui/components/{namespace}/GET/header`. public struct Headers: Sendable, Hashable { @@ -11763,7 +11215,6 @@ public enum Operations { self.accept = accept } } - public var headers: Operations.getRegisteredUIComponentsInNamespace.Input.Headers /// Creates a new `Input`. /// @@ -11771,15 +11222,16 @@ public enum Operations { /// - path: /// - query: /// - headers: - public init(path: Operations.getRegisteredUIComponentsInNamespace.Input.Path, - query: Operations.getRegisteredUIComponentsInNamespace.Input.Query = .init(), - headers: Operations.getRegisteredUIComponentsInNamespace.Input.Headers = .init()) { + public init( + path: Operations.getRegisteredUIComponentsInNamespace.Input.Path, + query: Operations.getRegisteredUIComponentsInNamespace.Input.Query = .init(), + headers: Operations.getRegisteredUIComponentsInNamespace.Input.Headers = .init() + ) { self.path = path self.query = query self.headers = headers } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/ui/components/{namespace}/GET/responses/200/content`. @@ -11794,12 +11246,11 @@ public enum Operations { get throws { switch self { case let .json(body): - body + return body } } } } - /// Received HTTP response body public var body: Operations.getRegisteredUIComponentsInNamespace.Output.Ok.Body /// Creates a new `Ok`. @@ -11810,7 +11261,6 @@ public enum Operations { self.body = body } } - /// OK /// /// - Remark: Generated from `#/paths//ui/components/{namespace}/get(getRegisteredUIComponentsInNamespace)/responses/200`. @@ -11825,7 +11275,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -11834,13 +11284,11 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } - @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -11852,16 +11300,14 @@ public enum Operations { self = .other(rawValue) } } - public var rawValue: Swift.String { switch self { case let .other(string): - string + return string case .json: - "application/json" + return "application/json" } } - public static var allCases: [Self] { [ .json @@ -11869,7 +11315,6 @@ public enum Operations { } } } - /// Add a UI component in the specified namespace. /// /// - Remark: HTTP `POST /ui/components/{namespace}`. @@ -11889,7 +11334,6 @@ public enum Operations { self.namespace = namespace } } - public var path: Operations.addUIComponentToNamespace.Input.Path /// - Remark: Generated from `#/paths/ui/components/{namespace}/POST/header`. public struct Headers: Sendable, Hashable { @@ -11902,14 +11346,12 @@ public enum Operations { self.accept = accept } } - public var headers: Operations.addUIComponentToNamespace.Input.Headers /// - Remark: Generated from `#/paths/ui/components/{namespace}/POST/requestBody`. @frozen public enum Body: Sendable, Hashable { /// - Remark: Generated from `#/paths/ui/components/{namespace}/POST/requestBody/content/application\/json`. case json(Components.Schemas.RootUIComponent) } - public var body: Operations.addUIComponentToNamespace.Input.Body? /// Creates a new `Input`. /// @@ -11917,15 +11359,16 @@ public enum Operations { /// - path: /// - headers: /// - body: - public init(path: Operations.addUIComponentToNamespace.Input.Path, - headers: Operations.addUIComponentToNamespace.Input.Headers = .init(), - body: Operations.addUIComponentToNamespace.Input.Body? = nil) { + public init( + path: Operations.addUIComponentToNamespace.Input.Path, + headers: Operations.addUIComponentToNamespace.Input.Headers = .init(), + body: Operations.addUIComponentToNamespace.Input.Body? = nil + ) { self.path = path self.headers = headers self.body = body } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/ui/components/{namespace}/POST/responses/200/content`. @@ -11940,12 +11383,11 @@ public enum Operations { get throws { switch self { case let .json(body): - body + return body } } } } - /// Received HTTP response body public var body: Operations.addUIComponentToNamespace.Output.Ok.Body /// Creates a new `Ok`. @@ -11956,7 +11398,6 @@ public enum Operations { self.body = body } } - /// OK /// /// - Remark: Generated from `#/paths//ui/components/{namespace}/post(addUIComponentToNamespace)/responses/200`. @@ -11971,7 +11412,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -11980,13 +11421,11 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } - @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -11998,16 +11437,14 @@ public enum Operations { self = .other(rawValue) } } - public var rawValue: Swift.String { switch self { case let .other(string): - string + return string case .json: - "application/json" + return "application/json" } } - public static var allCases: [Self] { [ .json @@ -12015,7 +11452,6 @@ public enum Operations { } } } - /// Get a specific UI component in the specified namespace. /// /// - Remark: HTTP `GET /ui/components/{namespace}/{componentUID}`. @@ -12034,13 +11470,14 @@ public enum Operations { /// - Parameters: /// - namespace: /// - componentUID: - public init(namespace: Swift.String, - componentUID: Swift.String) { + public init( + namespace: Swift.String, + componentUID: Swift.String + ) { self.namespace = namespace self.componentUID = componentUID } } - public var path: Operations.getUIComponentInNamespace.Input.Path /// - Remark: Generated from `#/paths/ui/components/{namespace}/{componentUID}/GET/header`. public struct Headers: Sendable, Hashable { @@ -12053,20 +11490,20 @@ public enum Operations { self.accept = accept } } - public var headers: Operations.getUIComponentInNamespace.Input.Headers /// Creates a new `Input`. /// /// - Parameters: /// - path: /// - headers: - public init(path: Operations.getUIComponentInNamespace.Input.Path, - headers: Operations.getUIComponentInNamespace.Input.Headers = .init()) { + public init( + path: Operations.getUIComponentInNamespace.Input.Path, + headers: Operations.getUIComponentInNamespace.Input.Headers = .init() + ) { self.path = path self.headers = headers } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/ui/components/{namespace}/{componentUID}/GET/responses/200/content`. @@ -12081,12 +11518,11 @@ public enum Operations { get throws { switch self { case let .json(body): - body + return body } } } } - /// Received HTTP response body public var body: Operations.getUIComponentInNamespace.Output.Ok.Body /// Creates a new `Ok`. @@ -12097,7 +11533,6 @@ public enum Operations { self.body = body } } - /// OK /// /// - Remark: Generated from `#/paths//ui/components/{namespace}/{componentUID}/get(getUIComponentInNamespace)/responses/200`. @@ -12112,7 +11547,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -12121,12 +11556,10 @@ public enum Operations { } } } - public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } - /// Component not found /// /// - Remark: Generated from `#/paths//ui/components/{namespace}/{componentUID}/get(getUIComponentInNamespace)/responses/404`. @@ -12141,7 +11574,6 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } - /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -12150,7 +11582,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -12159,13 +11591,11 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } - @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -12177,16 +11607,14 @@ public enum Operations { self = .other(rawValue) } } - public var rawValue: Swift.String { switch self { case let .other(string): - string + return string case .json: - "application/json" + return "application/json" } } - public static var allCases: [Self] { [ .json @@ -12194,7 +11622,6 @@ public enum Operations { } } } - /// Update a specific UI component in the specified namespace. /// /// - Remark: HTTP `PUT /ui/components/{namespace}/{componentUID}`. @@ -12213,13 +11640,14 @@ public enum Operations { /// - Parameters: /// - namespace: /// - componentUID: - public init(namespace: Swift.String, - componentUID: Swift.String) { + public init( + namespace: Swift.String, + componentUID: Swift.String + ) { self.namespace = namespace self.componentUID = componentUID } } - public var path: Operations.updateUIComponentInNamespace.Input.Path /// - Remark: Generated from `#/paths/ui/components/{namespace}/{componentUID}/PUT/header`. public struct Headers: Sendable, Hashable { @@ -12232,14 +11660,12 @@ public enum Operations { self.accept = accept } } - public var headers: Operations.updateUIComponentInNamespace.Input.Headers /// - Remark: Generated from `#/paths/ui/components/{namespace}/{componentUID}/PUT/requestBody`. @frozen public enum Body: Sendable, Hashable { /// - Remark: Generated from `#/paths/ui/components/{namespace}/{componentUID}/PUT/requestBody/content/application\/json`. case json(Components.Schemas.RootUIComponent) } - public var body: Operations.updateUIComponentInNamespace.Input.Body? /// Creates a new `Input`. /// @@ -12247,15 +11673,16 @@ public enum Operations { /// - path: /// - headers: /// - body: - public init(path: Operations.updateUIComponentInNamespace.Input.Path, - headers: Operations.updateUIComponentInNamespace.Input.Headers = .init(), - body: Operations.updateUIComponentInNamespace.Input.Body? = nil) { + public init( + path: Operations.updateUIComponentInNamespace.Input.Path, + headers: Operations.updateUIComponentInNamespace.Input.Headers = .init(), + body: Operations.updateUIComponentInNamespace.Input.Body? = nil + ) { self.path = path self.headers = headers self.body = body } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/ui/components/{namespace}/{componentUID}/PUT/responses/200/content`. @@ -12270,12 +11697,11 @@ public enum Operations { get throws { switch self { case let .json(body): - body + return body } } } } - /// Received HTTP response body public var body: Operations.updateUIComponentInNamespace.Output.Ok.Body /// Creates a new `Ok`. @@ -12286,7 +11712,6 @@ public enum Operations { self.body = body } } - /// OK /// /// - Remark: Generated from `#/paths//ui/components/{namespace}/{componentUID}/put(updateUIComponentInNamespace)/responses/200`. @@ -12301,7 +11726,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -12310,12 +11735,10 @@ public enum Operations { } } } - public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } - /// Component not found /// /// - Remark: Generated from `#/paths//ui/components/{namespace}/{componentUID}/put(updateUIComponentInNamespace)/responses/404`. @@ -12330,7 +11753,6 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } - /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -12339,7 +11761,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -12348,13 +11770,11 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } - @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -12366,16 +11786,14 @@ public enum Operations { self = .other(rawValue) } } - public var rawValue: Swift.String { switch self { case let .other(string): - string + return string case .json: - "application/json" + return "application/json" } } - public static var allCases: [Self] { [ .json @@ -12383,7 +11801,6 @@ public enum Operations { } } } - /// Remove a specific UI component in the specified namespace. /// /// - Remark: HTTP `DELETE /ui/components/{namespace}/{componentUID}`. @@ -12402,13 +11819,14 @@ public enum Operations { /// - Parameters: /// - namespace: /// - componentUID: - public init(namespace: Swift.String, - componentUID: Swift.String) { + public init( + namespace: Swift.String, + componentUID: Swift.String + ) { self.namespace = namespace self.componentUID = componentUID } } - public var path: Operations.removeUIComponentFromNamespace.Input.Path /// Creates a new `Input`. /// @@ -12418,13 +11836,11 @@ public enum Operations { self.path = path } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// Creates a new `Ok`. public init() {} } - /// OK /// /// - Remark: Generated from `#/paths//ui/components/{namespace}/{componentUID}/delete(removeUIComponentFromNamespace)/responses/200`. @@ -12439,7 +11855,6 @@ public enum Operations { public static var ok: Self { .ok(.init()) } - /// The associated value of the enum case if `self` is `.ok`. /// /// - Throws: An error if `self` is not `.ok`. @@ -12448,7 +11863,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -12457,12 +11872,10 @@ public enum Operations { } } } - public struct NotFound: Sendable, Hashable { /// Creates a new `NotFound`. public init() {} } - /// Component not found /// /// - Remark: Generated from `#/paths//ui/components/{namespace}/{componentUID}/delete(removeUIComponentFromNamespace)/responses/404`. @@ -12477,7 +11890,6 @@ public enum Operations { public static var notFound: Self { .notFound(.init()) } - /// The associated value of the enum case if `self` is `.notFound`. /// /// - Throws: An error if `self` is not `.notFound`. @@ -12486,7 +11898,7 @@ public enum Operations { get throws { switch self { case let .notFound(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "notFound", @@ -12495,14 +11907,12 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } } - /// Get all registered UI tiles. /// /// - Remark: HTTP `GET /ui/tiles`. @@ -12521,7 +11931,6 @@ public enum Operations { self.accept = accept } } - public var headers: Operations.getUITiles.Input.Headers /// Creates a new `Input`. /// @@ -12531,7 +11940,6 @@ public enum Operations { self.headers = headers } } - @frozen public enum Output: Sendable, Hashable { public struct Ok: Sendable, Hashable { /// - Remark: Generated from `#/paths/ui/tiles/GET/responses/200/content`. @@ -12546,12 +11954,11 @@ public enum Operations { get throws { switch self { case let .json(body): - body + return body } } } } - /// Received HTTP response body public var body: Operations.getUITiles.Output.Ok.Body /// Creates a new `Ok`. @@ -12562,7 +11969,6 @@ public enum Operations { self.body = body } } - /// OK /// /// - Remark: Generated from `#/paths//ui/tiles/get(getUITiles)/responses/200`. @@ -12577,7 +11983,7 @@ public enum Operations { get throws { switch self { case let .ok(response): - response + return response default: try throwUnexpectedResponseStatus( expectedStatus: "ok", @@ -12586,13 +11992,11 @@ public enum Operations { } } } - /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) } - @frozen public enum AcceptableContentType: AcceptableProtocol { case json case other(Swift.String) @@ -12604,16 +12008,14 @@ public enum Operations { self = .other(rawValue) } } - public var rawValue: Swift.String { switch self { case let .other(string): - string + return string case .json: - "application/json" + return "application/json" } } - public static var allCases: [Self] { [ .json From a1804ac3661522985d8ce52ba1c2f1a0b686f561 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Fri, 28 Feb 2025 21:24:50 +0100 Subject: [PATCH 026/476] Prepared HTTPClient to take over register and notification from NetworkConnection HTTPClient with doRequest endpoint URL Intermediary step: NotificationsView now calling async version of NetworkConnection.notification, OpenHABRootViewController of NetworkConnection.register OpenHABItemCache now making use of OpenAPIService for sendState and sendCommand Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../Sources/OpenHABCore/Util/HTTPClient.swift | 57 ++++++++++++--- .../OpenHABCore/Util/NetworkConnection.swift | 73 ++++++------------- .../OpenHABCore/Util/NetworkTracker.swift | 3 + .../OpenHABCore/Util/OpenHABItemCache.swift | 29 ++++++-- openHAB/NotificationsView.swift | 24 +++--- openHAB/OpenHABRootViewController.swift | 10 +-- 6 files changed, 108 insertions(+), 88 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift b/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift index 6a4e7e3fc..3c248430d 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift @@ -38,6 +38,7 @@ private enum HTTPClientError: Error { } public class HTTPClient: NSObject { + // MARK: - Properties public enum CertificateEvaluateResult { @@ -47,6 +48,8 @@ public class HTTPClient: NSObject { case permitAlways } + public static let share = HTTPClient() + // this can be changed if we detect another server public var baseURL: URL? @@ -59,7 +62,7 @@ public class HTTPClient: NSObject { private var trustedCertificates: [String: Data] = [:] private var authAttemptCounts = [URLSessionTask: Int]() - public init(baseURL: URL? = nil, username: String, password: String, alwaysSendBasicAuth: Bool = false, ignoreSSL: Bool = false) { + public init(baseURL: URL? = nil, username: String = "", password: String = "", alwaysSendBasicAuth: Bool = false, ignoreSSL: Bool = false) { self.baseURL = baseURL self.username = username self.password = password @@ -75,6 +78,28 @@ public class HTTPClient: NSObject { initializeCertificatesStore() } + @discardableResult + public func register(prefsURL: String, + deviceToken: String, + deviceId: String, + deviceName: String) async throws -> Data? { + if let url = Endpoint.appleRegistration(prefsURL: prefsURL, deviceToken: deviceToken, deviceId: deviceId, deviceName: deviceName).url { + let (data, _) = try await doRequest(endPoint: url, method: "GET", download: false) + return data as? Data + } else { + throw NetworkConnectionError.couldNotRegister + } + } + + public func notification(urlString: String) async throws -> Data? { + if let url = Endpoint.notification(prefsURL: urlString).url { + let (data, _) = try await doRequest(endPoint: url, method: "GET", download: false) + return data as? Data + } else { + throw NetworkConnectionError.couldNotLoadNotification + } + } + /** Initiates a download request to a specified base URL for a specified path and returns the file URL via a completion handler. @@ -99,22 +124,12 @@ public class HTTPClient: NSObject { return (fileURL1, response1) } - public func doRequest(baseURL: URL?, - path: String?, + public func doRequest(endPoint url: URL, method: String, headers: [String: String]? = nil, timeout: TimeInterval = 60.0, body: String? = nil, download: Bool = false) async throws -> (Any?, URLResponse?) { - guard var url = baseURL ?? self.baseURL else { - os_log("doRequest ERROR: Base URL is nil", log: .networking, type: .info) - throw HTTPClientError.baseURLIsNil - } - - if let path { - url.appendPathComponent(path) - } - var request = URLRequest(url: url) request.httpMethod = method request.timeoutInterval = timeout @@ -141,6 +156,24 @@ public class HTTPClient: NSObject { fatalError() } + public func doRequest(baseURL: URL?, + path: String?, + method: String, + headers: [String: String]? = nil, + timeout: TimeInterval = 60.0, + body: String? = nil, + download: Bool = false) async throws -> (Any?, URLResponse?) { + guard var url = baseURL ?? self.baseURL else { + os_log("doRequest ERROR: Base URL is nil", log: .networking, type: .info) + throw HTTPClientError.baseURLIsNil + } + + if let path { + url.appendPathComponent(path) + } + return try await doRequest(endPoint: url, method: method, headers: headers, timeout: timeout, body: body, download: download) + } + private func performRequest(request: URLRequest, download: Bool) async throws -> (Any?, URLResponse?) { var request = request if alwaysSendBasicAuth { diff --git a/OpenHABCore/Sources/OpenHABCore/Util/NetworkConnection.swift b/OpenHABCore/Sources/OpenHABCore/Util/NetworkConnection.swift index 272b01fea..3076014d0 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/NetworkConnection.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/NetworkConnection.swift @@ -60,6 +60,11 @@ public protocol CommItem { var link: String { get set } } +enum NetworkConnectionError: Error { + case couldNotRegister + case couldNotLoadNotification +} + public class NetworkConnection { public static var shared: NetworkConnection! @@ -91,76 +96,44 @@ public class NetworkConnection { ) } + @discardableResult public static func register(prefsURL: String, deviceToken: String, deviceId: String, - deviceName: String, completionHandler: @escaping (DataResponse) -> Void) { + deviceName: String) async throws -> Data { if let url = Endpoint.appleRegistration(prefsURL: prefsURL, deviceToken: deviceToken, deviceId: deviceId, deviceName: deviceName).url { - load(from: url, completionHandler: completionHandler) + return try await load(from: url) + } else { + throw NetworkConnectionError.couldNotRegister } } - public static func notification(urlString: String, - completionHandler: @escaping (DataResponse) -> Void) { + public static func notification(urlString: String) async throws -> Data { if let notificationsUrl = Endpoint.notification(prefsURL: urlString).url { - load(from: notificationsUrl, completionHandler: completionHandler) + return try await load(from: notificationsUrl) + } else { + throw NetworkConnectionError.couldNotLoadNotification } } - public static func sendState(item: CommItem, stateToSend state: String?) -> DataRequest? { - sendCommandOrState(item: item, commandToSend: state, state: true) - } - - public static func sendCommand(item: CommItem, commandToSend command: String?) -> DataRequest? { - sendCommandOrState(item: item, commandToSend: command, state: false) - } - - public static func sendCommandOrState(item: CommItem, commandToSend command: String?, state: Bool) -> DataRequest? { - if var commandUrl = URL(string: item.link) { - if state { - commandUrl = commandUrl.appendingPathComponent("/state") - } - - var commandRequest = URLRequest(url: commandUrl) - - if state { - commandRequest.httpMethod = "PUT" - } else { - commandRequest.httpMethod = "POST" - } - - commandRequest.httpBody = command?.data(using: .utf8) - - commandRequest.setValue("text/plain", forHTTPHeaderField: "Content-type") + static func load(from url: URL, timeout: Double? = nil) async throws -> Data { + var request = URLRequest(url: url) + request.timeoutInterval = timeout ?? 10.0 - os_log("Timeout %{PUBLIC}g", log: .default, type: .info, commandRequest.timeoutInterval) - let link = item.link - os_log("OpenHABViewController posting %{PUBLIC}@ command to %{PUBLIC}@", log: .default, type: .info, command ?? "", link) - os_log("%{PUBLIC}@", log: .default, type: .info, commandRequest.debugDescription) + os_log("Firing request", log: .viewCycle, type: .debug) - return NetworkConnection.shared.manager.request(commandRequest) + return try await withCheckedThrowingContinuation { continuation in + NetworkConnection.shared.manager.request(request) .validate() .responseData { response in switch response.result { - case .success: - os_log("Command sent!", log: .remoteAccess, type: .info) + case let .success(data): + continuation.resume(returning: data) case let .failure(error): - os_log("%{PUBLIC}@ %d", log: .default, type: .error, error.localizedDescription, response.response?.statusCode ?? 0) + continuation.resume(throwing: error) } } } - return nil - } - - static func load(from url: URL, timeout: Double? = nil, completionHandler: @escaping (DataResponse) -> Void) { - var request = URLRequest(url: url) - request.timeoutInterval = timeout ?? 10.0 - - os_log("Firing request", log: .viewCycle, type: .debug) - let task = NetworkConnection.shared.manager.request(request) - .validate() - .responseData(completionHandler: completionHandler) - task.resume() } public func assignDelegates(serverDelegate: ServerCertificateManagerDelegate?, clientDelegate: ClientCertificateManagerDelegate) { diff --git a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift index a231066fd..7bc929058 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift @@ -91,6 +91,7 @@ public final class NetworkTracker: ObservableObject { attemptConnection() } + @discardableResult public func waitForActiveConnection( perform action: @escaping (ConnectionInfo?) -> Void ) -> AnyCancellable { @@ -170,9 +171,11 @@ public final class NetworkTracker: ObservableObject { os_log("attemptConnection trying %{PUBLIC}@", log: OSLog.default, type: .info, configuration.url) if let url = URL(string: configuration.url) { Task { + defer { dispatchGroup.leave() // When each check completes, this signals the group that it's done } + do { await openApiService?.updateBaseURL(with: url) let serverProperties = try await openApiService?.getRoot() diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift index b6322c5c8..ffcaf891f 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift @@ -32,6 +32,7 @@ public class OpenHABItemCache { url: Preferences.remoteUrl, priority: 1 ) + NetworkTracker.shared.startTracking(connectionConfigurations: [connection1, connection2], username: Preferences.username, password: Preferences.password, alwaysSendBasicAuth: Preferences.alwaysSendCreds, ignoreSSLVerification: Preferences.ignoreSSL) } @@ -69,22 +70,35 @@ public class OpenHABItemCache { } public func sendCommand(_ item: OpenHABItem, commandToSend command: String) { - let commandOperation = NetworkConnection.sendCommand(item: item, commandToSend: command) - commandOperation?.resume() + NetworkTracker.shared.waitForActiveConnection { activeConnection in + if let openHABUrl = activeConnection?.configuration.url, let url = URL(string: openHABUrl) { + Task { + await NetworkTracker.shared.openApiService?.updateBaseURL(with: url) + try await NetworkTracker.shared.openApiService?.sendItemCommand(itemname: item.name, command: command) + } + } + } } - public func sendState(_ item: OpenHABItem, stateToSend command: String) { - let commandOperation = NetworkConnection.sendState(item: item, stateToSend: command) - commandOperation?.resume() + public func sendState(_ item: OpenHABItem, stateToSend state: String) { + NetworkTracker.shared.waitForActiveConnection { activeConnection in + if let openHABUrl = activeConnection?.configuration.url, let url = URL(string: openHABUrl) { + Task { + await NetworkTracker.shared.openApiService?.updateBaseURL(with: url) + try await NetworkTracker.shared.openApiService?.updateItemState(itemname: item.name, with: state) + } + } + } } public func reload(searchTerm: String?, types: [OpenHABItem.ItemType]?, completion: @escaping ([NSString]) -> Void) { NetworkTracker.shared.waitForActiveConnection { activeConnection in - if (activeConnection?.configuration.url) != nil { + if let openHABUrl = activeConnection?.configuration.url, let url = URL(string: openHABUrl) { os_log("OpenHABItemCache Loading items ") self.lastLoad = Date().timeIntervalSince1970 Task { do { + await NetworkTracker.shared.openApiService?.updateBaseURL(with: url) self.items = try await NetworkTracker.shared.openApiService?.getItems() os_log("Loaded items to cache: %{PUBLIC}d", log: .default, type: .info, self.items?.count ?? 0) @@ -108,9 +122,10 @@ public class OpenHABItemCache { public func reload(name: String, completion: @escaping (OpenHABItem?) -> Void) { NetworkTracker.shared.waitForActiveConnection { activeConnection in - if (activeConnection?.configuration.url) != nil { + if let openHABUrl = activeConnection?.configuration.url, let url = URL(string: openHABUrl) { Task { do { + await NetworkTracker.shared.openApiService?.updateBaseURL(with: url) self.items = try await NetworkTracker.shared.openApiService?.getItems() os_log("Loaded items to cache: %{PUBLIC}d", log: .default, type: .info, self.items?.count ?? 0) let ret = self.items?.filter { diff --git a/openHAB/NotificationsView.swift b/openHAB/NotificationsView.swift index 0019eafb7..032c93cce 100644 --- a/openHAB/NotificationsView.swift +++ b/openHAB/NotificationsView.swift @@ -85,21 +85,17 @@ struct NotificationsView: View { } private func loadNotifications() { - NetworkConnection.notification(urlString: Preferences.remoteUrl) { response in - DispatchQueue.main.async { - switch response.result { - case let .success(data): - do { - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .formatted(DateFormatter.iso8601Full) - let codingDatas = try data.decoded(as: [OpenHABNotification.CodingData].self, using: decoder) - notifications = codingDatas.map(\.openHABNotification) - } catch { - os_log("%{PUBLIC}@ ", log: .default, type: .error, error.localizedDescription) - } - case let .failure(error): - os_log("%{PUBLIC}@", log: .default, type: .error, error.localizedDescription) + Task { + do { + let data = try await NetworkConnection.notification(urlString: Preferences.remoteUrl) + try await MainActor.run { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .formatted(DateFormatter.iso8601Full) + let codingDatas = try data.decoded(as: [OpenHABNotification.CodingData].self, using: decoder) + notifications = codingDatas.map(\.openHABNotification) } + } catch { + os_log("%{PUBLIC}@", log: .default, type: .error, error.localizedDescription) } } } diff --git a/openHAB/OpenHABRootViewController.swift b/openHAB/OpenHABRootViewController.swift index b19313c77..612b95240 100644 --- a/openHAB/OpenHABRootViewController.swift +++ b/openHAB/OpenHABRootViewController.swift @@ -305,12 +305,12 @@ class OpenHABRootViewController: UIViewController { if prefsURL.contains("openhab.org") { guard let deviceId = theData?["deviceId"] as? String, let deviceToken = theData?["deviceToken"] as? String, let deviceName = theData?["deviceName"] as? String else { return } os_log("Registering notifications with %{PUBLIC}@", log: .notifications, type: .info, prefsURL) - NetworkConnection.register(prefsURL: prefsURL, deviceToken: deviceToken, deviceId: deviceId, deviceName: deviceName) { response in - switch response.result { - case .success: + Task { + do { + try await NetworkConnection.register(prefsURL: prefsURL, deviceToken: deviceToken, deviceId: deviceId, deviceName: deviceName) os_log("my.openHAB registration sent", log: .notifications, type: .info) - case let .failure(error): - os_log("my.openHAB registration failed %{PUBLIC}@ %d", log: .notifications, type: .error, error.localizedDescription, response.response?.statusCode ?? 0) + } catch { + os_log("my.openHAB registration failed %{PUBLIC}@", log: .notifications, type: .error, error.localizedDescription) } } } From 692d5b51d7dbc618d2b68c08c875f0345605394c Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Fri, 28 Feb 2025 22:00:29 +0100 Subject: [PATCH 027/476] Reverted a change on .swiftformat / now tested for GeneratedSource to be exluded. Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- BuildTools/.swiftformat | 86 ++++++++++--------- .../Sources/OpenHABCore/Util/HTTPClient.swift | 1 - .../OpenHABCore/Util/NetworkTracker.swift | 3 +- .../OpenHABCore/Util/OpenHABItemCache.swift | 2 +- 4 files changed, 47 insertions(+), 45 deletions(-) diff --git a/BuildTools/.swiftformat b/BuildTools/.swiftformat index 5b397b017..2901a6d6a 100644 --- a/BuildTools/.swiftformat +++ b/BuildTools/.swiftformat @@ -1,47 +1,51 @@ -# file options -ignore: ../fastlane, ../OpenHABCore/.build, ./build, ../OpenHABCore/swift-openapi-generator -symlinks: ignore +--exclude ../fastlane +--exclude ../OpenHABCore/.build +--exclude ./build +--exclude ../OpenHABCore/swift-openapi-generator +--exclude ../OpenHABCore/Sources/OpenHABCore/GeneratedSources +--symlinks ignore # disabled rules -disable: specifiers, redundantParens, wrapMultilineStatementBraces # see https://github.com/nicklockwood/SwiftFormat/issues/364 +--disable specifiers # see https://github.com/nicklockwood/SwiftFormat/issues/364 +--disable redundantParens +--disable wrapMultilineStatementBraces # opt-in rules -enable: isEmpty, typeSugar +--enable isEmpty +--enable typeSugar # format options -allman: false -binarygrouping: none -closingparen: balanced -commas: inline -conflictmarkers: reject -decimalgrouping: 3,6 -elseposition: same-line -empty: void -exponentcase: lowercase -exponentgrouping: disabled -fractiongrouping: disabled -fragment: false -header: ignore -hexgrouping: none -ifdef: no-indent -importgrouping: alphabetized -indent: 4 -indentcase: false -linebreaks: lf -octalgrouping: none -operatorfunc: space -patternlet: hoist -ranges: spaced -self: remove -selfrequired: true -semicolons: inline -stripunusedargs: closure-only -trailingclosures: true -trimwhitespace: always -wraparguments: before-first -wrapcollections: before-first -wrapparameters: after-first -xcodeindentation: disabled - -# Copyright header -header: "// Copyright (c) 2010-{year} Contributors to the openHAB project\n//\n// See the NOTICE file(s) distributed with this work for additional\n// information.\n//\n// This program and the accompanying materials are made available under the\n// terms of the Eclipse Public License 2.0 which is available at\n// http://www.eclipse.org/legal/epl-2.0\n//\n// SPDX-License-Identifier: EPL-2.0" +--allman false +--binarygrouping none +--closingparen balanced +--commas inline +--conflictmarkers reject +--decimalgrouping 3,6 +--elseposition same-line +--empty void +--exponentcase lowercase +--exponentgrouping disabled +--fractiongrouping disabled +--fragment false +--header ignore +--hexgrouping none +--ifdef no-indent +--importgrouping alphabetized +--indent 4 +--indentcase false +--linebreaks lf +--octalgrouping none +--operatorfunc space +--patternlet hoist +--ranges spaced +--self remove +--selfrequired +--semicolons inline +--stripunusedargs closure-only +--trailingclosures +--trimwhitespace always +--wraparguments before-first +--wrapcollections before-first +--wrapparameters after-first +--xcodeindentation disabled +--header "// Copyright (c) 2010-{year} Contributors to the openHAB project\n//\n// See the NOTICE file(s) distributed with this work for additional\n// information.\n//\n// This program and the accompanying materials are made available under the\n// terms of the Eclipse Public License 2.0 which is available at\n// http://www.eclipse.org/legal/epl-2.0\n//\n// SPDX-License-Identifier: EPL-2.0" diff --git a/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift b/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift index 3c248430d..6f7303861 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift @@ -38,7 +38,6 @@ private enum HTTPClientError: Error { } public class HTTPClient: NSObject { - // MARK: - Properties public enum CertificateEvaluateResult { diff --git a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift index 7bc929058..5f1242a5f 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift @@ -91,7 +91,7 @@ public final class NetworkTracker: ObservableObject { attemptConnection() } - @discardableResult + @discardableResult public func waitForActiveConnection( perform action: @escaping (ConnectionInfo?) -> Void ) -> AnyCancellable { @@ -171,7 +171,6 @@ public final class NetworkTracker: ObservableObject { os_log("attemptConnection trying %{PUBLIC}@", log: OSLog.default, type: .info, configuration.url) if let url = URL(string: configuration.url) { Task { - defer { dispatchGroup.leave() // When each check completes, this signals the group that it's done } diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift index ffcaf891f..6864f9bec 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift @@ -47,7 +47,7 @@ public class OpenHABItemCache { ret.append(contentsOf: items .filter { (searchTerm == nil || $0.name.contains(searchTerm.orEmpty)) && - (types == nil || ($0.type != nil && types!.contains($0.type!))) + (types == nil || ($0.type != nil && types!.contains($0.type!))) } .sorted(by: \.name) .map { NSString(string: $0.name) }) From 003402c7c86fa9d112af29aa5d162adfa1276419 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Sat, 1 Mar 2025 08:04:40 +0100 Subject: [PATCH 028/476] HTTPClient to take over all remaining functions from NetworkConnection: register, notification, load HTTPClient to use generic performRequest to avoid Any Removing NetworkConnection - step 2 : Deleting OpenHABLogger, removing test cases Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../Sources/OpenHABCore/Util/HTTPClient.swift | 102 ++++++++++-------- .../OpenHABCore/Util/NetworkConnection.swift | 49 +-------- .../OpenHABCore/Util/NetworkTracker.swift | 6 +- .../OpenHABCore/Util/OpenHABItemCache.swift | 3 - .../OpenHABCore/Util/OpenHABLogger.swift | 28 ----- .../Tests/OpenHABCoreTests/RESTAPITests.swift | 82 -------------- openHAB/AppDelegate.swift | 7 -- openHAB/NotificationsView.swift | 3 +- openHAB/OpenHABRootViewController.swift | 3 +- openHAB/OpenHABSitemapViewController.swift | 1 - openHAB/OpenHABViewController.swift | 5 - 11 files changed, 65 insertions(+), 224 deletions(-) delete mode 100644 OpenHABCore/Sources/OpenHABCore/Util/OpenHABLogger.swift delete mode 100644 OpenHABCore/Tests/OpenHABCoreTests/RESTAPITests.swift diff --git a/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift b/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift index 6f7303861..019d3d8a1 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift @@ -20,6 +20,9 @@ private enum HTTPClientError: Error { case noDataForProperties case baseURLIsNil case httpError(Int) + case couldNotRegister + case couldNotLoadNotification + case failedtoFetchMJPEG var debugDescription: String { switch self { @@ -33,6 +36,12 @@ private enum HTTPClientError: Error { "Base URL is nil" case let .httpError(statusCode): "HTTP error \(statusCode)" + case .couldNotRegister: + "Could not register" + case .couldNotLoadNotification: + "Could not load notification" + case .failedtoFetchMJPEG: + "Failed to fetch MJPEG" } } } @@ -47,6 +56,12 @@ public class HTTPClient: NSObject { case permitAlways } + public enum SessionType { + case download + case data + case bytes + } + public static let share = HTTPClient() // this can be changed if we detect another server @@ -77,25 +92,34 @@ public class HTTPClient: NSObject { initializeCertificatesStore() } + private func processStream(url: URL) async throws -> (URLSession.AsyncBytes, URLResponse) { + do { + return try await doRequest(baseURL: url, method: "GET", type: .bytes) + } catch { + os_log("Failed to fetch MJPEG stream: %@", log: .default, type: .error, error.localizedDescription) + throw HTTPClientError.failedtoFetchMJPEG + } + } + @discardableResult public func register(prefsURL: String, deviceToken: String, deviceId: String, deviceName: String) async throws -> Data? { if let url = Endpoint.appleRegistration(prefsURL: prefsURL, deviceToken: deviceToken, deviceId: deviceId, deviceName: deviceName).url { - let (data, _) = try await doRequest(endPoint: url, method: "GET", download: false) - return data as? Data + let (data, _): (Data, URLResponse) = try await doRequest(baseURL: url, method: "GET", type: .data) + return data } else { - throw NetworkConnectionError.couldNotRegister + throw HTTPClientError.couldNotRegister } } - public func notification(urlString: String) async throws -> Data? { + public func notification(urlString: String) async throws -> Data { if let url = Endpoint.notification(prefsURL: urlString).url { - let (data, _) = try await doRequest(endPoint: url, method: "GET", download: false) - return data as? Data + let (data, _): (Data, URLResponse) = try await doRequest(baseURL: url, method: "GET", type: .data) + return data } else { - throw NetworkConnectionError.couldNotLoadNotification + throw HTTPClientError.couldNotLoadNotification } } @@ -110,25 +134,27 @@ public class HTTPClient: NSObject { */ public func downloadFile(url: URL) async throws -> (URL, URLResponse) { - let (result, response) = try await doRequest(baseURL: url, path: nil, method: "GET", download: true) + let (fileURL, response): (URL, URLResponse) = try await doRequest(baseURL: url, path: nil, method: "GET", type: .download) - let fileURL = result as? URL + return (fileURL, response) + } - guard let fileURL1 = fileURL else { - fatalError("Expected non-nil result 'fileURL1' in the non-error case") + public func doRequest(baseURL: URL?, + path: String? = nil, + method: String, + headers: [String: String]? = nil, + timeout: TimeInterval = 60.0, + body: String? = nil, + type: SessionType) async throws -> (T, URLResponse) { + guard var url = baseURL ?? self.baseURL else { + os_log("doRequest ERROR: Base URL is nil", log: .networking, type: .info) + throw HTTPClientError.baseURLIsNil } - guard let response1 = response else { - fatalError("Expected non-nil result 'response1' in the non-error case") + + if let path { + url.appendPathComponent(path) } - return (fileURL1, response1) - } - public func doRequest(endPoint url: URL, - method: String, - headers: [String: String]? = nil, - timeout: TimeInterval = 60.0, - body: String? = nil, - download: Bool = false) async throws -> (Any?, URLResponse?) { var request = URLRequest(url: url) request.httpMethod = method request.timeoutInterval = timeout @@ -142,7 +168,7 @@ public class HTTPClient: NSObject { request.setValue("text/plain", forHTTPHeaderField: "Content-Type") } - let (result, response) = try await performRequest(request: request, download: download) + let (result, response): (T, URLResponse) = try await performRequest(request: request, type: type) if let response = response as? HTTPURLResponse { if (400 ... 599).contains(response.statusCode) { os_log("HTTP error from URL %{public}@ : %{public}d", log: .networking, type: .error, url.absoluteString, response.statusCode) @@ -155,33 +181,19 @@ public class HTTPClient: NSObject { fatalError() } - public func doRequest(baseURL: URL?, - path: String?, - method: String, - headers: [String: String]? = nil, - timeout: TimeInterval = 60.0, - body: String? = nil, - download: Bool = false) async throws -> (Any?, URLResponse?) { - guard var url = baseURL ?? self.baseURL else { - os_log("doRequest ERROR: Base URL is nil", log: .networking, type: .info) - throw HTTPClientError.baseURLIsNil - } - - if let path { - url.appendPathComponent(path) - } - return try await doRequest(endPoint: url, method: method, headers: headers, timeout: timeout, body: body, download: download) - } - - private func performRequest(request: URLRequest, download: Bool) async throws -> (Any?, URLResponse?) { + private func performRequest(request: URLRequest, type: SessionType = .data) async throws -> (T, URLResponse) { var request = request if alwaysSendBasicAuth { request.setValue(basicAuthHeader(), forHTTPHeaderField: "Authorization") } - if download { - return try await session.download(for: request) - } else { - return try await session.data(for: request) + + switch type { + case .download: + return try await session.download(for: request) as! (T, URLResponse) // Ensure correct type + case .data: + return try await session.data(for: request) as! (T, URLResponse) + case .bytes: + return try await session.bytes(for: request) as! (T, URLResponse) } } diff --git a/OpenHABCore/Sources/OpenHABCore/Util/NetworkConnection.swift b/OpenHABCore/Sources/OpenHABCore/Util/NetworkConnection.swift index 3076014d0..9d5c444fb 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/NetworkConnection.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/NetworkConnection.swift @@ -60,11 +60,6 @@ public protocol CommItem { var link: String { get set } } -enum NetworkConnectionError: Error { - case couldNotRegister - case couldNotLoadNotification -} - public class NetworkConnection { public static var shared: NetworkConnection! @@ -82,7 +77,6 @@ public class NetworkConnection { } public class func initialize(ignoreSSL: Bool, interceptor: RequestInterceptor?) { - let logger = OpenHABLogger() shared = NetworkConnection( ignoreSSL: ignoreSSL, manager: Session( @@ -90,52 +84,11 @@ public class NetworkConnection { delegate: OpenHABSessionDelegate(), startRequestsImmediately: false, interceptor: interceptor, - serverTrustManager: ServerCertificateManager(ignoreSSL: ignoreSSL), - eventMonitors: [logger] + serverTrustManager: ServerCertificateManager(ignoreSSL: ignoreSSL) ) ) } - @discardableResult - public static func register(prefsURL: String, - deviceToken: String, - deviceId: String, - deviceName: String) async throws -> Data { - if let url = Endpoint.appleRegistration(prefsURL: prefsURL, deviceToken: deviceToken, deviceId: deviceId, deviceName: deviceName).url { - return try await load(from: url) - } else { - throw NetworkConnectionError.couldNotRegister - } - } - - public static func notification(urlString: String) async throws -> Data { - if let notificationsUrl = Endpoint.notification(prefsURL: urlString).url { - return try await load(from: notificationsUrl) - } else { - throw NetworkConnectionError.couldNotLoadNotification - } - } - - static func load(from url: URL, timeout: Double? = nil) async throws -> Data { - var request = URLRequest(url: url) - request.timeoutInterval = timeout ?? 10.0 - - os_log("Firing request", log: .viewCycle, type: .debug) - - return try await withCheckedThrowingContinuation { continuation in - NetworkConnection.shared.manager.request(request) - .validate() - .responseData { response in - switch response.result { - case let .success(data): - continuation.resume(returning: data) - case let .failure(error): - continuation.resume(throwing: error) - } - } - } - } - public func assignDelegates(serverDelegate: ServerCertificateManagerDelegate?, clientDelegate: ClientCertificateManagerDelegate) { serverCertificateManager.delegate = serverDelegate clientCertificateManager.delegate = clientDelegate diff --git a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift index 5f1242a5f..f9b38b383 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift @@ -82,7 +82,7 @@ public final class NetworkTracker: ObservableObject { } public func startTracking(connectionConfigurations: [ConnectionConfiguration], username: String, password: String, alwaysSendBasicAuth: Bool, ignoreSSLVerification: Bool) { - os_log("NetworkConnection: startTracking", log: OSLog.default, type: .info) + os_log("StartTracking", log: OSLog.default, type: .info) self.connectionConfigurations = adjustMyOpenHABHosts(in: connectionConfigurations) Task { openApiService = await OpenAPIService(username: username, password: password, alwaysSendBasicAuth: alwaysSendBasicAuth, ignoreSSL: ignoreSSLVerification) @@ -95,7 +95,7 @@ public final class NetworkTracker: ObservableObject { public func waitForActiveConnection( perform action: @escaping (ConnectionInfo?) -> Void ) -> AnyCancellable { - os_log("NetworkConnection: waitForActiveConnection", log: OSLog.default, type: .info) + os_log("WaitForActiveConnection", log: OSLog.default, type: .info) return $activeConnection .filter { $0 != nil } // Only proceed if activeConnection is not nil @@ -114,7 +114,7 @@ public final class NetworkTracker: ObservableObject { private func checkActiveConnection() { guard let activeConnection else { // No active connection, proceed with the normal connection attempt - os_log("NetworkConnection: checkActiveConnection attemptConnection", log: OSLog.default, type: .info) + os_log("CheckActiveConnection attemptConnection", log: OSLog.default, type: .info) attemptConnection() return } diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift index 6864f9bec..c2ca2172a 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift @@ -21,9 +21,6 @@ public class OpenHABItemCache { var lastLoad = Date().timeIntervalSince1970 private init() { - if NetworkConnection.shared == nil { - NetworkConnection.initialize(ignoreSSL: Preferences.ignoreSSL, interceptor: nil) - } let connection1 = ConnectionConfiguration( url: Preferences.localUrl, priority: 0 diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OpenHABLogger.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABLogger.swift deleted file mode 100644 index 95bb6e46d..000000000 --- a/OpenHABCore/Sources/OpenHABCore/Util/OpenHABLogger.swift +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright (c) 2010-2025 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 os.log - -final class OpenHABLogger: EventMonitor { - // let queue = DispatchQueue(label: .roo) - - // Event called when any type of Request is resumed. - func requestDidResume(_ request: Request) { - os_log("Resuming: %{PUBLIC}@", log: .alamofire, type: .info, request.description) - } - - // Event called whenever a DataRequest has parsed a response. - func request(_ request: DataRequest, didParseResponse response: DataResponse) { - os_log("Finished %{PUBLIC}@", log: .alamofire, type: .debug, response.error.debugDescription) - } -} diff --git a/OpenHABCore/Tests/OpenHABCoreTests/RESTAPITests.swift b/OpenHABCore/Tests/OpenHABCoreTests/RESTAPITests.swift deleted file mode 100644 index 4ee5275e0..000000000 --- a/OpenHABCore/Tests/OpenHABCoreTests/RESTAPITests.swift +++ /dev/null @@ -1,82 +0,0 @@ -// Copyright (c) 2010-2025 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 - -@testable import OpenHABCore - -import Alamofire -import XCTest - -final class RESTAPITests: XCTestCase { - override func setUp() { - super.setUp() - - let manager: Session = { - let configuration: URLSessionConfiguration = { - let configuration = URLSessionConfiguration.default - configuration.protocolClasses = [MockURLProtocol.self] - return configuration - }() - - return Session(configuration: configuration) - }() - - class MockURLRequestAdapter: RequestAdapter { - func adapt(_ urlRequest: URLRequest, for session: Session, completion: @escaping (Result) -> Void) {} - } - - NetworkConnection.shared = NetworkConnection( - ignoreSSL: true, - manager: manager - ) - } - - override func tearDown() { - super.tearDown() - - NetworkConnection.shared = nil - } - - func testStatusCode200ReturnsStatusCode200() { - // given - MockURLProtocol.responseWithStatusCode(code: 200) - - let expectation = XCTestExpectation(description: "Performs a request") - - // when - let pageToLoadUrl = URL(string: "http://192.168.2.16")! - let pageRequest = URLRequest(url: pageToLoadUrl) - let registrationOperation = NetworkConnection.shared.manager.request(pageRequest) - .validate() - .responseData { response in - XCTAssertEqual(response.response?.statusCode, 200) - expectation.fulfill() - } - registrationOperation.resume() - - // then - wait(for: [expectation], timeout: 3) - } - - func testRegisterApp() { - // given - MockURLProtocol.responseWithStatusCode(code: 200) - - let expectation = XCTestExpectation(description: "Register App") - - // when - NetworkConnection.register(prefsURL: "http://192.168.2.16", deviceToken: "", deviceId: "", deviceName: "") { response in - XCTAssertEqual(response.response?.statusCode, 200) - expectation.fulfill() - } - // then - wait(for: [expectation], timeout: 3) - } -} diff --git a/openHAB/AppDelegate.swift b/openHAB/AppDelegate.swift index 0f46a2704..883af8ae1 100644 --- a/openHAB/AppDelegate.swift +++ b/openHAB/AppDelegate.swift @@ -57,8 +57,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD Preferences.migrateUserDefaultsIfRequired() - NetworkConnection.initialize(ignoreSSL: Preferences.ignoreSSL, interceptor: OpenHABAccessTokenAdapter(appData: AppDelegate.appDelegate.appData)) - NetworkActivityIndicatorManager.shared.isEnabled = true NetworkActivityIndicatorManager.shared.startDelay = 1.0 @@ -133,11 +131,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD os_log("URL scheme: %{PUBLIC}@", log: .notifications, type: .info, url.scheme ?? "") os_log("URL query: %{PUBLIC}@", log: .notifications, type: .info, url.query ?? "") - if url.isFileURL { - let clientCertificateManager = NetworkConnection.shared.clientCertificateManager - return clientCertificateManager.startImportClientCertificate(url: url) - } - // remove the 'openhab' from the url let action = url.absoluteString.split(separator: ":").dropFirst().joined(separator: ":") notifyNotificationListeners(["actionIdentifier": action]) diff --git a/openHAB/NotificationsView.swift b/openHAB/NotificationsView.swift index 032c93cce..bf63a01ff 100644 --- a/openHAB/NotificationsView.swift +++ b/openHAB/NotificationsView.swift @@ -87,7 +87,8 @@ struct NotificationsView: View { private func loadNotifications() { Task { do { - let data = try await NetworkConnection.notification(urlString: Preferences.remoteUrl) + let client = HTTPClient(username: Preferences.username, password: Preferences.username, alwaysSendBasicAuth: Preferences.alwaysSendCreds) + let data = try await client.notification(urlString: Preferences.remoteUrl) try await MainActor.run { let decoder = JSONDecoder() decoder.dateDecodingStrategy = .formatted(DateFormatter.iso8601Full) diff --git a/openHAB/OpenHABRootViewController.swift b/openHAB/OpenHABRootViewController.swift index 612b95240..c17a49509 100644 --- a/openHAB/OpenHABRootViewController.swift +++ b/openHAB/OpenHABRootViewController.swift @@ -307,7 +307,8 @@ class OpenHABRootViewController: UIViewController { os_log("Registering notifications with %{PUBLIC}@", log: .notifications, type: .info, prefsURL) Task { do { - try await NetworkConnection.register(prefsURL: prefsURL, deviceToken: deviceToken, deviceId: deviceId, deviceName: deviceName) + let client = HTTPClient(username: Preferences.username, password: Preferences.username, alwaysSendBasicAuth: Preferences.alwaysSendCreds) + try await client.register(prefsURL: prefsURL, deviceToken: deviceToken, deviceId: deviceId, deviceName: deviceName) os_log("my.openHAB registration sent", log: .notifications, type: .info) } catch { os_log("my.openHAB registration failed %{PUBLIC}@", log: .notifications, type: .error, error.localizedDescription) diff --git a/openHAB/OpenHABSitemapViewController.swift b/openHAB/OpenHABSitemapViewController.swift index daa9d0f3c..c6017520c 100644 --- a/openHAB/OpenHABSitemapViewController.swift +++ b/openHAB/OpenHABSitemapViewController.swift @@ -394,7 +394,6 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel os_log("DecodingError %{PUBLIC}@", log: .default, type: .error, error.localizedDescription) } catch { os_log("On LoadPage \"%{PUBLIC}@\" code: %d ", log: .remoteAccess, type: .error, error.localizedDescription) - NetworkConnection.atmosphereTrackingId = "" // Error DispatchQueue.main.async { if let urlError = error as? URLError, urlError.code == .clientCertificateRejected { diff --git a/openHAB/OpenHABViewController.swift b/openHAB/OpenHABViewController.swift index 7c3d7d9d8..8dade65ba 100644 --- a/openHAB/OpenHABViewController.swift +++ b/openHAB/OpenHABViewController.swift @@ -24,11 +24,6 @@ class OpenHABViewController: UIViewController { NotificationCenter.default.addObserver(self, selector: #selector(OpenHABViewController.didBecomeActive(_:)), name: UIApplication.didBecomeActiveNotification, object: nil) } - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - NetworkConnection.shared.assignDelegates(serverDelegate: self, clientDelegate: self) - } - func showPopupMessage(seconds: Double, title: String, message: String, theme: Theme) { var config = SwiftMessages.Config() config.duration = .seconds(seconds: seconds) From 530c1f79436b67a61699a35b1920d32fc43dd623 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Sat, 1 Mar 2025 15:18:08 +0100 Subject: [PATCH 029/476] Migrating VideoUITableViewCell to HTTPClient Removing Alamofire (cont.) Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../Sources/OpenHABCore/Util/HTTPClient.swift | 17 +++-- .../OpenHABCore/Util/NetworkTracker.swift | 2 +- .../OpenHABCore/Util/OSLogExtension.swift | 3 - openHAB/VideoUITableViewCell.swift | 68 ++++++++++--------- 4 files changed, 46 insertions(+), 44 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift b/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift index 019d3d8a1..24486840f 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift @@ -12,8 +12,6 @@ import Foundation import os -private let logger = Logger(subsystem: "org.openhab.core", category: "HTTPClient") - private enum HTTPClientError: Error { case serverTrustEvaluationFailed(reason: String) case noDataforItem @@ -76,6 +74,8 @@ public class HTTPClient: NSObject { private var trustedCertificates: [String: Data] = [:] private var authAttemptCounts = [URLSessionTask: Int]() + private let logger = Logger(subsystem: "org.openhab.core", category: "HTTPClient") + public init(baseURL: URL? = nil, username: String = "", password: String = "", alwaysSendBasicAuth: Bool = false, ignoreSSL: Bool = false) { self.baseURL = baseURL self.username = username @@ -92,9 +92,9 @@ public class HTTPClient: NSObject { initializeCertificatesStore() } - private func processStream(url: URL) async throws -> (URLSession.AsyncBytes, URLResponse) { + public func processStream(url: URL) async throws -> (URLSession.AsyncBytes, URLResponse) { do { - return try await doRequest(baseURL: url, method: "GET", type: .bytes) + return try await doRequest(baseURL: url, type: .bytes) } catch { os_log("Failed to fetch MJPEG stream: %@", log: .default, type: .error, error.localizedDescription) throw HTTPClientError.failedtoFetchMJPEG @@ -107,7 +107,7 @@ public class HTTPClient: NSObject { deviceId: String, deviceName: String) async throws -> Data? { if let url = Endpoint.appleRegistration(prefsURL: prefsURL, deviceToken: deviceToken, deviceId: deviceId, deviceName: deviceName).url { - let (data, _): (Data, URLResponse) = try await doRequest(baseURL: url, method: "GET", type: .data) + let (data, _): (Data, URLResponse) = try await doRequest(baseURL: url, type: .data) return data } else { throw HTTPClientError.couldNotRegister @@ -116,7 +116,7 @@ public class HTTPClient: NSObject { public func notification(urlString: String) async throws -> Data { if let url = Endpoint.notification(prefsURL: urlString).url { - let (data, _): (Data, URLResponse) = try await doRequest(baseURL: url, method: "GET", type: .data) + let (data, _): (Data, URLResponse) = try await doRequest(baseURL: url, type: .data) return data } else { throw HTTPClientError.couldNotLoadNotification @@ -134,14 +134,13 @@ public class HTTPClient: NSObject { */ public func downloadFile(url: URL) async throws -> (URL, URLResponse) { - let (fileURL, response): (URL, URLResponse) = try await doRequest(baseURL: url, path: nil, method: "GET", type: .download) + let (fileURL, response): (URL, URLResponse) = try await doRequest(baseURL: url, path: nil, type: .download) return (fileURL, response) } public func doRequest(baseURL: URL?, path: String? = nil, - method: String, headers: [String: String]? = nil, timeout: TimeInterval = 60.0, body: String? = nil, @@ -156,7 +155,7 @@ public class HTTPClient: NSObject { } var request = URLRequest(url: url) - request.httpMethod = method + request.httpMethod = "GET" request.timeoutInterval = timeout if let headers { for (key, value) in headers { diff --git a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift index f9b38b383..51f662449 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift @@ -122,9 +122,9 @@ public final class NetworkTracker: ObservableObject { // Check if the active connection is reachable if let url = URL(string: activeConnection.configuration.url) { os_log("checkActiveConnection trying %{PUBLIC}@", log: OSLog.default, type: .info, url.absoluteString) - Task { do { + await openApiService?.updateBaseURL(with: url) try await openApiService?.getRoot() logger.info("Network status: Active connection is reachable: \(activeConnection.configuration.url)") } catch { diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OSLogExtension.swift b/OpenHABCore/Sources/OpenHABCore/Util/OSLogExtension.swift index e9223da02..18b654fd8 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/OSLogExtension.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OSLogExtension.swift @@ -39,9 +39,6 @@ public extension OSLog { /// Logs decoding errors static let decoding = OSLog(subsystem: subsystem, category: "decoding") - /// Logs Alamofire events - static let alamofire = OSLog(subsystem: subsystem, category: "alamofire") - /// Logs WkWebView events static let wkwebview = OSLog(subsystem: subsystem, category: "wkwebview") diff --git a/openHAB/VideoUITableViewCell.swift b/openHAB/VideoUITableViewCell.swift index 5ef31661e..1dd170224 100644 --- a/openHAB/VideoUITableViewCell.swift +++ b/openHAB/VideoUITableViewCell.swift @@ -9,7 +9,6 @@ // // SPDX-License-Identifier: EPL-2.0 -import Alamofire import AVFoundation import AVKit import OpenHABCore @@ -39,7 +38,7 @@ class VideoUITableViewCell: GenericUITableViewCell { private var mainImageView: UIImageView! private var playerObserver: NSKeyValueObservation? private var aspectRatioConstraint: NSLayoutConstraint? - private var mjpegRequest: Alamofire.Request? + private var activeTask: Task? private var session: URLSession! private var appData: OpenHABDataObject? { AppDelegate.appDelegate.appData @@ -154,7 +153,7 @@ class VideoUITableViewCell: GenericUITableViewCell { return } - if mjpegRequest != nil { + if activeTask != nil { return } @@ -164,38 +163,44 @@ class VideoUITableViewCell: GenericUITableViewCell { streamRequest.timeoutInterval = 10.0 let streamImageInitialBytePattern = Data([255, 216]) - var imageData = Data() - mjpegRequest = NetworkConnection.shared.manager.streamRequest(streamRequest) - .validate() - .responseStream { stream in - switch stream.event { - case let .stream(result): - switch result { - case let .success(data): - if data.starts(with: streamImageInitialBytePattern) { - if let image = UIImage(data: imageData) { - DispatchQueue.main.async { - if self.mainImageView?.image == nil { - let aspectRatio = image.size.width / image.size.height - self.activityIndicator.isHidden = true - self.updateAspectRatio(forView: self.mainImageView, aspectRatio: aspectRatio) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) { - self.didLoad?() - } - } - self.mainImageView?.image = image + + activeTask = Task { + do { + let client = HTTPClient(username: Preferences.username, password: Preferences.username, alwaysSendBasicAuth: Preferences.alwaysSendCreds) + let (byteStream, _) = try await client.processStream(url: url) + await handleMJPEGStream(byteStream) + } catch { + os_log("Failed to start MJPEG stream: %@", log: .decoding, type: .error, error.localizedDescription) + } + } + + func handleMJPEGStream(_ byteStream: URLSession.AsyncBytes) async { + var imageData = Data() + + do { + for try await byte in byteStream { + imageData.append(byte) + + if imageData.starts(with: streamImageInitialBytePattern), let image = UIImage(data: imageData) { + await MainActor.run { + if self.mainImageView?.image == nil { + let aspectRatio = image.size.width / image.size.height + self.activityIndicator.isHidden = true + self.updateAspectRatio(forView: self.mainImageView, aspectRatio: aspectRatio) + Task { + try? await Task.sleep(nanoseconds: 10_000_000) // 10ms delay + self.didLoad?() } } - imageData = Data() + self.mainImageView?.image = image } - imageData.append(data) + imageData = Data() // Reset for the next image } - case let .complete(completion): - os_log("Failed to decode stream", log: .decoding, type: .debug, completion.error?.localizedDescription ?? "") } + } catch { + os_log("Failed to process MJPEG stream: %@", log: .decoding, type: .error, error.localizedDescription) } - - mjpegRequest?.resume() + } } private func updateAspectRatio(forView view: UIView?, aspectRatio: CGFloat) { @@ -228,8 +233,9 @@ class VideoUITableViewCell: GenericUITableViewCell { } playerObserver = nil playerView?.playerLayer.player = nil - mjpegRequest?.cancel() - mjpegRequest = nil + // Cancel the active task if it is running + activeTask?.cancel() + activeTask = nil mainImageView?.image = nil } } From 9da82d6cfa3c56b499147cd7c9c5bd8d693a2b74 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Sat, 1 Mar 2025 20:17:23 +0100 Subject: [PATCH 030/476] Minor touches Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../ObservableOpenHABWidgetExtension.swift | 36 ------- openHABWatch/Views/LogsViewer.swift | 26 ++--- .../Views/Utils/DownloadableImageView.swift | 102 +++++++++--------- 3 files changed, 64 insertions(+), 100 deletions(-) delete mode 100644 openHABWatch/Model/ObservableOpenHABWidgetExtension.swift diff --git a/openHABWatch/Model/ObservableOpenHABWidgetExtension.swift b/openHABWatch/Model/ObservableOpenHABWidgetExtension.swift deleted file mode 100644 index 17c197e7e..000000000 --- a/openHABWatch/Model/ObservableOpenHABWidgetExtension.swift +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright (c) 2010-2025 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 Foundation -import OpenHABCore -import os.log -import SwiftUI - -extension OpenHABWidget { - @ViewBuilder func makeView(settings: ObservableOpenHABDataObject) -> some View { - if let linkedPage { - let title = linkedPage.title.components(separatedBy: "[")[0] - let pageUrl = linkedPage.link - // os_log("Selected %{PUBLIC}@", log: .viewCycle, type: .info, pageUrl) - NavigationLink(destination: - LazyView( - // TODO: - EmptyView() - // ContentView(viewModel: UserData(url: URL(string: pageUrl)), settings: settings, title: title) - ) - ) { - Image(systemSymbol: .chevronRight) - } - } else { - EmptyView() - } - } -} diff --git a/openHABWatch/Views/LogsViewer.swift b/openHABWatch/Views/LogsViewer.swift index 046088ed5..7827a686d 100644 --- a/openHABWatch/Views/LogsViewer.swift +++ b/openHABWatch/Views/LogsViewer.swift @@ -66,15 +66,26 @@ public extension Logger { } struct LogsViewer: View { - @State private var text = "Loading..." - private static let template = NSPredicate(format: "(subsystem BEGINSWITH $PREFIX)") + @State private var text = "Loading..." + let myFont = Font .system(size: 10) .monospaced() + var body: some View { + ScrollView { + Text(text) + .font(myFont) + .padding() + } + .task { + text = await fetchLogs() + } + } + private func fetchLogs() async -> String { let calendar = Calendar.current guard let dayAgo = calendar.date( @@ -100,17 +111,6 @@ struct LogsViewer: View { return error.localizedDescription } } - - var body: some View { - ScrollView { - Text(text) - .font(myFont) - .padding() - } - .task { - text = await fetchLogs() - } - } } #Preview { diff --git a/openHABWatch/Views/Utils/DownloadableImageView.swift b/openHABWatch/Views/Utils/DownloadableImageView.swift index ad9fe6289..65ad4ded6 100644 --- a/openHABWatch/Views/Utils/DownloadableImageView.swift +++ b/openHABWatch/Views/Utils/DownloadableImageView.swift @@ -22,11 +22,59 @@ enum DownloadableImageError: Error { case nohttpClient } +class SVGImageLoader: ObservableObject { + @Published var uiImage: UIImage? + + func updateImage(_ image: UIImage?) { + DispatchQueue.main.async { + self.uiImage = image + } + } +} + +class ImageCacheManager { + static let shared = ImageCacheManager() + + private let cache = NSCache() + private let expirationTime: TimeInterval = 300 // 5 minutes + + private init() {} + + func getCachedImage(for url: URL) -> UIImage? { + guard let cachedImage = cache.object(forKey: url as NSURL) else { + return nil + } + + if Date().timeIntervalSince(cachedImage.timestamp) > expirationTime { + cache.removeObject(forKey: url as NSURL) // Expired, remove it + return nil + } + + return cachedImage.image + } + + func cacheImage(_ image: UIImage, for url: URL) { + let cachedImage = CachedImage(image: image, timestamp: Date()) + cache.setObject(cachedImage, forKey: url as NSURL) + } +} + +// A wrapper for storing images with timestamps +class CachedImage: NSObject { + let image: UIImage + let timestamp: Date + + init(image: UIImage, timestamp: Date) { + self.image = image + self.timestamp = timestamp + } +} + struct DownloadableImageView: View { let url: URL? @StateObject private var imageLoader = SVGImageLoader() @State private var isLoading = true - private var asyncOperation: Task? + @State private var asyncOperation: Task? private let logger = Logger(subsystem: "org.openhab.app", category: "DownloadableImageView") var body: some View { @@ -72,12 +120,12 @@ struct DownloadableImageView: View { } print("Fetching fresh image from \(url)") - let asyncOperation = Task { + asyncOperation = Task { do { guard let client = NetworkTracker.shared.httpClient else { throw DownloadableImageError.nohttpClient } - let (data, urlresponse): (Data, URLResponse) = try await client.doRequest(baseURL: url, type: .data) + let (data, _): (Data, URLResponse) = try await client.doRequest(baseURL: url, type: .data) try await MainActor.run { let scaleFactor = WKInterfaceDevice.current().screenScale let options: [SDImageCoderOption: Any] = [ @@ -103,51 +151,3 @@ struct DownloadableImageView: View { asyncOperation?.cancel() } } - -class SVGImageLoader: ObservableObject { - @Published var uiImage: UIImage? - - func updateImage(_ image: UIImage?) { - DispatchQueue.main.async { - self.uiImage = image - } - } -} - -class ImageCacheManager { - static let shared = ImageCacheManager() - - private let cache = NSCache() - private let expirationTime: TimeInterval = 300 // 5 minutes - - private init() {} - - func getCachedImage(for url: URL) -> UIImage? { - guard let cachedImage = cache.object(forKey: url as NSURL) else { - return nil - } - - if Date().timeIntervalSince(cachedImage.timestamp) > expirationTime { - cache.removeObject(forKey: url as NSURL) // Expired, remove it - return nil - } - - return cachedImage.image - } - - func cacheImage(_ image: UIImage, for url: URL) { - let cachedImage = CachedImage(image: image, timestamp: Date()) - cache.setObject(cachedImage, forKey: url as NSURL) - } -} - -// A wrapper for storing images with timestamps -class CachedImage: NSObject { - let image: UIImage - let timestamp: Date - - init(image: UIImage, timestamp: Date) { - self.image = image - self.timestamp = timestamp - } -} From 2e7b0eb44025377a36057eb70d426992ad24f84f Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Sat, 1 Mar 2025 20:29:41 +0100 Subject: [PATCH 031/476] Attempt to get tests working again an GitHub Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- openHAB.xcodeproj/project.pbxproj | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index 466e8889f..531ef8efd 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -1254,7 +1254,6 @@ DA2DC22E21F2736C00830730 = { CreatedOnToolsVersion = 10.1; LastSwiftMigration = 1020; - ProvisioningStyle = Automatic; TestTargetID = DFB2622618830A3600D3244D; }; DAD0856B2AE4782A001D36BE = { @@ -1480,7 +1479,7 @@ files = ( DAC9AF4724F9669F006DAE93 /* OpenHABWidgetExtension.swift in Sources */, 65DAE9122D6FCB1A00E99582 /* DownloadableImageView.swift in Sources */, - DAC9AF4724F9669F006DAE93 /* ObservableOpenHABWidgetExtension.swift in Sources */, + DAC9AF4724F9669F006DAE93 /* OpenHABWidgetExtension.swift in Sources */, DA2E0B0E23DCC153009B0A99 /* MapView.swift in Sources */, DA2E0B1023DCC439009B0A99 /* MapViewRow.swift in Sources */, DA0F37D023D4ACC7007EAB48 /* SliderRow.swift in Sources */, @@ -2075,10 +2074,11 @@ CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CODE_SIGN_IDENTITY = "Apple Distribution"; - CODE_SIGN_STYLE = Manual; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 33; DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = PBAPXHRAM9; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; INFOPLIST_FILE = openHABTestsSwift/Info.plist; @@ -2093,6 +2093,7 @@ MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = org.openhab.openHABTestsSwift; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_INSTALL_OBJC_HEADER = NO; SWIFT_OBJC_BRIDGING_HEADER = ""; @@ -2156,11 +2157,11 @@ CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CODE_SIGN_IDENTITY = "Apple Distribution"; - CODE_SIGN_STYLE = Manual; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 33; DEBUG_INFORMATION_FORMAT = dwarf; - DEVELOPMENT_TEAM = D6A95UZXVC; + DEVELOPMENT_TEAM = PBAPXHRAM9; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; GCC_NO_COMMON_BLOCKS = YES; @@ -2173,6 +2174,7 @@ MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "org.openhab.openHABWatchSwiftUI-Watch-AppTests"; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SDKROOT = watchos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_EMIT_LOC_STRINGS = NO; @@ -2196,6 +2198,7 @@ CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_IDENTITY = "Apple Distribution"; + "CODE_SIGN_IDENTITY[sdk=watchos*]" = "Apple Development"; CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 33; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; From 948e74853b575111b6fa65403d8e424313a4c318 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Sat, 1 Mar 2025 21:10:22 +0100 Subject: [PATCH 032/476] Migrate to iPhone 16 Pro with iOS 18.1 as simulator Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- fastlane/Fastfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fastlane/Fastfile b/fastlane/Fastfile index f016e49b8..f1a461951 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -57,7 +57,7 @@ platform :ios do scheme: 'openHABTestsSwift', xcargs: '-skipPackagePluginValidation', testplan: 'openHABTests', - devices: ['iPhone 15 Pro (17.5)'], + devices: ['iPhone 16 Pro (18.1)'], clean: true ) end From c5f514212024b49e14ce31a8d5ba5d5057fe6203 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Sun, 2 Mar 2025 07:21:56 +0100 Subject: [PATCH 033/476] minor touch Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../xcshareddata/xcschemes/openHABTestsSwift.xcscheme | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/openHAB.xcodeproj/xcshareddata/xcschemes/openHABTestsSwift.xcscheme b/openHAB.xcodeproj/xcshareddata/xcschemes/openHABTestsSwift.xcscheme index 2b520ec53..3acbef85e 100644 --- a/openHAB.xcodeproj/xcshareddata/xcschemes/openHABTestsSwift.xcscheme +++ b/openHAB.xcodeproj/xcshareddata/xcschemes/openHABTestsSwift.xcscheme @@ -63,6 +63,15 @@ savedToolIdentifier = "" useCustomWorkingDirectory = "NO" debugDocumentVersioning = "YES"> + + + + From b52d9f63910e695f183f2fe39ceb8a9f3894a750 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Sun, 2 Mar 2025 15:55:14 +0100 Subject: [PATCH 034/476] Make watch compilable again Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- openHAB.xcodeproj/project.pbxproj | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index 531ef8efd..d0b0eba89 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -121,8 +121,8 @@ DAC65FC7236EDF3900F4501E /* SpinnerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC65FC6236EDF3900F4501E /* SpinnerViewController.swift */; }; DAC6608D236F771600F4501E /* PreferencesSwiftUIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC6608C236F771600F4501E /* PreferencesSwiftUIView.swift */; }; DAC9395522B00E7600C5F423 /* XCTestCaseExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC9395422B00E7600C5F423 /* XCTestCaseExtension.swift */; }; - DAC9AF4724F9669F006DAE93 /* OpenHABWidgetExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC9AF4624F9669F006DAE93 /* OpenHABWidgetExtension.swift */; }; DAC9AF4924F966FA006DAE93 /* LazyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC9AF4824F966FA006DAE93 /* LazyView.swift */; }; + DACA368E2D7440B9003CD237 /* OpenHABWidgetExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC9AF4624F9669F006DAE93 /* OpenHABWidgetExtension.swift */; }; DACE664A2C63B0760069E514 /* OpenAPIURLSession in Frameworks */ = {isa = PBXBuildFile; productRef = DACE66492C63B0760069E514 /* OpenAPIURLSession */; }; DACE664D2C63B0840069E514 /* OpenAPIRuntime in Frameworks */ = {isa = PBXBuildFile; productRef = DACE664C2C63B0840069E514 /* OpenAPIRuntime */; }; DAD085712AE4782D001D36BE /* OpenHABWatchAppTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAD085702AE4782D001D36BE /* OpenHABWatchAppTests.swift */; }; @@ -1477,10 +1477,10 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - DAC9AF4724F9669F006DAE93 /* OpenHABWidgetExtension.swift in Sources */, + DACA368E2D7440B9003CD237 /* OpenHABWidgetExtension.swift in Sources */, 65DAE9122D6FCB1A00E99582 /* DownloadableImageView.swift in Sources */, - DAC9AF4724F9669F006DAE93 /* OpenHABWidgetExtension.swift in Sources */, DA2E0B0E23DCC153009B0A99 /* MapView.swift in Sources */, + DA15BFBD23C6726400BD8ADA /* ObservableOpenHABDataObject.swift in Sources */, DA2E0B1023DCC439009B0A99 /* MapViewRow.swift in Sources */, DA0F37D023D4ACC7007EAB48 /* SliderRow.swift in Sources */, DA32D1B42C8C98C40018D974 /* IconWithAction.swift in Sources */, @@ -1503,7 +1503,6 @@ DAF457A023DA3E1C0018B495 /* SegmentRow.swift in Sources */, DAF4578923D79AA50018B495 /* DetailTextLabelView.swift in Sources */, DAAC30872CBBF0420041927F /* ContentView.swift in Sources */, - DA15BFBD23C6726400BD8ADA /* ObservableOpenHABDataObject.swift in Sources */, DAC9AF4924F966FA006DAE93 /* LazyView.swift in Sources */, DA0776F0234788010086C685 /* UserData.swift in Sources */, DAC6608D236F771600F4501E /* PreferencesSwiftUIView.swift in Sources */, From faa7e1bfec8115a15113c397bc5793a6ee8e6008 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Mon, 3 Mar 2025 07:36:09 +0100 Subject: [PATCH 035/476] HTTPClient now also takes over functionality of NetworkConnection in NewImageUITableViewCell Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../Sources/OpenHABCore/Util/HTTPClient.swift | 9 ++++- openHAB/NewImageUITableViewCell.swift | 37 ++++++++----------- openHAB/OpenHABSitemapViewController.swift | 2 - 3 files changed, 22 insertions(+), 26 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift b/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift index 24486840f..2e9647bb5 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift @@ -144,7 +144,8 @@ public class HTTPClient: NSObject { headers: [String: String]? = nil, timeout: TimeInterval = 60.0, body: String? = nil, - type: SessionType) async throws -> (T, URLResponse) { + type: SessionType, + cacheingPolicy: URLRequest.CachePolicy = .useProtocolCachePolicy) async throws -> (T, URLResponse) { guard var url = baseURL ?? self.baseURL else { os_log("doRequest ERROR: Base URL is nil", log: .networking, type: .info) throw HTTPClientError.baseURLIsNil @@ -167,6 +168,10 @@ public class HTTPClient: NSObject { request.setValue("text/plain", forHTTPHeaderField: "Content-Type") } + if cacheingPolicy != .useProtocolCachePolicy { + request.cachePolicy = cacheingPolicy + } + let (result, response): (T, URLResponse) = try await performRequest(request: request, type: type) if let response = response as? HTTPURLResponse { if (400 ... 599).contains(response.statusCode) { @@ -188,7 +193,7 @@ public class HTTPClient: NSObject { switch type { case .download: - return try await session.download(for: request) as! (T, URLResponse) // Ensure correct type + return try await session.download(for: request) as! (T, URLResponse) case .data: return try await session.data(for: request) as! (T, URLResponse) case .bytes: diff --git a/openHAB/NewImageUITableViewCell.swift b/openHAB/NewImageUITableViewCell.swift index 5f82739ad..b9501f3cd 100644 --- a/openHAB/NewImageUITableViewCell.swift +++ b/openHAB/NewImageUITableViewCell.swift @@ -27,6 +27,7 @@ class NewImageUITableViewCell: GenericUITableViewCell { private var refreshTimer: Timer? private var downloadRequest: Alamofire.Request? private var chartStyle: ChartStyle = .light + private var activeTask: Task? private var appData: OpenHABDataObject? { AppDelegate.appDelegate.appData @@ -151,32 +152,24 @@ class NewImageUITableViewCell: GenericUITableViewCell { private func loadRemoteImage(withURL url: URL) { os_log("Image URL: %{PUBLIC}@", log: OSLog.urlComposition, type: .debug, url.absoluteString) - var imageRequest = URLRequest(url: url) - imageRequest.timeoutInterval = 10.0 - if !shouldCache { - imageRequest.cachePolicy = .reloadIgnoringCacheData + if activeTask != nil { + activeTask?.cancel() + activeTask = nil } - if downloadRequest != nil { - downloadRequest?.cancel() - downloadRequest = nil - } - - downloadRequest = NetworkConnection.shared.manager.request(imageRequest) - .validate(statusCode: 200 ..< 300) - .responseData { [weak self] response in - switch response.result { - case .success: - if let data = response.data { - self?.mainImageView?.image = UIImage(data: data) - self?.widget?.image = UIImage(data: data) - self?.didLoad?() - } - case let .failure(error): - os_log("Download failed: %{PUBLIC}@", log: .urlComposition, type: .debug, error.localizedDescription) + activeTask = Task { + do { + let client = HTTPClient(username: Preferences.username, password: Preferences.username, alwaysSendBasicAuth: Preferences.alwaysSendCreds) + let (data, _): (Data, URLResponse) = try await client.doRequest(baseURL: url, timeout: 10.0, type: .data, cacheingPolicy: !shouldCache ? .reloadIgnoringCacheData : .useProtocolCachePolicy) + await MainActor.run { + self.mainImageView?.image = UIImage(data: data) + self.widget?.image = UIImage(data: data) + self.didLoad?() } + } catch { + os_log("Download failed: %{PUBLIC}@", log: .urlComposition, type: .debug, error.localizedDescription) } - downloadRequest?.resume() + } } @objc diff --git a/openHAB/OpenHABSitemapViewController.swift b/openHAB/OpenHABSitemapViewController.swift index c6017520c..a2ed1c5d0 100644 --- a/openHAB/OpenHABSitemapViewController.swift +++ b/openHAB/OpenHABSitemapViewController.swift @@ -66,8 +66,6 @@ struct OpenHABImageProcessor: ImageProcessor { class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCellTouchEventDelegate { var pageUrl = "" private var selectedWidgetRow: Int = 0 - private var currentPageOperation: Alamofire.Request? - private var commandOperation: Alamofire.Request? private var iconType: IconType = .png private var openHABRootUrl = "" private var openHABUsername = "" From 671037209c0cded7084a36bba465377527b949bb Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Mon, 3 Mar 2025 17:25:28 +0100 Subject: [PATCH 036/476] =?UTF-8?q?Refactored=20NetworkTracker=20to=20make?= =?UTF-8?q?=20use=20of=20structured=20concurrency=20with=20async/await=20M?= =?UTF-8?q?ore=20Readable=20&=20Maintainable=20=20=20=20=201.=20=20=20=20E?= =?UTF-8?q?liminates=20reliance=20on=20DispatchGroup,=20DispatchWorkItem,?= =?UTF-8?q?=20and=20timers=20for=20connection=20handling.=20=20=20=20=202.?= =?UTF-8?q?=20=20=20=20UI=20Updates=20Only=20on=20Main=20Thread=20?= =?UTF-8?q?=E2=80=93=20updateActiveConnection=20runs=20in=20@MainActor.=20?= =?UTF-8?q?/=20Uses=20Task.detached(priority:)=20for=20Background=20Execut?= =?UTF-8?q?ion=20=20=20=20=203.=20=20=20=20Efficient=20Connection=20Attemp?= =?UTF-8?q?t=20Logic=20=E2=80=93=20Iterates=20through=20sorted=20connectio?= =?UTF-8?q?n=20configurations=20to=20establish=20the=20best=20connection.?= =?UTF-8?q?=20=20=20=20=204.=20=20=20=20Better=20Error=20Handling=20?= =?UTF-8?q?=E2=80=93=20Throws=20meaningful=20errors=20and=20logs=20failure?= =?UTF-8?q?s=20more=20clearly.=20=20=20=20=205.=20=20=20=20Improved=20NWPa?= =?UTF-8?q?thMonitor=20Handling=20=E2=80=93=20Reacts=20to=20network=20chan?= =?UTF-8?q?ges=20asynchronously=20without=20blocking=20execution.=20=20=20?= =?UTF-8?q?=20=206.=20=20=20Removed=20.store(in:=20&cancellables)=20/=20No?= =?UTF-8?q?w=20that=20everything=20is=20async/await,=20there=E2=80=99s=20n?= =?UTF-8?q?o=20need=20for=20Combine=20publishers.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consequently migrated OpenHABItemCache, NotificationService, OpenHABRootViewController, OpenHABSitemapController, and all IntentHandlers Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- NotificationService/NotificationService.swift | 130 ++++---- .../GeneratedSources/openapi/Client.swift | 2 +- .../Sources/OpenHABCore/Util/Endpoint.swift | 8 - .../Sources/OpenHABCore/Util/Future.swift | 121 -------- .../OpenHABCore/Util/LoggingMiddleware.swift | 3 +- .../OpenHABCore/Util/NetworkTracker.swift | 290 +++++++----------- .../OpenHABCore/Util/OpenAPIService.swift | 22 +- .../OpenHABCore/Util/OpenHABItemCache.swift | 124 +++----- openHAB/OpenHABRootViewController.swift | 129 ++++---- openHAB/OpenHABSitemapViewController.swift | 39 ++- .../GetItemStateIntentHandler.swift | 21 +- .../SetColorValueIntentHandler.swift | 24 +- .../SetContactStateValueIntentHandler.swift | 24 +- .../SetDimmerRollerValueIntentHandler.swift | 24 +- .../SetNumberValueIntentHandler.swift | 24 +- .../SetStringValueIntentHandler.swift | 23 +- .../SetSwitchStateIntentHandler.swift | 24 +- 17 files changed, 417 insertions(+), 615 deletions(-) delete mode 100644 OpenHABCore/Sources/OpenHABCore/Util/Future.swift diff --git a/NotificationService/NotificationService.swift b/NotificationService/NotificationService.swift index 356fa4f5e..f26685093 100644 --- a/NotificationService/NotificationService.swift +++ b/NotificationService/NotificationService.swift @@ -22,6 +22,7 @@ enum NotificationServiceError: Error { case failedToParse case failedToDecode case handleNotificationCouldNotAttach + case noActiveConnection var localizedDescription: String { switch self { @@ -35,6 +36,8 @@ enum NotificationServiceError: Error { "Failed to decode base64 string to Data" case .handleNotificationCouldNotAttach: "HandleNotification could not attach" + case .noActiveConnection: + "No active connection" } } } @@ -160,48 +163,51 @@ class NotificationService: UNNotificationServiceExtension { } private func downloadAndAttachMedia(url: String) async throws -> UNNotificationAttachment? { - let client = HTTPClient(username: Preferences.username, password: Preferences.username, alwaysSendBasicAuth: Preferences.alwaysSendCreds) + let client = HTTPClient( + username: Preferences.username, + password: Preferences.password, + alwaysSendBasicAuth: Preferences.alwaysSendCreds + ) + if url.starts(with: "/") { - let connection1 = ConnectionConfiguration( - url: Preferences.localUrl, - priority: 0 - ) - let connection2 = ConnectionConfiguration( - url: Preferences.remoteUrl, - priority: 1 + let connection1 = ConnectionConfiguration(url: Preferences.localUrl, priority: 0) + let connection2 = ConnectionConfiguration(url: Preferences.remoteUrl, priority: 1) + + NetworkTracker.shared.startTracking( + connectionConfigurations: [connection1, connection2], + username: Preferences.username, + password: Preferences.password, + alwaysSendBasicAuth: Preferences.alwaysSendCreds, + ignoreSSLVerification: Preferences.ignoreSSL ) - NetworkTracker.shared.startTracking(connectionConfigurations: [connection1, connection2], username: Preferences.username, password: Preferences.password, alwaysSendBasicAuth: Preferences.alwaysSendCreds, ignoreSSLVerification: Preferences.ignoreSSL) - NetworkTracker.shared.waitForActiveConnection { activeConnection in - if let openHABUrl = activeConnection?.configuration.url, let uurl = URL(string: openHABUrl) { - Task { - let (url, urlResponse) = try await client.downloadFile(url: uurl.appendingPathComponent(url)) - return await self.attachFile(localURL: url, mimeType: urlResponse.mimeType) - } - } + + // Await the active connection + guard let activeConnection = await NetworkTracker.shared.waitForActiveConnection(), + let fullURL = URL(string: activeConnection.configuration.url)?.appendingPathComponent(url) else { + return nil } - .store(in: &cancellables) - } else if let uurl = URL(string: url) { - let (url, urlResponse) = try await client.downloadFile(url: uurl.appendingPathComponent(url)) - return await attachFile(localURL: url, mimeType: urlResponse.mimeType) + + let (localURL, urlResponse) = try await client.downloadFile(url: fullURL) + return await attachFile(localURL: localURL, mimeType: urlResponse.mimeType) + + } else if let fullURL = URL(string: url) { + let (localURL, urlResponse) = try await client.downloadFile(url: fullURL) + return await attachFile(localURL: localURL, mimeType: urlResponse.mimeType) } + return nil } func downloadAndAttachItemImage(itemURI: String) async throws -> UNNotificationAttachment? { - guard let itemURI = URL(string: itemURI), let scheme = itemURI.scheme else { + guard let itemURL = URL(string: itemURI), let scheme = itemURL.scheme else { throw NotificationServiceError.noScheme(itemURI) } - let itemName = String(itemURI.absoluteString.dropFirst(scheme.count + 1)) + let itemName = String(itemURL.absoluteString.dropFirst(scheme.count + 1)) + + let connection1 = ConnectionConfiguration(url: Preferences.localUrl, priority: 0) + let connection2 = ConnectionConfiguration(url: Preferences.remoteUrl, priority: 1) - let connection1 = ConnectionConfiguration( - url: Preferences.localUrl, - priority: 0 - ) - let connection2 = ConnectionConfiguration( - url: Preferences.remoteUrl, - priority: 1 - ) NetworkTracker.shared.startTracking( connectionConfigurations: [connection1, connection2], username: Preferences.username, @@ -210,37 +216,41 @@ class NotificationService: UNNotificationServiceExtension { ignoreSSLVerification: Preferences.ignoreSSL ) - return try await withCheckedThrowingContinuation { _ in - NetworkTracker.shared.waitForActiveConnection { [self] activeConnection in - if let openHABUrl = activeConnection?.configuration.url, let url = URL(string: openHABUrl) { - Task { - let client = await OpenAPIService( - baseURL: url, - username: Preferences.username, - password: Preferences.password, - alwaysSendBasicAuth: Preferences.alwaysSendCreds - ) - let item = try await client.getItemByName(id: itemName) - guard let state = item?.state else { return nil as UNNotificationAttachment? } - - // Extract MIME type and base64 string - let pattern = /^data:(.*?);base64,(.*)$/ - guard let firstMatch = state.firstMatch(of: pattern) else { throw NotificationServiceError.failedToParse } - - let mimeType = String(firstMatch.1) - let base64String = String(firstMatch.2) - guard let imageData = Data(base64Encoded: base64String) else { throw NotificationServiceError.failedToDecode } - // Create a temporary file URL - let tempDirectory = FileManager.default.temporaryDirectory - let tempFileURL = tempDirectory.appendingPathComponent(UUID().uuidString) - try imageData.write(to: tempFileURL) - os_log("Image saved to temporary file: %{PUBLIC}@", log: .default, type: .info, tempFileURL.absoluteString) - return await attachFile(localURL: tempFileURL, mimeType: mimeType) - } - } - } - .store(in: &cancellables) + // Await the active connection + guard let activeConnection = await NetworkTracker.shared.waitForActiveConnection(), + let baseURL = URL(string: activeConnection.configuration.url) else { + throw NotificationServiceError.noActiveConnection + } + + let client = await OpenAPIService( + baseURL: baseURL, + username: Preferences.username, + password: Preferences.password, + alwaysSendBasicAuth: Preferences.alwaysSendCreds + ) + + let item = try await client.getItemByName(id: itemName) + guard let state = item?.state else { return nil } + + // Extract MIME type and base64 string + let pattern = /^data:(.*?);base64,(.*)$/ + guard let firstMatch = state.firstMatch(of: pattern) else { + throw NotificationServiceError.failedToParse } + + let mimeType = String(firstMatch.1) + let base64String = String(firstMatch.2) + guard let imageData = Data(base64Encoded: base64String) else { + throw NotificationServiceError.failedToDecode + } + + // Create a temporary file URL + let tempDirectory = FileManager.default.temporaryDirectory + let tempFileURL = tempDirectory.appendingPathComponent(UUID().uuidString) + try imageData.write(to: tempFileURL) + + os_log("Image saved to temporary file: %{PUBLIC}@", log: .default, type: .info, tempFileURL.absoluteString) + return await attachFile(localURL: tempFileURL, mimeType: mimeType) } func attachFile(localURL: URL, mimeType: String?) async -> UNNotificationAttachment? { diff --git a/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Client.swift b/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Client.swift index c2a733968..2c7497aff 100644 --- a/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Client.swift +++ b/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Client.swift @@ -2054,7 +2054,7 @@ public struct Client: APIProtocol { forOperation: Operations.getRoot.id, serializer: { input in let path = try converter.renderedPath( - template: "//", + template: "/", parameters: [] ) var request: HTTPTypes.HTTPRequest = .init( diff --git a/OpenHABCore/Sources/OpenHABCore/Util/Endpoint.swift b/OpenHABCore/Sources/OpenHABCore/Util/Endpoint.swift index fb686e89b..6d5097293 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/Endpoint.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/Endpoint.swift @@ -85,14 +85,6 @@ public extension Endpoint { ) } - static func items(openHABRootUrl: String) -> Endpoint { - Endpoint( - baseURL: openHABRootUrl, - path: "/rest/items", - queryItems: [] - ) - } - static func resource(openHABRootUrl: String, path: String) -> Endpoint { Endpoint( baseURL: openHABRootUrl, diff --git a/OpenHABCore/Sources/OpenHABCore/Util/Future.swift b/OpenHABCore/Sources/OpenHABCore/Util/Future.swift deleted file mode 100644 index d0e398ba8..000000000 --- a/OpenHABCore/Sources/OpenHABCore/Util/Future.swift +++ /dev/null @@ -1,121 +0,0 @@ -// Copyright (c) 2010-2025 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 Foundation - -// swiftlint:disable:next file_types_order -public class Future { - public typealias Result = Swift.Result - - fileprivate var result: Result? { - // Observe whenever a result is assigned, and report it: - didSet { result.map(report) } - } - - private var callbacks = [(Result) -> Void]() - - public func observe(using callback: @escaping (Result) -> Void) { - // If a result has already been set, call the callback directly: - if let result { - return callback(result) - } - - callbacks.append(callback) - } - - private func report(result: Result) { - callbacks.forEach { $0(result) } - callbacks = [] - } -} - -public class Promise: Future { - public init(value: Value? = nil) { - super.init() - - // If the value was already known at the time the promise - // was constructed, we can report it directly: - result = value.map(Result.success) - } - - public func resolve(with value: Value) { - result = .success(value) - } - - public func reject(with error: Error) { - result = .failure(error) - } -} - -public enum NetworkingError: Error { - case invalidURL -} - -public typealias Networking = (Endpoint) -> Future - -extension Future { - func chained(using closure: @escaping (Value) throws -> Future) -> Future { - // We'll start by constructing a "wrapper" promise that will be - // returned from this method: - let promise = Promise() - - // Observe the current future: - observe { result in - switch result { - case let .success(value): - do { - // Attempt to construct a new future using the value - // returned from the first one: - let future = try closure(value) - - // Observe the "nested" future, and once it - // completes, resolve/reject the "wrapper" future: - future.observe { result in - switch result { - case let .success(value): - promise.resolve(with: value) - case let .failure(error): - promise.reject(with: error) - } - } - } catch { - promise.reject(with: error) - } - case let .failure(error): - promise.reject(with: error) - } - } - - return promise - } -} - -public extension Future { - func transformed(with closure: @escaping (Value) throws -> T) -> Future { - chained { value in - try Promise(value: closure(value)) - } - } -} - -// extension Future where Value == Data { -// func decoded() -> Future { -// decoded(as: T.self, using: JSONDecoder()) -// } -// } - -public extension Future where Value == Data { - func decoded(as type: T.Type = T.self, using decoder: JSONDecoder = .init()) -> Future { - transformed { data in - try decoder.decode(T.self, from: data) - } - } -} diff --git a/OpenHABCore/Sources/OpenHABCore/Util/LoggingMiddleware.swift b/OpenHABCore/Sources/OpenHABCore/Util/LoggingMiddleware.swift index 9abf300d0..6721cea86 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/LoggingMiddleware.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/LoggingMiddleware.swift @@ -40,6 +40,7 @@ extension LoggingMiddleware: ClientMiddleware { do { let (response, responseBody) = try await next(request, requestBodyForNext, baseURL) let (responseBodyToLog, responseBodyForNext) = try await bodyLoggingPolicy.process(responseBody) + logger.debug("Trying URL: \(baseURL) for operation ID:\(operationID)") log(request, response, responseBodyToLog) return (response, responseBodyForNext) } catch { @@ -52,7 +53,7 @@ extension LoggingMiddleware: ClientMiddleware { extension LoggingMiddleware { func log(_ request: HTTPRequest, _ requestBody: BodyLoggingPolicy.BodyLog) { logger.debug( - "Request: \(request.method, privacy: .public) \(request.path ?? "", privacy: .public) body: \(requestBody, privacy: .auto)" + "Request: \(request.method, privacy: .public) \(request.debugDescription, privacy: .public) body: \(requestBody, privacy: .auto)" ) } diff --git a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift index 56c491a43..b9e12b354 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift @@ -38,11 +38,12 @@ public struct ConnectionInfo: Equatable { enum NetworkTrackerError: Error, CustomDebugStringConvertible { case invalidServerVersion + case failedConnection(String) var debugDescription: String { switch self { - case .invalidServerVersion: - "Invalid server version" + case .invalidServerVersion: "Invalid server version" + case let .failedConnection(url): "Failed to connect to \(url)" } } } @@ -52,240 +53,185 @@ public final class NetworkTracker: ObservableObject { @Published public private(set) var activeConnection: ConnectionInfo? @Published public private(set) var status: NetworkStatus = .connecting - public private(set) var openApiService: OpenAPIService? - public private(set) var httpClient: HTTPClient? + public var openApiService: OpenAPIService? private let monitor: NWPathMonitor private let monitorQueue = DispatchQueue.global(qos: .background) - private var priorityWorkItem: DispatchWorkItem? private var connectionConfigurations: [ConnectionConfiguration] = [] - private var retryTimer: DispatchSourceTimer? - private let timerQueue = DispatchQueue(label: "org.openhab.networktracker.timerQueue") - private let connectedRetryInterval: TimeInterval = 60 // amount of time we scan for better connections when connected - private let disconnectedRetryInterval: TimeInterval = 30 // amount of time we scan when not connected + private var retryTask: Task? + public private(set) var httpClient: HTTPClient? - let logger = Logger(subsystem: "org.openhab.core", category: "NetworkTracker") + private let logger = Logger(subsystem: "org.openhab.core", category: "NetworkTracker") private init() { monitor = NWPathMonitor() monitor.pathUpdateHandler = { [weak self] path in - guard self?.openApiService != nil else { return } - if path.status == .satisfied { - os_log("Network status: Connected", log: OSLog.default, type: .info) - self?.checkActiveConnection() - } else { - os_log("Network status: Disconnected", log: OSLog.default, type: .info) - self?.setActiveConnection(nil) - self?.startRetryTimer(10) // try every 10 seconds connect - } + Task { await self?.handleNetworkChange(isConnected: path.status == .satisfied) } } monitor.start(queue: monitorQueue) } - public func startTracking(connectionConfigurations: [ConnectionConfiguration], username: String, password: String, alwaysSendBasicAuth: Bool, ignoreSSLVerification: Bool) { + public func waitForActiveConnection(timeout: TimeInterval = 10) async -> ConnectionInfo? { + await withCheckedContinuation { continuation in + let deadline = Date().addingTimeInterval(timeout) + + func checkConnection() { + Task { @MainActor in + if let activeConnection = self.activeConnection { + continuation.resume(returning: activeConnection) + } else if Date() >= deadline { + continuation.resume(returning: nil) + } else { + DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) { + checkConnection() + } + } + } + } + + checkConnection() + } + } + + public func startTracking(connectionConfigurations: [ConnectionConfiguration], + username: String, + password: String, + alwaysSendBasicAuth: Bool, + ignoreSSLVerification: Bool) { os_log("StartTracking", log: OSLog.default, type: .info) self.connectionConfigurations = adjustMyOpenHABHosts(in: connectionConfigurations) + Task { - openApiService = await OpenAPIService(username: username, password: password, alwaysSendBasicAuth: alwaysSendBasicAuth, ignoreSSL: ignoreSSLVerification) + openApiService = await OpenAPIService( + username: username, + password: password, + alwaysSendBasicAuth: alwaysSendBasicAuth, + ignoreSSL: ignoreSSLVerification + ) + await attemptConnection() } - setActiveConnection(nil) - attemptConnection() } - @discardableResult - public func waitForActiveConnection( - perform action: @escaping (ConnectionInfo?) -> Void - ) -> AnyCancellable { - os_log("WaitForActiveConnection", log: OSLog.default, type: .info) - - return $activeConnection - .filter { $0 != nil } // Only proceed if activeConnection is not nil - .first() // Automatically cancels after the first non-nil value - .receive(on: DispatchQueue.main) - .sink { activeConnection in - action(activeConnection) - } + public func restartTracking() { + Task { await attemptConnection() } } - public func restartTracking() { - attemptConnection() + private func handleNetworkChange(isConnected: Bool) async { + if isConnected { + os_log("Network status: Connected", log: OSLog.default, type: .info) + await checkActiveConnection() + } else { + os_log("Network status: Disconnected", log: OSLog.default, type: .info) + await updateActiveConnection(nil) + startRetryTask() + } } - // This gets called periodically when we have an active connection to make sure it's still the best choice - private func checkActiveConnection() { + private func checkActiveConnection() async { guard let activeConnection else { - // No active connection, proceed with the normal connection attempt - os_log("CheckActiveConnection attemptConnection", log: OSLog.default, type: .info) - attemptConnection() + os_log("No active connection, attempting to reconnect...", log: OSLog.default, type: .info) + await attemptConnection() return } - // Check if the active connection is reachable - if let url = URL(string: activeConnection.configuration.url) { - os_log("checkActiveConnection trying %{PUBLIC}@", log: OSLog.default, type: .info, url.absoluteString) - Task { - do { - await openApiService?.updateBaseURL(with: url) - try await openApiService?.getRoot() - logger.info("Network status: Active connection is reachable: \(activeConnection.configuration.url)") - } catch { - logger.error("Network status: Active connection is not reachable: \(activeConnection.configuration.url) \(error.localizedDescription)") - self.attemptConnection() // If not reachable, run the connection logic - } - } + do { + guard let url = URL(string: activeConnection.configuration.url) else { return } + await openApiService?.updateBaseURL(with: url) + try await openApiService?.getRoot() + logger.info("Active connection is reachable: \(activeConnection.configuration.url)") + } catch { + logger.error("Active connection failed: \(activeConnection.configuration.url) - \(error.localizedDescription)") + await attemptConnection() } } - private func attemptConnection() { + private func attemptConnection() async { guard !connectionConfigurations.isEmpty else { - os_log("Network status: No connection configurations available.", log: OSLog.default, type: .error) - setActiveConnection(nil) + logger.error("No connection configurations available.") + await updateActiveConnection(nil) return } - priorityWorkItem?.cancel() - os_log("Network status: Checking available connections....", log: OSLog.default, type: .info) - let dispatchGroup = DispatchGroup() - var highestPriorityConnection: ConnectionInfo? - var firstAvailableConnection: ConnectionInfo? - var checkOutstanding = false // Track if there are any checks still in progress - - let priorityWaitTime: TimeInterval = 2.0 - - // Set up the work item to handle the 2-second timeout - priorityWorkItem = DispatchWorkItem { [weak self] in - guard let self else { return } - // After 2 seconds, if no high-priority connection was found, check for first available connection - if let firstAvailableConnection, highestPriorityConnection == nil { - setActiveConnection(firstAvailableConnection) - } else if highestPriorityConnection == nil, checkOutstanding { - os_log("Network status: No connection responded in 2 seconds, waiting for checks to finish.", log: OSLog.default, type: .info) - } else { - os_log("Network status: No connection responded in 2 seconds and no checks are outstanding.", log: OSLog.default, type: .error) - setActiveConnection(nil) + + logger.info("Checking available connections...") + + let sortedConfigs = connectionConfigurations.sorted { $0.priority < $1.priority } + var bestConnection: ConnectionInfo? + + await withTaskGroup(of: ConnectionInfo?.self) { group in + for config in sortedConfigs { + group.addTask { + await self.testConnection(configuration: config) + } } - } - // Begin checking each connection configuration in parallel - for configuration in connectionConfigurations { - dispatchGroup.enter() - checkOutstanding = true // Signal that checks are outstanding - os_log("attemptConnection trying %{PUBLIC}@", log: OSLog.default, type: .info, configuration.url) - if let url = URL(string: configuration.url) { - Task { - defer { - dispatchGroup.leave() // When each check completes, this signals the group that it's done + for await connection in group { + if let connection { + if connection.configuration.priority == 0 { + await updateActiveConnection(connection) + return } - - do { - await openApiService?.updateBaseURL(with: url) - let serverProperties = try await openApiService?.getRoot() - - let version = Int(serverProperties?.version ?? "0") - guard let version, version > 1 else { throw NetworkTrackerError.invalidServerVersion } - let connectionInfo = ConnectionInfo(configuration: configuration, version: version) - if configuration.priority == 0, highestPriorityConnection == nil { - // Found a high-priority (0) connection - highestPriorityConnection = connectionInfo - priorityWorkItem?.cancel() // Stop the 2-second wait if highest priority succeeds - setActiveConnection(connectionInfo) - } else if highestPriorityConnection == nil { - // Check if this connection has a higher priority than the current firstAvailableConnection - let connectionInfo = ConnectionInfo(configuration: configuration, version: version) - if firstAvailableConnection == nil || configuration.priority < firstAvailableConnection!.configuration.priority { - logger.info("Found a higher priority available connection: \(configuration.url)") - firstAvailableConnection = connectionInfo - } - } - } catch let error as NetworkTrackerError { - logger.error("\(error.debugDescription)") - } catch { - logger.error("Failed to connect to \(configuration.url)") + if bestConnection == nil || connection.configuration.priority < bestConnection!.configuration.priority { + bestConnection = connection } } } } - // Start a timer that waits for 2 seconds - DispatchQueue.global().asyncAfter(deadline: .now() + priorityWaitTime, execute: priorityWorkItem!) - - // When all checks complete, finalize logic based on connection status - dispatchGroup.notify(queue: .main) { [weak self] in - guard let self else { return } + await updateActiveConnection(bestConnection) + } - // All checks are finished here, so no outstanding checks - checkOutstanding = false + private func testConnection(configuration: ConnectionConfiguration) async -> ConnectionInfo? { + guard let url = URL(string: configuration.url) else { return nil } - // If a high-priority connection was already established, we are done - if let highestPriorityConnection { - os_log("Network status: High-priority connection established with %{PUBLIC}@", log: OSLog.default, type: .info, highestPriorityConnection.configuration.url) - return - } + do { + let service = await OpenAPIService( + baseURL: url, + username: Preferences.username, + password: Preferences.password + ) + let serverProperties = try await service.getRoot() - // If we have an available connection and no high-priority connection, set the first available - if let firstAvailableConnection { - setActiveConnection(firstAvailableConnection) - os_log("Network status: First available connection established with %{PUBLIC}@", log: OSLog.default, type: .info, firstAvailableConnection.configuration.url) - } else { - os_log("Network status: No connection responded, connection failed.", log: OSLog.default, type: .error) - setActiveConnection(nil) + guard let version = Int(serverProperties.version ?? "0"), version > 1 else { + throw NetworkTrackerError.invalidServerVersion } - } - } - // Start the retry timer to attempt connection every N seconds - private func startRetryTimer(_ retryInterval: TimeInterval) { - cancelRetryTimer() - timerQueue.sync { - retryTimer = DispatchSource.makeTimerSource(queue: timerQueue) - retryTimer?.schedule(deadline: .now() + retryInterval, repeating: retryInterval) - retryTimer?.setEventHandler { [weak self] in - os_log("Network status: Retry timer firing", log: OSLog.default, type: .info) - self?.attemptConnection() - } - retryTimer?.resume() + let connectionInfo = ConnectionInfo(configuration: configuration, version: version) + logger.info("Successfully connected to \(configuration.url)") + return connectionInfo + } catch { + logger.error("Failed to connect to \(configuration.url) - \(error.localizedDescription)") + return nil } } - private func cancelRetryTimer() { - timerQueue.sync { - retryTimer?.cancel() - retryTimer = nil + private func startRetryTask() { + retryTask?.cancel() + retryTask = Task { + try? await Task.sleep(nanoseconds: 10_000_000_000) // 10 seconds + await attemptConnection() } } - private func setActiveConnection(_ connection: ConnectionInfo?) { - os_log("Network status: setActiveConnection: %{PUBLIC}@", log: OSLog.default, type: .info, connection?.configuration.url ?? "no connection") + @MainActor + private func updateActiveConnection(_ connection: ConnectionInfo?) async { guard activeConnection != connection else { return } activeConnection = connection - if let activeConnection { - updateStatus(.connected) - Task { - await openApiService?.updateBaseURL(with: URL(string: activeConnection.configuration.url) ?? URL(staticString: "about:blank")) - } - // startRetryTimer(connectedRetryInterval) + status = (connection != nil) ? .connected : .notConnected + if let connection { + await openApiService?.updateBaseURL(with: URL(string: connection.configuration.url) ?? URL(string: "about:blank")!) } else { - updateStatus(.notConnected) - startRetryTimer(disconnectedRetryInterval) - } - } - - private func updateStatus(_ newStatus: NetworkStatus) { - if status != newStatus { - status = newStatus + startRetryTask() } } private func adjustMyOpenHABHosts(in configurations: [ConnectionConfiguration]) -> [ConnectionConfiguration] { configurations.map { configuration in - let updatedURL: String + var updatedURL = configuration.url if let urlComponents = URLComponents(string: configuration.url), - let host = urlComponents.host, - host.contains("myopenhab.org"), host != "home.myopenhab.org" { + let host = urlComponents.host, host.contains("myopenhab.org"), host != "home.myopenhab.org" { var newComponents = urlComponents newComponents.host = "home.myopenhab.org" updatedURL = newComponents.url?.absoluteString ?? configuration.url - } else { - updatedURL = configuration.url } return ConnectionConfiguration(url: updatedURL, priority: configuration.priority) } diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift index 7101e83f0..54884da22 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift @@ -29,17 +29,22 @@ public enum OpenAPIServiceError: Error { } public actor OpenAPIService { - private var client: Client + private var client: any APIProtocol private var url: URL? private var longPolling = false - private let username: String - private let password: String - private let alwaysSendBasicAuth: Bool - private let ignoreSSL: Bool + private var username: String = "" + private var password: String = "" + private var alwaysSendBasicAuth: Bool = false + private var ignoreSSL: Bool = false private let logger = Logger(subsystem: "org.openhab.app", category: "OpenAPIService") + /// Creates a new client for GreetingService. + public init(client: any APIProtocol) { + self.client = client + } + public init( baseURL url: URL = URL(staticString: "about:blank"), username: String, @@ -138,8 +143,11 @@ extension OpenAPIService: OpenHABUiTileService { public extension OpenAPIService { @discardableResult func getRoot() async throws -> OpenHABServerProperties { - let result = try await client.getRoot() - .ok.body.json + let interimResult = try await client.getRoot() + let result = try interimResult + .ok + .body + .json return OpenHABServerProperties(result) } } diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift index c2ca2172a..a128fbf86 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift @@ -33,109 +33,83 @@ public class OpenHABItemCache { NetworkTracker.shared.startTracking(connectionConfigurations: [connection1, connection2], username: Preferences.username, password: Preferences.password, alwaysSendBasicAuth: Preferences.alwaysSendCreds, ignoreSSLVerification: Preferences.ignoreSSL) } - public func getItemNames(searchTerm: String?, types: [OpenHABItem.ItemType]?, completion: @escaping ([NSString]) -> Void) { - var ret = [NSString]() - + public func getItemNames(searchTerm: String?, types: [OpenHABItem.ItemType]?) -> [NSString] { guard let items else { - reload(searchTerm: searchTerm, types: types, completion: completion) - return + return [] } - ret.append(contentsOf: items + return items .filter { (searchTerm == nil || $0.name.contains(searchTerm.orEmpty)) && (types == nil || ($0.type != nil && types!.contains($0.type!))) } .sorted(by: \.name) - .map { NSString(string: $0.name) }) - - completion(ret) + .map { NSString(string: $0.name) } } - public func getItem(name: String, completion: @escaping (OpenHABItem?) -> Void) { + public func getItem(name: String) async -> OpenHABItem? { let now = Date().timeIntervalSince1970 - if items == nil || (now - lastLoad) > 10 { // More than 10 seconds - reload - reload(name: name, completion: completion) - return + if items == nil || (now - lastLoad) > 10 { + return await reload(name: name) } - completion(getItem(name)) + return getItem(name) } func getItem(_ name: String) -> OpenHABItem? { items?.first { $0.name == name } } - public func sendCommand(_ item: OpenHABItem, commandToSend command: String) { - NetworkTracker.shared.waitForActiveConnection { activeConnection in - if let openHABUrl = activeConnection?.configuration.url, let url = URL(string: openHABUrl) { - Task { - await NetworkTracker.shared.openApiService?.updateBaseURL(with: url) - try await NetworkTracker.shared.openApiService?.sendItemCommand(itemname: item.name, command: command) - } - } + public func sendCommand(_ item: OpenHABItem, commandToSend command: String) async { + if let activeConnection = await NetworkTracker.shared.waitForActiveConnection(), + let url = URL(string: activeConnection.configuration.url) { + await NetworkTracker.shared.openApiService?.updateBaseURL(with: url) + try? await NetworkTracker.shared.openApiService?.sendItemCommand(itemname: item.name, command: command) } } - public func sendState(_ item: OpenHABItem, stateToSend state: String) { - NetworkTracker.shared.waitForActiveConnection { activeConnection in - if let openHABUrl = activeConnection?.configuration.url, let url = URL(string: openHABUrl) { - Task { - await NetworkTracker.shared.openApiService?.updateBaseURL(with: url) - try await NetworkTracker.shared.openApiService?.updateItemState(itemname: item.name, with: state) - } - } + public func sendState(_ item: OpenHABItem, stateToSend state: String) async { + if let activeConnection = await NetworkTracker.shared.waitForActiveConnection(), + let url = URL(string: activeConnection.configuration.url) { + await NetworkTracker.shared.openApiService?.updateBaseURL(with: url) + try? await NetworkTracker.shared.openApiService?.updateItemState(itemname: item.name, with: state) } } - public func reload(searchTerm: String?, types: [OpenHABItem.ItemType]?, completion: @escaping ([NSString]) -> Void) { - NetworkTracker.shared.waitForActiveConnection { activeConnection in - if let openHABUrl = activeConnection?.configuration.url, let url = URL(string: openHABUrl) { - os_log("OpenHABItemCache Loading items ") - self.lastLoad = Date().timeIntervalSince1970 - Task { - do { - await NetworkTracker.shared.openApiService?.updateBaseURL(with: url) - self.items = try await NetworkTracker.shared.openApiService?.getItems() - os_log("Loaded items to cache: %{PUBLIC}d", log: .default, type: .info, self.items?.count ?? 0) - - let ret = self.items? - .filter { - $0.type != .group && - (searchTerm == nil || $0.name.contains(searchTerm.orEmpty)) && - (types == nil || ($0.type != nil && types!.contains($0.type!))) - } - .sorted(by: \.name) - .map { NSString(string: $0.name) } ?? [] - completion(ret) - } catch { - os_log("OpenHABItemCache %{PUBLIC}@ ", log: .default, type: .error, error.localizedDescription) - } - } - } + public func reload(searchTerm: String?, types: [OpenHABItem.ItemType]?) async -> [NSString] { + guard let activeConnection = await NetworkTracker.shared.waitForActiveConnection(), + let url = URL(string: activeConnection.configuration.url) else { + return [] + } + + os_log("OpenHABItemCache Loading items ") + lastLoad = Date().timeIntervalSince1970 + + do { + await NetworkTracker.shared.openApiService?.updateBaseURL(with: url) + items = try await NetworkTracker.shared.openApiService?.getItems() + os_log("Loaded items to cache: %{PUBLIC}d", log: .default, type: .info, self.items?.count ?? 0) + return getItemNames(searchTerm: searchTerm, types: types) + } catch { + os_log("OpenHABItemCache %{PUBLIC}@ ", log: .default, type: .error, error.localizedDescription) + return [] } - .store(in: &cancellables) } - public func reload(name: String, completion: @escaping (OpenHABItem?) -> Void) { - NetworkTracker.shared.waitForActiveConnection { activeConnection in - if let openHABUrl = activeConnection?.configuration.url, let url = URL(string: openHABUrl) { - Task { - do { - await NetworkTracker.shared.openApiService?.updateBaseURL(with: url) - self.items = try await NetworkTracker.shared.openApiService?.getItems() - os_log("Loaded items to cache: %{PUBLIC}d", log: .default, type: .info, self.items?.count ?? 0) - let ret = self.items?.filter { - $0.type != .group - } - .first { $0.name == name } - completion(ret) - } catch { - os_log("OpenHABItemCache %{PUBLIC}@ ", log: .default, type: .error, error.localizedDescription) - } - } - } + public func reload(name: String) async -> OpenHABItem? { + guard let activeConnection = await NetworkTracker.shared.waitForActiveConnection(), + let url = URL(string: activeConnection.configuration.url) else { + return nil + } + + do { + await NetworkTracker.shared.openApiService?.updateBaseURL(with: url) + items = try await NetworkTracker.shared.openApiService?.getItems() + os_log("Loaded items to cache: %{PUBLIC}d", log: .default, type: .info, self.items?.count ?? 0) + return items?.first { $0.name == name } + } catch { + os_log("OpenHABItemCache %{PUBLIC}@ ", log: .default, type: .error, error.localizedDescription) + return nil } - .store(in: &cancellables) } } diff --git a/openHAB/OpenHABRootViewController.swift b/openHAB/OpenHABRootViewController.swift index c17a49509..77e87db5c 100644 --- a/openHAB/OpenHABRootViewController.swift +++ b/openHAB/OpenHABRootViewController.swift @@ -326,13 +326,17 @@ class OpenHABRootViewController: UIViewController { if action.hasPrefix("ui") { uiCommandAction(cmd, completionHandler: completionHandler) } else if action.hasPrefix("command") { - sendCommandAction(cmd, completionHandler: completionHandler) + Task { + await sendCommandAction(cmd, completionHandler: completionHandler) + } } else if action.hasPrefix("http") { httpCommandAction(action, completionHandler: completionHandler) } else if action.hasPrefix("app") { appCommandAction(action, completionHandler: completionHandler) } else if action.hasPrefix("rule") { - ruleCommandAction(action, completionHandler: completionHandler) + Task { + await ruleCommandAction(action, completionHandler: completionHandler) + } } else { if let completionHandler { DispatchQueue.main.async { @@ -364,7 +368,9 @@ class OpenHABRootViewController: UIViewController { let sitemap = queryItems?.first { $0.name == "sitemap" }?.value let subview = queryItems?.first { $0.name == "w" }?.value if let sitemap { - sitemapViewController.pushSitemap(name: sitemap, path: subview) + Task { + await sitemapViewController.pushSitemap(name: sitemap, path: subview) + } } } } else { @@ -389,46 +395,40 @@ class OpenHABRootViewController: UIViewController { } } - private func sendCommandAction(_ action: String, completionHandler: (() -> Void)? = nil) { + private func sendCommandAction(_ action: String, completionHandler: (() -> Void)? = nil) async { let components = action.split(separator: ":") - if components.count == 2 { - let itemName = String(components[0]) - let itemCommand = String(components[1]) - NetworkTracker.shared.waitForActiveConnection { activeConnection in - if let openHABUrl = activeConnection?.configuration.url, let url = URL(string: openHABUrl) { - os_log("Sending comand", log: .default, type: .error) - Task { - do { - let openAPIService = await OpenAPIService(username: Preferences.username, password: Preferences.password) - await openAPIService.updateBaseURL(with: url) - try await openAPIService.sendItemCommand(itemname: itemName, command: itemCommand) - } catch { - logger.error("Could not send data \(error.localizedDescription)") - self.displayErrorNotification("request to \(openHABUrl) \(error.localizedDescription)") - } - if let completionHandler { - DispatchQueue.main.async { - completionHandler() - } - } - } - } else { - self.displayErrorNotification("Could not find server") - if let completionHandler { - DispatchQueue.main.async { - completionHandler() - } - } - } - } - .store(in: &cancellables) - } else { - if let completionHandler { - DispatchQueue.main.async { - completionHandler() - } + guard components.count == 2 else { + completionHandler?() + return + } + + let itemName = String(components[0]) + let itemCommand = String(components[1]) + + do { + guard let activeConnection = await NetworkTracker.shared.waitForActiveConnection(), + let url = URL(string: activeConnection.configuration.url) else { + displayErrorNotification("Could not find server") + completionHandler?() + return } + + os_log("Sending command", log: .default, type: .error) + + let openAPIService = await OpenAPIService(username: Preferences.username, password: Preferences.password) + await openAPIService.updateBaseURL(with: url) + + try await openAPIService.sendItemCommand(itemname: itemName, command: itemCommand) + + } catch { + displayErrorNotification("Failed to establish a connection: \(error.localizedDescription)") + // TODOD +// logger.error("Could not send data \(error.localizedDescription)") +// +// self.displayErrorNotification("Request to \(url) failed: \(error.localizedDescription)") } + + completionHandler?() } private func displayErrorNotification(_ message: String, completionHandler: (() -> Void)? = nil) { @@ -481,7 +481,7 @@ class OpenHABRootViewController: UIViewController { } } - private func ruleCommandAction(_ command: String, completionHandler: (() -> Void)? = nil) { + private func ruleCommandAction(_ command: String, completionHandler: (() -> Void)? = nil) async { let components = command.split(separator: ":", maxSplits: 2) guard components.count == 3, components[0] == "rule" else { return } @@ -501,34 +501,29 @@ class OpenHABRootViewController: UIViewController { } } - NetworkTracker.shared.waitForActiveConnection { activeConnection in - if let openHABUrl = activeConnection?.configuration.url, let url = URL(string: openHABUrl) { - os_log("Sending comand", log: .default, type: .error) - Task { - do { - let openAPIService = await OpenAPIService(username: Preferences.username, password: Preferences.password) - let data = try await openAPIService.runNow(ruleUID: uuid, payload: properties) - logger.info("Request succeeded") - } catch { - logger.error("Could not send data \(error.localizedDescription)") - self.displayErrorNotification("request to \(openHABUrl) \(error.localizedDescription)") - } - if let completionHandler { - DispatchQueue.main.async { - completionHandler() - } - } - } - } else { - self.displayErrorNotification("Could not find active server") - if let completionHandler { - DispatchQueue.main.async { - completionHandler() - } - } + do { + guard let activeConnection = await NetworkTracker.shared.waitForActiveConnection(), + let url = URL(string: activeConnection.configuration.url) else { + displayErrorNotification("Could not find active server") + completionHandler?() + return } + + os_log("Sending command", log: .default, type: .error) + + let openAPIService = await OpenAPIService(username: Preferences.username, password: Preferences.password) + let data = try await openAPIService.runNow(ruleUID: uuid, payload: properties) + logger.info("Request succeeded") + + } catch { + logger.error("Could not send data \(error.localizedDescription)") + displayErrorNotification("Request to server failed: \(error.localizedDescription)") + } + + // Ensure completionHandler is executed on the main thread + DispatchQueue.main.async { + completionHandler?() } - .store(in: &cancellables) } func showSideMenu() { diff --git a/openHAB/OpenHABSitemapViewController.swift b/openHAB/OpenHABSitemapViewController.swift index a2ed1c5d0..a2d511624 100644 --- a/openHAB/OpenHABSitemapViewController.swift +++ b/openHAB/OpenHABSitemapViewController.swift @@ -480,23 +480,32 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel } // This is mainly used for navigting to a specific sitemap and path from notifications - func pushSitemap(name: String, path: String?) { - // this will be called imediately after connecting for the initial state, otherwise it will wait for the state to change - // since we do not reference the sink cancelable, this will only fire once - NetworkTracker.shared.waitForActiveConnection { activeConnection in - if let openHABUrl = activeConnection?.configuration.url { - os_log("pushSitemap: pushing page", log: .default, type: .error) - let newViewController = (self.storyboard?.instantiateViewController(withIdentifier: "OpenHABPageViewController") as? OpenHABSitemapViewController)! - if let path { - newViewController.pageUrl = "\(openHABUrl)/rest/sitemaps/\(name)/\(path)" - } else { - newViewController.pageUrl = "\(openHABUrl)/rest/sitemaps/\(name)" - } - newViewController.openHABRootUrl = openHABUrl - self.navigationController?.pushViewController(newViewController, animated: true) + + // This is mainly used for navigating to a specific sitemap and path from notifications + func pushSitemap(name: String, path: String?) async { + do { + guard let activeConnection = await NetworkTracker.shared.waitForActiveConnection() else { + os_log("pushSitemap: No active connection available", log: .default, type: .error) + return } + + os_log("pushSitemap: pushing page", log: .default, type: .error) + + guard let newViewController = storyboard?.instantiateViewController(withIdentifier: "OpenHABPageViewController") as? OpenHABSitemapViewController else { + os_log("pushSitemap: Failed to instantiate OpenHABSitemapViewController", log: .default, type: .error) + return + } + let openHABUrl = activeConnection.configuration.url + + newViewController.pageUrl = path != nil + ? "\(openHABUrl)/rest/sitemaps/\(name)/\(path!)" + : "\(openHABUrl)/rest/sitemaps/\(name)" + newViewController.openHABRootUrl = openHABUrl + + navigationController?.pushViewController(newViewController, animated: true) + } catch { + os_log("pushSitemap: Error waiting for active connection: %{PUBLIC}@", log: .default, type: .error, error.localizedDescription) } - .store(in: &trackerCancellables) } // load app settings diff --git a/openHABIntents/GetItemStateIntentHandler.swift b/openHABIntents/GetItemStateIntentHandler.swift index 1ad8746bd..6bb32b2db 100644 --- a/openHABIntents/GetItemStateIntentHandler.swift +++ b/openHABIntents/GetItemStateIntentHandler.swift @@ -16,19 +16,17 @@ import os.log class GetItemStateIntentHandler: NSObject, OpenHABGetItemStateIntentHandling { func provideItemOptionsCollection(for intent: OpenHABGetItemStateIntent, searchTerm: String?, with completion: @escaping (INObjectCollection?, Error?) -> Void) { - OpenHABItemCache.instance.getItemNames(searchTerm: searchTerm, types: nil) { items in - let retItems = INObjectCollection(items: items) - // Call the completion handler, passing the collection. - completion(retItems, nil) - } + let items = OpenHABItemCache.instance.getItemNames(searchTerm: searchTerm, types: nil) + let retItems = INObjectCollection(items: items) + // Call the completion handler, passing the collection. + completion(retItems, nil) } func provideItemOptionsCollection(for intent: OpenHABGetItemStateIntent, with completion: @escaping (INObjectCollection?, Error?) -> Void) { - OpenHABItemCache.instance.getItemNames(searchTerm: nil, types: nil) { items in - let retItems = INObjectCollection(items: items) - // Call the completion handler, passing the collection. - completion(retItems, nil) - } + let items = OpenHABItemCache.instance.getItemNames(searchTerm: nil, types: nil) + let retItems = INObjectCollection(items: items) + // Call the completion handler, passing the collection. + completion(retItems, nil) } func confirm(intent: OpenHABGetItemStateIntent, completion: @escaping (OpenHABGetItemStateIntentResponse) -> Void) { @@ -43,7 +41,8 @@ class GetItemStateIntentHandler: NSObject, OpenHABGetItemStateIntentHandling { return } - OpenHABItemCache.instance.getItem(name: itemName) { item in + Task { + let item = await OpenHABItemCache.instance.getItem(name: itemName) guard let item else { completion(OpenHABGetItemStateIntentResponse.failureInvalidItem(itemName)) return diff --git a/openHABIntents/SetColorValueIntentHandler.swift b/openHABIntents/SetColorValueIntentHandler.swift index 671643887..da464316a 100644 --- a/openHABIntents/SetColorValueIntentHandler.swift +++ b/openHABIntents/SetColorValueIntentHandler.swift @@ -16,19 +16,17 @@ import os.log class SetColorValueIntentHandler: NSObject, OpenHABSetColorValueIntentHandling { func provideItemOptionsCollection(for intent: OpenHABSetColorValueIntent, searchTerm: String?, with completion: @escaping (INObjectCollection?, Error?) -> Void) { - OpenHABItemCache.instance.getItemNames(searchTerm: searchTerm, types: [OpenHABItem.ItemType.color]) { items in - let retItems = INObjectCollection(items: items) - // Call the completion handler, passing the collection. - completion(retItems, nil) - } + let items = OpenHABItemCache.instance.getItemNames(searchTerm: searchTerm, types: [OpenHABItem.ItemType.color]) + let retItems = INObjectCollection(items: items) + // Call the completion handler, passing the collection. + completion(retItems, nil) } func provideItemOptionsCollection(for intent: OpenHABSetColorValueIntent, with completion: @escaping (INObjectCollection?, Error?) -> Void) { - OpenHABItemCache.instance.getItemNames(searchTerm: nil, types: [OpenHABItem.ItemType.color]) { items in - let retItems = INObjectCollection(items: items) - // Call the completion handler, passing the collection. - completion(retItems, nil) - } + let items = OpenHABItemCache.instance.getItemNames(searchTerm: nil, types: [OpenHABItem.ItemType.color]) + let retItems = INObjectCollection(items: items) + // Call the completion handler, passing the collection. + completion(retItems, nil) } func confirm(intent: OpenHABSetColorValueIntent, completion: @escaping (OpenHABSetColorValueIntentResponse) -> Void) { @@ -62,13 +60,13 @@ class SetColorValueIntentHandler: NSObject, OpenHABSetColorValueIntentHandling { return } value = "\(hue),\(sat),\(val)" - - OpenHABItemCache.instance.getItem(name: itemName) { item in + Task { + let item = await OpenHABItemCache.instance.getItem(name: itemName) guard let item else { completion(OpenHABSetColorValueIntentResponse.failureInvalidItem(itemName)) return } - OpenHABItemCache.instance.sendCommand(item, commandToSend: value) + await OpenHABItemCache.instance.sendCommand(item, commandToSend: value) completion(OpenHABSetColorValueIntentResponse.success(value: value, item: itemName)) } diff --git a/openHABIntents/SetContactStateValueIntentHandler.swift b/openHABIntents/SetContactStateValueIntentHandler.swift index 8c47fba13..264e1dd0c 100644 --- a/openHABIntents/SetContactStateValueIntentHandler.swift +++ b/openHABIntents/SetContactStateValueIntentHandler.swift @@ -28,19 +28,17 @@ class SetContactStateValueIntentHandler: NSObject, OpenHABSetContactStateValueIn } func provideItemOptionsCollection(for intent: OpenHABSetContactStateValueIntent, searchTerm: String?, with completion: @escaping (INObjectCollection?, Error?) -> Void) { - OpenHABItemCache.instance.getItemNames(searchTerm: searchTerm, types: [OpenHABItem.ItemType.contact]) { items in - let retItems = INObjectCollection(items: items) - // Call the completion handler, passing the collection. - completion(retItems, nil) - } + let items = OpenHABItemCache.instance.getItemNames(searchTerm: searchTerm, types: [OpenHABItem.ItemType.contact]) + let retItems = INObjectCollection(items: items) + // Call the completion handler, passing the collection. + completion(retItems, nil) } func provideItemOptionsCollection(for intent: OpenHABSetContactStateValueIntent, with completion: @escaping (INObjectCollection?, Error?) -> Void) { - OpenHABItemCache.instance.getItemNames(searchTerm: nil, types: [OpenHABItem.ItemType.contact]) { items in - let retItems = INObjectCollection(items: items) - // Call the completion handler, passing the collection. - completion(retItems, nil) - } + let items = OpenHABItemCache.instance.getItemNames(searchTerm: nil, types: [OpenHABItem.ItemType.contact]) + let retItems = INObjectCollection(items: items) + // Call the completion handler, passing the collection. + completion(retItems, nil) } func confirm(intent: OpenHABSetContactStateValueIntent, completion: @escaping (OpenHABSetContactStateValueIntentResponse) -> Void) { @@ -65,13 +63,13 @@ class SetContactStateValueIntentHandler: NSObject, OpenHABSetContactStateValueIn completion(OpenHABSetContactStateValueIntentResponse.failureInvalidAction(state: state, item: itemName)) return } - - OpenHABItemCache.instance.getItem(name: itemName) { item in + Task { + let item = await OpenHABItemCache.instance.getItem(name: itemName) guard let item else { completion(OpenHABSetContactStateValueIntentResponse.failureInvalidItem(itemName)) return } - OpenHABItemCache.instance.sendState(item, stateToSend: realState) + await OpenHABItemCache.instance.sendState(item, stateToSend: realState) completion(OpenHABSetContactStateValueIntentResponse.success(item: itemName, state: state)) } diff --git a/openHABIntents/SetDimmerRollerValueIntentHandler.swift b/openHABIntents/SetDimmerRollerValueIntentHandler.swift index d86d7adbd..d40edfcb1 100644 --- a/openHABIntents/SetDimmerRollerValueIntentHandler.swift +++ b/openHABIntents/SetDimmerRollerValueIntentHandler.swift @@ -16,19 +16,17 @@ import os.log class SetDimmerRollerValueIntentHandler: NSObject, OpenHABSetDimmerRollerValueIntentHandling { func provideItemOptionsCollection(for intent: OpenHABSetDimmerRollerValueIntent, searchTerm: String?, with completion: @escaping (INObjectCollection?, Error?) -> Void) { - OpenHABItemCache.instance.getItemNames(searchTerm: searchTerm, types: [OpenHABItem.ItemType.dimmer, OpenHABItem.ItemType.rollershutter]) { items in - let retItems = INObjectCollection(items: items) - // Call the completion handler, passing the collection. - completion(retItems, nil) - } + let items = OpenHABItemCache.instance.getItemNames(searchTerm: searchTerm, types: [OpenHABItem.ItemType.dimmer, OpenHABItem.ItemType.rollershutter]) + let retItems = INObjectCollection(items: items) + // Call the completion handler, passing the collection. + completion(retItems, nil) } func provideItemOptionsCollection(for intent: OpenHABSetDimmerRollerValueIntent, with completion: @escaping (INObjectCollection?, Error?) -> Void) { - OpenHABItemCache.instance.getItemNames(searchTerm: nil, types: [OpenHABItem.ItemType.dimmer, OpenHABItem.ItemType.rollershutter]) { items in - let retItems = INObjectCollection(items: items) - // Call the completion handler, passing the collection. - completion(retItems, nil) - } + let items = OpenHABItemCache.instance.getItemNames(searchTerm: nil, types: [OpenHABItem.ItemType.dimmer, OpenHABItem.ItemType.rollershutter]) + let retItems = INObjectCollection(items: items) + // Call the completion handler, passing the collection. + completion(retItems, nil) } func confirm(intent: OpenHABSetDimmerRollerValueIntent, completion: @escaping (OpenHABSetDimmerRollerValueIntentResponse) -> Void) { @@ -54,13 +52,13 @@ class SetDimmerRollerValueIntentHandler: NSObject, OpenHABSetDimmerRollerValueIn completion(OpenHABSetDimmerRollerValueIntentResponse.failureInvalidValue(value, item: itemName)) return } - - OpenHABItemCache.instance.getItem(name: itemName) { item in + Task { + let item = await OpenHABItemCache.instance.getItem(name: itemName) guard let item else { completion(OpenHABSetDimmerRollerValueIntentResponse.failureInvalidItem(itemName)) return } - OpenHABItemCache.instance.sendCommand(item, commandToSend: "\(number)") + await OpenHABItemCache.instance.sendCommand(item, commandToSend: "\(number)") completion(OpenHABSetDimmerRollerValueIntentResponse.success(value: NSNumber(value: number), item: itemName)) } diff --git a/openHABIntents/SetNumberValueIntentHandler.swift b/openHABIntents/SetNumberValueIntentHandler.swift index 414173d63..017f8a65b 100644 --- a/openHABIntents/SetNumberValueIntentHandler.swift +++ b/openHABIntents/SetNumberValueIntentHandler.swift @@ -16,19 +16,17 @@ import os.log class SetNumberValueIntentHandler: NSObject, OpenHABSetNumberValueIntentHandling { func provideItemOptionsCollection(for intent: OpenHABSetNumberValueIntent, searchTerm: String?, with completion: @escaping (INObjectCollection?, Error?) -> Void) { - OpenHABItemCache.instance.getItemNames(searchTerm: searchTerm, types: [OpenHABItem.ItemType.number]) { items in - let retItems = INObjectCollection(items: items) - // Call the completion handler, passing the collection. - completion(retItems, nil) - } + let items = OpenHABItemCache.instance.getItemNames(searchTerm: searchTerm, types: [OpenHABItem.ItemType.number]) + let retItems = INObjectCollection(items: items) + // Call the completion handler, passing the collection. + completion(retItems, nil) } func provideItemOptionsCollection(for intent: OpenHABSetNumberValueIntent, with completion: @escaping (INObjectCollection?, Error?) -> Void) { - OpenHABItemCache.instance.getItemNames(searchTerm: nil, types: [OpenHABItem.ItemType.number]) { items in - let retItems = INObjectCollection(items: items) - // Call the completion handler, passing the collection. - completion(retItems, nil) - } + let items = OpenHABItemCache.instance.getItemNames(searchTerm: nil, types: [OpenHABItem.ItemType.number]) + let retItems = INObjectCollection(items: items) + // Call the completion handler, passing the collection. + completion(retItems, nil) } func confirm(intent: OpenHABSetNumberValueIntent, completion: @escaping (OpenHABSetNumberValueIntentResponse) -> Void) { @@ -47,13 +45,13 @@ class SetNumberValueIntentHandler: NSObject, OpenHABSetNumberValueIntentHandling completion(OpenHABSetNumberValueIntentResponse.failureEmptyValue(item: itemName)) return } - - OpenHABItemCache.instance.getItem(name: itemName) { item in + Task { + let item = await OpenHABItemCache.instance.getItem(name: itemName) guard let item else { completion(OpenHABSetNumberValueIntentResponse.failureInvalidItem(itemName)) return } - OpenHABItemCache.instance.sendCommand(item, commandToSend: value.stringValue) + await OpenHABItemCache.instance.sendCommand(item, commandToSend: value.stringValue) completion(OpenHABSetNumberValueIntentResponse.success(value: value, item: itemName)) } diff --git a/openHABIntents/SetStringValueIntentHandler.swift b/openHABIntents/SetStringValueIntentHandler.swift index 6a168846b..7524bd7ad 100644 --- a/openHABIntents/SetStringValueIntentHandler.swift +++ b/openHABIntents/SetStringValueIntentHandler.swift @@ -16,19 +16,17 @@ import os.log class SetStringValueIntentHandler: NSObject, OpenHABSetStringValueIntentHandling { func provideItemOptionsCollection(for intent: OpenHABSetStringValueIntent, searchTerm: String?, with completion: @escaping (INObjectCollection?, Error?) -> Void) { - OpenHABItemCache.instance.getItemNames(searchTerm: searchTerm, types: [OpenHABItem.ItemType.stringItem]) { items in - let retItems = INObjectCollection(items: items) - // Call the completion handler, passing the collection. - completion(retItems, nil) - } + let items = OpenHABItemCache.instance.getItemNames(searchTerm: searchTerm, types: [OpenHABItem.ItemType.stringItem]) + let retItems = INObjectCollection(items: items) + // Call the completion handler, passing the collection. + completion(retItems, nil) } func provideItemOptionsCollection(for intent: OpenHABSetStringValueIntent, with completion: @escaping (INObjectCollection?, Error?) -> Void) { - OpenHABItemCache.instance.getItemNames(searchTerm: nil, types: [OpenHABItem.ItemType.stringItem]) { items in - let retItems = INObjectCollection(items: items) - // Call the completion handler, passing the collection. - completion(retItems, nil) - } + let items = OpenHABItemCache.instance.getItemNames(searchTerm: nil, types: [OpenHABItem.ItemType.stringItem]) + let retItems = INObjectCollection(items: items) + // Call the completion handler, passing the collection. + completion(retItems, nil) } func confirm(intent: OpenHABSetStringValueIntent, completion: @escaping (OpenHABSetStringValueIntentResponse) -> Void) { @@ -48,12 +46,13 @@ class SetStringValueIntentHandler: NSObject, OpenHABSetStringValueIntentHandling return } - OpenHABItemCache.instance.getItem(name: itemName) { item in + Task { + let item = await OpenHABItemCache.instance.getItem(name: itemName) guard let item else { completion(OpenHABSetStringValueIntentResponse.failureInvalidItem(itemName)) return } - OpenHABItemCache.instance.sendCommand(item, commandToSend: value) + await OpenHABItemCache.instance.sendCommand(item, commandToSend: value) completion(OpenHABSetStringValueIntentResponse.success(value: value, item: itemName)) } diff --git a/openHABIntents/SetSwitchStateIntentHandler.swift b/openHABIntents/SetSwitchStateIntentHandler.swift index 44024c641..c407f8e79 100644 --- a/openHABIntents/SetSwitchStateIntentHandler.swift +++ b/openHABIntents/SetSwitchStateIntentHandler.swift @@ -28,19 +28,17 @@ class SetSwitchStateIntentHandler: NSObject, OpenHABSetSwitchStateIntentHandling } func provideItemOptionsCollection(for intent: OpenHABSetSwitchStateIntent, searchTerm: String?, with completion: @escaping (INObjectCollection?, Error?) -> Void) { - OpenHABItemCache.instance.getItemNames(searchTerm: searchTerm, types: [OpenHABItem.ItemType.switchItem]) { items in - let retItems = INObjectCollection(items: items) - // Call the completion handler, passing the collection. - completion(retItems, nil) - } + let items = OpenHABItemCache.instance.getItemNames(searchTerm: searchTerm, types: [OpenHABItem.ItemType.switchItem]) + let retItems = INObjectCollection(items: items) + // Call the completion handler, passing the collection. + completion(retItems, nil) } func provideItemOptionsCollection(for intent: OpenHABSetSwitchStateIntent, with completion: @escaping (INObjectCollection?, Error?) -> Void) { - OpenHABItemCache.instance.getItemNames(searchTerm: nil, types: [OpenHABItem.ItemType.switchItem]) { items in - let retItems = INObjectCollection(items: items) - // Call the completion handler, passing the collection. - completion(retItems, nil) - } + let items = OpenHABItemCache.instance.getItemNames(searchTerm: nil, types: [OpenHABItem.ItemType.switchItem]) + let retItems = INObjectCollection(items: items) + // Call the completion handler, passing the collection. + completion(retItems, nil) } func confirm(intent: OpenHABSetSwitchStateIntent, completion: @escaping (OpenHABSetSwitchStateIntentResponse) -> Void) { @@ -65,13 +63,13 @@ class SetSwitchStateIntentHandler: NSObject, OpenHABSetSwitchStateIntentHandling completion(OpenHABSetSwitchStateIntentResponse.failureInvalidAction(action, item: itemName)) return } - - OpenHABItemCache.instance.getItem(name: itemName) { item in + Task { + let item = await OpenHABItemCache.instance.getItem(name: itemName) guard let item else { completion(OpenHABSetSwitchStateIntentResponse.failureInvalidItem(itemName)) return } - OpenHABItemCache.instance.sendCommand(item, commandToSend: realAction) + await OpenHABItemCache.instance.sendCommand(item, commandToSend: realAction) completion(OpenHABSetSwitchStateIntentResponse.success(action: action, item: itemName)) } From 4de6aa96933042a5911a924d4b79e10f8d09cf20 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Mon, 3 Mar 2025 17:43:38 +0100 Subject: [PATCH 037/476] Migrate NWPathMonitor to asyncStream version Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .github/workflows/runUnitTest2.yml | 33 ------------------- .../OpenHABCore/Util/NetworkTracker.swift | 21 ++++++------ 2 files changed, 11 insertions(+), 43 deletions(-) delete mode 100644 .github/workflows/runUnitTest2.yml diff --git a/.github/workflows/runUnitTest2.yml b/.github/workflows/runUnitTest2.yml deleted file mode 100644 index 771a9e9ab..000000000 --- a/.github/workflows/runUnitTest2.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: Run Unit Tests Update - -on: - pull_request: - types: [opened, reopened, synchronize, ready_for_review] - workflow_dispatch: - -jobs: - build_and_test: - runs-on: macos-latest - steps: - - uses: maxim-lobanov/setup-xcode@v1.6.0 - with: - xcode-version: latest-stable - - - uses: actions/checkout@v4 - - - name: Install dependencies - run: bundle install --redownload - - name: List available simulators - run: xcrun simctl list - - name: Boot Simulator - run: | - xcrun simctl boot "51C4E281-1F04-4BE9-9392-8499B31B9351" - - name: List available simulators - run: xcrun simctl list - - - name: Fastlane unit tests - env: - LANG: en_US.UTF-8 - LC_ALL: en_US.UTF-8 - FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT: 60 - run: bundle exec fastlane unittests diff --git a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift index b9e12b354..795506fee 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift @@ -55,8 +55,6 @@ public final class NetworkTracker: ObservableObject { @Published public private(set) var status: NetworkStatus = .connecting public var openApiService: OpenAPIService? - private let monitor: NWPathMonitor - private let monitorQueue = DispatchQueue.global(qos: .background) private var connectionConfigurations: [ConnectionConfiguration] = [] private var retryTask: Task? public private(set) var httpClient: HTTPClient? @@ -64,11 +62,13 @@ public final class NetworkTracker: ObservableObject { private let logger = Logger(subsystem: "org.openhab.core", category: "NetworkTracker") private init() { - monitor = NWPathMonitor() - monitor.pathUpdateHandler = { [weak self] path in - Task { await self?.handleNetworkChange(isConnected: path.status == .satisfied) } + // The `for await` loop automatically handles updates from NWPathMonitor, so there’s no need for a callback. + Task { + let monitor = NWPathMonitor() + for await path in monitor { + await handleNetworkChange(isConnected: path.status == .satisfied) + } } - monitor.start(queue: monitorQueue) } public func waitForActiveConnection(timeout: TimeInterval = 10) async -> ConnectionInfo? { @@ -98,7 +98,7 @@ public final class NetworkTracker: ObservableObject { password: String, alwaysSendBasicAuth: Bool, ignoreSSLVerification: Bool) { - os_log("StartTracking", log: OSLog.default, type: .info) + logger.info("Start Tracking") self.connectionConfigurations = adjustMyOpenHABHosts(in: connectionConfigurations) Task { @@ -115,18 +115,19 @@ public final class NetworkTracker: ObservableObject { public func restartTracking() { Task { await attemptConnection() } } - + private func handleNetworkChange(isConnected: Bool) async { if isConnected { - os_log("Network status: Connected", log: OSLog.default, type: .info) + logger.info("Network status: Connected") await checkActiveConnection() } else { - os_log("Network status: Disconnected", log: OSLog.default, type: .info) + logger.info("Network status: Disconnected") await updateActiveConnection(nil) startRetryTask() } } + private func checkActiveConnection() async { guard let activeConnection else { os_log("No active connection, attempting to reconnect...", log: OSLog.default, type: .info) From 5372b81fb724b308b1b670a7111b60eafcb206f0 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Mon, 3 Mar 2025 18:00:07 +0100 Subject: [PATCH 038/476] Revert change on NWPathMonitor because we are still on iOS 16 Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../OpenHABCore/Util/NetworkTracker.swift | 75 +++++++++++-------- 1 file changed, 42 insertions(+), 33 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift index 795506fee..0f5c3dfc5 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift @@ -24,7 +24,7 @@ public enum NetworkStatus: String { public struct ConnectionConfiguration: 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 @@ -39,7 +39,7 @@ public struct ConnectionInfo: Equatable { enum NetworkTrackerError: Error, CustomDebugStringConvertible { case invalidServerVersion case failedConnection(String) - + var debugDescription: String { switch self { case .invalidServerVersion: "Invalid server version" @@ -50,31 +50,40 @@ enum NetworkTrackerError: Error, CustomDebugStringConvertible { public final class NetworkTracker: ObservableObject { public static let shared = NetworkTracker() - + @Published public private(set) var activeConnection: ConnectionInfo? @Published public private(set) var status: NetworkStatus = .connecting + private let monitor: NWPathMonitor + private let monitorQueue = DispatchQueue.global(qos: .background) public var openApiService: OpenAPIService? private var connectionConfigurations: [ConnectionConfiguration] = [] private var retryTask: Task? public private(set) var httpClient: HTTPClient? - + private let logger = Logger(subsystem: "org.openhab.core", category: "NetworkTracker") - + private init() { - // The `for await` loop automatically handles updates from NWPathMonitor, so there’s no need for a callback. - Task { - let monitor = NWPathMonitor() - for await path in monitor { - await handleNetworkChange(isConnected: path.status == .satisfied) - } + monitor = NWPathMonitor() + monitor.pathUpdateHandler = { [weak self] path in + Task { await self?.handleNetworkChange(isConnected: path.status == .satisfied) } } - } + monitor.start(queue: monitorQueue) +// if #available(iOS 17, watchOS 10, *) { +// // The `for await` loop automatically handles updates from NWPathMonitor, so there’s no need for a callback. +// Task { +// let monitor = NWPathMonitor() +// for await path in monitor { +// await handleNetworkChange(isConnected: path.status == .satisfied) +// } +// } + } + public func waitForActiveConnection(timeout: TimeInterval = 10) async -> ConnectionInfo? { await withCheckedContinuation { continuation in let deadline = Date().addingTimeInterval(timeout) - + func checkConnection() { Task { @MainActor in if let activeConnection = self.activeConnection { @@ -88,11 +97,11 @@ public final class NetworkTracker: ObservableObject { } } } - + checkConnection() } } - + public func startTracking(connectionConfigurations: [ConnectionConfiguration], username: String, password: String, @@ -100,7 +109,7 @@ public final class NetworkTracker: ObservableObject { ignoreSSLVerification: Bool) { logger.info("Start Tracking") self.connectionConfigurations = adjustMyOpenHABHosts(in: connectionConfigurations) - + Task { openApiService = await OpenAPIService( username: username, @@ -111,7 +120,7 @@ public final class NetworkTracker: ObservableObject { await attemptConnection() } } - + public func restartTracking() { Task { await attemptConnection() } } @@ -126,15 +135,15 @@ public final class NetworkTracker: ObservableObject { startRetryTask() } } - - + + private func checkActiveConnection() async { guard let activeConnection else { os_log("No active connection, attempting to reconnect...", log: OSLog.default, type: .info) await attemptConnection() return } - + do { guard let url = URL(string: activeConnection.configuration.url) else { return } await openApiService?.updateBaseURL(with: url) @@ -145,26 +154,26 @@ public final class NetworkTracker: ObservableObject { await attemptConnection() } } - + private func attemptConnection() async { guard !connectionConfigurations.isEmpty else { logger.error("No connection configurations available.") await updateActiveConnection(nil) return } - + logger.info("Checking available connections...") - + let sortedConfigs = connectionConfigurations.sorted { $0.priority < $1.priority } var bestConnection: ConnectionInfo? - + await withTaskGroup(of: ConnectionInfo?.self) { group in for config in sortedConfigs { group.addTask { await self.testConnection(configuration: config) } } - + for await connection in group { if let connection { if connection.configuration.priority == 0 { @@ -177,13 +186,13 @@ public final class NetworkTracker: ObservableObject { } } } - + await updateActiveConnection(bestConnection) } - + private func testConnection(configuration: ConnectionConfiguration) async -> ConnectionInfo? { guard let url = URL(string: configuration.url) else { return nil } - + do { let service = await OpenAPIService( baseURL: url, @@ -191,11 +200,11 @@ public final class NetworkTracker: ObservableObject { password: Preferences.password ) let serverProperties = try await service.getRoot() - + guard let version = Int(serverProperties.version ?? "0"), version > 1 else { throw NetworkTrackerError.invalidServerVersion } - + let connectionInfo = ConnectionInfo(configuration: configuration, version: version) logger.info("Successfully connected to \(configuration.url)") return connectionInfo @@ -204,7 +213,7 @@ public final class NetworkTracker: ObservableObject { return nil } } - + private func startRetryTask() { retryTask?.cancel() retryTask = Task { @@ -212,7 +221,7 @@ public final class NetworkTracker: ObservableObject { await attemptConnection() } } - + @MainActor private func updateActiveConnection(_ connection: ConnectionInfo?) async { guard activeConnection != connection else { return } @@ -224,7 +233,7 @@ public final class NetworkTracker: ObservableObject { startRetryTask() } } - + private func adjustMyOpenHABHosts(in configurations: [ConnectionConfiguration]) -> [ConnectionConfiguration] { configurations.map { configuration in var updatedURL = configuration.url From 71bf7392cadeeabed53a7986e4800be3a1983cde Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Mon, 3 Mar 2025 18:07:47 +0100 Subject: [PATCH 039/476] Revert change on NWPathMonitor because we are still on iOS 16 Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../OpenHABCore/Util/NetworkTracker.swift | 56 +++++++++---------- 1 file changed, 27 insertions(+), 29 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift index 0f5c3dfc5..6f725b999 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift @@ -24,7 +24,7 @@ public enum NetworkStatus: String { public struct ConnectionConfiguration: 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 @@ -39,7 +39,7 @@ public struct ConnectionInfo: Equatable { enum NetworkTrackerError: Error, CustomDebugStringConvertible { case invalidServerVersion case failedConnection(String) - + var debugDescription: String { switch self { case .invalidServerVersion: "Invalid server version" @@ -50,7 +50,7 @@ enum NetworkTrackerError: Error, CustomDebugStringConvertible { public final class NetworkTracker: ObservableObject { public static let shared = NetworkTracker() - + @Published public private(set) var activeConnection: ConnectionInfo? @Published public private(set) var status: NetworkStatus = .connecting @@ -60,9 +60,9 @@ public final class NetworkTracker: ObservableObject { private var connectionConfigurations: [ConnectionConfiguration] = [] private var retryTask: Task? public private(set) var httpClient: HTTPClient? - + private let logger = Logger(subsystem: "org.openhab.core", category: "NetworkTracker") - + private init() { monitor = NWPathMonitor() monitor.pathUpdateHandler = { [weak self] path in @@ -77,13 +77,12 @@ public final class NetworkTracker: ObservableObject { // await handleNetworkChange(isConnected: path.status == .satisfied) // } // } - } - + public func waitForActiveConnection(timeout: TimeInterval = 10) async -> ConnectionInfo? { await withCheckedContinuation { continuation in let deadline = Date().addingTimeInterval(timeout) - + func checkConnection() { Task { @MainActor in if let activeConnection = self.activeConnection { @@ -97,11 +96,11 @@ public final class NetworkTracker: ObservableObject { } } } - + checkConnection() } } - + public func startTracking(connectionConfigurations: [ConnectionConfiguration], username: String, password: String, @@ -109,7 +108,7 @@ public final class NetworkTracker: ObservableObject { ignoreSSLVerification: Bool) { logger.info("Start Tracking") self.connectionConfigurations = adjustMyOpenHABHosts(in: connectionConfigurations) - + Task { openApiService = await OpenAPIService( username: username, @@ -120,11 +119,11 @@ public final class NetworkTracker: ObservableObject { await attemptConnection() } } - + public func restartTracking() { Task { await attemptConnection() } } - + private func handleNetworkChange(isConnected: Bool) async { if isConnected { logger.info("Network status: Connected") @@ -135,15 +134,14 @@ public final class NetworkTracker: ObservableObject { startRetryTask() } } - - + private func checkActiveConnection() async { guard let activeConnection else { os_log("No active connection, attempting to reconnect...", log: OSLog.default, type: .info) await attemptConnection() return } - + do { guard let url = URL(string: activeConnection.configuration.url) else { return } await openApiService?.updateBaseURL(with: url) @@ -154,26 +152,26 @@ public final class NetworkTracker: ObservableObject { await attemptConnection() } } - + private func attemptConnection() async { guard !connectionConfigurations.isEmpty else { logger.error("No connection configurations available.") await updateActiveConnection(nil) return } - + logger.info("Checking available connections...") - + let sortedConfigs = connectionConfigurations.sorted { $0.priority < $1.priority } var bestConnection: ConnectionInfo? - + await withTaskGroup(of: ConnectionInfo?.self) { group in for config in sortedConfigs { group.addTask { await self.testConnection(configuration: config) } } - + for await connection in group { if let connection { if connection.configuration.priority == 0 { @@ -186,13 +184,13 @@ public final class NetworkTracker: ObservableObject { } } } - + await updateActiveConnection(bestConnection) } - + private func testConnection(configuration: ConnectionConfiguration) async -> ConnectionInfo? { guard let url = URL(string: configuration.url) else { return nil } - + do { let service = await OpenAPIService( baseURL: url, @@ -200,11 +198,11 @@ public final class NetworkTracker: ObservableObject { password: Preferences.password ) let serverProperties = try await service.getRoot() - + guard let version = Int(serverProperties.version ?? "0"), version > 1 else { throw NetworkTrackerError.invalidServerVersion } - + let connectionInfo = ConnectionInfo(configuration: configuration, version: version) logger.info("Successfully connected to \(configuration.url)") return connectionInfo @@ -213,7 +211,7 @@ public final class NetworkTracker: ObservableObject { return nil } } - + private func startRetryTask() { retryTask?.cancel() retryTask = Task { @@ -221,7 +219,7 @@ public final class NetworkTracker: ObservableObject { await attemptConnection() } } - + @MainActor private func updateActiveConnection(_ connection: ConnectionInfo?) async { guard activeConnection != connection else { return } @@ -233,7 +231,7 @@ public final class NetworkTracker: ObservableObject { startRetryTask() } } - + private func adjustMyOpenHABHosts(in configurations: [ConnectionConfiguration]) -> [ConnectionConfiguration] { configurations.map { configuration in var updatedURL = configuration.url From 46b713e749b37377fa95a3dd387c0a153e6a1433 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Mon, 3 Mar 2025 21:05:23 +0100 Subject: [PATCH 040/476] Generated 'Client.swift' migrate getRoot path template back to // Better convenience function for getRootVersion() Wait for 30s in startRetryTask Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../GeneratedSources/openapi/Client.swift | 2 +- .../OpenHABCore/Util/NetworkTracker.swift | 17 +- .../OpenHABCore/Util/OpenAPIService.swift | 47 +++--- .../xcschemes/openHABIntents.xcscheme | 97 ++++++++++++ .../xcshareddata/WorkspaceSettings.xcsettings | 2 + openHAB/NewImageUITableViewCell.swift | 2 - openHAB/OpenHABRootViewController.swift | 149 ++++++++---------- openHAB/OpenHABSitemapViewController.swift | 4 +- ...iderWithSwitchSupportUITableViewCell.swift | 1 - 9 files changed, 198 insertions(+), 123 deletions(-) create mode 100644 openHAB.xcodeproj/xcshareddata/xcschemes/openHABIntents.xcscheme diff --git a/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Client.swift b/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Client.swift index 2c7497aff..c2a733968 100644 --- a/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Client.swift +++ b/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Client.swift @@ -2054,7 +2054,7 @@ public struct Client: APIProtocol { forOperation: Operations.getRoot.id, serializer: { input in let path = try converter.renderedPath( - template: "/", + template: "//", parameters: [] ) var request: HTTPTypes.HTTPRequest = .init( diff --git a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift index 6f725b999..928a060a5 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift @@ -197,12 +197,7 @@ public final class NetworkTracker: ObservableObject { username: Preferences.username, password: Preferences.password ) - let serverProperties = try await service.getRoot() - - guard let version = Int(serverProperties.version ?? "0"), version > 1 else { - throw NetworkTrackerError.invalidServerVersion - } - + let version = try await service.getRootVersion() let connectionInfo = ConnectionInfo(configuration: configuration, version: version) logger.info("Successfully connected to \(configuration.url)") return connectionInfo @@ -215,7 +210,7 @@ public final class NetworkTracker: ObservableObject { private func startRetryTask() { retryTask?.cancel() retryTask = Task { - try? await Task.sleep(nanoseconds: 10_000_000_000) // 10 seconds + try? await Task.sleep(nanoseconds: 30_000_000_000) // 30 seconds await attemptConnection() } } @@ -223,11 +218,13 @@ public final class NetworkTracker: ObservableObject { @MainActor private func updateActiveConnection(_ connection: ConnectionInfo?) async { guard activeConnection != connection else { return } + activeConnection = connection - status = (connection != nil) ? .connected : .notConnected if let connection { + status = .connected await openApiService?.updateBaseURL(with: URL(string: connection.configuration.url) ?? URL(string: "about:blank")!) } else { + status = .notConnected startRetryTask() } } @@ -236,7 +233,9 @@ public final class NetworkTracker: ObservableObject { configurations.map { configuration in var updatedURL = configuration.url if let urlComponents = URLComponents(string: configuration.url), - let host = urlComponents.host, host.contains("myopenhab.org"), host != "home.myopenhab.org" { + let host = urlComponents.host, + host.contains("myopenhab.org"), + host != "home.myopenhab.org" { var newComponents = urlComponents newComponents.host = "home.myopenhab.org" updatedURL = newComponents.url?.absoluteString ?? configuration.url diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift index 54884da22..34783bb10 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift @@ -15,19 +15,14 @@ import OpenAPIRuntime import OpenAPIURLSession import os -public protocol OpenHABSitemapsService { - func openHABSitemaps() async throws -> [OpenHABSitemap] -} - -public protocol OpenHABUiTileService { - func getUITiles() async throws -> [OpenHABUiTile] -} - public enum OpenAPIServiceError: Error { case undocumented(statusCode: Swift.Int, undocumentedPayload: OpenAPIRuntime.UndocumentedPayload) case noRootURL } +// The generated OpenAPI client is wrapped by this curated API. +// The library leaks the fact that it uses Swift OpenAPI Generator under the hood in 'openHABSitemapWidgetEvents'. +// It will require the migration to Swift 6.1 before this can be changed. public actor OpenAPIService { private var client: any APIProtocol private var url: URL? @@ -35,12 +30,12 @@ public actor OpenAPIService { private var username: String = "" private var password: String = "" - private var alwaysSendBasicAuth: Bool = false - private var ignoreSSL: Bool = false + private var alwaysSendBasicAuth = false + private var ignoreSSL = false private let logger = Logger(subsystem: "org.openhab.app", category: "OpenAPIService") - /// Creates a new client for GreetingService. + /// Creates a new client for OpenAPIService. public init(client: any APIProtocol) { self.client = client } @@ -116,8 +111,8 @@ public actor OpenAPIService { } } -extension OpenAPIService: OpenHABSitemapsService { - public func openHABSitemaps() async throws -> [OpenHABSitemap] { +public extension OpenAPIService { + func openHABSitemaps() async throws -> [OpenHABSitemap] { // swiftformat:disable:next redundantSelf guard let url else { throw OpenAPIServiceError.noRootURL } @@ -132,8 +127,8 @@ extension OpenAPIService: OpenHABSitemapsService { } } -extension OpenAPIService: OpenHABUiTileService { - public func getUITiles() async throws -> [OpenHABUiTile] { +public extension OpenAPIService { + func getUITiles() async throws -> [OpenHABUiTile] { try await client.getUITiles(.init()) .ok.body.json .map(OpenHABUiTile.init) @@ -143,13 +138,21 @@ extension OpenAPIService: OpenHABUiTileService { public extension OpenAPIService { @discardableResult func getRoot() async throws -> OpenHABServerProperties { - let interimResult = try await client.getRoot() - let result = try interimResult - .ok - .body - .json + let result = try await client.getRoot() + .ok.body.json return OpenHABServerProperties(result) } + + func getRootVersion() async throws -> Int { + let result = try await client.getRoot() + .ok.body.json + let serverProperties = OpenHABServerProperties(result) + guard let version = Int(serverProperties.version ?? "0"), + version > 1 else { + throw NetworkTrackerError.invalidServerVersion + } + return version + } } public extension OpenAPIService { @@ -162,11 +165,11 @@ public extension OpenAPIService { } public extension OpenAPIService { - func runNow(ruleUID: String, payload: [String: any Sendable]) async throws -> Operations.runRuleNow_1.Output { + func runNow(ruleUID: String, payload: [String: any Sendable]) async throws { let path = Operations.runRuleNow_1.Input.Path(ruleUID: ruleUID) let jsonPayload = try Operations.runRuleNow_1.Input.Body.jsonPayload( additionalProperties: OpenAPIObjectContainer(unvalidatedValue: payload)) - return try await client.runRuleNow_1( + _ = try await client.runRuleNow_1( path: path, body: .json(jsonPayload) ) diff --git a/openHAB.xcodeproj/xcshareddata/xcschemes/openHABIntents.xcscheme b/openHAB.xcodeproj/xcshareddata/xcschemes/openHABIntents.xcscheme new file mode 100644 index 000000000..126a60e4c --- /dev/null +++ b/openHAB.xcodeproj/xcshareddata/xcschemes/openHABIntents.xcscheme @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/openHAB.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/openHAB.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings index f9b0d7c5e..a6f6fb21d 100644 --- a/openHAB.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings +++ b/openHAB.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -2,6 +2,8 @@ + IDEWorkspaceSharedSettings_AutocreateContextsIfNeeded + PreviewsEnabled diff --git a/openHAB/NewImageUITableViewCell.swift b/openHAB/NewImageUITableViewCell.swift index b9501f3cd..645e31382 100644 --- a/openHAB/NewImageUITableViewCell.swift +++ b/openHAB/NewImageUITableViewCell.swift @@ -9,7 +9,6 @@ // // SPDX-License-Identifier: EPL-2.0 -import Alamofire import OpenHABCore import os.log import UIKit @@ -25,7 +24,6 @@ class NewImageUITableViewCell: GenericUITableViewCell { private var mainImageView: ScaleAspectFitImageView! private var refreshTimer: Timer? - private var downloadRequest: Alamofire.Request? private var chartStyle: ChartStyle = .light private var activeTask: Task? diff --git a/openHAB/OpenHABRootViewController.swift b/openHAB/OpenHABRootViewController.swift index 77e87db5c..47183bb97 100644 --- a/openHAB/OpenHABRootViewController.swift +++ b/openHAB/OpenHABRootViewController.swift @@ -323,37 +323,38 @@ class OpenHABRootViewController: UIViewController { // if not actionIdentifier, then the notification was clicked, so use "on-click" if there if let action = userInfo["actionIdentifier"] as? String ?? userInfo["on-click"] as? String { let cmd = action.split(separator: ":").dropFirst().joined(separator: ":") - if action.hasPrefix("ui") { - uiCommandAction(cmd, completionHandler: completionHandler) - } else if action.hasPrefix("command") { - Task { - await sendCommandAction(cmd, completionHandler: completionHandler) - } - } else if action.hasPrefix("http") { - httpCommandAction(action, completionHandler: completionHandler) - } else if action.hasPrefix("app") { - appCommandAction(action, completionHandler: completionHandler) - } else if action.hasPrefix("rule") { - Task { - await ruleCommandAction(action, completionHandler: completionHandler) - } - } else { - if let completionHandler { - DispatchQueue.main.async { - completionHandler() - } - } + switch true { + case action.hasPrefix("ui"): + uiCommandAction(cmd) + callCompletionHandler(completionHandler) + case action.hasPrefix("command"): + sendCommandAction(cmd) + callCompletionHandler(completionHandler) + case action.hasPrefix("http"): + httpCommandAction(action) + callCompletionHandler(completionHandler) + case action.hasPrefix("app"): + appCommandAction(action) + callCompletionHandler(completionHandler) + case action.hasPrefix("rule"): + ruleCommandAction(action) + callCompletionHandler(completionHandler) + default: + callCompletionHandler(completionHandler) } - } else { - if let completionHandler { - DispatchQueue.main.async { - completionHandler() - } + } + } + + // Helper function to safely call the completion handler on the main thread + private func callCompletionHandler(_ completionHandler: (() -> Void)?) { + if let completionHandler { + DispatchQueue.main.async { + completionHandler() } } } - private func uiCommandAction(_ command: String, completionHandler: (() -> Void)? = nil) { + private func uiCommandAction(_ command: String) { os_log("navigateCommandAction: %{PUBLIC}@", log: .notifications, type: .info, command) let regexPattern = /^(\/basicui\/app\\?.*|\/.*|.*)$/ if let firstMatch = command.firstMatch(of: regexPattern) { @@ -388,47 +389,39 @@ class OpenHABRootViewController: UIViewController { } else { os_log("Invalid regex: %{PUBLIC}@", log: .notifications, type: .error, command) } - if let completionHandler { - DispatchQueue.main.async { - completionHandler() - } - } } - private func sendCommandAction(_ action: String, completionHandler: (() -> Void)? = nil) async { + private func sendCommandAction(_ action: String) { let components = action.split(separator: ":") guard components.count == 2 else { - completionHandler?() return } let itemName = String(components[0]) let itemCommand = String(components[1]) + Task { + do { + guard let activeConnection = await NetworkTracker.shared.waitForActiveConnection(), + let url = URL(string: activeConnection.configuration.url) else { + displayErrorNotification("Could not find server") + return + } - do { - guard let activeConnection = await NetworkTracker.shared.waitForActiveConnection(), - let url = URL(string: activeConnection.configuration.url) else { - displayErrorNotification("Could not find server") - completionHandler?() - return - } - - os_log("Sending command", log: .default, type: .error) + os_log("Sending command", log: .default, type: .error) - let openAPIService = await OpenAPIService(username: Preferences.username, password: Preferences.password) - await openAPIService.updateBaseURL(with: url) + let openAPIService = await OpenAPIService(username: Preferences.username, password: Preferences.password) + await openAPIService.updateBaseURL(with: url) - try await openAPIService.sendItemCommand(itemname: itemName, command: itemCommand) + try await openAPIService.sendItemCommand(itemname: itemName, command: itemCommand) - } catch { - displayErrorNotification("Failed to establish a connection: \(error.localizedDescription)") - // TODOD -// logger.error("Could not send data \(error.localizedDescription)") -// -// self.displayErrorNotification("Request to \(url) failed: \(error.localizedDescription)") + } catch { + displayErrorNotification("Failed to establish a connection: \(error.localizedDescription)") + // TODOD + // logger.error("Could not send data \(error.localizedDescription)") + // + // self.displayErrorNotification("Request to \(url) failed: \(error.localizedDescription)") + } } - - completionHandler?() } private func displayErrorNotification(_ message: String, completionHandler: (() -> Void)? = nil) { @@ -448,19 +441,14 @@ class OpenHABRootViewController: UIViewController { } } - private func httpCommandAction(_ command: String, completionHandler: (() -> Void)? = nil) { + private func httpCommandAction(_ command: String) { if let url = URL(string: command) { let vc = SFSafariViewController(url: url) present(vc, animated: true) } - if let completionHandler { - DispatchQueue.main.async { - completionHandler() - } - } } - private func appCommandAction(_ command: String, completionHandler: (() -> Void)? = nil) { + private func appCommandAction(_ command: String) { let content = command.dropFirst(4) // Remove "app:" let pairs = content.split(separator: ",") for pair in pairs { @@ -474,14 +462,9 @@ class OpenHABRootViewController: UIViewController { } } } - if let completionHandler { - DispatchQueue.main.async { - completionHandler() - } - } } - private func ruleCommandAction(_ command: String, completionHandler: (() -> Void)? = nil) async { + private func ruleCommandAction(_ command: String) { let components = command.split(separator: ":", maxSplits: 2) guard components.count == 3, components[0] == "rule" else { return } @@ -500,29 +483,23 @@ class OpenHABRootViewController: UIViewController { properties[key] = value } } + Task { + do { + guard let activeConnection = await NetworkTracker.shared.waitForActiveConnection() else { + displayErrorNotification("Could not find active server") + return + } - do { - guard let activeConnection = await NetworkTracker.shared.waitForActiveConnection(), - let url = URL(string: activeConnection.configuration.url) else { - displayErrorNotification("Could not find active server") - completionHandler?() - return - } - - os_log("Sending command", log: .default, type: .error) - - let openAPIService = await OpenAPIService(username: Preferences.username, password: Preferences.password) - let data = try await openAPIService.runNow(ruleUID: uuid, payload: properties) - logger.info("Request succeeded") + os_log("Sending command", log: .default, type: .error) - } catch { - logger.error("Could not send data \(error.localizedDescription)") - displayErrorNotification("Request to server failed: \(error.localizedDescription)") - } + let openAPIService = await OpenAPIService(username: Preferences.username, password: Preferences.password) + try await openAPIService.runNow(ruleUID: uuid, payload: properties) + logger.info("Request succeeded") - // Ensure completionHandler is executed on the main thread - DispatchQueue.main.async { - completionHandler?() + } catch { + logger.error("Could not send data \(error.localizedDescription)") + displayErrorNotification("Request to server failed: \(error.localizedDescription)") + } } } diff --git a/openHAB/OpenHABSitemapViewController.swift b/openHAB/OpenHABSitemapViewController.swift index a2d511624..d5e2a6679 100644 --- a/openHAB/OpenHABSitemapViewController.swift +++ b/openHAB/OpenHABSitemapViewController.swift @@ -485,11 +485,11 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel func pushSitemap(name: String, path: String?) async { do { guard let activeConnection = await NetworkTracker.shared.waitForActiveConnection() else { - os_log("pushSitemap: No active connection available", log: .default, type: .error) + logger.error("pushSiteMap: No active connection available") return } - os_log("pushSitemap: pushing page", log: .default, type: .error) + logger.info("pushSitemap: pushing page") guard let newViewController = storyboard?.instantiateViewController(withIdentifier: "OpenHABPageViewController") as? OpenHABSitemapViewController else { os_log("pushSitemap: Failed to instantiate OpenHABSitemapViewController", log: .default, type: .error) diff --git a/openHAB/SliderWithSwitchSupportUITableViewCell.swift b/openHAB/SliderWithSwitchSupportUITableViewCell.swift index 947295516..49a33373d 100644 --- a/openHAB/SliderWithSwitchSupportUITableViewCell.swift +++ b/openHAB/SliderWithSwitchSupportUITableViewCell.swift @@ -9,7 +9,6 @@ // // SPDX-License-Identifier: EPL-2.0 -import Alamofire import AVFoundation import AVKit import OpenHABCore From 83ad2eedaead857e697adf6c247ac810d9b17e8d Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Tue, 4 Mar 2025 06:56:19 +0100 Subject: [PATCH 041/476] Retired NetworkConnection Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../Sources/OpenHABCore/Util/HTTPClient.swift | 4 +- .../OpenHABCore/Util/NetworkConnection.swift | 42 +- .../OpenHABCore/Util/NetworkTracker.swift | 15 + .../Util/OpenHABSessionDelegate.swift | 6 +- openHAB/ClientCertificatesViewModel.swift | 6 +- openHABWatch/Domain/UserData.swift | 394 ++++++++++++------ openHABWatch/Views/ContentView.swift | 8 +- 7 files changed, 307 insertions(+), 168 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift b/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift index 2e9647bb5..a80f0d87c 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift @@ -60,12 +60,10 @@ public class HTTPClient: NSObject { case bytes } - public static let share = HTTPClient() - // this can be changed if we detect another server public var baseURL: URL? - private var session: URLSession! + public var session: URLSession! private let username: String private let password: String private let alwaysSendBasicAuth: Bool diff --git a/OpenHABCore/Sources/OpenHABCore/Util/NetworkConnection.swift b/OpenHABCore/Sources/OpenHABCore/Util/NetworkConnection.swift index 9d5c444fb..a77645880 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/NetworkConnection.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/NetworkConnection.swift @@ -39,15 +39,16 @@ public func onReceiveSessionChallenge(with challenge: URLAuthenticationChallenge switch challenge.protectionSpace.authenticationMethod { case NSURLAuthenticationMethodServerTrust: - return NetworkConnection.shared.serverCertificateManager.evaluateTrust(with: challenge) + // TODO: + return (NetworkTracker.shared.serverCertificateManager?.evaluateTrust(with: challenge))! case NSURLAuthenticationMethodClientCertificate: - return NetworkConnection.shared.clientCertificateManager.evaluateTrust(with: challenge) + return NetworkTracker.shared.clientCertificateManager.evaluateTrust(with: challenge) // attemptCredentialAuthentication default: if challenge.previousFailureCount > 0 { disposition = .cancelAuthenticationChallenge } else { - credential = NetworkConnection.shared.manager.session.configuration.urlCredentialStorage?.defaultCredential(for: challenge.protectionSpace) + credential = NetworkTracker.shared.httpClient?.session.configuration.urlCredentialStorage?.defaultCredential(for: challenge.protectionSpace) if credential != nil { disposition = .useCredential } @@ -59,38 +60,3 @@ public func onReceiveSessionChallenge(with challenge: URLAuthenticationChallenge public protocol CommItem { var link: String { get set } } - -public class NetworkConnection { - public static var shared: NetworkConnection! - - public static var atmosphereTrackingId = "" - - public var clientCertificateManager = ClientCertificateManager() - public var serverCertificateManager: ServerCertificateManager! - public var manager: Alamofire.Session - public var rootUrl: URL? - - init(ignoreSSL: Bool, manager: Session) { - serverCertificateManager = ServerCertificateManager(ignoreSSL: ignoreSSL) - serverCertificateManager.initializeCertificatesStore() - self.manager = manager - } - - public class func initialize(ignoreSSL: Bool, interceptor: RequestInterceptor?) { - shared = NetworkConnection( - ignoreSSL: ignoreSSL, - manager: Session( - configuration: URLSessionConfiguration.default, - delegate: OpenHABSessionDelegate(), - startRequestsImmediately: false, - interceptor: interceptor, - serverTrustManager: ServerCertificateManager(ignoreSSL: ignoreSSL) - ) - ) - } - - public func assignDelegates(serverDelegate: ServerCertificateManagerDelegate?, clientDelegate: ClientCertificateManagerDelegate) { - serverCertificateManager.delegate = serverDelegate - clientCertificateManager.delegate = clientDelegate - } -} diff --git a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift index 928a060a5..53e80781c 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift @@ -60,6 +60,8 @@ public final class NetworkTracker: ObservableObject { private var connectionConfigurations: [ConnectionConfiguration] = [] private var retryTask: Task? public private(set) var httpClient: HTTPClient? + public var clientCertificateManager = ClientCertificateManager() + public var serverCertificateManager: ServerCertificateManager? private let logger = Logger(subsystem: "org.openhab.core", category: "NetworkTracker") @@ -77,6 +79,8 @@ public final class NetworkTracker: ObservableObject { // await handleNetworkChange(isConnected: path.status == .satisfied) // } // } + serverCertificateManager = ServerCertificateManager(ignoreSSL: false) + serverCertificateManager?.initializeCertificatesStore() } public func waitForActiveConnection(timeout: TimeInterval = 10) async -> ConnectionInfo? { @@ -244,3 +248,14 @@ public final class NetworkTracker: ObservableObject { } } } + +public extension NetworkTracker { + func activeConnectionStream() -> AsyncStream { + AsyncStream { continuation in + let cancellable = self.$activeConnection + .sink { continuation.yield($0) } + + continuation.onTermination = { _ in cancellable.cancel() } + } + } +} diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OpenHABSessionDelegate.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABSessionDelegate.swift index b6ebdee78..341211897 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/OpenHABSessionDelegate.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABSessionDelegate.swift @@ -33,15 +33,15 @@ class OpenHABSessionDelegate: SessionDelegate { evaluation = determineEvaluation(with: challenge.protectionSpace.host) case NSURLAuthenticationMethodHTTPDigest, NSURLAuthenticationMethodNTLM, NSURLAuthenticationMethodNegotiate: - (evaluation.disposition, evaluation.credential) = NetworkConnection.shared.clientCertificateManager.evaluateTrust(with: challenge) + (evaluation.disposition, evaluation.credential) = NetworkTracker.shared.clientCertificateManager.evaluateTrust(with: challenge) evaluation.error = nil #if !(os(Linux) || os(Windows)) case NSURLAuthenticationMethodServerTrust: - (evaluation.disposition, evaluation.credential) = NetworkConnection.shared.serverCertificateManager.evaluateTrust(with: challenge) + (evaluation.disposition, evaluation.credential) = (NetworkTracker.shared.serverCertificateManager?.evaluateTrust(with: challenge))! evaluation.error = nil case NSURLAuthenticationMethodClientCertificate: // evaluation = attemptCredentialAuthentication(for: challenge, belongingTo: task) - (evaluation.disposition, evaluation.credential) = NetworkConnection.shared.clientCertificateManager.evaluateTrust(with: challenge) + (evaluation.disposition, evaluation.credential) = NetworkTracker.shared.clientCertificateManager.evaluateTrust(with: challenge) evaluation.error = nil #endif default: diff --git a/openHAB/ClientCertificatesViewModel.swift b/openHAB/ClientCertificatesViewModel.swift index a804df350..5416d9208 100644 --- a/openHAB/ClientCertificatesViewModel.swift +++ b/openHAB/ClientCertificatesViewModel.swift @@ -21,17 +21,17 @@ class ClientCertificatesViewModel: ObservableObject { } func loadCertificates() { - clientCertificates = NetworkConnection.shared.clientCertificateManager.clientIdentities + clientCertificates = NetworkTracker.shared.clientCertificateManager.clientIdentities } func deleteCertificate(at index: Int) { - let status = NetworkConnection.shared.clientCertificateManager.deleteFromKeychain(index: index) + let status = NetworkTracker.shared.clientCertificateManager.deleteFromKeychain(index: index) if status == noErr { clientCertificates.remove(at: index) } } func getIdentityName(for index: Int) -> String { - NetworkConnection.shared.clientCertificateManager.getIdentityName(index: index) + NetworkTracker.shared.clientCertificateManager.getIdentityName(index: index) } } diff --git a/openHABWatch/Domain/UserData.swift b/openHABWatch/Domain/UserData.swift index f64609449..9811d54a0 100644 --- a/openHABWatch/Domain/UserData.swift +++ b/openHABWatch/Domain/UserData.swift @@ -9,58 +9,243 @@ // // SPDX-License-Identifier: EPL-2.0 -import Combine +//// Copyright (c) 2010-2025 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 Combine +// import Foundation +// import OpenHABCore +// import os.log +// import SwiftUI +// +// final class UserData: ObservableObject { +// static let shared = UserData() +// @Published var widgets: [OpenHABWidget] = [] +// @Published var showAlert = false +// @Published var errorDescription = "" +// @Published var showCertificateAlert = false +// @Published var certificateErrorDescription = "" +// let decoder = JSONDecoder() +// +// var openHABSitemapPage: OpenHABPage? +// +// private var commandOperation: URLSessionTask? +// private var currentPageOperation: URLSessionTask? +// private var activePageTask: Task? +// private var cancellables = Set() +// +// private let logger = Logger(subsystem: "org.openhab.app.watchkitapp", category: "UserData") +// +// // Add property near other published properties +// var currentClient: HTTPClient? +// +// // Add to init() after decoder setup +// init() { +// decoder.dateDecodingStrategy = .formatted(DateFormatter.iso8601Full) +// +// let data = PreviewConstants.sitemapJson +// +// do { +// // Self-executing closure +// // Inspired by https://www.swiftbysundell.com/posts/inline-types-and-functions-in-swift +// openHABSitemapPage = try { +// let sitemapPageCodingData = try data.decoded(as: OpenHABPage.CodingData.self) +// return sitemapPageCodingData.openHABSitemapPage +// }() +// } catch { +// logger.error("Should not throw \(error.localizedDescription)") +// } +// +// widgets = openHABSitemapPage?.widgets ?? [] +// +// openHABSitemapPage?.sendCommand = { [weak self] item, command in +// self?.sendCommand(item, command: command) +// } +// } +// +// init(sitemapName: String = "watch") { +// NotificationCenter.default.addObserver( +// forName: .evaluateServerTrust, +// object: nil, +// queue: .main +// ) { [weak self] notification in +// guard let self, +// let summary = notification.userInfo?["summary"] as? String, +// let domain = notification.userInfo?["domain"] as? String, +// let client = notification.object as? HTTPClient else { return } +// +// certificateErrorDescription = String(format: NSLocalizedString("ssl_certificate_invalid", comment: ""), summary, domain) +// currentClient = client +// DispatchQueue.main.async { +// self.showCertificateAlert = true +// } +// } +// NotificationCenter.default.addObserver( +// forName: .evaluateCertificateMismatch, +// object: nil, +// queue: .main +// ) { [weak self] notification in +// guard let self, +// let summary = notification.userInfo?["summary"] as? String, +// let domain = notification.userInfo?["domain"] as? String, +// let client = notification.object as? HTTPClient else { return } +// +// certificateErrorDescription = String(format: NSLocalizedString("ssl_certificate_no_match", comment: ""), summary, domain) +// currentClient = client +// DispatchQueue.main.async { +// self.showCertificateAlert = true +// } +// } +// +// NotificationCenter.default.addObserver( +// forName: .acceptedServerCertificatesChanged, +// object: nil, +// queue: nil +// ) { _ in +// NetworkTracker.shared.restartTracking() +// } +// +// updateNetwork() +// +// NetworkTracker.shared.$activeConnection +// .receive(on: DispatchQueue.main) +// .sink { [weak self] activeConnection in +// if let activeConnection { +// self?.logger.info("openHABTracked: \(activeConnection.configuration.url)") +// +// if !ObservableOpenHABDataObject.shared.haveReceivedAppContext { +// AppMessageService.singleton.requestApplicationContext() +// self?.errorDescription = NSLocalizedString("settings_not_received", comment: "") +// self?.showAlert = true +// return +// } +// +// ObservableOpenHABDataObject.shared.openHABRootUrl = activeConnection.configuration.url +// ObservableOpenHABDataObject.shared.openHABVersion = activeConnection.version +// +// // TODE Update RootURL +// self?.loadPage(sitemapName: ObservableOpenHABDataObject.shared.sitemapForWatch, longPolling: false, refresh: true) +// } +// } +// .store(in: &cancellables) +// +// ObservableOpenHABDataObject.shared.objectRefreshed.sink { _ in +// // New settings updates from the phone app to start a reconnect +// self.logger.info("Settings update received, starting reconnect") +// self.updateNetwork() +// } +// .store(in: &cancellables) +// } +// +// func updateNetwork() { +// if !ObservableOpenHABDataObject.shared.localUrl.isEmpty || !ObservableOpenHABDataObject.shared.remoteUrl.isEmpty { +// let connection1 = ConnectionConfiguration( +// url: ObservableOpenHABDataObject.shared.localUrl, +// priority: 0 +// ) +// let connection2 = ConnectionConfiguration( +// url: ObservableOpenHABDataObject.shared.remoteUrl, +// priority: 1 +// ) +// NetworkTracker.shared.startTracking(connectionConfigurations: [connection1, connection2], username: ObservableOpenHABDataObject.shared.openHABUsername, password: ObservableOpenHABDataObject.shared.openHABPassword, alwaysSendBasicAuth: ObservableOpenHABDataObject.shared.openHABAlwaysSendCreds, ignoreSSLVerification: ObservableOpenHABDataObject.shared.ignoreSSL) +// } +// } +// +// func loadPage(sitemapName: String, longPolling: Bool, refresh: Bool) { +// logger.info("Loading page \(sitemapName) longPolling \(longPolling) refresh \(refresh)") +// +// // Cancel the active task if it is running +// activePageTask?.cancel() +// +// activePageTask = Task { [weak self] in +// guard let self else { return } +// do { +// guard let openAPIService = NetworkTracker.shared.openApiService else { return } +// openHABSitemapPage = try await openAPIService.pollDataForPage(sitemapname: sitemapName, longPolling: longPolling) +// // Configures then sendCommand closure (existing logic) +// openHABSitemapPage?.sendCommand = { [weak self] item, command in +// self?.sendCommand(item, command: command) +// } +// // Always update UI on the main thread +// await MainActor.run { +// self.widgets = self.openHABSitemapPage?.widgets ?? [] +// self.showAlert = self.widgets.isEmpty +// if refresh { +// self.loadPage(sitemapName: sitemapName, longPolling: true, refresh: true) +// } +// } +// } catch { +// if Task.isCancelled { +// logger.info("Task was canceled") +// } else { +// logger.error("Polling failed with error \(error)") +// await MainActor.run { +// self.logger.error("On LoadPage \"\(error.localizedDescription)\"") +// self.widgets = [] +// self.showAlert = true +// } +// } +// } +// } +// } +// +// func sendCommand(_ item: OpenHABItem?, command: String?) { +// if let commandOperation, commandOperation.state == .running { +// commandOperation.cancel() +// } +// if let item, let command { +// Task { +// do { +// try await NetworkTracker.shared.openApiService?.sendItemCommand(itemname: item.name, command: command) +// } catch { +// logger.error("Error sending command \(command) to \(item.name): \(error.localizedDescription)") +// } +// } +// } +// } +// +// func refreshUrl() { +// if ObservableOpenHABDataObject.shared.haveReceivedAppContext, !ObservableOpenHABDataObject.shared.openHABRootUrl.isEmpty { +// showAlert = false +// // TODO: Update +// loadPage(sitemapName: ObservableOpenHABDataObject.shared.sitemapForWatch, longPolling: false, refresh: true) +// } +// } +// } + import Foundation import OpenHABCore import os.log import SwiftUI +@MainActor final class UserData: ObservableObject { static let shared = UserData() + @Published var widgets: [OpenHABWidget] = [] @Published var showAlert = false @Published var errorDescription = "" @Published var showCertificateAlert = false @Published var certificateErrorDescription = "" - let decoder = JSONDecoder() var openHABSitemapPage: OpenHABPage? - - private var commandOperation: URLSessionTask? - private var currentPageOperation: URLSessionTask? - private var activePageTask: Task? - private var cancellables = Set() - - private let logger = Logger(subsystem: "org.openhab.app.watchkitapp", category: "UserData") - - // Add property near other published properties var currentClient: HTTPClient? - // Add to init() after decoder setup - init() { - decoder.dateDecodingStrategy = .formatted(DateFormatter.iso8601Full) - - let data = PreviewConstants.sitemapJson - - do { - // Self-executing closure - // Inspired by https://www.swiftbysundell.com/posts/inline-types-and-functions-in-swift - openHABSitemapPage = try { - let sitemapPageCodingData = try data.decoded(as: OpenHABPage.CodingData.self) - return sitemapPageCodingData.openHABSitemapPage - }() - } catch { - logger.error("Should not throw \(error.localizedDescription)") - } - - widgets = openHABSitemapPage?.widgets ?? [] + private let logger = Logger(subsystem: "org.openhab.app.watchkitapp", category: "UserData") - openHABSitemapPage?.sendCommand = { [weak self] item, command in - self?.sendCommand(item, command: command) + init(sitemapName: String = "watch") { + Task { + await observeNetworkChanges() } - } - init(sitemapName: String = "watch") { NotificationCenter.default.addObserver( forName: .evaluateServerTrust, object: nil, @@ -70,13 +255,13 @@ final class UserData: ObservableObject { let summary = notification.userInfo?["summary"] as? String, let domain = notification.userInfo?["domain"] as? String, let client = notification.object as? HTTPClient else { return } - - certificateErrorDescription = String(format: NSLocalizedString("ssl_certificate_invalid", comment: ""), summary, domain) - currentClient = client DispatchQueue.main.async { + self.certificateErrorDescription = String(format: NSLocalizedString("ssl_certificate_invalid", comment: ""), summary, domain) + self.currentClient = client self.showCertificateAlert = true } } + NotificationCenter.default.addObserver( forName: .evaluateCertificateMismatch, object: nil, @@ -86,10 +271,9 @@ final class UserData: ObservableObject { let summary = notification.userInfo?["summary"] as? String, let domain = notification.userInfo?["domain"] as? String, let client = notification.object as? HTTPClient else { return } - - certificateErrorDescription = String(format: NSLocalizedString("ssl_certificate_no_match", comment: ""), summary, domain) - currentClient = client DispatchQueue.main.async { + self.certificateErrorDescription = String(format: NSLocalizedString("ssl_certificate_no_match", comment: ""), summary, domain) + self.currentClient = client self.showCertificateAlert = true } } @@ -101,40 +285,30 @@ final class UserData: ObservableObject { ) { _ in NetworkTracker.shared.restartTracking() } + } - updateNetwork() - - NetworkTracker.shared.$activeConnection - .receive(on: DispatchQueue.main) - .sink { [weak self] activeConnection in - if let activeConnection { - self?.logger.info("openHABTracked: \(activeConnection.configuration.url)") - - if !ObservableOpenHABDataObject.shared.haveReceivedAppContext { - AppMessageService.singleton.requestApplicationContext() - self?.errorDescription = NSLocalizedString("settings_not_received", comment: "") - self?.showAlert = true - return - } + /// Observes network connection changes and updates state + private func observeNetworkChanges() async { + for await activeConnection in NetworkTracker.shared.activeConnectionStream() { + guard let activeConnection else { continue } - ObservableOpenHABDataObject.shared.openHABRootUrl = activeConnection.configuration.url - ObservableOpenHABDataObject.shared.openHABVersion = activeConnection.version + logger.info("openHABTracked: \(activeConnection.configuration.url)") - // TODE Update RootURL - self?.loadPage(sitemapName: ObservableOpenHABDataObject.shared.sitemapForWatch, longPolling: false, refresh: true) - } + if !ObservableOpenHABDataObject.shared.haveReceivedAppContext { + AppMessageService.singleton.requestApplicationContext() + errorDescription = NSLocalizedString("settings_not_received", comment: "") + showAlert = true + continue } - .store(in: &cancellables) - ObservableOpenHABDataObject.shared.objectRefreshed.sink { _ in - // New settings updates from the phone app to start a reconnect - self.logger.info("Settings update received, starting reconnect") - self.updateNetwork() + ObservableOpenHABDataObject.shared.openHABRootUrl = activeConnection.configuration.url + ObservableOpenHABDataObject.shared.openHABVersion = activeConnection.version + + await loadPage(sitemapName: ObservableOpenHABDataObject.shared.sitemapForWatch, longPolling: false, refresh: true) } - .store(in: &cancellables) } - func updateNetwork() { + func updateNetwork() async { if !ObservableOpenHABDataObject.shared.localUrl.isEmpty || !ObservableOpenHABDataObject.shared.remoteUrl.isEmpty { let connection1 = ConnectionConfiguration( url: ObservableOpenHABDataObject.shared.localUrl, @@ -144,68 +318,56 @@ final class UserData: ObservableObject { url: ObservableOpenHABDataObject.shared.remoteUrl, priority: 1 ) - NetworkTracker.shared.startTracking(connectionConfigurations: [connection1, connection2], username: ObservableOpenHABDataObject.shared.openHABUsername, password: ObservableOpenHABDataObject.shared.openHABPassword, alwaysSendBasicAuth: ObservableOpenHABDataObject.shared.openHABAlwaysSendCreds, ignoreSSLVerification: ObservableOpenHABDataObject.shared.ignoreSSL) + + NetworkTracker.shared.startTracking( + connectionConfigurations: [connection1, connection2], + username: ObservableOpenHABDataObject.shared.openHABUsername, + password: ObservableOpenHABDataObject.shared.openHABPassword, + alwaysSendBasicAuth: ObservableOpenHABDataObject.shared.openHABAlwaysSendCreds, + ignoreSSLVerification: ObservableOpenHABDataObject.shared.ignoreSSL + ) } } - func loadPage(sitemapName: String, longPolling: Bool, refresh: Bool) { + func loadPage(sitemapName: String, longPolling: Bool, refresh: Bool) async { logger.info("Loading page \(sitemapName) longPolling \(longPolling) refresh \(refresh)") - // Cancel the active task if it is running - activePageTask?.cancel() - - activePageTask = Task { [weak self] in - guard let self else { return } - do { - guard let openAPIService = NetworkTracker.shared.openApiService else { return } - openHABSitemapPage = try await openAPIService.pollDataForPage(sitemapname: sitemapName, longPolling: longPolling) - // Configures then sendCommand closure (existing logic) - openHABSitemapPage?.sendCommand = { [weak self] item, command in - self?.sendCommand(item, command: command) - } - // Always update UI on the main thread - await MainActor.run { - self.widgets = self.openHABSitemapPage?.widgets ?? [] - self.showAlert = self.widgets.isEmpty - if refresh { - self.loadPage(sitemapName: sitemapName, longPolling: true, refresh: true) - } - } - } catch { - if Task.isCancelled { - logger.info("Task was canceled") - } else { - logger.error("Polling failed with error \(error)") - await MainActor.run { - self.logger.error("On LoadPage \"\(error.localizedDescription)\"") - self.widgets = [] - self.showAlert = true - } - } + do { + guard let openAPIService = NetworkTracker.shared.openApiService else { return } + openHABSitemapPage = try await openAPIService.pollDataForPage(sitemapname: sitemapName, longPolling: longPolling) + + openHABSitemapPage?.sendCommand = { [weak self] item, command in + Task { await self?.sendCommand(item, command: command) } } - } - } - func sendCommand(_ item: OpenHABItem?, command: String?) { - if let commandOperation, commandOperation.state == .running { - commandOperation.cancel() - } - if let item, let command { - Task { - do { - try await NetworkTracker.shared.openApiService?.sendItemCommand(itemname: item.name, command: command) - } catch { - logger.error("Error sending command \(command) to \(item.name): \(error.localizedDescription)") - } + widgets = openHABSitemapPage?.widgets ?? [] + showAlert = widgets.isEmpty + + if refresh { + await loadPage(sitemapName: sitemapName, longPolling: true, refresh: true) } + } catch { + logger.error("Polling failed with error \(error.localizedDescription)") + widgets = [] + showAlert = true } } - func refreshUrl() { - if ObservableOpenHABDataObject.shared.haveReceivedAppContext, !ObservableOpenHABDataObject.shared.openHABRootUrl.isEmpty { - showAlert = false - // TODO: Update - loadPage(sitemapName: ObservableOpenHABDataObject.shared.sitemapForWatch, longPolling: false, refresh: true) + func sendCommand(_ item: OpenHABItem?, command: String?) async { + guard let item, let command else { return } + + do { + try await NetworkTracker.shared.openApiService?.sendItemCommand(itemname: item.name, command: command) + } catch { + logger.error("Error sending command \(command) to \(item.name): \(error.localizedDescription)") } } + + func refreshUrl() async { + guard ObservableOpenHABDataObject.shared.haveReceivedAppContext, + !ObservableOpenHABDataObject.shared.openHABRootUrl.isEmpty else { return } + + showAlert = false + await loadPage(sitemapName: ObservableOpenHABDataObject.shared.sitemapForWatch, longPolling: false, refresh: true) + } } diff --git a/openHABWatch/Views/ContentView.swift b/openHABWatch/Views/ContentView.swift index 8d138b49f..b907bd618 100644 --- a/openHABWatch/Views/ContentView.swift +++ b/openHABWatch/Views/ContentView.swift @@ -50,11 +50,9 @@ struct ContentView: View { } if viewModel.showAlert { Text("Refreshing...") - .onAppear { - DispatchQueue.main.async { - viewModel.refreshUrl() - os_log("reload after alert", log: .default, type: .info) - } + .task { + await viewModel.refreshUrl() + os_log("reload after alert", log: .default, type: .info) viewModel.showAlert = false } } From 93472645ccdd412d4699446b82441ea3a7131625 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Tue, 4 Mar 2025 22:10:41 +0100 Subject: [PATCH 042/476] Removed unused protocol CommItem Initalized NetworkTracker.serverCertificateManager - avoid a force optional unwrapping Removing Alamofire where it is not needed anymore Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../OpenHABCore/Model/OpenHABItem.swift | 2 +- .../OpenHABCore/Util/NetworkConnection.swift | 7 +- .../OpenHABCore/Util/NetworkTracker.swift | 4 +- .../Util/OpenHABAccessTokenAdapter.swift | 22 +- .../Util/OpenHABSessionDelegate.swift | 103 ++++----- .../Util/ServerCertificateManager.swift | 12 +- openHAB/OpenHABRootViewController.swift | 4 - openHAB/OpenHABSitemapViewController.swift | 42 ++-- openHABWatch/Domain/UserData.swift | 201 ------------------ .../Extension/OpenHABWatchAppDelegate.swift | 6 - openHABWatch/Views/Rows/ImageRow.swift | 1 - 11 files changed, 79 insertions(+), 325 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABItem.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABItem.swift index 3f3235cd4..628d3d2e0 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABItem.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABItem.swift @@ -13,7 +13,7 @@ import CoreLocation import os.log import UIKit -public class OpenHABItem: NSObject, CommItem { +public class OpenHABItem: NSObject { public enum ItemType: String { case color = "Color" case contact = "Contact" diff --git a/OpenHABCore/Sources/OpenHABCore/Util/NetworkConnection.swift b/OpenHABCore/Sources/OpenHABCore/Util/NetworkConnection.swift index a77645880..b69eacd9b 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/NetworkConnection.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/NetworkConnection.swift @@ -9,7 +9,6 @@ // // SPDX-License-Identifier: EPL-2.0 -import Alamofire import Foundation import os.log @@ -40,7 +39,7 @@ public func onReceiveSessionChallenge(with challenge: URLAuthenticationChallenge switch challenge.protectionSpace.authenticationMethod { case NSURLAuthenticationMethodServerTrust: // TODO: - return (NetworkTracker.shared.serverCertificateManager?.evaluateTrust(with: challenge))! + return NetworkTracker.shared.serverCertificateManager.evaluateTrust(with: challenge) case NSURLAuthenticationMethodClientCertificate: return NetworkTracker.shared.clientCertificateManager.evaluateTrust(with: challenge) // attemptCredentialAuthentication @@ -56,7 +55,3 @@ public func onReceiveSessionChallenge(with challenge: URLAuthenticationChallenge return (disposition, credential) } } - -public protocol CommItem { - var link: String { get set } -} diff --git a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift index 53e80781c..4cb94f3c8 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift @@ -61,7 +61,7 @@ public final class NetworkTracker: ObservableObject { private var retryTask: Task? public private(set) var httpClient: HTTPClient? public var clientCertificateManager = ClientCertificateManager() - public var serverCertificateManager: ServerCertificateManager? + public var serverCertificateManager = ServerCertificateManager() private let logger = Logger(subsystem: "org.openhab.core", category: "NetworkTracker") @@ -79,8 +79,6 @@ public final class NetworkTracker: ObservableObject { // await handleNetworkChange(isConnected: path.status == .satisfied) // } // } - serverCertificateManager = ServerCertificateManager(ignoreSSL: false) - serverCertificateManager?.initializeCertificatesStore() } public func waitForActiveConnection(timeout: TimeInterval = 10) async -> ConnectionInfo? { diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OpenHABAccessTokenAdapter.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABAccessTokenAdapter.swift index 937c5a7e6..e3db179bb 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/OpenHABAccessTokenAdapter.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABAccessTokenAdapter.swift @@ -9,36 +9,16 @@ // // SPDX-License-Identifier: EPL-2.0 -import Alamofire import Foundation import Kingfisher -public class OpenHABAccessTokenAdapter: RequestInterceptor { +public class OpenHABAccessTokenAdapter { var appData: DataObject public init(appData data: DataObject) { appData = data } - public func adapt(_ urlRequest: URLRequest, for session: Session, completion: @escaping (Result) -> Void) { - guard appData.openHABAlwaysSendCreds || urlRequest.url?.host?.hasSuffix("myopenhab.org") == true else { - // The user did not choose for the credentials to be sent with every request. - return completion(.success(urlRequest)) - } - - let user = appData.openHABUsername - let password = appData.openHABPassword - - guard !user.isEmpty, !password.isEmpty else { - // In order to set the credentials on the `URLRequestt`, both username and password must be set up. - return completion(.success(urlRequest)) - } - - var urlRequest = urlRequest - urlRequest.headers.add(.authorization(username: user, password: password)) - completion(.success(urlRequest)) - } - public func adapt(_ urlRequest: URLRequest) throws -> URLRequest { guard appData.openHABAlwaysSendCreds || urlRequest.url?.host?.hasSuffix("myopenhab.org") == true else { // The user did not choose for the credentials to be sent with every request. diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OpenHABSessionDelegate.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABSessionDelegate.swift index 341211897..cf7dc95d7 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/OpenHABSessionDelegate.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABSessionDelegate.swift @@ -9,56 +9,57 @@ // // SPDX-License-Identifier: EPL-2.0 -import Alamofire import Foundation -// Alamofire 5 does not provide client certificate handling via a taskDidReceiveChallenge closure: -// The alternative method is explained by jshier in https://github.com/Alamofire/Alamofire/issues/2886#issuecomment-517951747 - -class OpenHABSessionDelegate: SessionDelegate { - // swiftlint:disable:next large_tuple - typealias ChallengeEvaluation = (disposition: URLSession.AuthChallengeDisposition, credential: URLCredential?, error: AFError?) - - var eventMonitor: EventMonitor? - - override func urlSession(_ session: URLSession, - task: URLSessionTask, - didReceive challenge: URLAuthenticationChallenge, - completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { - eventMonitor?.urlSession(session, task: task, didReceive: challenge) - - let evaluation: ChallengeEvaluation - switch challenge.protectionSpace.authenticationMethod { - case NSURLAuthenticationMethodHTTPBasic: - evaluation = determineEvaluation(with: challenge.protectionSpace.host) - case NSURLAuthenticationMethodHTTPDigest, NSURLAuthenticationMethodNTLM, - NSURLAuthenticationMethodNegotiate: - (evaluation.disposition, evaluation.credential) = NetworkTracker.shared.clientCertificateManager.evaluateTrust(with: challenge) - evaluation.error = nil - #if !(os(Linux) || os(Windows)) - case NSURLAuthenticationMethodServerTrust: - (evaluation.disposition, evaluation.credential) = (NetworkTracker.shared.serverCertificateManager?.evaluateTrust(with: challenge))! - evaluation.error = nil - case NSURLAuthenticationMethodClientCertificate: - // evaluation = attemptCredentialAuthentication(for: challenge, belongingTo: task) - (evaluation.disposition, evaluation.credential) = NetworkTracker.shared.clientCertificateManager.evaluateTrust(with: challenge) - evaluation.error = nil - #endif - default: - evaluation = determineEvaluation(with: challenge.protectionSpace.host) - } - - completionHandler(evaluation.disposition, evaluation.credential) - } - - private func determineEvaluation(with host: String) -> ChallengeEvaluation { - let localUrl = URL(string: Preferences.localUrl) - let remoteUrl = URL(string: Preferences.remoteUrl) - if host == localUrl?.host || host == remoteUrl?.host { - let credential = URLCredential(user: Preferences.username, password: Preferences.password, persistence: .forSession) - return (.useCredential, credential, nil) - } else { - return (.performDefaultHandling, nil, nil) - } - } -} +// Alamofire remaining +// +//// Alamofire 5 does not provide client certificate handling via a taskDidReceiveChallenge closure: +//// The alternative method is explained by jshier in https://github.com/Alamofire/Alamofire/issues/2886#issuecomment-517951747 +// +// class OpenHABSessionDelegate: SessionDelegate { +// // swiftlint:disable:next large_tuple +// typealias ChallengeEvaluation = (disposition: URLSession.AuthChallengeDisposition, credential: URLCredential?, error: AFError?) +// +// var eventMonitor: EventMonitor? +// +// override func urlSession(_ session: URLSession, +// task: URLSessionTask, +// didReceive challenge: URLAuthenticationChallenge, +// completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { +// eventMonitor?.urlSession(session, task: task, didReceive: challenge) +// +// let evaluation: ChallengeEvaluation +// switch challenge.protectionSpace.authenticationMethod { +// case NSURLAuthenticationMethodHTTPBasic: +// evaluation = determineEvaluation(with: challenge.protectionSpace.host) +// case NSURLAuthenticationMethodHTTPDigest, NSURLAuthenticationMethodNTLM, +// NSURLAuthenticationMethodNegotiate: +// (evaluation.disposition, evaluation.credential) = NetworkTracker.shared.clientCertificateManager.evaluateTrust(with: challenge) +// evaluation.error = nil +// #if !(os(Linux) || os(Windows)) +// case NSURLAuthenticationMethodServerTrust: +// (evaluation.disposition, evaluation.credential) = NetworkTracker.shared.serverCertificateManager.evaluateTrust(with: challenge) +// evaluation.error = nil +// case NSURLAuthenticationMethodClientCertificate: +// // evaluation = attemptCredentialAuthentication(for: challenge, belongingTo: task) +// (evaluation.disposition, evaluation.credential) = NetworkTracker.shared.clientCertificateManager.evaluateTrust(with: challenge) +// evaluation.error = nil +// #endif +// default: +// evaluation = determineEvaluation(with: challenge.protectionSpace.host) +// } +// +// completionHandler(evaluation.disposition, evaluation.credential) +// } +// +// private func determineEvaluation(with host: String) -> ChallengeEvaluation { +// let localUrl = URL(string: Preferences.localUrl) +// let remoteUrl = URL(string: Preferences.remoteUrl) +// if host == localUrl?.host || host == remoteUrl?.host { +// let credential = URLCredential(user: Preferences.username, password: Preferences.password, persistence: .forSession) +// return (.useCredential, credential, nil) +// } else { +// return (.performDefaultHandling, nil, nil) +// } +// } +// } diff --git a/OpenHABCore/Sources/OpenHABCore/Util/ServerCertificateManager.swift b/OpenHABCore/Sources/OpenHABCore/Util/ServerCertificateManager.swift index 227d3828e..37866f24d 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/ServerCertificateManager.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/ServerCertificateManager.swift @@ -22,7 +22,7 @@ public protocol ServerCertificateManagerDelegate: NSObjectProtocol { func acceptedServerCertificatesChanged(_ policy: ServerCertificateManager?) } -public class ServerCertificateManager: ServerTrustManager, ServerTrustEvaluating { +public class ServerCertificateManager { // ServerTrustManager, ServerTrustEvaluating { // Handle the different responses of the user public enum EvaluateResult { case undecided @@ -47,12 +47,10 @@ public class ServerCertificateManager: ServerTrustManager, ServerTrustEvaluating public var trustedCertificates: [String: Data] = [:] // Init a ServerCertificateManager and set ignore certificates setting - public init(ignoreSSL: Bool) { - super.init(evaluators: [:]) + public init(ignoreSSL: Bool = false) { +// super.init(evaluators: [:]) self.ignoreSSL = ignoreSSL - } - func initializeCertificatesStore() { os_log("Initializing cert store", log: .remoteAccess, type: .info) loadTrustedCertificates() if trustedCertificates.isEmpty { @@ -225,8 +223,4 @@ public class ServerCertificateManager: ServerTrustManager, ServerTrustEvaluating nil } } - - override public func serverTrustEvaluator(forHost host: String) -> ServerTrustEvaluating? { - self as ServerTrustEvaluating - } } diff --git a/openHAB/OpenHABRootViewController.swift b/openHAB/OpenHABRootViewController.swift index 47183bb97..ad71c2ad0 100644 --- a/openHAB/OpenHABRootViewController.swift +++ b/openHAB/OpenHABRootViewController.swift @@ -32,10 +32,6 @@ protocol ModalHandler: AnyObject { func modalDismissed(to: TargetController) } -struct CommandItem: CommItem { - var link: String -} - private let logger = Logger(subsystem: "org.openhab.UI", category: "OpenHABRootViewController") // swiftlint:disable type_body_length diff --git a/openHAB/OpenHABSitemapViewController.swift b/openHAB/OpenHABSitemapViewController.swift index d5e2a6679..5ac72fd23 100644 --- a/openHAB/OpenHABSitemapViewController.swift +++ b/openHAB/OpenHABSitemapViewController.swift @@ -23,11 +23,6 @@ import SVGKit import SwiftUI import UIKit -enum Action { - typealias Sync = (UIViewController, I) -> O - typealias Async = (UIViewController, I, @escaping (O) -> Void) -> Void -} - struct OpenHABImageProcessor: ImageProcessor { // `identifier` should be the same for processors with the same properties/functionality // It will be used when storing and retrieving the image to/from cache. @@ -210,7 +205,9 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel } os_log("OpenHABSitemapViewController pageUrl is empty, this is first launch", log: .viewCycle, type: .info) } else { - Task { await openAPIService?.updateBaseURL(with: URL(string: appData!.openHABRootUrl)!) } + Task { + await openAPIService?.updateBaseURL(with: URL(string: appData!.openHABRootUrl)!) + } // we only want to our watcher to notify us about changes, and not the inital value activeServerWatcher = activeServerWatcher.dropFirst().eraseToAnyPublisher() if !pageNetworkStatusChanged() { @@ -538,25 +535,26 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel return nil } - @discardableResult + @discardableResult func pageNetworkStatusChanged() -> Bool { os_log("OpenHABSitemapViewController pageNetworkStatusChange", log: .remoteAccess, type: .info) - if !pageUrl.isEmpty { - let pageReachability = NetworkReachabilityManager(host: pageUrl) - if !pageNetworkStatusAvailable { - pageNetworkStatus = pageReachability?.status - pageNetworkStatusAvailable = true - return false - } else { - if pageNetworkStatus == pageReachability?.status { - return false - } else { - pageNetworkStatus = pageReachability?.status - return true - } - } + + guard !pageUrl.isEmpty else { return false } + + let currentStatus = pageNetworkStatus + + if !pageNetworkStatusAvailable { + pageNetworkStatus = currentStatus + pageNetworkStatusAvailable = true + return false + } + + if pageNetworkStatus == currentStatus { + return false + } else { + pageNetworkStatus = currentStatus + return true } - return false } func filterContentForSearchText(_ searchText: String?, scope: String = "All") { diff --git a/openHABWatch/Domain/UserData.swift b/openHABWatch/Domain/UserData.swift index 9811d54a0..37f93bd47 100644 --- a/openHABWatch/Domain/UserData.swift +++ b/openHABWatch/Domain/UserData.swift @@ -19,207 +19,6 @@ //// http://www.eclipse.org/legal/epl-2.0 //// //// SPDX-License-Identifier: EPL-2.0 -// -// import Combine -// import Foundation -// import OpenHABCore -// import os.log -// import SwiftUI -// -// final class UserData: ObservableObject { -// static let shared = UserData() -// @Published var widgets: [OpenHABWidget] = [] -// @Published var showAlert = false -// @Published var errorDescription = "" -// @Published var showCertificateAlert = false -// @Published var certificateErrorDescription = "" -// let decoder = JSONDecoder() -// -// var openHABSitemapPage: OpenHABPage? -// -// private var commandOperation: URLSessionTask? -// private var currentPageOperation: URLSessionTask? -// private var activePageTask: Task? -// private var cancellables = Set() -// -// private let logger = Logger(subsystem: "org.openhab.app.watchkitapp", category: "UserData") -// -// // Add property near other published properties -// var currentClient: HTTPClient? -// -// // Add to init() after decoder setup -// init() { -// decoder.dateDecodingStrategy = .formatted(DateFormatter.iso8601Full) -// -// let data = PreviewConstants.sitemapJson -// -// do { -// // Self-executing closure -// // Inspired by https://www.swiftbysundell.com/posts/inline-types-and-functions-in-swift -// openHABSitemapPage = try { -// let sitemapPageCodingData = try data.decoded(as: OpenHABPage.CodingData.self) -// return sitemapPageCodingData.openHABSitemapPage -// }() -// } catch { -// logger.error("Should not throw \(error.localizedDescription)") -// } -// -// widgets = openHABSitemapPage?.widgets ?? [] -// -// openHABSitemapPage?.sendCommand = { [weak self] item, command in -// self?.sendCommand(item, command: command) -// } -// } -// -// init(sitemapName: String = "watch") { -// NotificationCenter.default.addObserver( -// forName: .evaluateServerTrust, -// object: nil, -// queue: .main -// ) { [weak self] notification in -// guard let self, -// let summary = notification.userInfo?["summary"] as? String, -// let domain = notification.userInfo?["domain"] as? String, -// let client = notification.object as? HTTPClient else { return } -// -// certificateErrorDescription = String(format: NSLocalizedString("ssl_certificate_invalid", comment: ""), summary, domain) -// currentClient = client -// DispatchQueue.main.async { -// self.showCertificateAlert = true -// } -// } -// NotificationCenter.default.addObserver( -// forName: .evaluateCertificateMismatch, -// object: nil, -// queue: .main -// ) { [weak self] notification in -// guard let self, -// let summary = notification.userInfo?["summary"] as? String, -// let domain = notification.userInfo?["domain"] as? String, -// let client = notification.object as? HTTPClient else { return } -// -// certificateErrorDescription = String(format: NSLocalizedString("ssl_certificate_no_match", comment: ""), summary, domain) -// currentClient = client -// DispatchQueue.main.async { -// self.showCertificateAlert = true -// } -// } -// -// NotificationCenter.default.addObserver( -// forName: .acceptedServerCertificatesChanged, -// object: nil, -// queue: nil -// ) { _ in -// NetworkTracker.shared.restartTracking() -// } -// -// updateNetwork() -// -// NetworkTracker.shared.$activeConnection -// .receive(on: DispatchQueue.main) -// .sink { [weak self] activeConnection in -// if let activeConnection { -// self?.logger.info("openHABTracked: \(activeConnection.configuration.url)") -// -// if !ObservableOpenHABDataObject.shared.haveReceivedAppContext { -// AppMessageService.singleton.requestApplicationContext() -// self?.errorDescription = NSLocalizedString("settings_not_received", comment: "") -// self?.showAlert = true -// return -// } -// -// ObservableOpenHABDataObject.shared.openHABRootUrl = activeConnection.configuration.url -// ObservableOpenHABDataObject.shared.openHABVersion = activeConnection.version -// -// // TODE Update RootURL -// self?.loadPage(sitemapName: ObservableOpenHABDataObject.shared.sitemapForWatch, longPolling: false, refresh: true) -// } -// } -// .store(in: &cancellables) -// -// ObservableOpenHABDataObject.shared.objectRefreshed.sink { _ in -// // New settings updates from the phone app to start a reconnect -// self.logger.info("Settings update received, starting reconnect") -// self.updateNetwork() -// } -// .store(in: &cancellables) -// } -// -// func updateNetwork() { -// if !ObservableOpenHABDataObject.shared.localUrl.isEmpty || !ObservableOpenHABDataObject.shared.remoteUrl.isEmpty { -// let connection1 = ConnectionConfiguration( -// url: ObservableOpenHABDataObject.shared.localUrl, -// priority: 0 -// ) -// let connection2 = ConnectionConfiguration( -// url: ObservableOpenHABDataObject.shared.remoteUrl, -// priority: 1 -// ) -// NetworkTracker.shared.startTracking(connectionConfigurations: [connection1, connection2], username: ObservableOpenHABDataObject.shared.openHABUsername, password: ObservableOpenHABDataObject.shared.openHABPassword, alwaysSendBasicAuth: ObservableOpenHABDataObject.shared.openHABAlwaysSendCreds, ignoreSSLVerification: ObservableOpenHABDataObject.shared.ignoreSSL) -// } -// } -// -// func loadPage(sitemapName: String, longPolling: Bool, refresh: Bool) { -// logger.info("Loading page \(sitemapName) longPolling \(longPolling) refresh \(refresh)") -// -// // Cancel the active task if it is running -// activePageTask?.cancel() -// -// activePageTask = Task { [weak self] in -// guard let self else { return } -// do { -// guard let openAPIService = NetworkTracker.shared.openApiService else { return } -// openHABSitemapPage = try await openAPIService.pollDataForPage(sitemapname: sitemapName, longPolling: longPolling) -// // Configures then sendCommand closure (existing logic) -// openHABSitemapPage?.sendCommand = { [weak self] item, command in -// self?.sendCommand(item, command: command) -// } -// // Always update UI on the main thread -// await MainActor.run { -// self.widgets = self.openHABSitemapPage?.widgets ?? [] -// self.showAlert = self.widgets.isEmpty -// if refresh { -// self.loadPage(sitemapName: sitemapName, longPolling: true, refresh: true) -// } -// } -// } catch { -// if Task.isCancelled { -// logger.info("Task was canceled") -// } else { -// logger.error("Polling failed with error \(error)") -// await MainActor.run { -// self.logger.error("On LoadPage \"\(error.localizedDescription)\"") -// self.widgets = [] -// self.showAlert = true -// } -// } -// } -// } -// } -// -// func sendCommand(_ item: OpenHABItem?, command: String?) { -// if let commandOperation, commandOperation.state == .running { -// commandOperation.cancel() -// } -// if let item, let command { -// Task { -// do { -// try await NetworkTracker.shared.openApiService?.sendItemCommand(itemname: item.name, command: command) -// } catch { -// logger.error("Error sending command \(command) to \(item.name): \(error.localizedDescription)") -// } -// } -// } -// } -// -// func refreshUrl() { -// if ObservableOpenHABDataObject.shared.haveReceivedAppContext, !ObservableOpenHABDataObject.shared.openHABRootUrl.isEmpty { -// showAlert = false -// // TODO: Update -// loadPage(sitemapName: ObservableOpenHABDataObject.shared.sitemapForWatch, longPolling: false, refresh: true) -// } -// } -// } import Foundation import OpenHABCore diff --git a/openHABWatch/Extension/OpenHABWatchAppDelegate.swift b/openHABWatch/Extension/OpenHABWatchAppDelegate.swift index 1bf3a7656..b7487346e 100644 --- a/openHABWatch/Extension/OpenHABWatchAppDelegate.swift +++ b/openHABWatch/Extension/OpenHABWatchAppDelegate.swift @@ -58,12 +58,6 @@ extension OpenHABWatchAppDelegate: WKApplicationDelegate { } } } - - func applicationDidFinishLaunching() { -// // Kingfisher setup -// ImageDownloader.default.authenticationChallengeResponder = self -// KingfisherManager.shared.defaultOptions = [.requestModifier(OpenHABAccessTokenAdapter(appData: ObservableOpenHABDataObject.shared))] - } } // MARK: - ClientCertificateManagerDelegate diff --git a/openHABWatch/Views/Rows/ImageRow.swift b/openHABWatch/Views/Rows/ImageRow.swift index 00f425366..69085eaae 100644 --- a/openHABWatch/Views/Rows/ImageRow.swift +++ b/openHABWatch/Views/Rows/ImageRow.swift @@ -11,7 +11,6 @@ import OpenHABCore import os.log -import SDWebImageSwiftUI import SwiftUI struct ImageRow: View { From 0f76c49aa7d6c43d5fca46f1330c8695c294e3f9 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Tue, 4 Mar 2025 23:51:13 +0100 Subject: [PATCH 043/476] Preparing to get rid of Alamofire's NetworkReachabilityManager Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- openHAB/OpenHABSitemapViewController.swift | 54 +++++++++++++++++++--- openHAB/OpenHABViewController.swift | 2 + 2 files changed, 50 insertions(+), 6 deletions(-) diff --git a/openHAB/OpenHABSitemapViewController.swift b/openHAB/OpenHABSitemapViewController.swift index 5ac72fd23..0926b28c8 100644 --- a/openHAB/OpenHABSitemapViewController.swift +++ b/openHAB/OpenHABSitemapViewController.swift @@ -188,12 +188,30 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel self.showPopupMessage(seconds: 60, title: NSLocalizedString("error", comment: ""), message: NSLocalizedString("network_not_available", comment: ""), theme: .error) case .connected: self.hidePopupMessages() - case _: - break } } .store(in: &trackerCancellables) + func trackNetworkStatus() { + let task = Task { + for await status in NetworkTracker.shared.$status.values { + os_log("OpenHABViewController tracker status %{PUBLIC}@", log: .viewCycle, type: .info, status.rawValue) + await MainActor.run { + switch status { + case .connecting: + self.showPopupMessage(seconds: 1.5, title: NSLocalizedString("connecting", comment: ""), message: "", theme: .info) + case .notConnected: + 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 .connected: + self.hidePopupMessages() + } + } + } + } + activeTasks.insert(task) + } + var activeServerWatcher = NetworkTracker.shared.$activeConnection.eraseToAnyPublisher() // if pageUrl == "" it means we are the first opened OpenHABSitemapViewController if pageUrl == "" { @@ -218,7 +236,7 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel restart() } } - // 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. + // listen for network changes, if stateWatcher.dropFirst() was NOT called, then this will execute imediately with current values and then again if the network changes, otherwise it will be called on changes only. activeServerWatcher .receive(on: DispatchQueue.main) .sink { activeConnection in @@ -230,13 +248,36 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel } .store(in: &trackerCancellables) + func startWatchingActiveServer() { + let task = Task { + for await activeConnection in NetworkTracker.shared.$activeConnection.values { + await MainActor.run { + if let activeConnection { + os_log("OpenHABSitemapViewController tracker URL %{PUBLIC}@", log: .viewCycle, type: .info, activeConnection.configuration.url) + self.openHABRootUrl = activeConnection.configuration.url + self.selectSitemap() + } + } + } + } + activeTasks.insert(task) + } + ImageDownloader.default.authenticationChallengeResponder = self } + func stopAllTasks() { + for task in activeTasks { + task.cancel() + } + activeTasks.removeAll() + } + override func viewWillDisappear(_ animated: Bool) { os_log("OpenHABSitemapViewController viewWillDisappear", log: .viewCycle, type: .info) trackerCancellables.removeAll() + stopAllTasks() super.viewWillDisappear(animated) @@ -535,14 +576,15 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel return nil } - @discardableResult + @discardableResult func pageNetworkStatusChanged() -> Bool { os_log("OpenHABSitemapViewController pageNetworkStatusChange", log: .remoteAccess, type: .info) - + guard !pageUrl.isEmpty else { return false } - let currentStatus = pageNetworkStatus + let currentStatus = NetworkReachabilityManager(host: pageUrl)?.status ?? .notReachable + // First run if !pageNetworkStatusAvailable { pageNetworkStatus = currentStatus pageNetworkStatusAvailable = true diff --git a/openHAB/OpenHABViewController.swift b/openHAB/OpenHABViewController.swift index 8dade65ba..bc6154ded 100644 --- a/openHAB/OpenHABViewController.swift +++ b/openHAB/OpenHABViewController.swift @@ -18,6 +18,8 @@ import UIKit class OpenHABViewController: UIViewController { var trackerCancellables = Set() + var activeTasks = Set>() + override func viewDidLoad() { super.viewDidLoad() NotificationCenter.default.addObserver(self, selector: #selector(OpenHABViewController.didEnterBackground(_:)), name: UIApplication.didEnterBackgroundNotification, object: nil) From 0826f5911c1fd02a82bd3da8803d7c97746a7f41 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Wed, 5 Mar 2025 07:46:04 +0100 Subject: [PATCH 044/476] Uses Swift Concurrency instead of Combine Preserves .dropFirst() behavior by skipping the first value manually Stores tasks in activeTasks for proper cleanup Updates UI safely on the main thread Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- openHAB/OpenHABSitemapViewController.swift | 60 ++++++++-------------- 1 file changed, 21 insertions(+), 39 deletions(-) diff --git a/openHAB/OpenHABSitemapViewController.swift b/openHAB/OpenHABSitemapViewController.swift index 0926b28c8..b8ce05452 100644 --- a/openHAB/OpenHABSitemapViewController.swift +++ b/openHAB/OpenHABSitemapViewController.swift @@ -176,22 +176,6 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel UIApplication.shared.isIdleTimerDisabled = true } - 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 .notConnected: - 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 .connected: - self.hidePopupMessages() - } - } - .store(in: &trackerCancellables) - func trackNetworkStatus() { let task = Task { for await status in NetworkTracker.shared.$status.values { @@ -212,10 +196,8 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel activeTasks.insert(task) } - var activeServerWatcher = NetworkTracker.shared.$activeConnection.eraseToAnyPublisher() // if pageUrl == "" it means we are the first opened OpenHABSitemapViewController if pageUrl == "" { - // Set self as root view controller appData?.sitemapViewController = self if currentPage != nil { currentPage?.widgets = [] @@ -226,8 +208,7 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel Task { await openAPIService?.updateBaseURL(with: URL(string: appData!.openHABRootUrl)!) } - // we only want to our watcher to notify us about changes, and not the inital value - activeServerWatcher = activeServerWatcher.dropFirst().eraseToAnyPublisher() + if !pageNetworkStatusChanged() { os_log("OpenHABSitemapViewController pageUrl = %{PUBLIC}@", log: .notifications, type: .info, pageUrl) loadPage(false) @@ -236,21 +217,18 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel restart() } } - // listen for network changes, if stateWatcher.dropFirst() was NOT called, then this will execute imediately with current values and then again if the network changes, otherwise it will be called on changes only. - activeServerWatcher - .receive(on: DispatchQueue.main) - .sink { activeConnection in - if let activeConnection { - os_log("OpenHABSitemapViewController tracker URL %{PUBLIC}@", log: .viewCycle, type: .info, activeConnection.configuration.url) - self.openHABRootUrl = activeConnection.configuration.url - self.selectSitemap() - } - } - .store(in: &trackerCancellables) func startWatchingActiveServer() { let task = Task { + var isFirst = true // Track first value + for await activeConnection in NetworkTracker.shared.$activeConnection.values { + // we only want our watcher to notify us about changes, and not the inital value + if isFirst { + isFirst = false + continue + } + await MainActor.run { if let activeConnection { os_log("OpenHABSitemapViewController tracker URL %{PUBLIC}@", log: .viewCycle, type: .info, activeConnection.configuration.url) @@ -260,17 +238,14 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel } } } - activeTasks.insert(task) + activeTasks.insert(task) // Store the task for cancellation } - ImageDownloader.default.authenticationChallengeResponder = self - } +// TODO: consider this feature to get rid of NetworkReachability + trackNetworkStatus() + startWatchingActiveServer() - func stopAllTasks() { - for task in activeTasks { - task.cancel() - } - activeTasks.removeAll() + ImageDownloader.default.authenticationChallengeResponder = self } override func viewWillDisappear(_ animated: Bool) { @@ -326,6 +301,13 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel widgetTableView.reloadData() } + func stopAllTasks() { + for task in activeTasks { + task.cancel() + } + activeTasks.removeAll() + } + /// Implementation of GenericUITableViewCellTouchEventDelegate func touchDown() { isUserInteracting = true From 9fdc8dca3187043348ec092c9a670d1dfc4b65f5 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Wed, 5 Mar 2025 20:35:42 +0100 Subject: [PATCH 045/476] Purged Alamofire from the project - Removed NetworkActivityIndicatorManager as this was relying on Alamofire Changed the ServerCertificateManager Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- OpenHABCore/Package.swift | 2 - .../Util/OpenHABAccessTokenAdapter.swift | 12 ++- .../Util/ServerCertificateManager.swift | 17 ++-- openHAB.xcodeproj/project.pbxproj | 34 ------- .../xcshareddata/swiftpm/Package.resolved | 20 +--- openHAB/AppDelegate.swift | 4 - openHAB/OpenHABSitemapViewController.swift | 94 +++++++++---------- openHABWatch/OpenHABWatch.swift | 12 ++- 8 files changed, 79 insertions(+), 116 deletions(-) diff --git a/OpenHABCore/Package.swift b/OpenHABCore/Package.swift index 18dac9534..001755cc7 100644 --- a/OpenHABCore/Package.swift +++ b/OpenHABCore/Package.swift @@ -15,7 +15,6 @@ let package = Package( ], dependencies: [ // Dependencies declare other packages that this package depends on. - .package(url: "https://github.com/Alamofire/Alamofire.git", from: "5.0.0"), .package(url: "https://github.com/onevcat/Kingfisher.git", from: "7.0.0"), .package(url: "https://github.com/apple/swift-openapi-runtime", from: "1.0.0"), .package(url: "https://github.com/apple/swift-openapi-urlsession", from: "1.0.0") @@ -26,7 +25,6 @@ let package = Package( .target( name: "OpenHABCore", dependencies: [ - .product(name: "Alamofire", package: "Alamofire", condition: .when(platforms: [.iOS, .watchOS])), .product(name: "Kingfisher", package: "Kingfisher", condition: .when(platforms: [.iOS, .watchOS])), .product(name: "OpenAPIRuntime", package: "swift-openapi-runtime"), .product(name: "OpenAPIURLSession", package: "swift-openapi-urlsession") diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OpenHABAccessTokenAdapter.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABAccessTokenAdapter.swift index e3db179bb..7f7a734d6 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/OpenHABAccessTokenAdapter.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABAccessTokenAdapter.swift @@ -33,7 +33,17 @@ public class OpenHABAccessTokenAdapter { } var urlRequest = urlRequest - urlRequest.headers.add(.authorization(username: user, password: password)) + + func basicAuthHeader() -> String { + let authString = "\(user):\(password)" + let authData = authString.data(using: .utf8)! + return "Basic \(authData.base64EncodedString())" + } + // We are handling URLRequests here, so we need to set the header fields + // to the request object with String and cannot use the type safe way of HTTPRequest + // like request.headerFields[.authorization] = basicAuthHeader() + // TODO revert this!! + urlRequest.setValue(basicAuthHeader(), forHTTPHeaderField: "Authorization") return urlRequest } } diff --git a/OpenHABCore/Sources/OpenHABCore/Util/ServerCertificateManager.swift b/OpenHABCore/Sources/OpenHABCore/Util/ServerCertificateManager.swift index 37866f24d..bd93c6e47 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/ServerCertificateManager.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/ServerCertificateManager.swift @@ -9,7 +9,6 @@ // // SPDX-License-Identifier: EPL-2.0 -import Alamofire import Foundation import os.log @@ -22,6 +21,10 @@ public protocol ServerCertificateManagerDelegate: NSObjectProtocol { func acceptedServerCertificatesChanged(_ policy: ServerCertificateManager?) } +enum ServerCertificateManagerError: Error { + case serverTrustEvaluationFailed +} + public class ServerCertificateManager { // ServerTrustManager, ServerTrustEvaluating { // Handle the different responses of the user public enum EvaluateResult { @@ -167,7 +170,7 @@ public class ServerCertificateManager { // ServerTrustManager, ServerTrustEvalua switch self.evaluateResult { case .deny: // User decided to abort connection - throw AFError.serverTrustEvaluationFailed(reason: .noCertificatesFound) + throw ServerCertificateManagerError.serverTrustEvaluationFailed case .permitOnce: // User decided to accept invalid certificate once return @@ -179,10 +182,10 @@ public class ServerCertificateManager { // ServerTrustManager, ServerTrustEvalua return case .undecided: // Something went wrong, abort connection - throw AFError.serverTrustEvaluationFailed(reason: .noCertificatesFound) + throw ServerCertificateManagerError.serverTrustEvaluationFailed } } - throw AFError.serverTrustEvaluationFailed(reason: .noCertificatesFound) + throw ServerCertificateManagerError.serverTrustEvaluationFailed } } // Warn user about invalid certificate and wait for user's decision @@ -195,7 +198,7 @@ public class ServerCertificateManager { // ServerTrustManager, ServerTrustEvalua switch self.evaluateResult { case .deny: // User decided to abort connection - throw AFError.serverTrustEvaluationFailed(reason: .noCertificatesFound) + throw ServerCertificateManagerError.serverTrustEvaluationFailed case .permitOnce: // User decided to accept invalid certificate once return @@ -206,11 +209,11 @@ public class ServerCertificateManager { // ServerTrustManager, ServerTrustEvalua delegate.acceptedServerCertificatesChanged(self) return case .undecided: - throw AFError.serverTrustEvaluationFailed(reason: .noCertificatesFound) + throw ServerCertificateManagerError.serverTrustEvaluationFailed } } // We have no way of handling it so no access! - throw AFError.serverTrustEvaluationFailed(reason: .noCertificatesFound) + throw ServerCertificateManagerError.serverTrustEvaluationFailed } func getLeafCertificate(trust: SecTrust?) -> SecCertificate? { diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index d0b0eba89..e84b34741 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -28,7 +28,6 @@ 934E592528F16EBA00162004 /* OpenHABCore in Frameworks */ = {isa = PBXBuildFile; productRef = 934E592428F16EBA00162004 /* OpenHABCore */; }; 934E592728F16EBA00162004 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 934E592628F16EBA00162004 /* Kingfisher */; }; 934E592928F16EBA00162004 /* DeviceKit in Frameworks */ = {isa = PBXBuildFile; productRef = 934E592828F16EBA00162004 /* DeviceKit */; }; - 934E592B28F16EBA00162004 /* Alamofire in Frameworks */ = {isa = PBXBuildFile; productRef = 934E592A28F16EBA00162004 /* Alamofire */; }; 935B484625342B8E00E44CF0 /* URL+Static.swift in Sources */ = {isa = PBXBuildFile; fileRef = 935B484525342B8E00E44CF0 /* URL+Static.swift */; }; 93685A7A2ADE755C0077A9A6 /* openHABTests.xctestplan in Resources */ = {isa = PBXBuildFile; fileRef = 93685A792ADE755C0077A9A6 /* openHABTests.xctestplan */; }; 937C8B0C2800A738009C055E /* Intents.intentdefinition in Sources */ = {isa = PBXBuildFile; fileRef = 935D340A257B7DC00020A404 /* Intents.intentdefinition */; }; @@ -59,9 +58,7 @@ 93AEE42A27D9D792008EB207 /* SetColorValueIntentHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D38D9612568978E0039DA6E /* SetColorValueIntentHandler.swift */; }; 93AEE42B27D9D796008EB207 /* SetContactStateValueIntentHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D38D969256897AD0039DA6E /* SetContactStateValueIntentHandler.swift */; }; 93B7B33128018301009EB296 /* Intents.intentdefinition in Sources */ = {isa = PBXBuildFile; fileRef = 935D340A257B7DC00020A404 /* Intents.intentdefinition */; }; - 93F8061B27AE615D0035A6B0 /* Alamofire in Frameworks */ = {isa = PBXBuildFile; productRef = 93F8061A27AE615D0035A6B0 /* Alamofire */; }; 93F8062F27AE63620035A6B0 /* Alamofire in Frameworks */ = {isa = PBXBuildFile; productRef = 93F8062E27AE63620035A6B0 /* Alamofire */; }; - 93F8063227AE6B940035A6B0 /* AlamofireNetworkActivityIndicator in Frameworks */ = {isa = PBXBuildFile; productRef = 93F8063127AE6B940035A6B0 /* AlamofireNetworkActivityIndicator */; }; 93F8063527AE6C620035A6B0 /* FirebaseCrashlytics in Frameworks */ = {isa = PBXBuildFile; productRef = 93F8063427AE6C620035A6B0 /* FirebaseCrashlytics */; }; 93F8064727AE7A050035A6B0 /* SwiftMessages in Frameworks */ = {isa = PBXBuildFile; productRef = 93F8064627AE7A050035A6B0 /* SwiftMessages */; }; 93F8064A27AE7A2E0035A6B0 /* FlexColorPicker in Frameworks */ = {isa = PBXBuildFile; productRef = 93F8064927AE7A2E0035A6B0 /* FlexColorPicker */; }; @@ -489,7 +486,6 @@ files = ( 934E592728F16EBA00162004 /* Kingfisher in Frameworks */, 937E4473270B36DD00A98C26 /* OpenHABCore in Frameworks */, - 934E592B28F16EBA00162004 /* Alamofire in Frameworks */, DA2C4FD52B4F573300D1C533 /* SDWebImageSVGCoder in Frameworks */, 934E592528F16EBA00162004 /* OpenHABCore in Frameworks */, 93F8062F27AE63620035A6B0 /* Alamofire in Frameworks */, @@ -558,11 +554,9 @@ DFB2622F18830A3600D3244D /* UIKit.framework in Frameworks */, DACE664A2C63B0760069E514 /* OpenAPIURLSession in Frameworks */, 93F8064A27AE7A2E0035A6B0 /* FlexColorPicker in Frameworks */, - 93F8063227AE6B940035A6B0 /* AlamofireNetworkActivityIndicator in Frameworks */, 93F8065327AE7B580035A6B0 /* SVGKit in Frameworks */, DA28C362225241DE00AB409C /* WebKit.framework in Frameworks */, DACE664D2C63B0840069E514 /* OpenAPIRuntime in Frameworks */, - 93F8061B27AE615D0035A6B0 /* Alamofire in Frameworks */, 93F8065027AE7A830035A6B0 /* SideMenu in Frameworks */, DFE10414197415F900D94943 /* Security.framework in Frameworks */, 93F8064727AE7A050035A6B0 /* SwiftMessages in Frameworks */, @@ -1121,7 +1115,6 @@ 934E592428F16EBA00162004 /* OpenHABCore */, 934E592628F16EBA00162004 /* Kingfisher */, 934E592828F16EBA00162004 /* DeviceKit */, - 934E592A28F16EBA00162004 /* Alamofire */, DA2C4FCC2B4F55D700D1C533 /* SDWebImage */, DA2C4FCE2B4F55D700D1C533 /* SDWebImageMapKit */, DA2C4FD12B4F56D000D1C533 /* SDWebImageSwiftUI */, @@ -1210,8 +1203,6 @@ 937E4470270B36D000A98C26 /* OpenHABCore */, 937E4484270B379900A98C26 /* DeviceKit */, 937E4487270B37A600A98C26 /* Kingfisher */, - 93F8061A27AE615D0035A6B0 /* Alamofire */, - 93F8063127AE6B940035A6B0 /* AlamofireNetworkActivityIndicator */, 93F8063427AE6C620035A6B0 /* FirebaseCrashlytics */, 93F8064627AE7A050035A6B0 /* SwiftMessages */, 93F8064927AE7A2E0035A6B0 /* FlexColorPicker */, @@ -1306,8 +1297,6 @@ packageReferences = ( 937E4483270B379900A98C26 /* XCRemoteSwiftPackageReference "DeviceKit" */, 937E4486270B37A600A98C26 /* XCRemoteSwiftPackageReference "Kingfisher" */, - 93F8061927AE615D0035A6B0 /* XCRemoteSwiftPackageReference "Alamofire" */, - 93F8063027AE6B940035A6B0 /* XCRemoteSwiftPackageReference "AlamofireNetworkActivityIndicator" */, 93F8063327AE6C620035A6B0 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */, 93F8064527AE7A050035A6B0 /* XCRemoteSwiftPackageReference "SwiftMessages" */, 93F8064827AE7A2E0035A6B0 /* XCRemoteSwiftPackageReference "FlexColorPicker" */, @@ -2623,14 +2612,6 @@ minimumVersion = 5.4.4; }; }; - 93F8063027AE6B940035A6B0 /* XCRemoteSwiftPackageReference "AlamofireNetworkActivityIndicator" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/Alamofire/AlamofireNetworkActivityIndicator.git"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 3.1.0; - }; - }; 93F8063327AE6C620035A6B0 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/firebase/firebase-ios-sdk.git"; @@ -2745,11 +2726,6 @@ package = 937E4483270B379900A98C26 /* XCRemoteSwiftPackageReference "DeviceKit" */; productName = DeviceKit; }; - 934E592A28F16EBA00162004 /* Alamofire */ = { - isa = XCSwiftPackageProductDependency; - package = 93F8061927AE615D0035A6B0 /* XCRemoteSwiftPackageReference "Alamofire" */; - productName = Alamofire; - }; 937E4470270B36D000A98C26 /* OpenHABCore */ = { isa = XCSwiftPackageProductDependency; productName = OpenHABCore; @@ -2787,21 +2763,11 @@ isa = XCSwiftPackageProductDependency; productName = OpenHABCore; }; - 93F8061A27AE615D0035A6B0 /* Alamofire */ = { - isa = XCSwiftPackageProductDependency; - package = 93F8061927AE615D0035A6B0 /* XCRemoteSwiftPackageReference "Alamofire" */; - productName = Alamofire; - }; 93F8062E27AE63620035A6B0 /* Alamofire */ = { isa = XCSwiftPackageProductDependency; package = 93F8061927AE615D0035A6B0 /* XCRemoteSwiftPackageReference "Alamofire" */; productName = Alamofire; }; - 93F8063127AE6B940035A6B0 /* AlamofireNetworkActivityIndicator */ = { - isa = XCSwiftPackageProductDependency; - package = 93F8063027AE6B940035A6B0 /* XCRemoteSwiftPackageReference "AlamofireNetworkActivityIndicator" */; - productName = AlamofireNetworkActivityIndicator; - }; 93F8063427AE6C620035A6B0 /* FirebaseCrashlytics */ = { isa = XCSwiftPackageProductDependency; package = 93F8063327AE6C620035A6B0 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; diff --git a/openHAB.xcworkspace/xcshareddata/swiftpm/Package.resolved b/openHAB.xcworkspace/xcshareddata/swiftpm/Package.resolved index 27d1be534..1c01ec0e3 100644 --- a/openHAB.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/openHAB.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "2ad7ac9694caf3d8c6de68292ac2e209c51ffb7db35b0cf43f6c36eaa4ab31c1", + "originHash" : "8eade3b50b066081f5b3cd85ab51a15e57c4063c0b6bb1223f02347d477b36c6", "pins" : [ { "identity" : "abseil-cpp-binary", @@ -10,24 +10,6 @@ "version" : "1.2024011602.0" } }, - { - "identity" : "alamofire", - "kind" : "remoteSourceControl", - "location" : "https://github.com/Alamofire/Alamofire.git", - "state" : { - "revision" : "513364f870f6bfc468f9d2ff0a95caccc10044c5", - "version" : "5.10.2" - } - }, - { - "identity" : "alamofirenetworkactivityindicator", - "kind" : "remoteSourceControl", - "location" : "https://github.com/Alamofire/AlamofireNetworkActivityIndicator.git", - "state" : { - "revision" : "392bed083e8d193aca16bfa684ee24e4bcff0510", - "version" : "3.1.0" - } - }, { "identity" : "app-check", "kind" : "remoteSourceControl", diff --git a/openHAB/AppDelegate.swift b/openHAB/AppDelegate.swift index 883af8ae1..0c7adbacf 100644 --- a/openHAB/AppDelegate.swift +++ b/openHAB/AppDelegate.swift @@ -9,7 +9,6 @@ // // SPDX-License-Identifier: EPL-2.0 -import AlamofireNetworkActivityIndicator import AVFoundation import Firebase import FirebaseMessaging @@ -57,9 +56,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD Preferences.migrateUserDefaultsIfRequired() - NetworkActivityIndicatorManager.shared.isEnabled = true - NetworkActivityIndicatorManager.shared.startDelay = 1.0 - registerForPushNotifications() os_log("uniq id: %{PUBLIC}s", log: .notifications, type: .info, UIDevice.current.identifierForVendor?.uuidString ?? "") diff --git a/openHAB/OpenHABSitemapViewController.swift b/openHAB/OpenHABSitemapViewController.swift index b8ce05452..c2025f8e1 100644 --- a/openHAB/OpenHABSitemapViewController.swift +++ b/openHAB/OpenHABSitemapViewController.swift @@ -9,7 +9,6 @@ // // SPDX-License-Identifier: EPL-2.0 -import Alamofire import AVFoundation import AVKit import Combine @@ -71,7 +70,7 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel private var sitemaps: [OpenHABSitemap] = [] private var currentPage: OpenHABPage? private var selectionPicker: UIPickerView? - private var pageNetworkStatus: NetworkReachabilityManager.NetworkReachabilityStatus? + private var pageNetworkStatus: NetworkStatus? private var pageNetworkStatusAvailable = false private var toggle: Int = 0 private var refreshControl: UIRefreshControl? @@ -176,26 +175,6 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel UIApplication.shared.isIdleTimerDisabled = true } - func trackNetworkStatus() { - let task = Task { - for await status in NetworkTracker.shared.$status.values { - os_log("OpenHABViewController tracker status %{PUBLIC}@", log: .viewCycle, type: .info, status.rawValue) - await MainActor.run { - switch status { - case .connecting: - self.showPopupMessage(seconds: 1.5, title: NSLocalizedString("connecting", comment: ""), message: "", theme: .info) - case .notConnected: - 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 .connected: - self.hidePopupMessages() - } - } - } - } - activeTasks.insert(task) - } - // if pageUrl == "" it means we are the first opened OpenHABSitemapViewController if pageUrl == "" { appData?.sitemapViewController = self @@ -218,31 +197,7 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel } } - func startWatchingActiveServer() { - let task = Task { - var isFirst = true // Track first value - - for await activeConnection in NetworkTracker.shared.$activeConnection.values { - // we only want our watcher to notify us about changes, and not the inital value - if isFirst { - isFirst = false - continue - } - - await MainActor.run { - if let activeConnection { - os_log("OpenHABSitemapViewController tracker URL %{PUBLIC}@", log: .viewCycle, type: .info, activeConnection.configuration.url) - self.openHABRootUrl = activeConnection.configuration.url - self.selectSitemap() - } - } - } - } - activeTasks.insert(task) // Store the task for cancellation - } - -// TODO: consider this feature to get rid of NetworkReachability - trackNetworkStatus() + startTrackNetworkStatus() startWatchingActiveServer() ImageDownloader.default.authenticationChallengeResponder = self @@ -301,6 +256,49 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel widgetTableView.reloadData() } + private func startTrackNetworkStatus() { + let task = Task { + for await status in NetworkTracker.shared.$status.values { + os_log("OpenHABViewController tracker status %{PUBLIC}@", log: .viewCycle, type: .info, status.rawValue) + await MainActor.run { + switch status { + case .connecting: + self.showPopupMessage(seconds: 1.5, title: NSLocalizedString("connecting", comment: ""), message: "", theme: .info) + case .notConnected: + 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 .connected: + self.hidePopupMessages() + } + } + } + } + activeTasks.insert(task) + } + + func startWatchingActiveServer() { + let task = Task { + var isFirst = true // Track first value + + for await activeConnection in NetworkTracker.shared.$activeConnection.values { + // we only want our watcher to notify us about changes, and not the inital value + if isFirst { + isFirst = false + continue + } + + await MainActor.run { + if let activeConnection { + os_log("OpenHABSitemapViewController tracker URL %{PUBLIC}@", log: .viewCycle, type: .info, activeConnection.configuration.url) + self.openHABRootUrl = activeConnection.configuration.url + self.selectSitemap() + } + } + } + } + activeTasks.insert(task) // Store the task for cancellation + } + func stopAllTasks() { for task in activeTasks { task.cancel() @@ -564,7 +562,7 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel guard !pageUrl.isEmpty else { return false } - let currentStatus = NetworkReachabilityManager(host: pageUrl)?.status ?? .notReachable + let currentStatus = NetworkTracker.shared.status // First run if !pageNetworkStatusAvailable { diff --git a/openHABWatch/OpenHABWatch.swift b/openHABWatch/OpenHABWatch.swift index d41fcf918..a3ed27310 100644 --- a/openHABWatch/OpenHABWatch.swift +++ b/openHABWatch/OpenHABWatch.swift @@ -65,7 +65,17 @@ struct OpenHABWatch: App { return request } var request = request - request.headers.add(.authorization(username: openHABUsername, password: openHABPassword)) + + func basicAuthHeader() -> String { + let authString = "\(openHABUsername):\(openHABPassword)" + let authData = authString.data(using: .utf8)! + return "Basic \(authData.base64EncodedString())" + } + // We are handling URLRequests here, so we need to set the header fields + // to the request object with String and cannot use the type safe way of HTTPRequest + // like request.headerFields[.authorization] = basicAuthHeader() + // TODO revert this + request.setValue(basicAuthHeader(), forHTTPHeaderField: "Authorization") return request } SDWebImageDownloader.shared.requestModifier = requestModifier From cb3ac6ddb2df833402b1ed823c8ffef5716e7305 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Wed, 5 Mar 2025 20:41:58 +0100 Subject: [PATCH 046/476] Remove Alamofire as linked library Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- openHAB.xcodeproj/project.pbxproj | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index e84b34741..eae02018b 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -58,7 +58,6 @@ 93AEE42A27D9D792008EB207 /* SetColorValueIntentHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D38D9612568978E0039DA6E /* SetColorValueIntentHandler.swift */; }; 93AEE42B27D9D796008EB207 /* SetContactStateValueIntentHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D38D969256897AD0039DA6E /* SetContactStateValueIntentHandler.swift */; }; 93B7B33128018301009EB296 /* Intents.intentdefinition in Sources */ = {isa = PBXBuildFile; fileRef = 935D340A257B7DC00020A404 /* Intents.intentdefinition */; }; - 93F8062F27AE63620035A6B0 /* Alamofire in Frameworks */ = {isa = PBXBuildFile; productRef = 93F8062E27AE63620035A6B0 /* Alamofire */; }; 93F8063527AE6C620035A6B0 /* FirebaseCrashlytics in Frameworks */ = {isa = PBXBuildFile; productRef = 93F8063427AE6C620035A6B0 /* FirebaseCrashlytics */; }; 93F8064727AE7A050035A6B0 /* SwiftMessages in Frameworks */ = {isa = PBXBuildFile; productRef = 93F8064627AE7A050035A6B0 /* SwiftMessages */; }; 93F8064A27AE7A2E0035A6B0 /* FlexColorPicker in Frameworks */ = {isa = PBXBuildFile; productRef = 93F8064927AE7A2E0035A6B0 /* FlexColorPicker */; }; @@ -488,7 +487,6 @@ 937E4473270B36DD00A98C26 /* OpenHABCore in Frameworks */, DA2C4FD52B4F573300D1C533 /* SDWebImageSVGCoder in Frameworks */, 934E592528F16EBA00162004 /* OpenHABCore in Frameworks */, - 93F8062F27AE63620035A6B0 /* Alamofire in Frameworks */, DA9A7EFD2D668D5900824156 /* SFSafeSymbols in Frameworks */, 937E448E270B37D200A98C26 /* DeviceKit in Frameworks */, DA2C4FCF2B4F55D700D1C533 /* SDWebImageMapKit in Frameworks */, @@ -2604,14 +2602,6 @@ minimumVersion = 7.0.0; }; }; - 93F8061927AE615D0035A6B0 /* XCRemoteSwiftPackageReference "Alamofire" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/Alamofire/Alamofire.git"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 5.4.4; - }; - }; 93F8063327AE6C620035A6B0 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/firebase/firebase-ios-sdk.git"; @@ -2763,11 +2753,6 @@ isa = XCSwiftPackageProductDependency; productName = OpenHABCore; }; - 93F8062E27AE63620035A6B0 /* Alamofire */ = { - isa = XCSwiftPackageProductDependency; - package = 93F8061927AE615D0035A6B0 /* XCRemoteSwiftPackageReference "Alamofire" */; - productName = Alamofire; - }; 93F8063427AE6C620035A6B0 /* FirebaseCrashlytics */ = { isa = XCSwiftPackageProductDependency; package = 93F8063327AE6C620035A6B0 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; From 3507a5bab13370e5f178b822a544354974835fe9 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Wed, 5 Mar 2025 22:35:02 +0100 Subject: [PATCH 047/476] Remove dependency on Alamofire in MockURLProtocol Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../Sources/OpenHABCore/Util/OpenHABAccessTokenAdapter.swift | 2 +- OpenHABCore/Tests/OpenHABCoreTests/MockURLProtocol.swift | 2 +- openHABWatch/OpenHABWatch.swift | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OpenHABAccessTokenAdapter.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABAccessTokenAdapter.swift index 7f7a734d6..566e7b0af 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/OpenHABAccessTokenAdapter.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABAccessTokenAdapter.swift @@ -42,7 +42,7 @@ public class OpenHABAccessTokenAdapter { // We are handling URLRequests here, so we need to set the header fields // to the request object with String and cannot use the type safe way of HTTPRequest // like request.headerFields[.authorization] = basicAuthHeader() - // TODO revert this!! + // TODO: revert this!! urlRequest.setValue(basicAuthHeader(), forHTTPHeaderField: "Authorization") return urlRequest } diff --git a/OpenHABCore/Tests/OpenHABCoreTests/MockURLProtocol.swift b/OpenHABCore/Tests/OpenHABCoreTests/MockURLProtocol.swift index 1d9928f27..08b602bc1 100644 --- a/OpenHABCore/Tests/OpenHABCoreTests/MockURLProtocol.swift +++ b/OpenHABCore/Tests/OpenHABCoreTests/MockURLProtocol.swift @@ -39,7 +39,7 @@ final class MockURLProtocol: URLProtocol { } override func startLoading() { - activeTask = session.dataTask(with: request.urlRequest!) + activeTask = session.dataTask(with: request) activeTask?.cancel() } diff --git a/openHABWatch/OpenHABWatch.swift b/openHABWatch/OpenHABWatch.swift index a3ed27310..1668390a6 100644 --- a/openHABWatch/OpenHABWatch.swift +++ b/openHABWatch/OpenHABWatch.swift @@ -74,7 +74,7 @@ struct OpenHABWatch: App { // We are handling URLRequests here, so we need to set the header fields // to the request object with String and cannot use the type safe way of HTTPRequest // like request.headerFields[.authorization] = basicAuthHeader() - // TODO revert this + // TODO: revert this request.setValue(basicAuthHeader(), forHTTPHeaderField: "Authorization") return request } From 463974ec0c84ed93f5e3f28c56d4dd84135eecfb Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Thu, 6 Mar 2025 21:56:41 +0100 Subject: [PATCH 048/476] LoggingMiddleware: Don't log errors for operationIDs getRoot and getRootVersion In OpenAPIService define setups for URLSessionConfigurations Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../OpenHABCore/Util/LoggingMiddleware.swift | 4 +- .../OpenHABCore/Util/NetworkTracker.swift | 64 ++++++++++++++----- .../OpenHABCore/Util/OpenAPIService.swift | 22 ++++++- openHAB/OpenHABSitemapViewController.swift | 2 + openHAB/OpenHABWebViewController.swift | 1 + 5 files changed, 72 insertions(+), 21 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/LoggingMiddleware.swift b/OpenHABCore/Sources/OpenHABCore/Util/LoggingMiddleware.swift index 6721cea86..ca2e4bb8d 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/LoggingMiddleware.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/LoggingMiddleware.swift @@ -44,7 +44,9 @@ extension LoggingMiddleware: ClientMiddleware { log(request, response, responseBodyToLog) return (response, responseBodyForNext) } catch { - log(request, failedWith: error) + if operationID != "getRoot", operationID != "getRootVersion" { + log(request, failedWith: error) + } throw error } } diff --git a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift index 4cb94f3c8..600083cbb 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift @@ -16,9 +16,11 @@ import os.log // TODO: these strings should reference Localizable keys public enum NetworkStatus: String { - case notConnected = "Not Connected" case connecting = "Connecting" case connected = "Connected" + case notConnected = "Not Connected" + case someConnected = "Some Connected" + case allConnected = "All Connected" } public struct ConnectionConfiguration: Equatable { @@ -54,14 +56,18 @@ public final class NetworkTracker: ObservableObject { @Published public private(set) var activeConnection: ConnectionInfo? @Published public private(set) var status: NetworkStatus = .connecting + private var retryCount = 0 + private let maxRetries = 5 private let monitor: NWPathMonitor private let monitorQueue = DispatchQueue.global(qos: .background) public var openApiService: OpenAPIService? + private var openAPIServices: [OpenAPIService?] = [] private var connectionConfigurations: [ConnectionConfiguration] = [] private var retryTask: Task? public private(set) var httpClient: HTTPClient? public var clientCertificateManager = ClientCertificateManager() public var serverCertificateManager = ServerCertificateManager() + private let disconnectedRetryInterval: UInt64 = 30 // / amount of time we scan when not connected private let logger = Logger(subsystem: "org.openhab.core", category: "NetworkTracker") @@ -103,6 +109,21 @@ public final class NetworkTracker: ObservableObject { } } +// private func getOrCreateService(for configuration: ConnectionConfiguration) async -> OpenAPIService { +// if let cachedService = serviceCache[configuration.url] { +// return cachedService +// } +// +// let newService = await OpenAPIService( +// baseURL: URL(string: configuration.url), +// username: Preferences.username, +// password: Preferences.password +// ) +// +// serviceCache[configuration.url] = newService +// return newService +// } + public func startTracking(connectionConfigurations: [ConnectionConfiguration], username: String, password: String, @@ -110,13 +131,14 @@ public final class NetworkTracker: ObservableObject { ignoreSSLVerification: Bool) { logger.info("Start Tracking") self.connectionConfigurations = adjustMyOpenHABHosts(in: connectionConfigurations) - Task { + // TODO: Remove openApiService = await OpenAPIService( username: username, password: password, alwaysSendBasicAuth: alwaysSendBasicAuth, - ignoreSSL: ignoreSSLVerification + ignoreSSL: ignoreSSLVerification, + configuration: .shorTerm ) await attemptConnection() } @@ -133,7 +155,7 @@ public final class NetworkTracker: ObservableObject { } else { logger.info("Network status: Disconnected") await updateActiveConnection(nil) - startRetryTask() + startRetryTask(10) } } @@ -166,6 +188,7 @@ public final class NetworkTracker: ObservableObject { let sortedConfigs = connectionConfigurations.sorted { $0.priority < $1.priority } var bestConnection: ConnectionInfo? + var connectedCounts = 0 await withTaskGroup(of: ConnectionInfo?.self) { group in for config in sortedConfigs { @@ -175,14 +198,13 @@ public final class NetworkTracker: ObservableObject { } for await connection in group { - if let connection { - if connection.configuration.priority == 0 { - await updateActiveConnection(connection) - return - } - if bestConnection == nil || connection.configuration.priority < bestConnection!.configuration.priority { - bestConnection = connection - } + guard let connection else { continue } + if connection.configuration.priority == 0 { + await updateActiveConnection(connection) + return + } + if bestConnection == nil || connection.configuration.priority < bestConnection!.configuration.priority { + bestConnection = connection } } } @@ -197,22 +219,29 @@ public final class NetworkTracker: ObservableObject { let service = await OpenAPIService( baseURL: url, username: Preferences.username, - password: Preferences.password + password: Preferences.password, + alwaysSendBasicAuth: Preferences.alwaysSendCreds, + ignoreSSL: Preferences.ignoreSSL, + configuration: .shorTerm ) let version = try await service.getRootVersion() let connectionInfo = ConnectionInfo(configuration: configuration, version: version) logger.info("Successfully connected to \(configuration.url)") return connectionInfo + } catch NetworkTrackerError.invalidServerVersion { + logger.info("Invalid server version from \(configuration.url)") + return nil } catch { - logger.error("Failed to connect to \(configuration.url) - \(error.localizedDescription)") + logger.info("Failed to connect to \(configuration.url)") return nil } } - private func startRetryTask() { + private func startRetryTask(_ retryInterval: UInt64) { retryTask?.cancel() retryTask = Task { - try? await Task.sleep(nanoseconds: 30_000_000_000) // 30 seconds + let retryInterval = retryInterval * 1_000_000_000 + try? await Task.sleep(nanoseconds: retryInterval) await attemptConnection() } } @@ -227,10 +256,11 @@ public final class NetworkTracker: ObservableObject { await openApiService?.updateBaseURL(with: URL(string: connection.configuration.url) ?? URL(string: "about:blank")!) } else { status = .notConnected - startRetryTask() + startRetryTask(disconnectedRetryInterval) } } + // Ensures that all URLs pointing to "myopenhab.org" are standardized to "home.myopenhab.org". private func adjustMyOpenHABHosts(in configurations: [ConnectionConfiguration]) -> [ConnectionConfiguration] { configurations.map { configuration in var updatedURL = configuration.url diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift index 34783bb10..999ce671d 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift @@ -20,6 +20,12 @@ public enum OpenAPIServiceError: Error { case noRootURL } +public enum OpenAPIServiceConfiguration { + case asDefault + case shorTerm + case longTerm +} + // The generated OpenAPI client is wrapped by this curated API. // The library leaks the fact that it uses Swift OpenAPI Generator under the hood in 'openHABSitemapWidgetEvents'. // It will require the migration to Swift 6.1 before this can be changed. @@ -45,12 +51,22 @@ public actor OpenAPIService { username: String, password: String, alwaysSendBasicAuth: Bool = false, - ignoreSSL: Bool = false + ignoreSSL: Bool = false, + configuration: OpenAPIServiceConfiguration = .asDefault ) async { // TODO: Make use of prepareURLSessionConfiguration + let config = URLSessionConfiguration.default -// config.timeoutIntervalForRequest = if longPolling { 35.0 } else { 20.0 } -// config.timeoutIntervalForResource = config.timeoutIntervalForRequest + 25 + switch configuration { + case .asDefault: + break + case .longTerm: + config.timeoutIntervalForRequest = 35.0 + config.timeoutIntervalForResource = config.timeoutIntervalForRequest + 25 + case .shorTerm: + config.timeoutIntervalForRequest = 2.0 + config.timeoutIntervalForResource = 2.0 + } let delegate = OpenAPIServiceDelegate(username: username, password: password) let session = URLSession(configuration: config, delegate: delegate, delegateQueue: nil) diff --git a/openHAB/OpenHABSitemapViewController.swift b/openHAB/OpenHABSitemapViewController.swift index c2025f8e1..894c64456 100644 --- a/openHAB/OpenHABSitemapViewController.swift +++ b/openHAB/OpenHABSitemapViewController.swift @@ -269,6 +269,8 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel self.showPopupMessage(seconds: 60, title: NSLocalizedString("error", comment: ""), message: NSLocalizedString("network_not_available", comment: ""), theme: .error) case .connected: self.hidePopupMessages() + default: + break } } } diff --git a/openHAB/OpenHABWebViewController.swift b/openHAB/OpenHABWebViewController.swift index d4ec2c0de..42af9a824 100644 --- a/openHAB/OpenHABWebViewController.swift +++ b/openHAB/OpenHABWebViewController.swift @@ -92,6 +92,7 @@ class OpenHABWebViewController: OpenHABViewController { self.pageLoadError(message: NSLocalizedString("network_not_available", comment: "")) case .connected: self.hidePopupMessages() + default: break } } .store(in: &trackerCancellables) From b12399be2bbf44bf22526ac228b73a1aa655174e Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Fri, 7 Mar 2025 07:37:38 +0100 Subject: [PATCH 049/476] Handle different timeouts within OpenAPIService Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift | 4 ++-- OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift index 600083cbb..ef4024e8a 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift @@ -138,7 +138,7 @@ public final class NetworkTracker: ObservableObject { password: password, alwaysSendBasicAuth: alwaysSendBasicAuth, ignoreSSL: ignoreSSLVerification, - configuration: .shorTerm + configuration: .shortTerm ) await attemptConnection() } @@ -222,7 +222,7 @@ public final class NetworkTracker: ObservableObject { password: Preferences.password, alwaysSendBasicAuth: Preferences.alwaysSendCreds, ignoreSSL: Preferences.ignoreSSL, - configuration: .shorTerm + configuration: .shortTerm ) let version = try await service.getRootVersion() let connectionInfo = ConnectionInfo(configuration: configuration, version: version) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift index 999ce671d..9276975cd 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift @@ -22,7 +22,7 @@ public enum OpenAPIServiceError: Error { public enum OpenAPIServiceConfiguration { case asDefault - case shorTerm + case shortTerm case longTerm } @@ -63,7 +63,7 @@ public actor OpenAPIService { case .longTerm: config.timeoutIntervalForRequest = 35.0 config.timeoutIntervalForResource = config.timeoutIntervalForRequest + 25 - case .shorTerm: + case .shortTerm: config.timeoutIntervalForRequest = 2.0 config.timeoutIntervalForResource = 2.0 } From 30e414aae62c23a010790ec0afffc61d14571f75 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Fri, 7 Mar 2025 10:33:22 +0100 Subject: [PATCH 050/476] Handle myopenhab.org OR alwaysSendBasicAuth Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../Sources/OpenHABCore/Util/AuthorisationMiddleware.swift | 2 +- OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/AuthorisationMiddleware.swift b/OpenHABCore/Sources/OpenHABCore/Util/AuthorisationMiddleware.swift index d62bb9d45..134ac19a5 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/AuthorisationMiddleware.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/AuthorisationMiddleware.swift @@ -40,7 +40,7 @@ extension AuthorisationMiddleware: ClientMiddleware { next: @Sendable (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?)) async throws -> (HTTPResponse, HTTPBody?) { // Use a mutable copy of request var request = request - if baseURL.host?.hasSuffix("myopenhab.org") == nil, alwaysSendBasicAuth, !username.isEmpty, !password.isEmpty { + if (baseURL.host?.hasSuffix("myopenhab.org") == nil || alwaysSendBasicAuth), !username.isEmpty, !password.isEmpty { request.headerFields[.authorization] = basicAuthHeader() } let (response, body) = try await next(request, body, baseURL) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift index 9276975cd..df12e93a0 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift @@ -54,8 +54,6 @@ public actor OpenAPIService { ignoreSSL: Bool = false, configuration: OpenAPIServiceConfiguration = .asDefault ) async { - // TODO: Make use of prepareURLSessionConfiguration - let config = URLSessionConfiguration.default switch configuration { case .asDefault: From 7fa3d90e821659cf57013316777aea9c7b1d12f0 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Fri, 7 Mar 2025 11:38:00 +0100 Subject: [PATCH 051/476] NetworkTracker: Working with a ConnectionPool to avoid initializing OpenAPIService. ConnectionPool designed as actor to ensure thread-safe access and to prevent race conditions Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../OpenHABCore/Util/NetworkTracker.swift | 126 ++++++++++-------- 1 file changed, 71 insertions(+), 55 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift index ef4024e8a..952192f18 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift @@ -23,7 +23,7 @@ public enum NetworkStatus: String { case allConnected = "All Connected" } -public struct ConnectionConfiguration: Equatable { +public struct ConnectionConfiguration: Hashable { public let url: String public let priority: Int // Lower is higher priority, 0 is primary @@ -50,6 +50,26 @@ enum NetworkTrackerError: Error, CustomDebugStringConvertible { } } +actor ConnectionPool { + private var services: [ConnectionConfiguration: OpenAPIService] = [:] + + func getOrCreateService(for configuration: ConnectionConfiguration, url: URL) async -> OpenAPIService { + if let existingService = services[configuration] { + return existingService + } + let newService = await OpenAPIService( + baseURL: url, + username: Preferences.username, + password: Preferences.password, + alwaysSendBasicAuth: Preferences.alwaysSendCreds, + ignoreSSL: Preferences.ignoreSSL, + configuration: .shortTerm + ) + services[configuration] = newService + return newService + } +} + public final class NetworkTracker: ObservableObject { public static let shared = NetworkTracker() @@ -62,6 +82,7 @@ public final class NetworkTracker: ObservableObject { private let monitorQueue = DispatchQueue.global(qos: .background) public var openApiService: OpenAPIService? private var openAPIServices: [OpenAPIService?] = [] + private var connectionPool: ConnectionPool = .init() private var connectionConfigurations: [ConnectionConfiguration] = [] private var retryTask: Task? public private(set) var httpClient: HTTPClient? @@ -72,11 +93,6 @@ public final class NetworkTracker: ObservableObject { private let logger = Logger(subsystem: "org.openhab.core", category: "NetworkTracker") private init() { - monitor = NWPathMonitor() - monitor.pathUpdateHandler = { [weak self] path in - Task { await self?.handleNetworkChange(isConnected: path.status == .satisfied) } - } - monitor.start(queue: monitorQueue) // if #available(iOS 17, watchOS 10, *) { // // The `for await` loop automatically handles updates from NWPathMonitor, so there’s no need for a callback. // Task { @@ -85,28 +101,12 @@ public final class NetworkTracker: ObservableObject { // await handleNetworkChange(isConnected: path.status == .satisfied) // } // } - } - - public func waitForActiveConnection(timeout: TimeInterval = 10) async -> ConnectionInfo? { - await withCheckedContinuation { continuation in - let deadline = Date().addingTimeInterval(timeout) - - func checkConnection() { - Task { @MainActor in - if let activeConnection = self.activeConnection { - continuation.resume(returning: activeConnection) - } else if Date() >= deadline { - continuation.resume(returning: nil) - } else { - DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) { - checkConnection() - } - } - } - } - - checkConnection() +// } else { + monitor = NWPathMonitor() + monitor.pathUpdateHandler = { [weak self] path in + Task { await self?.handleNetworkChange(isConnected: path.status == .satisfied) } } + monitor.start(queue: monitorQueue) } // private func getOrCreateService(for configuration: ConnectionConfiguration) async -> OpenAPIService { @@ -140,25 +140,37 @@ public final class NetworkTracker: ObservableObject { ignoreSSL: ignoreSSLVerification, configuration: .shortTerm ) + await setActiveConnection(nil) await attemptConnection() } } - public func restartTracking() { - Task { await attemptConnection() } - } + public func waitForActiveConnection(timeout: TimeInterval = 10) async -> ConnectionInfo? { + await withCheckedContinuation { continuation in + let deadline = Date().addingTimeInterval(timeout) - private func handleNetworkChange(isConnected: Bool) async { - if isConnected { - logger.info("Network status: Connected") - await checkActiveConnection() - } else { - logger.info("Network status: Disconnected") - await updateActiveConnection(nil) - startRetryTask(10) + func checkConnection() { + Task { @MainActor in + if let activeConnection = self.activeConnection { + continuation.resume(returning: activeConnection) + } else if Date() >= deadline { + continuation.resume(returning: nil) + } else { + DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) { + checkConnection() + } + } + } + } + + checkConnection() } } + public func restartTracking() { + Task { await attemptConnection() } + } + private func checkActiveConnection() async { guard let activeConnection else { os_log("No active connection, attempting to reconnect...", log: OSLog.default, type: .info) @@ -180,7 +192,7 @@ public final class NetworkTracker: ObservableObject { private func attemptConnection() async { guard !connectionConfigurations.isEmpty else { logger.error("No connection configurations available.") - await updateActiveConnection(nil) + await setActiveConnection(nil) return } @@ -197,33 +209,26 @@ public final class NetworkTracker: ObservableObject { } } - for await connection in group { - guard let connection else { continue } - if connection.configuration.priority == 0 { - await updateActiveConnection(connection) + for await connectionInfo in group { + guard let connectionInfo else { continue } + if connectionInfo.configuration.priority == 0 { + await setActiveConnection(connectionInfo) return } - if bestConnection == nil || connection.configuration.priority < bestConnection!.configuration.priority { - bestConnection = connection + if bestConnection == nil || connectionInfo.configuration.priority < bestConnection!.configuration.priority { + bestConnection = connectionInfo } } } - await updateActiveConnection(bestConnection) + await setActiveConnection(bestConnection) } private func testConnection(configuration: ConnectionConfiguration) async -> ConnectionInfo? { guard let url = URL(string: configuration.url) else { return nil } do { - let service = await OpenAPIService( - baseURL: url, - username: Preferences.username, - password: Preferences.password, - alwaysSendBasicAuth: Preferences.alwaysSendCreds, - ignoreSSL: Preferences.ignoreSSL, - configuration: .shortTerm - ) + let service = await connectionPool.getOrCreateService(for: configuration, url: url) let version = try await service.getRootVersion() let connectionInfo = ConnectionInfo(configuration: configuration, version: version) logger.info("Successfully connected to \(configuration.url)") @@ -246,8 +251,19 @@ public final class NetworkTracker: ObservableObject { } } + private func handleNetworkChange(isConnected: Bool) async { + if isConnected { + logger.info("Network status: Connected") + await checkActiveConnection() + } else { + logger.info("Network status: Disconnected") + await setActiveConnection(nil) + startRetryTask(10) + } + } + @MainActor - private func updateActiveConnection(_ connection: ConnectionInfo?) async { + private func setActiveConnection(_ connection: ConnectionInfo?) async { guard activeConnection != connection else { return } activeConnection = connection From 0e0faeaf1a9dde9c2f3c9bee417e890bc49a5ef3 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Fri, 7 Mar 2025 17:57:39 +0100 Subject: [PATCH 052/476] Extending usage of ConnectionPool Defining convenience functions for OpenHABItemCache and UserDate in NetworkTracker : send, updateState, getItems, pollDataForPage Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../OpenHABCore/Util/NetworkTracker.swift | 71 ++++++++++++++----- .../OpenHABCore/Util/OpenHABItemCache.swift | 28 ++------ openHABWatch/Domain/UserData.swift | 10 +-- 3 files changed, 58 insertions(+), 51 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift index 952192f18..cb72831f5 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift @@ -50,15 +50,19 @@ enum NetworkTrackerError: Error, CustomDebugStringConvertible { } } +// Prevent race conditions. +// Ensure thread-safe dictionary access. +// Avoid memory corruption errors like unrecognized selector. actor ConnectionPool { private var services: [ConnectionConfiguration: OpenAPIService] = [:] - func getOrCreateService(for configuration: ConnectionConfiguration, url: URL) async -> OpenAPIService { + @discardableResult + func getOrCreateService(for configuration: ConnectionConfiguration) async -> OpenAPIService { if let existingService = services[configuration] { return existingService } let newService = await OpenAPIService( - baseURL: url, + baseURL: URL(string: configuration.url) ?? URL(staticString: "about:blank"), username: Preferences.username, password: Preferences.password, alwaysSendBasicAuth: Preferences.alwaysSendCreds, @@ -80,8 +84,6 @@ public final class NetworkTracker: ObservableObject { private let maxRetries = 5 private let monitor: NWPathMonitor private let monitorQueue = DispatchQueue.global(qos: .background) - public var openApiService: OpenAPIService? - private var openAPIServices: [OpenAPIService?] = [] private var connectionPool: ConnectionPool = .init() private var connectionConfigurations: [ConnectionConfiguration] = [] private var retryTask: Task? @@ -132,14 +134,9 @@ public final class NetworkTracker: ObservableObject { logger.info("Start Tracking") self.connectionConfigurations = adjustMyOpenHABHosts(in: connectionConfigurations) Task { - // TODO: Remove - openApiService = await OpenAPIService( - username: username, - password: password, - alwaysSendBasicAuth: alwaysSendBasicAuth, - ignoreSSL: ignoreSSLVerification, - configuration: .shortTerm - ) + for configuration in connectionConfigurations { + await connectionPool.getOrCreateService(for: configuration) + } await setActiveConnection(nil) await attemptConnection() } @@ -179,9 +176,9 @@ public final class NetworkTracker: ObservableObject { } do { - guard let url = URL(string: activeConnection.configuration.url) else { return } - await openApiService?.updateBaseURL(with: url) - try await openApiService?.getRoot() + try await connectionPool + .getOrCreateService(for: activeConnection.configuration) + .getRoot() logger.info("Active connection is reachable: \(activeConnection.configuration.url)") } catch { logger.error("Active connection failed: \(activeConnection.configuration.url) - \(error.localizedDescription)") @@ -225,11 +222,10 @@ public final class NetworkTracker: ObservableObject { } private func testConnection(configuration: ConnectionConfiguration) async -> ConnectionInfo? { - guard let url = URL(string: configuration.url) else { return nil } + guard URL(string: configuration.url) != nil else { return nil } do { - let service = await connectionPool.getOrCreateService(for: configuration, url: url) - let version = try await service.getRootVersion() + let version = try await connectionPool.getOrCreateService(for: configuration).getRootVersion() let connectionInfo = ConnectionInfo(configuration: configuration, version: version) logger.info("Successfully connected to \(configuration.url)") return connectionInfo @@ -269,7 +265,6 @@ public final class NetworkTracker: ObservableObject { activeConnection = connection if let connection { status = .connected - await openApiService?.updateBaseURL(with: URL(string: connection.configuration.url) ?? URL(string: "about:blank")!) } else { status = .notConnected startRetryTask(disconnectedRetryInterval) @@ -293,6 +288,44 @@ public final class NetworkTracker: ObservableObject { } } +public extension NetworkTracker { + func send(to item: OpenHABItem, command: String) async { + if let activeConnection = await NetworkTracker.shared.waitForActiveConnection() { + let configuration = activeConnection.configuration + let service = await connectionPool.getOrCreateService(for: configuration) + try? await service.sendItemCommand(itemname: item.name, command: command) + } + } + + func updateState(for item: OpenHABItem, state: String) async { + if let activeConnection = await NetworkTracker.shared.waitForActiveConnection() { + let configuration = activeConnection.configuration + let service = await connectionPool.getOrCreateService(for: configuration) + try? await service.updateItemState(itemname: item.name, with: state) + } + } + + func getItems() async throws -> [OpenHABItem] { + if let activeConnection = await NetworkTracker.shared.waitForActiveConnection() { + let configuration = activeConnection.configuration + let service = await connectionPool.getOrCreateService(for: configuration) + return try await service.getItems() + } else { + return [] + } + } + + func pollDataForPage(sitemapname: String, longPolling: Bool = false) async throws -> OpenHABPage? { + if let activeConnection = await NetworkTracker.shared.waitForActiveConnection() { + let configuration = activeConnection.configuration + let service = await connectionPool.getOrCreateService(for: configuration) + return try await service.pollDataForPage(sitemapname: sitemapname, longPolling: longPolling) + } else { + return nil + } + } +} + public extension NetworkTracker { func activeConnectionStream() -> AsyncStream { AsyncStream { continuation in diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift index a128fbf86..3e9c4adaa 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift @@ -61,33 +61,19 @@ public class OpenHABItemCache { } public func sendCommand(_ item: OpenHABItem, commandToSend command: String) async { - if let activeConnection = await NetworkTracker.shared.waitForActiveConnection(), - let url = URL(string: activeConnection.configuration.url) { - await NetworkTracker.shared.openApiService?.updateBaseURL(with: url) - try? await NetworkTracker.shared.openApiService?.sendItemCommand(itemname: item.name, command: command) - } + await NetworkTracker.shared.send(to: item, command: command) } public func sendState(_ item: OpenHABItem, stateToSend state: String) async { - if let activeConnection = await NetworkTracker.shared.waitForActiveConnection(), - let url = URL(string: activeConnection.configuration.url) { - await NetworkTracker.shared.openApiService?.updateBaseURL(with: url) - try? await NetworkTracker.shared.openApiService?.updateItemState(itemname: item.name, with: state) - } + await NetworkTracker.shared.updateState(for: item, state: state) } public func reload(searchTerm: String?, types: [OpenHABItem.ItemType]?) async -> [NSString] { - guard let activeConnection = await NetworkTracker.shared.waitForActiveConnection(), - let url = URL(string: activeConnection.configuration.url) else { - return [] - } - os_log("OpenHABItemCache Loading items ") lastLoad = Date().timeIntervalSince1970 do { - await NetworkTracker.shared.openApiService?.updateBaseURL(with: url) - items = try await NetworkTracker.shared.openApiService?.getItems() + items = try await NetworkTracker.shared.getItems() os_log("Loaded items to cache: %{PUBLIC}d", log: .default, type: .info, self.items?.count ?? 0) return getItemNames(searchTerm: searchTerm, types: types) } catch { @@ -97,14 +83,8 @@ public class OpenHABItemCache { } public func reload(name: String) async -> OpenHABItem? { - guard let activeConnection = await NetworkTracker.shared.waitForActiveConnection(), - let url = URL(string: activeConnection.configuration.url) else { - return nil - } - do { - await NetworkTracker.shared.openApiService?.updateBaseURL(with: url) - items = try await NetworkTracker.shared.openApiService?.getItems() + items = try await NetworkTracker.shared.getItems() os_log("Loaded items to cache: %{PUBLIC}d", log: .default, type: .info, self.items?.count ?? 0) return items?.first { $0.name == name } } catch { diff --git a/openHABWatch/Domain/UserData.swift b/openHABWatch/Domain/UserData.swift index 37f93bd47..6b0ebdada 100644 --- a/openHABWatch/Domain/UserData.swift +++ b/openHABWatch/Domain/UserData.swift @@ -132,8 +132,7 @@ final class UserData: ObservableObject { logger.info("Loading page \(sitemapName) longPolling \(longPolling) refresh \(refresh)") do { - guard let openAPIService = NetworkTracker.shared.openApiService else { return } - openHABSitemapPage = try await openAPIService.pollDataForPage(sitemapname: sitemapName, longPolling: longPolling) + openHABSitemapPage = try await NetworkTracker.shared.pollDataForPage(sitemapname: sitemapName, longPolling: longPolling) openHABSitemapPage?.sendCommand = { [weak self] item, command in Task { await self?.sendCommand(item, command: command) } @@ -154,12 +153,7 @@ final class UserData: ObservableObject { func sendCommand(_ item: OpenHABItem?, command: String?) async { guard let item, let command else { return } - - do { - try await NetworkTracker.shared.openApiService?.sendItemCommand(itemname: item.name, command: command) - } catch { - logger.error("Error sending command \(command) to \(item.name): \(error.localizedDescription)") - } + await NetworkTracker.shared.send(to: item, command: command) } func refreshUrl() async { From 6194bd695a1c10b2bb7c75b161305980bd4bcad7 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Sat, 8 Mar 2025 06:42:27 +0100 Subject: [PATCH 053/476] Reworked waitForConnection Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../OpenHABCore/Util/NetworkTracker.swift | 86 ++++++++++--------- 1 file changed, 46 insertions(+), 40 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift index cb72831f5..25aa9701e 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift @@ -111,27 +111,12 @@ public final class NetworkTracker: ObservableObject { monitor.start(queue: monitorQueue) } -// private func getOrCreateService(for configuration: ConnectionConfiguration) async -> OpenAPIService { -// if let cachedService = serviceCache[configuration.url] { -// return cachedService -// } -// -// let newService = await OpenAPIService( -// baseURL: URL(string: configuration.url), -// username: Preferences.username, -// password: Preferences.password -// ) -// -// serviceCache[configuration.url] = newService -// return newService -// } - public func startTracking(connectionConfigurations: [ConnectionConfiguration], username: String, password: String, alwaysSendBasicAuth: Bool, ignoreSSLVerification: Bool) { - logger.info("Start Tracking") + logger.info("Start Network Tracking") self.connectionConfigurations = adjustMyOpenHABHosts(in: connectionConfigurations) Task { for configuration in connectionConfigurations { @@ -143,38 +128,52 @@ public final class NetworkTracker: ObservableObject { } public func waitForActiveConnection(timeout: TimeInterval = 10) async -> ConnectionInfo? { - await withCheckedContinuation { continuation in - let deadline = Date().addingTimeInterval(timeout) - - func checkConnection() { - Task { @MainActor in - if let activeConnection = self.activeConnection { - continuation.resume(returning: activeConnection) - } else if Date() >= deadline { - continuation.resume(returning: nil) - } else { - DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) { - checkConnection() - } - } - } + logger.info("NetworkConnection: waitForActiveConnection") + // Utilize for await to listen for changes in $activeConnection + // $activeConnection.values is an AsyncSequence, allowing you to iterate over its values asynchronously. + // Wait until a non-nil value is received + for await connection in $activeConnection.values { + if let connection { + return connection } - - checkConnection() } + + return nil +// await withCheckedContinuation { continuation in +// let deadline = Date().addingTimeInterval(timeout) +// +// func checkConnection() { +// Task { @MainActor in +// if let activeConnection = self.activeConnection { +// continuation.resume(returning: activeConnection) +// } else if Date() >= deadline { +// continuation.resume(returning: nil) +// } else { +// DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) { +// checkConnection() +// } +// } +// } +// } +// +// checkConnection() +// } } public func restartTracking() { Task { await attemptConnection() } } + // This gets called periodically when we have an active connection to make sure it's still the best choice private func checkActiveConnection() async { guard let activeConnection else { + // No active connection, proceed with the normal connection attempt os_log("No active connection, attempting to reconnect...", log: OSLog.default, type: .info) await attemptConnection() return } + // Check if the active connection is reachable do { try await connectionPool .getOrCreateService(for: activeConnection.configuration) @@ -195,9 +194,14 @@ public final class NetworkTracker: ObservableObject { logger.info("Checking available connections...") + let bestConnection = await findBestConnection() + await setActiveConnection(bestConnection) + } + + private func findBestConnection() async -> ConnectionInfo? { let sortedConfigs = connectionConfigurations.sorted { $0.priority < $1.priority } - var bestConnection: ConnectionInfo? - var connectedCounts = 0 + var bestConnection: ConnectionInfo? = nil + // var connectedCounts = 0 await withTaskGroup(of: ConnectionInfo?.self) { group in for config in sortedConfigs { @@ -208,17 +212,19 @@ public final class NetworkTracker: ObservableObject { for await connectionInfo in group { guard let connectionInfo else { continue } + if connectionInfo.configuration.priority == 0 { - await setActiveConnection(connectionInfo) - return + bestConnection = connectionInfo + group.cancelAll() // Stop further tasks if we found the highest-priority connection + break } + if bestConnection == nil || connectionInfo.configuration.priority < bestConnection!.configuration.priority { bestConnection = connectionInfo } } } - - await setActiveConnection(bestConnection) + return bestConnection } private func testConnection(configuration: ConnectionConfiguration) async -> ConnectionInfo? { @@ -263,7 +269,7 @@ public final class NetworkTracker: ObservableObject { guard activeConnection != connection else { return } activeConnection = connection - if let connection { + if connection != nil { status = .connected } else { status = .notConnected From 2b90c6f728b0b0f6e3bc69bdd11ee4496248546b Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Sat, 8 Mar 2025 16:25:49 +0100 Subject: [PATCH 054/476] Request Image only when Avoid warning DEBUG_INFORMATION_FORMAT changed from dwarf to dwarf-with-dsym Changed logger to info for OpenHABSitemapViewController request sent Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../OpenHABCore/Util/NetworkTracker.swift | 21 ++++++++- .../Util/OpenHABSessionDelegate.swift | 1 - openHAB.xcodeproj/project.pbxproj | 2 +- openHAB/OpenHABSitemapViewController.swift | 46 ++++++++++--------- 4 files changed, 44 insertions(+), 26 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift index 25aa9701e..44236bec6 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift @@ -227,6 +227,24 @@ public final class NetworkTracker: ObservableObject { return bestConnection } + private func withTimeout(seconds: Double, operation: @escaping () async -> T?) async -> T? { + await withTaskGroup(of: T?.self) { group in + // Start the operation + group.addTask { + await operation() + } + + // Start the timeout countdown + group.addTask { + try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) + return nil + } + + // Return the first task that finishes (operation or timeout) + return await group.first { $0 != nil } ?? nil + } + } + private func testConnection(configuration: ConnectionConfiguration) async -> ConnectionInfo? { guard URL(string: configuration.url) != nil else { return nil } @@ -247,8 +265,7 @@ public final class NetworkTracker: ObservableObject { private func startRetryTask(_ retryInterval: UInt64) { retryTask?.cancel() retryTask = Task { - let retryInterval = retryInterval * 1_000_000_000 - try? await Task.sleep(nanoseconds: retryInterval) + try? await Task.sleep(nanoseconds: UInt64(retryInterval * 1_000_000_000)) await attemptConnection() } } diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OpenHABSessionDelegate.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABSessionDelegate.swift index cf7dc95d7..a9cb2da7b 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/OpenHABSessionDelegate.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABSessionDelegate.swift @@ -17,7 +17,6 @@ import Foundation //// The alternative method is explained by jshier in https://github.com/Alamofire/Alamofire/issues/2886#issuecomment-517951747 // // class OpenHABSessionDelegate: SessionDelegate { -// // swiftlint:disable:next large_tuple // typealias ChallengeEvaluation = (disposition: URLSession.AuthChallengeDisposition, credential: URLCredential?, error: AFError?) // // var eventMonitor: EventMonitor? diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index eae02018b..2a2d9cb69 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -1959,7 +1959,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 33; - DEBUG_INFORMATION_FORMAT = dwarf; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = PBAPXHRAM9; GCC_C_LANGUAGE_STANDARD = "compiler-default"; GCC_NO_COMMON_BLOCKS = YES; diff --git a/openHAB/OpenHABSitemapViewController.swift b/openHAB/OpenHABSitemapViewController.swift index 894c64456..84d8cbdc5 100644 --- a/openHAB/OpenHABSitemapViewController.swift +++ b/openHAB/OpenHABSitemapViewController.swift @@ -434,7 +434,7 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel return 0 } - os_log("OpenHABSitemapViewController request sent", log: .remoteAccess, type: .error) + logger.info("OpenHABSitemapViewController request sent") } // Select sitemap @@ -750,28 +750,30 @@ extension OpenHABSitemapViewController: UITableViewDelegate, UITableViewDataSour } // No icon is needed for image, video, frame and web widgets if !((cell is NewImageUITableViewCell) || (cell is VideoUITableViewCell) || (cell is FrameUITableViewCell) || (cell is WebUITableViewCell)) { - if let urlc = Endpoint.icon( - rootUrl: openHABRootUrl, - version: appData?.openHABVersion ?? 2, - icon: widget.icon, - state: widget.iconState(), - iconType: iconType, - iconColor: iconColor - ).url { - var imageRequest = URLRequest(url: urlc) - imageRequest.timeoutInterval = 10.0 - cell.imageView?.kf.setImage( - with: KF.ImageResource(downloadURL: urlc, cacheKey: urlc.path + (urlc.query ?? "")), - placeholder: nil, - options: [.processor(OpenHABImageProcessor())] - ) { result in - switch result { - case .success: - DispatchQueue.main.async { - cell.setNeedsLayout() + if !widget.icon.isEmpty { + if let urlc = Endpoint.icon( + rootUrl: openHABRootUrl, + version: appData?.openHABVersion ?? 2, + icon: widget.icon, + state: widget.iconState(), + iconType: iconType, + iconColor: iconColor + ).url { + var imageRequest = URLRequest(url: urlc) + imageRequest.timeoutInterval = 10.0 + cell.imageView?.kf.setImage( + with: KF.ImageResource(downloadURL: urlc, cacheKey: urlc.path + (urlc.query ?? "")), + placeholder: nil, + options: [.processor(OpenHABImageProcessor())] + ) { result in + switch result { + case .success: + DispatchQueue.main.async { + cell.setNeedsLayout() + } + case let .failure(error): + os_log("Image loading failed: %{PUBLIC}@", log: .viewCycle, type: .error, error.localizedDescription) } - case let .failure(error): - os_log("Image loading failed: %{PUBLIC}@", log: .viewCycle, type: .error, error.localizedDescription) } } } From 2d03e3235e9db31f8dc7a8518a0dc5dc1b9640db Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Sat, 8 Mar 2025 17:32:58 +0100 Subject: [PATCH 055/476] Two convenience functions more in NetworkTracker runNow and send(:String) - convenience now throwing to propagate error. Applying convenience functions in OpenHABRootViewController Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../Util/AuthorisationMiddleware.swift | 2 +- .../OpenHABCore/Util/NetworkTracker.swift | 63 ++++++++++--------- .../OpenHABCore/Util/OpenHABItemCache.swift | 14 ++++- openHAB/OpenHABRootViewController.swift | 31 +++------ openHABWatch/Domain/UserData.swift | 6 +- 5 files changed, 60 insertions(+), 56 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/AuthorisationMiddleware.swift b/OpenHABCore/Sources/OpenHABCore/Util/AuthorisationMiddleware.swift index 134ac19a5..3449bf91a 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/AuthorisationMiddleware.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/AuthorisationMiddleware.swift @@ -40,7 +40,7 @@ extension AuthorisationMiddleware: ClientMiddleware { next: @Sendable (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?)) async throws -> (HTTPResponse, HTTPBody?) { // Use a mutable copy of request var request = request - if (baseURL.host?.hasSuffix("myopenhab.org") == nil || alwaysSendBasicAuth), !username.isEmpty, !password.isEmpty { + if baseURL.host?.hasSuffix("myopenhab.org") == nil || alwaysSendBasicAuth, !username.isEmpty, !password.isEmpty { request.headerFields[.authorization] = basicAuthHeader() } let (response, body) = try await next(request, body, baseURL) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift index 44236bec6..40e076c4b 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift @@ -38,14 +38,16 @@ public struct ConnectionInfo: Equatable { public let version: Int } -enum NetworkTrackerError: Error, CustomDebugStringConvertible { +public enum NetworkTrackerError: Error, CustomDebugStringConvertible { case invalidServerVersion case failedConnection(String) + case noActiveConnection - var debugDescription: String { + public var debugDescription: String { switch self { case .invalidServerVersion: "Invalid server version" case let .failedConnection(url): "Failed to connect to \(url)" + case .noActiveConnection: "No active server found" } } } @@ -200,7 +202,7 @@ public final class NetworkTracker: ObservableObject { private func findBestConnection() async -> ConnectionInfo? { let sortedConfigs = connectionConfigurations.sorted { $0.priority < $1.priority } - var bestConnection: ConnectionInfo? = nil + var bestConnection: ConnectionInfo? // var connectedCounts = 0 await withTaskGroup(of: ConnectionInfo?.self) { group in @@ -312,40 +314,43 @@ public final class NetworkTracker: ObservableObject { } public extension NetworkTracker { - func send(to item: OpenHABItem, command: String) async { - if let activeConnection = await NetworkTracker.shared.waitForActiveConnection() { - let configuration = activeConnection.configuration - let service = await connectionPool.getOrCreateService(for: configuration) - try? await service.sendItemCommand(itemname: item.name, command: command) - } + func send(to item: OpenHABItem, command: String) async throws { + try await send(to: item.name, command: command) } - func updateState(for item: OpenHABItem, state: String) async { - if let activeConnection = await NetworkTracker.shared.waitForActiveConnection() { - let configuration = activeConnection.configuration - let service = await connectionPool.getOrCreateService(for: configuration) - try? await service.updateItemState(itemname: item.name, with: state) - } + func send(to item: String, command: String) async throws { + guard let activeConnection = await NetworkTracker.shared.waitForActiveConnection() else { return } + let configuration = activeConnection.configuration + let service = await connectionPool.getOrCreateService(for: configuration) + try await service.sendItemCommand(itemname: item, command: command) + } + + func updateState(for item: OpenHABItem, state: String) async throws { + guard let activeConnection = await NetworkTracker.shared.waitForActiveConnection() else { return } + let configuration = activeConnection.configuration + let service = await connectionPool.getOrCreateService(for: configuration) + try await service.updateItemState(itemname: item.name, with: state) } func getItems() async throws -> [OpenHABItem] { - if let activeConnection = await NetworkTracker.shared.waitForActiveConnection() { - let configuration = activeConnection.configuration - let service = await connectionPool.getOrCreateService(for: configuration) - return try await service.getItems() - } else { - return [] - } + guard let activeConnection = await NetworkTracker.shared.waitForActiveConnection() else { return [] } + let configuration = activeConnection.configuration + let service = await connectionPool.getOrCreateService(for: configuration) + return try await service.getItems() } func pollDataForPage(sitemapname: String, longPolling: Bool = false) async throws -> OpenHABPage? { - if let activeConnection = await NetworkTracker.shared.waitForActiveConnection() { - let configuration = activeConnection.configuration - let service = await connectionPool.getOrCreateService(for: configuration) - return try await service.pollDataForPage(sitemapname: sitemapname, longPolling: longPolling) - } else { - return nil - } + guard let activeConnection = await NetworkTracker.shared.waitForActiveConnection() else { return nil } + let configuration = activeConnection.configuration + let service = await connectionPool.getOrCreateService(for: configuration) + return try await service.pollDataForPage(sitemapname: sitemapname, longPolling: longPolling) + } + + func runNow(ruleUID: String, payload: [String: String]) async throws { + guard let activeConnection = await NetworkTracker.shared.waitForActiveConnection() else { throw NetworkTrackerError.noActiveConnection } + let configuration = activeConnection.configuration + let service = await connectionPool.getOrCreateService(for: configuration) + try await service.runNow(ruleUID: ruleUID, payload: payload) } } diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift index 3e9c4adaa..ed975a1f5 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift @@ -20,6 +20,8 @@ public class OpenHABItemCache { var timeout: Double = 20 var lastLoad = Date().timeIntervalSince1970 + private let logger = Logger(subsystem: "org.openhab.app.watchkitapp", category: "OpenHABItemCache") + private init() { let connection1 = ConnectionConfiguration( url: Preferences.localUrl, @@ -61,11 +63,19 @@ public class OpenHABItemCache { } public func sendCommand(_ item: OpenHABItem, commandToSend command: String) async { - await NetworkTracker.shared.send(to: item, command: command) + do { + try await NetworkTracker.shared.send(to: item, command: command) + } catch { + logger.info("Could not send command: \(error.localizedDescription)") + } } public func sendState(_ item: OpenHABItem, stateToSend state: String) async { - await NetworkTracker.shared.updateState(for: item, state: state) + do { + try await NetworkTracker.shared.updateState(for: item, state: state) + } catch { + logger.info("Could not send state: \(error.localizedDescription)") + } } public func reload(searchTerm: String?, types: [OpenHABItem.ItemType]?) async -> [NSString] { diff --git a/openHAB/OpenHABRootViewController.swift b/openHAB/OpenHABRootViewController.swift index ad71c2ad0..5cf18eec6 100644 --- a/openHAB/OpenHABRootViewController.swift +++ b/openHAB/OpenHABRootViewController.swift @@ -397,19 +397,10 @@ class OpenHABRootViewController: UIViewController { let itemCommand = String(components[1]) Task { do { - guard let activeConnection = await NetworkTracker.shared.waitForActiveConnection(), - let url = URL(string: activeConnection.configuration.url) else { - displayErrorNotification("Could not find server") - return - } - - os_log("Sending command", log: .default, type: .error) - - let openAPIService = await OpenAPIService(username: Preferences.username, password: Preferences.password) - await openAPIService.updateBaseURL(with: url) - - try await openAPIService.sendItemCommand(itemname: itemName, command: itemCommand) - + logger.info("Sending command") + try await NetworkTracker.shared.send(to: itemName, command: itemCommand) + } catch NetworkTrackerError.noActiveConnection { + displayErrorNotification("Could not find server") } catch { displayErrorNotification("Failed to establish a connection: \(error.localizedDescription)") // TODOD @@ -481,17 +472,11 @@ class OpenHABRootViewController: UIViewController { } Task { do { - guard let activeConnection = await NetworkTracker.shared.waitForActiveConnection() else { - displayErrorNotification("Could not find active server") - return - } - - os_log("Sending command", log: .default, type: .error) - - let openAPIService = await OpenAPIService(username: Preferences.username, password: Preferences.password) - try await openAPIService.runNow(ruleUID: uuid, payload: properties) + logger.error("Sending command") + try await NetworkTracker.shared.runNow(ruleUID: uuid, payload: properties) logger.info("Request succeeded") - + } catch let error as NetworkTrackerError { + displayErrorNotification("\(error.localizedDescription)") } catch { logger.error("Could not send data \(error.localizedDescription)") displayErrorNotification("Request to server failed: \(error.localizedDescription)") diff --git a/openHABWatch/Domain/UserData.swift b/openHABWatch/Domain/UserData.swift index 6b0ebdada..c1d8a6cc8 100644 --- a/openHABWatch/Domain/UserData.swift +++ b/openHABWatch/Domain/UserData.swift @@ -153,7 +153,11 @@ final class UserData: ObservableObject { func sendCommand(_ item: OpenHABItem?, command: String?) async { guard let item, let command else { return } - await NetworkTracker.shared.send(to: item, command: command) + do { + try await NetworkTracker.shared.send(to: item, command: command) + } catch { + logger.info("Could not send command \(command) to \(item.name)") + } } func refreshUrl() async { From e50908d5dfb9915456c637610abffaedf2b18d47 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Sat, 8 Mar 2025 22:41:12 +0100 Subject: [PATCH 056/476] First test for openAPI generated Client Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- OpenHABCore/Package.swift | 12 +- .../Sources/OpenHABCore/Util/HTTPClient.swift | 1 + .../Tests/OpenHABCoreTests/Assertions.swift | 140 +++++++++++++ .../Tests/OpenHABCoreTests/Common.swift | 189 ++++++++++++++++++ .../Tests/OpenHABCoreTests/TestClient.swift | 66 ++++++ .../TestClientTransport.swift | 71 +++++++ 6 files changed, 476 insertions(+), 3 deletions(-) create mode 100644 OpenHABCore/Tests/OpenHABCoreTests/Assertions.swift create mode 100644 OpenHABCore/Tests/OpenHABCoreTests/Common.swift create mode 100644 OpenHABCore/Tests/OpenHABCoreTests/TestClient.swift create mode 100644 OpenHABCore/Tests/OpenHABCoreTests/TestClientTransport.swift diff --git a/OpenHABCore/Package.swift b/OpenHABCore/Package.swift index 001755cc7..cb91a68c0 100644 --- a/OpenHABCore/Package.swift +++ b/OpenHABCore/Package.swift @@ -17,7 +17,8 @@ let package = Package( // Dependencies declare other packages that this package depends on. .package(url: "https://github.com/onevcat/Kingfisher.git", from: "7.0.0"), .package(url: "https://github.com/apple/swift-openapi-runtime", from: "1.0.0"), - .package(url: "https://github.com/apple/swift-openapi-urlsession", from: "1.0.0") + .package(url: "https://github.com/apple/swift-openapi-urlsession", from: "1.0.0"), + .package(url: "https://github.com/apple/swift-http-types.git", from: "1.0.0") ], targets: [ // Targets are the basic building blocks of a package. A target can define a module or a test suite. @@ -27,13 +28,18 @@ let package = Package( dependencies: [ .product(name: "Kingfisher", package: "Kingfisher", condition: .when(platforms: [.iOS, .watchOS])), .product(name: "OpenAPIRuntime", package: "swift-openapi-runtime"), - .product(name: "OpenAPIURLSession", package: "swift-openapi-urlsession") + .product(name: "OpenAPIURLSession", package: "swift-openapi-urlsession"), + .product(name: "HTTPTypes", package: "swift-http-types") // ✅ From `swift-http-types` ], swiftSettings: [.enableUpcomingFeature("BareSlashRegexLiterals")] ), .testTarget( name: "OpenHABCoreTests", - dependencies: ["OpenHABCore"], + dependencies: [ + "OpenHABCore", + .product(name: "OpenAPIRuntime", package: "swift-openapi-runtime"), + .product(name: "HTTPTypes", package: "swift-http-types") // ✅ From `swift-http-types` + ], resources: [ .process("Resources") ], diff --git a/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift b/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift index a80f0d87c..7645529e8 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift @@ -156,6 +156,7 @@ public class HTTPClient: NSObject { var request = URLRequest(url: url) request.httpMethod = "GET" request.timeoutInterval = timeout + if let headers { for (key, value) in headers { request.setValue(value, forHTTPHeaderField: key) diff --git a/OpenHABCore/Tests/OpenHABCoreTests/Assertions.swift b/OpenHABCore/Tests/OpenHABCoreTests/Assertions.swift new file mode 100644 index 000000000..bd10eda70 --- /dev/null +++ b/OpenHABCore/Tests/OpenHABCoreTests/Assertions.swift @@ -0,0 +1,140 @@ +// Copyright (c) 2010-2025 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 Foundation +import OpenAPIRuntime +import XCTest + +// ===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftOpenAPIGenerator open source project +// +// Copyright (c) 2023 Apple Inc. and the SwiftOpenAPIGenerator project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +// ===----------------------------------------------------------------------===// +import Foundation +import OpenAPIRuntime +import XCTest + +/// Asserts that the stringified data matches the expected string value. +public func XCTAssertEqualStringifiedData(_ expression1: @autoclosure () throws -> Data?, + _ expression2: @autoclosure () throws -> String, + _ message: @autoclosure () -> String = "", + file: StaticString = #filePath, + line: UInt = #line) { + do { + guard let value1 = try expression1() else { + XCTFail("First value is nil", file: file, line: line) + return + } + let actualString = String(decoding: value1, as: UTF8.self) + XCTAssertEqual(actualString, try expression2(), file: file, line: line) + } catch { XCTFail(error.localizedDescription, file: file, line: line) } +} + +/// Asserts that the stringified data matches the expected string value. +public func XCTAssertEqualStringifiedData(_ expression1: @autoclosure () throws -> (some Sequence)?, + _ expression2: @autoclosure () throws -> String, + _ message: @autoclosure () -> String = "", + file: StaticString = #filePath, + line: UInt = #line) { + do { + guard let value1 = try expression1() else { + XCTFail("First value is nil", file: file, line: line) + return + } + let actualString = String(decoding: Array(value1), as: UTF8.self) + XCTAssertEqual(actualString, try expression2(), file: file, line: line) + } catch { XCTFail(error.localizedDescription, file: file, line: line) } +} + +/// Asserts that the stringified data matches the expected string value. +public func XCTAssertEqualStringifiedData(_ expression1: @autoclosure () throws -> HTTPBody?, + _ expression2: @autoclosure () throws -> String, + _ message: @autoclosure () -> String = "", + file: StaticString = #filePath, + line: UInt = #line) async throws { + let data: Data = if let body = try expression1() { try await Data(collecting: body, upTo: .max) } else { .init() } + XCTAssertEqualStringifiedData(data, try expression2(), message(), file: file, line: line) +} + +private extension UInt8 { + var asHex: String { + let original = switch self { + case 0x0D: "CR" + case 0x0A: "LF" + default: "\(UnicodeScalar(self)) " + } + return String(format: "%02x \(original)", self) + } +} + +/// Asserts that the data matches the expected value. +public func XCTAssertEqualData(_ expression1: @autoclosure () throws -> (some Collection)?, + _ expression2: @autoclosure () throws -> some Collection, + _ message: @autoclosure () -> String = "Data doesn't match.", + file: StaticString = #filePath, + line: UInt = #line) { + do { + guard let actualBytes = try expression1() else { + XCTFail("First value is nil", file: file, line: line) + return + } + let expectedBytes = try expression2() + if ArraySlice(actualBytes) == ArraySlice(expectedBytes) { return } + let actualCount = actualBytes.count + let expectedCount = expectedBytes.count + let minCount = min(actualCount, expectedCount) + print("Printing both byte sequences, first is the actual value and second is the expected one.") + for (index, byte) in zip(actualBytes.prefix(minCount), expectedBytes.prefix(minCount)).enumerated() { + print("\(String(format: "%04d", index)): \(byte.0 != byte.1 ? "x" : " ") \(byte.0.asHex) | \(byte.1.asHex)") + } + let direction: String + let extraBytes: ArraySlice + if actualCount > expectedCount { + direction = "Actual bytes has extra bytes" + extraBytes = ArraySlice(actualBytes.dropFirst(minCount)) + } else if expectedCount > actualCount { + direction = "Actual bytes is missing expected bytes" + extraBytes = ArraySlice(expectedBytes.dropFirst(minCount)) + } else { + direction = "" + extraBytes = [] + } + if !extraBytes.isEmpty { + print("\(direction):") + for (index, byte) in extraBytes.enumerated() { + print("\(String(format: "%04d", minCount + index)): \(byte.asHex)") + } + } + XCTFail( + "Actual stringified data '\(String(decoding: actualBytes, as: UTF8.self))' doesn't equal to expected stringified data '\(String(decoding: expectedBytes, as: UTF8.self))'. Details: \(message())", + file: file, + line: line + ) + } catch { XCTFail(error.localizedDescription, file: file, line: line) } +} + +/// Asserts that the data matches the expected value. +public func XCTAssertEqualData(_ expression1: @autoclosure () throws -> HTTPBody?, + _ expression2: @autoclosure () throws -> some Collection, + _ message: @autoclosure () -> String = "Data doesn't match.", + file: StaticString = #filePath, + line: UInt = #line) async throws { + let data: Data = if let body = try expression1() { try await Data(collecting: body, upTo: .max) } else { .init() } + XCTAssertEqualData(data, try expression2(), message(), file: file, line: line) +} diff --git a/OpenHABCore/Tests/OpenHABCoreTests/Common.swift b/OpenHABCore/Tests/OpenHABCoreTests/Common.swift new file mode 100644 index 000000000..87e9b0466 --- /dev/null +++ b/OpenHABCore/Tests/OpenHABCoreTests/Common.swift @@ -0,0 +1,189 @@ +// Copyright (c) 2010-2025 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 Foundation +import HTTPTypes + +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftOpenAPIGenerator open source project +// +// Copyright (c) 2023 Apple Inc. and the SwiftOpenAPIGenerator project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// +import OpenAPIRuntime + +public enum TestError: Swift.Error, LocalizedError, CustomStringConvertible, Sendable { + case noHandlerFound(method: HTTPRequest.Method, path: String) + case invalidURLString(String) + case unexpectedValue(any Sendable) + case unexpectedMissingRequestBody + + /// A human-readable description of the error. + public var description: String { + switch self { + case let .noHandlerFound(method, path): "No handler found for method \(method) and path \(path)" + case let .invalidURLString(string): "Invalid URL string: \(string)" + case let .unexpectedValue(value): "Unexpected value: \(value)" + case .unexpectedMissingRequestBody: "Unexpected missing request body" + } + } + + /// A localized description of the error suitable for presenting to the user. + public var errorDescription: String? { description } +} + +public extension Date { + static var test: Date { Date(timeIntervalSince1970: 1_674_036_251) } + + static var testString: String { "2023-01-18T10:04:11Z" } +} + +public extension HTTPResponse { + func withEncodedBody(_ encodedBody: String) throws -> (HTTPResponse, HTTPBody) { (self, .init(encodedBody)) } + + static var listPetsSuccess: (HTTPResponse, HTTPBody) { + get throws { + try Self(status: .ok, headerFields: [.contentType: "application/json"]) + .withEncodedBody( + #""" + [ + { + "id": 1, + "name": "Fluffz" + } + ] + """# + ) + } + } +} + +public extension Data { + var pretty: String { String(decoding: self, as: UTF8.self) } + + static var abcdString: String { "abcd" } + + static var abcd: Data { Data(abcdString.utf8) } + + static var efghString: String { "efgh" } + + static var quotedEfghString: String { #""efgh""# } + + static var efgh: Data { Data(efghString.utf8) } + + static let crlf: ArraySlice = [0xD, 0xA] + + static var multipartBodyString: String { String(decoding: multipartBodyAsSlice, as: UTF8.self) } + + static var multipartBodyAsSlice: [UInt8] { + var bytes: [UInt8] = [] + bytes.append(contentsOf: "--__X_SWIFT_OPENAPI_GENERATOR_BOUNDARY__".utf8) + bytes.append(contentsOf: crlf) + bytes.append(contentsOf: #"content-disposition: form-data; name="efficiency""#.utf8) + bytes.append(contentsOf: crlf) + bytes.append(contentsOf: #"content-length: 3"#.utf8) + bytes.append(contentsOf: crlf) + bytes.append(contentsOf: crlf) + bytes.append(contentsOf: "4.2".utf8) + bytes.append(contentsOf: crlf) + bytes.append(contentsOf: "--__X_SWIFT_OPENAPI_GENERATOR_BOUNDARY__".utf8) + bytes.append(contentsOf: crlf) + bytes.append(contentsOf: #"content-disposition: form-data; name="name""#.utf8) + bytes.append(contentsOf: crlf) + bytes.append(contentsOf: #"content-length: 21"#.utf8) + bytes.append(contentsOf: crlf) + bytes.append(contentsOf: crlf) + bytes.append(contentsOf: "Vitamin C and friends".utf8) + bytes.append(contentsOf: crlf) + bytes.append(contentsOf: "--__X_SWIFT_OPENAPI_GENERATOR_BOUNDARY__--".utf8) + bytes.append(contentsOf: crlf) + bytes.append(contentsOf: crlf) + return bytes + } + + static var multipartBody: Data { Data(multipartBodyAsSlice) } + + static var multipartTypedBodyAsSlice: [UInt8] { + var bytes: [UInt8] = [] + bytes.append(contentsOf: "--__X_SWIFT_OPENAPI_GENERATOR_BOUNDARY__".utf8) + bytes.append(contentsOf: crlf) + bytes.append(contentsOf: #"content-disposition: form-data; filename="process.log"; name="log""#.utf8) + bytes.append(contentsOf: crlf) + bytes.append(contentsOf: #"content-length: 35"#.utf8) + bytes.append(contentsOf: crlf) + bytes.append(contentsOf: #"content-type: text/plain"#.utf8) + bytes.append(contentsOf: crlf) + bytes.append(contentsOf: #"x-log-type: unstructured"#.utf8) + bytes.append(contentsOf: crlf) + bytes.append(contentsOf: crlf) + bytes.append(contentsOf: "here be logs!\nand more lines\nwheee\n".utf8) + bytes.append(contentsOf: crlf) + bytes.append(contentsOf: "--__X_SWIFT_OPENAPI_GENERATOR_BOUNDARY__".utf8) + bytes.append(contentsOf: crlf) + bytes.append(contentsOf: #"content-disposition: form-data; filename="fun.stuff"; name="keyword""#.utf8) + bytes.append(contentsOf: crlf) + bytes.append(contentsOf: #"content-length: 3"#.utf8) + bytes.append(contentsOf: crlf) + bytes.append(contentsOf: #"content-type: text/plain"#.utf8) + bytes.append(contentsOf: crlf) + bytes.append(contentsOf: crlf) + bytes.append(contentsOf: "fun".utf8) + bytes.append(contentsOf: crlf) + bytes.append(contentsOf: "--__X_SWIFT_OPENAPI_GENERATOR_BOUNDARY__".utf8) + + bytes.append(contentsOf: crlf) + bytes.append(contentsOf: #"content-disposition: form-data; filename="barfoo.txt"; name="foobar""#.utf8) + bytes.append(contentsOf: crlf) + bytes.append(contentsOf: #"content-length: 0"#.utf8) + bytes.append(contentsOf: crlf) + bytes.append(contentsOf: crlf) + bytes.append(contentsOf: "".utf8) + bytes.append(contentsOf: crlf) + bytes.append(contentsOf: "--__X_SWIFT_OPENAPI_GENERATOR_BOUNDARY__".utf8) + bytes.append(contentsOf: crlf) + bytes.append(contentsOf: #"content-disposition: form-data; name="metadata""#.utf8) + bytes.append(contentsOf: crlf) + bytes.append(contentsOf: #"content-length: 42"#.utf8) + bytes.append(contentsOf: crlf) + bytes.append(contentsOf: #"content-type: application/json; charset=utf-8"#.utf8) + bytes.append(contentsOf: crlf) + bytes.append(contentsOf: crlf) + bytes.append(contentsOf: "{\n \"createdAt\" : \"2023-01-18T10:04:11Z\"\n}".utf8) + bytes.append(contentsOf: crlf) + bytes.append(contentsOf: "--__X_SWIFT_OPENAPI_GENERATOR_BOUNDARY__".utf8) + bytes.append(contentsOf: crlf) + bytes.append(contentsOf: #"content-disposition: form-data; name="keyword""#.utf8) + bytes.append(contentsOf: crlf) + bytes.append(contentsOf: #"content-length: 3"#.utf8) + bytes.append(contentsOf: crlf) + bytes.append(contentsOf: #"content-type: text/plain"#.utf8) + bytes.append(contentsOf: crlf) + bytes.append(contentsOf: crlf) + bytes.append(contentsOf: "joy".utf8) + bytes.append(contentsOf: crlf) + bytes.append(contentsOf: "--__X_SWIFT_OPENAPI_GENERATOR_BOUNDARY__".utf8) + bytes.append(contentsOf: "--".utf8) + bytes.append(contentsOf: crlf) + bytes.append(contentsOf: crlf) + return bytes + } +} + +public extension HTTPRequest { + func withEncodedBody(_ encodedBody: String) -> (HTTPRequest, HTTPBody) { (self, .init(encodedBody)) } +} diff --git a/OpenHABCore/Tests/OpenHABCoreTests/TestClient.swift b/OpenHABCore/Tests/OpenHABCoreTests/TestClient.swift new file mode 100644 index 000000000..6c6639421 --- /dev/null +++ b/OpenHABCore/Tests/OpenHABCoreTests/TestClient.swift @@ -0,0 +1,66 @@ +// Copyright (c) 2010-2025 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 HTTPTypes +import OpenAPIRuntime +import OpenHABCore +import XCTest + +final class TestClient: XCTestCase { + var transport: TestClientTransport! + var client: Client { + get throws { + try .init( + serverURL: URL(validatingOpenAPIServerURL: "/api"), + configuration: .init(multipartBoundaryGenerator: .constant), + transport: transport + ) + } + } + + /// Setup method called before the invocation of each test method in the class. + override func setUp() async throws { + try await super.setUp() + continueAfterFailure = false + } + + // swiftlint:disable line_length + func testgetRoot() async throws { + transport = .init { (request: HTTPRequest, body: HTTPBody?, baseURL: URL, operationID: String) in + XCTAssertEqual(operationID, "getRoot") + XCTAssertEqual( + request.path, + "//" + ) + XCTAssertEqual(baseURL.absoluteString, "/api") + XCTAssertEqual(request.method, .get) + XCTAssertNil(body) + return try HTTPResponse( + status: .ok + ) + .withEncodedBody( + #""" + {"version":"8","locale":"en_DE","measurementSystem":"SI","runtimeInfo":{"version":"4.3.2","buildString":"Release Build"},"links":[{"type":"config-descriptions","url":"http://192.168.2.10:8080/rest/config-descriptions"},{"type":"auth","url":"http://192.168.2.10:8080/rest/auth"},{"type":"habpanel","url":"http://192.168.2.10:8080/rest/habpanel"},{"type":"sitemaps","url":"http://192.168.2.10:8080/rest/sitemaps"},{"type":"persistence","url":"http://192.168.2.10:8080/rest/persistence"},{"type":"addons","url":"http://192.168.2.10:8080/rest/addons"},{"type":"things","url":"http://192.168.2.10:8080/rest/things"},{"type":"channel-types","url":"http://192.168.2.10:8080/rest/channel-types"},{"type":"profile-types","url":"http://192.168.2.10:8080/rest/profile-types"},{"type":"module-types","url":"http://192.168.2.10:8080/rest/module-types"},{"type":"links","url":"http://192.168.2.10:8080/rest/links"},{"type":"thing-types","url":"http://192.168.2.10:8080/rest/thing-types"},{"type":"tags","url":"http://192.168.2.10:8080/rest/tags"},{"type":"discovery","url":"http://192.168.2.10:8080/rest/discovery"},{"type":"events","url":"http://192.168.2.10:8080/rest/events"},{"type":"rules","url":"http://192.168.2.10:8080/rest/rules"},{"type":"services","url":"http://192.168.2.10:8080/rest/services"},{"type":"items","url":"http://192.168.2.10:8080/rest/items"},{"type":"actions","url":"http://192.168.2.10:8080/rest/actions"},{"type":"logging","url":"http://192.168.2.10:8080/rest/logging"},{"type":"audio","url":"http://192.168.2.10:8080/rest/audio"},{"type":"voice","url":"http://192.168.2.10:8080/rest/voice"},{"type":"templates","url":"http://192.168.2.10:8080/rest/templates"},{"type":"inbox","url":"http://192.168.2.10:8080/rest/inbox"},{"type":"systeminfo","url":"http://192.168.2.10:8080/rest/systeminfo"},{"type":"ui","url":"http://192.168.2.10:8080/rest/ui"},{"type":"transformations","url":"http://192.168.2.10:8080/rest/transformations"},{"type":"uuid","url":"http://192.168.2.10:8080/rest/uuid"},{"type":"spec","url":"http://192.168.2.10:8080/rest/spec"},{"type":"iconsets","url":"http://192.168.2.10:8080/rest/iconsets"}]} + """# + ) + } + let response = try await client.getRoot() + guard case let .ok(value) = response else { + XCTFail("Unexpected response: \(response)") + return + } + switch value.body { + case let .json(rootBean): XCTAssertEqual(rootBean.version, "8") + } + } +} + +// swiftlint:enable line_length diff --git a/OpenHABCore/Tests/OpenHABCoreTests/TestClientTransport.swift b/OpenHABCore/Tests/OpenHABCoreTests/TestClientTransport.swift new file mode 100644 index 000000000..14790065b --- /dev/null +++ b/OpenHABCore/Tests/OpenHABCoreTests/TestClientTransport.swift @@ -0,0 +1,71 @@ +// Copyright (c) 2010-2025 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 Foundation +import HTTPTypes +import OpenAPIRuntime + +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftOpenAPIGenerator open source project +// +// Copyright (c) 2023 Apple Inc. and the SwiftOpenAPIGenerator project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +/// A test implementation of the `ClientTransport` protocol. +/// +/// The `TestClientTransport` struct provides a way to simulate network calls by +/// utilizing a custom `CallHandler` closure. This allows testing the behavior of +/// client-side API interactions in controlled scenarios. +/// +/// Example usage: +/// ```swift +/// let testTransport = TestClientTransport { request, baseURL, operationID in +/// // Simulate response logic here +/// return Response(...) +/// } +/// +/// let client = APIClient(transport: testTransport) +/// ``` +public struct TestClientTransport: ClientTransport { + /// A typealias representing a call handler closure for processing client requests. + public typealias CallHandler = @Sendable (HTTPRequest, HTTPBody?, URL, String) async throws -> ( + HTTPResponse, HTTPBody? + ) + + /// The call handler responsible for processing client requests. + public let callHandler: CallHandler + + /// Initializes a `TestClientTransport` instance with a custom call handler. + /// + /// - Parameter callHandler: The closure responsible for processing client requests. + public init(callHandler: @escaping CallHandler) { self.callHandler = callHandler } + + /// Sends a client request using the test transport. + /// + /// - Parameters: + /// - request: The request to send. + /// - body: The optional HTTP body to include in the request. + /// - baseURL: The base URL for the request. + /// - operationID: The ID of the operation being performed. + /// - Returns: The response received from the call handler. + /// - Throws: An error if the call handler encounters an issue. + public func send(_ request: HTTPRequest, body: HTTPBody?, baseURL: URL, operationID: String) async throws -> ( + HTTPResponse, HTTPBody? + ) { try await callHandler(request, body, baseURL, operationID) } +} From 3af10b3f8347089a666e5a090acf3c88d3d61147 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Sun, 9 Mar 2025 07:28:55 +0100 Subject: [PATCH 057/476] Clean up on test Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../Tests/OpenHABCoreTests/Common.swift | 15 ++--- .../Tests/OpenHABCoreTests/TestClient.swift | 66 ------------------- .../OpenHABCoreTests/TestOpenAPIClient.swift | 56 ++++++++++++++++ 3 files changed, 62 insertions(+), 75 deletions(-) delete mode 100644 OpenHABCore/Tests/OpenHABCoreTests/TestClient.swift create mode 100644 OpenHABCore/Tests/OpenHABCoreTests/TestOpenAPIClient.swift diff --git a/OpenHABCore/Tests/OpenHABCoreTests/Common.swift b/OpenHABCore/Tests/OpenHABCoreTests/Common.swift index 87e9b0466..f817648e0 100644 --- a/OpenHABCore/Tests/OpenHABCoreTests/Common.swift +++ b/OpenHABCore/Tests/OpenHABCoreTests/Common.swift @@ -54,23 +54,20 @@ public extension Date { } public extension HTTPResponse { - func withEncodedBody(_ encodedBody: String) throws -> (HTTPResponse, HTTPBody) { (self, .init(encodedBody)) } - - static var listPetsSuccess: (HTTPResponse, HTTPBody) { + static var listRootSuccess: (HTTPResponse, HTTPBody) { get throws { + // swiftlint:disable line_length try Self(status: .ok, headerFields: [.contentType: "application/json"]) .withEncodedBody( #""" - [ - { - "id": 1, - "name": "Fluffz" - } - ] + {"version":"8","locale":"en_DE","measurementSystem":"SI","runtimeInfo":{"version":"4.3.2","buildString":"Release Build"},"links":[{"type":"config-descriptions","url":"http://192.168.2.10:8080/rest/config-descriptions"},{"type":"auth","url":"http://192.168.2.10:8080/rest/auth"},{"type":"habpanel","url":"http://192.168.2.10:8080/rest/habpanel"},{"type":"sitemaps","url":"http://192.168.2.10:8080/rest/sitemaps"},{"type":"persistence","url":"http://192.168.2.10:8080/rest/persistence"},{"type":"addons","url":"http://192.168.2.10:8080/rest/addons"},{"type":"things","url":"http://192.168.2.10:8080/rest/things"},{"type":"channel-types","url":"http://192.168.2.10:8080/rest/channel-types"},{"type":"profile-types","url":"http://192.168.2.10:8080/rest/profile-types"},{"type":"module-types","url":"http://192.168.2.10:8080/rest/module-types"},{"type":"links","url":"http://192.168.2.10:8080/rest/links"},{"type":"thing-types","url":"http://192.168.2.10:8080/rest/thing-types"},{"type":"tags","url":"http://192.168.2.10:8080/rest/tags"},{"type":"discovery","url":"http://192.168.2.10:8080/rest/discovery"},{"type":"events","url":"http://192.168.2.10:8080/rest/events"},{"type":"rules","url":"http://192.168.2.10:8080/rest/rules"},{"type":"services","url":"http://192.168.2.10:8080/rest/services"},{"type":"items","url":"http://192.168.2.10:8080/rest/items"},{"type":"actions","url":"http://192.168.2.10:8080/rest/actions"},{"type":"logging","url":"http://192.168.2.10:8080/rest/logging"},{"type":"audio","url":"http://192.168.2.10:8080/rest/audio"},{"type":"voice","url":"http://192.168.2.10:8080/rest/voice"},{"type":"templates","url":"http://192.168.2.10:8080/rest/templates"},{"type":"inbox","url":"http://192.168.2.10:8080/rest/inbox"},{"type":"systeminfo","url":"http://192.168.2.10:8080/rest/systeminfo"},{"type":"ui","url":"http://192.168.2.10:8080/rest/ui"},{"type":"transformations","url":"http://192.168.2.10:8080/rest/transformations"},{"type":"uuid","url":"http://192.168.2.10:8080/rest/uuid"},{"type":"spec","url":"http://192.168.2.10:8080/rest/spec"},{"type":"iconsets","url":"http://192.168.2.10:8080/rest/iconsets"}]} """# ) + // swiftlint:enable line_length } } + + func withEncodedBody(_ encodedBody: String) throws -> (HTTPResponse, HTTPBody) { (self, .init(encodedBody)) } } public extension Data { diff --git a/OpenHABCore/Tests/OpenHABCoreTests/TestClient.swift b/OpenHABCore/Tests/OpenHABCoreTests/TestClient.swift deleted file mode 100644 index 6c6639421..000000000 --- a/OpenHABCore/Tests/OpenHABCoreTests/TestClient.swift +++ /dev/null @@ -1,66 +0,0 @@ -// Copyright (c) 2010-2025 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 HTTPTypes -import OpenAPIRuntime -import OpenHABCore -import XCTest - -final class TestClient: XCTestCase { - var transport: TestClientTransport! - var client: Client { - get throws { - try .init( - serverURL: URL(validatingOpenAPIServerURL: "/api"), - configuration: .init(multipartBoundaryGenerator: .constant), - transport: transport - ) - } - } - - /// Setup method called before the invocation of each test method in the class. - override func setUp() async throws { - try await super.setUp() - continueAfterFailure = false - } - - // swiftlint:disable line_length - func testgetRoot() async throws { - transport = .init { (request: HTTPRequest, body: HTTPBody?, baseURL: URL, operationID: String) in - XCTAssertEqual(operationID, "getRoot") - XCTAssertEqual( - request.path, - "//" - ) - XCTAssertEqual(baseURL.absoluteString, "/api") - XCTAssertEqual(request.method, .get) - XCTAssertNil(body) - return try HTTPResponse( - status: .ok - ) - .withEncodedBody( - #""" - {"version":"8","locale":"en_DE","measurementSystem":"SI","runtimeInfo":{"version":"4.3.2","buildString":"Release Build"},"links":[{"type":"config-descriptions","url":"http://192.168.2.10:8080/rest/config-descriptions"},{"type":"auth","url":"http://192.168.2.10:8080/rest/auth"},{"type":"habpanel","url":"http://192.168.2.10:8080/rest/habpanel"},{"type":"sitemaps","url":"http://192.168.2.10:8080/rest/sitemaps"},{"type":"persistence","url":"http://192.168.2.10:8080/rest/persistence"},{"type":"addons","url":"http://192.168.2.10:8080/rest/addons"},{"type":"things","url":"http://192.168.2.10:8080/rest/things"},{"type":"channel-types","url":"http://192.168.2.10:8080/rest/channel-types"},{"type":"profile-types","url":"http://192.168.2.10:8080/rest/profile-types"},{"type":"module-types","url":"http://192.168.2.10:8080/rest/module-types"},{"type":"links","url":"http://192.168.2.10:8080/rest/links"},{"type":"thing-types","url":"http://192.168.2.10:8080/rest/thing-types"},{"type":"tags","url":"http://192.168.2.10:8080/rest/tags"},{"type":"discovery","url":"http://192.168.2.10:8080/rest/discovery"},{"type":"events","url":"http://192.168.2.10:8080/rest/events"},{"type":"rules","url":"http://192.168.2.10:8080/rest/rules"},{"type":"services","url":"http://192.168.2.10:8080/rest/services"},{"type":"items","url":"http://192.168.2.10:8080/rest/items"},{"type":"actions","url":"http://192.168.2.10:8080/rest/actions"},{"type":"logging","url":"http://192.168.2.10:8080/rest/logging"},{"type":"audio","url":"http://192.168.2.10:8080/rest/audio"},{"type":"voice","url":"http://192.168.2.10:8080/rest/voice"},{"type":"templates","url":"http://192.168.2.10:8080/rest/templates"},{"type":"inbox","url":"http://192.168.2.10:8080/rest/inbox"},{"type":"systeminfo","url":"http://192.168.2.10:8080/rest/systeminfo"},{"type":"ui","url":"http://192.168.2.10:8080/rest/ui"},{"type":"transformations","url":"http://192.168.2.10:8080/rest/transformations"},{"type":"uuid","url":"http://192.168.2.10:8080/rest/uuid"},{"type":"spec","url":"http://192.168.2.10:8080/rest/spec"},{"type":"iconsets","url":"http://192.168.2.10:8080/rest/iconsets"}]} - """# - ) - } - let response = try await client.getRoot() - guard case let .ok(value) = response else { - XCTFail("Unexpected response: \(response)") - return - } - switch value.body { - case let .json(rootBean): XCTAssertEqual(rootBean.version, "8") - } - } -} - -// swiftlint:enable line_length diff --git a/OpenHABCore/Tests/OpenHABCoreTests/TestOpenAPIClient.swift b/OpenHABCore/Tests/OpenHABCoreTests/TestOpenAPIClient.swift new file mode 100644 index 000000000..01d3a0b09 --- /dev/null +++ b/OpenHABCore/Tests/OpenHABCoreTests/TestOpenAPIClient.swift @@ -0,0 +1,56 @@ +// Copyright (c) 2010-2025 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 HTTPTypes +import OpenAPIRuntime +import OpenHABCore +import XCTest + +final class TestOpenAPIClient: XCTestCase { + var transport: TestClientTransport! + var client: Client { + get throws { + try .init( + serverURL: URL(validatingOpenAPIServerURL: "/rest"), + configuration: .init(multipartBoundaryGenerator: .constant), + transport: transport + ) + } + } + + /// Setup method called before the invocation of each test method in the class. + override func setUp() async throws { + try await super.setUp() + continueAfterFailure = false + } + + func testgetRoot() async throws { + transport = .init { (request: HTTPRequest, body: HTTPBody?, baseURL: URL, operationID: String) in + XCTAssertEqual(operationID, "getRoot") + XCTAssertEqual( + request.path, + "//" + ) + XCTAssertEqual(baseURL.absoluteString, "/rest") + XCTAssertEqual(request.method, .get) + XCTAssertNil(body) + return try HTTPResponse.listRootSuccess + } + let response = try await client.getRoot() + guard case let .ok(value) = response else { + XCTFail("Unexpected response: \(response)") + return + } + switch value.body { + case let .json(rootBean): XCTAssertEqual(rootBean.version, "8") + } + } +} From e9752d74eb27e51dab2112f76196846e18bced4a Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Sun, 9 Mar 2025 08:38:34 +0100 Subject: [PATCH 058/476] getItemByName as convenience function on NetworkTracker Addressed some swiftlint warnings Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- NotificationService/NotificationService.swift | 26 +--- .../OpenHABCore/Util/NetworkTracker.swift | 7 + .../OpenHABCore/Util/OpenHABItemCache.swift | 8 +- .../Tests/OpenHABCoreTests/Assertions.swift | 140 ------------------ ...mmon.swift => HTTPResponseExtension.swift} | 10 +- .../TestClientTransport.swift | 4 +- openHAB/OpenHABSitemapViewController.swift | 2 +- openHABWatch/Views/LogsViewer.swift | 96 ++++++------ 8 files changed, 71 insertions(+), 222 deletions(-) delete mode 100644 OpenHABCore/Tests/OpenHABCoreTests/Assertions.swift rename OpenHABCore/Tests/OpenHABCoreTests/{Common.swift => HTTPResponseExtension.swift} (98%) diff --git a/NotificationService/NotificationService.swift b/NotificationService/NotificationService.swift index f26685093..8cef5305c 100644 --- a/NotificationService/NotificationService.swift +++ b/NotificationService/NotificationService.swift @@ -205,31 +205,7 @@ class NotificationService: UNNotificationServiceExtension { let itemName = String(itemURL.absoluteString.dropFirst(scheme.count + 1)) - let connection1 = ConnectionConfiguration(url: Preferences.localUrl, priority: 0) - let connection2 = ConnectionConfiguration(url: Preferences.remoteUrl, priority: 1) - - NetworkTracker.shared.startTracking( - connectionConfigurations: [connection1, connection2], - username: Preferences.username, - password: Preferences.password, - alwaysSendBasicAuth: Preferences.alwaysSendCreds, - ignoreSSLVerification: Preferences.ignoreSSL - ) - - // Await the active connection - guard let activeConnection = await NetworkTracker.shared.waitForActiveConnection(), - let baseURL = URL(string: activeConnection.configuration.url) else { - throw NotificationServiceError.noActiveConnection - } - - let client = await OpenAPIService( - baseURL: baseURL, - username: Preferences.username, - password: Preferences.password, - alwaysSendBasicAuth: Preferences.alwaysSendCreds - ) - - let item = try await client.getItemByName(id: itemName) + let item = try await NetworkTracker.shared.getItemByName(id: itemName) guard let state = item?.state else { return nil } // Extract MIME type and base64 string diff --git a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift index 40e076c4b..ae6cf1d30 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift @@ -339,6 +339,13 @@ public extension NetworkTracker { return try await service.getItems() } + func getItemByName(id: String) async throws -> OpenHABItem? { + guard let activeConnection = await NetworkTracker.shared.waitForActiveConnection() else { return nil } + let configuration = activeConnection.configuration + let service = await connectionPool.getOrCreateService(for: configuration) + return try await service.getItemByName(id: id) + } + func pollDataForPage(sitemapname: String, longPolling: Bool = false) async throws -> OpenHABPage? { guard let activeConnection = await NetworkTracker.shared.waitForActiveConnection() else { return nil } let configuration = activeConnection.configuration diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift index ed975a1f5..451138260 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift @@ -32,7 +32,13 @@ public class OpenHABItemCache { priority: 1 ) - NetworkTracker.shared.startTracking(connectionConfigurations: [connection1, connection2], username: Preferences.username, password: Preferences.password, alwaysSendBasicAuth: Preferences.alwaysSendCreds, ignoreSSLVerification: Preferences.ignoreSSL) + NetworkTracker.shared.startTracking( + connectionConfigurations: [connection1, connection2], + username: Preferences.username, + password: Preferences.password, + alwaysSendBasicAuth: Preferences.alwaysSendCreds, + ignoreSSLVerification: Preferences.ignoreSSL + ) } public func getItemNames(searchTerm: String?, types: [OpenHABItem.ItemType]?) -> [NSString] { diff --git a/OpenHABCore/Tests/OpenHABCoreTests/Assertions.swift b/OpenHABCore/Tests/OpenHABCoreTests/Assertions.swift deleted file mode 100644 index bd10eda70..000000000 --- a/OpenHABCore/Tests/OpenHABCoreTests/Assertions.swift +++ /dev/null @@ -1,140 +0,0 @@ -// Copyright (c) 2010-2025 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 Foundation -import OpenAPIRuntime -import XCTest - -// ===----------------------------------------------------------------------===// -// -// This source file is part of the SwiftOpenAPIGenerator open source project -// -// Copyright (c) 2023 Apple Inc. and the SwiftOpenAPIGenerator project authors -// Licensed under Apache License v2.0 -// -// See LICENSE.txt for license information -// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors -// -// SPDX-License-Identifier: Apache-2.0 -// -// ===----------------------------------------------------------------------===// -import Foundation -import OpenAPIRuntime -import XCTest - -/// Asserts that the stringified data matches the expected string value. -public func XCTAssertEqualStringifiedData(_ expression1: @autoclosure () throws -> Data?, - _ expression2: @autoclosure () throws -> String, - _ message: @autoclosure () -> String = "", - file: StaticString = #filePath, - line: UInt = #line) { - do { - guard let value1 = try expression1() else { - XCTFail("First value is nil", file: file, line: line) - return - } - let actualString = String(decoding: value1, as: UTF8.self) - XCTAssertEqual(actualString, try expression2(), file: file, line: line) - } catch { XCTFail(error.localizedDescription, file: file, line: line) } -} - -/// Asserts that the stringified data matches the expected string value. -public func XCTAssertEqualStringifiedData(_ expression1: @autoclosure () throws -> (some Sequence)?, - _ expression2: @autoclosure () throws -> String, - _ message: @autoclosure () -> String = "", - file: StaticString = #filePath, - line: UInt = #line) { - do { - guard let value1 = try expression1() else { - XCTFail("First value is nil", file: file, line: line) - return - } - let actualString = String(decoding: Array(value1), as: UTF8.self) - XCTAssertEqual(actualString, try expression2(), file: file, line: line) - } catch { XCTFail(error.localizedDescription, file: file, line: line) } -} - -/// Asserts that the stringified data matches the expected string value. -public func XCTAssertEqualStringifiedData(_ expression1: @autoclosure () throws -> HTTPBody?, - _ expression2: @autoclosure () throws -> String, - _ message: @autoclosure () -> String = "", - file: StaticString = #filePath, - line: UInt = #line) async throws { - let data: Data = if let body = try expression1() { try await Data(collecting: body, upTo: .max) } else { .init() } - XCTAssertEqualStringifiedData(data, try expression2(), message(), file: file, line: line) -} - -private extension UInt8 { - var asHex: String { - let original = switch self { - case 0x0D: "CR" - case 0x0A: "LF" - default: "\(UnicodeScalar(self)) " - } - return String(format: "%02x \(original)", self) - } -} - -/// Asserts that the data matches the expected value. -public func XCTAssertEqualData(_ expression1: @autoclosure () throws -> (some Collection)?, - _ expression2: @autoclosure () throws -> some Collection, - _ message: @autoclosure () -> String = "Data doesn't match.", - file: StaticString = #filePath, - line: UInt = #line) { - do { - guard let actualBytes = try expression1() else { - XCTFail("First value is nil", file: file, line: line) - return - } - let expectedBytes = try expression2() - if ArraySlice(actualBytes) == ArraySlice(expectedBytes) { return } - let actualCount = actualBytes.count - let expectedCount = expectedBytes.count - let minCount = min(actualCount, expectedCount) - print("Printing both byte sequences, first is the actual value and second is the expected one.") - for (index, byte) in zip(actualBytes.prefix(minCount), expectedBytes.prefix(minCount)).enumerated() { - print("\(String(format: "%04d", index)): \(byte.0 != byte.1 ? "x" : " ") \(byte.0.asHex) | \(byte.1.asHex)") - } - let direction: String - let extraBytes: ArraySlice - if actualCount > expectedCount { - direction = "Actual bytes has extra bytes" - extraBytes = ArraySlice(actualBytes.dropFirst(minCount)) - } else if expectedCount > actualCount { - direction = "Actual bytes is missing expected bytes" - extraBytes = ArraySlice(expectedBytes.dropFirst(minCount)) - } else { - direction = "" - extraBytes = [] - } - if !extraBytes.isEmpty { - print("\(direction):") - for (index, byte) in extraBytes.enumerated() { - print("\(String(format: "%04d", minCount + index)): \(byte.asHex)") - } - } - XCTFail( - "Actual stringified data '\(String(decoding: actualBytes, as: UTF8.self))' doesn't equal to expected stringified data '\(String(decoding: expectedBytes, as: UTF8.self))'. Details: \(message())", - file: file, - line: line - ) - } catch { XCTFail(error.localizedDescription, file: file, line: line) } -} - -/// Asserts that the data matches the expected value. -public func XCTAssertEqualData(_ expression1: @autoclosure () throws -> HTTPBody?, - _ expression2: @autoclosure () throws -> some Collection, - _ message: @autoclosure () -> String = "Data doesn't match.", - file: StaticString = #filePath, - line: UInt = #line) async throws { - let data: Data = if let body = try expression1() { try await Data(collecting: body, upTo: .max) } else { .init() } - XCTAssertEqualData(data, try expression2(), message(), file: file, line: line) -} diff --git a/OpenHABCore/Tests/OpenHABCoreTests/Common.swift b/OpenHABCore/Tests/OpenHABCoreTests/HTTPResponseExtension.swift similarity index 98% rename from OpenHABCore/Tests/OpenHABCoreTests/Common.swift rename to OpenHABCore/Tests/OpenHABCoreTests/HTTPResponseExtension.swift index f817648e0..b559e1c45 100644 --- a/OpenHABCore/Tests/OpenHABCoreTests/Common.swift +++ b/OpenHABCore/Tests/OpenHABCoreTests/HTTPResponseExtension.swift @@ -11,8 +11,9 @@ import Foundation import HTTPTypes +import OpenAPIRuntime -//===----------------------------------------------------------------------===// +// ===----------------------------------------------------------------------===// // // This source file is part of the SwiftOpenAPIGenerator open source project // @@ -24,8 +25,7 @@ import HTTPTypes // // SPDX-License-Identifier: Apache-2.0 // -//===----------------------------------------------------------------------===// -import OpenAPIRuntime +// ===----------------------------------------------------------------------===// public enum TestError: Swift.Error, LocalizedError, CustomStringConvertible, Sendable { case noHandlerFound(method: HTTPRequest.Method, path: String) @@ -71,8 +71,6 @@ public extension HTTPResponse { } public extension Data { - var pretty: String { String(decoding: self, as: UTF8.self) } - static var abcdString: String { "abcd" } static var abcd: Data { Data(abcdString.utf8) } @@ -179,6 +177,8 @@ public extension Data { bytes.append(contentsOf: crlf) return bytes } + + var pretty: String { String(decoding: self, as: UTF8.self) } } public extension HTTPRequest { diff --git a/OpenHABCore/Tests/OpenHABCoreTests/TestClientTransport.swift b/OpenHABCore/Tests/OpenHABCoreTests/TestClientTransport.swift index 14790065b..aa4b126d6 100644 --- a/OpenHABCore/Tests/OpenHABCoreTests/TestClientTransport.swift +++ b/OpenHABCore/Tests/OpenHABCoreTests/TestClientTransport.swift @@ -13,7 +13,7 @@ import Foundation import HTTPTypes import OpenAPIRuntime -//===----------------------------------------------------------------------===// +// ===----------------------------------------------------------------------===// // // This source file is part of the SwiftOpenAPIGenerator open source project // @@ -25,7 +25,7 @@ import OpenAPIRuntime // // SPDX-License-Identifier: Apache-2.0 // -//===----------------------------------------------------------------------===// +// ===----------------------------------------------------------------------===// /// A test implementation of the `ClientTransport` protocol. /// diff --git a/openHAB/OpenHABSitemapViewController.swift b/openHAB/OpenHABSitemapViewController.swift index 84d8cbdc5..e60254eaa 100644 --- a/openHAB/OpenHABSitemapViewController.swift +++ b/openHAB/OpenHABSitemapViewController.swift @@ -772,7 +772,7 @@ extension OpenHABSitemapViewController: UITableViewDelegate, UITableViewDataSour cell.setNeedsLayout() } case let .failure(error): - os_log("Image loading failed: %{PUBLIC}@", log: .viewCycle, type: .error, error.localizedDescription) + self.logger.error("Image loading failed for widget \(widget.label) : \(error.localizedDescription)") } } } diff --git a/openHABWatch/Views/LogsViewer.swift b/openHABWatch/Views/LogsViewer.swift index 7827a686d..6c8d56176 100644 --- a/openHABWatch/Views/LogsViewer.swift +++ b/openHABWatch/Views/LogsViewer.swift @@ -15,6 +15,54 @@ import SwiftUI // Thanks to https://useyourloaf.com/blog/fetching-oslog-messages-in-swift/ +struct LogsViewer: View { + private static let template = NSPredicate(format: + "(subsystem BEGINSWITH $PREFIX)") + + @State private var text = "Loading..." + + let myFont = Font + .system(size: 10) + .monospaced() + + var body: some View { + ScrollView { + Text(text) + .font(myFont) + .padding() + } + .task { + text = await fetchLogs() + } + } + + private func fetchLogs() async -> String { + let calendar = Calendar.current + guard let dayAgo = calendar.date( + byAdding: .day, + value: -1, + to: Date.now + ) else { + return "Invalid calendar" + } + + do { + let predicate = Self.template.withSubstitutionVariables( + [ + "PREFIX": "org.openhab" + ]) + + let logs = try await Logger.fetch( + since: dayAgo, + predicateFormat: predicate.predicateFormat + ) + return logs.joined() + } catch { + return error.localizedDescription + } + } +} + private extension OSLogEntryLog.Level { var description: String { switch self { @@ -65,54 +113,6 @@ public extension Logger { } } -struct LogsViewer: View { - private static let template = NSPredicate(format: - "(subsystem BEGINSWITH $PREFIX)") - - @State private var text = "Loading..." - - let myFont = Font - .system(size: 10) - .monospaced() - - var body: some View { - ScrollView { - Text(text) - .font(myFont) - .padding() - } - .task { - text = await fetchLogs() - } - } - - private func fetchLogs() async -> String { - let calendar = Calendar.current - guard let dayAgo = calendar.date( - byAdding: .day, - value: -1, - to: Date.now - ) else { - return "Invalid calendar" - } - - do { - let predicate = Self.template.withSubstitutionVariables( - [ - "PREFIX": "org.openhab" - ]) - - let logs = try await Logger.fetch( - since: dayAgo, - predicateFormat: predicate.predicateFormat - ) - return logs.joined() - } catch { - return error.localizedDescription - } - } -} - #Preview { LogsViewer() } From 84c065ab7987a49479fc82a25adf9566d90b1ce2 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Sun, 9 Mar 2025 09:24:39 +0100 Subject: [PATCH 059/476] Get rid of double pushViewController - Merge error? Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- openHAB/OpenHABSitemapViewController.swift | 29 ---------------------- 1 file changed, 29 deletions(-) diff --git a/openHAB/OpenHABSitemapViewController.swift b/openHAB/OpenHABSitemapViewController.swift index e60254eaa..eb098fce2 100644 --- a/openHAB/OpenHABSitemapViewController.swift +++ b/openHAB/OpenHABSitemapViewController.swift @@ -815,35 +815,6 @@ extension OpenHABSitemapViewController: UITableViewDelegate, UITableViewDataSour } func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - let widget: OpenHABWidget? = relevantWidget(indexPath: indexPath) - if widget?.linkedPage != nil { - if let link = widget?.linkedPage?.link { - os_log("Selected %{PUBLIC}@", log: .viewCycle, type: .info, link) - } - - selectedWidgetRow = indexPath.row - let newViewController = (storyboard?.instantiateViewController(withIdentifier: "OpenHABPageViewController") as? OpenHABSitemapViewController)! - newViewController.title = widget?.linkedPage?.title.components(separatedBy: "[")[0] - newViewController.pageUrl = widget?.linkedPage?.link ?? "" - newViewController.openHABRootUrl = openHABRootUrl - navigationController?.pushViewController(newViewController, animated: true) - } else if widget?.type == .selection { - selectedWidgetRow = indexPath.row - let selectedWidget: OpenHABWidget? = relevantWidget(indexPath: indexPath) - let selectionItemState = selectedWidget?.item?.state - logger.info("Selected selection widget in status: \(selectionItemState ?? "unknown")") - let hostingController = UIHostingController(rootView: SelectionView( - mappings: selectedWidget?.mappingsOrItemOptions ?? [], - selectionItemState: selectionItemState, - onSelection: { selectedMappingIndex in - let selectedWidget: OpenHABWidget? = self.relevantPage?.widgets[self.selectedWidgetRow] - let selectedMapping: OpenHABWidgetMapping? = selectedWidget?.mappingsOrItemOptions[selectedMappingIndex] - self.sendCommand(selectedWidget?.item, commandToSend: selectedMapping?.command) - } - )) - hostingController.title = widget?.labelText - navigationController?.pushViewController(hostingController, animated: true) - } if let index = widgetTableView.indexPathForSelectedRow { widgetTableView.deselectRow(at: index, animated: false) } From 00f51a7049b2112df1181607fc74a0c8cee9d8de Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Sun, 9 Mar 2025 09:42:32 +0100 Subject: [PATCH 060/476] First step in adopting Swift 6 concurrency features - Switching on feature that don't require code change Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- openHAB.xcodeproj/project.pbxproj | 42 +++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index 2a2d9cb69..08f6fca5f 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -2340,6 +2340,16 @@ MARKETING_VERSION = 3.0.9; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; + SWIFT_STRICT_CONCURRENCY = minimal; + SWIFT_UPCOMING_FEATURE_CONCISE_MAGIC_FILE = YES; + SWIFT_UPCOMING_FEATURE_DEPRECATE_APPLICATION_MAIN = YES; + SWIFT_UPCOMING_FEATURE_DISABLE_OUTWARD_ACTOR_ISOLATION = YES; + SWIFT_UPCOMING_FEATURE_FORWARD_TRAILING_CLOSURES = YES; + SWIFT_UPCOMING_FEATURE_IMPLICIT_OPEN_EXISTENTIALS = YES; + SWIFT_UPCOMING_FEATURE_IMPORT_OBJC_FORWARD_DECLS = YES; + SWIFT_UPCOMING_FEATURE_INFER_SENDABLE_FROM_CAPTURES = YES; + SWIFT_UPCOMING_FEATURE_ISOLATED_DEFAULT_VALUES = YES; + SWIFT_UPCOMING_FEATURE_REGION_BASED_ISOLATION = NO; SWIFT_VERSION = ""; TARGETED_DEVICE_FAMILY = "1,2"; VERSIONING_SYSTEM = "apple-generic"; @@ -2399,6 +2409,16 @@ ONLY_ACTIVE_ARCH = NO; SDKROOT = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_STRICT_CONCURRENCY = minimal; + SWIFT_UPCOMING_FEATURE_CONCISE_MAGIC_FILE = YES; + SWIFT_UPCOMING_FEATURE_DEPRECATE_APPLICATION_MAIN = YES; + SWIFT_UPCOMING_FEATURE_DISABLE_OUTWARD_ACTOR_ISOLATION = YES; + SWIFT_UPCOMING_FEATURE_FORWARD_TRAILING_CLOSURES = YES; + SWIFT_UPCOMING_FEATURE_IMPLICIT_OPEN_EXISTENTIALS = YES; + SWIFT_UPCOMING_FEATURE_IMPORT_OBJC_FORWARD_DECLS = YES; + SWIFT_UPCOMING_FEATURE_INFER_SENDABLE_FROM_CAPTURES = YES; + SWIFT_UPCOMING_FEATURE_ISOLATED_DEFAULT_VALUES = YES; + SWIFT_UPCOMING_FEATURE_REGION_BASED_ISOLATION = NO; SWIFT_VERSION = ""; TARGETED_DEVICE_FAMILY = "1,2"; VALIDATE_PRODUCT = YES; @@ -2445,6 +2465,17 @@ SWIFT_OBJC_BRIDGING_HEADER = ""; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_PRECOMPILE_BRIDGING_HEADER = NO; + SWIFT_STRICT_CONCURRENCY = targeted; + SWIFT_UPCOMING_FEATURE_CONCISE_MAGIC_FILE = YES; + SWIFT_UPCOMING_FEATURE_DEPRECATE_APPLICATION_MAIN = YES; + SWIFT_UPCOMING_FEATURE_DISABLE_OUTWARD_ACTOR_ISOLATION = YES; + SWIFT_UPCOMING_FEATURE_FORWARD_TRAILING_CLOSURES = YES; + SWIFT_UPCOMING_FEATURE_GLOBAL_CONCURRENCY = NO; + SWIFT_UPCOMING_FEATURE_IMPLICIT_OPEN_EXISTENTIALS = YES; + SWIFT_UPCOMING_FEATURE_IMPORT_OBJC_FORWARD_DECLS = YES; + SWIFT_UPCOMING_FEATURE_INFER_SENDABLE_FROM_CAPTURES = YES; + SWIFT_UPCOMING_FEATURE_ISOLATED_DEFAULT_VALUES = YES; + SWIFT_UPCOMING_FEATURE_REGION_BASED_ISOLATION = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; VERSIONING_SYSTEM = "apple-generic"; @@ -2492,6 +2523,17 @@ SWIFT_INSTALL_OBJC_HEADER = NO; SWIFT_OBJC_BRIDGING_HEADER = ""; SWIFT_PRECOMPILE_BRIDGING_HEADER = NO; + SWIFT_STRICT_CONCURRENCY = targeted; + SWIFT_UPCOMING_FEATURE_CONCISE_MAGIC_FILE = YES; + SWIFT_UPCOMING_FEATURE_DEPRECATE_APPLICATION_MAIN = YES; + SWIFT_UPCOMING_FEATURE_DISABLE_OUTWARD_ACTOR_ISOLATION = YES; + SWIFT_UPCOMING_FEATURE_FORWARD_TRAILING_CLOSURES = YES; + SWIFT_UPCOMING_FEATURE_GLOBAL_CONCURRENCY = NO; + SWIFT_UPCOMING_FEATURE_IMPLICIT_OPEN_EXISTENTIALS = YES; + SWIFT_UPCOMING_FEATURE_IMPORT_OBJC_FORWARD_DECLS = YES; + SWIFT_UPCOMING_FEATURE_INFER_SENDABLE_FROM_CAPTURES = YES; + SWIFT_UPCOMING_FEATURE_ISOLATED_DEFAULT_VALUES = YES; + SWIFT_UPCOMING_FEATURE_REGION_BASED_ISOLATION = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; VERSIONING_SYSTEM = "apple-generic"; From 4dc43ae07570235ddd7cc1c595d23a1bfb3c9c52 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Sun, 9 Mar 2025 14:29:20 +0100 Subject: [PATCH 061/476] Cancelling asyncOperation to avoid issues when switching sitemaps Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- openHAB/OpenHABSitemapViewController.swift | 73 +++++++++++----------- openHAB/OpenHABWebViewController.swift | 2 +- 2 files changed, 38 insertions(+), 37 deletions(-) diff --git a/openHAB/OpenHABSitemapViewController.swift b/openHAB/OpenHABSitemapViewController.swift index eb098fce2..04ecaed59 100644 --- a/openHAB/OpenHABSitemapViewController.swift +++ b/openHAB/OpenHABSitemapViewController.swift @@ -12,6 +12,7 @@ import AVFoundation import AVKit import Combine +import Foundation import Kingfisher import OpenAPIRuntime import OpenAPIURLSession @@ -79,7 +80,7 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel private let search = UISearchController(searchResultsController: nil) private var isUserInteracting = false private var isWaitingToReload = false - private var asyncOperation: Task? + private var asyncOperation: Task? private let logger = Logger(subsystem: "org.openhab.app", category: "OpenHABSitemapViewController") @@ -364,6 +365,11 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel // load our page and show it into UITableView func loadPage(_ longPolling: Bool) { + if asyncOperation != nil { + asyncOperation?.cancel() + asyncOperation = nil + } + if pageUrl == "" { return } @@ -376,18 +382,18 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel } asyncOperation = Task { do { -// if let apiactor { -// await apiactor.updateBaseURL(with: URL(string: appData?.openHABRootUrl ?? "")!) -// if let subscriptionid = try await apiactor.openHABcreateSubscription() { -// logger.log("Got subscriptionid: \(subscriptionid)") -// let sitemap = try await apiactor.openHABpollSitemap(sitemapname: defaultSitemap, longPolling: longPolling, subscriptionId: subscriptionid) -// currentPage = sitemap?.page -// let events = try await apiactor.openHABSitemapWidgetEvents(subscriptionid: subscriptionid, sitemap: defaultSitemap) -// for try await event in events { -// print(event) -// } -// } -// } + // if let apiactor { + // await apiactor.updateBaseURL(with: URL(string: appData?.openHABRootUrl ?? "")!) + // if let subscriptionid = try await apiactor.openHABcreateSubscription() { + // logger.log("Got subscriptionid: \(subscriptionid)") + // let sitemap = try await apiactor.openHABpollSitemap(sitemapname: defaultSitemap, longPolling: longPolling, subscriptionId: subscriptionid) + // currentPage = sitemap?.page + // let events = try await apiactor.openHABSitemapWidgetEvents(subscriptionid: subscriptionid, sitemap: defaultSitemap) + // for try await event in events { + // print(event) + // } + // } + // } currentPage = try await openAPIService?.pollDataForPage(sitemapname: defaultSitemap, longPolling: longPolling) @@ -408,32 +414,26 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel parent?.navigationItem.title = currentPage?.title.components(separatedBy: "[")[0] loadPage(true) + } catch is CancellationError { + logger.info("Task was cancelled") } catch let error as DecodingError { os_log("DecodingError %{PUBLIC}@", log: .default, type: .error, error.localizedDescription) + } catch let error as ClientError { + self.showPopupMessage( + seconds: 5, + title: NSLocalizedString("error", comment: ""), + message: NSLocalizedString("ssl_certificate_error", comment: ""), + theme: .error) } catch { - os_log("On LoadPage \"%{PUBLIC}@\" code: %d ", log: .remoteAccess, type: .error, error.localizedDescription) - // Error - DispatchQueue.main.async { - if let urlError = error as? URLError, urlError.code == .clientCertificateRejected { - self.showPopupMessage( - seconds: 5, - title: NSLocalizedString("error", comment: ""), - message: NSLocalizedString("ssl_certificate_error", comment: ""), - theme: .error - ) - } else { - self.showPopupMessage( - seconds: 5, - title: NSLocalizedString("error", comment: ""), - message: error.localizedDescription, - theme: .error - ) - } - } + logger.error("On LoadPage \(error.localizedDescription)") + self.showPopupMessage( + seconds: 5, + title: NSLocalizedString("error", comment: ""), + message: error.localizedDescription, + theme: .error + ) } - return 0 } - logger.info("OpenHABSitemapViewController request sent") } @@ -640,7 +640,7 @@ extension OpenHABSitemapViewController: ColorPickerCellDelegate { // MARK: - UITableViewDelegate, UITableViewDataSource -extension OpenHABSitemapViewController: UITableViewDelegate, UITableViewDataSource, UITextFieldDelegate { +extension OpenHABSitemapViewController: UITableViewDelegate, UITableViewDataSource { func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { if currentPage != nil { if isFiltering { @@ -902,7 +902,6 @@ extension OpenHABSitemapViewController: UITableViewDelegate, UITableViewDataSour } } - @available(iOS 13.0, *) func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { if let cell = tableView.cellForRow(at: indexPath) as? GenericUITableViewCell, cell.widget.type == .text, let text = cell.widget?.labelValue ?? cell.widget?.labelText, !text.isEmpty { return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { _ in @@ -916,7 +915,9 @@ extension OpenHABSitemapViewController: UITableViewDelegate, UITableViewDataSour return nil } +} +extension OpenHABSitemapViewController: UITextFieldDelegate { func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { let decimalSeparator = NSLocale.current.decimalSeparator ?? "" let oldString = (textField.text ?? "") diff --git a/openHAB/OpenHABWebViewController.swift b/openHAB/OpenHABWebViewController.swift index 42af9a824..2c68ec47b 100644 --- a/openHAB/OpenHABWebViewController.swift +++ b/openHAB/OpenHABWebViewController.swift @@ -135,7 +135,7 @@ class OpenHABWebViewController: OpenHABViewController { } func modifyUrl(orig: URL?, path: String? = nil) -> URL? { - // better way to cone/copy ? + // better way to clone/copy ? guard let urlString = orig?.absoluteString, var url = URL(string: urlString) else { return orig } if url.host == "myopenhab.org" { url = URL(string: "https://home.myopenhab.org") ?? url From 8704f9f0afa34261b05c9913cac4d8cf82910559 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Sun, 9 Mar 2025 22:51:39 +0100 Subject: [PATCH 062/476] Handling of cancellation when switching sitemap Turning OpenHABLink, OpenHABUiTile, OpenHABWidgetMapping into struct / This makes them Sendable Making ConnectionConfiguration, ConnectionInfo Handling of missing sitemap.icon Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../Sources/OpenHABCore/Model/OpenHABLink.swift | 9 ++------- .../OpenHABCore/Model/OpenHABUiTile.swift | 4 ++-- .../Model/OpenHABWidgetMapping.swift | 4 ++-- .../OpenHABCore/Util/NetworkTracker.swift | 4 ++-- openHAB/DrawerView.swift | 17 ++++++++++++----- openHAB/OpenHABSitemapViewController.swift | 17 ++++++++++++----- openHABWatch/Domain/UserData.swift | 11 ++++++----- 7 files changed, 38 insertions(+), 28 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABLink.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABLink.swift index 8d4c2afb3..506e90fc5 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABLink.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABLink.swift @@ -11,18 +11,13 @@ import Foundation -class OpenHABLink: Decodable { +struct OpenHABLink: Decodable, Sendable { public var type: String? public var url: String? - - init(type: String?, url: String?) { - self.type = type - self.url = url - } } extension OpenHABLink { - convenience init?(_ links: Components.Schemas.Links?) { + init?(_ links: Components.Schemas.Links?) { if let links { self.init(type: links._type, url: links.url) } else { diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABUiTile.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABUiTile.swift index afba9ddc4..7c0560c7e 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABUiTile.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABUiTile.swift @@ -11,7 +11,7 @@ import Foundation -public class OpenHABUiTile { +public struct OpenHABUiTile: Sendable { public var name = "" public var url = "" public var imageUrl = "" @@ -24,7 +24,7 @@ public class OpenHABUiTile { } extension OpenHABUiTile { - convenience init(_ tile: Components.Schemas.TileDTO) { + init(_ tile: Components.Schemas.TileDTO) { self.init(name: tile.name.orEmpty, url: tile.url.orEmpty, imageUrl: tile.imageUrl.orEmpty) } } diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidgetMapping.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidgetMapping.swift index c08554042..c02733166 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidgetMapping.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidgetMapping.swift @@ -11,7 +11,7 @@ import Foundation -public class OpenHABWidgetMapping: NSObject, Decodable { +public struct OpenHABWidgetMapping: Decodable, Sendable { public var command = "" public var label = "" @@ -22,7 +22,7 @@ public class OpenHABWidgetMapping: NSObject, Decodable { } extension OpenHABWidgetMapping { - convenience init(_ mapping: Components.Schemas.MappingDTO) { + init(_ mapping: Components.Schemas.MappingDTO) { self.init(command: mapping.command, label: mapping.label) } } diff --git a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift index ae6cf1d30..b0b8315d0 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift @@ -23,7 +23,7 @@ public enum NetworkStatus: String { case allConnected = "All Connected" } -public struct ConnectionConfiguration: Hashable { +public struct ConnectionConfiguration: Hashable, Sendable { public let url: String public let priority: Int // Lower is higher priority, 0 is primary @@ -33,7 +33,7 @@ public struct ConnectionConfiguration: Hashable { } } -public struct ConnectionInfo: Equatable { +public struct ConnectionInfo: Equatable, Sendable { public let configuration: ConnectionConfiguration public let version: Int } diff --git a/openHAB/DrawerView.swift b/openHAB/DrawerView.swift index b266fcc83..c40684aff 100644 --- a/openHAB/DrawerView.swift +++ b/openHAB/DrawerView.swift @@ -168,11 +168,18 @@ struct DrawerView: View { var body: some View { HStack { - let url = Endpoint.iconForDrawer(rootUrl: appData?.openHABRootUrl ?? "", icon: sitemap.icon).url - KFImage(url).placeholder { Image("openHABIcon").resizable() } - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: sitemapIconwidth) + if sitemap.icon.isEmpty { + Image("openHABIcon") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: sitemapIconwidth) + } else { + let url = Endpoint.iconForDrawer(rootUrl: appData?.openHABRootUrl ?? "", icon: sitemap.icon).url + KFImage(url).placeholder { Image("openHABIcon").resizable() } + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: sitemapIconwidth) + } Text(sitemap.label) if isWatchSitemap { Spacer() diff --git a/openHAB/OpenHABSitemapViewController.swift b/openHAB/OpenHABSitemapViewController.swift index 04ecaed59..980b8ea06 100644 --- a/openHAB/OpenHABSitemapViewController.swift +++ b/openHAB/OpenHABSitemapViewController.swift @@ -419,11 +419,18 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel } catch let error as DecodingError { os_log("DecodingError %{PUBLIC}@", log: .default, type: .error, error.localizedDescription) } catch let error as ClientError { - self.showPopupMessage( - seconds: 5, - title: NSLocalizedString("error", comment: ""), - message: NSLocalizedString("ssl_certificate_error", comment: ""), - theme: .error) + if let urlError = error.underlyingError as? URLError, urlError.code == .cancelled { + logger.info("Task was cancelled - URLError code: .cancelled") + } else { + logger.error("\(error.localizedDescription)") + + self.showPopupMessage( + seconds: 5, + title: NSLocalizedString("error", comment: ""), + message: error.localizedDescription, + theme: .error + ) + } } catch { logger.error("On LoadPage \(error.localizedDescription)") self.showPopupMessage( diff --git a/openHABWatch/Domain/UserData.swift b/openHABWatch/Domain/UserData.swift index c1d8a6cc8..74446a2f8 100644 --- a/openHABWatch/Domain/UserData.swift +++ b/openHABWatch/Domain/UserData.swift @@ -41,10 +41,6 @@ final class UserData: ObservableObject { private let logger = Logger(subsystem: "org.openhab.app.watchkitapp", category: "UserData") init(sitemapName: String = "watch") { - Task { - await observeNetworkChanges() - } - NotificationCenter.default.addObserver( forName: .evaluateServerTrust, object: nil, @@ -84,6 +80,11 @@ final class UserData: ObservableObject { ) { _ in NetworkTracker.shared.restartTracking() } + + Task { + await updateNetwork() + await observeNetworkChanges() + } } /// Observes network connection changes and updates state @@ -129,7 +130,7 @@ final class UserData: ObservableObject { } func loadPage(sitemapName: String, longPolling: Bool, refresh: Bool) async { - logger.info("Loading page \(sitemapName) longPolling \(longPolling) refresh \(refresh)") + logger.info("Loading page: \(sitemapName) longPolling: \(longPolling) refresh: \(refresh)") do { openHABSitemapPage = try await NetworkTracker.shared.pollDataForPage(sitemapname: sitemapName, longPolling: longPolling) From b78cc1b675e448aa35c80fbb91a1d5febcf6e9b7 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Mon, 10 Mar 2025 10:00:18 +0100 Subject: [PATCH 063/476] Use textContentType to hint at field types for username and password / Easier data entry Turning OpenHABCommandDescription, OpenHABCommandOptions into struct / This makes them Sendable Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../Model/OpenHABCommandDescription.swift | 4 +- .../Model/OpenHABCommandOptions.swift | 4 +- .../OpenHABCore/Util/NetworkTracker.swift | 22 +-- .../HTTPResponseExtension.swift | 8 +- .../Tests/OpenHABCoreTests/JSONData.swift | 134 ++++++++++++++++++ openHAB/SettingsView.swift | 5 +- 6 files changed, 143 insertions(+), 34 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABCommandDescription.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABCommandDescription.swift index a3099901c..a0957fbd3 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABCommandDescription.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABCommandDescription.swift @@ -11,7 +11,7 @@ import Foundation -public class OpenHABCommandDescription { +public struct OpenHABCommandDescription: Sendable { public var commandOptions: [OpenHABCommandOptions] = [] public init(commandOptions: [OpenHABCommandOptions]?) { @@ -32,7 +32,7 @@ extension OpenHABCommandDescription.CodingData { } extension OpenHABCommandDescription { - convenience init?(_ commands: Components.Schemas.CommandDescription?) { + init?(_ commands: Components.Schemas.CommandDescription?) { if let commands { self.init(commandOptions: commands.commandOptions?.compactMap { OpenHABCommandOptions($0) }) } else { diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABCommandOptions.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABCommandOptions.swift index 9c6d20017..65453e464 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABCommandOptions.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABCommandOptions.swift @@ -11,7 +11,7 @@ import Foundation -public class OpenHABCommandOptions: Decodable { +public struct OpenHABCommandOptions: Decodable, Sendable { public var command = "" public var label: String? = "" @@ -22,7 +22,7 @@ public class OpenHABCommandOptions: Decodable { } extension OpenHABCommandOptions { - convenience init?(_ options: Components.Schemas.CommandOption?) { + init?(_ options: Components.Schemas.CommandOption?) { if let options { self.init(command: options.command.orEmpty, label: options.label.orEmpty) } else { diff --git a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift index b0b8315d0..5144c0f04 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift @@ -139,27 +139,7 @@ public final class NetworkTracker: ObservableObject { return connection } } - return nil -// await withCheckedContinuation { continuation in -// let deadline = Date().addingTimeInterval(timeout) -// -// func checkConnection() { -// Task { @MainActor in -// if let activeConnection = self.activeConnection { -// continuation.resume(returning: activeConnection) -// } else if Date() >= deadline { -// continuation.resume(returning: nil) -// } else { -// DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) { -// checkConnection() -// } -// } -// } -// } -// -// checkConnection() -// } } public func restartTracking() { @@ -288,7 +268,7 @@ public final class NetworkTracker: ObservableObject { guard activeConnection != connection else { return } activeConnection = connection - if connection != nil { + if activeConnection != nil { status = .connected } else { status = .notConnected diff --git a/OpenHABCore/Tests/OpenHABCoreTests/HTTPResponseExtension.swift b/OpenHABCore/Tests/OpenHABCoreTests/HTTPResponseExtension.swift index b559e1c45..037afd4ce 100644 --- a/OpenHABCore/Tests/OpenHABCoreTests/HTTPResponseExtension.swift +++ b/OpenHABCore/Tests/OpenHABCoreTests/HTTPResponseExtension.swift @@ -56,14 +56,8 @@ public extension Date { public extension HTTPResponse { static var listRootSuccess: (HTTPResponse, HTTPBody) { get throws { - // swiftlint:disable line_length try Self(status: .ok, headerFields: [.contentType: "application/json"]) - .withEncodedBody( - #""" - {"version":"8","locale":"en_DE","measurementSystem":"SI","runtimeInfo":{"version":"4.3.2","buildString":"Release Build"},"links":[{"type":"config-descriptions","url":"http://192.168.2.10:8080/rest/config-descriptions"},{"type":"auth","url":"http://192.168.2.10:8080/rest/auth"},{"type":"habpanel","url":"http://192.168.2.10:8080/rest/habpanel"},{"type":"sitemaps","url":"http://192.168.2.10:8080/rest/sitemaps"},{"type":"persistence","url":"http://192.168.2.10:8080/rest/persistence"},{"type":"addons","url":"http://192.168.2.10:8080/rest/addons"},{"type":"things","url":"http://192.168.2.10:8080/rest/things"},{"type":"channel-types","url":"http://192.168.2.10:8080/rest/channel-types"},{"type":"profile-types","url":"http://192.168.2.10:8080/rest/profile-types"},{"type":"module-types","url":"http://192.168.2.10:8080/rest/module-types"},{"type":"links","url":"http://192.168.2.10:8080/rest/links"},{"type":"thing-types","url":"http://192.168.2.10:8080/rest/thing-types"},{"type":"tags","url":"http://192.168.2.10:8080/rest/tags"},{"type":"discovery","url":"http://192.168.2.10:8080/rest/discovery"},{"type":"events","url":"http://192.168.2.10:8080/rest/events"},{"type":"rules","url":"http://192.168.2.10:8080/rest/rules"},{"type":"services","url":"http://192.168.2.10:8080/rest/services"},{"type":"items","url":"http://192.168.2.10:8080/rest/items"},{"type":"actions","url":"http://192.168.2.10:8080/rest/actions"},{"type":"logging","url":"http://192.168.2.10:8080/rest/logging"},{"type":"audio","url":"http://192.168.2.10:8080/rest/audio"},{"type":"voice","url":"http://192.168.2.10:8080/rest/voice"},{"type":"templates","url":"http://192.168.2.10:8080/rest/templates"},{"type":"inbox","url":"http://192.168.2.10:8080/rest/inbox"},{"type":"systeminfo","url":"http://192.168.2.10:8080/rest/systeminfo"},{"type":"ui","url":"http://192.168.2.10:8080/rest/ui"},{"type":"transformations","url":"http://192.168.2.10:8080/rest/transformations"},{"type":"uuid","url":"http://192.168.2.10:8080/rest/uuid"},{"type":"spec","url":"http://192.168.2.10:8080/rest/spec"},{"type":"iconsets","url":"http://192.168.2.10:8080/rest/iconsets"}]} - """# - ) - // swiftlint:enable line_length + .withEncodedBody(jsonGroup) } } diff --git a/OpenHABCore/Tests/OpenHABCoreTests/JSONData.swift b/OpenHABCore/Tests/OpenHABCoreTests/JSONData.swift index 2ebf321f6..e93301c63 100644 --- a/OpenHABCore/Tests/OpenHABCoreTests/JSONData.swift +++ b/OpenHABCore/Tests/OpenHABCoreTests/JSONData.swift @@ -487,3 +487,137 @@ let jsonSitemap3 = """ [{"name":"myHome","label":"myHome","link":"https://192.168.2.63:8444/rest/sitemaps/myHome","homepage":{"link":"https://192.168.2.63:8444/rest/sitemaps/myHome/myHome","leaf":false,"timeout":false,"widgets":[]}},{"name":"grafana","label":"grafana","link":"https://192.168.2.63:8444/rest/sitemaps/grafana","homepage":{"link":"https://192.168.2.63:8444/rest/sitemaps/grafana/grafana","leaf":false,"timeout":false,"widgets":[]}},{"name":"_default","label":"Home","link":"https://192.168.2.63:8444/rest/sitemaps/_default","homepage":{"link":"https://192.168.2.63:8444/rest/sitemaps/_default/_default","leaf":false,"timeout":false,"widgets":[]}}] """ // swiftlint:enable line_length + +let jsonGroup = """ +{ + "version": "8", + "locale": "en_DE", + "measurementSystem": "SI", + "runtimeInfo": { + "version": "4.3.2", + "buildString": "Release Build" + }, + "links": [ + { + "type": "config-descriptions", + "url": "http://192.168.2.10:8080/rest/config-descriptions" + }, + { + "type": "auth", + "url": "http://192.168.2.10:8080/rest/auth" + }, + { + "type": "habpanel", + "url": "http://192.168.2.10:8080/rest/habpanel" + }, + { + "type": "sitemaps", + "url": "http://192.168.2.10:8080/rest/sitemaps" + }, + { + "type": "persistence", + "url": "http://192.168.2.10:8080/rest/persistence" + }, + { + "type": "addons", + "url": "http://192.168.2.10:8080/rest/addons" + }, + { + "type": "things", + "url": "http://192.168.2.10:8080/rest/things" + }, + { + "type": "channel-types", + "url": "http://192.168.2.10:8080/rest/channel-types" + }, + { + "type": "profile-types", + "url": "http://192.168.2.10:8080/rest/profile-types" + }, + { + "type": "module-types", + "url": "http://192.168.2.10:8080/rest/module-types" + }, + { + "type": "links", + "url": "http://192.168.2.10:8080/rest/links" + }, + { + "type": "thing-types", + "url": "http://192.168.2.10:8080/rest/thing-types" + }, + { + "type": "tags", + "url": "http://192.168.2.10:8080/rest/tags" + }, + { + "type": "discovery", + "url": "http://192.168.2.10:8080/rest/discovery" + }, + { + "type": "events", + "url": "http://192.168.2.10:8080/rest/events" + }, + { + "type": "rules", + "url": "http://192.168.2.10:8080/rest/rules" + }, + { + "type": "services", + "url": "http://192.168.2.10:8080/rest/services" + }, + { + "type": "items", + "url": "http://192.168.2.10:8080/rest/items" + }, + { + "type": "actions", + "url": "http://192.168.2.10:8080/rest/actions" + }, + { + "type": "logging", + "url": "http://192.168.2.10:8080/rest/logging" + }, + { + "type": "audio", + "url": "http://192.168.2.10:8080/rest/audio" + }, + { + "type": "voice", + "url": "http://192.168.2.10:8080/rest/voice" + }, + { + "type": "templates", + "url": "http://192.168.2.10:8080/rest/templates" + }, + { + "type": "inbox", + "url": "http://192.168.2.10:8080/rest/inbox" + }, + { + "type": "systeminfo", + "url": "http://192.168.2.10:8080/rest/systeminfo" + }, + { + "type": "ui", + "url": "http://192.168.2.10:8080/rest/ui" + }, + { + "type": "transformations", + "url": "http://192.168.2.10:8080/rest/transformations" + }, + { + "type": "uuid", + "url": "http://192.168.2.10:8080/rest/uuid" + }, + { + "type": "spec", + "url": "http://192.168.2.10:8080/rest/spec" + }, + { + "type": "iconsets", + "url": "http://192.168.2.10:8080/rest/iconsets" + } + ] +} +""" diff --git a/openHAB/SettingsView.swift b/openHAB/SettingsView.swift index 9de29309c..05d66a003 100644 --- a/openHAB/SettingsView.swift +++ b/openHAB/SettingsView.swift @@ -106,6 +106,7 @@ struct SettingsView: View { "Foo", text: $settingsUsername ) + .textContentType(.username) // Associates with AutoFill .fixedSize() .textInputAutocapitalization(.never) .disableAutocorrection(true) @@ -124,9 +125,9 @@ struct SettingsView: View { ) .fixedSize() .textInputAutocapitalization(.never) - .disableAutocorrection(true) + .disableAutocorrection(true) // or .autocorrectionDisabled(true) ?? .font(.system(.caption)) - + .textContentType(.password) // Associates with AutoFill } label: { Text("Password") if settingsPassword.isEmpty { From 92395dbf7109c834d124ebd389cc378ec13b180b Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Mon, 10 Mar 2025 15:32:36 +0100 Subject: [PATCH 064/476] LoggerView for display of logs Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- openHAB.xcodeproj/project.pbxproj | 4 + openHAB/AppDelegate.swift | 4 +- openHAB/LoggerView.swift | 118 ++++++++++++++++++++++++++++++ openHAB/SettingsView.swift | 8 ++ 4 files changed, 133 insertions(+), 1 deletion(-) create mode 100644 openHAB/LoggerView.swift diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index 08f6fca5f..232ec2ce2 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -90,6 +90,7 @@ DA2E0B0E23DCC153009B0A99 /* MapView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2E0B0D23DCC152009B0A99 /* MapView.swift */; }; DA2E0B1023DCC439009B0A99 /* MapViewRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2E0B0F23DCC439009B0A99 /* MapViewRow.swift */; }; DA32D1B42C8C98C40018D974 /* IconWithAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA32D1B32C8C98C40018D974 /* IconWithAction.swift */; }; + DA4642322D7EE6CA006C3908 /* LoggerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4642312D7EE6CA006C3908 /* LoggerView.swift */; }; DA4D4DB5233F9ACB00B37E37 /* README.md in Resources */ = {isa = PBXBuildFile; fileRef = DA4D4DB4233F9ACB00B37E37 /* README.md */; }; DA50C7BD2B0A51BD0009F716 /* SliderWithSwitchSupportRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA50C7BC2B0A51BD0009F716 /* SliderWithSwitchSupportRow.swift */; }; DA50C7BF2B0A65300009F716 /* SliderWithSwitchSupportUITableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA50C7BE2B0A652F0009F716 /* SliderWithSwitchSupportUITableViewCell.swift */; }; @@ -392,6 +393,7 @@ DA2E0B0D23DCC152009B0A99 /* MapView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MapView.swift; sourceTree = ""; }; DA2E0B0F23DCC439009B0A99 /* MapViewRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapViewRow.swift; sourceTree = ""; }; DA32D1B32C8C98C40018D974 /* IconWithAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconWithAction.swift; sourceTree = ""; }; + DA4642312D7EE6CA006C3908 /* LoggerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoggerView.swift; sourceTree = ""; }; DA4D4DB4233F9ACB00B37E37 /* README.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; DA4D4E0E2340A00200B37E37 /* Changes.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = Changes.md; sourceTree = ""; }; DA50C7BC2B0A51BD0009F716 /* SliderWithSwitchSupportRow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SliderWithSwitchSupportRow.swift; sourceTree = ""; }; @@ -874,6 +876,7 @@ DF4B83FD18857FA100F34902 /* UI */ = { isa = PBXGroup; children = ( + DA4642312D7EE6CA006C3908 /* LoggerView.swift */, 653B54C1285E714900298ECD /* OpenHABViewController.swift */, 653B54BF285C0AC700298ECD /* OpenHABRootViewController.swift */, 65570A7C2476D16A00D524EA /* OpenHABWebViewController.swift */, @@ -1544,6 +1547,7 @@ 65570A7D2476D16A00D524EA /* OpenHABWebViewController.swift in Sources */, DAF0A28B2C56E3A300A14A6A /* RollershutterCell.swift in Sources */, DF06F1FC18FEC2020011E7B9 /* ColorPickerViewController.swift in Sources */, + DA4642322D7EE6CA006C3908 /* LoggerView.swift in Sources */, 1224F78F228A89FD00750965 /* WatchMessageService.swift in Sources */, DAA42BAC21DC984A00244B2A /* WebUITableViewCell.swift in Sources */, DF4B84131886DAC400F34902 /* FrameUITableViewCell.swift in Sources */, diff --git a/openHAB/AppDelegate.swift b/openHAB/AppDelegate.swift index 0c7adbacf..1c8bba895 100644 --- a/openHAB/AppDelegate.swift +++ b/openHAB/AppDelegate.swift @@ -25,6 +25,8 @@ var player: AVAudioPlayer? @main class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate { static var appDelegate: AppDelegate! + + private let logger = Logger(subsystem: "org.openhab", category: "AppDelegate") var window: UIWindow? var appData: OpenHABDataObject @@ -272,7 +274,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD extension AppDelegate: MessagingDelegate { func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) { - os_log("My FCM token is: %{PUBLIC}@", log: .notifications, type: .info, fcmToken ?? "") + logger.info("My FCM token is: \(fcmToken ?? "", privacy: .private)") let dataDict = [ "deviceToken": fcmToken ?? "", "deviceId": UIDevice.current.identifierForVendor?.uuidString ?? "", diff --git a/openHAB/LoggerView.swift b/openHAB/LoggerView.swift new file mode 100644 index 000000000..af9f1a1e3 --- /dev/null +++ b/openHAB/LoggerView.swift @@ -0,0 +1,118 @@ +// Copyright (c) 2010-2025 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 OSLog +import SwiftUI + +struct LogEntry: Identifiable, Hashable { + let id = UUID() + let timestamp: Date + let message: String +} + +struct LoggerView: View { + let template = NSPredicate(format: "(subsystem BEGINSWITH $PREFIX)") + @State private var logs: [LogEntry] = [] + @State private var isLoading = true + private let logger = Logger(subsystem: "org.openhab.app", category: "LogViewer") + + var body: some View { + VStack { + if isLoading { + ProgressView("Loading logs...") + .padding() + } else if logs.isEmpty { + Text("No logs found") + .foregroundColor(.gray) + .padding() + } else { + List(logs, id: \.id) { log in + VStack(alignment: .leading, spacing: 1) { + Text(formattedDate(log.timestamp)) + .font(.caption.monospacedDigit()) + .foregroundColor(.gray) + + Text(log.message) + .font(.body) + } + } + .listStyle(.plain) + } + } + .task { + if logs.isEmpty { // Prevents overwriting preview logs + await loadLogs() + } + } + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + ShareLink( + item: shareLogs(), + preview: SharePreview("Share Log Data") + ) { + Label("Share", systemImage: "square.and.arrow.up") + } + .disabled(logs.isEmpty) + } + } + } + + init(logs: [LogEntry] = []) { // Custom initializer for prei + _logs = State(initialValue: logs) + _isLoading = State(initialValue: logs.isEmpty) // Set isLoading based on logs + } + + private func loadLogs() async { + isLoading = true + defer { isLoading = false } + logs = await fetchLogs(with: template) + } + + func fetchLogs(with template: NSPredicate) async -> [LogEntry] { + let predicate = template.withSubstitutionVariables(["PREFIX": "org.openhab"]) + + do { + let store = try OSLogStore(scope: .currentProcessIdentifier) + let position = store.position(date: Date().addingTimeInterval(-300)) // Last 300 seconds + let entries = try store.getEntries(at: position, matching: predicate) + + let logs = entries.compactMap { entry -> LogEntry? in + guard let logEntry = entry as? OSLogEntryLog else { return nil } + return LogEntry(timestamp: logEntry.date, message: logEntry.composedMessage) + } + + return Array(logs.reversed()) // Ensures latest logs appear first + } catch { + logger.error("Error fetching logs: \(error.localizedDescription)") + return [] + } + } + + // Custom Date Formatting Function + private func formattedDate(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm:ss.SSS" // Hours:Minutes:Seconds.Milliseconds + return formatter.string(from: date) + } + + private func shareLogs() -> String { + logs + .map { "[\($0.timestamp)] \($0.message)" } + .joined(separator: "\n") + } +} + +#Preview { + LoggerView(logs: [ + LogEntry(timestamp: Date(), message: "This is a test log entry."), + LogEntry(timestamp: Date().addingTimeInterval(-60), message: "Another log entry for preview.") + ]) +} diff --git a/openHAB/SettingsView.swift b/openHAB/SettingsView.swift index 05d66a003..d618a33a8 100644 --- a/openHAB/SettingsView.swift +++ b/openHAB/SettingsView.swift @@ -288,6 +288,14 @@ struct SettingsView: View { } } + Section(header: Text(LocalizedStringKey("debug"))) { + NavigationLink { + LoggerView() + } label: { + Text("Logs") + } + } + Section(header: Text(LocalizedStringKey("about_settings"))) { LabeledContent("App Version", value: appVersion) From 146d34f6f2c022c93735596d6fcadf007effd196 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Mon, 10 Mar 2025 15:41:29 +0100 Subject: [PATCH 065/476] First catch of LogViewer - No showPopupMessage for timeouts Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- openHAB/AppDelegate.swift | 2 +- openHAB/OpenHABSitemapViewController.swift | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/openHAB/AppDelegate.swift b/openHAB/AppDelegate.swift index 1c8bba895..1400900b5 100644 --- a/openHAB/AppDelegate.swift +++ b/openHAB/AppDelegate.swift @@ -25,7 +25,7 @@ var player: AVAudioPlayer? @main class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate { static var appDelegate: AppDelegate! - + private let logger = Logger(subsystem: "org.openhab", category: "AppDelegate") var window: UIWindow? diff --git a/openHAB/OpenHABSitemapViewController.swift b/openHAB/OpenHABSitemapViewController.swift index 980b8ea06..7ce11e358 100644 --- a/openHAB/OpenHABSitemapViewController.swift +++ b/openHAB/OpenHABSitemapViewController.swift @@ -421,6 +421,8 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel } catch let error as ClientError { if let urlError = error.underlyingError as? URLError, urlError.code == .cancelled { logger.info("Task was cancelled - URLError code: .cancelled") + } else if let urlError = error.underlyingError as? URLError, urlError.code == .timedOut { + logger.info("Task timed out - URLError code: .timedOut") } else { logger.error("\(error.localizedDescription)") From 4c5e1fe49bb13f8c8258274a9d1746f2818babcd Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Wed, 12 Mar 2025 23:03:32 +0100 Subject: [PATCH 066/476] In pollDataForPage changed definition of headers in case NO long-polling openAPIService now a class Changed signature of pollDataForSitemap Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../OpenHABCore/Util/NetworkTracker.swift | 6 ++-- .../OpenHABCore/Util/OpenAPIService.swift | 28 +++++++++---------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift index 5144c0f04..cca6a70b0 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift @@ -63,7 +63,7 @@ actor ConnectionPool { if let existingService = services[configuration] { return existingService } - let newService = await OpenAPIService( + let newService = OpenAPIService( baseURL: URL(string: configuration.url) ?? URL(staticString: "about:blank"), username: Preferences.username, password: Preferences.password, @@ -326,11 +326,11 @@ public extension NetworkTracker { return try await service.getItemByName(id: id) } - func pollDataForPage(sitemapname: String, longPolling: Bool = false) async throws -> OpenHABPage? { + func pollDataForPage(sitemapname: String, pageId: String = "", longPolling: Bool = false) async throws -> OpenHABPage? { guard let activeConnection = await NetworkTracker.shared.waitForActiveConnection() else { return nil } let configuration = activeConnection.configuration let service = await connectionPool.getOrCreateService(for: configuration) - return try await service.pollDataForPage(sitemapname: sitemapname, longPolling: longPolling) + return try await service.pollDataForPage(sitemapname: sitemapname, pageId: pageId, longPolling: longPolling) } func runNow(ruleUID: String, payload: [String: String]) async throws { diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift index df12e93a0..2dd8462f6 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift @@ -29,7 +29,7 @@ public enum OpenAPIServiceConfiguration { // The generated OpenAPI client is wrapped by this curated API. // The library leaks the fact that it uses Swift OpenAPI Generator under the hood in 'openHABSitemapWidgetEvents'. // It will require the migration to Swift 6.1 before this can be changed. -public actor OpenAPIService { +public class OpenAPIService { private var client: any APIProtocol private var url: URL? private var longPolling = false @@ -46,14 +46,12 @@ public actor OpenAPIService { self.client = client } - public init( - baseURL url: URL = URL(staticString: "about:blank"), - username: String, - password: String, - alwaysSendBasicAuth: Bool = false, - ignoreSSL: Bool = false, - configuration: OpenAPIServiceConfiguration = .asDefault - ) async { + public init(baseURL url: URL = URL(staticString: "about:blank"), + username: String, + password: String, + alwaysSendBasicAuth: Bool = false, + ignoreSSL: Bool = false, + configuration: OpenAPIServiceConfiguration = .asDefault) { let config = URLSessionConfiguration.default switch configuration { case .asDefault: @@ -223,16 +221,18 @@ public extension OpenAPIService { /// - Parameters: /// - sitemapname: name of sitemap /// - longPolling: set to true for long-polling - func pollDataForPage(sitemapname: String, longPolling: Bool) async throws -> OpenHABPage? { + func pollDataForPage(sitemapname: String, pageId: String = "", longPolling: Bool) async throws -> OpenHABPage? { var headers = Operations.pollDataForPage.Input.Headers() if longPolling { - logger.info("Long-polling, setting X-Atmosphere-Transport") + logger.info("Setting header X-Atmosphere-Transport to long-polling") headers.X_hyphen_Atmosphere_hyphen_Transport = "long-polling" + } + let path = if pageId.isEmpty { + Operations.pollDataForPage.Input.Path(sitemapname: sitemapname, pageid: sitemapname) } else { - headers.X_hyphen_Atmosphere_hyphen_Transport = nil + Operations.pollDataForPage.Input.Path(sitemapname: sitemapname, pageid: pageId) } - let path = Operations.pollDataForPage.Input.Path(sitemapname: sitemapname, pageid: sitemapname) - await updateForLongPolling(longPolling) + logger.debug("pollDataForPage: :\(String(describing: headers)), \(String(describing: path))") return try await pollDataForPage(path: path, headers: headers) } From a32a532968fa315334b26411e318e5df2d5ab19f Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Thu, 13 Mar 2025 00:02:43 +0100 Subject: [PATCH 067/476] Loading page data into OpenHABSitemapViewController Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- openHAB/OpenHABSitemapViewController.swift | 260 ++++++++++++++------- openHAB/OpenHABWebViewController.swift | 2 +- 2 files changed, 178 insertions(+), 84 deletions(-) diff --git a/openHAB/OpenHABSitemapViewController.swift b/openHAB/OpenHABSitemapViewController.swift index 7ce11e358..69388514e 100644 --- a/openHAB/OpenHABSitemapViewController.swift +++ b/openHAB/OpenHABSitemapViewController.swift @@ -58,6 +58,47 @@ struct OpenHABImageProcessor: ImageProcessor { } } +actor PageLoader { + private var openAPIService: OpenAPIService + private var pageId: String + private var defaultSitemap: String + + private var lastFetchedPage: OpenHABPage? // Store latest page data + + private let logger = Logger(subsystem: "org.openhab.app", category: "PageLoader") + + init(service: OpenAPIService, pageId: String, defaultSitemap: String) { + openAPIService = service + self.pageId = pageId + self.defaultSitemap = defaultSitemap + } + + func updatePageConfig(newPageId: String, newSitemap: String) { + pageId = newPageId + defaultSitemap = newSitemap + // swiftformat:disable:next redundantSelf + logger.info("🔄 Updated config: pageId = \(self.pageId), defaultSitemap = \(self.defaultSitemap)") + } + + func updateAPIService(newService: OpenAPIService) { + openAPIService = newService + logger.info("🔄 Updated OpenAPIService instance") + } + + func fetchPage(longPolling: Bool) async throws -> OpenHABPage? { + logger.info("📡 Fetching page... (longPolling: \(longPolling))") + let page = try await openAPIService.pollDataForPage( + sitemapname: defaultSitemap, + pageId: pageId, + longPolling: longPolling + ) + try Task.checkCancellation() + + return page + } +} + +// swiftlint:disable type_body_length class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCellTouchEventDelegate { var pageUrl = "" private var selectedWidgetRow: Int = 0 @@ -67,6 +108,7 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel private var openHABPassword = "" private var openHABAlwaysSendCreds = false private var defaultSitemap = "" + private var pageId = "" private var idleOff = false private var sitemaps: [OpenHABSitemap] = [] private var currentPage: OpenHABPage? @@ -80,7 +122,10 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel private let search = UISearchController(searchResultsController: nil) private var isUserInteracting = false private var isWaitingToReload = false - private var asyncOperation: Task? + // Properties in your view controller: + private var initialLoadTask: Task? + private var longPollingTask: Task? + private var pageLoader: PageLoader? private let logger = Logger(subsystem: "org.openhab.app", category: "OpenHABSitemapViewController") @@ -121,14 +166,20 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel pageNetworkStatus = nil sitemaps = [] widgetTableView.tableFooterView = UIView() - Task { - await openAPIService = OpenAPIService( - baseURL: URL(string: openHABRootUrl) ?? URL(staticString: "about:blank"), - username: openHABUsername, - password: openHABPassword, - alwaysSendBasicAuth: openHABAlwaysSendCreds - ) - } + openAPIService = OpenAPIService( + baseURL: URL(string: openHABRootUrl) ?? URL(staticString: "about:blank"), + username: openHABUsername, + password: openHABPassword, + alwaysSendBasicAuth: openHABAlwaysSendCreds + ) + + // ✅ Initialize PageLoader + guard let openAPIService else { return } + pageLoader = PageLoader( + service: openAPIService, + pageId: "", + defaultSitemap: "" + ) registerTableViewCells() configureTableView() @@ -164,7 +215,7 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel } override func viewWillAppear(_ animated: Bool) { - os_log("OpenHABSitemapViewController viewWillAppear", log: .viewCycle, type: .info) + logger.info("OpenHABSitemapViewController viewWillAppear") super.viewWillAppear(animated) navigationController?.navigationBar.prefersLargeTitles = true @@ -176,24 +227,21 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel UIApplication.shared.isIdleTimerDisabled = true } - // if pageUrl == "" it means we are the first opened OpenHABSitemapViewController - if pageUrl == "" { + // if pageUrl is empty, it means we are the first opened OpenHABSitemapViewController + if pageUrl.isEmpty { appData?.sitemapViewController = self if currentPage != nil { currentPage?.widgets = [] widgetTableView.reloadData() } - os_log("OpenHABSitemapViewController pageUrl is empty, this is first launch", log: .viewCycle, type: .info) + logger.info("OpenHABSitemapViewController pageUrl is empty, this is first launch") } else { - Task { - await openAPIService?.updateBaseURL(with: URL(string: appData!.openHABRootUrl)!) - } - - if !pageNetworkStatusChanged() { - os_log("OpenHABSitemapViewController pageUrl = %{PUBLIC}@", log: .notifications, type: .info, pageUrl) - loadPage(false) + if !pageNetworkStatusChanged() || !pageId.isEmpty { + // swiftformat:disable:next redundantSelf + logger.info("OpenHABSitemapViewController pageUrl \(self.pageUrl)") + loadInitialPage() } else { - os_log("OpenHABSitemapViewController network status changed while I was not appearing", log: .viewCycle, type: .info) + logger.info("OpenHABSitemapViewController network status changed while it was not appearing") restart() } } @@ -210,6 +258,13 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel trackerCancellables.removeAll() stopAllTasks() + // Cancel long polling to avoid carrying over a pending request. + longPollingTask?.cancel() + longPollingTask = nil + // Optionally cancel the initial load task if it’s still running. + initialLoadTask?.cancel() + initialLoadTask = nil + super.viewWillDisappear(animated) if #unavailable(iOS 13.0) { @@ -243,7 +298,7 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel if isViewLoaded, view.window != nil, !pageUrl.isEmpty { if !pageNetworkStatusChanged() { os_log("OpenHABSitemapViewController isViewLoaded, restarting network activity", log: .viewCycle, type: .info) - loadPage(false) + loadInitialPage() } else { os_log("OpenHABSitemapViewController network status changed while it was inactive", log: .viewCycle, type: .info) restart() @@ -337,7 +392,7 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel @objc func handleRefresh(_ refreshControl: UIRefreshControl?) { - loadPage(false) + loadInitialPage() widgetTableView.reloadData() widgetTableView.layoutIfNeeded() } @@ -363,57 +418,87 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel } } - // load our page and show it into UITableView - func loadPage(_ longPolling: Bool) { - if asyncOperation != nil { - asyncOperation?.cancel() - asyncOperation = nil + func updateUI(with page: OpenHABPage) { + currentPage = page + + if isFiltering { + filterContentForSearchText(search.searchBar.text) } - if pageUrl == "" { + currentPage?.sendCommand = { [weak self] item, command in + self?.sendCommand(item, commandToSend: command) + } + + // isUserInteracting fixes https://github.com/openhab/openhab-ios/issues/646 where reloading while the user is interacting can have unintended consequences + if !isUserInteracting { + widgetTableView.reloadData() + refreshControl?.endRefreshing() + } else { + isWaitingToReload = true + } + // on initial load ??? refreshControl?.endRefreshing() + + widgetTableView.reloadData() + parent?.navigationItem.title = currentPage?.title.components(separatedBy: "[")[0] + } + + func loadInitialPage() { + initialLoadTask?.cancel() + + guard !pageUrl.isEmpty else { + logger.error("loadPage: Cann't run with empty pageUrl") return } - os_log("pageUrl = %{PUBLIC}@", log: OSLog.remoteAccess, type: .info, pageUrl) + + // swiftformat:disable:next redundantSelf + logger.info("loadPage for \(self.pageUrl)") // If this is the first request to the page make a bulk call to pageNetworkStatusChanged // to save current reachability status. - if !longPolling { - pageNetworkStatusChanged() + pageNetworkStatusChanged() + initialLoadTask = Task { + await loadPage(longPolling: false).value + startLongPolling() } - asyncOperation = Task { + } + + func startLongPolling() { + longPollingTask?.cancel() // ✅ Cancel previous long polling task + + guard !pageUrl.isEmpty else { + logger.error("startLongPolling: Cannot run with empty pageUrl") + return + } + + logger.info("🔄 Starting long polling...") + longPollingTask = loadPage(longPolling: true) + } + + func loadPage(longPolling: Bool) -> Task { + Task { do { - // if let apiactor { - // await apiactor.updateBaseURL(with: URL(string: appData?.openHABRootUrl ?? "")!) - // if let subscriptionid = try await apiactor.openHABcreateSubscription() { - // logger.log("Got subscriptionid: \(subscriptionid)") - // let sitemap = try await apiactor.openHABpollSitemap(sitemapname: defaultSitemap, longPolling: longPolling, subscriptionId: subscriptionid) - // currentPage = sitemap?.page - // let events = try await apiactor.openHABSitemapWidgetEvents(subscriptionid: subscriptionid, sitemap: defaultSitemap) - // for try await event in events { - // print(event) - // } - // } - // } - - currentPage = try await openAPIService?.pollDataForPage(sitemapname: defaultSitemap, longPolling: longPolling) - - if isFiltering { - filterContentForSearchText(search.searchBar.text) + // ** Alternative 1 + logger.info("Calling pollDataForPage from loadPage") + let page = try await openAPIService?.pollDataForPage(sitemapname: defaultSitemap, pageId: pageId, longPolling: longPolling) + guard let page else { + logger.info("No page found ") + return } + // ** Alternative 2 too be tested. +// await pageLoader?.updatePageConfig(newPageId: pageId, newSitemap: defaultSitemap) +// guard let page = try await pageLoader?.fetchPage(longPolling: true) else { return } + // ** - currentPage?.sendCommand = { [weak self] item, command in - self?.sendCommand(item, commandToSend: command) - } - // isUserInteracting fixes https://github.com/openhab/openhab-ios/issues/646 where reloading while the user is interacting can have unintended consequences - if !isUserInteracting { - widgetTableView.reloadData() - refreshControl?.endRefreshing() - } else { - isWaitingToReload = true + try Task.checkCancellation() // Check for cancellation before processing results + await MainActor.run { + self.updateUI(with: page) } - parent?.navigationItem.title = currentPage?.title.components(separatedBy: "[")[0] - loadPage(true) + // Only start long polling recursively if this is not the initial load. + if longPolling { + // Start long polling in the background. + startLongPolling() + } } catch is CancellationError { logger.info("Task was cancelled") } catch let error as DecodingError { @@ -425,7 +510,18 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel logger.info("Task timed out - URLError code: .timedOut") } else { logger.error("\(error.localizedDescription)") - + await MainActor.run { + self.showPopupMessage( + seconds: 5, + title: NSLocalizedString("error", comment: ""), + message: error.localizedDescription, + theme: .error + ) + } + } + } catch { + logger.error("On LoadPage \(error.localizedDescription)") + await MainActor.run { self.showPopupMessage( seconds: 5, title: NSLocalizedString("error", comment: ""), @@ -433,17 +529,8 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel theme: .error ) } - } catch { - logger.error("On LoadPage \(error.localizedDescription)") - self.showPopupMessage( - seconds: 5, - title: NSLocalizedString("error", comment: ""), - message: error.localizedDescription, - theme: .error - ) } } - logger.info("OpenHABSitemapViewController request sent") } // Select sitemap @@ -451,7 +538,7 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel Task { do { logger.debug("Running selectSitemap for URL: \(self.appData?.openHABRootUrl ?? "")") - openAPIService = await OpenAPIService( + openAPIService = OpenAPIService( baseURL: URL(string: appData!.openHABRootUrl) ?? URL(staticString: "about:blank"), username: appData!.openHABUsername, password: appData!.openHABPassword, @@ -459,6 +546,12 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel ) sitemaps = try await openAPIService?.openHABSitemaps() ?? [] + guard let openAPIService else { + logger.error("Failed to load openAPIService") + return + } + await pageLoader?.updateAPIService(newService: openAPIService) + switch sitemaps.count { case 2...: if !self.defaultSitemap.isEmpty { @@ -467,7 +560,7 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel self.currentPage?.widgets.removeAll() // NOTE: remove all widgets to ensure cells get invalidated } pageUrl = sitemapToOpen.homepageLink - loadPage(false) + loadInitialPage() } else { showSideMenu() } @@ -476,7 +569,7 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel } case 1: pageUrl = sitemaps[0].homepageLink - loadPage(false) + loadInitialPage() case ...0: showPopupMessage(seconds: 5, title: NSLocalizedString("warning", comment: ""), message: NSLocalizedString("empty_sitemap", comment: ""), theme: .warning) showSideMenu() @@ -828,17 +921,18 @@ extension OpenHABSitemapViewController: UITableViewDelegate, UITableViewDataSour widgetTableView.deselectRow(at: index, animated: false) } - guard let widget: OpenHABWidget = relevantWidget(indexPath: indexPath) else { - return - } + guard let widget: OpenHABWidget = relevantWidget(indexPath: indexPath) else { return } - if widget.linkedPage != nil { - if let link = widget.linkedPage?.link { - os_log("Selected %{PUBLIC}@", log: .viewCycle, type: .info, link) - } + if let linkedPage = widget.linkedPage { + logger.info("Selected linked page: \(linkedPage.link)") + longPollingTask?.cancel() + longPollingTask = nil + initialLoadTask?.cancel() + initialLoadTask = nil let newViewController = (storyboard?.instantiateViewController(withIdentifier: "OpenHABPageViewController") as? OpenHABSitemapViewController)! - newViewController.title = widget.linkedPage?.title.components(separatedBy: "[")[0] - newViewController.pageUrl = widget.linkedPage?.link ?? "" + newViewController.title = linkedPage.title.components(separatedBy: "[")[0] + newViewController.pageId = linkedPage.pageId + newViewController.pageUrl = linkedPage.link newViewController.openHABRootUrl = openHABRootUrl navigationController?.pushViewController(newViewController, animated: true) } else if widget.type == .selection { diff --git a/openHAB/OpenHABWebViewController.swift b/openHAB/OpenHABWebViewController.swift index 2c68ec47b..11eda66f0 100644 --- a/openHAB/OpenHABWebViewController.swift +++ b/openHAB/OpenHABWebViewController.swift @@ -108,7 +108,7 @@ class OpenHABWebViewController: OpenHABViewController { } func startTracker() { - if currentTarget == "" { + if currentTarget.isEmpty { showActivityIndicator(show: true) } } From 9f237c643c02f6487da2656551663fc8766e1501 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Thu, 13 Mar 2025 08:04:52 +0100 Subject: [PATCH 068/476] set ENABLE_DEBUG_DYLIB = NO; based on the answer here: https://stackoverflow.com/questions/79133282/empty-dsym-file-detected-dsym-was-created-with-an-executable-with-no-debug-info Privacy safe logging of Endpoint Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- OpenHABCore/Sources/OpenHABCore/Util/Endpoint.swift | 7 +++++-- openHAB.xcodeproj/project.pbxproj | 2 ++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/Endpoint.swift b/OpenHABCore/Sources/OpenHABCore/Util/Endpoint.swift index 6d5097293..985b162ef 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/Endpoint.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/Endpoint.swift @@ -48,6 +48,8 @@ public enum SortSitemapsOrder: Int, CaseIterable, CustomStringConvertible { } public struct Endpoint { + static let logger = Logger(subsystem: "org.openhab.app", category: "EndPoint") + let baseURL: String let path: String var queryItems: [URLQueryItem] @@ -58,8 +60,9 @@ public extension Endpoint { var components = URLComponents(string: baseURL) components?.path = path components?.queryItems = queryItems - os_log("URL: %{PUBLIC}@", log: OSLog.urlComposition, type: .debug, components?.url?.absoluteString ?? "") - return components?.url + let url = components?.url + Endpoint.logger.info("URL: \(url?.absoluteString ?? "", privacy: .private)") + return url } static func appleRegistration(prefsURL: String, diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index 232ec2ce2..ba521834f 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -2442,6 +2442,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 33; DEVELOPMENT_TEAM = PBAPXHRAM9; + ENABLE_DEBUG_DYLIB = NO; GCC_PRECOMPILE_PREFIX_HEADER = YES; GCC_PREFIX_HEADER = "openHAB/openHAB-Prefix.pch"; INFOPLIST_FILE = "openHAB/openHAB-Info.plist"; @@ -2500,6 +2501,7 @@ CURRENT_PROJECT_VERSION = 33; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = PBAPXHRAM9; + ENABLE_DEBUG_DYLIB = NO; GCC_PRECOMPILE_PREFIX_HEADER = YES; GCC_PREFIX_HEADER = "openHAB/openHAB-Prefix.pch"; INFOPLIST_FILE = "openHAB/openHAB-Info.plist"; From 57b7a0eca45a006ddb9519290715bddeebec5efc Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Thu, 13 Mar 2025 14:54:31 +0100 Subject: [PATCH 069/476] Migrate WKNavigationDelegate to async interface Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- openHAB/DrawerView.swift | 2 +- openHAB/OpenHABSitemapViewController.swift | 2 + openHAB/OpenHABWebViewController.swift | 67 +++++++++------------- 3 files changed, 31 insertions(+), 40 deletions(-) diff --git a/openHAB/DrawerView.swift b/openHAB/DrawerView.swift index c40684aff..7d4a9fb65 100644 --- a/openHAB/DrawerView.swift +++ b/openHAB/DrawerView.swift @@ -271,7 +271,7 @@ struct DrawerView: View { } .listStyle(.inset) .task { - let openAPIService = await OpenAPIService(username: appData?.openHABUsername ?? "", password: appData?.openHABPassword ?? "", alwaysSendBasicAuth: appData?.openHABAlwaysSendCreds ?? false, ignoreSSL: false) + let openAPIService = OpenAPIService(username: appData?.openHABUsername ?? "", password: appData?.openHABPassword ?? "", alwaysSendBasicAuth: appData?.openHABAlwaysSendCreds ?? false, ignoreSSL: false) Task { do { diff --git a/openHAB/OpenHABSitemapViewController.swift b/openHAB/OpenHABSitemapViewController.swift index 69388514e..f4420d0b5 100644 --- a/openHAB/OpenHABSitemapViewController.swift +++ b/openHAB/OpenHABSitemapViewController.swift @@ -716,6 +716,8 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel } } +// swiftlint:enable type_body_length + // MARK: - UISearchResultsUpdating extension OpenHABSitemapViewController: UISearchResultsUpdating { diff --git a/openHAB/OpenHABWebViewController.swift b/openHAB/OpenHABWebViewController.swift index 11eda66f0..38b8e775e 100644 --- a/openHAB/OpenHABWebViewController.swift +++ b/openHAB/OpenHABWebViewController.swift @@ -53,6 +53,8 @@ class OpenHABWebViewController: OpenHABViewController { private lazy var webView: WKWebView = newWebView() + private var logger = Logger(subsystem: "org.openhab", category: "OpenHABWebViewController") + override func viewDidLoad() { super.viewDidLoad() navigationController?.interactivePopGestureRecognizer?.isEnabled = true @@ -312,90 +314,77 @@ extension OpenHABWebViewController: WKScriptMessageHandler { } extension OpenHABWebViewController: WKNavigationDelegate { - func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { - var action: WKNavigationActionPolicy? - - defer { - decisionHandler(action ?? .allow) - } - - guard let url = navigationAction.request.url else { return } - os_log("decidePolicyFor - url: %{PUBLIC}@", log: .wkwebview, type: .info, url.absoluteString) + func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction) async -> WKNavigationActionPolicy { + guard let url = navigationAction.request.url else { return .allow } + logger.info("decidePolicyFor - url: \(url.absoluteString)") if navigationAction.navigationType == .linkActivated { - action = .cancel // Stop in WebView - UIApplication.shared.open(url) + await UIApplication.shared.open(url) + return .cancel // Stop in WebView } + return .allow } - func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse, - decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) { + func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse) async -> WKNavigationResponsePolicy { if let response = navigationResponse.response as? HTTPURLResponse { dump(response.allHeaderFields) - os_log("navigationResponse: %{PUBLIC}@", log: .wkwebview, type: .info, String(response.statusCode)) + logger.info("navigationResponse: \(response.statusCode)") + if response.statusCode >= 400 { pageLoadError(message: "\(response.statusCode)") - decisionHandler(.cancel) - return + return .cancel } } - decisionHandler(.allow) + return .allow } - func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { - os_log("didStartProvisionalNavigation - webView.url: %{PUBLIC}@", log: .wkwebview, type: .info, String(describing: webView.url?.description)) + func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation?) { + logger.info("didStartProvisionalNavigation - webView.url: \(String(describing: webView.url?.description))") showActivityIndicator(show: true) } - func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { - os_log("didFail - webView.url %{PUBLIC}@", log: .wkwebview, type: .info, String(describing: webView.url?.description)) + func webView(_ webView: WKWebView, didFail navigation: WKNavigation?, withError error: Error) { + logger.error("didFail - webView.url: \(String(describing: webView.url?.description))") + if let urlError = error as? URLError, urlError.code == .cancelled { return // Ignore cancelled requests } + pageLoadError(message: error.localizedDescription) } func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { - os_log("didFinish - webView.url %{PUBLIC}@", log: .wkwebview, type: .info, String(describing: webView.url?.description)) + logger.info("didFinish - webView.url: \(String(describing: webView.url?.description))") showActivityIndicator(show: false) hidePopupMessages() } - func webView(_ webView: WKWebView, didReceive challenge: URLAuthenticationChallenge, - completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { - os_log("Challenge.protectionSpace.authtenticationMethod: %{PUBLIC}@", log: .wkwebview, type: .info, String(describing: challenge.protectionSpace.authenticationMethod)) + func webView(_ webView: WKWebView, didReceive challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { + logger.info("Challenge.protectionSpace.authenticationMethod: \(String(describing: challenge.protectionSpace.authenticationMethod))") if let url = modifyUrl(orig: URL(string: openHABTrackedRootUrl)), challenge.protectionSpace.host == url.host { if challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust { guard let serverTrust = challenge.protectionSpace.serverTrust else { - completionHandler(.performDefaultHandling, nil) - return + return (.performDefaultHandling, nil) } let credential = URLCredential(trust: serverTrust) - DispatchQueue.main.async { - completionHandler(.useCredential, credential) - } + return (.useCredential, credential) } else { - var disposition: URLSession.AuthChallengeDisposition = .performDefaultHandling - var credential: URLCredential? if challenge.protectionSpace.authenticationMethod.isAny(of: NSURLAuthenticationMethodHTTPBasic, NSURLAuthenticationMethodDefault) { - (disposition, credential) = onReceiveSessionTaskChallenge(with: challenge) + return onReceiveSessionTaskChallenge(with: challenge) } else { - (disposition, credential) = onReceiveSessionChallenge(with: challenge) + return onReceiveSessionChallenge(with: challenge) } - completionHandler(disposition, credential) } - } else { - completionHandler(.performDefaultHandling, nil) } + return (.performDefaultHandling, nil) } func webViewWebContentProcessDidTerminate(_ webView: WKWebView) { - os_log("webViewWebContentProcessDidTerminate reloading view", log: .wkwebview, type: .info) + logger.warning("webViewWebContentProcessDidTerminate - reloading view") reloadView() } - @available(iOS 15, *) func webView(_ webView: WKWebView, requestMediaCapturePermissionFor origin: WKSecurityOrigin, initiatedByFrame frame: WKFrameInfo, type: WKMediaCaptureType, decisionHandler: @escaping (WKPermissionDecision) -> Void) { decisionHandler(Preferences.alwaysAllowWebRTC ? .grant : .prompt) } From 792c946388f2738ea5ac589c8bc00fee18039077 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Thu, 13 Mar 2025 16:21:40 +0100 Subject: [PATCH 070/476] Set OpenHABPage and OpenHABSitemap to @unchecked Sendable. Hopefully they are. Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- OpenHABCore/Sources/OpenHABCore/Model/OpenHABPage.swift | 2 +- OpenHABCore/Sources/OpenHABCore/Model/OpenHABSitemap.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABPage.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABPage.swift index d45e055bd..8e96dc28f 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABPage.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABPage.swift @@ -12,7 +12,7 @@ import Foundation import os.log -public class OpenHABPage: NSObject { +public class OpenHABPage: NSObject, @unchecked Sendable { public var sendCommand: ((_ item: OpenHABItem, _ command: String?) -> Void)? public var widgets: [OpenHABWidget] = [] public var pageId = "" diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABSitemap.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABSitemap.swift index 12fd94005..b614b6286 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABSitemap.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABSitemap.swift @@ -27,7 +27,7 @@ public struct ValueOrFalse: Decodable { } } -public final class OpenHABSitemap: NSObject { +public final class OpenHABSitemap: NSObject, @unchecked Sendable { public var name = "" public var icon = "" public var label = "" From 8c3495c8505b0a96ec9727a4e070fd1af34c4f5a Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Thu, 13 Mar 2025 16:23:47 +0100 Subject: [PATCH 071/476] Migrated conformance to UNUserNotificationCenterDelegate to async interfaces Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- OpenHABCore/Package.swift | 5 +- openHAB.xcodeproj/project.pbxproj | 10 ++- openHAB/AppDelegate.swift | 106 +++++++++++++++--------- openHAB/OpenHABRootViewController.swift | 23 +++++ 4 files changed, 98 insertions(+), 46 deletions(-) diff --git a/OpenHABCore/Package.swift b/OpenHABCore/Package.swift index cb91a68c0..bea4ca7f2 100644 --- a/OpenHABCore/Package.swift +++ b/OpenHABCore/Package.swift @@ -31,7 +31,10 @@ let package = Package( .product(name: "OpenAPIURLSession", package: "swift-openapi-urlsession"), .product(name: "HTTPTypes", package: "swift-http-types") // ✅ From `swift-http-types` ], - swiftSettings: [.enableUpcomingFeature("BareSlashRegexLiterals")] + swiftSettings: [ + .enableUpcomingFeature("BareSlashRegexLiterals") +// , .unsafeFlags(["-strict-concurrency=targeted"]) + ] ), .testTarget( name: "OpenHABCoreTests", diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index ba521834f..97e58acae 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -2470,15 +2470,16 @@ SWIFT_OBJC_BRIDGING_HEADER = ""; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_PRECOMPILE_BRIDGING_HEADER = NO; - SWIFT_STRICT_CONCURRENCY = targeted; + SWIFT_STRICT_CONCURRENCY = complete; SWIFT_UPCOMING_FEATURE_CONCISE_MAGIC_FILE = YES; SWIFT_UPCOMING_FEATURE_DEPRECATE_APPLICATION_MAIN = YES; SWIFT_UPCOMING_FEATURE_DISABLE_OUTWARD_ACTOR_ISOLATION = YES; SWIFT_UPCOMING_FEATURE_FORWARD_TRAILING_CLOSURES = YES; - SWIFT_UPCOMING_FEATURE_GLOBAL_CONCURRENCY = NO; + SWIFT_UPCOMING_FEATURE_GLOBAL_CONCURRENCY = YES; SWIFT_UPCOMING_FEATURE_IMPLICIT_OPEN_EXISTENTIALS = YES; SWIFT_UPCOMING_FEATURE_IMPORT_OBJC_FORWARD_DECLS = YES; SWIFT_UPCOMING_FEATURE_INFER_SENDABLE_FROM_CAPTURES = YES; + SWIFT_UPCOMING_FEATURE_INTERNAL_IMPORTS_BY_DEFAULT = NO; SWIFT_UPCOMING_FEATURE_ISOLATED_DEFAULT_VALUES = YES; SWIFT_UPCOMING_FEATURE_REGION_BASED_ISOLATION = YES; SWIFT_VERSION = 5.0; @@ -2529,15 +2530,16 @@ SWIFT_INSTALL_OBJC_HEADER = NO; SWIFT_OBJC_BRIDGING_HEADER = ""; SWIFT_PRECOMPILE_BRIDGING_HEADER = NO; - SWIFT_STRICT_CONCURRENCY = targeted; + SWIFT_STRICT_CONCURRENCY = complete; SWIFT_UPCOMING_FEATURE_CONCISE_MAGIC_FILE = YES; SWIFT_UPCOMING_FEATURE_DEPRECATE_APPLICATION_MAIN = YES; SWIFT_UPCOMING_FEATURE_DISABLE_OUTWARD_ACTOR_ISOLATION = YES; SWIFT_UPCOMING_FEATURE_FORWARD_TRAILING_CLOSURES = YES; - SWIFT_UPCOMING_FEATURE_GLOBAL_CONCURRENCY = NO; + SWIFT_UPCOMING_FEATURE_GLOBAL_CONCURRENCY = YES; SWIFT_UPCOMING_FEATURE_IMPLICIT_OPEN_EXISTENTIALS = YES; SWIFT_UPCOMING_FEATURE_IMPORT_OBJC_FORWARD_DECLS = YES; SWIFT_UPCOMING_FEATURE_INFER_SENDABLE_FROM_CAPTURES = YES; + SWIFT_UPCOMING_FEATURE_INTERNAL_IMPORTS_BY_DEFAULT = NO; SWIFT_UPCOMING_FEATURE_ISOLATED_DEFAULT_VALUES = YES; SWIFT_UPCOMING_FEATURE_REGION_BASED_ISOLATION = YES; SWIFT_VERSION = 5.0; diff --git a/openHAB/AppDelegate.swift b/openHAB/AppDelegate.swift index 1400900b5..d1addea46 100644 --- a/openHAB/AppDelegate.swift +++ b/openHAB/AppDelegate.swift @@ -20,14 +20,38 @@ import UIKit import UserNotifications import WatchConnectivity -var player: AVAudioPlayer? +actor AudioPlayerActor { + private var player: AVAudioPlayer? + + let logger = Logger(subsystem: "org.openhab", category: "AudioPlayerActor") + + func playSound() { + guard let soundPath = Bundle.main.url(forResource: "ping", withExtension: "wav") else { + return + } + + do { + let newPlayer = try AVAudioPlayer(contentsOf: soundPath) + newPlayer.numberOfLoops = 0 + newPlayer.play() + player = newPlayer + } catch { + logger.info("Failed to play sound \(error.localizedDescription)") + } + } + + func stopSound() { + player?.stop() + } +} @main -class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate { +class AppDelegate: UIResponder, UIApplicationDelegate { static var appDelegate: AppDelegate! private let logger = Logger(subsystem: "org.openhab", category: "AppDelegate") + let audioPlayer = AudioPlayerActor() var window: UIWindow? var appData: OpenHABDataObject @@ -131,7 +155,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD // remove the 'openhab' from the url let action = url.absoluteString.split(separator: ":").dropFirst().joined(separator: ":") - notifyNotificationListeners(["actionIdentifier": action]) + Task { + await notifyNotificationListeners(["actionIdentifier": action]) + } return true } @@ -172,46 +198,40 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD } completionHandler(.newData) } +} +extension AppDelegate: UNUserNotificationCenterDelegate { // this is called when a notification comes in while in the foreground - func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { + func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification) async -> UNNotificationPresentationOptions { let userInfo = notification.request.content.userInfo - os_log("Notification received while app is in foreground: %{PUBLIC}@", log: .notifications, type: .info, userInfo) + logger.info("Notification received while app is in foreground: \(userInfo)") + appData.lastNotificationInfo = userInfo - displayNotification(userInfo: userInfo) - completionHandler([]) + await displayNotification(userInfo: userInfo) + + return [] // Modify this if you want to show banners, alerts, etc. } // this is called when clicking a notification while in the background - func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { + func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async { var userInfo = response.notification.request.content.userInfo let actionIdentifier = response.actionIdentifier - os_log("Notification clicked: action %{public}@ userInfo %{public}@", log: .notifications, type: .info, actionIdentifier, userInfo) + logger.info("Notification clicked: action \(actionIdentifier) userInfo \(userInfo)") + if actionIdentifier != UNNotificationDismissActionIdentifier { if actionIdentifier != UNNotificationDefaultActionIdentifier { userInfo["actionIdentifier"] = actionIdentifier } - notifyNotificationListeners(userInfo, withCompletionHandler: completionHandler) + await notifyNotificationListeners(userInfo) appData.lastNotificationInfo = userInfo - } else { - completionHandler() } } - private func displayNotification(userInfo: [AnyHashable: Any]) { + private func displayNotification(userInfo: [AnyHashable: Any]) async { os_log("displayNotification %{PUBLIC}@", log: .notifications, type: .info, userInfo["message"] as? String ?? "no message") - let soundPath: URL? = Bundle.main.url(forResource: "ping", withExtension: "wav") - if let soundPath { - do { - os_log("Sound path %{PUBLIC}@", log: .notifications, type: .info, soundPath.debugDescription) - player = try AVAudioPlayer(contentsOf: soundPath) - player?.numberOfLoops = 0 - player?.play() - } catch { - os_log("%{PUBLIC}@", log: .notifications, type: .error, error.localizedDescription) - } - player = try? AVAudioPlayer(contentsOf: soundPath) + Task { + await audioPlayer.playSound() } let message = userInfo["message"] as? String ?? NSLocalizedString("message_not_decoded", comment: "") @@ -220,35 +240,39 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD config.duration = .seconds(seconds: 5) config.presentationStyle = .bottom - SwiftMessages.show(config: config) { - let view = MessageView.viewFromNib(layout: .cardView) - view.configureTheme(.info) - view.configureContent(title: NSLocalizedString("notification", comment: ""), body: message) - view.button?.setTitle(NSLocalizedString("dismiss", comment: ""), for: .normal) - view.buttonTapHandler = { _ in SwiftMessages.hide() } - // Add tap gesture recognizer to the view for actions - let tapGesture = UITapGestureRecognizer(target: self, action: #selector(self.messageViewTapped)) - view.addGestureRecognizer(tapGesture) - return view + await MainActor.run { + SwiftMessages.show(config: config) { + let view = MessageView.viewFromNib(layout: .cardView) + view.configureTheme(.info) + view.configureContent(title: NSLocalizedString("notification", comment: ""), body: message) + view.button?.setTitle(NSLocalizedString("dismiss", comment: ""), for: .normal) + view.buttonTapHandler = { _ in SwiftMessages.hide() } + // Add tap gesture recognizer to the view for actions + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(self.messageViewTapped)) + view.addGestureRecognizer(tapGesture) + return view + } } } // Action to be performed when the notification message view is tapped - @objc func messageViewTapped() { + @objc func messageViewTapped() async { if let userInfo = appData.lastNotificationInfo { - notifyNotificationListeners(userInfo) + await notifyNotificationListeners(userInfo) SwiftMessages.hideAll() } } - private func notifyNotificationListeners(_ userInfo: [AnyHashable: Any], withCompletionHandler completionHandler: (() -> Void)? = nil) { - if let navigationController = window?.rootViewController as? UINavigationController { - if let rootViewController = navigationController.viewControllers.first as? OpenHABRootViewController { - rootViewController.handleNotification(userInfo, completionHandler: completionHandler) - } + // ✅ Ensure this runs on the MainActor + private func notifyNotificationListeners(_ userInfo: [AnyHashable: Any]) async { + if let navigationController = await MainActor.run(body: { window?.rootViewController as? UINavigationController }), + let rootViewController = await MainActor.run(body: { navigationController.viewControllers.first as? OpenHABRootViewController }) { + await rootViewController.handleNotification(userInfo) } } +} +extension AppDelegate { func applicationWillResignActive(_ application: UIApplication) { // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state. // Use this method to pause ongoing tasks, disable timers, and throttle down OpenGL ES frame rates. Games should use this method to pause the game. diff --git a/openHAB/OpenHABRootViewController.swift b/openHAB/OpenHABRootViewController.swift index 5cf18eec6..c2b3fced0 100644 --- a/openHAB/OpenHABRootViewController.swift +++ b/openHAB/OpenHABRootViewController.swift @@ -341,6 +341,29 @@ class OpenHABRootViewController: UIViewController { } } + func handleNotification(_ userInfo: [AnyHashable: Any]) async { + // Extract action identifier (from button press or notification click) + let action = userInfo["actionIdentifier"] as? String ?? userInfo["on-click"] as? String + + guard let action else { return } + let cmd = action.split(separator: ":").dropFirst().joined(separator: ":") + + switch true { + case action.hasPrefix("ui"): + uiCommandAction(cmd) + case action.hasPrefix("command"): + sendCommandAction(cmd) + case action.hasPrefix("http"): + httpCommandAction(action) + case action.hasPrefix("app"): + appCommandAction(action) + case action.hasPrefix("rule"): + ruleCommandAction(action) + default: + return + } + } + // Helper function to safely call the completion handler on the main thread private func callCompletionHandler(_ completionHandler: (() -> Void)?) { if let completionHandler { From e36cb9bc3d23e13b5e8821cfd92c4dc253f67b98 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Thu, 13 Mar 2025 17:02:15 +0100 Subject: [PATCH 072/476] Bringing down the number of swift 6 warnings Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- openHAB/AppDelegate.swift | 29 ++++++++++++++------ openHAB/LoggerView.swift | 2 +- openHAB/OpenHABRootViewController.swift | 36 +++++++++++++------------ 3 files changed, 41 insertions(+), 26 deletions(-) diff --git a/openHAB/AppDelegate.swift b/openHAB/AppDelegate.swift index d1addea46..5b8ba91bb 100644 --- a/openHAB/AppDelegate.swift +++ b/openHAB/AppDelegate.swift @@ -297,13 +297,26 @@ extension AppDelegate { } extension AppDelegate: MessagingDelegate { - func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) { - logger.info("My FCM token is: \(fcmToken ?? "", privacy: .private)") - let dataDict = [ - "deviceToken": fcmToken ?? "", - "deviceId": UIDevice.current.identifierForVendor?.uuidString ?? "", - "deviceName": UIDevice.current.name - ] - NotificationCenter.default.post(name: NSNotification.Name("apsRegistered"), object: self, userInfo: dataDict) + nonisolated func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) { + Task { @MainActor in + + let safeToken = fcmToken ?? "" + let deviceID = UIDevice.current.identifierForVendor?.uuidString ?? "UnknownDeviceID" + let deviceName = UIDevice.current.name + + logger.info("My FCM token is: \(safeToken, privacy: .private)") + + let dataDict: [String: Any] = [ + "deviceToken": safeToken, + "deviceId": deviceID, + "deviceName": deviceName + ] + + NotificationCenter.default.post( + name: NSNotification.Name("apsRegistered"), + object: self, + userInfo: dataDict + ) + } } } diff --git a/openHAB/LoggerView.swift b/openHAB/LoggerView.swift index af9f1a1e3..4d939500f 100644 --- a/openHAB/LoggerView.swift +++ b/openHAB/LoggerView.swift @@ -65,7 +65,7 @@ struct LoggerView: View { } } - init(logs: [LogEntry] = []) { // Custom initializer for prei + init(logs: [LogEntry] = []) { // Custom initializer for preview _logs = State(initialValue: logs) _isLoading = State(initialValue: logs.isEmpty) // Set isLoading based on logs } diff --git a/openHAB/OpenHABRootViewController.swift b/openHAB/OpenHABRootViewController.swift index c2b3fced0..3dd1ec8d6 100644 --- a/openHAB/OpenHABRootViewController.swift +++ b/openHAB/OpenHABRootViewController.swift @@ -609,7 +609,7 @@ class OpenHABRootViewController: UIViewController { // MARK: - UISideMenuNavigationControllerDelegate extension OpenHABRootViewController: SideMenuNavigationControllerDelegate { - func sideMenuWillAppear(menu: SideMenuNavigationController, animated: Bool) { + nonisolated func sideMenuWillAppear(menu: SideMenuNavigationController, animated: Bool) { os_log("OpenHABRootViewController sideMenuWillAppear", log: .viewCycle, type: .info) } } @@ -617,22 +617,24 @@ extension OpenHABRootViewController: SideMenuNavigationControllerDelegate { // MARK: - ModalHandler extension OpenHABRootViewController: ModalHandler { - func modalDismissed(to: TargetController) { - switch to { - case .sitemap: - switchView(target: to) - case .settings: - let hostingController = UIHostingController(rootView: SettingsView()) - navigationController?.pushViewController(hostingController, animated: true) - case .notifications: - let hostingController = UIHostingController(rootView: NotificationsView()) - navigationController?.pushViewController(hostingController, animated: true) - case .webview: - switchView(target: to) - case .browser: - break - case let .tile(urlString): - openTileURL(urlString) + nonisolated func modalDismissed(to: TargetController) { + Task { @MainActor in + switch to { + case .sitemap: + switchView(target: to) + case .settings: + let hostingController = UIHostingController(rootView: SettingsView()) + navigationController?.pushViewController(hostingController, animated: true) + case .notifications: + let hostingController = UIHostingController(rootView: NotificationsView()) + navigationController?.pushViewController(hostingController, animated: true) + case .webview: + switchView(target: to) + case .browser: + break + case let .tile(urlString): + openTileURL(urlString) + } } } } From 36ef1449fc589efaace8d85ff09d6408d448b76c Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Thu, 13 Mar 2025 23:45:44 +0100 Subject: [PATCH 073/476] Reverting ENABLE_DEBUG_DYLIB to YES / Otherwise #Preview does not work Refactoring of SettingsView to make it modular Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- openHAB.xcodeproj/project.pbxproj | 42 +- openHAB/AboutSettingsView.swift | 17 + openHAB/AppDelegate.swift | 8 +- openHAB/ApplicationSettingsView.swift | 71 +++ openHAB/ConnectionSettingsView.swift | 107 +++++ openHAB/DebugSettingsView.swift | 19 + openHAB/MainUISettingsView.swift | 19 + openHAB/SettingsView.swift | 404 ------------------ openHAB/SettingsView/AboutSettingsView.swift | 53 +++ .../ApplicationSettingsView.swift | 88 ++++ .../SettingsView/ClientCertificatesView.swift | 35 ++ .../SettingsView/ConnectionSettingsView.swift | 131 ++++++ openHAB/SettingsView/MainUISettingsView.swift | 99 +++++ openHAB/SettingsView/SettingsView.swift | 208 +++++++++ .../SettingsView/SitemapSettingsView.swift | 122 ++++++ openHAB/SitemapSettingsView.swift | 19 + 16 files changed, 1029 insertions(+), 413 deletions(-) create mode 100644 openHAB/AboutSettingsView.swift create mode 100644 openHAB/ApplicationSettingsView.swift create mode 100644 openHAB/ConnectionSettingsView.swift create mode 100644 openHAB/DebugSettingsView.swift create mode 100644 openHAB/MainUISettingsView.swift delete mode 100644 openHAB/SettingsView.swift create mode 100644 openHAB/SettingsView/AboutSettingsView.swift create mode 100644 openHAB/SettingsView/ApplicationSettingsView.swift create mode 100644 openHAB/SettingsView/ClientCertificatesView.swift create mode 100644 openHAB/SettingsView/ConnectionSettingsView.swift create mode 100644 openHAB/SettingsView/MainUISettingsView.swift create mode 100644 openHAB/SettingsView/SettingsView.swift create mode 100644 openHAB/SettingsView/SitemapSettingsView.swift create mode 100644 openHAB/SitemapSettingsView.swift diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index 97e58acae..2902b5b14 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -91,6 +91,12 @@ DA2E0B1023DCC439009B0A99 /* MapViewRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2E0B0F23DCC439009B0A99 /* MapViewRow.swift */; }; DA32D1B42C8C98C40018D974 /* IconWithAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA32D1B32C8C98C40018D974 /* IconWithAction.swift */; }; DA4642322D7EE6CA006C3908 /* LoggerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4642312D7EE6CA006C3908 /* LoggerView.swift */; }; + DA4800142D836892009CF127 /* ConnectionSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4800132D836892009CF127 /* ConnectionSettingsView.swift */; }; + DA4800162D836EF0009CF127 /* MainUISettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4800152D836EF0009CF127 /* MainUISettingsView.swift */; }; + DA4800182D837221009CF127 /* AboutSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4800172D837221009CF127 /* AboutSettingsView.swift */; }; + DA48001A2D83742A009CF127 /* DebugSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4800192D83742A009CF127 /* DebugSettingsView.swift */; }; + DA48001C2D837556009CF127 /* SitemapSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA48001B2D837556009CF127 /* SitemapSettingsView.swift */; }; + DA48001E2D837905009CF127 /* ApplicationSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA48001D2D837905009CF127 /* ApplicationSettingsView.swift */; }; DA4D4DB5233F9ACB00B37E37 /* README.md in Resources */ = {isa = PBXBuildFile; fileRef = DA4D4DB4233F9ACB00B37E37 /* README.md */; }; DA50C7BD2B0A51BD0009F716 /* SliderWithSwitchSupportRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA50C7BC2B0A51BD0009F716 /* SliderWithSwitchSupportRow.swift */; }; DA50C7BF2B0A65300009F716 /* SliderWithSwitchSupportUITableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA50C7BE2B0A652F0009F716 /* SliderWithSwitchSupportUITableViewCell.swift */; }; @@ -394,6 +400,12 @@ DA2E0B0F23DCC439009B0A99 /* MapViewRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapViewRow.swift; sourceTree = ""; }; DA32D1B32C8C98C40018D974 /* IconWithAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconWithAction.swift; sourceTree = ""; }; DA4642312D7EE6CA006C3908 /* LoggerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoggerView.swift; sourceTree = ""; }; + DA4800132D836892009CF127 /* ConnectionSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionSettingsView.swift; sourceTree = ""; }; + DA4800152D836EF0009CF127 /* MainUISettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainUISettingsView.swift; sourceTree = ""; }; + DA4800172D837221009CF127 /* AboutSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutSettingsView.swift; sourceTree = ""; }; + DA4800192D83742A009CF127 /* DebugSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugSettingsView.swift; sourceTree = ""; }; + DA48001B2D837556009CF127 /* SitemapSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SitemapSettingsView.swift; sourceTree = ""; }; + DA48001D2D837905009CF127 /* ApplicationSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationSettingsView.swift; sourceTree = ""; }; DA4D4DB4233F9ACB00B37E37 /* README.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; DA4D4E0E2340A00200B37E37 /* Changes.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = Changes.md; sourceTree = ""; }; DA50C7BC2B0A51BD0009F716 /* SliderWithSwitchSupportRow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SliderWithSwitchSupportRow.swift; sourceTree = ""; }; @@ -772,6 +784,20 @@ path = openHABTestsSwift; sourceTree = ""; }; + DA48001F2D837CD8009CF127 /* SettingsView */ = { + isa = PBXGroup; + children = ( + DA4800172D837221009CF127 /* AboutSettingsView.swift */, + DA48001D2D837905009CF127 /* ApplicationSettingsView.swift */, + DA5ED9BF2C8509C2004875E0 /* ClientCertificatesView.swift */, + DA4800132D836892009CF127 /* ConnectionSettingsView.swift */, + DA4800152D836EF0009CF127 /* MainUISettingsView.swift */, + DA242C612C83588600AFB10D /* SettingsView.swift */, + DA48001B2D837556009CF127 /* SitemapSettingsView.swift */, + ); + path = SettingsView; + sourceTree = ""; + }; DA658720236F841F007E2E7F /* Views */ = { isa = PBXGroup; children = ( @@ -877,19 +903,20 @@ isa = PBXGroup; children = ( DA4642312D7EE6CA006C3908 /* LoggerView.swift */, + DA48001F2D837CD8009CF127 /* SettingsView */, 653B54C1285E714900298ECD /* OpenHABViewController.swift */, 653B54BF285C0AC700298ECD /* OpenHABRootViewController.swift */, 65570A7C2476D16A00D524EA /* OpenHABWebViewController.swift */, DFB2624318830A3600D3244D /* OpenHABSitemapViewController.swift */, DAC65FC6236EDF3900F4501E /* SpinnerViewController.swift */, DA6B2EF62C8B92E800DF77CF /* SelectionView.swift */, - DA242C612C83588600AFB10D /* SettingsView.swift */, - DA5ED9BF2C8509C2004875E0 /* ClientCertificatesView.swift */, DA9F81862C85020F00B47B72 /* RTFTextView.swift */, DA6B2EF02C87B59000DF77CF /* NotificationsView.swift */, DA6B2EEE2C861BC900DF77CF /* DrawerView.swift */, + 1224F78B228A89E300750965 /* Watch */, DF4B84101886DA9900F34902 /* Widgets */, DFFD8FCE18EDBD30003B502A /* Util */, + DA4800192D83742A009CF127 /* DebugSettingsView.swift */, ); name = UI; sourceTree = ""; @@ -993,7 +1020,6 @@ DFDEE3FE1883228C008B26AC /* Models */, DF4B84051885AD4600F34902 /* Images */, DF4B83FD18857FA100F34902 /* UI */, - 1224F78B228A89E300750965 /* Watch */, DFB2623118830A3600D3244D /* Supporting Files */, 938BF9C724EFCCC000E6B52F /* Resources */, ); @@ -1545,6 +1571,7 @@ DA242C622C83588600AFB10D /* SettingsView.swift in Sources */, DA7E1E4B2233986E002AEFD8 /* PlayerView.swift in Sources */, 65570A7D2476D16A00D524EA /* OpenHABWebViewController.swift in Sources */, + DA48001E2D837905009CF127 /* ApplicationSettingsView.swift in Sources */, DAF0A28B2C56E3A300A14A6A /* RollershutterCell.swift in Sources */, DF06F1FC18FEC2020011E7B9 /* ColorPickerViewController.swift in Sources */, DA4642322D7EE6CA006C3908 /* LoggerView.swift in Sources */, @@ -1555,6 +1582,7 @@ 935B484625342B8E00E44CF0 /* URL+Static.swift in Sources */, B7D5ECE121499E55001B0EC6 /* MapViewTableViewCell.swift in Sources */, DA6B2EF52C89F8F200DF77CF /* ColorPickerView.swift in Sources */, + DA4800142D836892009CF127 /* ConnectionSettingsView.swift in Sources */, 2F6412EE2CE494A80039FB28 /* DatePickerUITableViewCell.swift in Sources */, DAA42BAA21DC983B00244B2A /* VideoUITableViewCell.swift in Sources */, DFB2623B18830A3600D3244D /* AppDelegate.swift in Sources */, @@ -1569,13 +1597,16 @@ DAF4F6C0222734D300C24876 /* NewImageUITableViewCell.swift in Sources */, DF1B302D1CF5C667009C921C /* OpenHABNotification.swift in Sources */, DA6B2EEF2C861BC900DF77CF /* DrawerView.swift in Sources */, + DA48001A2D83742A009CF127 /* DebugSettingsView.swift in Sources */, 938BF9D324EFD0B700E6B52F /* UIViewController+Localization.swift in Sources */, DAA42BA821DC97E000244B2A /* NotificationTableViewCell.swift in Sources */, DAF0A28F2C56F1EE00A14A6A /* ColorPickerCell.swift in Sources */, 2FEFD8F62BE7C5BE00E387B9 /* TextInputUITableViewCell.swift in Sources */, 938EDCE122C4FEB800661CA1 /* ScaleAspectFitImageView.swift in Sources */, + DA4800162D836EF0009CF127 /* MainUISettingsView.swift in Sources */, DAEAA89F21E6B16600267EA3 /* UITableView.swift in Sources */, DFB2624418830A3600D3244D /* OpenHABSitemapViewController.swift in Sources */, + DA4800182D837221009CF127 /* AboutSettingsView.swift in Sources */, 653B54C2285E714900298ECD /* OpenHABViewController.swift in Sources */, DFA16EC118898A8400EDB0BB /* SegmentedUITableViewCell.swift in Sources */, DAF0A28D2C56EF8900A14A6A /* SetpointCell.swift in Sources */, @@ -1584,6 +1615,7 @@ DFA16EBB18883DE500EDB0BB /* SliderUITableViewCell.swift in Sources */, DFA13CB418872EBD006355C3 /* SwitchUITableViewCell.swift in Sources */, DFFD8FD118EDBD4F003B502A /* UICircleButton.swift in Sources */, + DA48001C2D837556009CF127 /* SitemapSettingsView.swift in Sources */, 938BF9C624EFCC0700E6B52F /* UILabel+Localization.swift in Sources */, DA5ED9C02C8509C2004875E0 /* ClientCertificatesView.swift in Sources */, 653B54C0285C0AC700298ECD /* OpenHABRootViewController.swift in Sources */, @@ -2442,7 +2474,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 33; DEVELOPMENT_TEAM = PBAPXHRAM9; - ENABLE_DEBUG_DYLIB = NO; + ENABLE_DEBUG_DYLIB = YES; GCC_PRECOMPILE_PREFIX_HEADER = YES; GCC_PREFIX_HEADER = "openHAB/openHAB-Prefix.pch"; INFOPLIST_FILE = "openHAB/openHAB-Info.plist"; @@ -2502,7 +2534,7 @@ CURRENT_PROJECT_VERSION = 33; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = PBAPXHRAM9; - ENABLE_DEBUG_DYLIB = NO; + ENABLE_DEBUG_DYLIB = YES; GCC_PRECOMPILE_PREFIX_HEADER = YES; GCC_PREFIX_HEADER = "openHAB/openHAB-Prefix.pch"; INFOPLIST_FILE = "openHAB/openHAB-Info.plist"; diff --git a/openHAB/AboutSettingsView.swift b/openHAB/AboutSettingsView.swift new file mode 100644 index 000000000..92f5a913e --- /dev/null +++ b/openHAB/AboutSettingsView.swift @@ -0,0 +1,17 @@ +Section(header: Text(LocalizedStringKey("about_settings"))) { + LabeledContent("App Version", value: appVersion) + + NavigationLink { + RTFTextView(rtfFileName: "legal") + .navigationTitle("Legal") + .navigationBarTitleDisplayMode(.inline) + } label: { + Text("Legal") + } + + Button { + presentPrivacyPolicy() + } label: { + Text("privacy_policy") + } + } \ No newline at end of file diff --git a/openHAB/AppDelegate.swift b/openHAB/AppDelegate.swift index 5b8ba91bb..9013b24d3 100644 --- a/openHAB/AppDelegate.swift +++ b/openHAB/AppDelegate.swift @@ -299,19 +299,19 @@ extension AppDelegate { extension AppDelegate: MessagingDelegate { nonisolated func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) { Task { @MainActor in - + let safeToken = fcmToken ?? "" let deviceID = UIDevice.current.identifierForVendor?.uuidString ?? "UnknownDeviceID" let deviceName = UIDevice.current.name - + logger.info("My FCM token is: \(safeToken, privacy: .private)") - + let dataDict: [String: Any] = [ "deviceToken": safeToken, "deviceId": deviceID, "deviceName": deviceName ] - + NotificationCenter.default.post( name: NSNotification.Name("apsRegistered"), object: self, diff --git a/openHAB/ApplicationSettingsView.swift b/openHAB/ApplicationSettingsView.swift new file mode 100644 index 000000000..d2a1473a7 --- /dev/null +++ b/openHAB/ApplicationSettingsView.swift @@ -0,0 +1,71 @@ +struct ApplicationSettingsView: View { + @Binding var settingsIgnoreSSL: Bool + @Binding var settingsIdleOff: Bool + @Binding var settingsSendCrashReports: Bool + @Binding var showCrashReportingAlert: Bool + @Binding var hasBeenLoaded: Bool + + private let logger = Logger(subsystem: "org.openhab.app", category: "ApplicationSettingsView") + + var body: some View { + Section(header: Text(LocalizedStringKey("application_settings"))) { + Toggle(isOn: $settingsIgnoreSSL) { + Text("Ignore SSL certificates") + } + + Toggle(isOn: $settingsIdleOff) { + Text("Disable Idle Timeout") + } + + Toggle(isOn: $settingsSendCrashReports) { + Text("Crash Reporting") + } + + .onAppear { + // Setting .onAppear of view required here because onAppear of entire view is run after onChange is active + // when migrating to iOS17 this + settingsSendCrashReports = Preferences.sendCrashReports + // loadSitemaps() + hasBeenLoaded = true + } + .onChange(of: settingsSendCrashReports) { newValue in + logger.debug("Detected change on settingsSendCrashReports") + if newValue, hasBeenLoaded { + showCrashReportingAlert = true + } + } + .confirmationDialog( + "crash_reporting", + isPresented: $showCrashReportingAlert + ) { + Button(role: .destructive) { + settingsSendCrashReports = true + } label: { + Text(LocalizedStringKey("activate")) + } + Button(LocalizedStringKey("privacy_policy")) { + presentPrivacyPolicy() + settingsSendCrashReports = false + } + Button(role: .cancel) { + settingsSendCrashReports = false + } label: { + Text(LocalizedStringKey("cancel")) + } + } message: { + Text(LocalizedStringKey("crash_reporting_info")) + } + + NavigationLink { + ClientCertificatesView() + } label: { + Text("Client Certificates") + } + } + } + + func presentPrivacyPolicy() { + let vc = SFSafariViewController(url: .privacyPolicy) + UIApplication.shared.firstKeyWindow?.rootViewController?.present(vc, animated: true) + } +} \ No newline at end of file diff --git a/openHAB/ConnectionSettingsView.swift b/openHAB/ConnectionSettingsView.swift new file mode 100644 index 000000000..2ca2661b7 --- /dev/null +++ b/openHAB/ConnectionSettingsView.swift @@ -0,0 +1,107 @@ +// Copyright (c) 2010-2025 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 SwiftUI + +struct ConnectionSettingsView: View { + @Binding var settingsDemomode: Bool + @Binding var settingsLocalUrl: String + @Binding var settingsRemoteUrl: String + @Binding var settingsUsername: String + @Binding var settingsPassword: String + @Binding var settingsAlwaysSendCreds: Bool + + var body: some View { + Section(header: Text("OpenHAB Connection")) { + Toggle("Demo Mode", isOn: $settingsDemomode) + + if !settingsDemomode { + LabeledContent { + Spacer() + TextField( + "Local URL", + text: $settingsLocalUrl + ) + .fixedSize() + .keyboardType(.URL) + .textInputAutocapitalization(.never) + .disableAutocorrection(true) + .font(.system(.caption)) + } label: { + Text("Local URL") + if settingsLocalUrl.isEmpty { + Text("Enter URL of local server") + } + } + + LabeledContent { + Spacer() + TextField( + "Remote URL", + text: $settingsRemoteUrl + ) + .fixedSize() + .keyboardType(.URL) + .textInputAutocapitalization(.never) + .disableAutocorrection(true) + .font(.system(.caption)) + } label: { + Text("Remote URL") + if settingsRemoteUrl.isEmpty { + Text("Enter URL of remote server") + } + } + + LabeledContent { + TextField( + "Foo", + text: $settingsUsername + ) + .textContentType(.username) // Associates with AutoFill + .fixedSize() + .textInputAutocapitalization(.never) + .disableAutocorrection(true) + .font(.system(.caption)) + } label: { + Text("Username") + if settingsUsername.isEmpty { + Text("Enter username on server, if required") + } + } + + LabeledContent { + SecureField( + "1234", + text: $settingsPassword + ) + .fixedSize() + .textInputAutocapitalization(.never) + .disableAutocorrection(true) // or .autocorrectionDisabled(true) ?? + .font(.system(.caption)) + .textContentType(.password) // Associates with AutoFill + } label: { + Text("Password") + if settingsPassword.isEmpty { + Text("Enter password on server") + } + } + + Toggle(isOn: $settingsAlwaysSendCreds) { + Text("Always send credentials") + } + } + } + } +} + +#Preview { + ConnectionView() +} diff --git a/openHAB/DebugSettingsView.swift b/openHAB/DebugSettingsView.swift new file mode 100644 index 000000000..7534bdccf --- /dev/null +++ b/openHAB/DebugSettingsView.swift @@ -0,0 +1,19 @@ +// +// DebugSettingsView.swift +// openHAB +// +// Created by Tim Müller-Seydlitz on 13.03.25. +// Copyright © 2025 openHAB e.V. All rights reserved. +// + +import SwiftUI + +struct DebugSettingsView: View { + var body: some View { + Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/) + } +} + +#Preview { + DebugSettingsView() +} diff --git a/openHAB/MainUISettingsView.swift b/openHAB/MainUISettingsView.swift new file mode 100644 index 000000000..af2bd1d3c --- /dev/null +++ b/openHAB/MainUISettingsView.swift @@ -0,0 +1,19 @@ +// +// MainUISettingsView.swift +// openHAB +// +// Created by Tim Müller-Seydlitz on 13.03.25. +// Copyright © 2025 openHAB e.V. All rights reserved. +// + +import SwiftUI + +struct MainUISettingsView: View { + var body: some View { + Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/) + } +} + +#Preview { + MainUISettingsView() +} diff --git a/openHAB/SettingsView.swift b/openHAB/SettingsView.swift deleted file mode 100644 index d618a33a8..000000000 --- a/openHAB/SettingsView.swift +++ /dev/null @@ -1,404 +0,0 @@ -// Copyright (c) 2010-2025 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 FirebaseCrashlytics -import Kingfisher -import OpenHABCore -import os -import SafariServices -import SFSafeSymbols -import SwiftUI -import WebKit - -struct SettingsView: View { - @State var settingsDemomode = false - @State var settingsLocalUrl = "" - @State var settingsRemoteUrl = "" - @State var settingsUsername = "" - @State var settingsPassword = "" - @State var settingsAlwaysSendCreds = true - @State var settingsIdleOff = true - @State var settingsIgnoreSSL = true - @State var settingsRealTimeSliders = true - @State var settingsSendCrashReports = false - @State var settingsIconType: IconType = .png - @State var settingsSortSitemapsBy: SortSitemapsOrder = .label - @State var settingsDefaultMainUIPath = "" - @State var settingsAlwaysAllowWebRTC = true - @State var settingsSitemapForWatch = "" - - @State private var showingCacheAlert = false - @State private var showCrashReportingAlert = false - @State private var showUselastPathAlert = false - - @State private var hasBeenLoaded = false - - @State private var sitemaps: [OpenHABSitemap] = [] - - @Environment(\.dismiss) private var dismiss - - var appData: OpenHABDataObject? { - AppDelegate.appDelegate.appData - } - - var appVersion: String { - let appBuildString = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String - let appVersionString = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String - return "\(appVersionString ?? "") (\(appBuildString ?? ""))" - } - - private let logger = Logger(subsystem: "org.openhab.app", category: "SettingsView") - - var body: some View { - Form { - Section(header: Text(LocalizedStringKey("openhab_connection"))) { - Toggle(isOn: $settingsDemomode) { - Text("Demo Mode") - } - - if !settingsDemomode { - LabeledContent { - Spacer() - TextField( - "Local URL", - text: $settingsLocalUrl - ) - .fixedSize() - .keyboardType(.URL) - .textInputAutocapitalization(.never) - .disableAutocorrection(true) - .font(.system(.caption)) - } label: { - Text("Local URL") - if settingsLocalUrl.isEmpty { - Text("Enter URL of local server") - } - } - - LabeledContent { - Spacer() - TextField( - "Remote URL", - text: $settingsRemoteUrl - ) - .fixedSize() - .keyboardType(.URL) - .textInputAutocapitalization(.never) - .disableAutocorrection(true) - .font(.system(.caption)) - } label: { - Text("Remote URL") - if settingsRemoteUrl.isEmpty { - Text("Enter URL of remote server") - } - } - - LabeledContent { - TextField( - "Foo", - text: $settingsUsername - ) - .textContentType(.username) // Associates with AutoFill - .fixedSize() - .textInputAutocapitalization(.never) - .disableAutocorrection(true) - .font(.system(.caption)) - } label: { - Text("Username") - if settingsUsername.isEmpty { - Text("Enter username on server, if required") - } - } - - LabeledContent { - SecureField( - "1234", - text: $settingsPassword - ) - .fixedSize() - .textInputAutocapitalization(.never) - .disableAutocorrection(true) // or .autocorrectionDisabled(true) ?? - .font(.system(.caption)) - .textContentType(.password) // Associates with AutoFill - } label: { - Text("Password") - if settingsPassword.isEmpty { - Text("Enter password on server") - } - } - - Toggle(isOn: $settingsAlwaysSendCreds) { - Text("Always send credentials") - } - } - } - - Section(header: Text(LocalizedStringKey("application_settings"))) { - Toggle(isOn: $settingsIgnoreSSL) { - Text("Ignore SSL certificates") - } - - Toggle(isOn: $settingsIdleOff) { - Text("Disable Idle Timeout") - } - - Toggle(isOn: $settingsSendCrashReports) { - Text("Crash Reporting") - } - - .onAppear { - // Setting .onAppear of view required here because onAppear of entire view is run after onChange is active - // when migrating to iOS17 this - settingsSendCrashReports = Preferences.sendCrashReports -// loadSitemaps() - hasBeenLoaded = true - } - .onChange(of: settingsSendCrashReports) { newValue in - logger.debug("Detected change on settingsSendCrashReports") - if newValue, hasBeenLoaded { - showCrashReportingAlert = true - } - } - .confirmationDialog( - "crash_reporting", - isPresented: $showCrashReportingAlert - ) { - Button(role: .destructive) { - settingsSendCrashReports = true - } label: { - Text(LocalizedStringKey("activate")) - } - Button(LocalizedStringKey("privacy_policy")) { - presentPrivacyPolicy() - settingsSendCrashReports = false - } - Button(role: .cancel) { - settingsSendCrashReports = false - } label: { - Text(LocalizedStringKey("cancel")) - } - } message: { - Text(LocalizedStringKey("crash_reporting_info")) - } - - NavigationLink { - ClientCertificatesView() - } label: { - Text("Client Certificates") - } - } - - Section(header: Text(LocalizedStringKey("mainui_settings"))) { - Toggle(isOn: $settingsAlwaysAllowWebRTC) { - Text("Always allow WebRTC") - } - - LabeledContent { - TextField( - "/overview/", - text: $settingsDefaultMainUIPath - ) - .fixedSize() - Button { - showUselastPathAlert = true - } label: { - Image(systemSymbol: .plusCircle) - } - .confirmationDialog( - "uselastpath_settings", - isPresented: $showUselastPathAlert - ) { - Button("Ok") { - if let path = appData?.currentWebViewPath { - settingsDefaultMainUIPath = path - } - } - Button(role: .cancel) {} label: { - Text(LocalizedStringKey("cancel")) - } - Button("cancel", role: .cancel) {} - } message: { - Text(LocalizedStringKey("uselastpath_settings")) - } - - } label: { - Text("Default Path") - } - - Button { - let websiteDataTypes = NSSet(array: [WKWebsiteDataTypeDiskCache, WKWebsiteDataTypeMemoryCache]) - let date = Date(timeIntervalSince1970: 0) - WKWebsiteDataStore.default().removeData(ofTypes: websiteDataTypes as! Set, modifiedSince: date) {} - showingCacheAlert = true - } label: { - NavigationLink("Clear Web Cache", destination: EmptyView()) - } - .foregroundColor(Color(uiColor: .label)) - .alert("cache_cleared", isPresented: $showingCacheAlert) { - Button("OK", role: .cancel) {} - } - } - - Section(header: Text(LocalizedStringKey("sitemap_settings"))) { - Toggle(isOn: $settingsRealTimeSliders) { - Text("Real-time Sliders") - } - - Button { - clearWebsiteCache() - showingCacheAlert = true - } label: { - NavigationLink("Clear Image Cache", destination: EmptyView()) - } - .foregroundColor(Color(uiColor: .label)) - .alert("cache_cleared", isPresented: $showingCacheAlert) { - Button("OK", role: .cancel) {} - } - - Picker(selection: $settingsIconType) { - ForEach(IconType.allCases, id: \.self) { icontype in - Text(verbatim: "\(icontype)").tag(icontype) - } - } label: { - Text("Icon Type") - } - - Picker(selection: $settingsSortSitemapsBy) { - ForEach(SortSitemapsOrder.allCases, id: \.self) { sortsitemaporder in - Text(verbatim: "\(sortsitemaporder)").tag(sortsitemaporder) - } - } label: { - Text("Sort sitemaps by") - } - - Picker(selection: $settingsSitemapForWatch) { - ForEach(sitemaps, id: \.name) { sitemap in - Text(sitemap.label) - } - } label: { - Text("Sitemap For Apple Watch") - } - } - - Section(header: Text(LocalizedStringKey("debug"))) { - NavigationLink { - LoggerView() - } label: { - Text("Logs") - } - } - - Section(header: Text(LocalizedStringKey("about_settings"))) { - LabeledContent("App Version", value: appVersion) - - NavigationLink { - RTFTextView(rtfFileName: "legal") - .navigationTitle("Legal") - .navigationBarTitleDisplayMode(.inline) - } label: { - Text("Legal") - } - - Button { - presentPrivacyPolicy() - } label: { - Text("privacy_policy") - } - } - } - .formStyle(.grouped) - .navigationBarBackButtonHidden(true) - .navigationBarTitle("Settings") - .toolbar { - ToolbarItemGroup(placement: .primaryAction) { - Button("Save") { - saveSettings() - appData?.sitemapViewController?.pageUrl = "" - NotificationCenter.default.post(name: NSNotification.Name("org.openhab.preferences.saved"), object: nil) - dismiss() - } - } - ToolbarItemGroup(placement: .cancellationAction) { - Button("Cancel") { - dismiss() - } - } - } - .onAppear { - loadSettings() - logger.debug("Loading Settings") - } - } - - func clearWebsiteCache() { - logger.debug("Clearing image cache") - KingfisherManager.shared.cache.clearMemoryCache() - KingfisherManager.shared.cache.clearDiskCache() - KingfisherManager.shared.cache.cleanExpiredDiskCache() - } - - func presentPrivacyPolicy() { - let vc = SFSafariViewController(url: .privacyPolicy) - UIApplication.shared.firstKeyWindow?.rootViewController?.present(vc, animated: true) - } - - func loadSettings() { - settingsLocalUrl = Preferences.localUrl - settingsRemoteUrl = Preferences.remoteUrl - settingsUsername = Preferences.username - settingsPassword = Preferences.password - settingsAlwaysSendCreds = Preferences.alwaysSendCreds - settingsIgnoreSSL = Preferences.ignoreSSL - settingsDemomode = Preferences.demomode - settingsIdleOff = Preferences.idleOff - settingsRealTimeSliders = Preferences.realTimeSliders - settingsSendCrashReports = Preferences.sendCrashReports - settingsIconType = IconType(rawValue: Preferences.iconType) ?? .png - settingsSortSitemapsBy = SortSitemapsOrder(rawValue: Preferences.sortSitemapsby) ?? .label - settingsDefaultMainUIPath = Preferences.defaultMainUIPath - settingsAlwaysAllowWebRTC = Preferences.alwaysAllowWebRTC - settingsSitemapForWatch = Preferences.sitemapForWatch - } - - func saveSettings() { - Preferences.localUrl = settingsLocalUrl - Preferences.remoteUrl = settingsRemoteUrl - Preferences.username = settingsUsername - Preferences.password = settingsPassword - Preferences.alwaysSendCreds = settingsAlwaysSendCreds - Preferences.ignoreSSL = settingsIgnoreSSL - Preferences.demomode = settingsDemomode - Preferences.idleOff = settingsIdleOff - Preferences.realTimeSliders = settingsRealTimeSliders - Preferences.iconType = settingsIconType.rawValue - Preferences.sendCrashReports = settingsSendCrashReports - Preferences.sortSitemapsby = settingsSortSitemapsBy.rawValue - Preferences.defaultMainUIPath = settingsDefaultMainUIPath - Preferences.alwaysAllowWebRTC = settingsAlwaysAllowWebRTC - Preferences.sitemapForWatch = settingsSitemapForWatch - WatchMessageService.singleton.syncPreferencesToWatch() - Crashlytics.crashlytics().setCrashlyticsCollectionEnabled(settingsSendCrashReports) - logger.debug("setCrashlyticsCollectionEnabled to \(settingsSendCrashReports)") - } -} - -extension UIApplication { - var firstKeyWindow: UIWindow? { - UIApplication.shared.connectedScenes - .compactMap { $0 as? UIWindowScene } - .filter { $0.activationState == .foregroundActive } - .first?.keyWindow - } -} - -#Preview { - SettingsView() -} diff --git a/openHAB/SettingsView/AboutSettingsView.swift b/openHAB/SettingsView/AboutSettingsView.swift new file mode 100644 index 000000000..5ff81c64a --- /dev/null +++ b/openHAB/SettingsView/AboutSettingsView.swift @@ -0,0 +1,53 @@ +// Copyright (c) 2010-2025 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 + +// +// AboutSettingsView.swift +// openHAB +// +// Created by Tim Müller-Seydlitz on 13.03.25. +// Copyright © 2025 openHAB e.V. All rights reserved. +// +import SafariServices +import SwiftUI + +struct AboutSettingsView: View { + var appVersion: String { + let appBuildString = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String + let appVersionString = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String + return "\(appVersionString ?? "") (\(appBuildString ?? ""))" + } + + var body: some View { + Section(header: Text(LocalizedStringKey("about_settings"))) { + LabeledContent("App Version", value: appVersion) + + NavigationLink { + RTFTextView(rtfFileName: "legal") + .navigationTitle("Legal") + .navigationBarTitleDisplayMode(.inline) + } label: { + Text("Legal") + } + + Button { + presentPrivacyPolicy() + } label: { + Text("privacy_policy") + } + } + } + + func presentPrivacyPolicy() { + let vc = SFSafariViewController(url: .privacyPolicy) + UIApplication.shared.firstKeyWindow?.rootViewController?.present(vc, animated: true) + } +} diff --git a/openHAB/SettingsView/ApplicationSettingsView.swift b/openHAB/SettingsView/ApplicationSettingsView.swift new file mode 100644 index 000000000..3f178a871 --- /dev/null +++ b/openHAB/SettingsView/ApplicationSettingsView.swift @@ -0,0 +1,88 @@ +// Copyright (c) 2010-2025 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 OpenHABCore +import os +import SafariServices +import SwiftUI + +struct ApplicationSettingsView: View { + @Binding var settingsIgnoreSSL: Bool + @Binding var settingsIdleOff: Bool + @Binding var settingsSendCrashReports: Bool + + @State var showCrashReportingAlert = false + @State private var hasBeenLoaded = false + + private let logger = Logger(subsystem: "org.openhab.app", category: "ApplicationSettingsView") + + var body: some View { + Section(header: Text(LocalizedStringKey("application_settings"))) { + Toggle(isOn: $settingsIgnoreSSL) { + Text("Ignore SSL certificates") + } + + Toggle(isOn: $settingsIdleOff) { + Text("Disable Idle Timeout") + } + + Toggle(isOn: $settingsSendCrashReports) { + Text("Crash Reporting") + } + + .onAppear { + // Setting .onAppear of view required here because onAppear of entire view is run after .onChange is active + // when migrating to iOS17 this + settingsSendCrashReports = Preferences.sendCrashReports + // loadSitemaps() + hasBeenLoaded = true + } + .onChange(of: settingsSendCrashReports) { newValue in + logger.debug("Detected change on settingsSendCrashReports") + if newValue, hasBeenLoaded { + showCrashReportingAlert = true + } + } + .confirmationDialog( + "crash_reporting", + isPresented: $showCrashReportingAlert + ) { + Button(role: .destructive) { + settingsSendCrashReports = true + } label: { + Text(LocalizedStringKey("activate")) + } + Button(LocalizedStringKey("privacy_policy")) { + presentPrivacyPolicy() + settingsSendCrashReports = false + } + Button(role: .cancel) { + settingsSendCrashReports = false + } label: { + Text(LocalizedStringKey("cancel")) + } + } message: { + Text(LocalizedStringKey("crash_reporting_info")) + } + + NavigationLink { + ClientCertificatesView() + } label: { + Text("Client Certificates") + } + } + } + + func presentPrivacyPolicy() { + let vc = SFSafariViewController(url: .privacyPolicy) + UIApplication.shared.firstKeyWindow?.rootViewController?.present(vc, animated: true) + } +} diff --git a/openHAB/SettingsView/ClientCertificatesView.swift b/openHAB/SettingsView/ClientCertificatesView.swift new file mode 100644 index 000000000..14c4f8b6e --- /dev/null +++ b/openHAB/SettingsView/ClientCertificatesView.swift @@ -0,0 +1,35 @@ +// Copyright (c) 2010-2025 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 SwiftUI + +struct ClientCertificatesView: View { + @StateObject private var viewModel = ClientCertificatesViewModel() + + var body: some View { + List { + ForEach(viewModel.clientCertificates.indices, id: \.self) { index in + Text(viewModel.getIdentityName(for: index)) + } + .onDelete { indices in + indices.forEach { viewModel.deleteCertificate(at: $0) } + } + } + .navigationTitle(Text(LocalizedStringKey("client_certificates"))) + .onAppear { + viewModel.loadCertificates() + } + } +} + +#Preview { + ClientCertificatesView() +} diff --git a/openHAB/SettingsView/ConnectionSettingsView.swift b/openHAB/SettingsView/ConnectionSettingsView.swift new file mode 100644 index 000000000..7a20f2ee9 --- /dev/null +++ b/openHAB/SettingsView/ConnectionSettingsView.swift @@ -0,0 +1,131 @@ +// Copyright (c) 2010-2025 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 SwiftUI + +struct ConnectionSettingsView: View { + @Binding var settingsDemomode: Bool + @Binding var settingsLocalUrl: String + @Binding var settingsRemoteUrl: String + @Binding var settingsUsername: String + @Binding var settingsPassword: String + @Binding var settingsAlwaysSendCreds: Bool + + var body: some View { + Section(header: Text("OpenHAB Connection")) { + Toggle("Demo Mode", isOn: $settingsDemomode) + + if !settingsDemomode { + LabeledContent { + Spacer() + TextField( + "Local URL", + text: $settingsLocalUrl + ) + .fixedSize() + .keyboardType(.URL) + .textInputAutocapitalization(.never) + .disableAutocorrection(true) + .font(.system(.caption)) + } label: { + Text("Local URL") + if settingsLocalUrl.isEmpty { + Text("Enter URL of local server") + } + } + + LabeledContent { + Spacer() + TextField( + "Remote URL", + text: $settingsRemoteUrl + ) + .fixedSize() + .keyboardType(.URL) + .textInputAutocapitalization(.never) + .disableAutocorrection(true) + .font(.system(.caption)) + } label: { + Text("Remote URL") + if settingsRemoteUrl.isEmpty { + Text("Enter URL of remote server") + } + } + + LabeledContent { + TextField( + "Foo", + text: $settingsUsername + ) + .textContentType(.username) // Associates with AutoFill + .fixedSize() + .textInputAutocapitalization(.never) + .disableAutocorrection(true) + .font(.system(.caption)) + } label: { + Text("Username") + if settingsUsername.isEmpty { + Text("Enter username on server, if required") + } + } + + LabeledContent { + SecureField( + "1234", + text: $settingsPassword + ) + .fixedSize() + .textInputAutocapitalization(.never) + .disableAutocorrection(true) // or .autocorrectionDisabled(true) ?? + .font(.system(.caption)) + .textContentType(.password) // Associates with AutoFill + } label: { + Text("Password") + if settingsPassword.isEmpty { + Text("Enter password on server") + } + } + + Toggle(isOn: $settingsAlwaysSendCreds) { + Text("Always send credentials") + } + } + } + } +} + +// **TODO Migrate to @Previewable on iOS 17 +#Preview { + struct PreviewWrapper: View { + @State var demoMode = false + @State var localUrl = "http://192.168.1.100" + @State var remoteUrl = "https://myopenhab.org" + @State var username = "user" + @State var password = "password123" + @State var alwaysSendCreds = true + + var body: some View { + NavigationView { + Form { + ConnectionSettingsView( + settingsDemomode: $demoMode, + settingsLocalUrl: $localUrl, + settingsRemoteUrl: $remoteUrl, + settingsUsername: $username, + settingsPassword: $password, + settingsAlwaysSendCreds: $alwaysSendCreds + ) + } + } + } + } + return PreviewWrapper() +} diff --git a/openHAB/SettingsView/MainUISettingsView.swift b/openHAB/SettingsView/MainUISettingsView.swift new file mode 100644 index 000000000..09c8b7b5e --- /dev/null +++ b/openHAB/SettingsView/MainUISettingsView.swift @@ -0,0 +1,99 @@ +// Copyright (c) 2010-2025 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 SwiftUI +import WebKit + +struct MainUISettingsView: View { + @Binding var settingsAlwaysAllowWebRTC: Bool + @Binding var settingsDefaultMainUIPath: String + @State var showUselastPathAlert = false + + @State var showingCacheAlert = false + + var appData: OpenHABDataObject? { + AppDelegate.appDelegate.appData + } + + var body: some View { + Section(header: Text(LocalizedStringKey("mainui_settings"))) { + Toggle(isOn: $settingsAlwaysAllowWebRTC) { + Text("Always allow WebRTC") + } + + LabeledContent { + TextField( + "/overview/", + text: $settingsDefaultMainUIPath + ) + .fixedSize() + Button { + showUselastPathAlert = true + } label: { + Image(systemSymbol: .plusCircle) + } + .confirmationDialog( + "uselastpath_settings", + isPresented: $showUselastPathAlert + ) { + Button("Ok") { + if let path = appData?.currentWebViewPath { + settingsDefaultMainUIPath = path + } + } + Button(role: .cancel) {} label: { + Text(LocalizedStringKey("cancel")) + } + Button("cancel", role: .cancel) {} + } message: { + Text(LocalizedStringKey("uselastpath_settings")) + } + + } label: { + Text("Default Path") + } + + Button { + let websiteDataTypes = NSSet(array: [WKWebsiteDataTypeDiskCache, WKWebsiteDataTypeMemoryCache]) + let date = Date(timeIntervalSince1970: 0) + WKWebsiteDataStore.default().removeData(ofTypes: websiteDataTypes as! Set, modifiedSince: date) {} + showingCacheAlert = true + } label: { + NavigationLink("Clear Web Cache", destination: EmptyView()) + } + .foregroundColor(Color(uiColor: .label)) + .alert("cache_cleared", isPresented: $showingCacheAlert) { + Button("OK", role: .cancel) {} + } + } + } +} + +#Preview { + struct PreviewWrapper: View { + @State var alwaysAllowWebRTC = true + @State var defaultMainUIPath = "/overview/" + @State var showLastPathAlert = false + @State var showUselastPathAlert = false + + var body: some View { + NavigationView { + Form { + MainUISettingsView( + settingsAlwaysAllowWebRTC: $alwaysAllowWebRTC, + settingsDefaultMainUIPath: $defaultMainUIPath + ) + } + } + } + } + return PreviewWrapper() +} diff --git a/openHAB/SettingsView/SettingsView.swift b/openHAB/SettingsView/SettingsView.swift new file mode 100644 index 000000000..d87aeaf19 --- /dev/null +++ b/openHAB/SettingsView/SettingsView.swift @@ -0,0 +1,208 @@ +// Copyright (c) 2010-2025 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 FirebaseCrashlytics +import OpenHABCore +import os +import SwiftUI + +struct SettingsView: View { + @State var settingsDemomode = false + @State var settingsLocalUrl = "" + @State var settingsRemoteUrl = "" + @State var settingsUsername = "" + @State var settingsPassword = "" + @State var settingsAlwaysSendCreds = true + @State var settingsIdleOff = true + @State var settingsIgnoreSSL = true + @State var settingsRealTimeSliders = true + @State var settingsSendCrashReports = false + @State var settingsIconType: IconType = .png + @State var settingsSortSitemapsBy: SortSitemapsOrder = .label + @State var settingsDefaultMainUIPath = "" + @State var settingsAlwaysAllowWebRTC = true + @State var settingsSitemapForWatch = "" + + @State private var sitemaps: [OpenHABSitemap] = [] + + @Environment(\.dismiss) private var dismiss + + var appData: OpenHABDataObject? { + AppDelegate.appDelegate.appData + } + + private let logger = Logger(subsystem: "org.openhab.app", category: "SettingsView") + + var body: some View { + Form { + ConnectionSettingsView( + settingsDemomode: $settingsDemomode, + settingsLocalUrl: $settingsLocalUrl, + settingsRemoteUrl: $settingsRemoteUrl, + settingsUsername: $settingsUsername, + settingsPassword: $settingsPassword, + settingsAlwaysSendCreds: $settingsAlwaysSendCreds + ) + + ApplicationSettingsView( + settingsIgnoreSSL: $settingsIgnoreSSL, + settingsIdleOff: $settingsIdleOff, + settingsSendCrashReports: $settingsSendCrashReports + ) + + MainUISettingsView( + settingsAlwaysAllowWebRTC: $settingsAlwaysAllowWebRTC, + settingsDefaultMainUIPath: $settingsDefaultMainUIPath + ) + + SitemapSettingsView( + settingsRealTimeSliders: $settingsRealTimeSliders, + settingsIconType: $settingsIconType, + settingsSortSitemapsBy: $settingsSortSitemapsBy, + settingsSitemapForWatch: $settingsSitemapForWatch, + sitemaps: $sitemaps + ) + + DebugSettingsView() + + AboutSettingsView() + } + .formStyle(.grouped) + .navigationBarBackButtonHidden(true) + .navigationBarTitle("Settings") + .toolbar { + ToolbarItemGroup(placement: .primaryAction) { + Button("Save") { + saveSettings() + appData?.sitemapViewController?.pageUrl = "" + NotificationCenter.default.post(name: NSNotification.Name("org.openhab.preferences.saved"), object: nil) + dismiss() + } + } + ToolbarItemGroup(placement: .cancellationAction) { + Button("Cancel") { + dismiss() + } + } + } + .onAppear { + loadSettings() + logger.debug("Loading Settings") + } + } + + func loadSettings() { + settingsLocalUrl = Preferences.localUrl + settingsRemoteUrl = Preferences.remoteUrl + settingsUsername = Preferences.username + settingsPassword = Preferences.password + settingsAlwaysSendCreds = Preferences.alwaysSendCreds + settingsIgnoreSSL = Preferences.ignoreSSL + settingsDemomode = Preferences.demomode + settingsIdleOff = Preferences.idleOff + settingsRealTimeSliders = Preferences.realTimeSliders + settingsSendCrashReports = Preferences.sendCrashReports + settingsIconType = IconType(rawValue: Preferences.iconType) ?? .png + settingsSortSitemapsBy = SortSitemapsOrder(rawValue: Preferences.sortSitemapsby) ?? .label + settingsDefaultMainUIPath = Preferences.defaultMainUIPath + settingsAlwaysAllowWebRTC = Preferences.alwaysAllowWebRTC + settingsSitemapForWatch = Preferences.sitemapForWatch + } + + func saveSettings() { + Preferences.localUrl = settingsLocalUrl + Preferences.remoteUrl = settingsRemoteUrl + Preferences.username = settingsUsername + Preferences.password = settingsPassword + Preferences.alwaysSendCreds = settingsAlwaysSendCreds + Preferences.ignoreSSL = settingsIgnoreSSL + Preferences.demomode = settingsDemomode + Preferences.idleOff = settingsIdleOff + Preferences.realTimeSliders = settingsRealTimeSliders + Preferences.iconType = settingsIconType.rawValue + Preferences.sendCrashReports = settingsSendCrashReports + Preferences.sortSitemapsby = settingsSortSitemapsBy.rawValue + Preferences.defaultMainUIPath = settingsDefaultMainUIPath + Preferences.alwaysAllowWebRTC = settingsAlwaysAllowWebRTC + Preferences.sitemapForWatch = settingsSitemapForWatch + WatchMessageService.singleton.syncPreferencesToWatch() + Crashlytics.crashlytics().setCrashlyticsCollectionEnabled(settingsSendCrashReports) + logger.debug("setCrashlyticsCollectionEnabled to \(settingsSendCrashReports)") + } +} + +extension UIApplication { + var firstKeyWindow: UIWindow? { + UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .filter { $0.activationState == .foregroundActive } + .first?.keyWindow + } +} + +#Preview { + struct PreviewWrapper: View { + @State var settingsDemomode = false + @State var settingsLocalUrl = "http://192.168.1.100" + @State var settingsRemoteUrl = "https://myopenhab.org" + @State var settingsUsername = "user" + @State var settingsPassword = "password123" + @State var settingsAlwaysSendCreds = true + @State var settingsIdleOff = true + @State var settingsIgnoreSSL = true + @State var settingsRealTimeSliders = true + @State var settingsSendCrashReports = false + @State var settingsIconType: IconType = .png + @State var settingsSortSitemapsBy: SortSitemapsOrder = .label + @State var settingsDefaultMainUIPath = "/overview/" + @State var settingsAlwaysAllowWebRTC = true + @State var settingsSitemapForWatch = "home" + @State var sitemaps: [OpenHABSitemap] = [ + OpenHABSitemap( + name: "home", + icon: "", + label: "Home", + link: "http://192.168.1.100/rest/sitemaps/home", + page: nil // Replace with actual OpenHABPage if needed + ), + OpenHABSitemap( + name: "office", + icon: "", + label: "Office", + link: "http://192.168.1.100/rest/sitemaps/office", + page: nil // Replace with actual OpenHABPage if needed + ) + ] + + var body: some View { + NavigationView { + SettingsView( + settingsDemomode: settingsDemomode, + settingsLocalUrl: settingsLocalUrl, + settingsRemoteUrl: settingsRemoteUrl, + settingsUsername: settingsUsername, + settingsPassword: settingsPassword, + settingsAlwaysSendCreds: settingsAlwaysSendCreds, + settingsIdleOff: settingsIdleOff, + settingsIgnoreSSL: settingsIgnoreSSL, + settingsRealTimeSliders: settingsRealTimeSliders, + settingsSendCrashReports: settingsSendCrashReports, + settingsIconType: settingsIconType, + settingsSortSitemapsBy: settingsSortSitemapsBy, + settingsDefaultMainUIPath: settingsDefaultMainUIPath, + settingsAlwaysAllowWebRTC: settingsAlwaysAllowWebRTC, + settingsSitemapForWatch: settingsSitemapForWatch + ) + } + } + } + return PreviewWrapper() +} diff --git a/openHAB/SettingsView/SitemapSettingsView.swift b/openHAB/SettingsView/SitemapSettingsView.swift new file mode 100644 index 000000000..ecf7c293c --- /dev/null +++ b/openHAB/SettingsView/SitemapSettingsView.swift @@ -0,0 +1,122 @@ +// Copyright (c) 2010-2025 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 + +// +// SitemapSettingsView.swift +// openHAB +// +// Created by Tim Müller-Seydlitz on 13.03.25. +// Copyright © 2025 openHAB e.V. All rights reserved. +// +import Kingfisher +import OpenHABCore +import os +import SwiftUI + +struct SitemapSettingsView: View { + @Binding var settingsRealTimeSliders: Bool + @Binding var settingsIconType: IconType + @Binding var settingsSortSitemapsBy: SortSitemapsOrder + @Binding var settingsSitemapForWatch: String + @Binding var sitemaps: [OpenHABSitemap] + + @State private var showingCacheAlert = false + private let logger = Logger(subsystem: "org.openhab.app", category: "SitemapSettingsView") + + var body: some View { + Section(header: Text(LocalizedStringKey("sitemap_settings"))) { + Toggle(isOn: $settingsRealTimeSliders) { + Text("Real-time Sliders") + } + + Button { + clearWebsiteCache() + showingCacheAlert = true + } label: { + NavigationLink("Clear Image Cache", destination: EmptyView()) + } + .foregroundColor(Color(uiColor: .label)) + .alert("cache_cleared", isPresented: $showingCacheAlert) { + Button("OK", role: .cancel) {} + } + + Picker(selection: $settingsIconType) { + ForEach(IconType.allCases, id: \.self) { icontype in + Text(verbatim: "\(icontype)").tag(icontype) + } + } label: { + Text("Icon Type") + } + + Picker(selection: $settingsSortSitemapsBy) { + ForEach(SortSitemapsOrder.allCases, id: \.self) { sortsitemaporder in + Text(verbatim: "\(sortsitemaporder)").tag(sortsitemaporder) + } + } label: { + Text("Sort sitemaps by") + } + + Picker(selection: $settingsSitemapForWatch) { + ForEach(sitemaps, id: \.name) { sitemap in + Text(sitemap.label) + } + } label: { + Text("Sitemap For Apple Watch") + } + } + } + + func clearWebsiteCache() { + logger.debug("Clearing image cache") + KingfisherManager.shared.cache.clearMemoryCache() + KingfisherManager.shared.cache.clearDiskCache() + KingfisherManager.shared.cache.cleanExpiredDiskCache() + } +} + +#Preview { + struct PreviewWrapper: View { + @State var realTimeSliders = true + @State var iconType: IconType = .png + @State var sortSitemapsBy: SortSitemapsOrder = .label + @State var sitemapForWatch = "Home" + @State var sitemaps: [OpenHABSitemap] = [ + OpenHABSitemap( + name: "home", + icon: "", + label: "Home", + link: "http://192.168.1.100/rest/sitemaps/home", + page: nil // Replace with actual OpenHABPage if needed + ), + OpenHABSitemap( + name: "office", + icon: "", + label: "Office", + link: "http://192.168.1.100/rest/sitemaps/office", + page: nil // Replace with actual OpenHABPage if needed + ) + ] + var body: some View { + NavigationView { + Form { + SitemapSettingsView( + settingsRealTimeSliders: $realTimeSliders, + settingsIconType: $iconType, + settingsSortSitemapsBy: $sortSitemapsBy, + settingsSitemapForWatch: $sitemapForWatch, + sitemaps: $sitemaps + ) + } + } + } + } + return PreviewWrapper() +} diff --git a/openHAB/SitemapSettingsView.swift b/openHAB/SitemapSettingsView.swift new file mode 100644 index 000000000..c1b5817d6 --- /dev/null +++ b/openHAB/SitemapSettingsView.swift @@ -0,0 +1,19 @@ +// +// SitemapSettingsView.swift +// openHAB +// +// Created by Tim Müller-Seydlitz on 13.03.25. +// Copyright © 2025 openHAB e.V. All rights reserved. +// + +import SwiftUI + +struct SitemapSettingsView: View { + var body: some View { + Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/) + } +} + +#Preview { + SitemapSettingsView() +} From ef5b20ce00c6f5fe65806ca9a5ed3f97354301b9 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Thu, 13 Mar 2025 23:51:30 +0100 Subject: [PATCH 074/476] Moved DebugSettingsView in project view Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- openHAB.xcodeproj/project.pbxproj | 2 +- openHAB/SettingsView/DebugSettingsView.swift | 28 ++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 openHAB/SettingsView/DebugSettingsView.swift diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index 2902b5b14..1219c9516 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -791,6 +791,7 @@ DA48001D2D837905009CF127 /* ApplicationSettingsView.swift */, DA5ED9BF2C8509C2004875E0 /* ClientCertificatesView.swift */, DA4800132D836892009CF127 /* ConnectionSettingsView.swift */, + DA4800192D83742A009CF127 /* DebugSettingsView.swift */, DA4800152D836EF0009CF127 /* MainUISettingsView.swift */, DA242C612C83588600AFB10D /* SettingsView.swift */, DA48001B2D837556009CF127 /* SitemapSettingsView.swift */, @@ -916,7 +917,6 @@ 1224F78B228A89E300750965 /* Watch */, DF4B84101886DA9900F34902 /* Widgets */, DFFD8FCE18EDBD30003B502A /* Util */, - DA4800192D83742A009CF127 /* DebugSettingsView.swift */, ); name = UI; sourceTree = ""; diff --git a/openHAB/SettingsView/DebugSettingsView.swift b/openHAB/SettingsView/DebugSettingsView.swift new file mode 100644 index 000000000..5bed342aa --- /dev/null +++ b/openHAB/SettingsView/DebugSettingsView.swift @@ -0,0 +1,28 @@ +// Copyright (c) 2010-2025 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 SwiftUI + +struct DebugSettingsView: View { + var body: some View { + Section(header: Text(LocalizedStringKey("debug"))) { + NavigationLink { + LoggerView() + } label: { + Text("Logs") + } + } + } +} + +#Preview { + DebugSettingsView() +} From 9434068ff965c3638df391aa15c13edb18151e16 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Thu, 13 Mar 2025 23:54:37 +0100 Subject: [PATCH 075/476] Cleanup for moved files Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- openHAB/AboutSettingsView.swift | 17 ---- openHAB/ApplicationSettingsView.swift | 71 ----------------- openHAB/ClientCertificatesView.swift | 35 --------- openHAB/ConnectionSettingsView.swift | 107 -------------------------- openHAB/DebugSettingsView.swift | 19 ----- openHAB/MainUISettingsView.swift | 19 ----- openHAB/SitemapSettingsView.swift | 19 ----- 7 files changed, 287 deletions(-) delete mode 100644 openHAB/AboutSettingsView.swift delete mode 100644 openHAB/ApplicationSettingsView.swift delete mode 100644 openHAB/ClientCertificatesView.swift delete mode 100644 openHAB/ConnectionSettingsView.swift delete mode 100644 openHAB/DebugSettingsView.swift delete mode 100644 openHAB/MainUISettingsView.swift delete mode 100644 openHAB/SitemapSettingsView.swift diff --git a/openHAB/AboutSettingsView.swift b/openHAB/AboutSettingsView.swift deleted file mode 100644 index 92f5a913e..000000000 --- a/openHAB/AboutSettingsView.swift +++ /dev/null @@ -1,17 +0,0 @@ -Section(header: Text(LocalizedStringKey("about_settings"))) { - LabeledContent("App Version", value: appVersion) - - NavigationLink { - RTFTextView(rtfFileName: "legal") - .navigationTitle("Legal") - .navigationBarTitleDisplayMode(.inline) - } label: { - Text("Legal") - } - - Button { - presentPrivacyPolicy() - } label: { - Text("privacy_policy") - } - } \ No newline at end of file diff --git a/openHAB/ApplicationSettingsView.swift b/openHAB/ApplicationSettingsView.swift deleted file mode 100644 index d2a1473a7..000000000 --- a/openHAB/ApplicationSettingsView.swift +++ /dev/null @@ -1,71 +0,0 @@ -struct ApplicationSettingsView: View { - @Binding var settingsIgnoreSSL: Bool - @Binding var settingsIdleOff: Bool - @Binding var settingsSendCrashReports: Bool - @Binding var showCrashReportingAlert: Bool - @Binding var hasBeenLoaded: Bool - - private let logger = Logger(subsystem: "org.openhab.app", category: "ApplicationSettingsView") - - var body: some View { - Section(header: Text(LocalizedStringKey("application_settings"))) { - Toggle(isOn: $settingsIgnoreSSL) { - Text("Ignore SSL certificates") - } - - Toggle(isOn: $settingsIdleOff) { - Text("Disable Idle Timeout") - } - - Toggle(isOn: $settingsSendCrashReports) { - Text("Crash Reporting") - } - - .onAppear { - // Setting .onAppear of view required here because onAppear of entire view is run after onChange is active - // when migrating to iOS17 this - settingsSendCrashReports = Preferences.sendCrashReports - // loadSitemaps() - hasBeenLoaded = true - } - .onChange(of: settingsSendCrashReports) { newValue in - logger.debug("Detected change on settingsSendCrashReports") - if newValue, hasBeenLoaded { - showCrashReportingAlert = true - } - } - .confirmationDialog( - "crash_reporting", - isPresented: $showCrashReportingAlert - ) { - Button(role: .destructive) { - settingsSendCrashReports = true - } label: { - Text(LocalizedStringKey("activate")) - } - Button(LocalizedStringKey("privacy_policy")) { - presentPrivacyPolicy() - settingsSendCrashReports = false - } - Button(role: .cancel) { - settingsSendCrashReports = false - } label: { - Text(LocalizedStringKey("cancel")) - } - } message: { - Text(LocalizedStringKey("crash_reporting_info")) - } - - NavigationLink { - ClientCertificatesView() - } label: { - Text("Client Certificates") - } - } - } - - func presentPrivacyPolicy() { - let vc = SFSafariViewController(url: .privacyPolicy) - UIApplication.shared.firstKeyWindow?.rootViewController?.present(vc, animated: true) - } -} \ No newline at end of file diff --git a/openHAB/ClientCertificatesView.swift b/openHAB/ClientCertificatesView.swift deleted file mode 100644 index 14c4f8b6e..000000000 --- a/openHAB/ClientCertificatesView.swift +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright (c) 2010-2025 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 SwiftUI - -struct ClientCertificatesView: View { - @StateObject private var viewModel = ClientCertificatesViewModel() - - var body: some View { - List { - ForEach(viewModel.clientCertificates.indices, id: \.self) { index in - Text(viewModel.getIdentityName(for: index)) - } - .onDelete { indices in - indices.forEach { viewModel.deleteCertificate(at: $0) } - } - } - .navigationTitle(Text(LocalizedStringKey("client_certificates"))) - .onAppear { - viewModel.loadCertificates() - } - } -} - -#Preview { - ClientCertificatesView() -} diff --git a/openHAB/ConnectionSettingsView.swift b/openHAB/ConnectionSettingsView.swift deleted file mode 100644 index 2ca2661b7..000000000 --- a/openHAB/ConnectionSettingsView.swift +++ /dev/null @@ -1,107 +0,0 @@ -// Copyright (c) 2010-2025 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 SwiftUI - -struct ConnectionSettingsView: View { - @Binding var settingsDemomode: Bool - @Binding var settingsLocalUrl: String - @Binding var settingsRemoteUrl: String - @Binding var settingsUsername: String - @Binding var settingsPassword: String - @Binding var settingsAlwaysSendCreds: Bool - - var body: some View { - Section(header: Text("OpenHAB Connection")) { - Toggle("Demo Mode", isOn: $settingsDemomode) - - if !settingsDemomode { - LabeledContent { - Spacer() - TextField( - "Local URL", - text: $settingsLocalUrl - ) - .fixedSize() - .keyboardType(.URL) - .textInputAutocapitalization(.never) - .disableAutocorrection(true) - .font(.system(.caption)) - } label: { - Text("Local URL") - if settingsLocalUrl.isEmpty { - Text("Enter URL of local server") - } - } - - LabeledContent { - Spacer() - TextField( - "Remote URL", - text: $settingsRemoteUrl - ) - .fixedSize() - .keyboardType(.URL) - .textInputAutocapitalization(.never) - .disableAutocorrection(true) - .font(.system(.caption)) - } label: { - Text("Remote URL") - if settingsRemoteUrl.isEmpty { - Text("Enter URL of remote server") - } - } - - LabeledContent { - TextField( - "Foo", - text: $settingsUsername - ) - .textContentType(.username) // Associates with AutoFill - .fixedSize() - .textInputAutocapitalization(.never) - .disableAutocorrection(true) - .font(.system(.caption)) - } label: { - Text("Username") - if settingsUsername.isEmpty { - Text("Enter username on server, if required") - } - } - - LabeledContent { - SecureField( - "1234", - text: $settingsPassword - ) - .fixedSize() - .textInputAutocapitalization(.never) - .disableAutocorrection(true) // or .autocorrectionDisabled(true) ?? - .font(.system(.caption)) - .textContentType(.password) // Associates with AutoFill - } label: { - Text("Password") - if settingsPassword.isEmpty { - Text("Enter password on server") - } - } - - Toggle(isOn: $settingsAlwaysSendCreds) { - Text("Always send credentials") - } - } - } - } -} - -#Preview { - ConnectionView() -} diff --git a/openHAB/DebugSettingsView.swift b/openHAB/DebugSettingsView.swift deleted file mode 100644 index 7534bdccf..000000000 --- a/openHAB/DebugSettingsView.swift +++ /dev/null @@ -1,19 +0,0 @@ -// -// DebugSettingsView.swift -// openHAB -// -// Created by Tim Müller-Seydlitz on 13.03.25. -// Copyright © 2025 openHAB e.V. All rights reserved. -// - -import SwiftUI - -struct DebugSettingsView: View { - var body: some View { - Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/) - } -} - -#Preview { - DebugSettingsView() -} diff --git a/openHAB/MainUISettingsView.swift b/openHAB/MainUISettingsView.swift deleted file mode 100644 index af2bd1d3c..000000000 --- a/openHAB/MainUISettingsView.swift +++ /dev/null @@ -1,19 +0,0 @@ -// -// MainUISettingsView.swift -// openHAB -// -// Created by Tim Müller-Seydlitz on 13.03.25. -// Copyright © 2025 openHAB e.V. All rights reserved. -// - -import SwiftUI - -struct MainUISettingsView: View { - var body: some View { - Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/) - } -} - -#Preview { - MainUISettingsView() -} diff --git a/openHAB/SitemapSettingsView.swift b/openHAB/SitemapSettingsView.swift deleted file mode 100644 index c1b5817d6..000000000 --- a/openHAB/SitemapSettingsView.swift +++ /dev/null @@ -1,19 +0,0 @@ -// -// SitemapSettingsView.swift -// openHAB -// -// Created by Tim Müller-Seydlitz on 13.03.25. -// Copyright © 2025 openHAB e.V. All rights reserved. -// - -import SwiftUI - -struct SitemapSettingsView: View { - var body: some View { - Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/) - } -} - -#Preview { - SitemapSettingsView() -} From c2a5fb7acc4dd59bc699a8ed189c0cf0078ee5ce Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Fri, 14 Mar 2025 08:39:38 +0100 Subject: [PATCH 076/476] Migrating OpenAPIService back to actor Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift index 2dd8462f6..5b8b3be04 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift @@ -29,7 +29,7 @@ public enum OpenAPIServiceConfiguration { // The generated OpenAPI client is wrapped by this curated API. // The library leaks the fact that it uses Swift OpenAPI Generator under the hood in 'openHABSitemapWidgetEvents'. // It will require the migration to Swift 6.1 before this can be changed. -public class OpenAPIService { +public actor OpenAPIService { private var client: any APIProtocol private var url: URL? private var longPolling = false From 062b2365fbc87153dc36580086d31be811c802e7 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Fri, 14 Mar 2025 08:40:51 +0100 Subject: [PATCH 077/476] Adding AnimatedSecureTextField for password entry Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- openHAB.xcodeproj/project.pbxproj | 4 ++ .../AnimatedSecureTextField.swift | 60 +++++++++++++++++++ .../SettingsView/ConnectionSettingsView.swift | 16 +++-- openHAB/SettingsView/MainUISettingsView.swift | 2 +- 4 files changed, 72 insertions(+), 10 deletions(-) create mode 100644 openHAB/SettingsView/AnimatedSecureTextField.swift diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index 1219c9516..8875d0233 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -97,6 +97,7 @@ DA48001A2D83742A009CF127 /* DebugSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4800192D83742A009CF127 /* DebugSettingsView.swift */; }; DA48001C2D837556009CF127 /* SitemapSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA48001B2D837556009CF127 /* SitemapSettingsView.swift */; }; DA48001E2D837905009CF127 /* ApplicationSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA48001D2D837905009CF127 /* ApplicationSettingsView.swift */; }; + DA4800212D839D3A009CF127 /* AnimatedSecureTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4800202D839D39009CF127 /* AnimatedSecureTextField.swift */; }; DA4D4DB5233F9ACB00B37E37 /* README.md in Resources */ = {isa = PBXBuildFile; fileRef = DA4D4DB4233F9ACB00B37E37 /* README.md */; }; DA50C7BD2B0A51BD0009F716 /* SliderWithSwitchSupportRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA50C7BC2B0A51BD0009F716 /* SliderWithSwitchSupportRow.swift */; }; DA50C7BF2B0A65300009F716 /* SliderWithSwitchSupportUITableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA50C7BE2B0A652F0009F716 /* SliderWithSwitchSupportUITableViewCell.swift */; }; @@ -406,6 +407,7 @@ DA4800192D83742A009CF127 /* DebugSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugSettingsView.swift; sourceTree = ""; }; DA48001B2D837556009CF127 /* SitemapSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SitemapSettingsView.swift; sourceTree = ""; }; DA48001D2D837905009CF127 /* ApplicationSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationSettingsView.swift; sourceTree = ""; }; + DA4800202D839D39009CF127 /* AnimatedSecureTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnimatedSecureTextField.swift; sourceTree = ""; }; DA4D4DB4233F9ACB00B37E37 /* README.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; DA4D4E0E2340A00200B37E37 /* Changes.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = Changes.md; sourceTree = ""; }; DA50C7BC2B0A51BD0009F716 /* SliderWithSwitchSupportRow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SliderWithSwitchSupportRow.swift; sourceTree = ""; }; @@ -791,6 +793,7 @@ DA48001D2D837905009CF127 /* ApplicationSettingsView.swift */, DA5ED9BF2C8509C2004875E0 /* ClientCertificatesView.swift */, DA4800132D836892009CF127 /* ConnectionSettingsView.swift */, + DA4800202D839D39009CF127 /* AnimatedSecureTextField.swift */, DA4800192D83742A009CF127 /* DebugSettingsView.swift */, DA4800152D836EF0009CF127 /* MainUISettingsView.swift */, DA242C612C83588600AFB10D /* SettingsView.swift */, @@ -1587,6 +1590,7 @@ DAA42BAA21DC983B00244B2A /* VideoUITableViewCell.swift in Sources */, DFB2623B18830A3600D3244D /* AppDelegate.swift in Sources */, DA6B2EF72C8B92E800DF77CF /* SelectionView.swift in Sources */, + DA4800212D839D3A009CF127 /* AnimatedSecureTextField.swift in Sources */, DF4B84041885A53700F34902 /* OpenHABDataObject.swift in Sources */, DAC65FC7236EDF3900F4501E /* SpinnerViewController.swift in Sources */, DA9F81872C85020F00B47B72 /* RTFTextView.swift in Sources */, diff --git a/openHAB/SettingsView/AnimatedSecureTextField.swift b/openHAB/SettingsView/AnimatedSecureTextField.swift new file mode 100644 index 000000000..8d898dac3 --- /dev/null +++ b/openHAB/SettingsView/AnimatedSecureTextField.swift @@ -0,0 +1,60 @@ +// Copyright (c) 2010-2025 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 SwiftUI + +struct AnimatedSecureTextField: View { + @Binding var text: String + @State var isSecure = true + var titleKey: String + var body: some View { + HStack(spacing: 4) { + Button { + isSecure = !isSecure + } label: { + Image(systemSymbol: isSecure ? .eyeSlash : .eyeFill) + .foregroundColor(.gray) + } + Spacer() + HStack { + if isSecure { + SecureField(titleKey, text: $text) + .textContentType(.password) + .multilineTextAlignment(.trailing) // Ensures text aligns to the right + + } else { + TextField(titleKey, text: $text) + .textContentType(.password) + .multilineTextAlignment(.trailing) // Ensures text aligns to the right + } + } + .frame(maxWidth: .infinity, alignment: .trailing) // Push to the right + } + .frame(maxWidth: .infinity) + .animation(.easeInOut(duration: 0.3), value: isSecure) + } +} + +#Preview { + struct PreviewWrapper: View { + @State private var password: String = "password12" + @State var isSecure = true + + var body: some View { + Form { + AnimatedSecureTextField(text: $password, titleKey: "Enter Password") + } + .padding() + } + } + + return PreviewWrapper() +} diff --git a/openHAB/SettingsView/ConnectionSettingsView.swift b/openHAB/SettingsView/ConnectionSettingsView.swift index 7a20f2ee9..864745dec 100644 --- a/openHAB/SettingsView/ConnectionSettingsView.swift +++ b/openHAB/SettingsView/ConnectionSettingsView.swift @@ -9,6 +9,7 @@ // // SPDX-License-Identifier: EPL-2.0 +import SFSafeSymbols import SwiftUI struct ConnectionSettingsView: View { @@ -78,15 +79,12 @@ struct ConnectionSettingsView: View { } LabeledContent { - SecureField( - "1234", - text: $settingsPassword - ) - .fixedSize() - .textInputAutocapitalization(.never) - .disableAutocorrection(true) // or .autocorrectionDisabled(true) ?? - .font(.system(.caption)) - .textContentType(.password) // Associates with AutoFill + AnimatedSecureTextField(text: $settingsPassword, titleKey: "password") + .fixedSize() + .textInputAutocapitalization(.never) + .disableAutocorrection(true) // or .autocorrectionDisabled(true) ?? + .font(.system(.caption)) + .textContentType(.password) // Associates with AutoFill } label: { Text("Password") if settingsPassword.isEmpty { diff --git a/openHAB/SettingsView/MainUISettingsView.swift b/openHAB/SettingsView/MainUISettingsView.swift index 09c8b7b5e..98d1b85fa 100644 --- a/openHAB/SettingsView/MainUISettingsView.swift +++ b/openHAB/SettingsView/MainUISettingsView.swift @@ -15,8 +15,8 @@ import WebKit struct MainUISettingsView: View { @Binding var settingsAlwaysAllowWebRTC: Bool @Binding var settingsDefaultMainUIPath: String - @State var showUselastPathAlert = false + @State var showUselastPathAlert = false @State var showingCacheAlert = false var appData: OpenHABDataObject? { From 452b0d343778d56adcc3e3182a7096e0018a4314 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Fri, 14 Mar 2025 10:34:42 +0100 Subject: [PATCH 078/476] Make all subviews of SettingsView previewable Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- openHAB/SettingsView/AboutSettingsView.swift | 15 +- .../ApplicationSettingsView.swift | 176 +++++++++++++----- openHAB/SettingsView/DebugSettingsView.swift | 6 +- openHAB/SettingsView/SettingsView.swift | 4 +- .../SettingsView/SitemapSettingsView.swift | 15 +- 5 files changed, 151 insertions(+), 65 deletions(-) diff --git a/openHAB/SettingsView/AboutSettingsView.swift b/openHAB/SettingsView/AboutSettingsView.swift index 5ff81c64a..ae140d5dc 100644 --- a/openHAB/SettingsView/AboutSettingsView.swift +++ b/openHAB/SettingsView/AboutSettingsView.swift @@ -9,13 +9,6 @@ // // SPDX-License-Identifier: EPL-2.0 -// -// AboutSettingsView.swift -// openHAB -// -// Created by Tim Müller-Seydlitz on 13.03.25. -// Copyright © 2025 openHAB e.V. All rights reserved. -// import SafariServices import SwiftUI @@ -51,3 +44,11 @@ struct AboutSettingsView: View { UIApplication.shared.firstKeyWindow?.rootViewController?.present(vc, animated: true) } } + +#Preview { + NavigationView { + Form { + AboutSettingsView() + } + } +} diff --git a/openHAB/SettingsView/ApplicationSettingsView.swift b/openHAB/SettingsView/ApplicationSettingsView.swift index 3f178a871..27fb813cb 100644 --- a/openHAB/SettingsView/ApplicationSettingsView.swift +++ b/openHAB/SettingsView/ApplicationSettingsView.swift @@ -14,6 +14,83 @@ import os import SafariServices import SwiftUI +// struct ApplicationSettingsView: View { +// @Binding var settingsIgnoreSSL: Bool +// @Binding var settingsIdleOff: Bool +// @Binding var settingsSendCrashReports: Bool +// +// @State var showCrashReportingAlert = false +// @State private var hasBeenLoaded = false +// +// private let logger = Logger(subsystem: "org.openhab.app", category: "ApplicationSettingsView") +// +// var body: some View { +// Section(header: Text(LocalizedStringKey("application_settings"))) { +// Toggle(isOn: $settingsIgnoreSSL) { +// Text("Ignore SSL certificates") +// } +// +// Toggle(isOn: $settingsIdleOff) { +// Text("Disable Idle Timeout") +// } +// +// Toggle(isOn: $settingsSendCrashReports) { +// Text("Crash Reporting") +// } +// +// .onAppear { +// // Setting .onAppear of view required here because onAppear of entire view is run after .onChange is active +// // when migrating to iOS17 this +// settingsSendCrashReports = Preferences.sendCrashReports +// // loadSitemaps() +// hasBeenLoaded = true +// } +// .onChange(of: settingsSendCrashReports) { newValue in +// if ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == nil { +// logger.debug("Detected change on settingsSendCrashReports") +// } +// if newValue, hasBeenLoaded { +// showCrashReportingAlert = true +// } +// } +// .confirmationDialog( +// "crash_reporting", +// isPresented: $showCrashReportingAlert +// ) { +// Button(role: .destructive) { +// settingsSendCrashReports = true +// } label: { +// Text(LocalizedStringKey("activate")) +// } +// Button(LocalizedStringKey("privacy_policy")) { +// presentPrivacyPolicy() +// settingsSendCrashReports = false +// } +// Button(role: .cancel) { +// settingsSendCrashReports = false +// } label: { +// Text(LocalizedStringKey("cancel")) +// } +// } message: { +// Text(LocalizedStringKey("crash_reporting_info")) +// } +// +// NavigationLink { +// ClientCertificatesView() +// } label: { +// Text("Client Certificates") +// } +// } +// } +// +// func presentPrivacyPolicy() { +// if ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == nil { +// let vc = SFSafariViewController(url: .privacyPolicy) +// UIApplication.shared.firstKeyWindow?.rootViewController?.present(vc, animated: true) +// } +// } +// } + struct ApplicationSettingsView: View { @Binding var settingsIgnoreSSL: Bool @Binding var settingsIdleOff: Bool @@ -26,57 +103,44 @@ struct ApplicationSettingsView: View { var body: some View { Section(header: Text(LocalizedStringKey("application_settings"))) { - Toggle(isOn: $settingsIgnoreSSL) { - Text("Ignore SSL certificates") - } - - Toggle(isOn: $settingsIdleOff) { - Text("Disable Idle Timeout") - } - - Toggle(isOn: $settingsSendCrashReports) { - Text("Crash Reporting") - } - - .onAppear { - // Setting .onAppear of view required here because onAppear of entire view is run after .onChange is active - // when migrating to iOS17 this - settingsSendCrashReports = Preferences.sendCrashReports - // loadSitemaps() - hasBeenLoaded = true - } - .onChange(of: settingsSendCrashReports) { newValue in - logger.debug("Detected change on settingsSendCrashReports") - if newValue, hasBeenLoaded { - showCrashReportingAlert = true + Toggle("Ignore SSL certificates", isOn: $settingsIgnoreSSL) + Toggle("Disable Idle Timeout", isOn: $settingsIdleOff) + Toggle("Crash Reporting", isOn: $settingsSendCrashReports) + .onAppear { + settingsSendCrashReports = Preferences.sendCrashReports } - } - .confirmationDialog( - "crash_reporting", - isPresented: $showCrashReportingAlert - ) { - Button(role: .destructive) { - settingsSendCrashReports = true - } label: { - Text(LocalizedStringKey("activate")) + .onChange(of: settingsSendCrashReports) { newValue in + #if !DEBUG + logger.debug("Detected change on settingsSendCrashReports") + #endif + if newValue, hasBeenLoaded { + showCrashReportingAlert = true + } } - Button(LocalizedStringKey("privacy_policy")) { - presentPrivacyPolicy() - settingsSendCrashReports = false + .confirmationDialog( + "crash_reporting", + isPresented: $showCrashReportingAlert + ) { + Button(role: .destructive) { + settingsSendCrashReports = true + } label: { + Text(LocalizedStringKey("activate")) + } + Button(LocalizedStringKey("privacy_policy")) { + presentPrivacyPolicy() + settingsSendCrashReports = false + } + Button(role: .cancel) { + settingsSendCrashReports = false + } label: { + Text(LocalizedStringKey("cancel")) + } + } message: { + Text(LocalizedStringKey("crash_reporting_info")) } - Button(role: .cancel) { - settingsSendCrashReports = false - } label: { - Text(LocalizedStringKey("cancel")) - } - } message: { - Text(LocalizedStringKey("crash_reporting_info")) - } - NavigationLink { + NavigationLink("Client Certificates") { ClientCertificatesView() - } label: { - Text("Client Certificates") } } } @@ -86,3 +150,23 @@ struct ApplicationSettingsView: View { UIApplication.shared.firstKeyWindow?.rootViewController?.present(vc, animated: true) } } + +#Preview { + struct PreviewWrapper: View { + @State private var ignoreSSL = true + @State private var idleOff = false + @State private var sendCrashReports = false + + var body: some View { + Form { + ApplicationSettingsView( + settingsIgnoreSSL: $ignoreSSL, + settingsIdleOff: $idleOff, + settingsSendCrashReports: $sendCrashReports + ) + } + } + } + + return PreviewWrapper() +} diff --git a/openHAB/SettingsView/DebugSettingsView.swift b/openHAB/SettingsView/DebugSettingsView.swift index 5bed342aa..7f3757a93 100644 --- a/openHAB/SettingsView/DebugSettingsView.swift +++ b/openHAB/SettingsView/DebugSettingsView.swift @@ -24,5 +24,9 @@ struct DebugSettingsView: View { } #Preview { - DebugSettingsView() + NavigationView { + Form { + DebugSettingsView() + } + } } diff --git a/openHAB/SettingsView/SettingsView.swift b/openHAB/SettingsView/SettingsView.swift index d87aeaf19..f6956e486 100644 --- a/openHAB/SettingsView/SettingsView.swift +++ b/openHAB/SettingsView/SettingsView.swift @@ -95,11 +95,13 @@ struct SettingsView: View { } .onAppear { loadSettings() - logger.debug("Loading Settings") } } func loadSettings() { + #if !DEBUG + logger.debug("Loading Settings") + #endif settingsLocalUrl = Preferences.localUrl settingsRemoteUrl = Preferences.remoteUrl settingsUsername = Preferences.username diff --git a/openHAB/SettingsView/SitemapSettingsView.swift b/openHAB/SettingsView/SitemapSettingsView.swift index ecf7c293c..eac507edb 100644 --- a/openHAB/SettingsView/SitemapSettingsView.swift +++ b/openHAB/SettingsView/SitemapSettingsView.swift @@ -9,13 +9,6 @@ // // SPDX-License-Identifier: EPL-2.0 -// -// SitemapSettingsView.swift -// openHAB -// -// Created by Tim Müller-Seydlitz on 13.03.25. -// Copyright © 2025 openHAB e.V. All rights reserved. -// import Kingfisher import OpenHABCore import os @@ -75,7 +68,9 @@ struct SitemapSettingsView: View { } func clearWebsiteCache() { + #if !DEBUG logger.debug("Clearing image cache") + #endif KingfisherManager.shared.cache.clearMemoryCache() KingfisherManager.shared.cache.clearDiskCache() KingfisherManager.shared.cache.cleanExpiredDiskCache() @@ -85,7 +80,7 @@ struct SitemapSettingsView: View { #Preview { struct PreviewWrapper: View { @State var realTimeSliders = true - @State var iconType: IconType = .png + @State var iconType: IconType = .svg @State var sortSitemapsBy: SortSitemapsOrder = .label @State var sitemapForWatch = "Home" @State var sitemaps: [OpenHABSitemap] = [ @@ -94,14 +89,14 @@ struct SitemapSettingsView: View { icon: "", label: "Home", link: "http://192.168.1.100/rest/sitemaps/home", - page: nil // Replace with actual OpenHABPage if needed + page: nil ), OpenHABSitemap( name: "office", icon: "", label: "Office", link: "http://192.168.1.100/rest/sitemaps/office", - page: nil // Replace with actual OpenHABPage if needed + page: nil ) ] var body: some View { From 3e614dd20747f1dc6f69e21175fd801cce9792a7 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Fri, 14 Mar 2025 17:01:02 +0100 Subject: [PATCH 079/476] Continue preparation for swift 6 Migrate WebUITableViewCel to async protocol conformance of WKNavigationDelegate, WKUIDelegate Migrate OpenHABItem, OpenHABOptions, OpenHABStateDescription to struct Prepare OSLogExtension Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- OpenHABCore/Package.swift | 3 +- .../OpenHABCore/Model/OpenHABItem.swift | 6 +-- .../OpenHABCore/Model/OpenHABOptions.swift | 4 +- .../Model/OpenHABStateDescription.swift | 4 +- .../OpenHABCore/Model/OpenHABWidget.swift | 19 --------- .../OpenHABCore/Util/OSLogExtension.swift | 2 +- openHAB.xcodeproj/project.pbxproj | 8 ++-- openHAB/GenericUITableViewCell.swift | 1 + openHAB/OpenHABSitemapViewController.swift | 42 ++++++++++--------- openHAB/WebUITableViewCell.swift | 21 ++++++---- 10 files changed, 49 insertions(+), 61 deletions(-) diff --git a/OpenHABCore/Package.swift b/OpenHABCore/Package.swift index bea4ca7f2..a31bf9485 100644 --- a/OpenHABCore/Package.swift +++ b/OpenHABCore/Package.swift @@ -32,7 +32,8 @@ let package = Package( .product(name: "HTTPTypes", package: "swift-http-types") // ✅ From `swift-http-types` ], swiftSettings: [ - .enableUpcomingFeature("BareSlashRegexLiterals") + .enableUpcomingFeature("BareSlashRegexLiterals"), + .enableExperimentalFeature("StrictConcurrency") // , .unsafeFlags(["-strict-concurrency=targeted"]) ] ), diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABItem.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABItem.swift index 628d3d2e0..95fab8adc 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABItem.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABItem.swift @@ -13,8 +13,8 @@ import CoreLocation import os.log import UIKit -public class OpenHABItem: NSObject { - public enum ItemType: String { +public struct OpenHABItem: Sendable { + public enum ItemType: String, Sendable { case color = "Color" case contact = "Contact" case dateTime = "DateTime" @@ -149,7 +149,7 @@ public extension OpenHABItem.CodingData { } extension OpenHABItem { - convenience init?(_ item: Components.Schemas.EnrichedItemDTO?) { + init?(_ item: Components.Schemas.EnrichedItemDTO?) { if let item { // swiftlint:disable:next line_length self.init(name: item.name.orEmpty, type: item._type.orEmpty, state: item.state.orEmpty, link: item.link.orEmpty, label: item.label.orEmpty, groupType: nil, stateDescription: OpenHABStateDescription(item.stateDescription), commandDescription: OpenHABCommandDescription(item.commandDescription), members: [], category: item.category, options: []) diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABOptions.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABOptions.swift index a07383456..710239ea9 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABOptions.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABOptions.swift @@ -11,7 +11,7 @@ import Foundation -public class OpenHABOptions: Decodable { +public struct OpenHABOptions: Decodable, Sendable { public var value = "" public var label = "" @@ -22,7 +22,7 @@ public class OpenHABOptions: Decodable { } extension OpenHABOptions { - convenience init?(_ options: Components.Schemas.StateOption?) { + init?(_ options: Components.Schemas.StateOption?) { if let options { self.init(value: options.value.orEmpty, label: options.label.orEmpty) } else { diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABStateDescription.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABStateDescription.swift index a8873d621..b3687383e 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABStateDescription.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABStateDescription.swift @@ -11,7 +11,7 @@ import Foundation -public class OpenHABStateDescription { +public struct OpenHABStateDescription: Sendable { public var minimum = 0.0 public var maximum = 100.0 public var step = 1.0 @@ -61,7 +61,7 @@ extension OpenHABStateDescription.CodingData { } extension OpenHABStateDescription { - convenience init?(_ state: Components.Schemas.StateDescription?) { + init?(_ state: Components.Schemas.StateDescription?) { if let state { self.init(minimum: state.minimum, maximum: state.maximum, step: state.step, readOnly: state.readOnly, options: state.options?.compactMap { OpenHABOptions($0) }, pattern: state.pattern) } else { diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift index a9a35ef8a..5f5d1639e 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift @@ -389,22 +389,3 @@ extension OpenHABWidget { ) } } - -extension OpenHABWidget { - func update(with event: OpenHABSitemapWidgetEvent) { - state = event.state ?? state - icon = event.icon ?? icon - label = event.label ?? label - iconColor = event.iconcolor ?? "" - labelcolor = event.labelcolor ?? "" - valuecolor = event.valuecolor ?? "" - visibility = event.visibility ?? visibility - - if let enrichedItem = event.enrichedItem { - if let link = item?.link { - enrichedItem.link = link - } - item = enrichedItem - } - } -} diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OSLogExtension.swift b/OpenHABCore/Sources/OpenHABCore/Util/OSLogExtension.swift index 18b654fd8..3e346c13b 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/OSLogExtension.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OSLogExtension.swift @@ -16,7 +16,7 @@ import Foundation import os.log public extension OSLog { - private static var subsystem = Bundle.main.bundleIdentifier! + private static let subsystem = Bundle.main.bundleIdentifier ?? "org.openhab.app" /// Logs the view cycles like viewDidLoad. static let viewCycle = OSLog(subsystem: subsystem, category: "viewcycle") diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index 8875d0233..ef95c8e24 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -906,17 +906,17 @@ DF4B83FD18857FA100F34902 /* UI */ = { isa = PBXGroup; children = ( + DA6B2EEE2C861BC900DF77CF /* DrawerView.swift */, DA4642312D7EE6CA006C3908 /* LoggerView.swift */, - DA48001F2D837CD8009CF127 /* SettingsView */, + DA6B2EF02C87B59000DF77CF /* NotificationsView.swift */, 653B54C1285E714900298ECD /* OpenHABViewController.swift */, 653B54BF285C0AC700298ECD /* OpenHABRootViewController.swift */, 65570A7C2476D16A00D524EA /* OpenHABWebViewController.swift */, DFB2624318830A3600D3244D /* OpenHABSitemapViewController.swift */, + DA9F81862C85020F00B47B72 /* RTFTextView.swift */, DAC65FC6236EDF3900F4501E /* SpinnerViewController.swift */, DA6B2EF62C8B92E800DF77CF /* SelectionView.swift */, - DA9F81862C85020F00B47B72 /* RTFTextView.swift */, - DA6B2EF02C87B59000DF77CF /* NotificationsView.swift */, - DA6B2EEE2C861BC900DF77CF /* DrawerView.swift */, + DA48001F2D837CD8009CF127 /* SettingsView */, 1224F78B228A89E300750965 /* Watch */, DF4B84101886DA9900F34902 /* Widgets */, DFFD8FCE18EDBD30003B502A /* Util */, diff --git a/openHAB/GenericUITableViewCell.swift b/openHAB/GenericUITableViewCell.swift index a11b85bc8..e6111d825 100644 --- a/openHAB/GenericUITableViewCell.swift +++ b/openHAB/GenericUITableViewCell.swift @@ -17,6 +17,7 @@ protocol GenericCellCacheProtocol: UITableViewCell { func invalidateCache() } +@MainActor protocol GenericUITableViewCellTouchEventDelegate: AnyObject { func touchDown() func touchUp() diff --git a/openHAB/OpenHABSitemapViewController.swift b/openHAB/OpenHABSitemapViewController.swift index f4420d0b5..6b38191c4 100644 --- a/openHAB/OpenHABSitemapViewController.swift +++ b/openHAB/OpenHABSitemapViewController.swift @@ -99,7 +99,7 @@ actor PageLoader { } // swiftlint:disable type_body_length -class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCellTouchEventDelegate { +class OpenHABSitemapViewController: OpenHABViewController { var pageUrl = "" private var selectedWidgetRow: Int = 0 private var iconType: IconType = .png @@ -364,12 +364,22 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel activeTasks.removeAll() } - /// Implementation of GenericUITableViewCellTouchEventDelegate + override func reloadView() { + defaultSitemap = Preferences.defaultSitemap + logger.debug("Reload view") + selectSitemap() + } + + override func viewName() -> String { + "sitemap" + } +} + +extension OpenHABSitemapViewController: GenericUITableViewCellTouchEventDelegate { func touchDown() { isUserInteracting = true } - /// Implementation of GenericUITableViewCellTouchEventDelegate func touchUp() { isUserInteracting = false if isWaitingToReload { @@ -378,7 +388,9 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel } isWaitingToReload = false } +} +extension OpenHABSitemapViewController { func configureTableView() { widgetTableView.dataSource = self widgetTableView.delegate = self @@ -704,16 +716,6 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel func sendCommand(itemname: String, command: String) { Task { try await openAPIService?.sendItemCommand(itemname: itemname, command: command) } } - - override func reloadView() { - defaultSitemap = Preferences.defaultSitemap - logger.debug("Reload view") - selectSitemap() - } - - override func viewName() -> String { - "sitemap" - } } // swiftlint:enable type_body_length @@ -1062,18 +1064,18 @@ extension OpenHABSitemapViewController: UITextFieldDelegate { extension OpenHABSitemapViewController: AuthenticationChallengeResponsible { // sessionDelegate.onReceiveSessionTaskChallenge - func downloader(_ downloader: ImageDownloader, - task: URLSessionTask, - didReceive challenge: URLAuthenticationChallenge, - completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { + nonisolated func downloader(_ downloader: ImageDownloader, + task: URLSessionTask, + didReceive challenge: URLAuthenticationChallenge, + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { let (disposition, credential) = onReceiveSessionTaskChallenge(with: challenge) completionHandler(disposition, credential) } // sessionDelegate.onReceiveSessionChallenge - func downloader(_ downloader: ImageDownloader, - didReceive challenge: URLAuthenticationChallenge, - completionHandler: (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { + nonisolated func downloader(_ downloader: ImageDownloader, + didReceive challenge: URLAuthenticationChallenge, + completionHandler: (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { let (disposition, credential) = onReceiveSessionChallenge(with: challenge) completionHandler(disposition, credential) } diff --git a/openHAB/WebUITableViewCell.swift b/openHAB/WebUITableViewCell.swift index 0ddbd9146..007f97953 100644 --- a/openHAB/WebUITableViewCell.swift +++ b/openHAB/WebUITableViewCell.swift @@ -41,8 +41,10 @@ class WebUITableViewCell: GenericUITableViewCell { override func awakeFromNib() { super.awakeFromNib() - widgetWebView.navigationDelegate = self - widgetWebView.uiDelegate = self + MainActor.assumeIsolated { // See explanation https://www.massicotte.org/awakefromnib + widgetWebView.navigationDelegate = self + widgetWebView.uiDelegate = self + } } override func displayWidget() { @@ -86,12 +88,12 @@ extension WebUITableViewCell: WKNavigationDelegate { os_log("webview finished load with URL: %{PUBLIC}s", log: .viewCycle, type: .info, widget.url) } - func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse, decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) { + func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse) async -> WKNavigationResponsePolicy { if let response = navigationResponse.response as? HTTPURLResponse, response.statusCode >= 400 { os_log("webview failed with status code: %{PUBLIC}i", log: .urlComposition, type: .debug, response.statusCode) url = nil } - decisionHandler(.allow) + return .allow } func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { @@ -104,19 +106,20 @@ extension WebUITableViewCell: WKNavigationDelegate { url = nil } - func webView(_ webView: WKWebView, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { - let (disposition, credential) = onReceiveSessionChallenge(with: challenge) - completionHandler(disposition, credential) + // Signature changed on transfer from completion handler to async / from didRecieve to respondTo + func webView(_ webView: WKWebView, + respondTo challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { + onReceiveSessionChallenge(with: challenge) } } extension WebUITableViewCell: WKUIDelegate { - @available(iOS 15, *) + // Matches https://developer.apple.com/documentation/webkit/wkuidelegate/webview(_:requestdeviceorientationandmotionpermissionfor:initiatedbyframe:decisionhandler:) func webView(_ webView: WKWebView, requestMediaCapturePermissionFor origin: WKSecurityOrigin, initiatedByFrame frame: WKFrameInfo, type: WKMediaCaptureType, - decisionHandler: @escaping (WKPermissionDecision) -> Void) { + decisionHandler: @escaping @MainActor (WKPermissionDecision) -> Void) { decisionHandler(.grant) } } From 0e91e842c4c26f5c3665e7f88dabef446913baec Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Fri, 14 Mar 2025 17:04:33 +0100 Subject: [PATCH 080/476] Migrate OpenHABItemNames getItemNames, reload to return [String] to make the result Sendable Migrate Intent Handlers to changed interface Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../OpenHABCore/Util/OpenHABItemCache.swift | 8 ++++---- .../OpenHABCore/Util/Preferences.swift | 2 +- .../GetItemStateIntentHandler.swift | 20 +++++++++++-------- .../SetColorValueIntentHandler.swift | 20 +++++++++++-------- .../SetContactStateValueIntentHandler.swift | 20 +++++++++++-------- .../SetDimmerRollerValueIntentHandler.swift | 20 +++++++++++-------- .../SetNumberValueIntentHandler.swift | 20 +++++++++++-------- .../SetStringValueIntentHandler.swift | 20 +++++++++++-------- .../SetSwitchStateIntentHandler.swift | 20 +++++++++++-------- 9 files changed, 89 insertions(+), 61 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift index 451138260..16007bdc8 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift @@ -13,7 +13,7 @@ import Combine import Foundation import os.log -public class OpenHABItemCache { +public actor OpenHABItemCache { public static let instance = OpenHABItemCache() public var items: [OpenHABItem]? var cancellables = Set() @@ -41,7 +41,7 @@ public class OpenHABItemCache { ) } - public func getItemNames(searchTerm: String?, types: [OpenHABItem.ItemType]?) -> [NSString] { + public func getItemNames(searchTerm: String?, types: [OpenHABItem.ItemType]?) -> [String] { guard let items else { return [] } @@ -52,7 +52,7 @@ public class OpenHABItemCache { (types == nil || ($0.type != nil && types!.contains($0.type!))) } .sorted(by: \.name) - .map { NSString(string: $0.name) } + .map(\.name) } public func getItem(name: String) async -> OpenHABItem? { @@ -84,7 +84,7 @@ public class OpenHABItemCache { } } - public func reload(searchTerm: String?, types: [OpenHABItem.ItemType]?) async -> [NSString] { + public func reload(searchTerm: String?, types: [OpenHABItem.ItemType]?) async -> [String] { os_log("OpenHABItemCache Loading items ") lastLoad = Date().timeIntervalSince1970 diff --git a/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift index 371f39da7..ab9596545 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift @@ -93,7 +93,7 @@ public struct UserDefaultURL { } public enum Preferences { - fileprivate static let sharedDefaults = UserDefaults(suiteName: "group.org.openhab.app")! + static let sharedDefaults = UserDefaults(suiteName: "group.org.openhab.app")! // MARK: - Public diff --git a/openHABIntents/GetItemStateIntentHandler.swift b/openHABIntents/GetItemStateIntentHandler.swift index 6bb32b2db..51674ae41 100644 --- a/openHABIntents/GetItemStateIntentHandler.swift +++ b/openHABIntents/GetItemStateIntentHandler.swift @@ -16,17 +16,21 @@ import os.log class GetItemStateIntentHandler: NSObject, OpenHABGetItemStateIntentHandling { func provideItemOptionsCollection(for intent: OpenHABGetItemStateIntent, searchTerm: String?, with completion: @escaping (INObjectCollection?, Error?) -> Void) { - let items = OpenHABItemCache.instance.getItemNames(searchTerm: searchTerm, types: nil) - let retItems = INObjectCollection(items: items) - // Call the completion handler, passing the collection. - completion(retItems, nil) + Task { + let items = await OpenHABItemCache.instance.getItemNames(searchTerm: searchTerm, types: nil).map(NSString.init) + let retItems = INObjectCollection(items: items) + // Call the completion handler, passing the collection. + completion(retItems, nil) + } } func provideItemOptionsCollection(for intent: OpenHABGetItemStateIntent, with completion: @escaping (INObjectCollection?, Error?) -> Void) { - let items = OpenHABItemCache.instance.getItemNames(searchTerm: nil, types: nil) - let retItems = INObjectCollection(items: items) - // Call the completion handler, passing the collection. - completion(retItems, nil) + Task { + let items = await OpenHABItemCache.instance.getItemNames(searchTerm: nil, types: nil).map(NSString.init) + let retItems = INObjectCollection(items: items) + // Call the completion handler, passing the collection. + completion(retItems, nil) + } } func confirm(intent: OpenHABGetItemStateIntent, completion: @escaping (OpenHABGetItemStateIntentResponse) -> Void) { diff --git a/openHABIntents/SetColorValueIntentHandler.swift b/openHABIntents/SetColorValueIntentHandler.swift index da464316a..5ef06fa06 100644 --- a/openHABIntents/SetColorValueIntentHandler.swift +++ b/openHABIntents/SetColorValueIntentHandler.swift @@ -16,17 +16,21 @@ import os.log class SetColorValueIntentHandler: NSObject, OpenHABSetColorValueIntentHandling { func provideItemOptionsCollection(for intent: OpenHABSetColorValueIntent, searchTerm: String?, with completion: @escaping (INObjectCollection?, Error?) -> Void) { - let items = OpenHABItemCache.instance.getItemNames(searchTerm: searchTerm, types: [OpenHABItem.ItemType.color]) - let retItems = INObjectCollection(items: items) - // Call the completion handler, passing the collection. - completion(retItems, nil) + Task { + let items = await OpenHABItemCache.instance.getItemNames(searchTerm: searchTerm, types: [OpenHABItem.ItemType.color]).map(NSString.init) + let retItems = INObjectCollection(items: items) + // Call the completion handler, passing the collection. + completion(retItems, nil) + } } func provideItemOptionsCollection(for intent: OpenHABSetColorValueIntent, with completion: @escaping (INObjectCollection?, Error?) -> Void) { - let items = OpenHABItemCache.instance.getItemNames(searchTerm: nil, types: [OpenHABItem.ItemType.color]) - let retItems = INObjectCollection(items: items) - // Call the completion handler, passing the collection. - completion(retItems, nil) + Task { + let items = await OpenHABItemCache.instance.getItemNames(searchTerm: nil, types: [OpenHABItem.ItemType.color]).map(NSString.init) + let retItems = INObjectCollection(items: items) + // Call the completion handler, passing the collection. + completion(retItems, nil) + } } func confirm(intent: OpenHABSetColorValueIntent, completion: @escaping (OpenHABSetColorValueIntentResponse) -> Void) { diff --git a/openHABIntents/SetContactStateValueIntentHandler.swift b/openHABIntents/SetContactStateValueIntentHandler.swift index 264e1dd0c..306d818c0 100644 --- a/openHABIntents/SetContactStateValueIntentHandler.swift +++ b/openHABIntents/SetContactStateValueIntentHandler.swift @@ -28,17 +28,21 @@ class SetContactStateValueIntentHandler: NSObject, OpenHABSetContactStateValueIn } func provideItemOptionsCollection(for intent: OpenHABSetContactStateValueIntent, searchTerm: String?, with completion: @escaping (INObjectCollection?, Error?) -> Void) { - let items = OpenHABItemCache.instance.getItemNames(searchTerm: searchTerm, types: [OpenHABItem.ItemType.contact]) - let retItems = INObjectCollection(items: items) - // Call the completion handler, passing the collection. - completion(retItems, nil) + Task { + let items = await OpenHABItemCache.instance.getItemNames(searchTerm: searchTerm, types: [OpenHABItem.ItemType.contact]).map(NSString.init) + let retItems = INObjectCollection(items: items) + // Call the completion handler, passing the collection. + completion(retItems, nil) + } } func provideItemOptionsCollection(for intent: OpenHABSetContactStateValueIntent, with completion: @escaping (INObjectCollection?, Error?) -> Void) { - let items = OpenHABItemCache.instance.getItemNames(searchTerm: nil, types: [OpenHABItem.ItemType.contact]) - let retItems = INObjectCollection(items: items) - // Call the completion handler, passing the collection. - completion(retItems, nil) + Task { + let items = await OpenHABItemCache.instance.getItemNames(searchTerm: nil, types: [OpenHABItem.ItemType.contact]).map(NSString.init) + let retItems = INObjectCollection(items: items) + // Call the completion handler, passing the collection. + completion(retItems, nil) + } } func confirm(intent: OpenHABSetContactStateValueIntent, completion: @escaping (OpenHABSetContactStateValueIntentResponse) -> Void) { diff --git a/openHABIntents/SetDimmerRollerValueIntentHandler.swift b/openHABIntents/SetDimmerRollerValueIntentHandler.swift index d40edfcb1..aae58d57f 100644 --- a/openHABIntents/SetDimmerRollerValueIntentHandler.swift +++ b/openHABIntents/SetDimmerRollerValueIntentHandler.swift @@ -16,17 +16,21 @@ import os.log class SetDimmerRollerValueIntentHandler: NSObject, OpenHABSetDimmerRollerValueIntentHandling { func provideItemOptionsCollection(for intent: OpenHABSetDimmerRollerValueIntent, searchTerm: String?, with completion: @escaping (INObjectCollection?, Error?) -> Void) { - let items = OpenHABItemCache.instance.getItemNames(searchTerm: searchTerm, types: [OpenHABItem.ItemType.dimmer, OpenHABItem.ItemType.rollershutter]) - let retItems = INObjectCollection(items: items) - // Call the completion handler, passing the collection. - completion(retItems, nil) + Task { + let items = await OpenHABItemCache.instance.getItemNames(searchTerm: searchTerm, types: [OpenHABItem.ItemType.dimmer, OpenHABItem.ItemType.rollershutter]).map(NSString.init) + let retItems = INObjectCollection(items: items) + // Call the completion handler, passing the collection. + completion(retItems, nil) + } } func provideItemOptionsCollection(for intent: OpenHABSetDimmerRollerValueIntent, with completion: @escaping (INObjectCollection?, Error?) -> Void) { - let items = OpenHABItemCache.instance.getItemNames(searchTerm: nil, types: [OpenHABItem.ItemType.dimmer, OpenHABItem.ItemType.rollershutter]) - let retItems = INObjectCollection(items: items) - // Call the completion handler, passing the collection. - completion(retItems, nil) + Task { + let items = await OpenHABItemCache.instance.getItemNames(searchTerm: nil, types: [OpenHABItem.ItemType.dimmer, OpenHABItem.ItemType.rollershutter]).map(NSString.init) + let retItems = INObjectCollection(items: items) + // Call the completion handler, passing the collection. + completion(retItems, nil) + } } func confirm(intent: OpenHABSetDimmerRollerValueIntent, completion: @escaping (OpenHABSetDimmerRollerValueIntentResponse) -> Void) { diff --git a/openHABIntents/SetNumberValueIntentHandler.swift b/openHABIntents/SetNumberValueIntentHandler.swift index 017f8a65b..1e999ef59 100644 --- a/openHABIntents/SetNumberValueIntentHandler.swift +++ b/openHABIntents/SetNumberValueIntentHandler.swift @@ -16,17 +16,21 @@ import os.log class SetNumberValueIntentHandler: NSObject, OpenHABSetNumberValueIntentHandling { func provideItemOptionsCollection(for intent: OpenHABSetNumberValueIntent, searchTerm: String?, with completion: @escaping (INObjectCollection?, Error?) -> Void) { - let items = OpenHABItemCache.instance.getItemNames(searchTerm: searchTerm, types: [OpenHABItem.ItemType.number]) - let retItems = INObjectCollection(items: items) - // Call the completion handler, passing the collection. - completion(retItems, nil) + Task { + let items = await OpenHABItemCache.instance.getItemNames(searchTerm: searchTerm, types: [OpenHABItem.ItemType.number]).map(NSString.init) + let retItems = INObjectCollection(items: items) + // Call the completion handler, passing the collection. + completion(retItems, nil) + } } func provideItemOptionsCollection(for intent: OpenHABSetNumberValueIntent, with completion: @escaping (INObjectCollection?, Error?) -> Void) { - let items = OpenHABItemCache.instance.getItemNames(searchTerm: nil, types: [OpenHABItem.ItemType.number]) - let retItems = INObjectCollection(items: items) - // Call the completion handler, passing the collection. - completion(retItems, nil) + Task { + let items = await OpenHABItemCache.instance.getItemNames(searchTerm: nil, types: [OpenHABItem.ItemType.number]).map(NSString.init) + let retItems = INObjectCollection(items: items) + // Call the completion handler, passing the collection. + completion(retItems, nil) + } } func confirm(intent: OpenHABSetNumberValueIntent, completion: @escaping (OpenHABSetNumberValueIntentResponse) -> Void) { diff --git a/openHABIntents/SetStringValueIntentHandler.swift b/openHABIntents/SetStringValueIntentHandler.swift index 7524bd7ad..b08c78dea 100644 --- a/openHABIntents/SetStringValueIntentHandler.swift +++ b/openHABIntents/SetStringValueIntentHandler.swift @@ -16,17 +16,21 @@ import os.log class SetStringValueIntentHandler: NSObject, OpenHABSetStringValueIntentHandling { func provideItemOptionsCollection(for intent: OpenHABSetStringValueIntent, searchTerm: String?, with completion: @escaping (INObjectCollection?, Error?) -> Void) { - let items = OpenHABItemCache.instance.getItemNames(searchTerm: searchTerm, types: [OpenHABItem.ItemType.stringItem]) - let retItems = INObjectCollection(items: items) - // Call the completion handler, passing the collection. - completion(retItems, nil) + Task { + let items = await OpenHABItemCache.instance.getItemNames(searchTerm: searchTerm, types: [OpenHABItem.ItemType.stringItem]).map(NSString.init) + let retItems = INObjectCollection(items: items) + // Call the completion handler, passing the collection. + completion(retItems, nil) + } } func provideItemOptionsCollection(for intent: OpenHABSetStringValueIntent, with completion: @escaping (INObjectCollection?, Error?) -> Void) { - let items = OpenHABItemCache.instance.getItemNames(searchTerm: nil, types: [OpenHABItem.ItemType.stringItem]) - let retItems = INObjectCollection(items: items) - // Call the completion handler, passing the collection. - completion(retItems, nil) + Task { + let items = await OpenHABItemCache.instance.getItemNames(searchTerm: nil, types: [OpenHABItem.ItemType.stringItem]).map(NSString.init) + let retItems = INObjectCollection(items: items) + // Call the completion handler, passing the collection. + completion(retItems, nil) + } } func confirm(intent: OpenHABSetStringValueIntent, completion: @escaping (OpenHABSetStringValueIntentResponse) -> Void) { diff --git a/openHABIntents/SetSwitchStateIntentHandler.swift b/openHABIntents/SetSwitchStateIntentHandler.swift index c407f8e79..d89d51e1b 100644 --- a/openHABIntents/SetSwitchStateIntentHandler.swift +++ b/openHABIntents/SetSwitchStateIntentHandler.swift @@ -28,17 +28,21 @@ class SetSwitchStateIntentHandler: NSObject, OpenHABSetSwitchStateIntentHandling } func provideItemOptionsCollection(for intent: OpenHABSetSwitchStateIntent, searchTerm: String?, with completion: @escaping (INObjectCollection?, Error?) -> Void) { - let items = OpenHABItemCache.instance.getItemNames(searchTerm: searchTerm, types: [OpenHABItem.ItemType.switchItem]) - let retItems = INObjectCollection(items: items) - // Call the completion handler, passing the collection. - completion(retItems, nil) + Task { + let items = await OpenHABItemCache.instance.getItemNames(searchTerm: searchTerm, types: [OpenHABItem.ItemType.switchItem]).map(NSString.init) + let retItems = INObjectCollection(items: items) + // Call the completion handler, passing the collection. + completion(retItems, nil) + } } func provideItemOptionsCollection(for intent: OpenHABSetSwitchStateIntent, with completion: @escaping (INObjectCollection?, Error?) -> Void) { - let items = OpenHABItemCache.instance.getItemNames(searchTerm: nil, types: [OpenHABItem.ItemType.switchItem]) - let retItems = INObjectCollection(items: items) - // Call the completion handler, passing the collection. - completion(retItems, nil) + Task { + let items = await OpenHABItemCache.instance.getItemNames(searchTerm: nil, types: [OpenHABItem.ItemType.switchItem]).map(NSString.init) + let retItems = INObjectCollection(items: items) + // Call the completion handler, passing the collection. + completion(retItems, nil) + } } func confirm(intent: OpenHABSetSwitchStateIntent, completion: @escaping (OpenHABSetSwitchStateIntentResponse) -> Void) { From b1cb6d812a3d9a0eb7c66986741b7365ef3362e1 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Fri, 14 Mar 2025 17:50:20 +0100 Subject: [PATCH 081/476] Remove UnknownCaseRepresentable because we don't do Decoding anymor / Mapping to openapi is anyway a manual process Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../OpenHABCore/Model/OpenHABWidget.swift | 13 ++++------ .../Util/UnknownCaseRepresentable.swift | 24 ------------------- 2 files changed, 5 insertions(+), 32 deletions(-) delete mode 100644 OpenHABCore/Sources/OpenHABCore/Util/UnknownCaseRepresentable.swift diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift index 5f5d1639e..6685f4d5e 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift @@ -36,9 +36,7 @@ public enum WidgetTypeEnum { } public class OpenHABWidget: NSObject, MKAnnotation, Identifiable, ObservableObject { - public enum WidgetType: String, Decodable, UnknownCaseRepresentable { - static var unknownCase: OpenHABWidget.WidgetType = .unknown - + public enum WidgetType: String, Decodable { case chart = "Chart" case colorpicker = "Colorpicker" case defaultWidget = "Default" @@ -57,9 +55,8 @@ public class OpenHABWidget: NSObject, MKAnnotation, Identifiable, ObservableObje case unknown = "Unknown" } - public enum InputHint: String, Decodable, UnknownCaseRepresentable { - static var unknownCase: OpenHABWidget.InputHint = .text - case text, number, date, time, datetime + public enum InputHint: String, Decodable { + case text, number, date, time, datetime, unknown } public var id: String = "" @@ -84,7 +81,7 @@ public class OpenHABWidget: NSObject, MKAnnotation, Identifiable, ObservableObje @Published public var state = "" public var text = "" public var legend: Bool? - public var inputHint = InputHint.unknownCase + public var inputHint = InputHint.unknown public var encoding = "" public var forceAsItem: Bool? @Published public var item: OpenHABItem? @@ -361,7 +358,7 @@ extension OpenHABWidget { widgetId: widget.widgetId.orEmpty, label: widget.label.orEmpty, icon: widget.icon.orEmpty, - type: OpenHABWidget.WidgetType(rawValue: widget._type!), + type: OpenHABWidget.WidgetType(rawValue: widget._type ?? "Unknown") ?? .unknown, url: widget.url, period: widget.period, minValue: widget.minValue, diff --git a/OpenHABCore/Sources/OpenHABCore/Util/UnknownCaseRepresentable.swift b/OpenHABCore/Sources/OpenHABCore/Util/UnknownCaseRepresentable.swift deleted file mode 100644 index bd6b39f2b..000000000 --- a/OpenHABCore/Sources/OpenHABCore/Util/UnknownCaseRepresentable.swift +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright (c) 2010-2025 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 Foundation - -// Graciously handling of unknown enum types: https://www.latenightswift.com/2019/02/04/unknown-enum-cases/ -protocol UnknownCaseRepresentable: RawRepresentable, CaseIterable where RawValue: Equatable { - static var unknownCase: Self { get } -} - -extension UnknownCaseRepresentable { - public init(rawValue: RawValue) { - let value = Self.allCases.first { $0.rawValue == rawValue } - self = value ?? Self.unknownCase - } -} From 8f9e178832bb432bf4d56f7df48e2a053197e0fc Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Fri, 14 Mar 2025 18:17:29 +0100 Subject: [PATCH 082/476] Next step in removing UnknownCaseRepresentable Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- OpenHABCore/Sources/OpenHABCore/Util/StringExtension.swift | 2 +- openHAB/OpenHABSitemapViewController.swift | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/StringExtension.swift b/OpenHABCore/Sources/OpenHABCore/Util/StringExtension.swift index 0fda9fb65..83898d226 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/StringExtension.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/StringExtension.swift @@ -78,7 +78,7 @@ public extension String { } internal func toWidgetType() -> OpenHABWidget.WidgetType { - OpenHABWidget.WidgetType(rawValue: self) + OpenHABWidget.WidgetType(rawValue: self) ?? .unknown } func parseAsBool() -> Bool { diff --git a/openHAB/OpenHABSitemapViewController.swift b/openHAB/OpenHABSitemapViewController.swift index 6b38191c4..00563d2f2 100644 --- a/openHAB/OpenHABSitemapViewController.swift +++ b/openHAB/OpenHABSitemapViewController.swift @@ -978,6 +978,9 @@ extension OpenHABSitemapViewController: UITableViewDelegate, UITableViewDataSour textField.keyboardType = .default } textExtractor = { $0.textFields?[0].text } + case .unknown: + textExtractor = nil + textFieldAdder = nil } guard let textExtractor, let textFieldAdder else { return From 2e2f5e1b91be7f4ec0cbc619e041f25f11d66bc5 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Fri, 14 Mar 2025 22:34:42 +0100 Subject: [PATCH 083/476] Migrate OpenHABServerProperties to struct OpenAPIServiceDelegate had a race condition that was addressed with the new actor AuthAttemptTracker Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../Model/OpenHABServerProperties.swift | 4 +-- .../OpenHABCore/Util/NetworkTracker.swift | 2 +- .../Util/OpenAPIServiceDelegate.swift | 34 +++++++++++++++---- 3 files changed, 30 insertions(+), 10 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABServerProperties.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABServerProperties.swift index 083806d18..f7fb82058 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABServerProperties.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABServerProperties.swift @@ -11,7 +11,7 @@ import Foundation -public class OpenHABServerProperties: Decodable { +public struct OpenHABServerProperties: Decodable, Sendable { public let version: String? let links: [OpenHABLink] @@ -34,7 +34,7 @@ public class OpenHABServerProperties: Decodable { } extension OpenHABServerProperties { - convenience init(_ rootBean: Components.Schemas.RootBean) { + init(_ rootBean: Components.Schemas.RootBean) { self.init( version: rootBean.version, links: rootBean.links?.compactMap { OpenHABLink($0) } ?? [] diff --git a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift index cca6a70b0..1eaa23deb 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift @@ -209,7 +209,7 @@ public final class NetworkTracker: ObservableObject { return bestConnection } - private func withTimeout(seconds: Double, operation: @escaping () async -> T?) async -> T? { + private func withTimeout(seconds: Double, operation: @escaping () async -> T?) async -> T? { await withTaskGroup(of: T?.self) { group in // Start the operation group.addTask { diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIServiceDelegate.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIServiceDelegate.swift index 6055e80be..d80906834 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIServiceDelegate.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIServiceDelegate.swift @@ -12,12 +12,25 @@ import Foundation import os +actor AuthAttemptTracker { + private var attemptCounts: [URLSessionTask: Int] = [:] + + func incrementAttempt(for task: URLSessionTask) -> Int { + attemptCounts[task, default: 0] += 1 + return attemptCounts[task]! + } + + func resetAttempt(for task: URLSessionTask) { + attemptCounts[task] = 0 + } +} + // MARK: - URLSessionDelegate for Client Certificates and Basic Auth -class OpenAPIServiceDelegate: NSObject, URLSessionDelegate, URLSessionTaskDelegate { +final class OpenAPIServiceDelegate: NSObject, URLSessionDelegate, URLSessionTaskDelegate { private let username: String private let password: String - private var authAttemptCounts = [URLSessionTask: Int]() + private let authTracker = AuthAttemptTracker() // ✅ Use an actor instead of a dictionary init(username: String, password: String) { self.username = username @@ -37,20 +50,27 @@ class OpenAPIServiceDelegate: NSObject, URLSessionDelegate, URLSessionTaskDelega let authenticationMethod = challenge.protectionSpace.authenticationMethod switch authenticationMethod { case NSURLAuthenticationMethodServerTrust: - return await handleServerTrust(challenge: challenge) + let result = await handleServerTrust(challenge: challenge) + if let task { await authTracker.resetAttempt(for: task) } // ✅ Reset on success + return result case NSURLAuthenticationMethodDefault, NSURLAuthenticationMethodHTTPBasic: if let task { - authAttemptCounts[task, default: 0] += 1 - if authAttemptCounts[task]! > 1 { + let attemptCount = await authTracker.incrementAttempt(for: task) // ✅ Call actor asynchronously + if attemptCount > 1 { + await authTracker.resetAttempt(for: task) // ✅ Reset if we cancel authentication return (.cancelAuthenticationChallenge, nil) } else { - return await handleBasicAuth(challenge: challenge) + let result = await handleBasicAuth(challenge: challenge) + await authTracker.resetAttempt(for: task) // ✅ Reset on success + return result } } else { return await handleBasicAuth(challenge: challenge) } case NSURLAuthenticationMethodClientCertificate: - return await handleClientCertificateAuth(challenge: challenge) + let result = await handleClientCertificateAuth(challenge: challenge) + if let task { await authTracker.resetAttempt(for: task) } // ✅ Reset on success + return result default: return (.performDefaultHandling, nil) } From dcc8e348238665cbf76934cb257681a578fc34c1 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Sat, 15 Mar 2025 09:08:47 +0100 Subject: [PATCH 084/476] Systematically use .task instead of .onAppear / gives a async context Make use of AuthAttemptTracker in HTTPClient / simplified interface for resetAttempt Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../OpenHABCore/Util/AuthAttemptTracker.swift | 26 +++++++++ .../Sources/OpenHABCore/Util/HTTPClient.swift | 19 ++++--- .../OpenHABCore/Util/NetworkTracker.swift | 3 +- .../Util/OpenAPIServiceDelegate.swift | 17 +----- openHAB/NotificationsView.swift | 30 +++++------ .../ApplicationSettingsView.swift | 2 +- .../SettingsView/ClientCertificatesView.swift | 2 +- openHAB/SettingsView/SettingsView.swift | 2 +- .../Views/Utils/DownloadableImageView.swift | 54 +++++++++---------- 9 files changed, 83 insertions(+), 72 deletions(-) create mode 100644 OpenHABCore/Sources/OpenHABCore/Util/AuthAttemptTracker.swift diff --git a/OpenHABCore/Sources/OpenHABCore/Util/AuthAttemptTracker.swift b/OpenHABCore/Sources/OpenHABCore/Util/AuthAttemptTracker.swift new file mode 100644 index 000000000..f132a72fc --- /dev/null +++ b/OpenHABCore/Sources/OpenHABCore/Util/AuthAttemptTracker.swift @@ -0,0 +1,26 @@ +// Copyright (c) 2010-2025 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 Foundation + +public actor AuthAttemptTracker { + private var attemptCounts: [URLSessionTask: Int] = [:] + + func incrementAttempt(for task: URLSessionTask) -> Int { + attemptCounts[task, default: 0] += 1 + return attemptCounts[task]! + } + + func resetAttempt(for task: URLSessionTask?) { + guard let task else { return } + attemptCounts[task] = 0 + } +} diff --git a/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift b/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift index 7645529e8..65c4386cf 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift @@ -70,7 +70,7 @@ public class HTTPClient: NSObject { private let ignoreSSL: Bool private var evaluateContinuation: CheckedContinuation? private var trustedCertificates: [String: Data] = [:] - private var authAttemptCounts = [URLSessionTask: Int]() + private let authAttemptTracker = AuthAttemptTracker() private let logger = Logger(subsystem: "org.openhab.core", category: "HTTPClient") @@ -309,20 +309,27 @@ extension HTTPClient: URLSessionDelegate, URLSessionTaskDelegate { let authenticationMethod = challenge.protectionSpace.authenticationMethod switch authenticationMethod { case NSURLAuthenticationMethodServerTrust: - return await handleServerTrust(challenge: challenge) + let result = await handleServerTrust(challenge: challenge) + await authAttemptTracker.resetAttempt(for: task) // ✅ Reset on success + return result case NSURLAuthenticationMethodDefault, NSURLAuthenticationMethodHTTPBasic: if let task { - authAttemptCounts[task, default: 0] += 1 - if authAttemptCounts[task]! > 1 { + let attemptCount = await authAttemptTracker.incrementAttempt(for: task) // ✅ Call actor asynchronously + if attemptCount > 1 { + await authAttemptTracker.resetAttempt(for: task) // ✅ Reset if we cancel authentication return (.cancelAuthenticationChallenge, nil) } else { - return await handleBasicAuth(challenge: challenge) + let result = await handleBasicAuth(challenge: challenge) + await authAttemptTracker.resetAttempt(for: task) // ✅ Reset on success + return result } } else { return await handleBasicAuth(challenge: challenge) } case NSURLAuthenticationMethodClientCertificate: - return await handleClientCertificateAuth(challenge: challenge) + let result = await handleClientCertificateAuth(challenge: challenge) + await authAttemptTracker.resetAttempt(for: task) // ✅ Reset on success + return result default: return (.performDefaultHandling, nil) } diff --git a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift index 1eaa23deb..d8540f6a9 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift @@ -84,7 +84,7 @@ public final class NetworkTracker: ObservableObject { private var retryCount = 0 private let maxRetries = 5 - private let monitor: NWPathMonitor + private let monitor = NWPathMonitor() private let monitorQueue = DispatchQueue.global(qos: .background) private var connectionPool: ConnectionPool = .init() private var connectionConfigurations: [ConnectionConfiguration] = [] @@ -106,7 +106,6 @@ public final class NetworkTracker: ObservableObject { // } // } // } else { - monitor = NWPathMonitor() monitor.pathUpdateHandler = { [weak self] path in Task { await self?.handleNetworkChange(isConnected: path.status == .satisfied) } } diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIServiceDelegate.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIServiceDelegate.swift index d80906834..f3fba6f63 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIServiceDelegate.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIServiceDelegate.swift @@ -12,19 +12,6 @@ import Foundation import os -actor AuthAttemptTracker { - private var attemptCounts: [URLSessionTask: Int] = [:] - - func incrementAttempt(for task: URLSessionTask) -> Int { - attemptCounts[task, default: 0] += 1 - return attemptCounts[task]! - } - - func resetAttempt(for task: URLSessionTask) { - attemptCounts[task] = 0 - } -} - // MARK: - URLSessionDelegate for Client Certificates and Basic Auth final class OpenAPIServiceDelegate: NSObject, URLSessionDelegate, URLSessionTaskDelegate { @@ -51,7 +38,7 @@ final class OpenAPIServiceDelegate: NSObject, URLSessionDelegate, URLSessionTask switch authenticationMethod { case NSURLAuthenticationMethodServerTrust: let result = await handleServerTrust(challenge: challenge) - if let task { await authTracker.resetAttempt(for: task) } // ✅ Reset on success + await authTracker.resetAttempt(for: task) // ✅ Reset on success return result case NSURLAuthenticationMethodDefault, NSURLAuthenticationMethodHTTPBasic: if let task { @@ -69,7 +56,7 @@ final class OpenAPIServiceDelegate: NSObject, URLSessionDelegate, URLSessionTask } case NSURLAuthenticationMethodClientCertificate: let result = await handleClientCertificateAuth(challenge: challenge) - if let task { await authTracker.resetAttempt(for: task) } // ✅ Reset on success + await authTracker.resetAttempt(for: task) // ✅ Reset on success return result default: return (.performDefaultHandling, nil) diff --git a/openHAB/NotificationsView.swift b/openHAB/NotificationsView.swift index bf63a01ff..8e380396f 100644 --- a/openHAB/NotificationsView.swift +++ b/openHAB/NotificationsView.swift @@ -76,28 +76,26 @@ struct NotificationsView: View { NotificationRow(notification: notification) } .refreshable { - loadNotifications() + await loadNotifications() } .navigationTitle("Notifications") - .onAppear { - loadNotifications() + .task { + await loadNotifications() } } - private func loadNotifications() { - Task { - do { - let client = HTTPClient(username: Preferences.username, password: Preferences.username, alwaysSendBasicAuth: Preferences.alwaysSendCreds) - let data = try await client.notification(urlString: Preferences.remoteUrl) - try await MainActor.run { - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .formatted(DateFormatter.iso8601Full) - let codingDatas = try data.decoded(as: [OpenHABNotification.CodingData].self, using: decoder) - notifications = codingDatas.map(\.openHABNotification) - } - } catch { - os_log("%{PUBLIC}@", log: .default, type: .error, error.localizedDescription) + private func loadNotifications() async { + do { + let client = HTTPClient(username: Preferences.username, password: Preferences.username, alwaysSendBasicAuth: Preferences.alwaysSendCreds) + let data = try await client.notification(urlString: Preferences.remoteUrl) + try await MainActor.run { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .formatted(DateFormatter.iso8601Full) + let codingDatas = try data.decoded(as: [OpenHABNotification.CodingData].self, using: decoder) + notifications = codingDatas.map(\.openHABNotification) } + } catch { + os_log("%{PUBLIC}@", log: .default, type: .error, error.localizedDescription) } } } diff --git a/openHAB/SettingsView/ApplicationSettingsView.swift b/openHAB/SettingsView/ApplicationSettingsView.swift index 27fb813cb..c734bbed8 100644 --- a/openHAB/SettingsView/ApplicationSettingsView.swift +++ b/openHAB/SettingsView/ApplicationSettingsView.swift @@ -106,7 +106,7 @@ struct ApplicationSettingsView: View { Toggle("Ignore SSL certificates", isOn: $settingsIgnoreSSL) Toggle("Disable Idle Timeout", isOn: $settingsIdleOff) Toggle("Crash Reporting", isOn: $settingsSendCrashReports) - .onAppear { + .task { settingsSendCrashReports = Preferences.sendCrashReports } .onChange(of: settingsSendCrashReports) { newValue in diff --git a/openHAB/SettingsView/ClientCertificatesView.swift b/openHAB/SettingsView/ClientCertificatesView.swift index 14c4f8b6e..92e4cf105 100644 --- a/openHAB/SettingsView/ClientCertificatesView.swift +++ b/openHAB/SettingsView/ClientCertificatesView.swift @@ -24,7 +24,7 @@ struct ClientCertificatesView: View { } } .navigationTitle(Text(LocalizedStringKey("client_certificates"))) - .onAppear { + .task { viewModel.loadCertificates() } } diff --git a/openHAB/SettingsView/SettingsView.swift b/openHAB/SettingsView/SettingsView.swift index f6956e486..28436278a 100644 --- a/openHAB/SettingsView/SettingsView.swift +++ b/openHAB/SettingsView/SettingsView.swift @@ -93,7 +93,7 @@ struct SettingsView: View { } } } - .onAppear { + .task { loadSettings() } } diff --git a/openHABWatch/Views/Utils/DownloadableImageView.swift b/openHABWatch/Views/Utils/DownloadableImageView.swift index 65ad4ded6..f50bd432f 100644 --- a/openHABWatch/Views/Utils/DownloadableImageView.swift +++ b/openHABWatch/Views/Utils/DownloadableImageView.swift @@ -92,10 +92,9 @@ struct DownloadableImageView: View { .opacity(0.3) } } - .onAppear { - fetchImage() + .task { + await fetchImage() } - .onDisappear { cancelDownload() } .scaledToFit() } @@ -104,7 +103,7 @@ struct DownloadableImageView: View { self.url = url } - private func fetchImage() { + private func fetchImage() async { print("Fetching Image from \(String(describing: url))") guard let url else { print("fetchImage() skipped: URL is nil") @@ -120,34 +119,29 @@ struct DownloadableImageView: View { } print("Fetching fresh image from \(url)") - asyncOperation = Task { - do { - guard let client = NetworkTracker.shared.httpClient else { - throw DownloadableImageError.nohttpClient - } - let (data, _): (Data, URLResponse) = try await client.doRequest(baseURL: url, type: .data) - try await MainActor.run { - let scaleFactor = WKInterfaceDevice.current().screenScale - let options: [SDImageCoderOption: Any] = [ - .decodeScaleFactor: scaleFactor, - .decodeThumbnailPixelSize: CGSize(width: 200, height: 200) - ] - - if let image = SDImageCodersManager.shared.decodedImage(with: data, options: options) { - logger.info("Downloaded and decoded image from \(url)") - ImageCacheManager.shared.cacheImage(image, for: url) // Cache it - imageLoader.updateImage(image) - } else { - throw DownloadableImageError.failedToDecode - } + + do { + guard let client = NetworkTracker.shared.httpClient else { + throw DownloadableImageError.nohttpClient + } + let (data, _): (Data, URLResponse) = try await client.doRequest(baseURL: url, type: .data) + try await MainActor.run { + let scaleFactor = WKInterfaceDevice.current().screenScale + let options: [SDImageCoderOption: Any] = [ + .decodeScaleFactor: scaleFactor, + .decodeThumbnailPixelSize: CGSize(width: 200, height: 200) + ] + + if let image = SDImageCodersManager.shared.decodedImage(with: data, options: options) { + logger.info("Downloaded and decoded image from \(url)") + ImageCacheManager.shared.cacheImage(image, for: url) // Cache it + imageLoader.updateImage(image) + } else { + throw DownloadableImageError.failedToDecode } - } catch { - logger.error("Image loading failed") } + } catch { + logger.error("Image loading failed") } } - - private func cancelDownload() { - asyncOperation?.cancel() - } } From 84f091058d5483bfaaed94696f4bab0f298ca4d2 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Sat, 15 Mar 2025 09:26:32 +0100 Subject: [PATCH 085/476] Avoid loop with AuthAttemptTracker Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift | 1 - .../Sources/OpenHABCore/Util/OpenAPIServiceDelegate.swift | 1 - 2 files changed, 2 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift b/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift index 65c4386cf..ec952743c 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift @@ -316,7 +316,6 @@ extension HTTPClient: URLSessionDelegate, URLSessionTaskDelegate { if let task { let attemptCount = await authAttemptTracker.incrementAttempt(for: task) // ✅ Call actor asynchronously if attemptCount > 1 { - await authAttemptTracker.resetAttempt(for: task) // ✅ Reset if we cancel authentication return (.cancelAuthenticationChallenge, nil) } else { let result = await handleBasicAuth(challenge: challenge) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIServiceDelegate.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIServiceDelegate.swift index f3fba6f63..b4c14bf80 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIServiceDelegate.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIServiceDelegate.swift @@ -44,7 +44,6 @@ final class OpenAPIServiceDelegate: NSObject, URLSessionDelegate, URLSessionTask if let task { let attemptCount = await authTracker.incrementAttempt(for: task) // ✅ Call actor asynchronously if attemptCount > 1 { - await authTracker.resetAttempt(for: task) // ✅ Reset if we cancel authentication return (.cancelAuthenticationChallenge, nil) } else { let result = await handleBasicAuth(challenge: challenge) From 61f7fb857b354a1b9ffe2c04719339183bbd3e87 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Sat, 15 Mar 2025 09:43:18 +0100 Subject: [PATCH 086/476] Avoid loop with AuthAttemptTracker - step 2 Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift | 1 - .../Sources/OpenHABCore/Util/OpenAPIServiceDelegate.swift | 1 - 2 files changed, 2 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift b/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift index ec952743c..8ae7b9d1c 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift @@ -319,7 +319,6 @@ extension HTTPClient: URLSessionDelegate, URLSessionTaskDelegate { return (.cancelAuthenticationChallenge, nil) } else { let result = await handleBasicAuth(challenge: challenge) - await authAttemptTracker.resetAttempt(for: task) // ✅ Reset on success return result } } else { diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIServiceDelegate.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIServiceDelegate.swift index b4c14bf80..8c3617036 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIServiceDelegate.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIServiceDelegate.swift @@ -47,7 +47,6 @@ final class OpenAPIServiceDelegate: NSObject, URLSessionDelegate, URLSessionTask return (.cancelAuthenticationChallenge, nil) } else { let result = await handleBasicAuth(challenge: challenge) - await authTracker.resetAttempt(for: task) // ✅ Reset on success return result } } else { From 4a642b09766f260b6eea3f1bd6f1a4cbb389b409 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Sat, 15 Mar 2025 11:19:09 +0100 Subject: [PATCH 087/476] Avoid MainActor.run on NotificationsView Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- openHAB/NotificationsView.swift | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/openHAB/NotificationsView.swift b/openHAB/NotificationsView.swift index 8e380396f..d6b3d5bec 100644 --- a/openHAB/NotificationsView.swift +++ b/openHAB/NotificationsView.swift @@ -76,27 +76,26 @@ struct NotificationsView: View { NotificationRow(notification: notification) } .refreshable { - await loadNotifications() + await notifications = loadNotifications() } .navigationTitle("Notifications") .task { - await loadNotifications() + await notifications = loadNotifications() } } - private func loadNotifications() async { + private func loadNotifications() async -> [OpenHABNotification]{ do { let client = HTTPClient(username: Preferences.username, password: Preferences.username, alwaysSendBasicAuth: Preferences.alwaysSendCreds) let data = try await client.notification(urlString: Preferences.remoteUrl) - try await MainActor.run { - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .formatted(DateFormatter.iso8601Full) - let codingDatas = try data.decoded(as: [OpenHABNotification.CodingData].self, using: decoder) - notifications = codingDatas.map(\.openHABNotification) - } + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .formatted(DateFormatter.iso8601Full) + let codingDatas = try data.decoded(as: [OpenHABNotification.CodingData].self, using: decoder) + return codingDatas.map(\.openHABNotification) } catch { os_log("%{PUBLIC}@", log: .default, type: .error, error.localizedDescription) } + return [] } } From 0cc797f0f47101c2d966d75ca7c396aba6b63090 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Sun, 16 Mar 2025 15:12:38 +0100 Subject: [PATCH 088/476] Properly checking whether hostname has suffix myopenhab.org in AuthorisationMiddleware, HTTPClient In OpenHABRootViewController transfer of password. Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../OpenHABCore/Util/AuthorisationMiddleware.swift | 2 +- OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift | 8 ++++---- openHAB/OpenHABRootViewController.swift | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/AuthorisationMiddleware.swift b/OpenHABCore/Sources/OpenHABCore/Util/AuthorisationMiddleware.swift index 3449bf91a..eb9875863 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/AuthorisationMiddleware.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/AuthorisationMiddleware.swift @@ -40,7 +40,7 @@ extension AuthorisationMiddleware: ClientMiddleware { next: @Sendable (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?)) async throws -> (HTTPResponse, HTTPBody?) { // Use a mutable copy of request var request = request - if baseURL.host?.hasSuffix("myopenhab.org") == nil || alwaysSendBasicAuth, !username.isEmpty, !password.isEmpty { + if baseURL.host?.hasSuffix("myopenhab.org") == true || alwaysSendBasicAuth, !username.isEmpty, !password.isEmpty { request.headerFields[.authorization] = basicAuthHeader() } let (response, body) = try await next(request, body, baseURL) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift b/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift index 8ae7b9d1c..9214a3fe8 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift @@ -186,7 +186,8 @@ public class HTTPClient: NSObject { private func performRequest(request: URLRequest, type: SessionType = .data) async throws -> (T, URLResponse) { var request = request - if alwaysSendBasicAuth { + + if request.url?.host?.hasSuffix("myopenhab.org") == true || alwaysSendBasicAuth, !username.isEmpty, !password.isEmpty { request.setValue(basicAuthHeader(), forHTTPHeaderField: "Authorization") } @@ -203,9 +204,8 @@ public class HTTPClient: NSObject { // MARK: - Basic Authentication private func basicAuthHeader() -> String { - let authString = "\(username):\(password)" - let authData = authString.data(using: .utf8)! - return "Basic \(authData.base64EncodedString())" + let credential = Data("\(username):\(password)".utf8).base64EncodedString() + return "Basic \(credential)" } // MARK: - SSL Certificate Handling diff --git a/openHAB/OpenHABRootViewController.swift b/openHAB/OpenHABRootViewController.swift index 3dd1ec8d6..2bd236b5e 100644 --- a/openHAB/OpenHABRootViewController.swift +++ b/openHAB/OpenHABRootViewController.swift @@ -303,9 +303,9 @@ class OpenHABRootViewController: UIViewController { os_log("Registering notifications with %{PUBLIC}@", log: .notifications, type: .info, prefsURL) Task { do { - let client = HTTPClient(username: Preferences.username, password: Preferences.username, alwaysSendBasicAuth: Preferences.alwaysSendCreds) + let client = HTTPClient(username: Preferences.username, password: Preferences.password, alwaysSendBasicAuth: Preferences.alwaysSendCreds) try await client.register(prefsURL: prefsURL, deviceToken: deviceToken, deviceId: deviceId, deviceName: deviceName) - os_log("my.openHAB registration sent", log: .notifications, type: .info) + os_log("my.openHAB registration succeeded", log: .notifications, type: .info) } catch { os_log("my.openHAB registration failed %{PUBLIC}@", log: .notifications, type: .error, error.localizedDescription) } From 478710d761b773da42e89a5143cd9785c9a1894a Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Sun, 16 Mar 2025 23:16:03 +0100 Subject: [PATCH 089/476] Adjust myopenhab.org to home.openhab.org moved to OpenAPIService - correspondingly remove it from NetworkTracker. Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../OpenHABCore/Util/NetworkTracker.swift | 69 +++++++++++++------ .../OpenHABCore/Util/OpenAPIService.swift | 16 +++-- .../Util/OpenAPIServiceDelegate.swift | 2 +- openHAB/NotificationsView.swift | 2 +- 4 files changed, 62 insertions(+), 27 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift index d8540f6a9..f118b7580 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift @@ -74,6 +74,26 @@ actor ConnectionPool { services[configuration] = newService return newService } + + // Ensures that all URLs pointing to "myopenhab.org" are standardized to "home.myopenhab.org". + private func adjustMyOpenHABHosts(in configurations: [ConnectionConfiguration]) -> [ConnectionConfiguration] { + configurations.map { configuration in + adjustMyOpenHABHost(in: configuration) + } + } + + private func adjustMyOpenHABHost(in configuration: ConnectionConfiguration) -> ConnectionConfiguration { + var updatedURL = configuration.url + if let urlComponents = URLComponents(string: configuration.url), + let host = urlComponents.host, + host.contains("myopenhab.org"), + host != "home.myopenhab.org" { + var newComponents = urlComponents + newComponents.host = "home.myopenhab.org" + updatedURL = newComponents.url?.absoluteString ?? configuration.url + } + return ConnectionConfiguration(url: updatedURL, priority: configuration.priority) + } } public final class NetworkTracker: ObservableObject { @@ -118,7 +138,8 @@ public final class NetworkTracker: ObservableObject { alwaysSendBasicAuth: Bool, ignoreSSLVerification: Bool) { logger.info("Start Network Tracking") - self.connectionConfigurations = adjustMyOpenHABHosts(in: connectionConfigurations) +// self.connectionConfigurations = adjustMyOpenHABHosts(in: connectionConfigurations) + self.connectionConfigurations = connectionConfigurations Task { for configuration in connectionConfigurations { await connectionPool.getOrCreateService(for: configuration) @@ -230,15 +251,17 @@ public final class NetworkTracker: ObservableObject { guard URL(string: configuration.url) != nil else { return nil } do { - let version = try await connectionPool.getOrCreateService(for: configuration).getRootVersion() + logger.info("testConnection for \(configuration.url)") + let connection = await connectionPool.getOrCreateService(for: configuration) + let version = try await connection.getRootVersion() let connectionInfo = ConnectionInfo(configuration: configuration, version: version) - logger.info("Successfully connected to \(configuration.url)") + logger.info("testConnection successful for \(configuration.url)") return connectionInfo } catch NetworkTrackerError.invalidServerVersion { - logger.info("Invalid server version from \(configuration.url)") + logger.info("testConnection error - Invalid server version from \(configuration.url)") return nil } catch { - logger.info("Failed to connect to \(configuration.url)") + logger.info("testConnection error - Failed to connect to \(configuration.url)") return nil } } @@ -262,6 +285,26 @@ public final class NetworkTracker: ObservableObject { } } + // Ensures that all URLs pointing to "myopenhab.org" are standardized to "home.myopenhab.org". + private func adjustMyOpenHABHosts(in configurations: [ConnectionConfiguration]) -> [ConnectionConfiguration] { + configurations.map { configuration in + adjustMyOpenHABHost(in: configuration) + } + } + + private func adjustMyOpenHABHost(in configuration: ConnectionConfiguration) -> ConnectionConfiguration { + var updatedURL = configuration.url + if let urlComponents = URLComponents(string: configuration.url), + let host = urlComponents.host, + host.contains("myopenhab.org"), + host != "home.myopenhab.org" { + var newComponents = urlComponents + newComponents.host = "home.myopenhab.org" + updatedURL = newComponents.url?.absoluteString ?? configuration.url + } + return ConnectionConfiguration(url: updatedURL, priority: configuration.priority) + } + @MainActor private func setActiveConnection(_ connection: ConnectionInfo?) async { guard activeConnection != connection else { return } @@ -274,22 +317,6 @@ public final class NetworkTracker: ObservableObject { startRetryTask(disconnectedRetryInterval) } } - - // Ensures that all URLs pointing to "myopenhab.org" are standardized to "home.myopenhab.org". - private func adjustMyOpenHABHosts(in configurations: [ConnectionConfiguration]) -> [ConnectionConfiguration] { - configurations.map { configuration in - var updatedURL = configuration.url - if let urlComponents = URLComponents(string: configuration.url), - let host = urlComponents.host, - host.contains("myopenhab.org"), - host != "home.myopenhab.org" { - var newComponents = urlComponents - newComponents.host = "home.myopenhab.org" - updatedURL = newComponents.url?.absoluteString ?? configuration.url - } - return ConnectionConfiguration(url: updatedURL, priority: configuration.priority) - } - } } public extension NetworkTracker { diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift index 5b8b3be04..242937df6 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift @@ -69,9 +69,19 @@ public actor OpenAPIService { self.username = username self.password = password self.alwaysSendBasicAuth = alwaysSendBasicAuth - self.url = url self.ignoreSSL = ignoreSSL + if let urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false), + let host = urlComponents.host, + host.contains("myopenhab.org"), + host != "home.myopenhab.org" { + var newComponents = urlComponents + newComponents.host = "home.myopenhab.org" + self.url = newComponents.url + } else { + self.url = url + } + client = Client( serverURL: url.appending(path: "/rest"), transport: URLSessionTransport(configuration: .init(session: session)), @@ -156,9 +166,7 @@ public extension OpenAPIService { } func getRootVersion() async throws -> Int { - let result = try await client.getRoot() - .ok.body.json - let serverProperties = OpenHABServerProperties(result) + let serverProperties = try await getRoot() guard let version = Int(serverProperties.version ?? "0"), version > 1 else { throw NetworkTrackerError.invalidServerVersion diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIServiceDelegate.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIServiceDelegate.swift index 8c3617036..9f1543328 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIServiceDelegate.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIServiceDelegate.swift @@ -33,7 +33,7 @@ final class OpenAPIServiceDelegate: NSObject, URLSessionDelegate, URLSessionTask } private func urlSessionInternal(_ session: URLSession, task: URLSessionTask?, didReceive challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { - os_log("URLAuthenticationChallenge: %{public}@", log: .networking, type: .info, challenge.protectionSpace.authenticationMethod) + os_log("URLAuthenticationChallenge for : %{public}@", log: .networking, type: .info, challenge.protectionSpace.authenticationMethod) let authenticationMethod = challenge.protectionSpace.authenticationMethod switch authenticationMethod { case NSURLAuthenticationMethodServerTrust: diff --git a/openHAB/NotificationsView.swift b/openHAB/NotificationsView.swift index d6b3d5bec..d7fed4819 100644 --- a/openHAB/NotificationsView.swift +++ b/openHAB/NotificationsView.swift @@ -84,7 +84,7 @@ struct NotificationsView: View { } } - private func loadNotifications() async -> [OpenHABNotification]{ + private func loadNotifications() async -> [OpenHABNotification] { do { let client = HTTPClient(username: Preferences.username, password: Preferences.username, alwaysSendBasicAuth: Preferences.alwaysSendCreds) let data = try await client.notification(urlString: Preferences.remoteUrl) From 8356591fb624668fdc49939aab45450850fe8812 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Mon, 17 Mar 2025 21:33:11 +0100 Subject: [PATCH 090/476] Migration to configuration setup for local and remote server Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- NotificationService/NotificationService.swift | 45 +++----- .../Util/AuthorisationMiddleware.swift | 6 +- .../Util/ConnectionConfiguration.swift | 28 +++++ .../OpenHABCore/Util/LoggingMiddleware.swift | 8 +- .../OpenHABCore/Util/NetworkTracker.swift | 36 +++--- .../OpenHABCore/Util/OpenHABItemCache.swift | 18 +-- .../OpenHABCore/Util/Preferences.swift | 107 ++++++++++++++++++ openHAB.xcodeproj/project.pbxproj | 4 + openHAB/AppDelegate.swift | 1 + openHAB/DrawerView.swift | 2 +- openHAB/NotificationsView.swift | 5 +- openHAB/OpenHABRootViewController.swift | 64 +++++------ openHAB/OpenHABWebViewController.swift | 17 +-- .../SettingsView/ConnectionSettingsView.swift | 104 ++++------------- openHAB/SettingsView/SettingsView.swift | 27 ++++- .../SingleConnectionSettingsView.swift | 98 ++++++++++++++++ openHABWatch/Domain/UserData.swift | 13 +-- 17 files changed, 367 insertions(+), 216 deletions(-) create mode 100644 OpenHABCore/Sources/OpenHABCore/Util/ConnectionConfiguration.swift create mode 100644 openHAB/SettingsView/SingleConnectionSettingsView.swift diff --git a/NotificationService/NotificationService.swift b/NotificationService/NotificationService.swift index 8cef5305c..6c167cb32 100644 --- a/NotificationService/NotificationService.swift +++ b/NotificationService/NotificationService.swift @@ -163,39 +163,30 @@ class NotificationService: UNNotificationServiceExtension { } private func downloadAndAttachMedia(url: String) async throws -> UNNotificationAttachment? { - let client = HTTPClient( - username: Preferences.username, - password: Preferences.password, - alwaysSendBasicAuth: Preferences.alwaysSendCreds - ) + NetworkTracker.shared.startTracking(connectionConfigurations: [Preferences.localConnectionConfig, Preferences.remoteConnectionConfig]) - if url.starts(with: "/") { - let connection1 = ConnectionConfiguration(url: Preferences.localUrl, priority: 0) - let connection2 = ConnectionConfiguration(url: Preferences.remoteUrl, priority: 1) + guard let fullURL = await resolveFullURL(from: url) else { return nil } - NetworkTracker.shared.startTracking( - connectionConfigurations: [connection1, connection2], - username: Preferences.username, - password: Preferences.password, - alwaysSendBasicAuth: Preferences.alwaysSendCreds, - ignoreSSLVerification: Preferences.ignoreSSL - ) + guard let activeConfig = await NetworkTracker.shared.waitForActiveConnection()?.configuration else { return nil } - // Await the active connection - guard let activeConnection = await NetworkTracker.shared.waitForActiveConnection(), - let fullURL = URL(string: activeConnection.configuration.url)?.appendingPathComponent(url) else { - return nil - } + let client = HTTPClient( + username: activeConfig.username, + password: activeConfig.password, + alwaysSendBasicAuth: activeConfig.alwaysSendBasicAuth + ) - let (localURL, urlResponse) = try await client.downloadFile(url: fullURL) - return await attachFile(localURL: localURL, mimeType: urlResponse.mimeType) + let (localURL, urlResponse) = try await client.downloadFile(url: fullURL) + return await attachFile(localURL: localURL, mimeType: urlResponse.mimeType) + } - } else if let fullURL = URL(string: url) { - let (localURL, urlResponse) = try await client.downloadFile(url: fullURL) - return await attachFile(localURL: localURL, mimeType: urlResponse.mimeType) + // 🔹 Extracted helper function to determine full URL + private func resolveFullURL(from url: String) async -> URL? { + if url.starts(with: "/") { + guard let activeConfig = await NetworkTracker.shared.waitForActiveConnection()?.configuration else { return nil } + return URL(string: activeConfig.url)?.appendingPathComponent(url) + } else { + return URL(string: url) } - - return nil } func downloadAndAttachItemImage(itemURI: String) async throws -> UNNotificationAttachment? { diff --git a/OpenHABCore/Sources/OpenHABCore/Util/AuthorisationMiddleware.swift b/OpenHABCore/Sources/OpenHABCore/Util/AuthorisationMiddleware.swift index eb9875863..a2f34b563 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/AuthorisationMiddleware.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/AuthorisationMiddleware.swift @@ -39,11 +39,11 @@ extension AuthorisationMiddleware: ClientMiddleware { operationID: String, next: @Sendable (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?)) async throws -> (HTTPResponse, HTTPBody?) { // Use a mutable copy of request - var request = request + var newRequest = request if baseURL.host?.hasSuffix("myopenhab.org") == true || alwaysSendBasicAuth, !username.isEmpty, !password.isEmpty { - request.headerFields[.authorization] = basicAuthHeader() + newRequest.headerFields[.authorization] = basicAuthHeader() } - let (response, body) = try await next(request, body, baseURL) + let (response, body) = try await next(newRequest, body, baseURL) return (response, body) } } diff --git a/OpenHABCore/Sources/OpenHABCore/Util/ConnectionConfiguration.swift b/OpenHABCore/Sources/OpenHABCore/Util/ConnectionConfiguration.swift new file mode 100644 index 000000000..dbcfd89ba --- /dev/null +++ b/OpenHABCore/Sources/OpenHABCore/Util/ConnectionConfiguration.swift @@ -0,0 +1,28 @@ +// Copyright (c) 2010-2025 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 + +public struct ConnectionConfiguration: Hashable, Sendable, Codable { + public var url: String + public var username: String + public var password: String + public var alwaysSendBasicAuth: Bool + public var ignoreSSL: Bool + public var priority: Int // Lower is higher priority, 0 is primary + + public init(url: String, username: String, password: String, alwaysSendBasicAuth: Bool = false, ignoreSSL: Bool = false, priority: Int = 10) { + self.url = url + self.username = username + self.password = password + self.alwaysSendBasicAuth = alwaysSendBasicAuth + self.ignoreSSL = ignoreSSL + self.priority = priority + } +} diff --git a/OpenHABCore/Sources/OpenHABCore/Util/LoggingMiddleware.swift b/OpenHABCore/Sources/OpenHABCore/Util/LoggingMiddleware.swift index ca2e4bb8d..4e301f7ac 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/LoggingMiddleware.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/LoggingMiddleware.swift @@ -44,9 +44,9 @@ extension LoggingMiddleware: ClientMiddleware { log(request, response, responseBodyToLog) return (response, responseBodyForNext) } catch { - if operationID != "getRoot", operationID != "getRootVersion" { - log(request, failedWith: error) - } +// if operationID != "getRoot", operationID != "getRootVersion" { + log(request, failedWith: error) +// } throw error } } @@ -61,7 +61,7 @@ extension LoggingMiddleware { func log(_ request: HTTPRequest, _ response: HTTPResponse, _ responseBody: BodyLoggingPolicy.BodyLog) { logger.debug( - "Response: \(request.method, privacy: .public) \(request.path ?? "", privacy: .public) \(response.status, privacy: .public) body: \(responseBody, privacy: .auto)" + "Response: \(request.method, privacy: .public) \(request.path ?? "", privacy: .public) \(response.status, privacy: .public) body: \(responseBody, privacy: .public)" ) } diff --git a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift index f118b7580..1e31bafc5 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift @@ -12,6 +12,7 @@ import Combine import Foundation import Network +import OpenAPIRuntime import os.log // TODO: these strings should reference Localizable keys @@ -23,16 +24,6 @@ public enum NetworkStatus: String { case allConnected = "All Connected" } -public struct ConnectionConfiguration: Hashable, Sendable { - 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 struct ConnectionInfo: Equatable, Sendable { public let configuration: ConnectionConfiguration public let version: Int @@ -65,10 +56,10 @@ actor ConnectionPool { } let newService = OpenAPIService( baseURL: URL(string: configuration.url) ?? URL(staticString: "about:blank"), - username: Preferences.username, - password: Preferences.password, - alwaysSendBasicAuth: Preferences.alwaysSendCreds, - ignoreSSL: Preferences.ignoreSSL, + username: configuration.username, + password: configuration.password, + alwaysSendBasicAuth: configuration.alwaysSendBasicAuth, + ignoreSSL: configuration.ignoreSSL, configuration: .shortTerm ) services[configuration] = newService @@ -92,7 +83,7 @@ actor ConnectionPool { newComponents.host = "home.myopenhab.org" updatedURL = newComponents.url?.absoluteString ?? configuration.url } - return ConnectionConfiguration(url: updatedURL, priority: configuration.priority) + return ConnectionConfiguration(url: updatedURL, username: configuration.username, password: configuration.password, priority: configuration.priority) } } @@ -132,11 +123,7 @@ public final class NetworkTracker: ObservableObject { monitor.start(queue: monitorQueue) } - public func startTracking(connectionConfigurations: [ConnectionConfiguration], - username: String, - password: String, - alwaysSendBasicAuth: Bool, - ignoreSSLVerification: Bool) { + public func startTracking(connectionConfigurations: [ConnectionConfiguration]) { logger.info("Start Network Tracking") // self.connectionConfigurations = adjustMyOpenHABHosts(in: connectionConfigurations) self.connectionConfigurations = connectionConfigurations @@ -217,7 +204,7 @@ public final class NetworkTracker: ObservableObject { if connectionInfo.configuration.priority == 0 { bestConnection = connectionInfo - group.cancelAll() // Stop further tasks if we found the highest-priority connection +// group.cancelAll() // Stop further tasks if we found the highest-priority connection break } @@ -260,8 +247,11 @@ public final class NetworkTracker: ObservableObject { } catch NetworkTrackerError.invalidServerVersion { logger.info("testConnection error - Invalid server version from \(configuration.url)") return nil + } catch let openAPIError as OpenAPIRuntime.ClientError { + logger.info("testConnection error - OpenAPIRuntime.RuntimeError encountered for \(configuration.url): \(openAPIError)") + return nil } catch { - logger.info("testConnection error - Failed to connect to \(configuration.url)") + logger.info("testConnection error - Failed to connect to \(configuration.url) \(error.localizedDescription)") return nil } } @@ -302,7 +292,7 @@ public final class NetworkTracker: ObservableObject { newComponents.host = "home.myopenhab.org" updatedURL = newComponents.url?.absoluteString ?? configuration.url } - return ConnectionConfiguration(url: updatedURL, priority: configuration.priority) + return ConnectionConfiguration(url: updatedURL, username: configuration.username, password: configuration.password, priority: configuration.priority) } @MainActor diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift index 16007bdc8..812527ce6 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift @@ -23,22 +23,10 @@ public actor OpenHABItemCache { private let logger = Logger(subsystem: "org.openhab.app.watchkitapp", category: "OpenHABItemCache") private init() { - let connection1 = ConnectionConfiguration( - url: Preferences.localUrl, - priority: 0 - ) - let connection2 = ConnectionConfiguration( - url: Preferences.remoteUrl, - priority: 1 - ) + let connection1 = Preferences.localConnectionConfig + let connection2 = Preferences.remoteConnectionConfig - NetworkTracker.shared.startTracking( - connectionConfigurations: [connection1, connection2], - username: Preferences.username, - password: Preferences.password, - alwaysSendBasicAuth: Preferences.alwaysSendCreds, - ignoreSSLVerification: Preferences.ignoreSSL - ) + NetworkTracker.shared.startTracking(connectionConfigurations: [connection1, connection2]) } public func getItemNames(searchTerm: String?, types: [OpenHABItem.ItemType]?) -> [String] { diff --git a/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift index ab9596545..886d46da4 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift @@ -45,6 +45,50 @@ public struct UserDefault { } } +@propertyWrapper +public struct UserDefaultObject { + private let key: String + private let defaultValue: T + private let subject: CurrentValueSubject + + public var wrappedValue: T { + get { + guard let data = Preferences.sharedDefaults.data(forKey: key), + let object = try? JSONDecoder().decode(T.self, from: data) else { + return defaultValue + } + return object + } + set { + if let encoded = try? JSONEncoder().encode(newValue) { + Preferences.sharedDefaults.set(encoded, forKey: key) + // Relevant for Combine publication + let subject = subject + DispatchQueue.main.async { + subject.send(newValue) + } + } + } + } + + public var projectedValue: AnyPublisher { + subject.eraseToAnyPublisher() + } + + init(_ key: String, defaultValue: T) { + self.key = key + self.defaultValue = defaultValue + + // Combine publication + if let data = Preferences.sharedDefaults.data(forKey: key), + let object = try? JSONDecoder().decode(T.self, from: data) { + subject = CurrentValueSubject(object) + } else { + subject = CurrentValueSubject(defaultValue) + } + } +} + @propertyWrapper public struct UserDefaultURL { private let key: String @@ -114,10 +158,25 @@ public enum Preferences { @UserDefault("defaultMainUIPath", defaultValue: "") public static var defaultMainUIPath: String @UserDefault("alwaysAllowWebRTC", defaultValue: false) public static var alwaysAllowWebRTC: Bool @UserDefault("sitemapForWatch", defaultValue: "watch") public static var sitemapForWatch: String + @UserDefaultObject("localConnectionConfig", defaultValue: ConnectionConfiguration( + url: "http://localhost:8080", + username: "", + password: "", + alwaysSendBasicAuth: false, + ignoreSSL: false, + priority: 0)) public static var localConnectionConfig: ConnectionConfiguration + @UserDefaultObject("connectionConfig", defaultValue: ConnectionConfiguration( + url: "https://myopenhab.org", + username: "", + password: "", + alwaysSendBasicAuth: false, + ignoreSSL: false, + priority: 1)) public static var remoteConnectionConfig: ConnectionConfiguration // MARK: - Private @UserDefault("didMigrateToSharedDefaults", defaultValue: false) private static var didMigrateToSharedDefaults: Bool + @UserDefault("didMigrateToConnectionConfig", defaultValue: false) private static var didMigrateToConnectionConfig: Bool } public extension Preferences { @@ -138,4 +197,52 @@ public extension Preferences { Preferences.defaultSitemap = UserDefaults.standard.string(forKey: "defaultSitemap") ?? Preferences.defaultSitemap Preferences.sendCrashReports = UserDefaults.standard.object(forKey: "sendCrashReports") as? Bool ?? Preferences.sendCrashReports } + + static func migrateUserDefaultsToConnectionIfRequired() { + guard !didMigrateToConnectionConfig else { return } + + let oldLocalUrl = UserDefaults.standard.string(forKey: "localUrl") ?? Preferences.localUrl + let oldRemoteUrl = UserDefaults.standard.string(forKey: "remoteUrl") ?? Preferences.remoteUrl + let oldUsername = UserDefaults.standard.string(forKey: "username") ?? Preferences.username + let oldPassword = UserDefaults.standard.string(forKey: "password") ?? Preferences.password + let oldAlwaysSendCreds = UserDefaults.standard.object(forKey: "alwaysSendCreds") as? Bool ?? Preferences.alwaysSendCreds + let oldIgnoreSSL = UserDefaults.standard.object(forKey: "ignoreSSL") as? Bool ?? Preferences.ignoreSSL + + // Create new configuration + let newLocalConfiguration = ConnectionConfiguration( + url: oldLocalUrl, + username: "", + password: "", + alwaysSendBasicAuth: oldAlwaysSendCreds, + ignoreSSL: oldIgnoreSSL, + priority: 0 + ) + + let newRemoteConfiguration = ConnectionConfiguration( + url: oldRemoteUrl, + username: oldUsername, + password: oldPassword, + alwaysSendBasicAuth: oldAlwaysSendCreds, + ignoreSSL: oldIgnoreSSL, + priority: 1 + ) + + // Save to Preferences + Preferences.localConnectionConfig = newLocalConfiguration + Preferences.remoteConnectionConfig = newRemoteConfiguration + didMigrateToConnectionConfig = true + // Ensure UserDefaults writes to disk immediately + Preferences.sharedDefaults.synchronize() + } +} + +public extension Preferences { + static func getLowestPriorityOpenHABConnection() -> ConnectionConfiguration? { + let allConnections = [localConnectionConfig, remoteConnectionConfig] + + return allConnections + .filter { $0.url.contains("openhab.org") } + .sorted { $0.priority < $1.priority } + .first + } } diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index ef95c8e24..b49b6011b 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -110,6 +110,7 @@ DA6B2EF72C8B92E800DF77CF /* SelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA6B2EF62C8B92E800DF77CF /* SelectionView.swift */; }; DA7224D223828D3400712D20 /* PreviewConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA7224D123828D3300712D20 /* PreviewConstants.swift */; }; DA72E1B8236DEA0900B8EF3A /* AppMessageService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA72E1B5236DEA0900B8EF3A /* AppMessageService.swift */; }; + DA77E19B2D886D9B007CFF0F /* SingleConnectionSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA77E19A2D886D9B007CFF0F /* SingleConnectionSettingsView.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 */; }; @@ -421,6 +422,7 @@ DA6B2EF62C8B92E800DF77CF /* SelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectionView.swift; sourceTree = ""; }; DA7224D123828D3300712D20 /* PreviewConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewConstants.swift; sourceTree = ""; }; DA72E1B5236DEA0900B8EF3A /* AppMessageService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppMessageService.swift; sourceTree = ""; }; + DA77E19A2D886D9B007CFF0F /* SingleConnectionSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SingleConnectionSettingsView.swift; sourceTree = ""; }; DA7E1E47222EB00B002AEFD8 /* PlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerView.swift; sourceTree = ""; }; DA817E79234BF39B00C91824 /* CHANGELOG.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = CHANGELOG.md; sourceTree = ""; }; DA88F8C522EC377100B408E5 /* ReleaseNotes.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = ReleaseNotes.md; sourceTree = ""; }; @@ -793,6 +795,7 @@ DA48001D2D837905009CF127 /* ApplicationSettingsView.swift */, DA5ED9BF2C8509C2004875E0 /* ClientCertificatesView.swift */, DA4800132D836892009CF127 /* ConnectionSettingsView.swift */, + DA77E19A2D886D9B007CFF0F /* SingleConnectionSettingsView.swift */, DA4800202D839D39009CF127 /* AnimatedSecureTextField.swift */, DA4800192D83742A009CF127 /* DebugSettingsView.swift */, DA4800152D836EF0009CF127 /* MainUISettingsView.swift */, @@ -1584,6 +1587,7 @@ DF4B84161886EACA00F34902 /* GenericUITableViewCell.swift in Sources */, 935B484625342B8E00E44CF0 /* URL+Static.swift in Sources */, B7D5ECE121499E55001B0EC6 /* MapViewTableViewCell.swift in Sources */, + DA77E19B2D886D9B007CFF0F /* SingleConnectionSettingsView.swift in Sources */, DA6B2EF52C89F8F200DF77CF /* ColorPickerView.swift in Sources */, DA4800142D836892009CF127 /* ConnectionSettingsView.swift in Sources */, 2F6412EE2CE494A80039FB28 /* DatePickerUITableViewCell.swift in Sources */, diff --git a/openHAB/AppDelegate.swift b/openHAB/AppDelegate.swift index 9013b24d3..ca789b5e6 100644 --- a/openHAB/AppDelegate.swift +++ b/openHAB/AppDelegate.swift @@ -81,6 +81,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { UserDefaults.standard.register(defaults: appDefaults) Preferences.migrateUserDefaultsIfRequired() + Preferences.migrateUserDefaultsToConnectionIfRequired() registerForPushNotifications() diff --git a/openHAB/DrawerView.swift b/openHAB/DrawerView.swift index 7d4a9fb65..75635a9c8 100644 --- a/openHAB/DrawerView.swift +++ b/openHAB/DrawerView.swift @@ -212,7 +212,7 @@ struct DrawerView: View { onDismiss(.settings) } - if Preferences.remoteUrl.contains("openhab.org"), !Preferences.demomode { + if Preferences.getLowestPriorityOpenHABConnection() != nil, !Preferences.demomode { HStack { Image(systemSymbol: .bell) .resizable() diff --git a/openHAB/NotificationsView.swift b/openHAB/NotificationsView.swift index d7fed4819..2800495ff 100644 --- a/openHAB/NotificationsView.swift +++ b/openHAB/NotificationsView.swift @@ -86,7 +86,10 @@ struct NotificationsView: View { private func loadNotifications() async -> [OpenHABNotification] { do { - let client = HTTPClient(username: Preferences.username, password: Preferences.username, alwaysSendBasicAuth: Preferences.alwaysSendCreds) + guard let config = Preferences.getLowestPriorityOpenHABConnection() else { + return [] + } + let client = HTTPClient(username: config.username, password: config.username, alwaysSendBasicAuth: config.alwaysSendBasicAuth) let data = try await client.notification(urlString: Preferences.remoteUrl) let decoder = JSONDecoder() decoder.dateDecodingStrategy = .formatted(DateFormatter.iso8601Full) diff --git a/openHAB/OpenHABRootViewController.swift b/openHAB/OpenHABRootViewController.swift index 2bd236b5e..c8e114e18 100644 --- a/openHAB/OpenHABRootViewController.swift +++ b/openHAB/OpenHABRootViewController.swift @@ -117,20 +117,16 @@ class OpenHABRootViewController: UIViewController { } fileprivate func setupTracker() { - let serverInfo = Publishers.CombineLatest4( - Preferences.$localUrl, - Preferences.$remoteUrl, - Preferences.$username, - Preferences.$password + let serverInfo = Publishers.CombineLatest( + Preferences.$localConnectionConfig, + Preferences.$remoteConnectionConfig ) .eraseToAnyPublisher() - let misc = Publishers.CombineLatest3( - Preferences.$demomode, - Preferences.$alwaysSendCreds, - Preferences.$ignoreSSL - ) - .eraseToAnyPublisher() +// let misc = Publishers.CombineLatest3( +// Preferences.$demomode, +// ) +// .eraseToAnyPublisher() // Register for certificate trust notifications NotificationCenter.default.addObserver( @@ -158,28 +154,22 @@ class OpenHABRootViewController: UIViewController { NetworkTracker.shared.restartTracking() } - Publishers.CombineLatest(serverInfo, misc) + Publishers.CombineLatest(serverInfo, Preferences.$demomode) .debounce(for: .milliseconds(500), scheduler: RunLoop.main) // ensures if multiple values are saved, we get called once .sink { (serverInfoTuple, miscTuple) in - let (localUrl, remoteUrl, username, password) = serverInfoTuple - let (demomode, alwaysSendCreds, ignoreSSL) = miscTuple + let (localConnectionConfig, remoteConnectionConfig) = serverInfoTuple + let (demomode) = miscTuple if demomode { NetworkTracker.shared.startTracking(connectionConfigurations: [ ConnectionConfiguration( url: "https://demo.openhab.org", + username: "", + password: "", priority: 0 ) - ], username: "", password: "", alwaysSendBasicAuth: false, ignoreSSLVerification: true) + ]) } else { - let connection1 = ConnectionConfiguration( - url: localUrl, - priority: 0 - ) - let connection2 = ConnectionConfiguration( - url: remoteUrl, - priority: 1 - ) - NetworkTracker.shared.startTracking(connectionConfigurations: [connection1, connection2], username: username, password: password, alwaysSendBasicAuth: alwaysSendCreds, ignoreSSLVerification: ignoreSSL) + NetworkTracker.shared.startTracking(connectionConfigurations: [localConnectionConfig, remoteConnectionConfig]) } } .store(in: &cancellables) @@ -294,21 +284,19 @@ class OpenHABRootViewController: UIViewController { @objc func handleApsRegistration(_ note: Notification?) { - os_log("handleApsRegistration", log: .notifications, type: .info) + logger.info("handleApsRegistration") let theData = note?.userInfo if theData != nil { - let prefsURL = Preferences.remoteUrl - if prefsURL.contains("openhab.org") { - guard let deviceId = theData?["deviceId"] as? String, let deviceToken = theData?["deviceToken"] as? String, let deviceName = theData?["deviceName"] as? String else { return } - os_log("Registering notifications with %{PUBLIC}@", log: .notifications, type: .info, prefsURL) - Task { - do { - let client = HTTPClient(username: Preferences.username, password: Preferences.password, alwaysSendBasicAuth: Preferences.alwaysSendCreds) - try await client.register(prefsURL: prefsURL, deviceToken: deviceToken, deviceId: deviceId, deviceName: deviceName) - os_log("my.openHAB registration succeeded", log: .notifications, type: .info) - } catch { - os_log("my.openHAB registration failed %{PUBLIC}@", log: .notifications, type: .error, error.localizedDescription) - } + guard let config = Preferences.getLowestPriorityOpenHABConnection() else { return } + guard let deviceId = theData?["deviceId"] as? String, let deviceToken = theData?["deviceToken"] as? String, let deviceName = theData?["deviceName"] as? String else { return } + logger.info("Registering notifications with \(config.url)") + Task { + do { + let client = HTTPClient(username: config.username, password: config.password, alwaysSendBasicAuth: config.alwaysSendBasicAuth) + try await client.register(prefsURL: config.url, deviceToken: deviceToken, deviceId: deviceId, deviceName: deviceName) + logger.info("my.openHAB registration succeeded") + } catch { + logger.error("my.openHAB registration failed \(error.localizedDescription)") } } } @@ -426,7 +414,7 @@ class OpenHABRootViewController: UIViewController { displayErrorNotification("Could not find server") } catch { displayErrorNotification("Failed to establish a connection: \(error.localizedDescription)") - // TODOD + // TODO: // logger.error("Could not send data \(error.localizedDescription)") // // self.displayErrorNotification("Request to \(url) failed: \(error.localizedDescription)") diff --git a/openHAB/OpenHABWebViewController.swift b/openHAB/OpenHABWebViewController.swift index 38b8e775e..58612de27 100644 --- a/openHAB/OpenHABWebViewController.swift +++ b/openHAB/OpenHABWebViewController.swift @@ -19,6 +19,7 @@ import WebKit class OpenHABWebViewController: OpenHABViewController { private var currentTarget = "" private var openHABTrackedRootUrl = "" + private var activeConfig: ConnectionConfiguration? private var hideNavBar = false private var activityIndicator: UIActivityIndicatorView! private var observation: NSKeyValueObservation? @@ -76,8 +77,10 @@ class OpenHABWebViewController: OpenHABViewController { .receive(on: DispatchQueue.main) .sink { activeConnection in if let activeConnection { - os_log("OpenHABWebViewController openHAB URL = %{PUBLIC}@", log: .remoteAccess, type: .info, "\(activeConnection.configuration.url)") - self.openHABTrackedRootUrl = activeConnection.configuration.url + let activeConfiguration = activeConnection.configuration + os_log("OpenHABWebViewController openHAB URL = %{PUBLIC}@", log: .remoteAccess, type: .info, "\(activeConfiguration.url)") + self.openHABTrackedRootUrl = activeConfiguration.url + self.activeConfig = activeConfiguration self.loadWebView(force: false) } } @@ -116,17 +119,17 @@ class OpenHABWebViewController: OpenHABViewController { } func loadWebView(force: Bool = false, path: String? = nil) { - os_log("loadWebView tracked URL: %{PUBLIC}@ forced %{PUBLIC}@", log: OSLog.remoteAccess, type: .info, openHABTrackedRootUrl, force ? "true" : "false") - - let authStr = "\(Preferences.username):\(Preferences.password)" - let newTarget = "\(openHABTrackedRootUrl):\(authStr)" + logger.info("loadWebView tracked URL: \(self.activeConfig?.url ?? "") forced \(force ? "true" : "false")") + guard let activeConfig else { return } + let authStr = "\(activeConfig.username):\(activeConfig.password)" + let newTarget = "\(activeConfig.url):\(authStr)" if !force, currentTarget == newTarget { showActivityIndicator(show: false) return } currentTarget = newTarget - let url = URL(string: openHABTrackedRootUrl) + let url = URL(string: activeConfig.url) if let modifiedUrl = modifyUrl(orig: url, path: path) { let request = URLRequest(url: modifiedUrl) diff --git a/openHAB/SettingsView/ConnectionSettingsView.swift b/openHAB/SettingsView/ConnectionSettingsView.swift index 864745dec..6deda2f93 100644 --- a/openHAB/SettingsView/ConnectionSettingsView.swift +++ b/openHAB/SettingsView/ConnectionSettingsView.swift @@ -9,93 +9,21 @@ // // SPDX-License-Identifier: EPL-2.0 +import OpenHABCore import SFSafeSymbols import SwiftUI struct ConnectionSettingsView: View { @Binding var settingsDemomode: Bool - @Binding var settingsLocalUrl: String - @Binding var settingsRemoteUrl: String - @Binding var settingsUsername: String - @Binding var settingsPassword: String - @Binding var settingsAlwaysSendCreds: Bool + @Binding var localConnectionConfiguration: ConnectionConfiguration + @Binding var remoteConnectionConfiguration: ConnectionConfiguration var body: some View { - Section(header: Text("OpenHAB Connection")) { - Toggle("Demo Mode", isOn: $settingsDemomode) + Toggle("Demo Mode", isOn: $settingsDemomode) - if !settingsDemomode { - LabeledContent { - Spacer() - TextField( - "Local URL", - text: $settingsLocalUrl - ) - .fixedSize() - .keyboardType(.URL) - .textInputAutocapitalization(.never) - .disableAutocorrection(true) - .font(.system(.caption)) - } label: { - Text("Local URL") - if settingsLocalUrl.isEmpty { - Text("Enter URL of local server") - } - } - - LabeledContent { - Spacer() - TextField( - "Remote URL", - text: $settingsRemoteUrl - ) - .fixedSize() - .keyboardType(.URL) - .textInputAutocapitalization(.never) - .disableAutocorrection(true) - .font(.system(.caption)) - } label: { - Text("Remote URL") - if settingsRemoteUrl.isEmpty { - Text("Enter URL of remote server") - } - } - - LabeledContent { - TextField( - "Foo", - text: $settingsUsername - ) - .textContentType(.username) // Associates with AutoFill - .fixedSize() - .textInputAutocapitalization(.never) - .disableAutocorrection(true) - .font(.system(.caption)) - } label: { - Text("Username") - if settingsUsername.isEmpty { - Text("Enter username on server, if required") - } - } - - LabeledContent { - AnimatedSecureTextField(text: $settingsPassword, titleKey: "password") - .fixedSize() - .textInputAutocapitalization(.never) - .disableAutocorrection(true) // or .autocorrectionDisabled(true) ?? - .font(.system(.caption)) - .textContentType(.password) // Associates with AutoFill - } label: { - Text("Password") - if settingsPassword.isEmpty { - Text("Enter password on server") - } - } - - Toggle(isOn: $settingsAlwaysSendCreds) { - Text("Always send credentials") - } - } + if !settingsDemomode { + SingleConnectionSettingsView(headerText: "Configuration for local server", connectionConfig: $localConnectionConfiguration) + SingleConnectionSettingsView(headerText: "Configuration for remote server", connectionConfig: $remoteConnectionConfiguration) } } } @@ -110,16 +38,24 @@ struct ConnectionSettingsView: View { @State var password = "password123" @State var alwaysSendCreds = true + @State var connectionConfig1 = ConnectionConfiguration( + url: "http://192.168.2.1", + username: "user", + password: "password123" + ) + @State var connectionConfig2 = ConnectionConfiguration( + url: "http://192.168.2.1", + username: "user", + password: "password123" + ) + var body: some View { NavigationView { Form { ConnectionSettingsView( settingsDemomode: $demoMode, - settingsLocalUrl: $localUrl, - settingsRemoteUrl: $remoteUrl, - settingsUsername: $username, - settingsPassword: $password, - settingsAlwaysSendCreds: $alwaysSendCreds + localConnectionConfiguration: $connectionConfig1, + remoteConnectionConfiguration: $connectionConfig2 ) } } diff --git a/openHAB/SettingsView/SettingsView.swift b/openHAB/SettingsView/SettingsView.swift index 28436278a..4a8aa15c5 100644 --- a/openHAB/SettingsView/SettingsView.swift +++ b/openHAB/SettingsView/SettingsView.swift @@ -32,6 +32,8 @@ struct SettingsView: View { @State var settingsSitemapForWatch = "" @State private var sitemaps: [OpenHABSitemap] = [] + @State var settingsLocalConnectionConfiguration = ConnectionConfiguration(url: "", username: "", password: "") + @State var settingsRemoteConnectionConfiguration = ConnectionConfiguration(url: "", username: "", password: "") @Environment(\.dismiss) private var dismiss @@ -45,11 +47,8 @@ struct SettingsView: View { Form { ConnectionSettingsView( settingsDemomode: $settingsDemomode, - settingsLocalUrl: $settingsLocalUrl, - settingsRemoteUrl: $settingsRemoteUrl, - settingsUsername: $settingsUsername, - settingsPassword: $settingsPassword, - settingsAlwaysSendCreds: $settingsAlwaysSendCreds + localConnectionConfiguration: $settingsLocalConnectionConfiguration, + remoteConnectionConfiguration: $settingsRemoteConnectionConfiguration ) ApplicationSettingsView( @@ -117,6 +116,8 @@ struct SettingsView: View { settingsDefaultMainUIPath = Preferences.defaultMainUIPath settingsAlwaysAllowWebRTC = Preferences.alwaysAllowWebRTC settingsSitemapForWatch = Preferences.sitemapForWatch + settingsLocalConnectionConfiguration = Preferences.localConnectionConfig + settingsRemoteConnectionConfiguration = Preferences.remoteConnectionConfig } func saveSettings() { @@ -135,6 +136,8 @@ struct SettingsView: View { Preferences.defaultMainUIPath = settingsDefaultMainUIPath Preferences.alwaysAllowWebRTC = settingsAlwaysAllowWebRTC Preferences.sitemapForWatch = settingsSitemapForWatch + Preferences.localConnectionConfig = settingsLocalConnectionConfiguration + Preferences.remoteConnectionConfig = settingsRemoteConnectionConfiguration WatchMessageService.singleton.syncPreferencesToWatch() Crashlytics.crashlytics().setCrashlyticsCollectionEnabled(settingsSendCrashReports) logger.debug("setCrashlyticsCollectionEnabled to \(settingsSendCrashReports)") @@ -183,6 +186,16 @@ extension UIApplication { page: nil // Replace with actual OpenHABPage if needed ) ] + @State var localConnectionConfiguration = ConnectionConfiguration( + url: "http://192.168.2.1", + username: "user", + password: "password123" + ) + @State var remoteConnectionConfiguration = ConnectionConfiguration( + url: "http://192.168.2.1", + username: "user", + password: "password123" + ) var body: some View { NavigationView { @@ -201,7 +214,9 @@ extension UIApplication { settingsSortSitemapsBy: settingsSortSitemapsBy, settingsDefaultMainUIPath: settingsDefaultMainUIPath, settingsAlwaysAllowWebRTC: settingsAlwaysAllowWebRTC, - settingsSitemapForWatch: settingsSitemapForWatch + settingsSitemapForWatch: settingsSitemapForWatch, + settingsLocalConnectionConfiguration: localConnectionConfiguration, + settingsRemoteConnectionConfiguration: remoteConnectionConfiguration ) } } diff --git a/openHAB/SettingsView/SingleConnectionSettingsView.swift b/openHAB/SettingsView/SingleConnectionSettingsView.swift new file mode 100644 index 000000000..015cad1dd --- /dev/null +++ b/openHAB/SettingsView/SingleConnectionSettingsView.swift @@ -0,0 +1,98 @@ +// Copyright (c) 2010-2025 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 OpenHABCore +import SFSafeSymbols +import SwiftUI + +struct SingleConnectionSettingsView: View { + var headerText: String + @Binding var connectionConfig: ConnectionConfiguration + + var body: some View { + Section(header: Text(headerText)) { + LabeledContent { + Spacer() + TextField( + "URL", + text: $connectionConfig.url + ) + .fixedSize() + .keyboardType(.URL) + .textInputAutocapitalization(.never) + .disableAutocorrection(true) + .font(.system(.caption)) + } label: { + Text("URL") + if connectionConfig.url.isEmpty { + Text("Enter URL of remote server") + } + } + + LabeledContent { + TextField( + "Foo", + text: $connectionConfig.username + ) + .textContentType(.username) // Associates with AutoFill + .fixedSize() + .textInputAutocapitalization(.never) + .disableAutocorrection(true) + .font(.system(.caption)) + } label: { + Text("Username") + if connectionConfig.username.isEmpty { + Text("Enter username on server, if required") + } + } + + LabeledContent { + AnimatedSecureTextField(text: $connectionConfig.password, titleKey: "password") + .fixedSize() + .textInputAutocapitalization(.never) + .disableAutocorrection(true) // or .autocorrectionDisabled(true) ?? + .font(.system(.caption)) + .textContentType(.password) // Associates with AutoFill + } label: { + Text("Password") + if connectionConfig.password.isEmpty { + Text("Enter password on server") + } + } + + Toggle(isOn: $connectionConfig.alwaysSendBasicAuth) { + Text("Always send credentials") + } + + Toggle("Ignore SSL certificates", isOn: $connectionConfig.ignoreSSL) + } + } +} + +// **TODO Migrate to @Previewable on iOS 17 +#Preview { + struct PreviewWrapper: View { + @State var connectionConfig = ConnectionConfiguration( + url: "http://192.168.2.1", + username: "user", + password: "password123" + ) + + var body: some View { + NavigationView { + Form { + SingleConnectionSettingsView(headerText: "Connection Settings for local server", connectionConfig: $connectionConfig) + } + } + } + } + return PreviewWrapper() +} diff --git a/openHABWatch/Domain/UserData.swift b/openHABWatch/Domain/UserData.swift index 74446a2f8..3095850ab 100644 --- a/openHABWatch/Domain/UserData.swift +++ b/openHABWatch/Domain/UserData.swift @@ -110,22 +110,21 @@ final class UserData: ObservableObject { func updateNetwork() async { if !ObservableOpenHABDataObject.shared.localUrl.isEmpty || !ObservableOpenHABDataObject.shared.remoteUrl.isEmpty { + // TODO: let connection1 = ConnectionConfiguration( url: ObservableOpenHABDataObject.shared.localUrl, + username: "", + password: "", priority: 0 ) let connection2 = ConnectionConfiguration( url: ObservableOpenHABDataObject.shared.remoteUrl, + username: "", + password: "", priority: 1 ) - NetworkTracker.shared.startTracking( - connectionConfigurations: [connection1, connection2], - username: ObservableOpenHABDataObject.shared.openHABUsername, - password: ObservableOpenHABDataObject.shared.openHABPassword, - alwaysSendBasicAuth: ObservableOpenHABDataObject.shared.openHABAlwaysSendCreds, - ignoreSSLVerification: ObservableOpenHABDataObject.shared.ignoreSSL - ) + NetworkTracker.shared.startTracking(connectionConfigurations: [connection1, connection2]) } } From 6346d02bf91adc37711167f5f2eb28307f1eb9f8 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Tue, 18 Mar 2025 08:07:28 +0100 Subject: [PATCH 091/476] Migration to configuration setup for local and remote server Changed path for getRoot to '/' Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../GeneratedSources/openapi/Client.swift | 2 +- .../Util/AuthorisationMiddleware.swift | 8 +- .../Sources/OpenHABCore/Util/HTTPClient.swift | 14 +- .../OpenHABCore/Util/NetworkTracker.swift | 6 +- .../OpenHABCore/Util/OpenAPIService.swift | 60 +++--- .../Util/OpenAPIServiceDelegate.swift | 203 +++++++++++++++--- openHAB/DrawerView.swift | 11 +- openHAB/OpenHABSitemapViewController.swift | 21 +- .../ApplicationSettingsView.swift | 126 +---------- openHAB/SettingsView/DebugSettingsView.swift | 61 +++++- openHAB/SettingsView/SettingsView.swift | 8 +- 11 files changed, 306 insertions(+), 214 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Client.swift b/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Client.swift index c2a733968..2c7497aff 100644 --- a/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Client.swift +++ b/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Client.swift @@ -2054,7 +2054,7 @@ public struct Client: APIProtocol { forOperation: Operations.getRoot.id, serializer: { input in let path = try converter.renderedPath( - template: "//", + template: "/", parameters: [] ) var request: HTTPTypes.HTTPRequest = .init( diff --git a/OpenHABCore/Sources/OpenHABCore/Util/AuthorisationMiddleware.swift b/OpenHABCore/Sources/OpenHABCore/Util/AuthorisationMiddleware.swift index a2f34b563..929959bba 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/AuthorisationMiddleware.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/AuthorisationMiddleware.swift @@ -20,10 +20,10 @@ public struct AuthorisationMiddleware { private let password: String private let alwaysSendBasicAuth: Bool - public init(username: String, password: String, alwaysSendBasicAuth: Bool = false) { - self.username = username - self.password = password - self.alwaysSendBasicAuth = alwaysSendBasicAuth + public init(configuration: ConnectionConfiguration) { + username = configuration.username + password = configuration.password + alwaysSendBasicAuth = configuration.alwaysSendBasicAuth } } diff --git a/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift b/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift index 9214a3fe8..c8a52e6d0 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift @@ -44,16 +44,16 @@ private enum HTTPClientError: Error { } } +public enum CertificateEvaluateResult { + case undecided + case deny + case permitOnce + case permitAlways +} + public class HTTPClient: NSObject { // MARK: - Properties - public enum CertificateEvaluateResult { - case undecided - case deny - case permitOnce - case permitAlways - } - public enum SessionType { case download case data diff --git a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift index 1e31bafc5..cf6315980 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift @@ -55,11 +55,7 @@ actor ConnectionPool { return existingService } let newService = OpenAPIService( - baseURL: URL(string: configuration.url) ?? URL(staticString: "about:blank"), - username: configuration.username, - password: configuration.password, - alwaysSendBasicAuth: configuration.alwaysSendBasicAuth, - ignoreSSL: configuration.ignoreSSL, + connectionConfiguration: configuration, configuration: .shortTerm ) services[configuration] = newService diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift index 242937df6..59527cee3 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift @@ -33,25 +33,20 @@ public actor OpenAPIService { private var client: any APIProtocol private var url: URL? private var longPolling = false - - private var username: String = "" - private var password: String = "" - private var alwaysSendBasicAuth = false - private var ignoreSSL = false + private var connectionConfiguration: ConnectionConfiguration private let logger = Logger(subsystem: "org.openhab.app", category: "OpenAPIService") /// Creates a new client for OpenAPIService. public init(client: any APIProtocol) { self.client = client + connectionConfiguration = ConnectionConfiguration(url: "", username: "", password: "") } - public init(baseURL url: URL = URL(staticString: "about:blank"), - username: String, - password: String, - alwaysSendBasicAuth: Bool = false, - ignoreSSL: Bool = false, + public init(connectionConfiguration: ConnectionConfiguration, configuration: OpenAPIServiceConfiguration = .asDefault) { + self.connectionConfiguration = connectionConfiguration + let delegate = OpenAPIServiceDelegate(with: connectionConfiguration) let config = URLSessionConfiguration.default switch configuration { case .asDefault: @@ -63,33 +58,36 @@ public actor OpenAPIService { config.timeoutIntervalForRequest = 2.0 config.timeoutIntervalForResource = 2.0 } - - let delegate = OpenAPIServiceDelegate(username: username, password: password) let session = URLSession(configuration: config, delegate: delegate, delegateQueue: nil) - self.username = username - self.password = password - self.alwaysSendBasicAuth = alwaysSendBasicAuth - self.ignoreSSL = ignoreSSL + let url = URL(string: connectionConfiguration.url) ?? URL(staticString: "about:blank") + let resolvedURL = OpenAPIService.getServerURL(for: url) + self.url = resolvedURL + + let serverURL = resolvedURL.appending(path: "/rest") + + client = Client( + serverURL: serverURL, + transport: URLSessionTransport(configuration: .init(session: session)), + middlewares: [ + LoggingMiddleware(), + AuthorisationMiddleware(configuration: connectionConfiguration) + ] + ) + } + private static func getServerURL(for url: URL) -> URL { if let urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false), let host = urlComponents.host, host.contains("myopenhab.org"), host != "home.myopenhab.org" { +// URL(string: "https://home.myopenhab.org")! var newComponents = urlComponents newComponents.host = "home.myopenhab.org" - self.url = newComponents.url +// newComponents.scheme = "https" + return newComponents.url! } else { - self.url = url + return url } - - client = Client( - serverURL: url.appending(path: "/rest"), - transport: URLSessionTransport(configuration: .init(session: session)), - middlewares: [ - AuthorisationMiddleware(username: username, password: password, alwaysSendBasicAuth: alwaysSendBasicAuth), - LoggingMiddleware() - ] - ) } private func prepareURLSessionConfiguration(longPolling: Bool) -> URLSessionConfiguration { @@ -109,8 +107,8 @@ public actor OpenAPIService { serverURL: newURL.appending(path: "/rest"), transport: URLSessionTransport(configuration: .init(session: session)), middlewares: [ - AuthorisationMiddleware(username: username, password: password), - LoggingMiddleware() + LoggingMiddleware(), + AuthorisationMiddleware(configuration: connectionConfiguration) ] ) } @@ -126,8 +124,8 @@ public actor OpenAPIService { serverURL: url!.appending(path: "/rest"), transport: URLSessionTransport(configuration: .init(session: session)), middlewares: [ - AuthorisationMiddleware(username: username, password: password), - LoggingMiddleware() + LoggingMiddleware(), + AuthorisationMiddleware(configuration: connectionConfiguration) ] ) } diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIServiceDelegate.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIServiceDelegate.swift index 9f1543328..075dd9459 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIServiceDelegate.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIServiceDelegate.swift @@ -15,13 +15,15 @@ import os // MARK: - URLSessionDelegate for Client Certificates and Basic Auth final class OpenAPIServiceDelegate: NSObject, URLSessionDelegate, URLSessionTaskDelegate { - private let username: String - private let password: String + private let connectionConfiguration: ConnectionConfiguration private let authTracker = AuthAttemptTracker() // ✅ Use an actor instead of a dictionary + private var trustedCertificates: [String: Data] = [:] + private var evaluateContinuation: CheckedContinuation? - init(username: String, password: String) { - self.username = username - self.password = password + private let logger = Logger(subsystem: "org.openhab.core", category: "OpenAPIServiceDelegate") + + init(with connectionConfiguration: ConnectionConfiguration) { + self.connectionConfiguration = connectionConfiguration } public func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { @@ -33,28 +35,18 @@ final class OpenAPIServiceDelegate: NSObject, URLSessionDelegate, URLSessionTask } private func urlSessionInternal(_ session: URLSession, task: URLSessionTask?, didReceive challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { - os_log("URLAuthenticationChallenge for : %{public}@", log: .networking, type: .info, challenge.protectionSpace.authenticationMethod) + os_log("URLAuthenticationChallenge: %{public}@", log: .networking, type: .info, challenge.protectionSpace.authenticationMethod) let authenticationMethod = challenge.protectionSpace.authenticationMethod switch authenticationMethod { case NSURLAuthenticationMethodServerTrust: let result = await handleServerTrust(challenge: challenge) - await authTracker.resetAttempt(for: task) // ✅ Reset on success return result case NSURLAuthenticationMethodDefault, NSURLAuthenticationMethodHTTPBasic: - if let task { - let attemptCount = await authTracker.incrementAttempt(for: task) // ✅ Call actor asynchronously - if attemptCount > 1 { - return (.cancelAuthenticationChallenge, nil) - } else { - let result = await handleBasicAuth(challenge: challenge) - return result - } - } else { - return await handleBasicAuth(challenge: challenge) - } + + let result = handleBasicAuth(challenge: challenge) + return result case NSURLAuthenticationMethodClientCertificate: - let result = await handleClientCertificateAuth(challenge: challenge) - await authTracker.resetAttempt(for: task) // ✅ Reset on success + let result = handleClientCertificateAuth(challenge: challenge) return result default: return (.performDefaultHandling, nil) @@ -62,21 +54,180 @@ final class OpenAPIServiceDelegate: NSObject, URLSessionDelegate, URLSessionTask } private func handleServerTrust(challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { + let domain = challenge.protectionSpace.host + logger.info("Handling server trust for domain: \(domain)") + guard let serverTrust = challenge.protectionSpace.serverTrust else { - return (.performDefaultHandling, nil) + logger.error("No server trust object available") + return (.cancelAuthenticationChallenge, nil) + } + + var result: SecTrustResultType = .invalid + var error: CFError? + _ = SecTrustEvaluateWithError(serverTrust, &error) + SecTrustGetTrustResult(serverTrust, &result) + logger.info("Trust evaluation result: \(result.rawValue), error: \(String(describing: error))") + + if result.isAny(of: .unspecified, .proceed) || connectionConfiguration.ignoreSSL { + logger.info("Certificate is trusted or SSL verification ignored") + return (.useCredential, URLCredential(trust: serverTrust)) + } + + guard let certificate = getLeafCertificate(trust: serverTrust) else { + logger.error("Could not get leaf certificate") + return (.cancelAuthenticationChallenge, nil) + } + + let certificateSummary = SecCertificateCopySubjectSummary(certificate) + let certificateData = SecCertificateCopyData(certificate) + + // If we have a certificate for this domain + if let previousCertificateData = self.certificateData(forDomain: domain) { + if CFEqual(previousCertificateData, certificateData) { + logger.info("Using previously trusted certificate for domain: \(domain)") + return (.useCredential, URLCredential(trust: serverTrust)) + } else { + logger.warning("Certificate mismatch detected for domain: \(domain)") + // Certificate mismatch - possible MitM attack + NotificationCenter.default.post( + name: .evaluateCertificateMismatch, + object: self, + userInfo: ["summary": certificateSummary as Any, "domain": domain] + ) + let evaluateResult = await waitForEvaluation() + logger.info("User decision for certificate mismatch: \(String(describing: evaluateResult))") + + switch evaluateResult { + case .deny: + return (.cancelAuthenticationChallenge, nil) + case .permitOnce: + return (.useCredential, URLCredential(trust: serverTrust)) + case .permitAlways: + storeCertificateData(certificateData, forDomain: domain) + NotificationCenter.default.post(name: .acceptedServerCertificatesChanged, object: self) + return (.useCredential, URLCredential(trust: serverTrust)) + case .undecided: + return (.cancelAuthenticationChallenge, nil) + } + } + } + + // New certificate + logger.info("New untrusted certificate for domain: \(domain)") + NotificationCenter.default.post( + name: .evaluateServerTrust, + object: self, + userInfo: ["summary": certificateSummary as Any, "domain": domain] + ) + let evaluateResult = await waitForEvaluation() + logger.info("User decision for new certificate: \(String(describing: evaluateResult))") + + switch evaluateResult { + case .deny: + return (.cancelAuthenticationChallenge, nil) + case .permitOnce: + return (.useCredential, URLCredential(trust: serverTrust)) + case .permitAlways: + storeCertificateData(certificateData, forDomain: domain) + NotificationCenter.default.post(name: .acceptedServerCertificatesChanged, object: self) + return (.useCredential, URLCredential(trust: serverTrust)) + case .undecided: + return (.cancelAuthenticationChallenge, nil) } - let credential = URLCredential(trust: serverTrust) - return (.useCredential, credential) } - private func handleBasicAuth(challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { - let credential = URLCredential(user: username, password: password, persistence: .forSession) + private func handleBasicAuth(challenge: URLAuthenticationChallenge) -> (URLSession.AuthChallengeDisposition, URLCredential?) { + let credential = URLCredential(user: connectionConfiguration.username, password: connectionConfiguration.password, persistence: .forSession) return (.useCredential, credential) } - private func handleClientCertificateAuth(challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { + private func handleClientCertificateAuth(challenge: URLAuthenticationChallenge) -> (URLSession.AuthChallengeDisposition, URLCredential?) { let certificateManager = ClientCertificateManager() let (disposition, credential) = certificateManager.evaluateTrust(with: challenge) return (disposition, credential) } + + // MARK: - SSL Certificate Handling + + private func initializeCertificatesStore() { + os_log("Initializing cert store", log: .default, type: .info) + loadTrustedCertificates() + if trustedCertificates.isEmpty { + os_log("No cert store, creating", log: .default, type: .info) + trustedCertificates = [:] + saveTrustedCertificates() + } else { + os_log("Loaded existing cert store", log: .default, type: .info) + } + } + + private func getPersistencePath() -> URL { + #if os(watchOS) + let documentsDirectory = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true)[0] + return URL(fileURLWithPath: documentsDirectory).appendingPathComponent("trustedCertificates") + #else + FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.org.openhab.app")!.appendingPathComponent("trustedCertificates") + #endif + } + + private func saveTrustedCertificates() { + do { + let data = try PropertyListEncoder().encode(trustedCertificates) + try data.write(to: getPersistencePath()) + } catch { + os_log("Could not save trusted certificates", log: .default) + } + } + + private func loadTrustedCertificates() { + var decodableTrustedCertificates: [String: Data] = [:] + do { + let rawdata = try Data(contentsOf: getPersistencePath()) + let decoder = PropertyListDecoder() + decodableTrustedCertificates = try decoder.decode([String: Data].self, from: rawdata) + trustedCertificates = decodableTrustedCertificates + } catch { + // if Decodable fails, fall back to NSKeyedArchiver + do { + let rawdata = try Data(contentsOf: getPersistencePath()) + if let unarchivedTrustedCertificates = try? NSKeyedUnarchiver.unarchivedObject(ofClasses: [NSDictionary.self, NSString.self, NSData.self], from: rawdata) as? [String: Data] { + trustedCertificates = unarchivedTrustedCertificates + saveTrustedCertificates() // Ensure that data is written in new format + } + } catch { + os_log("Could not load trusted certificates", log: .default) + } + } + } + + private func storeCertificateData(_ certificate: CFData?, forDomain domain: String) { + let certificateData = certificate as Data? + trustedCertificates[domain] = certificateData + saveTrustedCertificates() + } + + private func certificateData(forDomain domain: String) -> CFData? { + guard let certificateData = trustedCertificates[domain] else { return nil } + return certificateData as CFData + } + + private func getLeafCertificate(trust: SecTrust?) -> SecCertificate? { + if let trust, SecTrustGetCertificateCount(trust) > 0, + let certificates = SecTrustCopyCertificateChain(trust) as? [SecCertificate] { + return certificates[0] + } + return nil + } + + private func waitForEvaluation() async -> CertificateEvaluateResult { + await withCheckedContinuation { continuation in + evaluateContinuation = continuation + } + } + + public func completeEvaluation(_ result: CertificateEvaluateResult) { + logger.info("Completing evaluation with result: \(String(describing: result))") + evaluateContinuation?.resume(returning: result) + evaluateContinuation = nil + } } diff --git a/openHAB/DrawerView.swift b/openHAB/DrawerView.swift index 75635a9c8..01fef079a 100644 --- a/openHAB/DrawerView.swift +++ b/openHAB/DrawerView.swift @@ -271,7 +271,16 @@ struct DrawerView: View { } .listStyle(.inset) .task { - let openAPIService = OpenAPIService(username: appData?.openHABUsername ?? "", password: appData?.openHABPassword ?? "", alwaysSendBasicAuth: appData?.openHABAlwaysSendCreds ?? false, ignoreSSL: false) + let initialConfiguration = ConnectionConfiguration( + url: appData!.openHABRootUrl, + username: appData?.openHABUsername ?? "", + password: appData?.openHABUsername ?? "", + alwaysSendBasicAuth: appData?.openHABAlwaysSendCreds ?? false, + ignoreSSL: false + ) + let openAPIService = OpenAPIService( + connectionConfiguration: initialConfiguration + ) Task { do { diff --git a/openHAB/OpenHABSitemapViewController.swift b/openHAB/OpenHABSitemapViewController.swift index 00563d2f2..b37208cd0 100644 --- a/openHAB/OpenHABSitemapViewController.swift +++ b/openHAB/OpenHABSitemapViewController.swift @@ -166,11 +166,15 @@ class OpenHABSitemapViewController: OpenHABViewController { pageNetworkStatus = nil sitemaps = [] widgetTableView.tableFooterView = UIView() + let initialConfiguration = ConnectionConfiguration( + url: "", + username: "", + password: "" + ) + openAPIService = OpenAPIService( - baseURL: URL(string: openHABRootUrl) ?? URL(staticString: "about:blank"), - username: openHABUsername, - password: openHABPassword, - alwaysSendBasicAuth: openHABAlwaysSendCreds + connectionConfiguration: initialConfiguration, + configuration: .shortTerm ) // ✅ Initialize PageLoader @@ -550,12 +554,15 @@ extension OpenHABSitemapViewController { Task { do { logger.debug("Running selectSitemap for URL: \(self.appData?.openHABRootUrl ?? "")") - openAPIService = OpenAPIService( - baseURL: URL(string: appData!.openHABRootUrl) ?? URL(staticString: "about:blank"), + let initialConfiguration = ConnectionConfiguration( + url: appData!.openHABRootUrl, username: appData!.openHABUsername, - password: appData!.openHABPassword, + password: appData!.openHABUsername, alwaysSendBasicAuth: appData!.openHABAlwaysSendCreds ) + openAPIService = OpenAPIService( + connectionConfiguration: initialConfiguration) + sitemaps = try await openAPIService?.openHABSitemaps() ?? [] guard let openAPIService else { diff --git a/openHAB/SettingsView/ApplicationSettingsView.swift b/openHAB/SettingsView/ApplicationSettingsView.swift index c734bbed8..9a1333d91 100644 --- a/openHAB/SettingsView/ApplicationSettingsView.swift +++ b/openHAB/SettingsView/ApplicationSettingsView.swift @@ -11,144 +11,22 @@ import OpenHABCore import os -import SafariServices import SwiftUI -// struct ApplicationSettingsView: View { -// @Binding var settingsIgnoreSSL: Bool -// @Binding var settingsIdleOff: Bool -// @Binding var settingsSendCrashReports: Bool -// -// @State var showCrashReportingAlert = false -// @State private var hasBeenLoaded = false -// -// private let logger = Logger(subsystem: "org.openhab.app", category: "ApplicationSettingsView") -// -// var body: some View { -// Section(header: Text(LocalizedStringKey("application_settings"))) { -// Toggle(isOn: $settingsIgnoreSSL) { -// Text("Ignore SSL certificates") -// } -// -// Toggle(isOn: $settingsIdleOff) { -// Text("Disable Idle Timeout") -// } -// -// Toggle(isOn: $settingsSendCrashReports) { -// Text("Crash Reporting") -// } -// -// .onAppear { -// // Setting .onAppear of view required here because onAppear of entire view is run after .onChange is active -// // when migrating to iOS17 this -// settingsSendCrashReports = Preferences.sendCrashReports -// // loadSitemaps() -// hasBeenLoaded = true -// } -// .onChange(of: settingsSendCrashReports) { newValue in -// if ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == nil { -// logger.debug("Detected change on settingsSendCrashReports") -// } -// if newValue, hasBeenLoaded { -// showCrashReportingAlert = true -// } -// } -// .confirmationDialog( -// "crash_reporting", -// isPresented: $showCrashReportingAlert -// ) { -// Button(role: .destructive) { -// settingsSendCrashReports = true -// } label: { -// Text(LocalizedStringKey("activate")) -// } -// Button(LocalizedStringKey("privacy_policy")) { -// presentPrivacyPolicy() -// settingsSendCrashReports = false -// } -// Button(role: .cancel) { -// settingsSendCrashReports = false -// } label: { -// Text(LocalizedStringKey("cancel")) -// } -// } message: { -// Text(LocalizedStringKey("crash_reporting_info")) -// } -// -// NavigationLink { -// ClientCertificatesView() -// } label: { -// Text("Client Certificates") -// } -// } -// } -// -// func presentPrivacyPolicy() { -// if ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == nil { -// let vc = SFSafariViewController(url: .privacyPolicy) -// UIApplication.shared.firstKeyWindow?.rootViewController?.present(vc, animated: true) -// } -// } -// } - struct ApplicationSettingsView: View { - @Binding var settingsIgnoreSSL: Bool @Binding var settingsIdleOff: Bool - @Binding var settingsSendCrashReports: Bool - - @State var showCrashReportingAlert = false - @State private var hasBeenLoaded = false private let logger = Logger(subsystem: "org.openhab.app", category: "ApplicationSettingsView") var body: some View { Section(header: Text(LocalizedStringKey("application_settings"))) { - Toggle("Ignore SSL certificates", isOn: $settingsIgnoreSSL) Toggle("Disable Idle Timeout", isOn: $settingsIdleOff) - Toggle("Crash Reporting", isOn: $settingsSendCrashReports) - .task { - settingsSendCrashReports = Preferences.sendCrashReports - } - .onChange(of: settingsSendCrashReports) { newValue in - #if !DEBUG - logger.debug("Detected change on settingsSendCrashReports") - #endif - if newValue, hasBeenLoaded { - showCrashReportingAlert = true - } - } - .confirmationDialog( - "crash_reporting", - isPresented: $showCrashReportingAlert - ) { - Button(role: .destructive) { - settingsSendCrashReports = true - } label: { - Text(LocalizedStringKey("activate")) - } - Button(LocalizedStringKey("privacy_policy")) { - presentPrivacyPolicy() - settingsSendCrashReports = false - } - Button(role: .cancel) { - settingsSendCrashReports = false - } label: { - Text(LocalizedStringKey("cancel")) - } - } message: { - Text(LocalizedStringKey("crash_reporting_info")) - } NavigationLink("Client Certificates") { ClientCertificatesView() } } } - - func presentPrivacyPolicy() { - let vc = SFSafariViewController(url: .privacyPolicy) - UIApplication.shared.firstKeyWindow?.rootViewController?.present(vc, animated: true) - } } #Preview { @@ -160,9 +38,7 @@ struct ApplicationSettingsView: View { var body: some View { Form { ApplicationSettingsView( - settingsIgnoreSSL: $ignoreSSL, - settingsIdleOff: $idleOff, - settingsSendCrashReports: $sendCrashReports + settingsIdleOff: $idleOff ) } } diff --git a/openHAB/SettingsView/DebugSettingsView.swift b/openHAB/SettingsView/DebugSettingsView.swift index 7f3757a93..020a87060 100644 --- a/openHAB/SettingsView/DebugSettingsView.swift +++ b/openHAB/SettingsView/DebugSettingsView.swift @@ -9,10 +9,50 @@ // // SPDX-License-Identifier: EPL-2.0 +import OpenHABCore +import SafariServices import SwiftUI struct DebugSettingsView: View { + @Binding var settingsSendCrashReports: Bool + + @State private var hasBeenLoaded = false + @State var showCrashReportingAlert = false + var body: some View { + Toggle("Crash Reporting", isOn: $settingsSendCrashReports) + .task { + settingsSendCrashReports = Preferences.sendCrashReports + } + .onChange(of: settingsSendCrashReports) { newValue in + #if !DEBUG + logger.debug("Detected change on settingsSendCrashReports") + #endif + if newValue, hasBeenLoaded { + showCrashReportingAlert = true + } + } + .confirmationDialog( + "crash_reporting", + isPresented: $showCrashReportingAlert + ) { + Button(role: .destructive) { + settingsSendCrashReports = true + } label: { + Text(LocalizedStringKey("activate")) + } + Button(LocalizedStringKey("privacy_policy")) { + presentPrivacyPolicy() + settingsSendCrashReports = false + } + Button(role: .cancel) { + settingsSendCrashReports = false + } label: { + Text(LocalizedStringKey("cancel")) + } + } message: { + Text(LocalizedStringKey("crash_reporting_info")) + } Section(header: Text(LocalizedStringKey("debug"))) { NavigationLink { LoggerView() @@ -21,12 +61,27 @@ struct DebugSettingsView: View { } } } + + func presentPrivacyPolicy() { + let vc = SFSafariViewController(url: .privacyPolicy) + UIApplication.shared.firstKeyWindow?.rootViewController?.present(vc, animated: true) + } } #Preview { - NavigationView { - Form { - DebugSettingsView() + struct PreviewWrapper: View { + @State private var ignoreSSL = true + @State private var idleOff = false + @State private var sendCrashReports = false + + var body: some View { + Form { + DebugSettingsView( + settingsSendCrashReports: $sendCrashReports + ) + } } } + + return PreviewWrapper() } diff --git a/openHAB/SettingsView/SettingsView.swift b/openHAB/SettingsView/SettingsView.swift index 4a8aa15c5..0eefa8c06 100644 --- a/openHAB/SettingsView/SettingsView.swift +++ b/openHAB/SettingsView/SettingsView.swift @@ -52,9 +52,7 @@ struct SettingsView: View { ) ApplicationSettingsView( - settingsIgnoreSSL: $settingsIgnoreSSL, - settingsIdleOff: $settingsIdleOff, - settingsSendCrashReports: $settingsSendCrashReports + settingsIdleOff: $settingsIdleOff ) MainUISettingsView( @@ -70,7 +68,9 @@ struct SettingsView: View { sitemaps: $sitemaps ) - DebugSettingsView() + DebugSettingsView( + settingsSendCrashReports: $settingsSendCrashReports + ) AboutSettingsView() } From 76289d47d203aeb93603a4e9ef52c3bd0adcaa69 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Tue, 18 Mar 2025 16:31:39 +0100 Subject: [PATCH 092/476] Make tests work again / adapt to change in getRoot, when loading and saving url in a codable, ensure the string is well formatted Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../Util/ConnectionConfiguration.swift | 47 ++++++++++++++++++- .../OpenHABCoreTests/TestOpenAPIClient.swift | 2 +- openHAB.xcodeproj/project.pbxproj | 6 ++- .../SingleConnectionSettingsView.swift | 9 ++-- 4 files changed, 57 insertions(+), 7 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/ConnectionConfiguration.swift b/OpenHABCore/Sources/OpenHABCore/Util/ConnectionConfiguration.swift index dbcfd89ba..598e05f5a 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/ConnectionConfiguration.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/ConnectionConfiguration.swift @@ -18,11 +18,56 @@ public struct ConnectionConfiguration: Hashable, Sendable, Codable { public var priority: Int // Lower is higher priority, 0 is primary public init(url: String, username: String, password: String, alwaysSendBasicAuth: Bool = false, ignoreSSL: Bool = false, priority: Int = 10) { - self.url = url + self.url = ConnectionConfiguration.normalizeURL(url) self.username = username self.password = password self.alwaysSendBasicAuth = alwaysSendBasicAuth self.ignoreSSL = ignoreSSL self.priority = priority } + + // 🔹 Ensure normalization on decoding + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let rawURL = try container.decode(String.self, forKey: .url) // Decode raw URL + url = ConnectionConfiguration.normalizeURL(rawURL) // Normalize it + username = try container.decode(String.self, forKey: .username) + password = try container.decode(String.self, forKey: .password) + alwaysSendBasicAuth = try container.decode(Bool.self, forKey: .alwaysSendBasicAuth) + ignoreSSL = try container.decode(Bool.self, forKey: .ignoreSSL) + priority = try container.decode(Int.self, forKey: .priority) + } + + // 🔹 Ensure normalization on encoding (optional, since we store it normalized) + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(url, forKey: .url) // Already normalized + try container.encode(username, forKey: .username) + try container.encode(password, forKey: .password) + try container.encode(alwaysSendBasicAuth, forKey: .alwaysSendBasicAuth) + try container.encode(ignoreSSL, forKey: .ignoreSSL) + try container.encode(priority, forKey: .priority) + } + + // 🔹 Normalize a URL (removes trailing slashes, trims spaces, redirects openHAB cloud) + private static func normalizeURL(_ url: String) -> String { + var cleanedURL = url.trimmingCharacters(in: .whitespacesAndNewlines) // Trim whitespace + cleanedURL = uriWithoutTrailingSlashes(cleanedURL) // Remove trailing slashes + + return cleanedURL + } + + /// Removes trailing slashes from a URL string + private static func uriWithoutTrailingSlashes(_ url: String) -> String { + var newUrl = url + while newUrl.hasSuffix("/") { + newUrl.removeLast() + } + return newUrl + } + + // 🔹 Coding keys for manual encoding/decoding + private enum CodingKeys: String, CodingKey { + case url, username, password, alwaysSendBasicAuth, ignoreSSL, priority + } } diff --git a/OpenHABCore/Tests/OpenHABCoreTests/TestOpenAPIClient.swift b/OpenHABCore/Tests/OpenHABCoreTests/TestOpenAPIClient.swift index 01d3a0b09..fb6f54d2b 100644 --- a/OpenHABCore/Tests/OpenHABCoreTests/TestOpenAPIClient.swift +++ b/OpenHABCore/Tests/OpenHABCoreTests/TestOpenAPIClient.swift @@ -37,7 +37,7 @@ final class TestOpenAPIClient: XCTestCase { XCTAssertEqual(operationID, "getRoot") XCTAssertEqual( request.path, - "//" + "/" ) XCTAssertEqual(baseURL.absoluteString, "/rest") XCTAssertEqual(request.method, .get) diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index b49b6011b..4654c754d 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -1912,10 +1912,11 @@ CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CODE_SIGN_IDENTITY = "Apple Distribution"; - CODE_SIGN_STYLE = Manual; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 33; DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = PBAPXHRAM9; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; INFOPLIST_FILE = openHABUITests/Info.plist; @@ -1930,6 +1931,7 @@ MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = org.openhab.openHABUITests; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; diff --git a/openHAB/SettingsView/SingleConnectionSettingsView.swift b/openHAB/SettingsView/SingleConnectionSettingsView.swift index 015cad1dd..ddfc97592 100644 --- a/openHAB/SettingsView/SingleConnectionSettingsView.swift +++ b/openHAB/SettingsView/SingleConnectionSettingsView.swift @@ -68,11 +68,14 @@ struct SingleConnectionSettingsView: View { } } - Toggle(isOn: $connectionConfig.alwaysSendBasicAuth) { - Text("Always send credentials") - } + Toggle("Always send credentials",isOn: $connectionConfig.alwaysSendBasicAuth) + .font(.caption) + .opacity(0.8) + Toggle("Ignore SSL certificates", isOn: $connectionConfig.ignoreSSL) + .font(.caption) + .opacity(0.8) } } } From 8ce6ad678b4b57b789d792f35a9236622d3d4e4c Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Tue, 18 Mar 2025 21:09:20 +0100 Subject: [PATCH 093/476] Handle cancellation errors in LoggingMiddlewar gracefully as they are likely due to navigation Continue adaptation of ConnectionConfiguration also in OpenHABSitemapViewController Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../OpenHABCore/Util/LoggingMiddleware.swift | 22 +++++++++--- .../OpenHABCore/Util/NetworkTracker.swift | 2 +- .../Util/OpenAPIServiceDelegate.swift | 4 +-- openHAB/OpenHABSitemapViewController.swift | 36 ++++++++++++------- .../SingleConnectionSettingsView.swift | 3 +- 5 files changed, 46 insertions(+), 21 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/LoggingMiddleware.swift b/OpenHABCore/Sources/OpenHABCore/Util/LoggingMiddleware.swift index 4e301f7ac..6ad8359e2 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/LoggingMiddleware.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/LoggingMiddleware.swift @@ -43,10 +43,24 @@ extension LoggingMiddleware: ClientMiddleware { logger.debug("Trying URL: \(baseURL) for operation ID:\(operationID)") log(request, response, responseBodyToLog) return (response, responseBodyForNext) + } catch is CancellationError { + // ✅ If the task is canceled, we simply return nil without logging it as an error. + logger.info("🔄 Polling request was canceled (likely due to navigation)") + let emptyResponse = HTTPResponse(status: .noContent, headerFields: [:]) + return (emptyResponse, nil) + } catch let error as ClientError { + if let urlError = error.underlyingError as? URLError, urlError.code == .cancelled { + // ✅ If the task is canceled, we simply return nil without logging it as an error. + logger.info("🔄 Task was cancelled - URLError code: .cancelled (likely due to navigation)") + let emptyResponse = HTTPResponse(status: .noContent, headerFields: [:]) + return (emptyResponse, nil) + } else { + throw error + } } catch { -// if operationID != "getRoot", operationID != "getRootVersion" { - log(request, failedWith: error) -// } + if operationID != "getRoot", operationID != "getRootVersion" { + log(request, failedWith: error) + } throw error } } @@ -61,7 +75,7 @@ extension LoggingMiddleware { func log(_ request: HTTPRequest, _ response: HTTPResponse, _ responseBody: BodyLoggingPolicy.BodyLog) { logger.debug( - "Response: \(request.method, privacy: .public) \(request.path ?? "", privacy: .public) \(response.status, privacy: .public) body: \(responseBody, privacy: .public)" + "Response: \(request.method, privacy: .public) \(request.path ?? "", privacy: .public) \(response.status, privacy: .public) body: \(responseBody, privacy: .auto)" ) } diff --git a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift index cf6315980..19d6e4442 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift @@ -238,7 +238,7 @@ public final class NetworkTracker: ObservableObject { let connection = await connectionPool.getOrCreateService(for: configuration) let version = try await connection.getRootVersion() let connectionInfo = ConnectionInfo(configuration: configuration, version: version) - logger.info("testConnection successful for \(configuration.url)") + logger.info("testConnection successful for \(configuration.url)") return connectionInfo } catch NetworkTrackerError.invalidServerVersion { logger.info("testConnection error - Invalid server version from \(configuration.url)") diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIServiceDelegate.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIServiceDelegate.swift index 075dd9459..2ef289d47 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIServiceDelegate.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIServiceDelegate.swift @@ -66,10 +66,10 @@ final class OpenAPIServiceDelegate: NSObject, URLSessionDelegate, URLSessionTask var error: CFError? _ = SecTrustEvaluateWithError(serverTrust, &error) SecTrustGetTrustResult(serverTrust, &result) - logger.info("Trust evaluation result: \(result.rawValue), error: \(String(describing: error))") + logger.debug("Trust evaluation result: \(result.rawValue), error: \(String(describing: error))") if result.isAny(of: .unspecified, .proceed) || connectionConfiguration.ignoreSSL { - logger.info("Certificate is trusted or SSL verification ignored") + logger.debug("Certificate is trusted or SSL verification ignored") return (.useCredential, URLCredential(trust: serverTrust)) } diff --git a/openHAB/OpenHABSitemapViewController.swift b/openHAB/OpenHABSitemapViewController.swift index b37208cd0..00f6a2260 100644 --- a/openHAB/OpenHABSitemapViewController.swift +++ b/openHAB/OpenHABSitemapViewController.swift @@ -87,14 +87,24 @@ actor PageLoader { func fetchPage(longPolling: Bool) async throws -> OpenHABPage? { logger.info("📡 Fetching page... (longPolling: \(longPolling))") - let page = try await openAPIService.pollDataForPage( - sitemapname: defaultSitemap, - pageId: pageId, - longPolling: longPolling - ) - try Task.checkCancellation() - return page + do { + let page = try await openAPIService.pollDataForPage( + sitemapname: defaultSitemap, + pageId: pageId, + longPolling: longPolling + ) + try Task.checkCancellation() + return page + } catch is CancellationError { + // ✅ If the task is canceled, we simply return nil without logging it as an error. + logger.info("🔄 Polling request was canceled (likely due to navigation)") + return nil + } catch { + // ❌ Handle all other unexpected errors. + logger.error("❌ Failed to fetch page: \(error.localizedDescription)") + throw error + } } } @@ -166,11 +176,13 @@ class OpenHABSitemapViewController: OpenHABViewController { pageNetworkStatus = nil sitemaps = [] widgetTableView.tableFooterView = UIView() - let initialConfiguration = ConnectionConfiguration( - url: "", - username: "", - password: "" - ) + let initialConfiguration = + ConnectionConfiguration( + url: openHABRootUrl, + username: openHABUsername, + password: openHABUsername, + alwaysSendBasicAuth: openHABAlwaysSendCreds + ) openAPIService = OpenAPIService( connectionConfiguration: initialConfiguration, diff --git a/openHAB/SettingsView/SingleConnectionSettingsView.swift b/openHAB/SettingsView/SingleConnectionSettingsView.swift index ddfc97592..a3d9d60c1 100644 --- a/openHAB/SettingsView/SingleConnectionSettingsView.swift +++ b/openHAB/SettingsView/SingleConnectionSettingsView.swift @@ -68,10 +68,9 @@ struct SingleConnectionSettingsView: View { } } - Toggle("Always send credentials",isOn: $connectionConfig.alwaysSendBasicAuth) + Toggle("Always send credentials", isOn: $connectionConfig.alwaysSendBasicAuth) .font(.caption) .opacity(0.8) - Toggle("Ignore SSL certificates", isOn: $connectionConfig.ignoreSSL) .font(.caption) From 17ad789abfd9002d5c668b1ee5036fb021d26cc0 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Wed, 19 Mar 2025 22:41:13 +0100 Subject: [PATCH 094/476] Regenerated Client.swift with OpenAPIKit version 3.4.2 Reworked DrawerView to remove appData / the environmentobject networktracker is injected now. Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../GeneratedSources/openapi/Client.swift | 4 +- .../GeneratedSources/openapi/Types.swift | 14 +-- .../OpenHABCore/Util/NetworkTracker.swift | 94 +++++++--------- .../OpenHABCore/Util/OpenAPIService.swift | 16 --- .../Util/OpenAPIServiceDelegate.swift | 5 +- openHAB/DrawerView.swift | 101 ++++++++---------- openHAB/OpenHABRootViewController.swift | 2 + openHAB/OpenHABSitemapViewController.swift | 26 ++--- 8 files changed, 104 insertions(+), 158 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Client.swift b/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Client.swift index 2c7497aff..cdcb172c4 100644 --- a/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Client.swift +++ b/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Client.swift @@ -2046,8 +2046,8 @@ public struct Client: APIProtocol { } /// Gets information about the runtime, the API version and links to resources. /// - /// - Remark: HTTP `GET //`. - /// - Remark: Generated from `#/paths////get(getRoot)`. + /// - Remark: HTTP `GET /`. + /// - Remark: Generated from `#/paths///get(getRoot)`. public func getRoot(_ input: Operations.getRoot.Input) async throws -> Operations.getRoot.Output { try await client.send( input: input, diff --git a/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Types.swift b/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Types.swift index f3915e63c..1bb1d980f 100644 --- a/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Types.swift +++ b/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Types.swift @@ -183,8 +183,8 @@ public protocol APIProtocol: Sendable { func purgeDatabase(_ input: Operations.purgeDatabase.Input) async throws -> Operations.purgeDatabase.Output /// Gets information about the runtime, the API version and links to resources. /// - /// - Remark: HTTP `GET //`. - /// - Remark: Generated from `#/paths////get(getRoot)`. + /// - Remark: HTTP `GET /`. + /// - Remark: Generated from `#/paths///get(getRoot)`. func getRoot(_ input: Operations.getRoot.Input) async throws -> Operations.getRoot.Output /// Gets information about the system. /// @@ -661,8 +661,8 @@ extension APIProtocol { } /// Gets information about the runtime, the API version and links to resources. /// - /// - Remark: HTTP `GET //`. - /// - Remark: Generated from `#/paths////get(getRoot)`. + /// - Remark: HTTP `GET /`. + /// - Remark: Generated from `#/paths///get(getRoot)`. public func getRoot(headers: Operations.getRoot.Input.Headers = .init()) async throws -> Operations.getRoot.Output { try await getRoot(Operations.getRoot.Input(headers: headers)) } @@ -9468,8 +9468,8 @@ public enum Operations { } /// Gets information about the runtime, the API version and links to resources. /// - /// - Remark: HTTP `GET //`. - /// - Remark: Generated from `#/paths////get(getRoot)`. + /// - Remark: HTTP `GET /`. + /// - Remark: Generated from `#/paths///get(getRoot)`. public enum getRoot { public static let id: Swift.String = "getRoot" public struct Input: Sendable, Hashable { @@ -9524,7 +9524,7 @@ public enum Operations { } /// OK /// - /// - Remark: Generated from `#/paths////get(getRoot)/responses/200`. + /// - Remark: Generated from `#/paths///get(getRoot)/responses/200`. /// /// HTTP response code: `200 ok`. case ok(Operations.getRoot.Output.Ok) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift index 19d6e4442..bc919ce1a 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift @@ -61,32 +61,14 @@ actor ConnectionPool { services[configuration] = newService return newService } - - // Ensures that all URLs pointing to "myopenhab.org" are standardized to "home.myopenhab.org". - private func adjustMyOpenHABHosts(in configurations: [ConnectionConfiguration]) -> [ConnectionConfiguration] { - configurations.map { configuration in - adjustMyOpenHABHost(in: configuration) - } - } - - private func adjustMyOpenHABHost(in configuration: ConnectionConfiguration) -> ConnectionConfiguration { - var updatedURL = configuration.url - if let urlComponents = URLComponents(string: configuration.url), - let host = urlComponents.host, - host.contains("myopenhab.org"), - host != "home.myopenhab.org" { - var newComponents = urlComponents - newComponents.host = "home.myopenhab.org" - updatedURL = newComponents.url?.absoluteString ?? configuration.url - } - return ConnectionConfiguration(url: updatedURL, username: configuration.username, password: configuration.password, priority: configuration.priority) - } } public final class NetworkTracker: ObservableObject { public static let shared = NetworkTracker() + // @MainActor @Published public private(set) var activeConnection: ConnectionInfo? + // @MainActor @Published public private(set) var status: NetworkStatus = .connecting private var retryCount = 0 @@ -96,10 +78,12 @@ public final class NetworkTracker: ObservableObject { private var connectionPool: ConnectionPool = .init() private var connectionConfigurations: [ConnectionConfiguration] = [] private var retryTask: Task? - public private(set) var httpClient: HTTPClient? + private let disconnectedRetryInterval: UInt64 = 30 // / amount of time we scan when not connected + + // TODO: remove public var clientCertificateManager = ClientCertificateManager() public var serverCertificateManager = ServerCertificateManager() - private let disconnectedRetryInterval: UInt64 = 30 // / amount of time we scan when not connected + public private(set) var httpClient: HTTPClient? private let logger = Logger(subsystem: "org.openhab.core", category: "NetworkTracker") @@ -121,7 +105,6 @@ public final class NetworkTracker: ObservableObject { public func startTracking(connectionConfigurations: [ConnectionConfiguration]) { logger.info("Start Network Tracking") -// self.connectionConfigurations = adjustMyOpenHABHosts(in: connectionConfigurations) self.connectionConfigurations = connectionConfigurations Task { for configuration in connectionConfigurations { @@ -163,7 +146,7 @@ public final class NetworkTracker: ObservableObject { try await connectionPool .getOrCreateService(for: activeConnection.configuration) .getRoot() - logger.info("Active connection is reachable: \(activeConnection.configuration.url)") + logger.debug("Active connection is reachable: \(activeConnection.configuration.url)") } catch { logger.error("Active connection failed: \(activeConnection.configuration.url) - \(error.localizedDescription)") await attemptConnection() @@ -173,20 +156,26 @@ public final class NetworkTracker: ObservableObject { private func attemptConnection() async { guard !connectionConfigurations.isEmpty else { logger.error("No connection configurations available.") + await updateStatus(.notConnected) await setActiveConnection(nil) return } - logger.info("Checking available connections...") - - let bestConnection = await findBestConnection() - await setActiveConnection(bestConnection) + logger.debug("Checking available connections...") + if let bestConnection = await findBestConnection() { + await setActiveConnection(bestConnection) + } else { + await updateStatus(.notConnected) + await setActiveConnection(nil) + } +// let bestConnection = await findBestConnection() +// await setActiveConnection(bestConnection) } private func findBestConnection() async -> ConnectionInfo? { let sortedConfigs = connectionConfigurations.sorted { $0.priority < $1.priority } var bestConnection: ConnectionInfo? - // var connectedCounts = 0 + var connectedCount = 0 await withTaskGroup(of: ConnectionInfo?.self) { group in for config in sortedConfigs { @@ -200,7 +189,7 @@ public final class NetworkTracker: ObservableObject { if connectionInfo.configuration.priority == 0 { bestConnection = connectionInfo -// group.cancelAll() // Stop further tasks if we found the highest-priority connection + group.cancelAll() // Stop further tasks if we found the highest-priority connection break } @@ -209,6 +198,15 @@ public final class NetworkTracker: ObservableObject { } } } + + // Update status based on the number of successful connections + if connectedCount == 0 { + await updateStatus(.notConnected) + } else if connectedCount == 1 { + await updateStatus(.someConnected) + } else if bestConnection != nil { + await updateStatus(.allConnected) + } return bestConnection } @@ -267,42 +265,28 @@ public final class NetworkTracker: ObservableObject { } else { logger.info("Network status: Disconnected") await setActiveConnection(nil) + await updateStatus(.notConnected) startRetryTask(10) } } - // Ensures that all URLs pointing to "myopenhab.org" are standardized to "home.myopenhab.org". - private func adjustMyOpenHABHosts(in configurations: [ConnectionConfiguration]) -> [ConnectionConfiguration] { - configurations.map { configuration in - adjustMyOpenHABHost(in: configuration) - } - } - - private func adjustMyOpenHABHost(in configuration: ConnectionConfiguration) -> ConnectionConfiguration { - var updatedURL = configuration.url - if let urlComponents = URLComponents(string: configuration.url), - let host = urlComponents.host, - host.contains("myopenhab.org"), - host != "home.myopenhab.org" { - var newComponents = urlComponents - newComponents.host = "home.myopenhab.org" - updatedURL = newComponents.url?.absoluteString ?? configuration.url - } - return ConnectionConfiguration(url: updatedURL, username: configuration.username, password: configuration.password, priority: configuration.priority) - } - @MainActor private func setActiveConnection(_ connection: ConnectionInfo?) async { guard activeConnection != connection else { return } activeConnection = connection - if activeConnection != nil { - status = .connected - } else { - status = .notConnected - startRetryTask(disconnectedRetryInterval) + status = connection == nil ? .notConnected : .connected + if connection == nil { + startRetryTask(30) } } + + @MainActor + private func updateStatus(_ newStatus: NetworkStatus) async { + guard status != newStatus else { return } // Prevent redundant updates + status = newStatus + logger.info("Network status updated: \(newStatus.rawValue)") + } } public extension NetworkTracker { diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift index 59527cee3..1759eb0af 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift @@ -97,22 +97,6 @@ public actor OpenAPIService { return config } - public func updateBaseURL(with newURL: URL) async { - guard newURL != url else { return } - url = newURL - - let config = prepareURLSessionConfiguration(longPolling: longPolling) - let session = URLSession(configuration: config) - client = Client( - serverURL: newURL.appending(path: "/rest"), - transport: URLSessionTransport(configuration: .init(session: session)), - middlewares: [ - LoggingMiddleware(), - AuthorisationMiddleware(configuration: connectionConfiguration) - ] - ) - } - // timeoutIntervalForRequest/timeoutIntervalForResource need to be passed through URLSessionConfiguration when URLSession is created. Therefore create a new APIClient to change values. public func updateForLongPolling(_ newlongPolling: Bool) async { guard newlongPolling != longPolling else { return } diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIServiceDelegate.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIServiceDelegate.swift index 2ef289d47..61a8f776d 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIServiceDelegate.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIServiceDelegate.swift @@ -35,7 +35,7 @@ final class OpenAPIServiceDelegate: NSObject, URLSessionDelegate, URLSessionTask } private func urlSessionInternal(_ session: URLSession, task: URLSessionTask?, didReceive challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { - os_log("URLAuthenticationChallenge: %{public}@", log: .networking, type: .info, challenge.protectionSpace.authenticationMethod) + logger.debug("URLAuthenticationChallenge: \(challenge.protectionSpace.authenticationMethod)") let authenticationMethod = challenge.protectionSpace.authenticationMethod switch authenticationMethod { case NSURLAuthenticationMethodServerTrust: @@ -55,7 +55,7 @@ final class OpenAPIServiceDelegate: NSObject, URLSessionDelegate, URLSessionTask private func handleServerTrust(challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { let domain = challenge.protectionSpace.host - logger.info("Handling server trust for domain: \(domain)") + logger.debug("Handling server trust for domain: \(domain)") guard let serverTrust = challenge.protectionSpace.serverTrust else { logger.error("No server trust object available") @@ -66,7 +66,6 @@ final class OpenAPIServiceDelegate: NSObject, URLSessionDelegate, URLSessionTask var error: CFError? _ = SecTrustEvaluateWithError(serverTrust, &error) SecTrustGetTrustResult(serverTrust, &result) - logger.debug("Trust evaluation result: \(result.rawValue), error: \(String(describing: error))") if result.isAny(of: .unspecified, .proceed) || connectionConfiguration.ignoreSSL { logger.debug("Certificate is trusted or SSL verification ignored") diff --git a/openHAB/DrawerView.swift b/openHAB/DrawerView.swift index 01fef079a..57b1cf18b 100644 --- a/openHAB/DrawerView.swift +++ b/openHAB/DrawerView.swift @@ -30,10 +30,7 @@ enum DrawerViewError: Error, CustomDebugStringConvertible { struct ImageView: View { let url: String - // App wide data access - var appData: OpenHABDataObject? { - AppDelegate.appDelegate.appData - } + @EnvironmentObject var networkTracker: NetworkTracker var body: some View { if !url.isEmpty { @@ -44,7 +41,10 @@ struct ImageView: View { case _ where url.hasPrefix("http"): return KFImage(URL(string: url)).resizable() default: - let builtURL = Endpoint.resource(openHABRootUrl: appData?.openHABRootUrl ?? "", path: url.prepare()).url + let builtURL = Endpoint.resource( + openHABRootUrl: networkTracker.activeConnection?.configuration.url ?? "", + path: url.prepare() + ).url return KFImage(builtURL).resizable() } } else { @@ -56,7 +56,7 @@ struct ImageView: View { // Display the connected URL struct ConnectionView: View { - @ObservedObject private var networkTracker = NetworkTracker.shared + @StateObject private var networkTracker = NetworkTracker.shared var body: some View { HStack { @@ -128,7 +128,6 @@ struct DrawerView: View { struct SitemapsSectionView: View { var sitemaps: [OpenHABSitemap] var sitemapIconwidth: CGFloat - var appData: OpenHABDataObject? @Binding var sitemapForWatch: String? var onDismiss: (TargetController) -> Void var dismiss: DismissAction @@ -139,7 +138,6 @@ struct DrawerView: View { SitemapRowView( sitemap: sitemap, sitemapIconwidth: sitemapIconwidth, - appData: appData, isWatchSitemap: sitemap.name == sitemapForWatch, onDismiss: onDismiss, dismiss: dismiss @@ -159,9 +157,9 @@ struct DrawerView: View { } struct SitemapRowView: View { + @EnvironmentObject var networkTracker: NetworkTracker var sitemap: OpenHABSitemap var sitemapIconwidth: CGFloat - var appData: OpenHABDataObject? var isWatchSitemap: Bool var onDismiss: (TargetController) -> Void var dismiss: DismissAction @@ -174,7 +172,10 @@ struct DrawerView: View { .aspectRatio(contentMode: .fit) .frame(width: sitemapIconwidth) } else { - let url = Endpoint.iconForDrawer(rootUrl: appData?.openHABRootUrl ?? "", icon: sitemap.icon).url + let url = Endpoint.iconForDrawer( + rootUrl: networkTracker.activeConnection?.configuration.url ?? "", + icon: sitemap.icon + ).url KFImage(url).placeholder { Image("openHABIcon").resizable() } .resizable() .aspectRatio(contentMode: .fit) @@ -233,19 +234,12 @@ struct DrawerView: View { @State private var uiTiles: [OpenHABUiTile] = [] @State private var selectedSection: Int? @State private var connectedUrl: String = "Not connected" // Default label text - @ObservedObject private var networkTracker = NetworkTracker.shared - var openHABUsername = "" - var openHABPassword = "" + @EnvironmentObject private var networkTracker: NetworkTracker var onDismiss: (TargetController) -> Void @Environment(\.dismiss) private var dismiss - // App wide data access - var appData: OpenHABDataObject? { - AppDelegate.appDelegate.appData - } - @ScaledMetric var openHABIconwidth = 20.0 @ScaledMetric var tilesIconwidth = 20.0 @ScaledMetric var sitemapIconwidth = 20.0 @@ -259,7 +253,7 @@ struct DrawerView: View { TilesSectionView(uiTiles: uiTiles, tilesIconwidth: tilesIconwidth, onDismiss: onDismiss, dismiss: dismiss) - SitemapsSectionView(sitemaps: sitemaps, sitemapIconwidth: sitemapIconwidth, appData: appData, sitemapForWatch: $sitemapForWatch, onDismiss: onDismiss, dismiss: dismiss) + SitemapsSectionView(sitemaps: sitemaps, sitemapIconwidth: sitemapIconwidth, sitemapForWatch: $sitemapForWatch, onDismiss: onDismiss, dismiss: dismiss) SystemSectionView(openHABIconwidth: openHABIconwidth, onDismiss: onDismiss, dismiss: dismiss) } @@ -271,50 +265,43 @@ struct DrawerView: View { } .listStyle(.inset) .task { - let initialConfiguration = ConnectionConfiguration( - url: appData!.openHABRootUrl, - username: appData?.openHABUsername ?? "", - password: appData?.openHABUsername ?? "", - alwaysSendBasicAuth: appData?.openHABAlwaysSendCreds ?? false, - ignoreSSL: false - ) - let openAPIService = OpenAPIService( - connectionConfiguration: initialConfiguration - ) - + let activeConnection = networkTracker.activeConnection + await updateSitemapsAndUITiles(activeConnection: activeConnection) + } + .onReceive(networkTracker.$activeConnection) { activeConnection in Task { - do { - guard let url = URL(string: appData?.openHABRootUrl ?? "") else { throw DrawerViewError.noRootURL } - await openAPIService.updateBaseURL(with: url) + await updateSitemapsAndUITiles(activeConnection: activeConnection) + } + } + } - sitemaps = try await openAPIService.openHABSitemaps() - if sitemaps.last?.name == "_default", sitemaps.count > 1 { - sitemaps = Array(sitemaps.dropLast()) - } - // Sort the sitemaps according to Settings selection. - switch SortSitemapsOrder(rawValue: Preferences.sortSitemapsby) ?? .label { - case .label: sitemaps.sort { $0.label < $1.label } - case .name: sitemaps.sort { $0.name < $1.name } - } + private func updateSitemapsAndUITiles(activeConnection: ConnectionInfo?) async { + guard let activeConnection else { return } - } catch { - os_log("%{PUBLIC}@", log: .default, type: .error, error.localizedDescription) - sitemaps = [] - } + let openAPIService = OpenAPIService(connectionConfiguration: activeConnection.configuration) + + do { + sitemaps = try await openAPIService.openHABSitemaps() + if sitemaps.last?.name == "_default", sitemaps.count > 1 { + sitemaps = Array(sitemaps.dropLast()) } - Task { - do { - guard let url = URL(string: appData?.openHABRootUrl ?? "") else { throw DrawerViewError.noRootURL } - await openAPIService.updateBaseURL(with: url) - - uiTiles = try await openAPIService.getUITiles() - os_log("ui tiles response", log: .viewCycle, type: .info) - } catch { - os_log("%{PUBLIC}@", log: .default, type: .error, error.localizedDescription) - uiTiles = [] - } + // Sort the sitemaps according to Settings selection. + switch SortSitemapsOrder(rawValue: Preferences.sortSitemapsby) ?? .label { + case .label: sitemaps.sort { $0.label < $1.label } + case .name: sitemaps.sort { $0.name < $1.name } } + } catch { + os_log("%{PUBLIC}@", log: .default, type: .error, error.localizedDescription) + sitemaps = [] + } + + do { + uiTiles = try await openAPIService.getUITiles() + os_log("ui tiles response", log: .viewCycle, type: .info) + } catch { + os_log("%{PUBLIC}@", log: .default, type: .error, error.localizedDescription) + uiTiles = [] } } } diff --git a/openHAB/OpenHABRootViewController.swift b/openHAB/OpenHABRootViewController.swift index c8e114e18..7f5107f4b 100644 --- a/openHAB/OpenHABRootViewController.swift +++ b/openHAB/OpenHABRootViewController.swift @@ -207,9 +207,11 @@ class OpenHABRootViewController: UIViewController { SideMenuManager.default.rightMenuNavigationController?.settings = settings + let networkTracker = NetworkTracker.shared let drawerView = DrawerView { mode in self.handleDismiss(mode: mode) } + .environmentObject(networkTracker) let hostingController = UIHostingController(rootView: drawerView) let menu = SideMenuNavigationController(rootViewController: hostingController) diff --git a/openHAB/OpenHABSitemapViewController.swift b/openHAB/OpenHABSitemapViewController.swift index 00f6a2260..a906ee687 100644 --- a/openHAB/OpenHABSitemapViewController.swift +++ b/openHAB/OpenHABSitemapViewController.swift @@ -87,24 +87,14 @@ actor PageLoader { func fetchPage(longPolling: Bool) async throws -> OpenHABPage? { logger.info("📡 Fetching page... (longPolling: \(longPolling))") + let page = try await openAPIService.pollDataForPage( + sitemapname: defaultSitemap, + pageId: pageId, + longPolling: longPolling + ) + try Task.checkCancellation() - do { - let page = try await openAPIService.pollDataForPage( - sitemapname: defaultSitemap, - pageId: pageId, - longPolling: longPolling - ) - try Task.checkCancellation() - return page - } catch is CancellationError { - // ✅ If the task is canceled, we simply return nil without logging it as an error. - logger.info("🔄 Polling request was canceled (likely due to navigation)") - return nil - } catch { - // ❌ Handle all other unexpected errors. - logger.error("❌ Failed to fetch page: \(error.localizedDescription)") - throw error - } + return page } } @@ -512,7 +502,7 @@ extension OpenHABSitemapViewController { logger.info("No page found ") return } - // ** Alternative 2 too be tested. + // ** Alternative 2 to be tested. // await pageLoader?.updatePageConfig(newPageId: pageId, newSitemap: defaultSitemap) // guard let page = try await pageLoader?.fetchPage(longPolling: true) else { return } // ** From 5b188edb9a7221dc90fb8af8a0f3a397ec87ea6d Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Thu, 20 Mar 2025 23:46:58 +0100 Subject: [PATCH 095/476] Migrating to connectionInfo cont. / Remove openHAPRootUrl ... from OpenHABRootViewController, OpenHABSitemapViewController Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- openHAB/OpenHABDataObject.swift | 1 + openHAB/OpenHABRootViewController.swift | 4 +++ openHAB/OpenHABSitemapViewController.swift | 38 ++++++++++------------ 3 files changed, 23 insertions(+), 20 deletions(-) diff --git a/openHAB/OpenHABDataObject.swift b/openHAB/OpenHABDataObject.swift index 8578ef34d..df414e0c2 100644 --- a/openHAB/OpenHABDataObject.swift +++ b/openHAB/OpenHABDataObject.swift @@ -23,6 +23,7 @@ class OpenHABDataObject: NSObject, DataObject { var currentWebViewPath = "" var currentView: TargetController? var lastNotificationInfo: [AnyHashable: Any]? + var connectionInfo: ConnectionInfo? } extension OpenHABDataObject { diff --git a/openHAB/OpenHABRootViewController.swift b/openHAB/OpenHABRootViewController.swift index 7f5107f4b..f8578492d 100644 --- a/openHAB/OpenHABRootViewController.swift +++ b/openHAB/OpenHABRootViewController.swift @@ -179,7 +179,11 @@ class OpenHABRootViewController: UIViewController { .sink { [weak self] activeConnection in if let activeConnection { self?.appData?.openHABRootUrl = activeConnection.configuration.url + self?.appData?.openHABUsername = activeConnection.configuration.username + self?.appData?.openHABPassword = activeConnection.configuration.password + self?.appData?.openHABAlwaysSendCreds = activeConnection.configuration.alwaysSendBasicAuth self?.appData?.openHABVersion = activeConnection.version + self?.appData?.connectionInfo = activeConnection } } .store(in: &cancellables) diff --git a/openHAB/OpenHABSitemapViewController.swift b/openHAB/OpenHABSitemapViewController.swift index a906ee687..2595149bc 100644 --- a/openHAB/OpenHABSitemapViewController.swift +++ b/openHAB/OpenHABSitemapViewController.swift @@ -107,6 +107,9 @@ class OpenHABSitemapViewController: OpenHABViewController { private var openHABUsername = "" private var openHABPassword = "" private var openHABAlwaysSendCreds = false + + private var activeConnectionInfo: ConnectionInfo? + private var defaultSitemap = "" private var pageId = "" private var idleOff = false @@ -163,31 +166,23 @@ class OpenHABSitemapViewController: OpenHABViewController { super.viewDidLoad() os_log("OpenHABSitemapViewController viewDidLoad", log: .default, type: .info) + registerTableViewCells() + pageNetworkStatus = nil sitemaps = [] widgetTableView.tableFooterView = UIView() - let initialConfiguration = - ConnectionConfiguration( - url: openHABRootUrl, - username: openHABUsername, - password: openHABUsername, - alwaysSendBasicAuth: openHABAlwaysSendCreds - ) - openAPIService = OpenAPIService( - connectionConfiguration: initialConfiguration, - configuration: .shortTerm - ) + guard let initialConfiguration = activeConnectionInfo?.configuration else { return } + openAPIService = OpenAPIService(connectionConfiguration: initialConfiguration) - // ✅ Initialize PageLoader guard let openAPIService else { return } + // ✅ Initialize PageLoader pageLoader = PageLoader( service: openAPIService, pageId: "", defaultSitemap: "" ) - registerTableViewCells() configureTableView() refreshControl = UIRefreshControl() @@ -355,6 +350,7 @@ class OpenHABSitemapViewController: OpenHABViewController { if let activeConnection { os_log("OpenHABSitemapViewController tracker URL %{PUBLIC}@", log: .viewCycle, type: .info, activeConnection.configuration.url) self.openHABRootUrl = activeConnection.configuration.url + self.activeConnectionInfo = activeConnection self.selectSitemap() } } @@ -497,6 +493,12 @@ extension OpenHABSitemapViewController { do { // ** Alternative 1 logger.info("Calling pollDataForPage from loadPage") + + if openAPIService == nil { + openAPIService = OpenAPIService( + connectionConfiguration: appData!.connectionInfo!.configuration) + } + let page = try await openAPIService?.pollDataForPage(sitemapname: defaultSitemap, pageId: pageId, longPolling: longPolling) guard let page else { logger.info("No page found ") @@ -556,14 +558,9 @@ extension OpenHABSitemapViewController { Task { do { logger.debug("Running selectSitemap for URL: \(self.appData?.openHABRootUrl ?? "")") - let initialConfiguration = ConnectionConfiguration( - url: appData!.openHABRootUrl, - username: appData!.openHABUsername, - password: appData!.openHABUsername, - alwaysSendBasicAuth: appData!.openHABAlwaysSendCreds - ) + openAPIService = OpenAPIService( - connectionConfiguration: initialConfiguration) + connectionConfiguration: appData!.connectionInfo!.configuration) sitemaps = try await openAPIService?.openHABSitemaps() ?? [] @@ -942,6 +939,7 @@ extension OpenHABSitemapViewController: UITableViewDelegate, UITableViewDataSour longPollingTask = nil initialLoadTask?.cancel() initialLoadTask = nil +// pageId = linkedPage.pageId let newViewController = (storyboard?.instantiateViewController(withIdentifier: "OpenHABPageViewController") as? OpenHABSitemapViewController)! newViewController.title = linkedPage.title.components(separatedBy: "[")[0] newViewController.pageId = linkedPage.pageId From aff2cf5040a3e366b6c0ac24ffc7e0c33347f9c4 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Sat, 22 Mar 2025 09:04:03 +0100 Subject: [PATCH 096/476] Bug introduced with migration of WKNavigationDelegate to async corrected. Apple renamed webView(_, didReceive:, completionHandler:) to webView(_, respondTo:) Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- openHAB/OpenHABWebViewController.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openHAB/OpenHABWebViewController.swift b/openHAB/OpenHABWebViewController.swift index 58612de27..f5e7baff6 100644 --- a/openHAB/OpenHABWebViewController.swift +++ b/openHAB/OpenHABWebViewController.swift @@ -362,7 +362,7 @@ extension OpenHABWebViewController: WKNavigationDelegate { hidePopupMessages() } - func webView(_ webView: WKWebView, didReceive challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { + func webView(_ webView: WKWebView, respondTo challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { logger.info("Challenge.protectionSpace.authenticationMethod: \(String(describing: challenge.protectionSpace.authenticationMethod))") if let url = modifyUrl(orig: URL(string: openHABTrackedRootUrl)), challenge.protectionSpace.host == url.host { From 262b60d13a3366a7974f4270acc2afa44a2bf2b6 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Sat, 22 Mar 2025 10:17:39 +0100 Subject: [PATCH 097/476] Add a test connection button to SingleConnectionSettingsView Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../OpenHABCore/Util/OpenAPIService.swift | 1 + .../OpenHABCore/Util/StringExtension.swift | 11 +++ .../Tests/OpenHABCoreTests/ParseAsTests.swift | 27 +++++++ .../SingleConnectionSettingsView.swift | 79 +++++++++++++++++++ 4 files changed, 118 insertions(+) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift index 1759eb0af..9f40dd6ec 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift @@ -147,6 +147,7 @@ public extension OpenAPIService { return OpenHABServerProperties(result) } + @discardableResult func getRootVersion() async throws -> Int { let serverProperties = try await getRoot() guard let version = Int(serverProperties.version ?? "0"), diff --git a/OpenHABCore/Sources/OpenHABCore/Util/StringExtension.swift b/OpenHABCore/Sources/OpenHABCore/Util/StringExtension.swift index 83898d226..9724f1245 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/StringExtension.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/StringExtension.swift @@ -134,6 +134,17 @@ public extension String { guard hasPrefix(prefix) else { return self } return String(dropFirst(prefix.count)) } + + func testAsValidOpenHABURL() throws { + guard + let components = URLComponents(string: self), + let scheme = components.scheme?.lowercased(), + ["http", "https"].contains(scheme), + let host = components.host, !host.isEmpty + else { + throw URLError(.badURL) + } + } } public extension String? { diff --git a/OpenHABCore/Tests/OpenHABCoreTests/ParseAsTests.swift b/OpenHABCore/Tests/OpenHABCoreTests/ParseAsTests.swift index 76f1f835a..41c144358 100644 --- a/OpenHABCore/Tests/OpenHABCoreTests/ParseAsTests.swift +++ b/OpenHABCore/Tests/OpenHABCoreTests/ParseAsTests.swift @@ -29,4 +29,31 @@ final class ParseAsTests: XCTestCase { XCTAssertFalse("10,10,0".parseAsBool()) XCTAssertTrue("10,10,50".parseAsBool()) } + + func testValidOpenHABURL() throws { + try "http://localhost:8080".testAsValidOpenHABURL() + try "https://localhost:8080".testAsValidOpenHABURL() + } + + func testInvalidOpenHABURL() { + let invalidURLs = [ + "localhost:8080", // Missing scheme + "ftp://localhost", // Unsupported scheme + "http:/localhost", // Malformed + "http://", // Missing host + "://localhost", // Missing scheme + "file:///Users/me", // Unsupported scheme + "https://" // No host + ] + + for url in invalidURLs { + XCTAssertThrowsError(try url.testAsValidOpenHABURL(), "Expected to throw for URL: \(url)") { error in + if let urlError = error as? URLError { + XCTAssertEqual(urlError.code, .badURL, "Expected .badURL, got \(urlError.code)") + } else { + XCTFail("Unexpected error type: \(error)") + } + } + } + } } diff --git a/openHAB/SettingsView/SingleConnectionSettingsView.swift b/openHAB/SettingsView/SingleConnectionSettingsView.swift index a3d9d60c1..c213247e6 100644 --- a/openHAB/SettingsView/SingleConnectionSettingsView.swift +++ b/openHAB/SettingsView/SingleConnectionSettingsView.swift @@ -17,6 +17,10 @@ struct SingleConnectionSettingsView: View { var headerText: String @Binding var connectionConfig: ConnectionConfiguration + @State private var isTestingConnection = false + @State private var connectionTestMessage: String? + @State private var connectionTestSuccess: Bool? + var body: some View { Section(header: Text(headerText)) { LabeledContent { @@ -75,6 +79,81 @@ struct SingleConnectionSettingsView: View { Toggle("Ignore SSL certificates", isOn: $connectionConfig.ignoreSSL) .font(.caption) .opacity(0.8) + + // 🧪 Test Connection Button + HStack { + Button { + Task { + await handleTestConnection() + } + } label: { + if isTestingConnection { + ProgressView() + } else { + Label("Test Connection", systemSymbol: .arrowClockwise) + } + } + .disabled(isTestingConnection || connectionConfig.url.isEmpty) + Spacer() + } + + // 🟢/🔴 Feedback Message + if let message = connectionTestMessage, let success = connectionTestSuccess { + HStack { + Spacer() + Label(message, systemImage: success ? "checkmark.circle.fill" : "xmark.octagon.fill") + .foregroundColor(success ? .green : .red) + .font(.caption2) + Spacer() + } + .transition(.opacity) + } + } + } + + private func handleTestConnection() async { + isTestingConnection = true + connectionTestMessage = nil + connectionTestSuccess = nil + + do { + try await testConnection() + connectionTestMessage = "Connection successful" + connectionTestSuccess = true + } catch let urlError as URLError { + connectionTestMessage = friendlyMessage(for: urlError) + connectionTestSuccess = false + } catch { + connectionTestMessage = "Unexpected error: \(error.localizedDescription)" + connectionTestSuccess = false + } + + isTestingConnection = false + } + + private func testConnection() async throws { + try connectionConfig.url.testAsValidOpenHABURL() + + let connection = OpenAPIService(connectionConfiguration: connectionConfig) + try await connection.getRootVersion() + } + + private func friendlyMessage(for error: URLError) -> String { + switch error.code { + case .badURL: + "The URL is invalid. Please check the format (e.g., http://192.168.2.1)." + case .cannotFindHost: + "Cannot find the server. Is the URL correct?" + case .cannotConnectToHost: + "Cannot connect to the server. Is it online?" + case .notConnectedToInternet: + "You appear to be offline. Check your internet connection." + case .timedOut: + "The connection timed out. Try again later." + case .secureConnectionFailed: + "SSL error. The connection couldn’t be established securely." + default: + error.localizedDescription } } } From 4a54ce3d4c64d9e3ff942ce46bda5301a26bbfd4 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Sat, 22 Mar 2025 20:26:36 +0100 Subject: [PATCH 098/476] In pollDataForPage convenience function surface the underlying error to pass them down the throw handling / Enable ignore in the case of a cancellation. Nicer connection testing button and feedback Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../OpenHABCore/Util/NetworkConnection.swift | 2 +- .../OpenHABCore/Util/OpenAPIService.swift | 17 +++- .../OpenHABCore/Util/StringExtension.swift | 36 ++++++-- .../Tests/OpenHABCoreTests/ParseAsTests.swift | 2 +- openHAB/DrawerView.swift | 2 + openHAB/OpenHABSitemapViewController.swift | 2 + openHAB/OpenHABWebViewController.swift | 2 +- .../SingleConnectionSettingsView.swift | 90 ++++++++++--------- 8 files changed, 97 insertions(+), 56 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/NetworkConnection.swift b/OpenHABCore/Sources/OpenHABCore/Util/NetworkConnection.swift index b69eacd9b..0122fd69e 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/NetworkConnection.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/NetworkConnection.swift @@ -32,7 +32,7 @@ public func onReceiveSessionTaskChallenge(with challenge: URLAuthenticationChall } public func onReceiveSessionChallenge(with challenge: URLAuthenticationChallenge) -> (URLSession.AuthChallengeDisposition, URLCredential?) { - os_log("onReceiveSessionChallenge host:'%{PUBLIC}@'", log: .default, type: .error, challenge.protectionSpace.host) + os_log("onReceiveSessionChallenge host:'%{PUBLIC}@'", log: .default, type: .info, challenge.protectionSpace.host) var disposition: URLSession.AuthChallengeDisposition = .performDefaultHandling var credential: URLCredential? diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift index 9f40dd6ec..ad3478298 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift @@ -18,6 +18,8 @@ import os public enum OpenAPIServiceError: Error { case undocumented(statusCode: Swift.Int, undocumentedPayload: OpenAPIRuntime.UndocumentedPayload) case noRootURL + case badRequest + case notFound } public enum OpenAPIServiceConfiguration { @@ -203,9 +205,18 @@ public extension OpenAPIService { internal func pollDataForPage(path: Operations.pollDataForPage.Input.Path, query: Operations.pollDataForPage.Input.Query = .init(), headers: Operations.pollDataForPage.Input.Headers) async throws -> OpenHABPage? { - let result = try await client.pollDataForPage(path: path, query: query, headers: headers) - .ok.body.json - return OpenHABPage(result) + let response = try await client.pollDataForPage(path: path, query: query, headers: headers) + switch response { + case let .ok(okresponse): + let result = try okresponse.body.json + return OpenHABPage(result) + case let .undocumented(statusCode, undocumentedPayload): + throw OpenAPIServiceError.undocumented(statusCode: statusCode, undocumentedPayload: undocumentedPayload) + case .badRequest: + throw OpenAPIServiceError.badRequest + case .notFound: + throw OpenAPIServiceError.notFound + } } /// Poll page data on sitemap diff --git a/OpenHABCore/Sources/OpenHABCore/Util/StringExtension.swift b/OpenHABCore/Sources/OpenHABCore/Util/StringExtension.swift index 9724f1245..091eac292 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/StringExtension.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/StringExtension.swift @@ -135,13 +135,37 @@ public extension String { return String(dropFirst(prefix.count)) } + func isValidURLByRegex() throws -> Bool { + let pattern = #"^(https?://)?(localhost|(\d{1,3}\.){3}\d{1,3}|([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,})(:\d+)?(/[^\s]*)?$"# + + let regex = try Regex(pattern).ignoresCase() + + return wholeMatch(of: regex) != nil + } + func testAsValidOpenHABURL() throws { - guard - let components = URLComponents(string: self), - let scheme = components.scheme?.lowercased(), - ["http", "https"].contains(scheme), - let host = components.host, !host.isEmpty - else { + var urlString = self + + guard try urlString.isValidURLByRegex() else { + throw URLError(.badURL) + } + + if !urlString.contains("://") { + urlString = "http://" + urlString + } + + guard let components = URLComponents(string: urlString) else { + throw URLError(.badURL) + } + + let allowedSchemes = ["http", "https"] + if let scheme = components.scheme?.lowercased() { + if !allowedSchemes.contains(scheme) { + throw URLError(.unsupportedURL) + } + } + + guard let host = components.host, !host.isEmpty else { throw URLError(.badURL) } } diff --git a/OpenHABCore/Tests/OpenHABCoreTests/ParseAsTests.swift b/OpenHABCore/Tests/OpenHABCoreTests/ParseAsTests.swift index 41c144358..e6435bbdc 100644 --- a/OpenHABCore/Tests/OpenHABCoreTests/ParseAsTests.swift +++ b/OpenHABCore/Tests/OpenHABCoreTests/ParseAsTests.swift @@ -33,11 +33,11 @@ final class ParseAsTests: XCTestCase { func testValidOpenHABURL() throws { try "http://localhost:8080".testAsValidOpenHABURL() try "https://localhost:8080".testAsValidOpenHABURL() + try "192.168.2.10".testAsValidOpenHABURL() } func testInvalidOpenHABURL() { let invalidURLs = [ - "localhost:8080", // Missing scheme "ftp://localhost", // Unsupported scheme "http:/localhost", // Malformed "http://", // Missing host diff --git a/openHAB/DrawerView.swift b/openHAB/DrawerView.swift index 57b1cf18b..8634431f7 100644 --- a/openHAB/DrawerView.swift +++ b/openHAB/DrawerView.swift @@ -307,5 +307,7 @@ struct DrawerView: View { } #Preview { + let networkTracker = NetworkTracker.shared DrawerView { _ in } + .environmentObject(networkTracker) } diff --git a/openHAB/OpenHABSitemapViewController.swift b/openHAB/OpenHABSitemapViewController.swift index 2595149bc..a30479681 100644 --- a/openHAB/OpenHABSitemapViewController.swift +++ b/openHAB/OpenHABSitemapViewController.swift @@ -539,6 +539,8 @@ extension OpenHABSitemapViewController { ) } } + } catch let openAPIError as OpenAPIServiceError { + logger.info("On LoadPage \(openAPIError)") } catch { logger.error("On LoadPage \(error.localizedDescription)") await MainActor.run { diff --git a/openHAB/OpenHABWebViewController.swift b/openHAB/OpenHABWebViewController.swift index f5e7baff6..ff255a0a0 100644 --- a/openHAB/OpenHABWebViewController.swift +++ b/openHAB/OpenHABWebViewController.swift @@ -330,7 +330,7 @@ extension OpenHABWebViewController: WKNavigationDelegate { func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse) async -> WKNavigationResponsePolicy { if let response = navigationResponse.response as? HTTPURLResponse { - dump(response.allHeaderFields) +// dump(response.allHeaderFields) logger.info("navigationResponse: \(response.statusCode)") if response.statusCode >= 400 { diff --git a/openHAB/SettingsView/SingleConnectionSettingsView.swift b/openHAB/SettingsView/SingleConnectionSettingsView.swift index c213247e6..168c3eb68 100644 --- a/openHAB/SettingsView/SingleConnectionSettingsView.swift +++ b/openHAB/SettingsView/SingleConnectionSettingsView.swift @@ -23,21 +23,52 @@ struct SingleConnectionSettingsView: View { var body: some View { Section(header: Text(headerText)) { - LabeledContent { - Spacer() - TextField( - "URL", - text: $connectionConfig.url - ) - .fixedSize() - .keyboardType(.URL) - .textInputAutocapitalization(.never) - .disableAutocorrection(true) - .font(.system(.caption)) - } label: { - Text("URL") - if connectionConfig.url.isEmpty { - Text("Enter URL of remote server") + VStack(alignment: .leading) { + LabeledContent { + Spacer() + TextField( + "URL", + text: $connectionConfig.url + ) + .fixedSize() + .keyboardType(.URL) + .textInputAutocapitalization(.never) + .disableAutocorrection(true) + .font(.system(.caption)) + } label: { + HStack { + Text("URL") + if isTestingConnection { + ProgressView() + .scaleEffect(0.5) + } else { + Button { + Task { + await handleTestConnection() + } + } label: { + Image(systemName: "arrow.clockwise") + } + .buttonStyle(.plain) + .foregroundColor(.accentColor) + .disabled(connectionConfig.url.isEmpty) + .help("Test Connection") + } + } + if connectionConfig.url.isEmpty { + Text("Enter URL of remote server") + } + } + + if let message = connectionTestMessage, let success = connectionTestSuccess { + HStack(spacing: 4) { + Image(systemName: success ? "checkmark.circle" : "xmark.octagon") + .foregroundColor(success ? .green : .red) + Text(message) + .foregroundColor(success ? .green : .red) + .font(.caption2) + } + .transition(.opacity) } } @@ -79,35 +110,6 @@ struct SingleConnectionSettingsView: View { Toggle("Ignore SSL certificates", isOn: $connectionConfig.ignoreSSL) .font(.caption) .opacity(0.8) - - // 🧪 Test Connection Button - HStack { - Button { - Task { - await handleTestConnection() - } - } label: { - if isTestingConnection { - ProgressView() - } else { - Label("Test Connection", systemSymbol: .arrowClockwise) - } - } - .disabled(isTestingConnection || connectionConfig.url.isEmpty) - Spacer() - } - - // 🟢/🔴 Feedback Message - if let message = connectionTestMessage, let success = connectionTestSuccess { - HStack { - Spacer() - Label(message, systemImage: success ? "checkmark.circle.fill" : "xmark.octagon.fill") - .foregroundColor(success ? .green : .red) - .font(.caption2) - Spacer() - } - .transition(.opacity) - } } } From 6bbe63ffb47d2cf4d98f95bdcc0679c16ce444f2 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Sat, 22 Mar 2025 22:50:22 +0100 Subject: [PATCH 099/476] Reducing logging Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- OpenHABCore/Sources/OpenHABCore/Util/Endpoint.swift | 2 +- .../OpenHABCore/Util/LoggingMiddleware.swift | 10 +++++----- .../Sources/OpenHABCore/Util/NetworkTracker.swift | 8 ++++++++ .../Sources/OpenHABCore/Util/OpenAPIService.swift | 13 +++++++++---- openHAB/OpenHABSitemapViewController.swift | 2 +- 5 files changed, 24 insertions(+), 11 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/Endpoint.swift b/OpenHABCore/Sources/OpenHABCore/Util/Endpoint.swift index 985b162ef..794ea9764 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/Endpoint.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/Endpoint.swift @@ -61,7 +61,7 @@ public extension Endpoint { components?.path = path components?.queryItems = queryItems let url = components?.url - Endpoint.logger.info("URL: \(url?.absoluteString ?? "", privacy: .private)") +// Endpoint.logger.debug("URL: \(url?.absoluteString ?? "", privacy: .private)") return url } diff --git a/OpenHABCore/Sources/OpenHABCore/Util/LoggingMiddleware.swift b/OpenHABCore/Sources/OpenHABCore/Util/LoggingMiddleware.swift index 6ad8359e2..8b3267537 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/LoggingMiddleware.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/LoggingMiddleware.swift @@ -36,12 +36,12 @@ extension LoggingMiddleware: ClientMiddleware { operationID: String, next: @Sendable (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?)) async throws -> (HTTPResponse, HTTPBody?) { let (requestBodyToLog, requestBodyForNext) = try await bodyLoggingPolicy.process(body) - log(request, requestBodyToLog) +// log(request, requestBodyToLog) do { let (response, responseBody) = try await next(request, requestBodyForNext, baseURL) let (responseBodyToLog, responseBodyForNext) = try await bodyLoggingPolicy.process(responseBody) logger.debug("Trying URL: \(baseURL) for operation ID:\(operationID)") - log(request, response, responseBodyToLog) +// log(request, response, responseBodyToLog) return (response, responseBodyForNext) } catch is CancellationError { // ✅ If the task is canceled, we simply return nil without logging it as an error. @@ -58,9 +58,9 @@ extension LoggingMiddleware: ClientMiddleware { throw error } } catch { - if operationID != "getRoot", operationID != "getRootVersion" { - log(request, failedWith: error) - } +// if operationID != "getRoot" { + log(request, failedWith: error) +// } throw error } } diff --git a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift index bc919ce1a..2b2fa2e42 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift @@ -241,6 +241,14 @@ public final class NetworkTracker: ObservableObject { } catch NetworkTrackerError.invalidServerVersion { logger.info("testConnection error - Invalid server version from \(configuration.url)") return nil + } catch let error as OpenAPIServiceError { + switch error { + case let .undocumented(statusCode, payload): + logger.info("Undocumented status code: ") // \(statusCode), ") // payload: \(String(describing: payload))") + return nil + default: + return nil + } } catch let openAPIError as OpenAPIRuntime.ClientError { logger.info("testConnection error - OpenAPIRuntime.RuntimeError encountered for \(configuration.url): \(openAPIError)") return nil diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift index ad3478298..ec59a1033 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift @@ -16,7 +16,7 @@ import OpenAPIURLSession import os public enum OpenAPIServiceError: Error { - case undocumented(statusCode: Swift.Int, undocumentedPayload: OpenAPIRuntime.UndocumentedPayload) + case undocumented(statusCode: Int, undocumentedPayload: OpenAPIRuntime.UndocumentedPayload) case noRootURL case badRequest case notFound @@ -144,9 +144,14 @@ public extension OpenAPIService { public extension OpenAPIService { @discardableResult func getRoot() async throws -> OpenHABServerProperties { - let result = try await client.getRoot() - .ok.body.json - return OpenHABServerProperties(result) + let response = try await client.getRoot() + switch response { + case let .ok(okresponse): + let result = try okresponse.body.json + return OpenHABServerProperties(result) + case let .undocumented(statusCode, undocumentedPayload): + throw OpenAPIServiceError.undocumented(statusCode: statusCode, undocumentedPayload: undocumentedPayload) + } } @discardableResult diff --git a/openHAB/OpenHABSitemapViewController.swift b/openHAB/OpenHABSitemapViewController.swift index a30479681..d17aa31c8 100644 --- a/openHAB/OpenHABSitemapViewController.swift +++ b/openHAB/OpenHABSitemapViewController.swift @@ -323,7 +323,7 @@ class OpenHABSitemapViewController: OpenHABViewController { self.showPopupMessage(seconds: 1.5, title: NSLocalizedString("connecting", comment: ""), message: "", theme: .info) case .notConnected: os_log("Tracking error", log: .viewCycle, type: .info) - self.showPopupMessage(seconds: 60, title: NSLocalizedString("error", comment: ""), message: NSLocalizedString("network_not_available", comment: ""), theme: .error) +// self.showPopupMessage(seconds: 60, title: NSLocalizedString("error", comment: ""), message: NSLocalizedString("network_not_available", comment: ""), theme: .error) case .connected: self.hidePopupMessages() default: From 5366697f2679228563bf17e30ea6c10a87c62ce5 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Sun, 23 Mar 2025 19:00:03 +0100 Subject: [PATCH 100/476] Fix for crash when tapping on message. Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../OpenHABCore/Util/NetworkTracker.swift | 55 +++++++++++++++++-- .../OpenHABCore/Util/OpenAPIService.swift | 5 +- openHAB/AppDelegate.swift | 31 +++++++++-- 3 files changed, 81 insertions(+), 10 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift index 2b2fa2e42..1a69e0f98 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift @@ -63,6 +63,32 @@ actor ConnectionPool { } } +// Ensures a thread safe access to failureCounts dictionary +actor ConnectionFailureTracker { + private var failureCounts: [ConnectionConfiguration: Int] = [:] + private let maxFailures = 3 + + func shouldAttempt(_ config: ConnectionConfiguration) -> Bool { + (failureCounts[config] ?? 0) < maxFailures + } + + func recordFailure(_ config: ConnectionConfiguration) { + failureCounts[config, default: 0] += 1 + } + + func reset(_ config: ConnectionConfiguration) { + failureCounts[config] = 0 + } + + func resetAll() { + failureCounts.removeAll() + } + + func maxFailureCount() -> Int { + failureCounts.values.max() ?? 0 + } +} + public final class NetworkTracker: ObservableObject { public static let shared = NetworkTracker() @@ -71,8 +97,6 @@ public final class NetworkTracker: ObservableObject { // @MainActor @Published public private(set) var status: NetworkStatus = .connecting - private var retryCount = 0 - private let maxRetries = 5 private let monitor = NWPathMonitor() private let monitorQueue = DispatchQueue.global(qos: .background) private var connectionPool: ConnectionPool = .init() @@ -80,6 +104,8 @@ public final class NetworkTracker: ObservableObject { private var retryTask: Task? private let disconnectedRetryInterval: UInt64 = 30 // / amount of time we scan when not connected + private var failureTracker = ConnectionFailureTracker() + // TODO: remove public var clientCertificateManager = ClientCertificateManager() public var serverCertificateManager = ServerCertificateManager() @@ -231,15 +257,25 @@ public final class NetworkTracker: ObservableObject { private func testConnection(configuration: ConnectionConfiguration) async -> ConnectionInfo? { guard URL(string: configuration.url) != nil else { return nil } + let shouldTry = await failureTracker.shouldAttempt(configuration) + if !shouldTry { + logger.info("Skipping \(configuration.url) due to repeated failures.") + return nil + } + do { logger.info("testConnection for \(configuration.url)") let connection = await connectionPool.getOrCreateService(for: configuration) let version = try await connection.getRootVersion() let connectionInfo = ConnectionInfo(configuration: configuration, version: version) + + await failureTracker.reset(configuration) // Reset on success logger.info("testConnection successful for \(configuration.url)") return connectionInfo } catch NetworkTrackerError.invalidServerVersion { logger.info("testConnection error - Invalid server version from \(configuration.url)") + await failureTracker.recordFailure(configuration) + return nil } catch let error as OpenAPIServiceError { switch error { @@ -254,14 +290,19 @@ public final class NetworkTracker: ObservableObject { return nil } catch { logger.info("testConnection error - Failed to connect to \(configuration.url) \(error.localizedDescription)") + await failureTracker.recordFailure(configuration) return nil } } - private func startRetryTask(_ retryInterval: UInt64) { + private func startRetryTask(_ initialRetryInterval: UInt64) { retryTask?.cancel() retryTask = Task { - try? await Task.sleep(nanoseconds: UInt64(retryInterval * 1_000_000_000)) + let backoffMultiplier = await UInt64(failureTracker.maxFailureCount()) + let safeBackoff = min(backoffMultiplier, 10) // 2^10 = 1024 + let delay = min(initialRetryInterval * (1 << safeBackoff), 300) + logger.info("Retrying in \(delay) seconds based on failure count.") + try? await Task.sleep(nanoseconds: delay * 1_000_000_000) await attemptConnection() } } @@ -295,6 +336,12 @@ public final class NetworkTracker: ObservableObject { status = newStatus logger.info("Network status updated: \(newStatus.rawValue)") } + + public func resetFailures() { + Task { + await failureTracker.resetAll() + } + } } public extension NetworkTracker { diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift index ec59a1033..3af15a9a2 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift @@ -227,6 +227,7 @@ public extension OpenAPIService { /// Poll page data on sitemap /// - Parameters: /// - sitemapname: name of sitemap + /// - pageId: id of subpage /// - longPolling: set to true for long-polling func pollDataForPage(sitemapname: String, pageId: String = "", longPolling: Bool) async throws -> OpenHABPage? { var headers = Operations.pollDataForPage.Input.Headers() @@ -239,7 +240,7 @@ public extension OpenAPIService { } else { Operations.pollDataForPage.Input.Path(sitemapname: sitemapname, pageid: pageId) } - logger.debug("pollDataForPage: :\(String(describing: headers)), \(String(describing: path))") + // logger.debug("pollDataForPage: :\(String(describing: headers)), \(String(describing: path))") return try await pollDataForPage(path: path, headers: headers) } @@ -252,6 +253,8 @@ public extension OpenAPIService { return OpenHABSitemap(result) } + // Unused currently + // To be used when migrating to SSE func pollDataForSitemap(sitemapname: String, longPolling: Bool, subscriptionId: String? = nil) async throws -> OpenHABSitemap? { var headers = Operations.pollDataForSitemap.Input.Headers() if longPolling { diff --git a/openHAB/AppDelegate.swift b/openHAB/AppDelegate.swift index ca789b5e6..781053221 100644 --- a/openHAB/AppDelegate.swift +++ b/openHAB/AppDelegate.swift @@ -241,6 +241,20 @@ extension AppDelegate: UNUserNotificationCenterDelegate { config.duration = .seconds(seconds: 5) config.presentationStyle = .bottom + class MessageTapGestureRecognizer: UITapGestureRecognizer { + private let handler: () -> Void + + init(handler: @escaping () -> Void) { + self.handler = handler + super.init(target: nil, action: nil) + addTarget(self, action: #selector(handleTap)) + } + + @objc private func handleTap() { + handler() + } + } + await MainActor.run { SwiftMessages.show(config: config) { let view = MessageView.viewFromNib(layout: .cardView) @@ -248,16 +262,22 @@ extension AppDelegate: UNUserNotificationCenterDelegate { view.configureContent(title: NSLocalizedString("notification", comment: ""), body: message) view.button?.setTitle(NSLocalizedString("dismiss", comment: ""), for: .normal) view.buttonTapHandler = { _ in SwiftMessages.hide() } - // Add tap gesture recognizer to the view for actions - let tapGesture = UITapGestureRecognizer(target: self, action: #selector(self.messageViewTapped)) + + // Use closure-based tap gesture insteae of #selector + let tapGesture = MessageTapGestureRecognizer { + Task { + await self.messageViewTapped() + } + } view.addGestureRecognizer(tapGesture) + return view } } } // Action to be performed when the notification message view is tapped - @objc func messageViewTapped() async { + func messageViewTapped() async { if let userInfo = appData.lastNotificationInfo { await notifyNotificationListeners(userInfo) SwiftMessages.hideAll() @@ -265,9 +285,10 @@ extension AppDelegate: UNUserNotificationCenterDelegate { } // ✅ Ensure this runs on the MainActor + @MainActor private func notifyNotificationListeners(_ userInfo: [AnyHashable: Any]) async { - if let navigationController = await MainActor.run(body: { window?.rootViewController as? UINavigationController }), - let rootViewController = await MainActor.run(body: { navigationController.viewControllers.first as? OpenHABRootViewController }) { + if let navigationController = window?.rootViewController as? UINavigationController, + let rootViewController = navigationController.viewControllers.first as? OpenHABRootViewController { await rootViewController.handleNotification(userInfo) } } From a5a957614247dffb1eed2ca03ed7b57586881a6b Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Mon, 24 Mar 2025 20:14:56 +0100 Subject: [PATCH 101/476] Centralizing all basicAuthHeader functions Migration of HTTPClient's initializer to init(configuration: ) Migrating watchApp to ConnectionConfiguration Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- NotificationService/NotificationService.swift | 6 +- .../Util/AuthorisationMiddleware.swift | 14 +-- .../Util/ConnectionConfiguration.swift | 15 ++-- .../Sources/OpenHABCore/Util/HTTPClient.swift | 26 ++++-- .../Util/OpenHABAccessTokenAdapter.swift | 7 +- .../OpenHABCore/Util/Preferences.swift | 4 +- openHAB/OpenHABRootViewController.swift | 2 +- openHAB/OpenHABWebViewController.swift | 1 + openHABWatch/Domain/UserData.swift | 43 +++++---- openHABWatch/External/AppMessageService.swift | 63 ++++++-------- .../Model/ObservableOpenHABDataObject.swift | 87 ++----------------- openHABWatch/OpenHABWatch.swift | 26 +----- .../Views/PreferencesSwiftUIView.swift | 5 +- 13 files changed, 104 insertions(+), 195 deletions(-) diff --git a/NotificationService/NotificationService.swift b/NotificationService/NotificationService.swift index 6c167cb32..30e116d13 100644 --- a/NotificationService/NotificationService.swift +++ b/NotificationService/NotificationService.swift @@ -169,11 +169,7 @@ class NotificationService: UNNotificationServiceExtension { guard let activeConfig = await NetworkTracker.shared.waitForActiveConnection()?.configuration else { return nil } - let client = HTTPClient( - username: activeConfig.username, - password: activeConfig.password, - alwaysSendBasicAuth: activeConfig.alwaysSendBasicAuth - ) + let client = HTTPClient(configuration: activeConfig) let (localURL, urlResponse) = try await client.downloadFile(url: fullURL) return await attachFile(localURL: localURL, mimeType: urlResponse.mimeType) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/AuthorisationMiddleware.swift b/OpenHABCore/Sources/OpenHABCore/Util/AuthorisationMiddleware.swift index 929959bba..5d96a1587 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/AuthorisationMiddleware.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/AuthorisationMiddleware.swift @@ -15,24 +15,26 @@ import OpenAPIRuntime import OpenAPIURLSession import os +public func basicAuthHeader(username: String, password: String) -> String { + let credential = Data("\(username):\(password)".utf8).base64EncodedString() + return "Basic \(credential)" +} + public struct AuthorisationMiddleware { private let username: String private let password: String private let alwaysSendBasicAuth: Bool + private let configuration: ConnectionConfiguration public init(configuration: ConnectionConfiguration) { username = configuration.username password = configuration.password alwaysSendBasicAuth = configuration.alwaysSendBasicAuth + self.configuration = configuration } } extension AuthorisationMiddleware: ClientMiddleware { - private func basicAuthHeader() -> String { - let credential = Data("\(username):\(password)".utf8).base64EncodedString() - return "Basic \(credential)" - } - public func intercept(_ request: HTTPRequest, body: HTTPBody?, baseURL: URL, @@ -41,7 +43,7 @@ extension AuthorisationMiddleware: ClientMiddleware { // Use a mutable copy of request var newRequest = request if baseURL.host?.hasSuffix("myopenhab.org") == true || alwaysSendBasicAuth, !username.isEmpty, !password.isEmpty { - newRequest.headerFields[.authorization] = basicAuthHeader() + newRequest.headerFields[.authorization] = basicAuthHeader(username: username, password: password) } let (response, body) = try await next(newRequest, body, baseURL) return (response, body) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/ConnectionConfiguration.swift b/OpenHABCore/Sources/OpenHABCore/Util/ConnectionConfiguration.swift index 598e05f5a..a2333866c 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/ConnectionConfiguration.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/ConnectionConfiguration.swift @@ -9,7 +9,17 @@ // // SPDX-License-Identifier: EPL-2.0 +public struct ConnectionPayload: Codable { + public var local: ConnectionConfiguration + public var remote: ConnectionConfiguration +} + public struct ConnectionConfiguration: Hashable, Sendable, Codable { + // 🔹 Coding keys for manual encoding/decoding + private enum CodingKeys: String, CodingKey { + case url, username, password, alwaysSendBasicAuth, ignoreSSL, priority + } + public var url: String public var username: String public var password: String @@ -65,9 +75,4 @@ public struct ConnectionConfiguration: Hashable, Sendable, Codable { } return newUrl } - - // 🔹 Coding keys for manual encoding/decoding - private enum CodingKeys: String, CodingKey { - case url, username, password, alwaysSendBasicAuth, ignoreSSL, priority - } } diff --git a/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift b/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift index c8a52e6d0..6f7eb57f7 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift @@ -75,7 +75,6 @@ public class HTTPClient: NSObject { private let logger = Logger(subsystem: "org.openhab.core", category: "HTTPClient") public init(baseURL: URL? = nil, username: String = "", password: String = "", alwaysSendBasicAuth: Bool = false, ignoreSSL: Bool = false) { - self.baseURL = baseURL self.username = username self.password = password self.alwaysSendBasicAuth = alwaysSendBasicAuth @@ -90,6 +89,22 @@ public class HTTPClient: NSObject { initializeCertificatesStore() } + public init(baseURL: URL? = nil, configuration: ConnectionConfiguration) { +// public init(baseURL: URL? = nil, username: String = "", password: String = "", alwaysSendBasicAuth: Bool = false, ignoreSSL: Bool = false) { + username = configuration.username + password = configuration.password + alwaysSendBasicAuth = configuration.alwaysSendBasicAuth + ignoreSSL = configuration.ignoreSSL + super.init() + + let config = URLSessionConfiguration.default + config.timeoutIntervalForRequest = 10 + config.timeoutIntervalForResource = 60 + + session = URLSession(configuration: config, delegate: self, delegateQueue: nil) + initializeCertificatesStore() + } + public func processStream(url: URL) async throws -> (URLSession.AsyncBytes, URLResponse) { do { return try await doRequest(baseURL: url, type: .bytes) @@ -188,7 +203,7 @@ public class HTTPClient: NSObject { var request = request if request.url?.host?.hasSuffix("myopenhab.org") == true || alwaysSendBasicAuth, !username.isEmpty, !password.isEmpty { - request.setValue(basicAuthHeader(), forHTTPHeaderField: "Authorization") + request.setValue(basicAuthHeader(username: username, password: password), forHTTPHeaderField: "Authorization") } switch type { @@ -201,13 +216,6 @@ public class HTTPClient: NSObject { } } - // MARK: - Basic Authentication - - private func basicAuthHeader() -> String { - let credential = Data("\(username):\(password)".utf8).base64EncodedString() - return "Basic \(credential)" - } - // MARK: - SSL Certificate Handling private func initializeCertificatesStore() { diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OpenHABAccessTokenAdapter.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABAccessTokenAdapter.swift index 566e7b0af..977c64908 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/OpenHABAccessTokenAdapter.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABAccessTokenAdapter.swift @@ -34,16 +34,11 @@ public class OpenHABAccessTokenAdapter { var urlRequest = urlRequest - func basicAuthHeader() -> String { - let authString = "\(user):\(password)" - let authData = authString.data(using: .utf8)! - return "Basic \(authData.base64EncodedString())" - } // We are handling URLRequests here, so we need to set the header fields // to the request object with String and cannot use the type safe way of HTTPRequest // like request.headerFields[.authorization] = basicAuthHeader() // TODO: revert this!! - urlRequest.setValue(basicAuthHeader(), forHTTPHeaderField: "Authorization") + urlRequest.setValue(basicAuthHeader(username: user, password: password), forHTTPHeaderField: "Authorization") return urlRequest } } diff --git a/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift index 886d46da4..e1034491c 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift @@ -159,13 +159,13 @@ public enum Preferences { @UserDefault("alwaysAllowWebRTC", defaultValue: false) public static var alwaysAllowWebRTC: Bool @UserDefault("sitemapForWatch", defaultValue: "watch") public static var sitemapForWatch: String @UserDefaultObject("localConnectionConfig", defaultValue: ConnectionConfiguration( - url: "http://localhost:8080", + url: "http://192.168.1.1:8080", username: "", password: "", alwaysSendBasicAuth: false, ignoreSSL: false, priority: 0)) public static var localConnectionConfig: ConnectionConfiguration - @UserDefaultObject("connectionConfig", defaultValue: ConnectionConfiguration( + @UserDefaultObject("remoteConnectionConfig", defaultValue: ConnectionConfiguration( url: "https://myopenhab.org", username: "", password: "", diff --git a/openHAB/OpenHABRootViewController.swift b/openHAB/OpenHABRootViewController.swift index f8578492d..ec281c82f 100644 --- a/openHAB/OpenHABRootViewController.swift +++ b/openHAB/OpenHABRootViewController.swift @@ -298,7 +298,7 @@ class OpenHABRootViewController: UIViewController { logger.info("Registering notifications with \(config.url)") Task { do { - let client = HTTPClient(username: config.username, password: config.password, alwaysSendBasicAuth: config.alwaysSendBasicAuth) + let client = HTTPClient(configuration: config) try await client.register(prefsURL: config.url, deviceToken: deviceToken, deviceId: deviceId, deviceName: deviceName) logger.info("my.openHAB registration succeeded") } catch { diff --git a/openHAB/OpenHABWebViewController.swift b/openHAB/OpenHABWebViewController.swift index ff255a0a0..896d16e02 100644 --- a/openHAB/OpenHABWebViewController.swift +++ b/openHAB/OpenHABWebViewController.swift @@ -121,6 +121,7 @@ class OpenHABWebViewController: OpenHABViewController { func loadWebView(force: Bool = false, path: String? = nil) { logger.info("loadWebView tracked URL: \(self.activeConfig?.url ?? "") forced \(force ? "true" : "false")") guard let activeConfig else { return } + // TODO: Check whether credentials are truly put into newTarget let authStr = "\(activeConfig.username):\(activeConfig.password)" let newTarget = "\(activeConfig.url):\(authStr)" diff --git a/openHABWatch/Domain/UserData.swift b/openHABWatch/Domain/UserData.swift index 3095850ab..45625d25a 100644 --- a/openHABWatch/Domain/UserData.swift +++ b/openHABWatch/Domain/UserData.swift @@ -23,6 +23,7 @@ import Foundation import OpenHABCore import os.log +import SDWebImage import SwiftUI @MainActor @@ -104,28 +105,38 @@ final class UserData: ObservableObject { ObservableOpenHABDataObject.shared.openHABRootUrl = activeConnection.configuration.url ObservableOpenHABDataObject.shared.openHABVersion = activeConnection.version + let alwaysSendBasicAuth = activeConnection.configuration.alwaysSendBasicAuth + let username = activeConnection.configuration.username + let password = activeConnection.configuration.password + let requestModifier = SDWebImageDownloaderRequestModifier { (request) -> URLRequest? in + guard alwaysSendBasicAuth || request.url?.host?.hasSuffix("myopenhab.org") == true else { + return request + } + guard !username.isEmpty, !password.isEmpty else { + return request + } + var request = request + + // We are handling URLRequests here, so we need to set the header fields + // to the request object with String and cannot use the type safe way of HTTPRequest + // like request.headerFields[.authorization] = basicAuthHeader() + // TODO: revert this + request.setValue(basicAuthHeader(username: username, password: password), forHTTPHeaderField: "Authorization") + return request + } + SDWebImageDownloader.shared.requestModifier = requestModifier + await loadPage(sitemapName: ObservableOpenHABDataObject.shared.sitemapForWatch, longPolling: false, refresh: true) } } func updateNetwork() async { - if !ObservableOpenHABDataObject.shared.localUrl.isEmpty || !ObservableOpenHABDataObject.shared.remoteUrl.isEmpty { - // TODO: - let connection1 = ConnectionConfiguration( - url: ObservableOpenHABDataObject.shared.localUrl, - username: "", - password: "", - priority: 0 - ) - let connection2 = ConnectionConfiguration( - url: ObservableOpenHABDataObject.shared.remoteUrl, - username: "", - password: "", - priority: 1 - ) - - NetworkTracker.shared.startTracking(connectionConfigurations: [connection1, connection2]) + guard let connection1 = ObservableOpenHABDataObject.shared.localConnectionConfig, + let connection2 = ObservableOpenHABDataObject.shared.remoteConnectionConfig else { + logger.info("No connections defined") + return } + NetworkTracker.shared.startTracking(connectionConfigurations: [connection1, connection2]) } func loadPage(sitemapName: String, longPolling: Bool, refresh: Bool) async { diff --git a/openHABWatch/External/AppMessageService.swift b/openHABWatch/External/AppMessageService.swift index dfb6f5c45..8ffb3d6ec 100644 --- a/openHABWatch/External/AppMessageService.swift +++ b/openHABWatch/External/AppMessageService.swift @@ -22,15 +22,18 @@ class AppMessageService: NSObject, WCSessionDelegate { private let logger = Logger(subsystem: "org.openhab.app.watchkitapp", category: "AppMessageService") func updateValuesFromApplicationContext(_ applicationContext: [String: AnyObject]) { - if !applicationContext.isEmpty { - if let localUrl = applicationContext["localUrl"] as? String { - ObservableOpenHABDataObject.shared.localUrl = localUrl - } + guard !applicationContext.isEmpty else { return } + + do { + // Decode the connection payload + if let connectionPayloadDict = applicationContext["connectionPayload"] as? [String: Any] { + let data = try JSONSerialization.data(withJSONObject: connectionPayloadDict, options: []) + let payload = try JSONDecoder().decode(ConnectionPayload.self, from: data) - if let remoteUrl = applicationContext["remoteUrl"] as? String { - ObservableOpenHABDataObject.shared.remoteUrl = remoteUrl + ObservableOpenHABDataObject.shared.localConnectionConfig = payload.local + ObservableOpenHABDataObject.shared.remoteConnectionConfig = payload.remote } - // !!! + if let sitemapName = applicationContext["defaultSitemap"] as? String { ObservableOpenHABDataObject.shared.sitemapName = sitemapName } @@ -39,51 +42,35 @@ class AppMessageService: NSObject, WCSessionDelegate { ObservableOpenHABDataObject.shared.sitemapForWatch = sitemapForWatch } - if let username = applicationContext["username"] as? String { - ObservableOpenHABDataObject.shared.openHABUsername = username - } - - if let password = applicationContext["password"] as? String { - ObservableOpenHABDataObject.shared.openHABPassword = password - } - - if let ignoreSSL = applicationContext["ignoreSSL"] as? Bool { - ObservableOpenHABDataObject.shared.ignoreSSL = ignoreSSL - } - if let trustedCertificates = applicationContext["trustedCertificates"] as? [String: Data] { // do we need to do anything here? We load from the shared keychain. } - if let alwaysSendCreds = applicationContext["alwaysSendCreds"] as? Bool { - ObservableOpenHABDataObject.shared.openHABAlwaysSendCreds = alwaysSendCreds - } - if let iconType = applicationContext["iconType"] as? IconType { ObservableOpenHABDataObject.shared.iconType = iconType } ObservableOpenHABDataObject.shared.haveReceivedAppContext = true + + } catch { + logger.error("Failed to decode ConnectionPayload: \(error.localizedDescription)") } } func requestApplicationContext() { - WCSession - .default - .sendMessage( - ["request": "Preferences"], - replyHandler: { (response) in - let filteredMessages = response.filter { ["remoteUrl", "localUrl", "username"].contains($0.key) } - self.logger.info("Received \(filteredMessages)") - - DispatchQueue.main.async { () in - self.updateValuesFromApplicationContext(response as [String: AnyObject]) - } - }, - errorHandler: { (error) in - self.logger.error("Error sending message \(error.localizedDescription)") + WCSession.default.sendMessage( + ["request": "Preferences"], + replyHandler: { response in + let filteredMessages = response.filter { ["remoteUrl", "localUrl", "username"].contains($0.key) } + self.logger.info("Received \(filteredMessages)") + + DispatchQueue.main.async { () in + self.updateValuesFromApplicationContext(response as [String: AnyObject]) } - ) + } + ) { error in + self.logger.error("Error sending message \(error.localizedDescription)") + } } func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) { diff --git a/openHABWatch/Model/ObservableOpenHABDataObject.swift b/openHABWatch/Model/ObservableOpenHABDataObject.swift index 3fd47549d..589b88dbc 100644 --- a/openHABWatch/Model/ObservableOpenHABDataObject.swift +++ b/openHABWatch/Model/ObservableOpenHABDataObject.swift @@ -13,90 +13,19 @@ import Combine import Foundation import OpenHABCore -final class ObservableOpenHABDataObject: DataObject, ObservableObject { +final class ObservableOpenHABDataObject: ObservableObject { static let shared = ObservableOpenHABDataObject() var openHABVersion: Int = 2 - let objectWillChange = PassthroughSubject() - let objectRefreshed = PassthroughSubject() + @Published var localConnectionConfig: ConnectionConfiguration? + @Published var remoteConnectionConfig: ConnectionConfiguration? + @Published var haveReceivedAppContext: Bool = false - @UserDefaultsBacked(key: "rootUrl", defaultValue: "") - var openHABRootUrl: String { - willSet { - objectWillChange.send() - } - } - - @UserDefaultsBacked(key: "localUrl", defaultValue: "") - var localUrl: String { - willSet { - objectWillChange.send() - } - } - - @UserDefaultsBacked(key: "remoteUrl", defaultValue: "") - var remoteUrl: String { - willSet { - objectWillChange.send() - } - } - - @UserDefaultsBacked(key: "sitemapName", defaultValue: "") - var sitemapName: String { - willSet { - objectWillChange.send() - } - } - - @UserDefaultsBacked(key: "sitemapForWatch", defaultValue: "") - var sitemapForWatch: String { - willSet { - objectWillChange.send() - } - } - - @UserDefaultsBacked(key: "username", defaultValue: "") - var openHABUsername: String { - willSet { - objectWillChange.send() - } - } - - @UserDefaultsBacked(key: "password", defaultValue: "") - var openHABPassword: String { - willSet { - objectWillChange.send() - } - } - - @UserDefaultsBacked(key: "ignoreSSL", defaultValue: true) - var ignoreSSL: Bool { - willSet { - objectWillChange.send() - } - } - - @UserDefaultsBacked(key: "alwaysSendCreds", defaultValue: false) - var openHABAlwaysSendCreds: Bool { - willSet { - objectWillChange.send() - } - } - - @UserDefaultsBacked(key: "haveReceivedAppContext", defaultValue: false) - var haveReceivedAppContext: Bool { - didSet { - objectRefreshed.send() - } - } - - @UserDefaultsBacked(key: "iconType", defaultValue: .svg) - var iconType: IconType { - didSet { - objectRefreshed.send() - } - } + @Published var openHABRootUrl = "" + @Published var sitemapName = "" + @Published var sitemapForWatch = "" + @Published var iconType: IconType = .svg } extension ObservableOpenHABDataObject { diff --git a/openHABWatch/OpenHABWatch.swift b/openHABWatch/OpenHABWatch.swift index 1668390a6..ff71728c8 100644 --- a/openHABWatch/OpenHABWatch.swift +++ b/openHABWatch/OpenHABWatch.swift @@ -9,6 +9,7 @@ // // SPDX-License-Identifier: EPL-2.0 +import OpenHABCore import SDWebImage import SDWebImageSVGCoder import SwiftUI @@ -54,31 +55,6 @@ struct OpenHABWatch: App { let SVGCoder = SDImageSVGCoder.shared SDImageCodersManager.shared.addCoder(SVGCoder) SDWebImageDownloader.shared.config.operationClass = OpenHABImageDownloaderOperation.self - let alwaysSendCreds = settings.openHABAlwaysSendCreds - let openHABUsername = settings.openHABUsername - let openHABPassword = settings.openHABPassword - let requestModifier = SDWebImageDownloaderRequestModifier { (request) -> URLRequest? in - guard alwaysSendCreds || request.url?.host?.hasSuffix("myopenhab.org") == true else { - return request - } - guard !openHABUsername.isEmpty, !openHABPassword.isEmpty else { - return request - } - var request = request - - func basicAuthHeader() -> String { - let authString = "\(openHABUsername):\(openHABPassword)" - let authData = authString.data(using: .utf8)! - return "Basic \(authData.base64EncodedString())" - } - // We are handling URLRequests here, so we need to set the header fields - // to the request object with String and cannot use the type safe way of HTTPRequest - // like request.headerFields[.authorization] = basicAuthHeader() - // TODO: revert this - request.setValue(basicAuthHeader(), forHTTPHeaderField: "Authorization") - return request - } - SDWebImageDownloader.shared.requestModifier = requestModifier DispatchQueue.main.async { AppMessageService.singleton.requestApplicationContext() } diff --git a/openHABWatch/Views/PreferencesSwiftUIView.swift b/openHABWatch/Views/PreferencesSwiftUIView.swift index 1fe57b30c..2def30de9 100644 --- a/openHABWatch/Views/PreferencesSwiftUIView.swift +++ b/openHABWatch/Views/PreferencesSwiftUIView.swift @@ -26,10 +26,9 @@ struct PreferencesSwiftUIView: View { var body: some View { List { LabeledContent(LocalizedStringKey("active_url"), value: settings.openHABRootUrl) - LabeledContent(LocalizedStringKey("local_url"), value: settings.localUrl) - LabeledContent(LocalizedStringKey("remote_url"), value: settings.remoteUrl) + LabeledContent(LocalizedStringKey("local_url"), value: settings.localConnectionConfig?.url ?? "empty") + LabeledContent(LocalizedStringKey("remote_url"), value: settings.remoteConnectionConfig?.url ?? "empty") LabeledContent(LocalizedStringKey("sitemap"), value: settings.sitemapForWatch) - LabeledContent(LocalizedStringKey("username"), value: settings.openHABUsername) LabeledContent(LocalizedStringKey("version"), value: applicationVersionNumber) } From 7e645095d707b0509ef5705b7e36bd774ddd2a58 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Mon, 24 Mar 2025 21:22:44 +0100 Subject: [PATCH 102/476] Moved OpenHABNotification to openHABCore, migrated it to struct, moved the decoding to HTTPClient convenience function Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../Model}/OpenHABNotification.swift | 35 +++++-------------- .../Sources/OpenHABCore/Util/HTTPClient.swift | 20 +++++++---- .../OpenHABCore/Util/LoggingMiddleware.swift | 4 +-- .../OpenHABCore/Util/NetworkTracker.swift | 1 + openHAB.xcodeproj/project.pbxproj | 4 --- openHAB/NotificationsView.swift | 15 ++++---- 6 files changed, 31 insertions(+), 48 deletions(-) rename {openHAB => OpenHABCore/Sources/OpenHABCore/Model}/OpenHABNotification.swift (57%) diff --git a/openHAB/OpenHABNotification.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABNotification.swift similarity index 57% rename from openHAB/OpenHABNotification.swift rename to OpenHABCore/Sources/OpenHABCore/Model/OpenHABNotification.swift index c669f878a..bc1fe4eb0 100644 --- a/openHAB/OpenHABNotification.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABNotification.swift @@ -11,44 +11,27 @@ import Foundation -class OpenHABNotification: NSObject { - var message: String? - var created: Date? - var icon: String? +public struct OpenHABNotification: Sendable { + public var message: String? + public var created: Date? + public var icon: String? var severity: String? - var id = "" - init(message: String? = nil, created: Date? = nil, icon: String? = nil, severity: String? = nil, id: String = "") { + public var id = "" + + public init(message: String? = nil, created: Date? = nil, icon: String? = nil, severity: String? = nil, id: String = "") { self.message = message self.created = created self.icon = icon self.severity = severity self.id = id } - - convenience init(dictionary: [String: Any]) { - let propertyNames: Set = ["message", "icon", "severity"] - self.init() - let keyArray = dictionary.keys - for key in keyArray { - if key as String == "created" { - let dateFormatter = DateFormatter() - // 2015-09-15T13:39:19.938Z - dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.S'Z'" - created = dateFormatter.date(from: dictionary[key] as? String ?? "") - } else { - if propertyNames.contains(key) { - setValue(dictionary[key], forKey: key) - } - } - } - } } // Decode an instance of OpenHABNotification.CodingData rather than decoding a OpenHABNotificaiton value directly, // then convert that into a openHABNotification // Inspired by https://www.swiftbysundell.com/basics/codable?rq=codingdata -extension OpenHABNotification { - public struct CodingData: Decodable { +public extension OpenHABNotification { + struct CodingData: Decodable { let id: String let message: String? let v: Int diff --git a/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift b/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift index 6f7eb57f7..0f04e0cc3 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift @@ -127,13 +127,19 @@ public class HTTPClient: NSObject { } } - public func notification(urlString: String) async throws -> Data { - if let url = Endpoint.notification(prefsURL: urlString).url { - let (data, _): (Data, URLResponse) = try await doRequest(baseURL: url, type: .data) - return data - } else { - throw HTTPClientError.couldNotLoadNotification - } + public func notification(url: URL) async throws -> Data { + let (data, _): (Data, URLResponse) = try await doRequest(baseURL: url, type: .data) + return data + } + + public func notification(urlString: String) async throws -> [OpenHABNotification] { + guard let url = Endpoint.notification(prefsURL: urlString).url else { throw HTTPClientError.couldNotLoadNotification } + let data = try await notification(url: url) + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .formatted(DateFormatter.iso8601Full) + let codingDatas = try data.decoded(as: [OpenHABNotification.CodingData].self, using: decoder) + return codingDatas.map(\.openHABNotification) } /** diff --git a/OpenHABCore/Sources/OpenHABCore/Util/LoggingMiddleware.swift b/OpenHABCore/Sources/OpenHABCore/Util/LoggingMiddleware.swift index 8b3267537..1865d7ecb 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/LoggingMiddleware.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/LoggingMiddleware.swift @@ -36,12 +36,12 @@ extension LoggingMiddleware: ClientMiddleware { operationID: String, next: @Sendable (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?)) async throws -> (HTTPResponse, HTTPBody?) { let (requestBodyToLog, requestBodyForNext) = try await bodyLoggingPolicy.process(body) -// log(request, requestBodyToLog) + log(request, requestBodyToLog) do { let (response, responseBody) = try await next(request, requestBodyForNext, baseURL) let (responseBodyToLog, responseBodyForNext) = try await bodyLoggingPolicy.process(responseBody) logger.debug("Trying URL: \(baseURL) for operation ID:\(operationID)") -// log(request, response, responseBodyToLog) + log(request, response, responseBodyToLog) return (response, responseBodyForNext) } catch is CancellationError { // ✅ If the task is canceled, we simply return nil without logging it as an error. diff --git a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift index 1a69e0f98..6221c9006 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift @@ -212,6 +212,7 @@ public final class NetworkTracker: ObservableObject { for await connectionInfo in group { guard let connectionInfo else { continue } + connectedCount += 1 if connectionInfo.configuration.priority == 0 { bestConnection = connectionInfo diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index 4654c754d..dc608acdd 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -158,7 +158,6 @@ DAF4F6C0222734D300C24876 /* NewImageUITableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF4F6BF222734D200C24876 /* NewImageUITableViewCell.swift */; }; DF05FF231896BD2D00FF2F9B /* SelectionUITableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF05FF221896BD2D00FF2F9B /* SelectionUITableViewCell.swift */; }; DF06F1FC18FEC2020011E7B9 /* ColorPickerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF06F1FB18FEC2020011E7B9 /* ColorPickerViewController.swift */; }; - DF1B302D1CF5C667009C921C /* OpenHABNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF1B302C1CF5C667009C921C /* OpenHABNotification.swift */; }; DF4B84041885A53700F34902 /* OpenHABDataObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF4B84031885A53700F34902 /* OpenHABDataObject.swift */; }; DF4B84131886DAC400F34902 /* FrameUITableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF4B84121886DAC400F34902 /* FrameUITableViewCell.swift */; }; DF4B84161886EACA00F34902 /* GenericUITableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF4B84151886EACA00F34902 /* GenericUITableViewCell.swift */; }; @@ -474,7 +473,6 @@ DAF6F4112C67E83B0083883E /* openapiCorrected.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = openapiCorrected.json; sourceTree = ""; }; DF05FF221896BD2D00FF2F9B /* SelectionUITableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SelectionUITableViewCell.swift; sourceTree = ""; }; DF06F1FB18FEC2020011E7B9 /* ColorPickerViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ColorPickerViewController.swift; sourceTree = ""; }; - DF1B302C1CF5C667009C921C /* OpenHABNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OpenHABNotification.swift; sourceTree = ""; }; DF4B84031885A53700F34902 /* OpenHABDataObject.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OpenHABDataObject.swift; sourceTree = ""; }; DF4B84121886DAC400F34902 /* FrameUITableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FrameUITableViewCell.swift; sourceTree = ""; }; DF4B84151886EACA00F34902 /* GenericUITableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GenericUITableViewCell.swift; sourceTree = ""; }; @@ -1050,7 +1048,6 @@ isa = PBXGroup; children = ( DF4B84031885A53700F34902 /* OpenHABDataObject.swift */, - DF1B302C1CF5C667009C921C /* OpenHABNotification.swift */, DA5ED9BD2C850955004875E0 /* ClientCertificatesViewModel.swift */, ); name = Models; @@ -1603,7 +1600,6 @@ DA5ED9BE2C850955004875E0 /* ClientCertificatesViewModel.swift in Sources */, DA21EAE22339621C001AB415 /* Throttler.swift in Sources */, DAF4F6C0222734D300C24876 /* NewImageUITableViewCell.swift in Sources */, - DF1B302D1CF5C667009C921C /* OpenHABNotification.swift in Sources */, DA6B2EEF2C861BC900DF77CF /* DrawerView.swift in Sources */, DA48001A2D83742A009CF127 /* DebugSettingsView.swift in Sources */, 938BF9D324EFD0B700E6B52F /* UIViewController+Localization.swift in Sources */, diff --git a/openHAB/NotificationsView.swift b/openHAB/NotificationsView.swift index 2800495ff..b79c1375f 100644 --- a/openHAB/NotificationsView.swift +++ b/openHAB/NotificationsView.swift @@ -69,6 +69,8 @@ struct NotificationRow: View { } struct NotificationsView: View { + private let logger = Logger(subsystem: "org.openhab.app", category: "NotificationView") + @State var notifications: [OpenHABNotification] = [] var body: some View { @@ -86,15 +88,10 @@ struct NotificationsView: View { private func loadNotifications() async -> [OpenHABNotification] { do { - guard let config = Preferences.getLowestPriorityOpenHABConnection() else { - return [] - } - let client = HTTPClient(username: config.username, password: config.username, alwaysSendBasicAuth: config.alwaysSendBasicAuth) - let data = try await client.notification(urlString: Preferences.remoteUrl) - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .formatted(DateFormatter.iso8601Full) - let codingDatas = try data.decoded(as: [OpenHABNotification.CodingData].self, using: decoder) - return codingDatas.map(\.openHABNotification) + guard let config = Preferences.getLowestPriorityOpenHABConnection() else { return [] } + let client = HTTPClient(configuration: config) + return try await client.notification(urlString: config.url) + } catch { os_log("%{PUBLIC}@", log: .default, type: .error, error.localizedDescription) } From f438103b25ff32b4741fffbafbe4f842920a0dec Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Mon, 24 Mar 2025 21:45:36 +0100 Subject: [PATCH 103/476] Make NotificationsView work again and previewable Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- openHAB/NotificationsView.swift | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/openHAB/NotificationsView.swift b/openHAB/NotificationsView.swift index b79c1375f..15566c4c3 100644 --- a/openHAB/NotificationsView.swift +++ b/openHAB/NotificationsView.swift @@ -86,23 +86,26 @@ struct NotificationsView: View { } } + init(notifications: [OpenHABNotification] = []) { + _notifications = State(initialValue: notifications) + } + private func loadNotifications() async -> [OpenHABNotification] { do { guard let config = Preferences.getLowestPriorityOpenHABConnection() else { return [] } let client = HTTPClient(configuration: config) return try await client.notification(urlString: config.url) - } catch { - os_log("%{PUBLIC}@", log: .default, type: .error, error.localizedDescription) + logger.error("\(error.localizedDescription)") } return [] } } #Preview { - NotificationsView(notifications: [OpenHABNotification(message: "message1", created: Date.now, id: UUID().uuidString), OpenHABNotification(message: "message2", created: Date.now, id: UUID().uuidString)]) -} - -#Preview { - NotificationRow(notification: OpenHABNotification(message: "message3", created: Date.now)) + NotificationsView(notifications: [ + OpenHABNotification(message: "message1", created: Date.now, id: UUID().uuidString), + OpenHABNotification(message: "message2", created: Date.now, id: UUID().uuidString) + ] + ) } From dab22357329af455fb4b6429dd36ba4054dd8389 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Tue, 25 Mar 2025 11:16:02 +0100 Subject: [PATCH 104/476] pageHandlingTask as one task managing the entire lifecycle (initial load and longpolling): Easier to cancel and debug, No more recursion or chaining needed. Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- openHAB/OpenHABSitemapViewController.swift | 216 ++++++++++----------- openHAB/OpenHABWebViewController.swift | 2 +- 2 files changed, 100 insertions(+), 118 deletions(-) diff --git a/openHAB/OpenHABSitemapViewController.swift b/openHAB/OpenHABSitemapViewController.swift index d17aa31c8..ddefde6fc 100644 --- a/openHAB/OpenHABSitemapViewController.swift +++ b/openHAB/OpenHABSitemapViewController.swift @@ -126,8 +126,9 @@ class OpenHABSitemapViewController: OpenHABViewController { private var isUserInteracting = false private var isWaitingToReload = false // Properties in your view controller: - private var initialLoadTask: Task? - private var longPollingTask: Task? + + private var pageHandlingTask: Task? + private var pageLoader: PageLoader? private let logger = Logger(subsystem: "org.openhab.app", category: "OpenHABSitemapViewController") @@ -240,7 +241,7 @@ class OpenHABSitemapViewController: OpenHABViewController { if !pageNetworkStatusChanged() || !pageId.isEmpty { // swiftformat:disable:next redundantSelf logger.info("OpenHABSitemapViewController pageUrl \(self.pageUrl)") - loadInitialPage() + startPageHandling() } else { logger.info("OpenHABSitemapViewController network status changed while it was not appearing") restart() @@ -259,13 +260,6 @@ class OpenHABSitemapViewController: OpenHABViewController { trackerCancellables.removeAll() stopAllTasks() - // Cancel long polling to avoid carrying over a pending request. - longPollingTask?.cancel() - longPollingTask = nil - // Optionally cancel the initial load task if it’s still running. - initialLoadTask?.cancel() - initialLoadTask = nil - super.viewWillDisappear(animated) if #unavailable(iOS 13.0) { @@ -299,7 +293,7 @@ class OpenHABSitemapViewController: OpenHABViewController { if isViewLoaded, view.window != nil, !pageUrl.isEmpty { if !pageNetworkStatusChanged() { os_log("OpenHABSitemapViewController isViewLoaded, restarting network activity", log: .viewCycle, type: .info) - loadInitialPage() + startPageHandling() } else { os_log("OpenHABSitemapViewController network status changed while it was inactive", log: .viewCycle, type: .info) restart() @@ -364,6 +358,8 @@ class OpenHABSitemapViewController: OpenHABViewController { task.cancel() } activeTasks.removeAll() + pageHandlingTask?.cancel() + pageHandlingTask = nil } override func reloadView() { @@ -406,7 +402,7 @@ extension OpenHABSitemapViewController { @objc func handleRefresh(_ refreshControl: UIRefreshControl?) { - loadInitialPage() + startPageHandling() widgetTableView.reloadData() widgetTableView.layoutIfNeeded() } @@ -456,105 +452,6 @@ extension OpenHABSitemapViewController { parent?.navigationItem.title = currentPage?.title.components(separatedBy: "[")[0] } - func loadInitialPage() { - initialLoadTask?.cancel() - - guard !pageUrl.isEmpty else { - logger.error("loadPage: Cann't run with empty pageUrl") - return - } - - // swiftformat:disable:next redundantSelf - logger.info("loadPage for \(self.pageUrl)") - - // If this is the first request to the page make a bulk call to pageNetworkStatusChanged - // to save current reachability status. - pageNetworkStatusChanged() - initialLoadTask = Task { - await loadPage(longPolling: false).value - startLongPolling() - } - } - - func startLongPolling() { - longPollingTask?.cancel() // ✅ Cancel previous long polling task - - guard !pageUrl.isEmpty else { - logger.error("startLongPolling: Cannot run with empty pageUrl") - return - } - - logger.info("🔄 Starting long polling...") - longPollingTask = loadPage(longPolling: true) - } - - func loadPage(longPolling: Bool) -> Task { - Task { - do { - // ** Alternative 1 - logger.info("Calling pollDataForPage from loadPage") - - if openAPIService == nil { - openAPIService = OpenAPIService( - connectionConfiguration: appData!.connectionInfo!.configuration) - } - - let page = try await openAPIService?.pollDataForPage(sitemapname: defaultSitemap, pageId: pageId, longPolling: longPolling) - guard let page else { - logger.info("No page found ") - return - } - // ** Alternative 2 to be tested. -// await pageLoader?.updatePageConfig(newPageId: pageId, newSitemap: defaultSitemap) -// guard let page = try await pageLoader?.fetchPage(longPolling: true) else { return } - // ** - - try Task.checkCancellation() // Check for cancellation before processing results - await MainActor.run { - self.updateUI(with: page) - } - - // Only start long polling recursively if this is not the initial load. - if longPolling { - // Start long polling in the background. - startLongPolling() - } - } catch is CancellationError { - logger.info("Task was cancelled") - } catch let error as DecodingError { - os_log("DecodingError %{PUBLIC}@", log: .default, type: .error, error.localizedDescription) - } catch let error as ClientError { - if let urlError = error.underlyingError as? URLError, urlError.code == .cancelled { - logger.info("Task was cancelled - URLError code: .cancelled") - } else if let urlError = error.underlyingError as? URLError, urlError.code == .timedOut { - logger.info("Task timed out - URLError code: .timedOut") - } else { - logger.error("\(error.localizedDescription)") - await MainActor.run { - self.showPopupMessage( - seconds: 5, - title: NSLocalizedString("error", comment: ""), - message: error.localizedDescription, - theme: .error - ) - } - } - } catch let openAPIError as OpenAPIServiceError { - logger.info("On LoadPage \(openAPIError)") - } catch { - logger.error("On LoadPage \(error.localizedDescription)") - await MainActor.run { - self.showPopupMessage( - seconds: 5, - title: NSLocalizedString("error", comment: ""), - message: error.localizedDescription, - theme: .error - ) - } - } - } - } - // Select sitemap func selectSitemap() { Task { @@ -580,7 +477,7 @@ extension OpenHABSitemapViewController { self.currentPage?.widgets.removeAll() // NOTE: remove all widgets to ensure cells get invalidated } pageUrl = sitemapToOpen.homepageLink - loadInitialPage() + startPageHandling() } else { showSideMenu() } @@ -589,7 +486,7 @@ extension OpenHABSitemapViewController { } case 1: pageUrl = sitemaps[0].homepageLink - loadInitialPage() + startPageHandling() case ...0: showPopupMessage(seconds: 5, title: NSLocalizedString("warning", comment: ""), message: NSLocalizedString("empty_sitemap", comment: ""), theme: .warning) showSideMenu() @@ -650,6 +547,94 @@ extension OpenHABSitemapViewController { } } + func startPageHandling() { + pageHandlingTask?.cancel() + + guard !pageUrl.isEmpty else { + logger.error("startPageHandling: Cannot run with empty pageUrl") + return + } + + logger.info("🚀 Starting page load and long polling flow...") + + pageHandlingTask = Task { + do { + // Initial page load + + if openAPIService == nil { + openAPIService = OpenAPIService( + connectionConfiguration: appData!.connectionInfo!.configuration) + } + + let initialPage = try await openAPIService?.pollDataForPage( + sitemapname: defaultSitemap, + pageId: pageId, + longPolling: false + ) + + // Alternative 2 to be tested. + // await pageLoader?.updatePageConfig(newPageId: pageId, newSitemap: defaultSitemap) + // guard let page = try await pageLoader?.fetchPage(longPolling: true) else { return } + // + try Task.checkCancellation() + if let page = initialPage { + await MainActor.run { + self.updateUI(with: page) + } + } + + // Start long polling loop + while !Task.isCancelled { + let page = try await openAPIService?.pollDataForPage( + sitemapname: defaultSitemap, + pageId: pageId, + longPolling: true + ) + try Task.checkCancellation() + + if let page { + await MainActor.run { + self.updateUI(with: page) + } + } + } + + } catch is CancellationError { + logger.info("🔁 pageHandlingTask was cancelled") + } catch let error as DecodingError { + os_log("DecodingError %{PUBLIC}@", log: .default, type: .error, error.localizedDescription) + } catch let error as ClientError { + if let urlError = error.underlyingError as? URLError, urlError.code == .cancelled { + logger.info("Task was cancelled - URLError code: .cancelled") + } else if let urlError = error.underlyingError as? URLError, urlError.code == .timedOut { + logger.info("Task timed out - URLError code: .timedOut") + } else { + logger.error("\(error.localizedDescription)") + await MainActor.run { + self.showPopupMessage( + seconds: 5, + title: NSLocalizedString("error", comment: ""), + message: error.localizedDescription, + theme: .error + ) + } + } + } catch let openAPIError as OpenAPIServiceError { + logger.info("On pageHandling \(openAPIError)") + } catch { + logger.error("❌ pageHandlingTask error: \(error.localizedDescription)") + await MainActor.run { + self.showPopupMessage( + seconds: 5, + title: NSLocalizedString("error", comment: ""), + message: error.localizedDescription, + theme: .error + ) + } + } + } + } + // load app settings func loadSettings() { openHABUsername = Preferences.username @@ -937,10 +922,7 @@ extension OpenHABSitemapViewController: UITableViewDelegate, UITableViewDataSour if let linkedPage = widget.linkedPage { logger.info("Selected linked page: \(linkedPage.link)") - longPollingTask?.cancel() - longPollingTask = nil - initialLoadTask?.cancel() - initialLoadTask = nil + stopAllTasks() // pageId = linkedPage.pageId let newViewController = (storyboard?.instantiateViewController(withIdentifier: "OpenHABPageViewController") as? OpenHABSitemapViewController)! newViewController.title = linkedPage.title.components(separatedBy: "[")[0] diff --git a/openHAB/OpenHABWebViewController.swift b/openHAB/OpenHABWebViewController.swift index 896d16e02..af2c2a098 100644 --- a/openHAB/OpenHABWebViewController.swift +++ b/openHAB/OpenHABWebViewController.swift @@ -54,7 +54,7 @@ class OpenHABWebViewController: OpenHABViewController { private lazy var webView: WKWebView = newWebView() - private var logger = Logger(subsystem: "org.openhab", category: "OpenHABWebViewController") + private var logger = Logger(subsystem: "org.openhab.app", category: "OpenHABWebViewController") override func viewDidLoad() { super.viewDidLoad() From c14011b36f82a579942e86adb45fa34d19f219c8 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Tue, 25 Mar 2025 13:54:48 +0100 Subject: [PATCH 105/476] Fix for failed tests Decluttering OpenHABSitemapViewController: moved helpers to separate files, moved extract icon logic to helper WidgetIconRenderer, preparing for using a widget cell provider Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../OpenHABJSONParserTests.swift | 2 +- openHAB.xcodeproj/project.pbxproj | 30 ++++- .../Providers/RollerShutterCellProvider.swift | 29 +++++ .../Providers/SegmentedCellProvider.swift | 31 +++++ .../Cells/Providers/SwitchCellProvider.swift | 29 +++++ openHAB/OpenHABImageProcessor.swift | 50 ++++++++ openHAB/OpenHABSitemapViewController.swift | 118 ++---------------- openHAB/PageLoader.swift | 53 ++++++++ openHAB/WidgetCellProvider.swift | 60 +++++++++ openHAB/WidgetIconRenderer.swift | 60 +++++++++ 10 files changed, 347 insertions(+), 115 deletions(-) rename {openHABTestsSwift => OpenHABCore/Tests/OpenHABCoreTests}/OpenHABJSONParserTests.swift (98%) create mode 100644 openHAB/Cells/Providers/RollerShutterCellProvider.swift create mode 100644 openHAB/Cells/Providers/SegmentedCellProvider.swift create mode 100644 openHAB/Cells/Providers/SwitchCellProvider.swift create mode 100644 openHAB/OpenHABImageProcessor.swift create mode 100644 openHAB/PageLoader.swift create mode 100644 openHAB/WidgetCellProvider.swift create mode 100644 openHAB/WidgetIconRenderer.swift diff --git a/openHABTestsSwift/OpenHABJSONParserTests.swift b/OpenHABCore/Tests/OpenHABCoreTests/OpenHABJSONParserTests.swift similarity index 98% rename from openHABTestsSwift/OpenHABJSONParserTests.swift rename to OpenHABCore/Tests/OpenHABCoreTests/OpenHABJSONParserTests.swift index 76e4fba3e..a54dc1d4a 100644 --- a/openHABTestsSwift/OpenHABJSONParserTests.swift +++ b/OpenHABCore/Tests/OpenHABCoreTests/OpenHABJSONParserTests.swift @@ -9,7 +9,7 @@ // // SPDX-License-Identifier: EPL-2.0 -@testable import openHAB +@testable import OpenHABCore import XCTest class OpenHABJSONParserTests: XCTestCase { diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index dc608acdd..f8c7e77ad 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 54; + objectVersion = 70; objects = { /* Begin PBXBuildFile section */ @@ -81,11 +81,14 @@ DA21EAE22339621C001AB415 /* Throttler.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA21EAE12339621C001AB415 /* Throttler.swift */; }; DA242C622C83588600AFB10D /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA242C612C83588600AFB10D /* SettingsView.swift */; }; DA28C362225241DE00AB409C /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DA28C361225241DE00AB409C /* WebKit.framework */; settings = {ATTRIBUTES = (Required, ); }; }; + DA2AEB6C2D92BA3F00897D80 /* OpenHABImageProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2AEB6B2D92BA3F00897D80 /* OpenHABImageProcessor.swift */; }; + DA2AEB6E2D92BAD800897D80 /* PageLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2AEB6D2D92BAD800897D80 /* PageLoader.swift */; }; + DA2AEB702D92CF3E00897D80 /* WidgetIconRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2AEB6F2D92CF3E00897D80 /* WidgetIconRenderer.swift */; }; + DA2AEB742D92D2D100897D80 /* WidgetCellProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2AEB732D92D2D100897D80 /* WidgetCellProvider.swift */; }; DA2C4FCD2B4F55D700D1C533 /* SDWebImage in Frameworks */ = {isa = PBXBuildFile; productRef = DA2C4FCC2B4F55D700D1C533 /* SDWebImage */; }; DA2C4FCF2B4F55D700D1C533 /* SDWebImageMapKit in Frameworks */ = {isa = PBXBuildFile; productRef = DA2C4FCE2B4F55D700D1C533 /* SDWebImageMapKit */; }; DA2C4FD22B4F56D000D1C533 /* SDWebImageSwiftUI in Frameworks */ = {isa = PBXBuildFile; productRef = DA2C4FD12B4F56D000D1C533 /* SDWebImageSwiftUI */; }; DA2C4FD52B4F573300D1C533 /* SDWebImageSVGCoder in Frameworks */ = {isa = PBXBuildFile; productRef = DA2C4FD42B4F573300D1C533 /* SDWebImageSVGCoder */; }; - DA2DC23221F2736C00830730 /* OpenHABJSONParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2DC23121F2736C00830730 /* OpenHABJSONParserTests.swift */; }; DA2E0AA423DC96E9009B0A99 /* ImageWithAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2E0AA323DC96E9009B0A99 /* ImageWithAction.swift */; }; DA2E0B0E23DCC153009B0A99 /* MapView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2E0B0D23DCC152009B0A99 /* MapView.swift */; }; DA2E0B1023DCC439009B0A99 /* MapViewRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2E0B0F23DCC439009B0A99 /* MapViewRow.swift */; }; @@ -393,8 +396,11 @@ DA21EAE12339621C001AB415 /* Throttler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Throttler.swift; sourceTree = ""; }; DA242C612C83588600AFB10D /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; DA28C361225241DE00AB409C /* WebKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WebKit.framework; path = System/Library/Frameworks/WebKit.framework; sourceTree = SDKROOT; }; + DA2AEB6B2D92BA3F00897D80 /* OpenHABImageProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenHABImageProcessor.swift; sourceTree = ""; }; + DA2AEB6D2D92BAD800897D80 /* PageLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageLoader.swift; sourceTree = ""; }; + DA2AEB6F2D92CF3E00897D80 /* WidgetIconRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetIconRenderer.swift; sourceTree = ""; }; + DA2AEB732D92D2D100897D80 /* WidgetCellProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetCellProvider.swift; sourceTree = ""; }; DA2DC22F21F2736C00830730 /* openHABTestsSwift.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = openHABTestsSwift.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - DA2DC23121F2736C00830730 /* OpenHABJSONParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenHABJSONParserTests.swift; sourceTree = ""; }; DA2DC23321F2736C00830730 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; DA2E0AA323DC96E9009B0A99 /* ImageWithAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageWithAction.swift; sourceTree = ""; }; DA2E0B0D23DCC152009B0A99 /* MapView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MapView.swift; sourceTree = ""; }; @@ -494,6 +500,10 @@ DFFD8FD018EDBD4F003B502A /* UICircleButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UICircleButton.swift; sourceTree = ""; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedRootGroup section */ + DA2AEB752D92D32000897D80 /* Cells */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Cells; sourceTree = ""; }; +/* End PBXFileSystemSynchronizedRootGroup section */ + /* Begin PBXFrameworksBuildPhase section */ 39C91164B60A5677322E8DE2 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; @@ -776,7 +786,6 @@ children = ( DAF231D327BB6F5C00AB916C /* Ressources */, DA19E25A22FD801D002F8F2F /* OpenHABGeneralTests.swift */, - DA2DC23121F2736C00830730 /* OpenHABJSONParserTests.swift */, 938BF89524EFBC5400E6B52F /* LocalizationTests.swift */, DAC9395422B00E7600C5F423 /* XCTestCaseExtension.swift */, DAC9394322AD4A7A00C5F423 /* OpenHABWatchTests.swift */, @@ -907,6 +916,7 @@ DF4B83FD18857FA100F34902 /* UI */ = { isa = PBXGroup; children = ( + DA2AEB752D92D32000897D80 /* Cells */, DA6B2EEE2C861BC900DF77CF /* DrawerView.swift */, DA4642312D7EE6CA006C3908 /* LoggerView.swift */, DA6B2EF02C87B59000DF77CF /* NotificationsView.swift */, @@ -914,6 +924,8 @@ 653B54BF285C0AC700298ECD /* OpenHABRootViewController.swift */, 65570A7C2476D16A00D524EA /* OpenHABWebViewController.swift */, DFB2624318830A3600D3244D /* OpenHABSitemapViewController.swift */, + DA2AEB732D92D2D100897D80 /* WidgetCellProvider.swift */, + DA2AEB6D2D92BAD800897D80 /* PageLoader.swift */, DA9F81862C85020F00B47B72 /* RTFTextView.swift */, DAC65FC6236EDF3900F4501E /* SpinnerViewController.swift */, DA6B2EF62C8B92E800DF77CF /* SelectionView.swift */, @@ -921,6 +933,8 @@ 1224F78B228A89E300750965 /* Watch */, DF4B84101886DA9900F34902 /* Widgets */, DFFD8FCE18EDBD30003B502A /* Util */, + DA2AEB6B2D92BA3F00897D80 /* OpenHABImageProcessor.swift */, + DA2AEB6F2D92CF3E00897D80 /* WidgetIconRenderer.swift */, ); name = UI; sourceTree = ""; @@ -1228,6 +1242,9 @@ 4D6470D92561F935007B03FC /* PBXTargetDependency */, 657144542C1E438700C8A1F3 /* PBXTargetDependency */, ); + fileSystemSynchronizedGroups = ( + DA2AEB752D92D32000897D80 /* Cells */, + ); name = openHAB; packageProductDependencies = ( 937E4470270B36D000A98C26 /* OpenHABCore */, @@ -1545,7 +1562,6 @@ DA19E25B22FD801D002F8F2F /* OpenHABGeneralTests.swift in Sources */, DAC9395522B00E7600C5F423 /* XCTestCaseExtension.swift in Sources */, 938BF89624EFBC5400E6B52F /* LocalizationTests.swift in Sources */, - DA2DC23221F2736C00830730 /* OpenHABJSONParserTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1577,6 +1593,7 @@ DA48001E2D837905009CF127 /* ApplicationSettingsView.swift in Sources */, DAF0A28B2C56E3A300A14A6A /* RollershutterCell.swift in Sources */, DF06F1FC18FEC2020011E7B9 /* ColorPickerViewController.swift in Sources */, + DA2AEB742D92D2D100897D80 /* WidgetCellProvider.swift in Sources */, DA4642322D7EE6CA006C3908 /* LoggerView.swift in Sources */, 1224F78F228A89FD00750965 /* WatchMessageService.swift in Sources */, DAA42BAC21DC984A00244B2A /* WebUITableViewCell.swift in Sources */, @@ -1607,12 +1624,15 @@ DAF0A28F2C56F1EE00A14A6A /* ColorPickerCell.swift in Sources */, 2FEFD8F62BE7C5BE00E387B9 /* TextInputUITableViewCell.swift in Sources */, 938EDCE122C4FEB800661CA1 /* ScaleAspectFitImageView.swift in Sources */, + DA2AEB702D92CF3E00897D80 /* WidgetIconRenderer.swift in Sources */, DA4800162D836EF0009CF127 /* MainUISettingsView.swift in Sources */, DAEAA89F21E6B16600267EA3 /* UITableView.swift in Sources */, DFB2624418830A3600D3244D /* OpenHABSitemapViewController.swift in Sources */, DA4800182D837221009CF127 /* AboutSettingsView.swift in Sources */, 653B54C2285E714900298ECD /* OpenHABViewController.swift in Sources */, + DA2AEB6E2D92BAD800897D80 /* PageLoader.swift in Sources */, DFA16EC118898A8400EDB0BB /* SegmentedUITableViewCell.swift in Sources */, + DA2AEB6C2D92BA3F00897D80 /* OpenHABImageProcessor.swift in Sources */, DAF0A28D2C56EF8900A14A6A /* SetpointCell.swift in Sources */, DAEAA89D21E6B06400267EA3 /* ReusableView.swift in Sources */, DF05FF231896BD2D00FF2F9B /* SelectionUITableViewCell.swift in Sources */, diff --git a/openHAB/Cells/Providers/RollerShutterCellProvider.swift b/openHAB/Cells/Providers/RollerShutterCellProvider.swift new file mode 100644 index 000000000..353505768 --- /dev/null +++ b/openHAB/Cells/Providers/RollerShutterCellProvider.swift @@ -0,0 +1,29 @@ +// Copyright (c) 2010-2025 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 Foundation +import OpenHABCore +import UIKit + +struct RollerShutterCellProvider: WidgetCellProvider { + static var reuseIdentifier: String { "RollerShutterTableViewCell" } + + static func dequeue(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { + tableView.dequeueReusableCell(for: indexPath) as RollershutterCell + } + + static func configure(cell: UITableViewCell, for widget: OpenHABWidget, controller: OpenHABSitemapViewController) { + guard let cell = cell as? RollershutterCell else { return } + cell.widget = widget + cell.displayWidget() + cell.touchEventDelegate = controller + } +} diff --git a/openHAB/Cells/Providers/SegmentedCellProvider.swift b/openHAB/Cells/Providers/SegmentedCellProvider.swift new file mode 100644 index 000000000..1253d5a0e --- /dev/null +++ b/openHAB/Cells/Providers/SegmentedCellProvider.swift @@ -0,0 +1,31 @@ +// Copyright (c) 2010-2025 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 Foundation + +import Foundation +import OpenHABCore +import UIKit + +struct SegmentCellProvider: WidgetCellProvider { + static var reuseIdentifier: String { "SegmentUITableViewCell" } + + static func dequeue(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { + tableView.dequeueReusableCell(for: indexPath) as SegmentedUITableViewCell + } + + static func configure(cell: UITableViewCell, for widget: OpenHABWidget, controller: OpenHABSitemapViewController) { + guard let cell = cell as? SegmentedUITableViewCell else { return } + cell.widget = widget + cell.displayWidget() + cell.touchEventDelegate = controller + } +} diff --git a/openHAB/Cells/Providers/SwitchCellProvider.swift b/openHAB/Cells/Providers/SwitchCellProvider.swift new file mode 100644 index 000000000..9c979741f --- /dev/null +++ b/openHAB/Cells/Providers/SwitchCellProvider.swift @@ -0,0 +1,29 @@ +// Copyright (c) 2010-2025 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 Foundation +import OpenHABCore +import UIKit + +struct SwitchCellProvider: WidgetCellProvider { + static var reuseIdentifier: String { "SwitchUITableViewCell" } + + static func dequeue(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { + tableView.dequeueReusableCell(for: indexPath) as SwitchUITableViewCell + } + + static func configure(cell: UITableViewCell, for widget: OpenHABWidget, controller: OpenHABSitemapViewController) { + guard let cell = cell as? SwitchUITableViewCell else { return } + cell.widget = widget + cell.displayWidget() + cell.touchEventDelegate = controller + } +} diff --git a/openHAB/OpenHABImageProcessor.swift b/openHAB/OpenHABImageProcessor.swift new file mode 100644 index 000000000..8492e4933 --- /dev/null +++ b/openHAB/OpenHABImageProcessor.swift @@ -0,0 +1,50 @@ +// Copyright (c) 2010-2025 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 Foundation +import Kingfisher +import os.log +import SVGKit + +struct OpenHABImageProcessor: ImageProcessor { + // `identifier` should be the same for processors with the same properties/functionality + // It will be used when storing and retrieving the image to/from cache. + let identifier = "org.openhab.svgprocessor" + + // Convert input data/image to target image and return it. + func process(item: ImageProcessItem, options: KingfisherParsedOptionsInfo) -> KFCrossPlatformImage? { + switch item { + case let .image(image): + os_log("already an image", log: .default, type: .info) + return image + case let .data(data): + guard !data.isEmpty else { return nil } + + switch data[0] { + case 0x3C: // svg + // + // 1000 || image.size.height > 1000 { + return UIImage(systemSymbol: .exclamationmarkTriangle).withTintColor(.orange) + } + return image.uiImage + } else { + return UIImage(systemSymbol: .exclamationmarkTriangle).withTintColor(.orange) + } + default: + return Kingfisher.DefaultImageProcessor().process(item: item, options: KingfisherParsedOptionsInfo(KingfisherManager.shared.defaultOptions)) + } + } + } +} diff --git a/openHAB/OpenHABSitemapViewController.swift b/openHAB/OpenHABSitemapViewController.swift index ddefde6fc..a9a84a037 100644 --- a/openHAB/OpenHABSitemapViewController.swift +++ b/openHAB/OpenHABSitemapViewController.swift @@ -23,81 +23,6 @@ import SVGKit import SwiftUI import UIKit -struct OpenHABImageProcessor: ImageProcessor { - // `identifier` should be the same for processors with the same properties/functionality - // It will be used when storing and retrieving the image to/from cache. - let identifier = "org.openhab.svgprocessor" - - // Convert input data/image to target image and return it. - func process(item: ImageProcessItem, options: KingfisherParsedOptionsInfo) -> KFCrossPlatformImage? { - switch item { - case let .image(image): - os_log("already an image", log: .default, type: .info) - return image - case let .data(data): - guard !data.isEmpty else { return nil } - - switch data[0] { - case 0x3C: // svg - // - // 1000 || image.size.height > 1000 { - return UIImage(systemSymbol: .exclamationmarkTriangle).withTintColor(.orange) - } - return image.uiImage - } else { - return UIImage(systemSymbol: .exclamationmarkTriangle).withTintColor(.orange) - } - default: - return Kingfisher.DefaultImageProcessor().process(item: item, options: KingfisherParsedOptionsInfo(KingfisherManager.shared.defaultOptions)) - } - } - } -} - -actor PageLoader { - private var openAPIService: OpenAPIService - private var pageId: String - private var defaultSitemap: String - - private var lastFetchedPage: OpenHABPage? // Store latest page data - - private let logger = Logger(subsystem: "org.openhab.app", category: "PageLoader") - - init(service: OpenAPIService, pageId: String, defaultSitemap: String) { - openAPIService = service - self.pageId = pageId - self.defaultSitemap = defaultSitemap - } - - func updatePageConfig(newPageId: String, newSitemap: String) { - pageId = newPageId - defaultSitemap = newSitemap - // swiftformat:disable:next redundantSelf - logger.info("🔄 Updated config: pageId = \(self.pageId), defaultSitemap = \(self.defaultSitemap)") - } - - func updateAPIService(newService: OpenAPIService) { - openAPIService = newService - logger.info("🔄 Updated OpenAPIService instance") - } - - func fetchPage(longPolling: Bool) async throws -> OpenHABPage? { - logger.info("📡 Fetching page... (longPolling: \(longPolling))") - let page = try await openAPIService.pollDataForPage( - sitemapname: defaultSitemap, - pageId: pageId, - longPolling: longPolling - ) - try Task.checkCancellation() - - return page - } -} - // swiftlint:disable type_body_length class OpenHABSitemapViewController: OpenHABViewController { var pageUrl = "" @@ -843,40 +768,15 @@ extension OpenHABSitemapViewController: UITableViewDelegate, UITableViewDataSour cell = tableView.dequeueReusableCell(for: indexPath) as GenericUITableViewCell } - var iconColor = widget.iconColor - if iconColor.isEmpty, traitCollection.userInterfaceStyle == .dark { - iconColor = "white" - } - // No icon is needed for image, video, frame and web widgets - if !((cell is NewImageUITableViewCell) || (cell is VideoUITableViewCell) || (cell is FrameUITableViewCell) || (cell is WebUITableViewCell)) { - if !widget.icon.isEmpty { - if let urlc = Endpoint.icon( - rootUrl: openHABRootUrl, - version: appData?.openHABVersion ?? 2, - icon: widget.icon, - state: widget.iconState(), - iconType: iconType, - iconColor: iconColor - ).url { - var imageRequest = URLRequest(url: urlc) - imageRequest.timeoutInterval = 10.0 - cell.imageView?.kf.setImage( - with: KF.ImageResource(downloadURL: urlc, cacheKey: urlc.path + (urlc.query ?? "")), - placeholder: nil, - options: [.processor(OpenHABImageProcessor())] - ) { result in - switch result { - case .success: - DispatchQueue.main.async { - cell.setNeedsLayout() - } - case let .failure(error): - self.logger.error("Image loading failed for widget \(widget.label) : \(error.localizedDescription)") - } - } - } - } - } + WidgetIconRenderer.loadIcon( + for: widget, + into: cell.imageView, + in: traitCollection, + openHABRootUrl: openHABRootUrl, + openHABVersion: appData?.openHABVersion ?? 2, + iconType: iconType, + logger: logger + ) if cell is FrameUITableViewCell { cell.backgroundColor = .ohSystemGroupedBackground diff --git a/openHAB/PageLoader.swift b/openHAB/PageLoader.swift new file mode 100644 index 000000000..8628104fc --- /dev/null +++ b/openHAB/PageLoader.swift @@ -0,0 +1,53 @@ +// Copyright (c) 2010-2025 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 OpenHABCore +import os + +actor PageLoader { + private var openAPIService: OpenAPIService + private var pageId: String + private var defaultSitemap: String + + private var lastFetchedPage: OpenHABPage? // Store latest page data + + private let logger = Logger(subsystem: "org.openhab.app", category: "PageLoader") + + init(service: OpenAPIService, pageId: String, defaultSitemap: String) { + openAPIService = service + self.pageId = pageId + self.defaultSitemap = defaultSitemap + } + + func updatePageConfig(newPageId: String, newSitemap: String) { + pageId = newPageId + defaultSitemap = newSitemap + // swiftformat:disable:next redundantSelf + logger.info("🔄 Updated config: pageId = \(self.pageId), defaultSitemap = \(self.defaultSitemap)") + } + + func updateAPIService(newService: OpenAPIService) { + openAPIService = newService + logger.info("🔄 Updated OpenAPIService instance") + } + + func fetchPage(longPolling: Bool) async throws -> OpenHABPage? { + logger.info("📡 Fetching page... (longPolling: \(longPolling))") + let page = try await openAPIService.pollDataForPage( + sitemapname: defaultSitemap, + pageId: pageId, + longPolling: longPolling + ) + try Task.checkCancellation() + + return page + } +} diff --git a/openHAB/WidgetCellProvider.swift b/openHAB/WidgetCellProvider.swift new file mode 100644 index 000000000..9e5c7f36d --- /dev/null +++ b/openHAB/WidgetCellProvider.swift @@ -0,0 +1,60 @@ +// Copyright (c) 2010-2025 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 Foundation +import OpenHABCore +import UIKit + +protocol WidgetCellProvider { + static var reuseIdentifier: String { get } + static func dequeue(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell + static func configure(cell: UITableViewCell, for widget: OpenHABWidget, controller: OpenHABSitemapViewController) +} + +// enum WidgetCellFactory { +// static func provider(for widget: OpenHABWidget) -> WidgetCellProvider.Type { +// switch widget.type { +// case .switchWidget: +// if !widget.mappings.isEmpty { +// return SegmentedCellProvider.self +// } else if widget.item?.isOfTypeOrGroupType(.switchItem) ?? false { +// return SwitchCellProvider.self +// } else if widget.item?.isOfTypeOrGroupType(.rollershutter) ?? false { +// return RollershutterCellProvider.self +// } else if !widget.mappingsOrItemOptions.isEmpty { +// return SegmentedCellProvider.self +// } else { +// return SwitchCellProvider.self +// } +// +// case .slider: +// return widget.switchSupport ? SliderWithSwitchProvider.self : SliderProvider.self +// +// case .input: +// if [.date, .time, .datetime].contains(widget.inputHint) { +// return DatePickerInputProvider.self +// } else { +// return TextInputProvider.self +// } +// +// case .frame: return FrameCellProvider.self +// case .setpoint: return SetpointCellProvider.self +// case .selection: return SelectionCellProvider.self +// case .colorpicker: return ColorPickerCellProvider.self +// case .image, .chart: return ImageCellProvider.self +// case .video: return VideoCellProvider.self +// case .webview: return WebViewCellProvider.self +// case .mapview: return MapViewCellProvider.self +// case .group, .text, .defaultWidget, .unknown: +// return GenericCellProvider.self +// } +// } +// } diff --git a/openHAB/WidgetIconRenderer.swift b/openHAB/WidgetIconRenderer.swift new file mode 100644 index 000000000..14b116fc5 --- /dev/null +++ b/openHAB/WidgetIconRenderer.swift @@ -0,0 +1,60 @@ +// Copyright (c) 2010-2025 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 Foundation +import Kingfisher +import OpenHABCore +import os.log +import UIKit + +enum WidgetIconRenderer { + static func loadIcon(for widget: OpenHABWidget, + into imageView: UIImageView?, + in traitCollection: UITraitCollection, + openHABRootUrl: String, + openHABVersion: Int, + iconType: IconType, + logger: Logger) { + guard let imageView, !widget.icon.isEmpty else { return } + + var iconColor = widget.iconColor + if iconColor.isEmpty, traitCollection.userInterfaceStyle == .dark { + iconColor = "white" + } + + guard let url = Endpoint.icon( + rootUrl: openHABRootUrl, + version: openHABVersion, + icon: widget.icon, + state: widget.iconState(), + iconType: iconType, + iconColor: iconColor + ).url else { return } + + var request = URLRequest(url: url) + request.timeoutInterval = 10 + + imageView.kf.setImage( + with: KF.ImageResource(downloadURL: url, cacheKey: url.path + (url.query ?? "")), + placeholder: nil, + options: [.processor(OpenHABImageProcessor())] + ) { result in + switch result { + case .success: + DispatchQueue.main.async { + imageView.setNeedsLayout() + } + case let .failure(error): + logger.error("❌ Failed to load widget icon: \(error.localizedDescription)") + } + } + } +} From c33e2a9214dba9416c644c5cad0e3d12947dbc21 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Tue, 25 Mar 2025 17:02:48 +0100 Subject: [PATCH 106/476] Create Protocols for Widget Handling, Instead of switch-case with all widget types, abstract common behavior into protocols/extensions to reduce the giant cellForRowAt Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- openHAB.xcodeproj/project.pbxproj | 8 +- .../Providers/ColorPickerCellProvider.swift | 30 +++++ .../Providers/DatePickerInputProvider.swift | 30 +++++ .../Cells/Providers/FrameCellProvider.swift | 29 +++++ .../Cells/Providers/GenericCellProvider.swift | 29 +++++ .../Cells/Providers/ImageCellProvider.swift | 32 +++++ .../Cells/Providers/MapViewCellProvider.swift | 29 +++++ .../Providers/RollerShutterCellProvider.swift | 2 +- .../Providers/SegmentedCellProvider.swift | 6 +- .../Providers/SelectionCellProvider.swift | 29 +++++ .../Providers/SetpointCellProvider.swift | 29 +++++ openHAB/Cells/Providers/SliderProvider.swift | 29 +++++ .../Providers/SliderWithSwitchProvider.swift | 29 +++++ .../Cells/Providers/TextInputProvider.swift | 29 +++++ .../Cells/Providers/VideoCellProvider.swift | 29 +++++ .../Cells/Providers/WebViewCellProvider.swift | 29 +++++ openHAB/Cells/WidgetCellProvider.swift | 57 +++++++++ openHAB/FrameUITableViewCell.swift | 2 +- openHAB/NewImageUITableViewCell.swift | 2 +- openHAB/NoIconDisplayableCell.swift | 14 +++ openHAB/OpenHABSitemapViewController.swift | 115 ++++++++---------- openHAB/VideoUITableViewCell.swift | 2 +- openHAB/WebUITableViewCell.swift | 2 +- openHAB/WidgetCellProvider.swift | 60 --------- 24 files changed, 513 insertions(+), 139 deletions(-) create mode 100644 openHAB/Cells/Providers/ColorPickerCellProvider.swift create mode 100644 openHAB/Cells/Providers/DatePickerInputProvider.swift create mode 100644 openHAB/Cells/Providers/FrameCellProvider.swift create mode 100644 openHAB/Cells/Providers/GenericCellProvider.swift create mode 100644 openHAB/Cells/Providers/ImageCellProvider.swift create mode 100644 openHAB/Cells/Providers/MapViewCellProvider.swift create mode 100644 openHAB/Cells/Providers/SelectionCellProvider.swift create mode 100644 openHAB/Cells/Providers/SetpointCellProvider.swift create mode 100644 openHAB/Cells/Providers/SliderProvider.swift create mode 100644 openHAB/Cells/Providers/SliderWithSwitchProvider.swift create mode 100644 openHAB/Cells/Providers/TextInputProvider.swift create mode 100644 openHAB/Cells/Providers/VideoCellProvider.swift create mode 100644 openHAB/Cells/Providers/WebViewCellProvider.swift create mode 100644 openHAB/Cells/WidgetCellProvider.swift create mode 100644 openHAB/NoIconDisplayableCell.swift delete mode 100644 openHAB/WidgetCellProvider.swift diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index f8c7e77ad..92653f25b 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -84,7 +84,7 @@ DA2AEB6C2D92BA3F00897D80 /* OpenHABImageProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2AEB6B2D92BA3F00897D80 /* OpenHABImageProcessor.swift */; }; DA2AEB6E2D92BAD800897D80 /* PageLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2AEB6D2D92BAD800897D80 /* PageLoader.swift */; }; DA2AEB702D92CF3E00897D80 /* WidgetIconRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2AEB6F2D92CF3E00897D80 /* WidgetIconRenderer.swift */; }; - DA2AEB742D92D2D100897D80 /* WidgetCellProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2AEB732D92D2D100897D80 /* WidgetCellProvider.swift */; }; + DA2AEBA02D92FB6500897D80 /* NoIconDisplayableCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2AEB9F2D92FB6500897D80 /* NoIconDisplayableCell.swift */; }; DA2C4FCD2B4F55D700D1C533 /* SDWebImage in Frameworks */ = {isa = PBXBuildFile; productRef = DA2C4FCC2B4F55D700D1C533 /* SDWebImage */; }; DA2C4FCF2B4F55D700D1C533 /* SDWebImageMapKit in Frameworks */ = {isa = PBXBuildFile; productRef = DA2C4FCE2B4F55D700D1C533 /* SDWebImageMapKit */; }; DA2C4FD22B4F56D000D1C533 /* SDWebImageSwiftUI in Frameworks */ = {isa = PBXBuildFile; productRef = DA2C4FD12B4F56D000D1C533 /* SDWebImageSwiftUI */; }; @@ -399,7 +399,7 @@ DA2AEB6B2D92BA3F00897D80 /* OpenHABImageProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenHABImageProcessor.swift; sourceTree = ""; }; DA2AEB6D2D92BAD800897D80 /* PageLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageLoader.swift; sourceTree = ""; }; DA2AEB6F2D92CF3E00897D80 /* WidgetIconRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetIconRenderer.swift; sourceTree = ""; }; - DA2AEB732D92D2D100897D80 /* WidgetCellProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetCellProvider.swift; sourceTree = ""; }; + DA2AEB9F2D92FB6500897D80 /* NoIconDisplayableCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoIconDisplayableCell.swift; sourceTree = ""; }; DA2DC22F21F2736C00830730 /* openHABTestsSwift.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = openHABTestsSwift.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; DA2DC23321F2736C00830730 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; DA2E0AA323DC96E9009B0A99 /* ImageWithAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageWithAction.swift; sourceTree = ""; }; @@ -924,7 +924,6 @@ 653B54BF285C0AC700298ECD /* OpenHABRootViewController.swift */, 65570A7C2476D16A00D524EA /* OpenHABWebViewController.swift */, DFB2624318830A3600D3244D /* OpenHABSitemapViewController.swift */, - DA2AEB732D92D2D100897D80 /* WidgetCellProvider.swift */, DA2AEB6D2D92BAD800897D80 /* PageLoader.swift */, DA9F81862C85020F00B47B72 /* RTFTextView.swift */, DAC65FC6236EDF3900F4501E /* SpinnerViewController.swift */, @@ -973,6 +972,7 @@ DA7E1E47222EB00B002AEFD8 /* PlayerView.swift */, DA21EAE12339621C001AB415 /* Throttler.swift */, DA6B2EF42C89F8F200DF77CF /* ColorPickerView.swift */, + DA2AEB9F2D92FB6500897D80 /* NoIconDisplayableCell.swift */, ); name = Widgets; sourceTree = ""; @@ -1593,7 +1593,6 @@ DA48001E2D837905009CF127 /* ApplicationSettingsView.swift in Sources */, DAF0A28B2C56E3A300A14A6A /* RollershutterCell.swift in Sources */, DF06F1FC18FEC2020011E7B9 /* ColorPickerViewController.swift in Sources */, - DA2AEB742D92D2D100897D80 /* WidgetCellProvider.swift in Sources */, DA4642322D7EE6CA006C3908 /* LoggerView.swift in Sources */, 1224F78F228A89FD00750965 /* WatchMessageService.swift in Sources */, DAA42BAC21DC984A00244B2A /* WebUITableViewCell.swift in Sources */, @@ -1624,6 +1623,7 @@ DAF0A28F2C56F1EE00A14A6A /* ColorPickerCell.swift in Sources */, 2FEFD8F62BE7C5BE00E387B9 /* TextInputUITableViewCell.swift in Sources */, 938EDCE122C4FEB800661CA1 /* ScaleAspectFitImageView.swift in Sources */, + DA2AEBA02D92FB6500897D80 /* NoIconDisplayableCell.swift in Sources */, DA2AEB702D92CF3E00897D80 /* WidgetIconRenderer.swift in Sources */, DA4800162D836EF0009CF127 /* MainUISettingsView.swift in Sources */, DAEAA89F21E6B16600267EA3 /* UITableView.swift in Sources */, diff --git a/openHAB/Cells/Providers/ColorPickerCellProvider.swift b/openHAB/Cells/Providers/ColorPickerCellProvider.swift new file mode 100644 index 000000000..afa53aff0 --- /dev/null +++ b/openHAB/Cells/Providers/ColorPickerCellProvider.swift @@ -0,0 +1,30 @@ +// Copyright (c) 2010-2025 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 Foundation +import OpenHABCore +import UIKit + +struct ColorPickerCellProvider: WidgetCellProvider { + static var reuseIdentifier: String { "ColorPickerCell" } + + static func dequeue(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { + tableView.dequeueReusableCell(for: indexPath) as ColorPickerCell + } + + static func configure(cell: UITableViewCell, for widget: OpenHABWidget, controller: OpenHABSitemapViewController) { + guard let cell = cell as? ColorPickerCell else { return } + cell.delegate = controller + cell.widget = widget + cell.displayWidget() + cell.touchEventDelegate = controller + } +} diff --git a/openHAB/Cells/Providers/DatePickerInputProvider.swift b/openHAB/Cells/Providers/DatePickerInputProvider.swift new file mode 100644 index 000000000..4c40e8d30 --- /dev/null +++ b/openHAB/Cells/Providers/DatePickerInputProvider.swift @@ -0,0 +1,30 @@ +// Copyright (c) 2010-2025 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 Foundation +import OpenHABCore +import UIKit + +struct DatePickerInputProvider: WidgetCellProvider { + static var reuseIdentifier: String { "DatePickerUITableViewCell" } + + static func dequeue(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { + tableView.dequeueReusableCell(for: indexPath) as DatePickerUITableViewCell + } + + static func configure(cell: UITableViewCell, for widget: OpenHABWidget, controller: OpenHABSitemapViewController) { + guard let cell = cell as? DatePickerUITableViewCell else { return } + cell.controller = controller + cell.widget = widget + cell.displayWidget() + cell.touchEventDelegate = controller + } +} diff --git a/openHAB/Cells/Providers/FrameCellProvider.swift b/openHAB/Cells/Providers/FrameCellProvider.swift new file mode 100644 index 000000000..7d35c6c45 --- /dev/null +++ b/openHAB/Cells/Providers/FrameCellProvider.swift @@ -0,0 +1,29 @@ +// Copyright (c) 2010-2025 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 Foundation +import OpenHABCore +import UIKit + +struct FrameCellProvider: WidgetCellProvider { + static var reuseIdentifier: String { "FrameUITableViewCell" } + + static func dequeue(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { + tableView.dequeueReusableCell(for: indexPath) as FrameUITableViewCell + } + + static func configure(cell: UITableViewCell, for widget: OpenHABWidget, controller: OpenHABSitemapViewController) { + guard let cell = cell as? FrameUITableViewCell else { return } + cell.widget = widget + cell.displayWidget() + cell.touchEventDelegate = controller + } +} diff --git a/openHAB/Cells/Providers/GenericCellProvider.swift b/openHAB/Cells/Providers/GenericCellProvider.swift new file mode 100644 index 000000000..b52494596 --- /dev/null +++ b/openHAB/Cells/Providers/GenericCellProvider.swift @@ -0,0 +1,29 @@ +// Copyright (c) 2010-2025 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 Foundation +import OpenHABCore +import UIKit + +struct GenericCellProvider: WidgetCellProvider { + static var reuseIdentifier: String { "GenericUITableViewCell" } + + static func dequeue(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { + tableView.dequeueReusableCell(for: indexPath) as GenericUITableViewCell + } + + static func configure(cell: UITableViewCell, for widget: OpenHABWidget, controller: OpenHABSitemapViewController) { + guard let cell = cell as? GenericUITableViewCell else { return } + cell.widget = widget + cell.displayWidget() + cell.touchEventDelegate = controller + } +} diff --git a/openHAB/Cells/Providers/ImageCellProvider.swift b/openHAB/Cells/Providers/ImageCellProvider.swift new file mode 100644 index 000000000..7f9f7a00c --- /dev/null +++ b/openHAB/Cells/Providers/ImageCellProvider.swift @@ -0,0 +1,32 @@ +// Copyright (c) 2010-2025 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 Foundation +import OpenHABCore +import UIKit + +struct ImageCellProvider: WidgetCellProvider { + static var reuseIdentifier: String { "NewImageUITableViewCell" } + + static func dequeue(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { + tableView.dequeueReusableCell(for: indexPath) as NewImageUITableViewCell + } + + static func configure(cell: UITableViewCell, for widget: OpenHABWidget, controller: OpenHABSitemapViewController) { + guard let cell = cell as? NewImageUITableViewCell else { return } + cell.didLoad = { [weak controller] in + controller?.updateWidgetTableView() + } + cell.widget = widget + cell.displayWidget() + cell.touchEventDelegate = controller + } +} diff --git a/openHAB/Cells/Providers/MapViewCellProvider.swift b/openHAB/Cells/Providers/MapViewCellProvider.swift new file mode 100644 index 000000000..27d023d8d --- /dev/null +++ b/openHAB/Cells/Providers/MapViewCellProvider.swift @@ -0,0 +1,29 @@ +// Copyright (c) 2010-2025 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 Foundation +import OpenHABCore +import UIKit + +struct MapViewCellProvider: WidgetCellProvider { + static var reuseIdentifier: String { "MapViewTableViewCell" } + + static func dequeue(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { + tableView.dequeueReusableCell(for: indexPath) as MapViewTableViewCell + } + + static func configure(cell: UITableViewCell, for widget: OpenHABWidget, controller: OpenHABSitemapViewController) { + guard let cell = cell as? MapViewTableViewCell else { return } + cell.widget = widget + cell.displayWidget() + cell.touchEventDelegate = controller + } +} diff --git a/openHAB/Cells/Providers/RollerShutterCellProvider.swift b/openHAB/Cells/Providers/RollerShutterCellProvider.swift index 353505768..b62dcdb7c 100644 --- a/openHAB/Cells/Providers/RollerShutterCellProvider.swift +++ b/openHAB/Cells/Providers/RollerShutterCellProvider.swift @@ -13,7 +13,7 @@ import Foundation import OpenHABCore import UIKit -struct RollerShutterCellProvider: WidgetCellProvider { +struct RollershutterCellProvider: WidgetCellProvider { static var reuseIdentifier: String { "RollerShutterTableViewCell" } static func dequeue(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { diff --git a/openHAB/Cells/Providers/SegmentedCellProvider.swift b/openHAB/Cells/Providers/SegmentedCellProvider.swift index 1253d5a0e..7f6b766c6 100644 --- a/openHAB/Cells/Providers/SegmentedCellProvider.swift +++ b/openHAB/Cells/Providers/SegmentedCellProvider.swift @@ -9,14 +9,12 @@ // // SPDX-License-Identifier: EPL-2.0 -import Foundation - import Foundation import OpenHABCore import UIKit -struct SegmentCellProvider: WidgetCellProvider { - static var reuseIdentifier: String { "SegmentUITableViewCell" } +struct SegmentedCellProvider: WidgetCellProvider { + static var reuseIdentifier: String { "SegmentedUITableViewCell" } static func dequeue(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { tableView.dequeueReusableCell(for: indexPath) as SegmentedUITableViewCell diff --git a/openHAB/Cells/Providers/SelectionCellProvider.swift b/openHAB/Cells/Providers/SelectionCellProvider.swift new file mode 100644 index 000000000..3ac46a763 --- /dev/null +++ b/openHAB/Cells/Providers/SelectionCellProvider.swift @@ -0,0 +1,29 @@ +// Copyright (c) 2010-2025 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 Foundation +import OpenHABCore +import UIKit + +struct SelectionCellProvider: WidgetCellProvider { + static var reuseIdentifier: String { "SelectionUITableViewCell" } + + static func dequeue(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { + tableView.dequeueReusableCell(for: indexPath) as SelectionUITableViewCell + } + + static func configure(cell: UITableViewCell, for widget: OpenHABWidget, controller: OpenHABSitemapViewController) { + guard let cell = cell as? SelectionUITableViewCell else { return } + cell.widget = widget + cell.displayWidget() + cell.touchEventDelegate = controller + } +} diff --git a/openHAB/Cells/Providers/SetpointCellProvider.swift b/openHAB/Cells/Providers/SetpointCellProvider.swift new file mode 100644 index 000000000..6b55a6b94 --- /dev/null +++ b/openHAB/Cells/Providers/SetpointCellProvider.swift @@ -0,0 +1,29 @@ +// Copyright (c) 2010-2025 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 Foundation +import OpenHABCore +import UIKit + +struct SetpointCellProvider: WidgetCellProvider { + static var reuseIdentifier: String { "SetpointCell" } + + static func dequeue(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { + tableView.dequeueReusableCell(for: indexPath) as SetpointCell + } + + static func configure(cell: UITableViewCell, for widget: OpenHABWidget, controller: OpenHABSitemapViewController) { + guard let cell = cell as? SetpointCell else { return } + cell.widget = widget + cell.displayWidget() + cell.touchEventDelegate = controller + } +} diff --git a/openHAB/Cells/Providers/SliderProvider.swift b/openHAB/Cells/Providers/SliderProvider.swift new file mode 100644 index 000000000..a26952529 --- /dev/null +++ b/openHAB/Cells/Providers/SliderProvider.swift @@ -0,0 +1,29 @@ +// Copyright (c) 2010-2025 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 Foundation +import OpenHABCore +import UIKit + +struct SliderProvider: WidgetCellProvider { + static var reuseIdentifier: String { "SliderUITableViewCell" } + + static func dequeue(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { + tableView.dequeueReusableCell(for: indexPath) as SliderUITableViewCell + } + + static func configure(cell: UITableViewCell, for widget: OpenHABWidget, controller: OpenHABSitemapViewController) { + guard let cell = cell as? SliderUITableViewCell else { return } + cell.widget = widget + cell.displayWidget() + cell.touchEventDelegate = controller + } +} diff --git a/openHAB/Cells/Providers/SliderWithSwitchProvider.swift b/openHAB/Cells/Providers/SliderWithSwitchProvider.swift new file mode 100644 index 000000000..54ce5ecef --- /dev/null +++ b/openHAB/Cells/Providers/SliderWithSwitchProvider.swift @@ -0,0 +1,29 @@ +// Copyright (c) 2010-2025 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 Foundation +import OpenHABCore +import UIKit + +struct SliderWithSwitchProvider: WidgetCellProvider { + static var reuseIdentifier: String { "SliderWithSwitchSupportUITableViewCell" } + + static func dequeue(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { + tableView.dequeueReusableCell(for: indexPath) as SliderWithSwitchSupportUITableViewCell + } + + static func configure(cell: UITableViewCell, for widget: OpenHABWidget, controller: OpenHABSitemapViewController) { + guard let cell = cell as? SliderWithSwitchSupportUITableViewCell else { return } + cell.widget = widget + cell.displayWidget() + cell.touchEventDelegate = controller + } +} diff --git a/openHAB/Cells/Providers/TextInputProvider.swift b/openHAB/Cells/Providers/TextInputProvider.swift new file mode 100644 index 000000000..fab79045c --- /dev/null +++ b/openHAB/Cells/Providers/TextInputProvider.swift @@ -0,0 +1,29 @@ +// Copyright (c) 2010-2025 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 Foundation +import OpenHABCore +import UIKit + +struct TextInputProvider: WidgetCellProvider { + static var reuseIdentifier: String { "TextInputUITableViewCell" } + + static func dequeue(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { + tableView.dequeueReusableCell(for: indexPath) as TextInputUITableViewCell + } + + static func configure(cell: UITableViewCell, for widget: OpenHABWidget, controller: OpenHABSitemapViewController) { + guard let cell = cell as? TextInputUITableViewCell else { return } + cell.widget = widget + cell.displayWidget() + cell.touchEventDelegate = controller + } +} diff --git a/openHAB/Cells/Providers/VideoCellProvider.swift b/openHAB/Cells/Providers/VideoCellProvider.swift new file mode 100644 index 000000000..b2ffb7c5f --- /dev/null +++ b/openHAB/Cells/Providers/VideoCellProvider.swift @@ -0,0 +1,29 @@ +// Copyright (c) 2010-2025 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 Foundation +import OpenHABCore +import UIKit + +struct VideoCellProvider: WidgetCellProvider { + static var reuseIdentifier: String { "VideoUITableViewCell" } + + static func dequeue(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { + tableView.dequeueReusableCell(for: indexPath) as VideoUITableViewCell + } + + static func configure(cell: UITableViewCell, for widget: OpenHABWidget, controller: OpenHABSitemapViewController) { + guard let cell = cell as? VideoUITableViewCell else { return } + cell.widget = widget + cell.displayWidget() + cell.touchEventDelegate = controller + } +} diff --git a/openHAB/Cells/Providers/WebViewCellProvider.swift b/openHAB/Cells/Providers/WebViewCellProvider.swift new file mode 100644 index 000000000..e45fd6513 --- /dev/null +++ b/openHAB/Cells/Providers/WebViewCellProvider.swift @@ -0,0 +1,29 @@ +// Copyright (c) 2010-2025 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 Foundation +import OpenHABCore +import UIKit + +struct WebViewCellProvider: WidgetCellProvider { + static var reuseIdentifier: String { "WebUITableViewCell" } + + static func dequeue(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { + tableView.dequeueReusableCell(for: indexPath) as WebUITableViewCell + } + + static func configure(cell: UITableViewCell, for widget: OpenHABWidget, controller: OpenHABSitemapViewController) { + guard let cell = cell as? WebUITableViewCell else { return } + cell.widget = widget + cell.displayWidget() + cell.touchEventDelegate = controller + } +} diff --git a/openHAB/Cells/WidgetCellProvider.swift b/openHAB/Cells/WidgetCellProvider.swift new file mode 100644 index 000000000..b2ed1911b --- /dev/null +++ b/openHAB/Cells/WidgetCellProvider.swift @@ -0,0 +1,57 @@ +// Copyright (c) 2010-2025 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 Foundation +import OpenHABCore +import UIKit + +protocol WidgetCellProvider { + static var reuseIdentifier: String { get } + static func dequeue(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell + static func configure(cell: UITableViewCell, for widget: OpenHABWidget, controller: OpenHABSitemapViewController) +} + +enum WidgetCellFactory { + static func provider(for widget: OpenHABWidget) -> WidgetCellProvider.Type { + switch widget.type { + case .switchWidget: + if !widget.mappings.isEmpty { + SegmentedCellProvider.self // 🔥 Done + } else if widget.item?.isOfTypeOrGroupType(.switchItem) ?? false { + SwitchCellProvider.self // 🔥 Done + } else if widget.item?.isOfTypeOrGroupType(.rollershutter) ?? false { + RollershutterCellProvider.self // 🔥 Done + } else if !widget.mappingsOrItemOptions.isEmpty { + SegmentedCellProvider.self + } else { + SwitchCellProvider.self + } + case .slider: + widget.switchSupport ? SliderWithSwitchProvider.self : SliderProvider.self // 🔥 Done + case .input: + if [.date, .time, .datetime].contains(widget.inputHint) { + DatePickerInputProvider.self // 🔥 Done + } else { + TextInputProvider.self // 🔥 Done + } + case .frame: FrameCellProvider.self // 🔥 Done + case .setpoint: SetpointCellProvider.self // 🔥 Done + case .selection: SelectionCellProvider.self // 🔥 Done + case .colorpicker: ColorPickerCellProvider.self // 🔥 Done + case .image, .chart: ImageCellProvider.self // 🔥 Done + case .video: VideoCellProvider.self // 🔥 Done + case .webview: WebViewCellProvider.self // 🔥 Done + case .mapview: MapViewCellProvider.self // 🔥 Done + case .group, .text, .defaultWidget, .unknown: + GenericCellProvider.self // 🔥 Done + } + } +} diff --git a/openHAB/FrameUITableViewCell.swift b/openHAB/FrameUITableViewCell.swift index 0d4739719..87e878719 100644 --- a/openHAB/FrameUITableViewCell.swift +++ b/openHAB/FrameUITableViewCell.swift @@ -12,7 +12,7 @@ import OpenHABCore import UIKit -class FrameUITableViewCell: GenericUITableViewCell { +class FrameUITableViewCell: GenericUITableViewCell, NoIconDisplayableCell { required init?(coder: NSCoder) { super.init(coder: coder) diff --git a/openHAB/NewImageUITableViewCell.swift b/openHAB/NewImageUITableViewCell.swift index 645e31382..cfac9d522 100644 --- a/openHAB/NewImageUITableViewCell.swift +++ b/openHAB/NewImageUITableViewCell.swift @@ -19,7 +19,7 @@ enum ImageType { case empty } -class NewImageUITableViewCell: GenericUITableViewCell { +class NewImageUITableViewCell: GenericUITableViewCell, NoIconDisplayableCell { var didLoad: (() -> Void)? private var mainImageView: ScaleAspectFitImageView! diff --git a/openHAB/NoIconDisplayableCell.swift b/openHAB/NoIconDisplayableCell.swift new file mode 100644 index 000000000..0692c7ee2 --- /dev/null +++ b/openHAB/NoIconDisplayableCell.swift @@ -0,0 +1,14 @@ +// Copyright (c) 2010-2025 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 + +// No icon will be displazed for cells that conform to NoIconDisplayableCell protocol + +protocol NoIconDisplayableCell {} diff --git a/openHAB/OpenHABSitemapViewController.swift b/openHAB/OpenHABSitemapViewController.swift index a9a84a037..dd852363c 100644 --- a/openHAB/OpenHABSitemapViewController.swift +++ b/openHAB/OpenHABSitemapViewController.swift @@ -346,7 +346,7 @@ extension OpenHABSitemapViewController { relevantPage?.widgets[safe: indexPath.row] } - private func updateWidgetTableView() { + public func updateWidgetTableView() { UIView.performWithoutAnimation { widgetTableView.beginUpdates() widgetTableView.endUpdates() @@ -711,72 +711,55 @@ extension OpenHABSitemapViewController: UITableViewDelegate, UITableViewDataSour return cell } - let cell: UITableViewCell - - switch widget.type { - case .frame: - cell = tableView.dequeueReusableCell(for: indexPath) as FrameUITableViewCell - case .switchWidget: - // Reflecting the discussion held in https://github.com/openhab/openhab-core/issues/952 - if !widget.mappings.isEmpty { - cell = tableView.dequeueReusableCell(for: indexPath) as SegmentedUITableViewCell - } else if widget.item?.isOfTypeOrGroupType(.switchItem) ?? false { - cell = tableView.dequeueReusableCell(for: indexPath) as SwitchUITableViewCell - } else if widget.item?.isOfTypeOrGroupType(.rollershutter) ?? false { - cell = tableView.dequeueReusableCell(for: indexPath) as RollershutterCell - } else if !widget.mappingsOrItemOptions.isEmpty { - cell = tableView.dequeueReusableCell(for: indexPath) as SegmentedUITableViewCell - } else { - cell = tableView.dequeueReusableCell(for: indexPath) as SwitchUITableViewCell - } - case .setpoint: - cell = tableView.dequeueReusableCell(for: indexPath) as SetpointCell - case .slider: - if widget.switchSupport { - cell = tableView.dequeueReusableCell(for: indexPath) as SliderWithSwitchSupportUITableViewCell - } else { - cell = tableView.dequeueReusableCell(for: indexPath) as SliderUITableViewCell - } - case .selection: - cell = tableView.dequeueReusableCell(for: indexPath) as SelectionUITableViewCell - case .colorpicker: - cell = tableView.dequeueReusableCell(for: indexPath) as ColorPickerCell - (cell as? ColorPickerCell)?.delegate = self - case .image, .chart: - cell = tableView.dequeueReusableCell(for: indexPath) as NewImageUITableViewCell - (cell as? NewImageUITableViewCell)?.didLoad = { [weak self] in - self?.updateWidgetTableView() - } - case .video: - cell = tableView.dequeueReusableCell(for: indexPath) as VideoUITableViewCell - (cell as? VideoUITableViewCell)?.didLoad = { [weak self] in - self?.updateWidgetTableView() - } - case .webview: - cell = tableView.dequeueReusableCell(for: indexPath) as WebUITableViewCell - case .mapview: - cell = tableView.dequeueReusableCell(for: indexPath) as MapViewTableViewCell - case .input: - if [.date, .time, .datetime].contains(widget.inputHint) { - let pickerCell = tableView.dequeueReusableCell(for: indexPath) as DatePickerUITableViewCell - pickerCell.controller = self - cell = pickerCell - } else { - cell = tableView.dequeueReusableCell(for: indexPath) as TextInputUITableViewCell + let cell = WidgetCellFactory.provider(for: widget) + .dequeue(from: tableView, at: indexPath) + + WidgetCellFactory.provider(for: widget) + .configure(cell: cell, for: widget, controller: self) + + var iconColor = widget.iconColor + if iconColor.isEmpty, traitCollection.userInterfaceStyle == .dark { + iconColor = "white" + } + // No icon will be displazed for cells that conform to NoIconDisplayableCell protocol + if !(cell is NoIconDisplayableCell) { + if !widget.icon.isEmpty { + if let urlc = Endpoint.icon( + rootUrl: openHABRootUrl, + version: appData?.openHABVersion ?? 2, + icon: widget.icon, + state: widget.iconState(), + iconType: iconType, + iconColor: iconColor + ).url { + var imageRequest = URLRequest(url: urlc) + imageRequest.timeoutInterval = 10.0 + cell.imageView?.kf.setImage( + with: KF.ImageResource(downloadURL: urlc, cacheKey: urlc.path + (urlc.query ?? "")), + placeholder: nil, + options: [.processor(OpenHABImageProcessor())] + ) { result in + switch result { + case .success: + DispatchQueue.main.async { + cell.setNeedsLayout() + } + case let .failure(error): + self.logger.error("Image loading failed for widget \(widget.label) : \(error.localizedDescription)") + } + } + } } - case .group, .text, .defaultWidget, .unknown: - cell = tableView.dequeueReusableCell(for: indexPath) as GenericUITableViewCell - } - - WidgetIconRenderer.loadIcon( - for: widget, - into: cell.imageView, - in: traitCollection, - openHABRootUrl: openHABRootUrl, - openHABVersion: appData?.openHABVersion ?? 2, - iconType: iconType, - logger: logger - ) + // WidgetIconRenderer.loadIcon( + // for: widget, + // into: cell.imageView, + // in: traitCollection, + // openHABRootUrl: openHABRootUrl, + // openHABVersion: appData?.openHABVersion ?? 2, + // iconType: iconType, + // logger: logger + // ) + } if cell is FrameUITableViewCell { cell.backgroundColor = .ohSystemGroupedBackground diff --git a/openHAB/VideoUITableViewCell.swift b/openHAB/VideoUITableViewCell.swift index 1dd170224..1913f6617 100644 --- a/openHAB/VideoUITableViewCell.swift +++ b/openHAB/VideoUITableViewCell.swift @@ -18,7 +18,7 @@ enum VideoEncoding: String { case hls, mjpeg } -class VideoUITableViewCell: GenericUITableViewCell { +class VideoUITableViewCell: GenericUITableViewCell, NoIconDisplayableCell { private var activityIndicator: UIActivityIndicatorView = if #available(iOS 13.0, *) { .init(style: .medium) } else { diff --git a/openHAB/WebUITableViewCell.swift b/openHAB/WebUITableViewCell.swift index 007f97953..3f57188db 100644 --- a/openHAB/WebUITableViewCell.swift +++ b/openHAB/WebUITableViewCell.swift @@ -13,7 +13,7 @@ import OpenHABCore import os.log import WebKit -class WebUITableViewCell: GenericUITableViewCell { +class WebUITableViewCell: GenericUITableViewCell, NoIconDisplayableCell { private var url: URL? private var widgetWebView: WKWebView! diff --git a/openHAB/WidgetCellProvider.swift b/openHAB/WidgetCellProvider.swift deleted file mode 100644 index 9e5c7f36d..000000000 --- a/openHAB/WidgetCellProvider.swift +++ /dev/null @@ -1,60 +0,0 @@ -// Copyright (c) 2010-2025 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 Foundation -import OpenHABCore -import UIKit - -protocol WidgetCellProvider { - static var reuseIdentifier: String { get } - static func dequeue(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell - static func configure(cell: UITableViewCell, for widget: OpenHABWidget, controller: OpenHABSitemapViewController) -} - -// enum WidgetCellFactory { -// static func provider(for widget: OpenHABWidget) -> WidgetCellProvider.Type { -// switch widget.type { -// case .switchWidget: -// if !widget.mappings.isEmpty { -// return SegmentedCellProvider.self -// } else if widget.item?.isOfTypeOrGroupType(.switchItem) ?? false { -// return SwitchCellProvider.self -// } else if widget.item?.isOfTypeOrGroupType(.rollershutter) ?? false { -// return RollershutterCellProvider.self -// } else if !widget.mappingsOrItemOptions.isEmpty { -// return SegmentedCellProvider.self -// } else { -// return SwitchCellProvider.self -// } -// -// case .slider: -// return widget.switchSupport ? SliderWithSwitchProvider.self : SliderProvider.self -// -// case .input: -// if [.date, .time, .datetime].contains(widget.inputHint) { -// return DatePickerInputProvider.self -// } else { -// return TextInputProvider.self -// } -// -// case .frame: return FrameCellProvider.self -// case .setpoint: return SetpointCellProvider.self -// case .selection: return SelectionCellProvider.self -// case .colorpicker: return ColorPickerCellProvider.self -// case .image, .chart: return ImageCellProvider.self -// case .video: return VideoCellProvider.self -// case .webview: return WebViewCellProvider.self -// case .mapview: return MapViewCellProvider.self -// case .group, .text, .defaultWidget, .unknown: -// return GenericCellProvider.self -// } -// } -// } From 1822b710a8885318ac38f11624d251c2b6e0ee92 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Tue, 25 Mar 2025 18:09:59 +0100 Subject: [PATCH 107/476] Some rework Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- openHAB/OpenHABSitemapViewController.swift | 17 +++++++------- openHAB/WidgetIconRenderer.swift | 26 +++++++++++++--------- 2 files changed, 23 insertions(+), 20 deletions(-) diff --git a/openHAB/OpenHABSitemapViewController.swift b/openHAB/OpenHABSitemapViewController.swift index dd852363c..429815db2 100644 --- a/openHAB/OpenHABSitemapViewController.swift +++ b/openHAB/OpenHABSitemapViewController.swift @@ -750,15 +750,14 @@ extension OpenHABSitemapViewController: UITableViewDelegate, UITableViewDataSour } } } - // WidgetIconRenderer.loadIcon( - // for: widget, - // into: cell.imageView, - // in: traitCollection, - // openHABRootUrl: openHABRootUrl, - // openHABVersion: appData?.openHABVersion ?? 2, - // iconType: iconType, - // logger: logger - // ) + +// cell.loadWidgetIcon( +// widget: widget, +// traitCollection: traitCollection, +// openHABRootUrl: openHABRootUrl, +// openHABVersion: appData?.openHABVersion ?? 2, +// iconType: iconType +// ) } if cell is FrameUITableViewCell { diff --git a/openHAB/WidgetIconRenderer.swift b/openHAB/WidgetIconRenderer.swift index 14b116fc5..1159ade0c 100644 --- a/openHAB/WidgetIconRenderer.swift +++ b/openHAB/WidgetIconRenderer.swift @@ -15,15 +15,17 @@ import OpenHABCore import os.log import UIKit -enum WidgetIconRenderer { - static func loadIcon(for widget: OpenHABWidget, - into imageView: UIImageView?, - in traitCollection: UITraitCollection, - openHABRootUrl: String, - openHABVersion: Int, - iconType: IconType, - logger: Logger) { - guard let imageView, !widget.icon.isEmpty else { return } +extension UITableViewCell { + func loadWidgetIcon(widget: OpenHABWidget, + traitCollection: UITraitCollection, + openHABRootUrl: String, + openHABVersion: Int, + iconType: IconType) { + guard !(self is NewImageUITableViewCell || self is VideoUITableViewCell || self is FrameUITableViewCell || self is WebUITableViewCell), + let imageView, + !widget.icon.isEmpty else { + return + } var iconColor = widget.iconColor if iconColor.isEmpty, traitCollection.userInterfaceStyle == .dark { @@ -37,7 +39,9 @@ enum WidgetIconRenderer { state: widget.iconState(), iconType: iconType, iconColor: iconColor - ).url else { return } + ).url else { + return + } var request = URLRequest(url: url) request.timeoutInterval = 10 @@ -53,7 +57,7 @@ enum WidgetIconRenderer { imageView.setNeedsLayout() } case let .failure(error): - logger.error("❌ Failed to load widget icon: \(error.localizedDescription)") + print("Image loading failed for widget \(widget.label): \(error.localizedDescription)") } } } From a7e9b3b3ed65643e6302f1efbd55a5484c02f1da Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Wed, 26 Mar 2025 22:14:36 +0100 Subject: [PATCH 108/476] Migrating OpenHABAccessTokenAdapter from appData to connectionConfiguration Setting .requestModifier in NetworkTracker Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../OpenHABCore/Util/NetworkTracker.swift | 5 ++++- .../Util/OpenHABAccessTokenAdapter.swift | 13 ++++++------ .../OpenHABCore/Util/Preferences.swift | 1 + openHAB/AppDelegate.swift | 2 -- openHAB/OpenHABSitemapViewController.swift | 21 +++++++------------ openHAB/OpenHABWebViewController.swift | 2 +- openHAB/SettingsView/MainUISettingsView.swift | 10 +++------ openHAB/SettingsView/SettingsView.swift | 5 ----- 8 files changed, 23 insertions(+), 36 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift index 6221c9006..56e4576a7 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift @@ -11,6 +11,7 @@ import Combine import Foundation +import Kingfisher import Network import OpenAPIRuntime import os.log @@ -326,7 +327,9 @@ public final class NetworkTracker: ObservableObject { activeConnection = connection status = connection == nil ? .notConnected : .connected - if connection == nil { + if let connection { + KingfisherManager.shared.defaultOptions = [.requestModifier(OpenHABAccessTokenAdapter(connectionConfiguration: connection.configuration))] + } else { startRetryTask(30) } } diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OpenHABAccessTokenAdapter.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABAccessTokenAdapter.swift index 977c64908..b84e37c55 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/OpenHABAccessTokenAdapter.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABAccessTokenAdapter.swift @@ -13,20 +13,21 @@ import Foundation import Kingfisher public class OpenHABAccessTokenAdapter { - var appData: DataObject + var connectionConfiguration: ConnectionConfiguration? - public init(appData data: DataObject) { - appData = data + public init(connectionConfiguration: ConnectionConfiguration) { + self.connectionConfiguration = connectionConfiguration } public func adapt(_ urlRequest: URLRequest) throws -> URLRequest { - guard appData.openHABAlwaysSendCreds || urlRequest.url?.host?.hasSuffix("myopenhab.org") == true else { + guard let connectionConfiguration else { return urlRequest } + guard connectionConfiguration.alwaysSendBasicAuth || urlRequest.url?.host?.hasSuffix("myopenhab.org") == true else { // The user did not choose for the credentials to be sent with every request. return urlRequest } - let user = appData.openHABUsername - let password = appData.openHABPassword + let user = connectionConfiguration.username + let password = connectionConfiguration.password guard !user.isEmpty, !password.isEmpty else { // In order to set the credentials on the `URLRequestt`, both username and password must be set up. return urlRequest diff --git a/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift index e1034491c..9f8e232fd 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift @@ -177,6 +177,7 @@ public enum Preferences { @UserDefault("didMigrateToSharedDefaults", defaultValue: false) private static var didMigrateToSharedDefaults: Bool @UserDefault("didMigrateToConnectionConfig", defaultValue: false) private static var didMigrateToConnectionConfig: Bool + @UserDefault("currentWebViewPath", defaultValue: "") public static var currentWebViewPath: String } public extension Preferences { diff --git a/openHAB/AppDelegate.swift b/openHAB/AppDelegate.swift index 781053221..5a49af689 100644 --- a/openHAB/AppDelegate.swift +++ b/openHAB/AppDelegate.swift @@ -99,8 +99,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate { activateWatchConnectivity() - KingfisherManager.shared.defaultOptions = [.requestModifier(OpenHABAccessTokenAdapter(appData: AppDelegate.appDelegate.appData))] - return true } diff --git a/openHAB/OpenHABSitemapViewController.swift b/openHAB/OpenHABSitemapViewController.swift index 429815db2..595149c8c 100644 --- a/openHAB/OpenHABSitemapViewController.swift +++ b/openHAB/OpenHABSitemapViewController.swift @@ -26,7 +26,6 @@ import UIKit // swiftlint:disable type_body_length class OpenHABSitemapViewController: OpenHABViewController { var pageUrl = "" - private var selectedWidgetRow: Int = 0 private var iconType: IconType = .png private var openHABRootUrl = "" private var openHABUsername = "" @@ -40,13 +39,10 @@ class OpenHABSitemapViewController: OpenHABViewController { private var idleOff = false private var sitemaps: [OpenHABSitemap] = [] private var currentPage: OpenHABPage? - private var selectionPicker: UIPickerView? private var pageNetworkStatus: NetworkStatus? private var pageNetworkStatusAvailable = false - private var toggle: Int = 0 private var refreshControl: UIRefreshControl? private var filteredPage: OpenHABPage? - private var serverProperties: OpenHABServerProperties? private let search = UISearchController(searchResultsController: nil) private var isUserInteracting = false private var isWaitingToReload = false @@ -157,6 +153,8 @@ class OpenHABSitemapViewController: OpenHABViewController { // if pageUrl is empty, it means we are the first opened OpenHABSitemapViewController if pageUrl.isEmpty { appData?.sitemapViewController = self +// if navigationController?.viewControllers.first == self { + // This is the first sitemap opened if currentPage != nil { currentPage?.widgets = [] widgetTableView.reloadData() @@ -381,10 +379,10 @@ extension OpenHABSitemapViewController { func selectSitemap() { Task { do { - logger.debug("Running selectSitemap for URL: \(self.appData?.openHABRootUrl ?? "")") + logger.debug("Running selectSitemap for URL: \(NetworkTracker.shared.activeConnection?.configuration.url ?? "")") openAPIService = OpenAPIService( - connectionConfiguration: appData!.connectionInfo!.configuration) + connectionConfiguration: NetworkTracker.shared.activeConnection!.configuration) sitemaps = try await openAPIService?.openHABSitemaps() ?? [] @@ -488,7 +486,7 @@ extension OpenHABSitemapViewController { if openAPIService == nil { openAPIService = OpenAPIService( - connectionConfiguration: appData!.connectionInfo!.configuration) + connectionConfiguration: NetworkTracker.shared.activeConnection!.configuration) } let initialPage = try await openAPIService?.pollDataForPage( @@ -560,7 +558,7 @@ extension OpenHABSitemapViewController { } } - // load app settings + // load settings into local properties func loadSettings() { openHABUsername = Preferences.username openHABPassword = Preferences.password @@ -568,11 +566,6 @@ extension OpenHABSitemapViewController { defaultSitemap = Preferences.defaultSitemap idleOff = Preferences.idleOff iconType = IconType(rawValue: Preferences.iconType) ?? .png - - appData?.openHABUsername = openHABUsername - appData?.openHABPassword = openHABPassword - appData?.openHABAlwaysSendCreds = openHABAlwaysSendCreds - #if DEBUG // always use demo sitemap for UITest if ProcessInfo.processInfo.environment["UITest"] != nil { @@ -726,7 +719,7 @@ extension OpenHABSitemapViewController: UITableViewDelegate, UITableViewDataSour if !widget.icon.isEmpty { if let urlc = Endpoint.icon( rootUrl: openHABRootUrl, - version: appData?.openHABVersion ?? 2, + version: NetworkTracker.shared.activeConnection?.version ?? 2, icon: widget.icon, state: widget.iconState(), iconType: iconType, diff --git a/openHAB/OpenHABWebViewController.swift b/openHAB/OpenHABWebViewController.swift index af2c2a098..980cc1977 100644 --- a/openHAB/OpenHABWebViewController.swift +++ b/openHAB/OpenHABWebViewController.swift @@ -273,7 +273,7 @@ class OpenHABWebViewController: OpenHABViewController { if let path = url?.path { os_log("navigation change base: %{PUBLIC}@ path: %{PUBLIC}@", log: OSLog.default, type: .info, self.openHABTrackedRootUrl, path) // append trailing slash as WebUI/Vue/F7 will try and issue a 302 if the url is navigated to directly, this can be problamatic on myopenHAB - self.appData?.currentWebViewPath = path.hasSuffix("/") ? path : path + "/" + Preferences.currentWebViewPath = path.hasSuffix("/") ? path : path + "/" } } } diff --git a/openHAB/SettingsView/MainUISettingsView.swift b/openHAB/SettingsView/MainUISettingsView.swift index 98d1b85fa..198cb430a 100644 --- a/openHAB/SettingsView/MainUISettingsView.swift +++ b/openHAB/SettingsView/MainUISettingsView.swift @@ -9,20 +9,18 @@ // // SPDX-License-Identifier: EPL-2.0 +import OpenHABCore import SwiftUI import WebKit struct MainUISettingsView: View { @Binding var settingsAlwaysAllowWebRTC: Bool @Binding var settingsDefaultMainUIPath: String +// @Binding var currentWebViewPath: String @State var showUselastPathAlert = false @State var showingCacheAlert = false - var appData: OpenHABDataObject? { - AppDelegate.appDelegate.appData - } - var body: some View { Section(header: Text(LocalizedStringKey("mainui_settings"))) { Toggle(isOn: $settingsAlwaysAllowWebRTC) { @@ -45,9 +43,7 @@ struct MainUISettingsView: View { isPresented: $showUselastPathAlert ) { Button("Ok") { - if let path = appData?.currentWebViewPath { - settingsDefaultMainUIPath = path - } + settingsDefaultMainUIPath = Preferences.currentWebViewPath } Button(role: .cancel) {} label: { Text(LocalizedStringKey("cancel")) diff --git a/openHAB/SettingsView/SettingsView.swift b/openHAB/SettingsView/SettingsView.swift index 0eefa8c06..4492b7fb2 100644 --- a/openHAB/SettingsView/SettingsView.swift +++ b/openHAB/SettingsView/SettingsView.swift @@ -37,10 +37,6 @@ struct SettingsView: View { @Environment(\.dismiss) private var dismiss - var appData: OpenHABDataObject? { - AppDelegate.appDelegate.appData - } - private let logger = Logger(subsystem: "org.openhab.app", category: "SettingsView") var body: some View { @@ -81,7 +77,6 @@ struct SettingsView: View { ToolbarItemGroup(placement: .primaryAction) { Button("Save") { saveSettings() - appData?.sitemapViewController?.pageUrl = "" NotificationCenter.default.post(name: NSNotification.Name("org.openhab.preferences.saved"), object: nil) dismiss() } From 2e7310cbd2db83447284c700d9dc42d78fa6aabe Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Fri, 28 Mar 2025 21:06:42 +0100 Subject: [PATCH 109/476] Clean-up on Preferences Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../OpenHABCore/Util/Preferences.swift | 38 ++++++++++++------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift index 9f8e232fd..a0788fb17 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift @@ -158,20 +158,8 @@ public enum Preferences { @UserDefault("defaultMainUIPath", defaultValue: "") public static var defaultMainUIPath: String @UserDefault("alwaysAllowWebRTC", defaultValue: false) public static var alwaysAllowWebRTC: Bool @UserDefault("sitemapForWatch", defaultValue: "watch") public static var sitemapForWatch: String - @UserDefaultObject("localConnectionConfig", defaultValue: ConnectionConfiguration( - url: "http://192.168.1.1:8080", - username: "", - password: "", - alwaysSendBasicAuth: false, - ignoreSSL: false, - priority: 0)) public static var localConnectionConfig: ConnectionConfiguration - @UserDefaultObject("remoteConnectionConfig", defaultValue: ConnectionConfiguration( - url: "https://myopenhab.org", - username: "", - password: "", - alwaysSendBasicAuth: false, - ignoreSSL: false, - priority: 1)) public static var remoteConnectionConfig: ConnectionConfiguration + @UserDefaultObject("localConnectionConfig", defaultValue: ConnectionConfiguration.localDefault) public static var localConnectionConfig: ConnectionConfiguration + @UserDefaultObject("remoteConnectionConfig", defaultValue: ConnectionConfiguration.remoteDefault) public static var remoteConnectionConfig: ConnectionConfiguration // MARK: - Private @@ -247,3 +235,25 @@ public extension Preferences { .first } } + +// MARK: - Sample Codable Model + +public extension ConnectionConfiguration { + static let localDefault = ConnectionConfiguration( + url: "http://192.168.1.1:8080", + username: "", + password: "", + alwaysSendBasicAuth: false, + ignoreSSL: false, + priority: 0 + ) + + static let remoteDefault = ConnectionConfiguration( + url: "https://myopenhab.org", + username: "", + password: "", + alwaysSendBasicAuth: false, + ignoreSSL: false, + priority: 1 + ) +} From 3d7804dd6e6e5719ba3d10d07f454994df21cdd8 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Fri, 28 Mar 2025 21:14:41 +0100 Subject: [PATCH 110/476] Clean-up on OpenHABDataObject, VideoUITableViewCell Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- openHAB/OpenHABDataObject.swift | 1 - openHAB/VideoUITableViewCell.swift | 3 --- 2 files changed, 4 deletions(-) diff --git a/openHAB/OpenHABDataObject.swift b/openHAB/OpenHABDataObject.swift index df414e0c2..6197400ea 100644 --- a/openHAB/OpenHABDataObject.swift +++ b/openHAB/OpenHABDataObject.swift @@ -22,7 +22,6 @@ class OpenHABDataObject: NSObject, DataObject { var openHABVersion: Int = 0 var currentWebViewPath = "" var currentView: TargetController? - var lastNotificationInfo: [AnyHashable: Any]? var connectionInfo: ConnectionInfo? } diff --git a/openHAB/VideoUITableViewCell.swift b/openHAB/VideoUITableViewCell.swift index 1913f6617..59314a2f4 100644 --- a/openHAB/VideoUITableViewCell.swift +++ b/openHAB/VideoUITableViewCell.swift @@ -40,9 +40,6 @@ class VideoUITableViewCell: GenericUITableViewCell, NoIconDisplayableCell { private var aspectRatioConstraint: NSLayoutConstraint? private var activeTask: Task? private var session: URLSession! - private var appData: OpenHABDataObject? { - AppDelegate.appDelegate.appData - } override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) From 2ca7f0002809d154a8c84fd72314f2667328fd50 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Sat, 29 Mar 2025 07:42:24 +0100 Subject: [PATCH 111/476] Remove usage of lastNotificationInfo for Notifications Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- openHAB/AppDelegate.swift | 31 +++++++++++----- openHAB/OpenHABRootViewController.swift | 49 ++++++++----------------- 2 files changed, 37 insertions(+), 43 deletions(-) diff --git a/openHAB/AppDelegate.swift b/openHAB/AppDelegate.swift index 5a49af689..f09edec41 100644 --- a/openHAB/AppDelegate.swift +++ b/openHAB/AppDelegate.swift @@ -205,7 +205,11 @@ extension AppDelegate: UNUserNotificationCenterDelegate { let userInfo = notification.request.content.userInfo logger.info("Notification received while app is in foreground: \(userInfo)") - appData.lastNotificationInfo = userInfo + NotificationCenter.default.post( + name: .openHABDidReceiveNotification, + object: nil, + userInfo: userInfo + ) await displayNotification(userInfo: userInfo) return [] // Modify this if you want to show banners, alerts, etc. @@ -221,8 +225,12 @@ extension AppDelegate: UNUserNotificationCenterDelegate { if actionIdentifier != UNNotificationDefaultActionIdentifier { userInfo["actionIdentifier"] = actionIdentifier } + NotificationCenter.default.post( + name: .openHABDidReceiveNotification, + object: nil, + userInfo: userInfo + ) await notifyNotificationListeners(userInfo) - appData.lastNotificationInfo = userInfo } } @@ -264,7 +272,7 @@ extension AppDelegate: UNUserNotificationCenterDelegate { // Use closure-based tap gesture insteae of #selector let tapGesture = MessageTapGestureRecognizer { Task { - await self.messageViewTapped() + self.messageViewTapped(userInfo: userInfo) } } view.addGestureRecognizer(tapGesture) @@ -275,23 +283,26 @@ extension AppDelegate: UNUserNotificationCenterDelegate { } // Action to be performed when the notification message view is tapped - func messageViewTapped() async { - if let userInfo = appData.lastNotificationInfo { - await notifyNotificationListeners(userInfo) - SwiftMessages.hideAll() - } + func messageViewTapped(userInfo: [AnyHashable: Any]) { + notifyNotificationListeners(userInfo) + SwiftMessages.hideAll() } // ✅ Ensure this runs on the MainActor @MainActor - private func notifyNotificationListeners(_ userInfo: [AnyHashable: Any]) async { + private func notifyNotificationListeners(_ userInfo: [AnyHashable: Any]) { if let navigationController = window?.rootViewController as? UINavigationController, let rootViewController = navigationController.viewControllers.first as? OpenHABRootViewController { - await rootViewController.handleNotification(userInfo) + let action = userInfo["actionIdentifier"] as? String ?? userInfo["on-click"] as? String + rootViewController.handleNotification(action: action) } } } +extension Notification.Name { + static let openHABDidReceiveNotification = Notification.Name("openHABDidReceiveNotification") +} + extension AppDelegate { func applicationWillResignActive(_ application: UIApplication) { // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state. diff --git a/openHAB/OpenHABRootViewController.swift b/openHAB/OpenHABRootViewController.swift index ec281c82f..e9deb4db9 100644 --- a/openHAB/OpenHABRootViewController.swift +++ b/openHAB/OpenHABRootViewController.swift @@ -100,8 +100,20 @@ class OpenHABRootViewController: UIViewController { switchToSavedView() setupTracker() // check if we were launched with a notification - if let userInfo = appData?.lastNotificationInfo { - handleNotification(userInfo) + // ✅ Observe notifications (in-app or from AppDelegate) + NotificationCenter.default.addObserver( + forName: .openHABDidReceiveNotification, + object: nil, + queue: .main + ) { [weak self] notification in + guard let userInfo = notification.userInfo else { return } + + // ✅ Extract action identifier (from button press or notification click) *before* entering the Task + let action = userInfo["actionIdentifier"] as? String ?? userInfo["on-click"] as? String + + Task { @MainActor in + self?.handleNotification(action: action) + } } } @@ -308,38 +320,9 @@ class OpenHABRootViewController: UIViewController { } } - func handleNotification(_ userInfo: [AnyHashable: Any], completionHandler: (() -> Void)? = nil) { - // actionIdentifier is the result of a action button being pressed - // if not actionIdentifier, then the notification was clicked, so use "on-click" if there - if let action = userInfo["actionIdentifier"] as? String ?? userInfo["on-click"] as? String { - let cmd = action.split(separator: ":").dropFirst().joined(separator: ":") - switch true { - case action.hasPrefix("ui"): - uiCommandAction(cmd) - callCompletionHandler(completionHandler) - case action.hasPrefix("command"): - sendCommandAction(cmd) - callCompletionHandler(completionHandler) - case action.hasPrefix("http"): - httpCommandAction(action) - callCompletionHandler(completionHandler) - case action.hasPrefix("app"): - appCommandAction(action) - callCompletionHandler(completionHandler) - case action.hasPrefix("rule"): - ruleCommandAction(action) - callCompletionHandler(completionHandler) - default: - callCompletionHandler(completionHandler) - } - } - } - - func handleNotification(_ userInfo: [AnyHashable: Any]) async { - // Extract action identifier (from button press or notification click) - let action = userInfo["actionIdentifier"] as? String ?? userInfo["on-click"] as? String - + func handleNotification(action: String?) { guard let action else { return } + let cmd = action.split(separator: ":").dropFirst().joined(separator: ":") switch true { From 9396ccb41ddd9a7b293f172aaadeb15d8cef72cc Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Sat, 29 Mar 2025 08:39:44 +0100 Subject: [PATCH 112/476] Use func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable : Any]) async Migrate away from non-sendable userInfo for notifications Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- openHAB/AppDelegate.swift | 93 ++++++++++++++++++++------------------- 1 file changed, 48 insertions(+), 45 deletions(-) diff --git a/openHAB/AppDelegate.swift b/openHAB/AppDelegate.swift index f09edec41..b5d32ad84 100644 --- a/openHAB/AppDelegate.swift +++ b/openHAB/AppDelegate.swift @@ -154,9 +154,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // remove the 'openhab' from the url let action = url.absoluteString.split(separator: ":").dropFirst().joined(separator: ":") - Task { - await notifyNotificationListeners(["actionIdentifier": action]) - } + notifyNotificationListeners(action: action) return true } @@ -168,55 +166,62 @@ class AppDelegate: UIResponder, UIApplicationDelegate { func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { os_log("Failed to get token for notifications: %{PUBLIC}@", log: .notifications, type: .error, error.localizedDescription) } + + @MainActor + func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable : Any]) async -> UIBackgroundFetchResult { - // this is called for "content-available" silent notifications (background notifications) - func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { - os_log("didReceiveRemoteNotification %{PUBLIC}@", log: .default, type: .info, userInfo) - // Hide notification logic - if let type = userInfo["type"] as? String, type == "hideNotification" { - if let refid = userInfo["reference-id"] as? String { - os_log("didReceiveRemoteNotification remove id %{PUBLIC}@", log: .default, type: .info, refid) - UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: [refid]) - } - if let tag = userInfo["tag"] as? String { - UNUserNotificationCenter.current().getDeliveredNotifications { notifications in - let notificationsWithSeverity = notifications.filter { notification in - notification.request.content.userInfo["tag"] as? String == tag - } + logger.info("didReceiveRemoteNotification \(String(describing: userInfo), privacy: .public)") - // Get the identifiers of these notifications - let identifiers = notificationsWithSeverity.map(\.request.identifier) + guard let type = userInfo["type"] as? String, type == "hideNotification" else { + return .noData + } - if !identifiers.isEmpty { - os_log("didReceiveRemoteNotification remove tag %{PUBLIC}@ %{PUBLIC}@", log: .default, type: .info, tag, identifiers) - // Remove the filtered notifications - UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: identifiers) - } - } + if let refid = userInfo["reference-id"] as? String { + logger.info("Removing notification with id \(refid, privacy: .public)") + UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: [refid]) + } + + if let tag = userInfo["tag"] as? String { + // Hop off the MainActor to avoid Sendable warning + let identifiers: [String] = await Task.detached(priority: .userInitiated) { + let notifications = await UNUserNotificationCenter.current().deliveredNotifications() + return notifications + .filter { $0.request.content.userInfo["tag"] as? String == tag } + .map(\.request.identifier) + }.value + + + if !identifiers.isEmpty { + logger.info("Removing notifications with tag \(tag, privacy: .public), identifiers: \(String(describing: identifiers), privacy: .public)") + UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: identifiers) } } - completionHandler(.newData) + + return .newData } } extension AppDelegate: UNUserNotificationCenterDelegate { // this is called when a notification comes in while in the foreground - func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification) async -> UNNotificationPresentationOptions { + nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification) async -> UNNotificationPresentationOptions { let userInfo = notification.request.content.userInfo logger.info("Notification received while app is in foreground: \(userInfo)") - NotificationCenter.default.post( + NotificationCenter.default.post( name: .openHABDidReceiveNotification, object: nil, userInfo: userInfo - ) - await displayNotification(userInfo: userInfo) - - return [] // Modify this if you want to show banners, alerts, etc. - } - + ) + + let message = userInfo["message"] as? String ?? NSLocalizedString("message_not_decoded", comment: "") + let action = userInfo["actionIdentifier"] as? String ?? userInfo["on-click"] as? String + await displayNotification(message: message, action: action) + + return [] // Modify this if you want to show banners, alerts, etc. + } + // this is called when clicking a notification while in the background - func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async { + nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async { var userInfo = response.notification.request.content.userInfo let actionIdentifier = response.actionIdentifier logger.info("Notification clicked: action \(actionIdentifier) userInfo \(userInfo)") @@ -230,19 +235,18 @@ extension AppDelegate: UNUserNotificationCenterDelegate { object: nil, userInfo: userInfo ) - await notifyNotificationListeners(userInfo) + let action = userInfo["actionIdentifier"] as? String ?? userInfo["on-click"] as? String + await notifyNotificationListeners(action: action) } } - private func displayNotification(userInfo: [AnyHashable: Any]) async { - os_log("displayNotification %{PUBLIC}@", log: .notifications, type: .info, userInfo["message"] as? String ?? "no message") + private func displayNotification(message: String, action: String?) async { + logger.info("displayNotification \(message)") Task { await audioPlayer.playSound() } - let message = userInfo["message"] as? String ?? NSLocalizedString("message_not_decoded", comment: "") - var config = SwiftMessages.Config() config.duration = .seconds(seconds: 5) config.presentationStyle = .bottom @@ -272,7 +276,7 @@ extension AppDelegate: UNUserNotificationCenterDelegate { // Use closure-based tap gesture insteae of #selector let tapGesture = MessageTapGestureRecognizer { Task { - self.messageViewTapped(userInfo: userInfo) + self.messageViewTapped(action: action) } } view.addGestureRecognizer(tapGesture) @@ -283,17 +287,16 @@ extension AppDelegate: UNUserNotificationCenterDelegate { } // Action to be performed when the notification message view is tapped - func messageViewTapped(userInfo: [AnyHashable: Any]) { - notifyNotificationListeners(userInfo) + func messageViewTapped(action: String?) { + notifyNotificationListeners(action: action) SwiftMessages.hideAll() } // ✅ Ensure this runs on the MainActor @MainActor - private func notifyNotificationListeners(_ userInfo: [AnyHashable: Any]) { + private func notifyNotificationListeners(action: String?) { if let navigationController = window?.rootViewController as? UINavigationController, let rootViewController = navigationController.viewControllers.first as? OpenHABRootViewController { - let action = userInfo["actionIdentifier"] as? String ?? userInfo["on-click"] as? String rootViewController.handleNotification(action: action) } } From 7b5061d55ca42bd712c70795e7b9459422b736f7 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Sat, 29 Mar 2025 12:24:46 +0100 Subject: [PATCH 113/476] Update to Kingfisher >= 8.0.0 Fix error on WidgetCellFactory Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- OpenHABCore/Package.swift | 2 +- .../OpenHABCore/Util/NetworkTracker.swift | 4 +- openHAB.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/swiftpm/Package.resolved | 6 +-- openHAB/AppDelegate.swift | 30 +++++------ .../Providers/ColorPickerCellProvider.swift | 7 +-- .../Providers/DatePickerInputProvider.swift | 6 +-- .../Cells/Providers/FrameCellProvider.swift | 6 +-- .../Cells/Providers/GenericCellProvider.swift | 6 +-- .../Cells/Providers/ImageCellProvider.swift | 6 +-- .../Cells/Providers/MapViewCellProvider.swift | 6 +-- .../Providers/RollerShutterCellProvider.swift | 6 +-- .../Providers/SegmentedCellProvider.swift | 6 +-- .../Providers/SelectionCellProvider.swift | 6 +-- .../Providers/SetpointCellProvider.swift | 6 +-- openHAB/Cells/Providers/SliderProvider.swift | 6 +-- .../Providers/SliderWithSwitchProvider.swift | 6 +-- .../Cells/Providers/SwitchCellProvider.swift | 6 +-- .../Cells/Providers/TextInputProvider.swift | 6 +-- .../Cells/Providers/VideoCellProvider.swift | 6 +-- .../Cells/Providers/WebViewCellProvider.swift | 6 +-- openHAB/Cells/WidgetCellProvider.swift | 52 ++++++++++--------- openHAB/OpenHABSitemapViewController.swift | 32 +++++------- 23 files changed, 111 insertions(+), 114 deletions(-) diff --git a/OpenHABCore/Package.swift b/OpenHABCore/Package.swift index a31bf9485..803b48068 100644 --- a/OpenHABCore/Package.swift +++ b/OpenHABCore/Package.swift @@ -15,7 +15,7 @@ let package = Package( ], dependencies: [ // Dependencies declare other packages that this package depends on. - .package(url: "https://github.com/onevcat/Kingfisher.git", from: "7.0.0"), + .package(url: "https://github.com/onevcat/Kingfisher.git", from: "8.0.0"), .package(url: "https://github.com/apple/swift-openapi-runtime", from: "1.0.0"), .package(url: "https://github.com/apple/swift-openapi-urlsession", from: "1.0.0"), .package(url: "https://github.com/apple/swift-http-types.git", from: "1.0.0") diff --git a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift index 56e4576a7..dd8d56ec6 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift @@ -125,7 +125,9 @@ public final class NetworkTracker: ObservableObject { // } // } else { monitor.pathUpdateHandler = { [weak self] path in - Task { await self?.handleNetworkChange(isConnected: path.status == .satisfied) } + Task.detached(priority: .utility) { + await self?.handleNetworkChange(isConnected: path.status == .satisfied) + } } monitor.start(queue: monitorQueue) } diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index 92653f25b..cc91b74ac 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -2707,7 +2707,7 @@ repositoryURL = "https://github.com/onevcat/Kingfisher.git"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 7.0.0; + minimumVersion = 8.0.0; }; }; 93F8063327AE6C620035A6B0 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */ = { diff --git a/openHAB.xcworkspace/xcshareddata/swiftpm/Package.resolved b/openHAB.xcworkspace/xcshareddata/swiftpm/Package.resolved index 1c01ec0e3..7fa39b7bf 100644 --- a/openHAB.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/openHAB.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "8eade3b50b066081f5b3cd85ab51a15e57c4063c0b6bb1223f02347d477b36c6", + "originHash" : "f60c1a47f871d46f44f67bd143f8264999978e92660c6e1cb2f2c922d2b6cf6b", "pins" : [ { "identity" : "abseil-cpp-binary", @@ -114,8 +114,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/onevcat/Kingfisher.git", "state" : { - "revision" : "2ef543ee21d63734e1c004ad6c870255e8716c50", - "version" : "7.12.0" + "revision" : "4c6b067f96953ee19526e49e4189403a2be21fb3", + "version" : "8.3.1" } }, { diff --git a/openHAB/AppDelegate.swift b/openHAB/AppDelegate.swift index b5d32ad84..037f4fd5a 100644 --- a/openHAB/AppDelegate.swift +++ b/openHAB/AppDelegate.swift @@ -166,10 +166,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate { func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { os_log("Failed to get token for notifications: %{PUBLIC}@", log: .notifications, type: .error, error.localizedDescription) } - - @MainActor - func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable : Any]) async -> UIBackgroundFetchResult { + @MainActor + func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any]) async -> UIBackgroundFetchResult { logger.info("didReceiveRemoteNotification \(String(describing: userInfo), privacy: .public)") guard let type = userInfo["type"] as? String, type == "hideNotification" else { @@ -189,8 +188,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { .filter { $0.request.content.userInfo["tag"] as? String == tag } .map(\.request.identifier) }.value - - + if !identifiers.isEmpty { logger.info("Removing notifications with tag \(tag, privacy: .public), identifiers: \(String(describing: identifiers), privacy: .public)") UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: identifiers) @@ -203,23 +201,23 @@ class AppDelegate: UIResponder, UIApplicationDelegate { extension AppDelegate: UNUserNotificationCenterDelegate { // this is called when a notification comes in while in the foreground - nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification) async -> UNNotificationPresentationOptions { + nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification) async -> UNNotificationPresentationOptions { let userInfo = notification.request.content.userInfo logger.info("Notification received while app is in foreground: \(userInfo)") - NotificationCenter.default.post( + NotificationCenter.default.post( name: .openHABDidReceiveNotification, object: nil, userInfo: userInfo - ) - - let message = userInfo["message"] as? String ?? NSLocalizedString("message_not_decoded", comment: "") - let action = userInfo["actionIdentifier"] as? String ?? userInfo["on-click"] as? String - await displayNotification(message: message, action: action) - - return [] // Modify this if you want to show banners, alerts, etc. - } - + ) + + let message = userInfo["message"] as? String ?? NSLocalizedString("message_not_decoded", comment: "") + let action = userInfo["actionIdentifier"] as? String ?? userInfo["on-click"] as? String + await displayNotification(message: message, action: action) + + return [] // Modify this if you want to show banners, alerts, etc. + } + // this is called when clicking a notification while in the background nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async { var userInfo = response.notification.request.content.userInfo diff --git a/openHAB/Cells/Providers/ColorPickerCellProvider.swift b/openHAB/Cells/Providers/ColorPickerCellProvider.swift index afa53aff0..0ca8422f4 100644 --- a/openHAB/Cells/Providers/ColorPickerCellProvider.swift +++ b/openHAB/Cells/Providers/ColorPickerCellProvider.swift @@ -14,13 +14,14 @@ import OpenHABCore import UIKit struct ColorPickerCellProvider: WidgetCellProvider { - static var reuseIdentifier: String { "ColorPickerCell" } + var reuseIdentifier: String { "ColorPickerCell" } - static func dequeue(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { + func dequeue(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { tableView.dequeueReusableCell(for: indexPath) as ColorPickerCell } - static func configure(cell: UITableViewCell, for widget: OpenHABWidget, controller: OpenHABSitemapViewController) { + @MainActor + func configure(cell: UITableViewCell, for widget: OpenHABWidget, controller: OpenHABSitemapViewController) { guard let cell = cell as? ColorPickerCell else { return } cell.delegate = controller cell.widget = widget diff --git a/openHAB/Cells/Providers/DatePickerInputProvider.swift b/openHAB/Cells/Providers/DatePickerInputProvider.swift index 4c40e8d30..8c782c419 100644 --- a/openHAB/Cells/Providers/DatePickerInputProvider.swift +++ b/openHAB/Cells/Providers/DatePickerInputProvider.swift @@ -14,13 +14,13 @@ import OpenHABCore import UIKit struct DatePickerInputProvider: WidgetCellProvider { - static var reuseIdentifier: String { "DatePickerUITableViewCell" } + var reuseIdentifier: String { "DatePickerUITableViewCell" } - static func dequeue(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { + func dequeue(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { tableView.dequeueReusableCell(for: indexPath) as DatePickerUITableViewCell } - static func configure(cell: UITableViewCell, for widget: OpenHABWidget, controller: OpenHABSitemapViewController) { + func configure(cell: UITableViewCell, for widget: OpenHABWidget, controller: OpenHABSitemapViewController) { guard let cell = cell as? DatePickerUITableViewCell else { return } cell.controller = controller cell.widget = widget diff --git a/openHAB/Cells/Providers/FrameCellProvider.swift b/openHAB/Cells/Providers/FrameCellProvider.swift index 7d35c6c45..0c670c011 100644 --- a/openHAB/Cells/Providers/FrameCellProvider.swift +++ b/openHAB/Cells/Providers/FrameCellProvider.swift @@ -14,13 +14,13 @@ import OpenHABCore import UIKit struct FrameCellProvider: WidgetCellProvider { - static var reuseIdentifier: String { "FrameUITableViewCell" } + var reuseIdentifier: String { "FrameUITableViewCell" } - static func dequeue(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { + func dequeue(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { tableView.dequeueReusableCell(for: indexPath) as FrameUITableViewCell } - static func configure(cell: UITableViewCell, for widget: OpenHABWidget, controller: OpenHABSitemapViewController) { + func configure(cell: UITableViewCell, for widget: OpenHABWidget, controller: OpenHABSitemapViewController) { guard let cell = cell as? FrameUITableViewCell else { return } cell.widget = widget cell.displayWidget() diff --git a/openHAB/Cells/Providers/GenericCellProvider.swift b/openHAB/Cells/Providers/GenericCellProvider.swift index b52494596..b277deb7e 100644 --- a/openHAB/Cells/Providers/GenericCellProvider.swift +++ b/openHAB/Cells/Providers/GenericCellProvider.swift @@ -14,13 +14,13 @@ import OpenHABCore import UIKit struct GenericCellProvider: WidgetCellProvider { - static var reuseIdentifier: String { "GenericUITableViewCell" } + var reuseIdentifier: String { "GenericUITableViewCell" } - static func dequeue(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { + func dequeue(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { tableView.dequeueReusableCell(for: indexPath) as GenericUITableViewCell } - static func configure(cell: UITableViewCell, for widget: OpenHABWidget, controller: OpenHABSitemapViewController) { + func configure(cell: UITableViewCell, for widget: OpenHABWidget, controller: OpenHABSitemapViewController) { guard let cell = cell as? GenericUITableViewCell else { return } cell.widget = widget cell.displayWidget() diff --git a/openHAB/Cells/Providers/ImageCellProvider.swift b/openHAB/Cells/Providers/ImageCellProvider.swift index 7f9f7a00c..385d41fba 100644 --- a/openHAB/Cells/Providers/ImageCellProvider.swift +++ b/openHAB/Cells/Providers/ImageCellProvider.swift @@ -14,13 +14,13 @@ import OpenHABCore import UIKit struct ImageCellProvider: WidgetCellProvider { - static var reuseIdentifier: String { "NewImageUITableViewCell" } + var reuseIdentifier: String { "NewImageUITableViewCell" } - static func dequeue(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { + func dequeue(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { tableView.dequeueReusableCell(for: indexPath) as NewImageUITableViewCell } - static func configure(cell: UITableViewCell, for widget: OpenHABWidget, controller: OpenHABSitemapViewController) { + func configure(cell: UITableViewCell, for widget: OpenHABWidget, controller: OpenHABSitemapViewController) { guard let cell = cell as? NewImageUITableViewCell else { return } cell.didLoad = { [weak controller] in controller?.updateWidgetTableView() diff --git a/openHAB/Cells/Providers/MapViewCellProvider.swift b/openHAB/Cells/Providers/MapViewCellProvider.swift index 27d023d8d..0f2667d9b 100644 --- a/openHAB/Cells/Providers/MapViewCellProvider.swift +++ b/openHAB/Cells/Providers/MapViewCellProvider.swift @@ -14,13 +14,13 @@ import OpenHABCore import UIKit struct MapViewCellProvider: WidgetCellProvider { - static var reuseIdentifier: String { "MapViewTableViewCell" } + var reuseIdentifier: String { "MapViewTableViewCell" } - static func dequeue(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { + func dequeue(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { tableView.dequeueReusableCell(for: indexPath) as MapViewTableViewCell } - static func configure(cell: UITableViewCell, for widget: OpenHABWidget, controller: OpenHABSitemapViewController) { + func configure(cell: UITableViewCell, for widget: OpenHABWidget, controller: OpenHABSitemapViewController) { guard let cell = cell as? MapViewTableViewCell else { return } cell.widget = widget cell.displayWidget() diff --git a/openHAB/Cells/Providers/RollerShutterCellProvider.swift b/openHAB/Cells/Providers/RollerShutterCellProvider.swift index b62dcdb7c..eadc25f29 100644 --- a/openHAB/Cells/Providers/RollerShutterCellProvider.swift +++ b/openHAB/Cells/Providers/RollerShutterCellProvider.swift @@ -14,13 +14,13 @@ import OpenHABCore import UIKit struct RollershutterCellProvider: WidgetCellProvider { - static var reuseIdentifier: String { "RollerShutterTableViewCell" } + var reuseIdentifier: String { "RollerShutterTableViewCell" } - static func dequeue(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { + func dequeue(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { tableView.dequeueReusableCell(for: indexPath) as RollershutterCell } - static func configure(cell: UITableViewCell, for widget: OpenHABWidget, controller: OpenHABSitemapViewController) { + func configure(cell: UITableViewCell, for widget: OpenHABWidget, controller: OpenHABSitemapViewController) { guard let cell = cell as? RollershutterCell else { return } cell.widget = widget cell.displayWidget() diff --git a/openHAB/Cells/Providers/SegmentedCellProvider.swift b/openHAB/Cells/Providers/SegmentedCellProvider.swift index 7f6b766c6..4a445dda0 100644 --- a/openHAB/Cells/Providers/SegmentedCellProvider.swift +++ b/openHAB/Cells/Providers/SegmentedCellProvider.swift @@ -14,13 +14,13 @@ import OpenHABCore import UIKit struct SegmentedCellProvider: WidgetCellProvider { - static var reuseIdentifier: String { "SegmentedUITableViewCell" } + var reuseIdentifier: String { "SegmentedUITableViewCell" } - static func dequeue(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { + func dequeue(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { tableView.dequeueReusableCell(for: indexPath) as SegmentedUITableViewCell } - static func configure(cell: UITableViewCell, for widget: OpenHABWidget, controller: OpenHABSitemapViewController) { + func configure(cell: UITableViewCell, for widget: OpenHABWidget, controller: OpenHABSitemapViewController) { guard let cell = cell as? SegmentedUITableViewCell else { return } cell.widget = widget cell.displayWidget() diff --git a/openHAB/Cells/Providers/SelectionCellProvider.swift b/openHAB/Cells/Providers/SelectionCellProvider.swift index 3ac46a763..bcdc58dff 100644 --- a/openHAB/Cells/Providers/SelectionCellProvider.swift +++ b/openHAB/Cells/Providers/SelectionCellProvider.swift @@ -14,13 +14,13 @@ import OpenHABCore import UIKit struct SelectionCellProvider: WidgetCellProvider { - static var reuseIdentifier: String { "SelectionUITableViewCell" } + var reuseIdentifier: String { "SelectionUITableViewCell" } - static func dequeue(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { + func dequeue(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { tableView.dequeueReusableCell(for: indexPath) as SelectionUITableViewCell } - static func configure(cell: UITableViewCell, for widget: OpenHABWidget, controller: OpenHABSitemapViewController) { + func configure(cell: UITableViewCell, for widget: OpenHABWidget, controller: OpenHABSitemapViewController) { guard let cell = cell as? SelectionUITableViewCell else { return } cell.widget = widget cell.displayWidget() diff --git a/openHAB/Cells/Providers/SetpointCellProvider.swift b/openHAB/Cells/Providers/SetpointCellProvider.swift index 6b55a6b94..14810d692 100644 --- a/openHAB/Cells/Providers/SetpointCellProvider.swift +++ b/openHAB/Cells/Providers/SetpointCellProvider.swift @@ -14,13 +14,13 @@ import OpenHABCore import UIKit struct SetpointCellProvider: WidgetCellProvider { - static var reuseIdentifier: String { "SetpointCell" } + var reuseIdentifier: String { "SetpointCell" } - static func dequeue(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { + func dequeue(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { tableView.dequeueReusableCell(for: indexPath) as SetpointCell } - static func configure(cell: UITableViewCell, for widget: OpenHABWidget, controller: OpenHABSitemapViewController) { + func configure(cell: UITableViewCell, for widget: OpenHABWidget, controller: OpenHABSitemapViewController) { guard let cell = cell as? SetpointCell else { return } cell.widget = widget cell.displayWidget() diff --git a/openHAB/Cells/Providers/SliderProvider.swift b/openHAB/Cells/Providers/SliderProvider.swift index a26952529..8d832c7de 100644 --- a/openHAB/Cells/Providers/SliderProvider.swift +++ b/openHAB/Cells/Providers/SliderProvider.swift @@ -14,13 +14,13 @@ import OpenHABCore import UIKit struct SliderProvider: WidgetCellProvider { - static var reuseIdentifier: String { "SliderUITableViewCell" } + var reuseIdentifier: String { "SliderUITableViewCell" } - static func dequeue(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { + func dequeue(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { tableView.dequeueReusableCell(for: indexPath) as SliderUITableViewCell } - static func configure(cell: UITableViewCell, for widget: OpenHABWidget, controller: OpenHABSitemapViewController) { + func configure(cell: UITableViewCell, for widget: OpenHABWidget, controller: OpenHABSitemapViewController) { guard let cell = cell as? SliderUITableViewCell else { return } cell.widget = widget cell.displayWidget() diff --git a/openHAB/Cells/Providers/SliderWithSwitchProvider.swift b/openHAB/Cells/Providers/SliderWithSwitchProvider.swift index 54ce5ecef..ae792779a 100644 --- a/openHAB/Cells/Providers/SliderWithSwitchProvider.swift +++ b/openHAB/Cells/Providers/SliderWithSwitchProvider.swift @@ -14,13 +14,13 @@ import OpenHABCore import UIKit struct SliderWithSwitchProvider: WidgetCellProvider { - static var reuseIdentifier: String { "SliderWithSwitchSupportUITableViewCell" } + var reuseIdentifier: String { "SliderWithSwitchSupportUITableViewCell" } - static func dequeue(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { + func dequeue(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { tableView.dequeueReusableCell(for: indexPath) as SliderWithSwitchSupportUITableViewCell } - static func configure(cell: UITableViewCell, for widget: OpenHABWidget, controller: OpenHABSitemapViewController) { + func configure(cell: UITableViewCell, for widget: OpenHABWidget, controller: OpenHABSitemapViewController) { guard let cell = cell as? SliderWithSwitchSupportUITableViewCell else { return } cell.widget = widget cell.displayWidget() diff --git a/openHAB/Cells/Providers/SwitchCellProvider.swift b/openHAB/Cells/Providers/SwitchCellProvider.swift index 9c979741f..ea990eb3f 100644 --- a/openHAB/Cells/Providers/SwitchCellProvider.swift +++ b/openHAB/Cells/Providers/SwitchCellProvider.swift @@ -14,13 +14,13 @@ import OpenHABCore import UIKit struct SwitchCellProvider: WidgetCellProvider { - static var reuseIdentifier: String { "SwitchUITableViewCell" } + var reuseIdentifier: String { "SwitchUITableViewCell" } - static func dequeue(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { + func dequeue(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { tableView.dequeueReusableCell(for: indexPath) as SwitchUITableViewCell } - static func configure(cell: UITableViewCell, for widget: OpenHABWidget, controller: OpenHABSitemapViewController) { + func configure(cell: UITableViewCell, for widget: OpenHABWidget, controller: OpenHABSitemapViewController) { guard let cell = cell as? SwitchUITableViewCell else { return } cell.widget = widget cell.displayWidget() diff --git a/openHAB/Cells/Providers/TextInputProvider.swift b/openHAB/Cells/Providers/TextInputProvider.swift index fab79045c..f9656a685 100644 --- a/openHAB/Cells/Providers/TextInputProvider.swift +++ b/openHAB/Cells/Providers/TextInputProvider.swift @@ -14,13 +14,13 @@ import OpenHABCore import UIKit struct TextInputProvider: WidgetCellProvider { - static var reuseIdentifier: String { "TextInputUITableViewCell" } + var reuseIdentifier: String { "TextInputUITableViewCell" } - static func dequeue(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { + func dequeue(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { tableView.dequeueReusableCell(for: indexPath) as TextInputUITableViewCell } - static func configure(cell: UITableViewCell, for widget: OpenHABWidget, controller: OpenHABSitemapViewController) { + func configure(cell: UITableViewCell, for widget: OpenHABWidget, controller: OpenHABSitemapViewController) { guard let cell = cell as? TextInputUITableViewCell else { return } cell.widget = widget cell.displayWidget() diff --git a/openHAB/Cells/Providers/VideoCellProvider.swift b/openHAB/Cells/Providers/VideoCellProvider.swift index b2ffb7c5f..dce36d322 100644 --- a/openHAB/Cells/Providers/VideoCellProvider.swift +++ b/openHAB/Cells/Providers/VideoCellProvider.swift @@ -14,13 +14,13 @@ import OpenHABCore import UIKit struct VideoCellProvider: WidgetCellProvider { - static var reuseIdentifier: String { "VideoUITableViewCell" } + var reuseIdentifier: String { "VideoUITableViewCell" } - static func dequeue(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { + func dequeue(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { tableView.dequeueReusableCell(for: indexPath) as VideoUITableViewCell } - static func configure(cell: UITableViewCell, for widget: OpenHABWidget, controller: OpenHABSitemapViewController) { + func configure(cell: UITableViewCell, for widget: OpenHABWidget, controller: OpenHABSitemapViewController) { guard let cell = cell as? VideoUITableViewCell else { return } cell.widget = widget cell.displayWidget() diff --git a/openHAB/Cells/Providers/WebViewCellProvider.swift b/openHAB/Cells/Providers/WebViewCellProvider.swift index e45fd6513..7d9204846 100644 --- a/openHAB/Cells/Providers/WebViewCellProvider.swift +++ b/openHAB/Cells/Providers/WebViewCellProvider.swift @@ -14,13 +14,13 @@ import OpenHABCore import UIKit struct WebViewCellProvider: WidgetCellProvider { - static var reuseIdentifier: String { "WebUITableViewCell" } + var reuseIdentifier: String { "WebUITableViewCell" } - static func dequeue(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { + func dequeue(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { tableView.dequeueReusableCell(for: indexPath) as WebUITableViewCell } - static func configure(cell: UITableViewCell, for widget: OpenHABWidget, controller: OpenHABSitemapViewController) { + func configure(cell: UITableViewCell, for widget: OpenHABWidget, controller: OpenHABSitemapViewController) { guard let cell = cell as? WebUITableViewCell else { return } cell.widget = widget cell.displayWidget() diff --git a/openHAB/Cells/WidgetCellProvider.swift b/openHAB/Cells/WidgetCellProvider.swift index b2ed1911b..9847cf119 100644 --- a/openHAB/Cells/WidgetCellProvider.swift +++ b/openHAB/Cells/WidgetCellProvider.swift @@ -13,45 +13,49 @@ import Foundation import OpenHABCore import UIKit -protocol WidgetCellProvider { - static var reuseIdentifier: String { get } - static func dequeue(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell - static func configure(cell: UITableViewCell, for widget: OpenHABWidget, controller: OpenHABSitemapViewController) -} - enum WidgetCellFactory { - static func provider(for widget: OpenHABWidget) -> WidgetCellProvider.Type { + static func provider(for widget: OpenHABWidget) -> WidgetCellProvider { switch widget.type { case .switchWidget: if !widget.mappings.isEmpty { - SegmentedCellProvider.self // 🔥 Done + SegmentedCellProvider() } else if widget.item?.isOfTypeOrGroupType(.switchItem) ?? false { - SwitchCellProvider.self // 🔥 Done + SwitchCellProvider() } else if widget.item?.isOfTypeOrGroupType(.rollershutter) ?? false { - RollershutterCellProvider.self // 🔥 Done + RollershutterCellProvider() } else if !widget.mappingsOrItemOptions.isEmpty { - SegmentedCellProvider.self + SegmentedCellProvider() } else { - SwitchCellProvider.self + SwitchCellProvider() } case .slider: - widget.switchSupport ? SliderWithSwitchProvider.self : SliderProvider.self // 🔥 Done + if widget.switchSupport { + SliderWithSwitchProvider() + } else { + SliderProvider() + } case .input: if [.date, .time, .datetime].contains(widget.inputHint) { - DatePickerInputProvider.self // 🔥 Done + DatePickerInputProvider() } else { - TextInputProvider.self // 🔥 Done + TextInputProvider() } - case .frame: FrameCellProvider.self // 🔥 Done - case .setpoint: SetpointCellProvider.self // 🔥 Done - case .selection: SelectionCellProvider.self // 🔥 Done - case .colorpicker: ColorPickerCellProvider.self // 🔥 Done - case .image, .chart: ImageCellProvider.self // 🔥 Done - case .video: VideoCellProvider.self // 🔥 Done - case .webview: WebViewCellProvider.self // 🔥 Done - case .mapview: MapViewCellProvider.self // 🔥 Done + case .frame: FrameCellProvider() + case .setpoint: SetpointCellProvider() + case .selection: SelectionCellProvider() + case .colorpicker: ColorPickerCellProvider() + case .image, .chart: ImageCellProvider() + case .video: VideoCellProvider() + case .webview: WebViewCellProvider() + case .mapview: MapViewCellProvider() case .group, .text, .defaultWidget, .unknown: - GenericCellProvider.self // 🔥 Done + GenericCellProvider() } } } + +protocol WidgetCellProvider { + var reuseIdentifier: String { get } + @MainActor func dequeue(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell + @MainActor func configure(cell: UITableViewCell, for widget: OpenHABWidget, controller: OpenHABSitemapViewController) +} diff --git a/openHAB/OpenHABSitemapViewController.swift b/openHAB/OpenHABSitemapViewController.swift index 595149c8c..6e82ffeef 100644 --- a/openHAB/OpenHABSitemapViewController.swift +++ b/openHAB/OpenHABSitemapViewController.swift @@ -704,11 +704,9 @@ extension OpenHABSitemapViewController: UITableViewDelegate, UITableViewDataSour return cell } - let cell = WidgetCellFactory.provider(for: widget) - .dequeue(from: tableView, at: indexPath) - - WidgetCellFactory.provider(for: widget) - .configure(cell: cell, for: widget, controller: self) + let provider = WidgetCellFactory.provider(for: widget) + let cell = provider.dequeue(from: tableView, at: indexPath) + provider.configure(cell: cell, for: widget, controller: self) var iconColor = widget.iconColor if iconColor.isEmpty, traitCollection.userInterfaceStyle == .dark { @@ -932,20 +930,14 @@ extension OpenHABSitemapViewController: UITextFieldDelegate { // MARK: Kingfisher authentication with NSURLCredential extension OpenHABSitemapViewController: AuthenticationChallengeResponsible { - // sessionDelegate.onReceiveSessionTaskChallenge - nonisolated func downloader(_ downloader: ImageDownloader, - task: URLSessionTask, - didReceive challenge: URLAuthenticationChallenge, - completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { - let (disposition, credential) = onReceiveSessionTaskChallenge(with: challenge) - completionHandler(disposition, credential) - } - - // sessionDelegate.onReceiveSessionChallenge - nonisolated func downloader(_ downloader: ImageDownloader, - didReceive challenge: URLAuthenticationChallenge, - completionHandler: (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { - let (disposition, credential) = onReceiveSessionChallenge(with: challenge) - completionHandler(disposition, credential) + func downloader(_ downloader: ImageDownloader, + didReceive challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { + onReceiveSessionChallenge(with: challenge) + } + + func downloader(_ downloader: ImageDownloader, + task: URLSessionTask, + didReceive challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { + onReceiveSessionTaskChallenge(with: challenge) } } From d97cc224621af9be8a72d87e2722596535a10e45 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Sat, 29 Mar 2025 17:29:33 +0100 Subject: [PATCH 114/476] Reworked ServerCertificateManager: Replace blocking threads via evaluateResultSemaphore.wait() with async/await Updated delegate to support async Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../OpenHABCore/Util/NetworkConnection.swift | 82 +++++++++- .../Util/ServerCertificateManager.swift | 145 ++++++++---------- openHAB/OpenHABSitemapViewController.swift | 2 +- openHAB/OpenHABViewController.swift | 45 ++++-- openHAB/OpenHABWebViewController.swift | 2 +- openHAB/WebUITableViewCell.swift | 2 +- .../OpenHABImageDownloaderOperation.swift | 6 +- 7 files changed, 184 insertions(+), 100 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/NetworkConnection.swift b/OpenHABCore/Sources/OpenHABCore/Util/NetworkConnection.swift index 0122fd69e..43c9f6e85 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/NetworkConnection.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/NetworkConnection.swift @@ -31,7 +31,7 @@ public func onReceiveSessionTaskChallenge(with challenge: URLAuthenticationChall return (disposition, credential) } -public func onReceiveSessionChallenge(with challenge: URLAuthenticationChallenge) -> (URLSession.AuthChallengeDisposition, URLCredential?) { +public func onReceiveSessionChallenge(with challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { os_log("onReceiveSessionChallenge host:'%{PUBLIC}@'", log: .default, type: .info, challenge.protectionSpace.host) var disposition: URLSession.AuthChallengeDisposition = .performDefaultHandling var credential: URLCredential? @@ -39,7 +39,7 @@ public func onReceiveSessionChallenge(with challenge: URLAuthenticationChallenge switch challenge.protectionSpace.authenticationMethod { case NSURLAuthenticationMethodServerTrust: // TODO: - return NetworkTracker.shared.serverCertificateManager.evaluateTrust(with: challenge) + return await NetworkTracker.shared.serverCertificateManager.evaluateTrust(with: challenge) case NSURLAuthenticationMethodClientCertificate: return NetworkTracker.shared.clientCertificateManager.evaluateTrust(with: challenge) // attemptCredentialAuthentication @@ -55,3 +55,81 @@ public func onReceiveSessionChallenge(with challenge: URLAuthenticationChallenge return (disposition, credential) } } + +import Foundation +import os + +final class SessionChallengeHandler { + private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "SessionChallengeHandler", category: "Auth") + + private let username: String + private let password: String + private let localUrl: URL? + private let remoteUrl: URL? + + private let clientCertEvaluator: ((URLAuthenticationChallenge) -> (URLSession.AuthChallengeDisposition, URLCredential?))? + private let serverTrustEvaluator: ((URLAuthenticationChallenge) -> (URLSession.AuthChallengeDisposition, URLCredential?))? + + init(username: String, + password: String, + localUrl: URL?, + remoteUrl: URL?, + serverTrustEvaluator: ((URLAuthenticationChallenge) -> (URLSession.AuthChallengeDisposition, URLCredential?))? = nil, + clientCertEvaluator: ((URLAuthenticationChallenge) -> (URLSession.AuthChallengeDisposition, URLCredential?))? = nil) { + self.username = username + self.password = password + self.localUrl = localUrl + self.remoteUrl = remoteUrl + self.serverTrustEvaluator = serverTrustEvaluator + self.clientCertEvaluator = clientCertEvaluator + } + + func handleSessionTaskChallenge(_ challenge: URLAuthenticationChallenge) -> (URLSession.AuthChallengeDisposition, URLCredential?) { + logger.debug("SessionTaskChallenge host: \(challenge.protectionSpace.host, privacy: .public)") + + if challenge.previousFailureCount > 0 { + return (.cancelAuthenticationChallenge, nil) + } + + let authMethod = challenge.protectionSpace.authenticationMethod + if authMethod == NSURLAuthenticationMethodHTTPBasic || authMethod == NSURLAuthenticationMethodDefault { + if isTrustedHost(challenge.protectionSpace.host) { + let credential = URLCredential(user: username, password: password, persistence: .forSession) + logger.debug("Using HTTP BasicAuth for host: \(challenge.protectionSpace.host, privacy: .public)") + return (.useCredential, credential) + } + } + + return (.performDefaultHandling, nil) + } + + func handleSessionChallenge(_ challenge: URLAuthenticationChallenge) -> (URLSession.AuthChallengeDisposition, URLCredential?) { + logger.debug("SessionChallenge host: \(challenge.protectionSpace.host, privacy: .public)") + + if challenge.previousFailureCount > 0 { + return (.cancelAuthenticationChallenge, nil) + } + + switch challenge.protectionSpace.authenticationMethod { + case NSURLAuthenticationMethodServerTrust: + if let serverTrustEvaluator { + return serverTrustEvaluator(challenge) + } + case NSURLAuthenticationMethodClientCertificate: + if let clientCertEvaluator { + return clientCertEvaluator(challenge) + } + default: + // Try using stored credential if available + if let credential = URLCredentialStorage.shared.defaultCredential(for: challenge.protectionSpace) { + return (.useCredential, credential) + } + } + + return (.performDefaultHandling, nil) + } + + private func isTrustedHost(_ host: String) -> Bool { + host == localUrl?.host || host == remoteUrl?.host || host == "home.myopenhab.org" + } +} diff --git a/OpenHABCore/Sources/OpenHABCore/Util/ServerCertificateManager.swift b/OpenHABCore/Sources/OpenHABCore/Util/ServerCertificateManager.swift index bd93c6e47..01f54e22c 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/ServerCertificateManager.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/ServerCertificateManager.swift @@ -12,13 +12,22 @@ import Foundation import os.log -public protocol ServerCertificateManagerDelegate: NSObjectProtocol { +// public protocol ServerCertificateManagerDelegate: NSObjectProtocol { +// // delegate should ask user for a decision on what to do with invalid certificate +// func evaluateServerTrust(_ policy: ServerCertificateManager?, summary certificateSummary: String?, forDomain domain: String?) +// // certificate received from openHAB doesn't match our record, ask user for a decision +// func evaluateCertificateMismatch(_ policy: ServerCertificateManager?, summary certificateSummary: String?, forDomain domain: String?) +// // notify delegate that the certificagtes that a user is willing to trust has changed +// func acceptedServerCertificatesChanged(_ policy: ServerCertificateManager?) +// } + +public protocol ServerCertificateManagerDelegate: AnyObject { // delegate should ask user for a decision on what to do with invalid certificate - func evaluateServerTrust(_ policy: ServerCertificateManager?, summary certificateSummary: String?, forDomain domain: String?) + func evaluateServerTrust(summary certificateSummary: String?, forDomain domain: String?) async -> ServerCertificateManager.EvaluateResult // certificate received from openHAB doesn't match our record, ask user for a decision - func evaluateCertificateMismatch(_ policy: ServerCertificateManager?, summary certificateSummary: String?, forDomain domain: String?) + func evaluateCertificateMismatch(summary certificateSummary: String?, forDomain domain: String?) async -> ServerCertificateManager.EvaluateResult // notify delegate that the certificagtes that a user is willing to trust has changed - func acceptedServerCertificatesChanged(_ policy: ServerCertificateManager?) + func acceptedServerCertificatesChanged() } enum ServerCertificateManagerError: Error { @@ -27,22 +36,20 @@ enum ServerCertificateManagerError: Error { public class ServerCertificateManager { // ServerTrustManager, ServerTrustEvaluating { // Handle the different responses of the user - public enum EvaluateResult { + public enum EvaluateResult: Sendable { case undecided case deny case permitOnce case permitAlways } - public var evaluateResult: EvaluateResult = .undecided { - didSet { - if evaluateResult != .undecided { - evaluateResultSemaphore.signal() - } - } - } - - private let evaluateResultSemaphore = DispatchSemaphore(value: 0) +// public var evaluateResult: EvaluateResult = .undecided { +// didSet { +// if evaluateResult != .undecided { +// evaluateResultSemaphore.signal() +// } +// } +// } weak var delegate: ServerCertificateManagerDelegate? // ignoreSSL is a synonym for allowInvalidCertificates, ignoreCertificates @@ -119,10 +126,10 @@ public class ServerCertificateManager { // ServerTrustManager, ServerTrustEvalua } } - func evaluateTrust(with challenge: URLAuthenticationChallenge) -> (URLSession.AuthChallengeDisposition, URLCredential?) { + func evaluateTrust(with challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { do { let serverTrust = challenge.protectionSpace.serverTrust! - try evaluate(serverTrust, forHost: challenge.protectionSpace.host) + try await evaluate(serverTrust, forHost: challenge.protectionSpace.host) return (.useCredential, URLCredential(trust: serverTrust)) } catch { return (.cancelAuthenticationChallenge, nil) @@ -141,79 +148,57 @@ public class ServerCertificateManager { // ServerTrustManager, ServerTrustEvalua return result } - public func evaluate(_ serverTrust: SecTrust, forHost domain: String) throws { - // Evaluates trust received during SSL negotiation and checks it against known ones, - // against policy setting to ignore certificate errors and so on. + // Evaluates trust received during SSL negotiation and checks it against known ones, + // against policy setting to ignore certificate errors and so on. + public func evaluate(_ serverTrust: SecTrust, forHost domain: String) async throws { let evaluateResult = wrapperSecTrustEvaluate(serverTrust: serverTrust) + // This means that system thinks this is a legal/usable certificate, just permit the connection if evaluateResult.isAny(of: .unspecified, .proceed) || ignoreSSL { - // This means system thinks this is a legal/usable certificate, just permit the connection return } - let certificate = getLeafCertificate(trust: serverTrust) - let certificateSummary = SecCertificateCopySubjectSummary(certificate!) - let certificateData = SecCertificateCopyData(certificate!) + + guard let certificate = getLeafCertificate(trust: serverTrust) else { + throw ServerCertificateManagerError.serverTrustEvaluationFailed + } + + let certificateSummary = SecCertificateCopySubjectSummary(certificate) + let certificateData = SecCertificateCopyData(certificate) + // If we have a certificate for this domain // Obtain certificate we have and compare it with the certificate presented by the server - if let previousCertificateData = self.certificateData(forDomain: domain) { - if CFEqual(previousCertificateData, certificateData) { - // If certificate matched one in our store - permit this connection - return - } else { - // We have a certificate for this domain in our memory of decisions, but the certificate we've got now - // differs. We need to warn user about possible MiM attack and wait for users decision. - // TODO: notify user and wait for decision - if let delegate { - self.evaluateResult = .undecided - delegate.evaluateCertificateMismatch(self, summary: certificateSummary as String?, forDomain: domain) - evaluateResultSemaphore.wait() - switch self.evaluateResult { - case .deny: - // User decided to abort connection - throw ServerCertificateManagerError.serverTrustEvaluationFailed - case .permitOnce: - // User decided to accept invalid certificate once - return - case .permitAlways: - // User decided to accept invalid certificate and remember decision - // Add certificate to storage - storeCertificateData(certificateData, forDomain: domain) - delegate.acceptedServerCertificatesChanged(self) - return - case .undecided: - // Something went wrong, abort connection - throw ServerCertificateManagerError.serverTrustEvaluationFailed - } - } - throw ServerCertificateManagerError.serverTrustEvaluationFailed - } + if let previousData = self.certificateData(forDomain: domain), CFEqual(previousData, certificateData) { + // If certificate matched one in our store - permit this connection + return // trusted } - // Warn user about invalid certificate and wait for user's decision - if let delegate { - // Delegate should ask user for decision - self.evaluateResult = .undecided - delegate.evaluateServerTrust(self, summary: certificateSummary as String?, forDomain: domain) - // Wait until we get response from delegate with user's decision - evaluateResultSemaphore.wait() - switch self.evaluateResult { - case .deny: - // User decided to abort connection - throw ServerCertificateManagerError.serverTrustEvaluationFailed - case .permitOnce: - // User decided to accept invalid certificate once - return - case .permitAlways: - // User decided to accept invalid certificate and remember decision - // Add certificate to storage - storeCertificateData(certificateData, forDomain: domain) - delegate.acceptedServerCertificatesChanged(self) - return - case .undecided: - throw ServerCertificateManagerError.serverTrustEvaluationFailed - } + + guard let delegate else { + throw ServerCertificateManagerError.serverTrustEvaluationFailed + } + + let decision: EvaluateResult = if self.certificateData(forDomain: domain) != nil { + // mismatch, we have a certificate for this domain in our memory of decisions, but the certificate we've got now + // differs. We need to warn user about possible MiM attack and wait for users decision. + await delegate.evaluateCertificateMismatch(summary: certificateSummary as String?, forDomain: domain) + } else { + // new untrusted cert, warn user about invalid certificate and wait for user's decision + await delegate.evaluateServerTrust(summary: certificateSummary as String?, forDomain: domain) + } + + switch decision { + case .deny, .undecided: + // User decided to abort connection or something went wrong, abort connection + throw ServerCertificateManagerError.serverTrustEvaluationFailed + case .permitOnce: + // User decided to accept invalid certificate once + return + case .permitAlways: + // User decided to accept invalid certificate and remember decision + // Add certificate to storage + storeCertificateData(certificateData, forDomain: domain) + delegate.acceptedServerCertificatesChanged() + return } - // We have no way of handling it so no access! - throw ServerCertificateManagerError.serverTrustEvaluationFailed } func getLeafCertificate(trust: SecTrust?) -> SecCertificate? { diff --git a/openHAB/OpenHABSitemapViewController.swift b/openHAB/OpenHABSitemapViewController.swift index 6e82ffeef..f974eb1ee 100644 --- a/openHAB/OpenHABSitemapViewController.swift +++ b/openHAB/OpenHABSitemapViewController.swift @@ -932,7 +932,7 @@ extension OpenHABSitemapViewController: UITextFieldDelegate { extension OpenHABSitemapViewController: AuthenticationChallengeResponsible { func downloader(_ downloader: ImageDownloader, didReceive challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { - onReceiveSessionChallenge(with: challenge) + await onReceiveSessionChallenge(with: challenge) } func downloader(_ downloader: ImageDownloader, diff --git a/openHAB/OpenHABViewController.swift b/openHAB/OpenHABViewController.swift index bc6154ded..38522662d 100644 --- a/openHAB/OpenHABViewController.swift +++ b/openHAB/OpenHABViewController.swift @@ -78,32 +78,51 @@ class OpenHABViewController: UIViewController { extension OpenHABViewController: ServerCertificateManagerDelegate { // delegate should ask user for a decision on what to do with invalid certificate - func evaluateServerTrust(_ policy: ServerCertificateManager?, summary certificateSummary: String?, forDomain domain: String?) { - DispatchQueue.main.async { + @MainActor + func evaluateServerTrust(summary certificateSummary: String?, forDomain domain: String?) async -> ServerCertificateManager.EvaluateResult { + await withCheckedContinuation { continuation in let title = NSLocalizedString("ssl_certificate_warning", comment: "") let message = String(format: NSLocalizedString("ssl_certificate_invalid", comment: ""), certificateSummary ?? "", domain ?? "") let alertView = UIAlertController(title: title, message: message, preferredStyle: .alert) - alertView.addAction(UIAlertAction(title: NSLocalizedString("abort", comment: ""), style: .default) { _ in policy?.evaluateResult = .deny }) - alertView.addAction(UIAlertAction(title: NSLocalizedString("once", comment: ""), style: .default) { _ in policy?.evaluateResult = .permitOnce }) - alertView.addAction(UIAlertAction(title: NSLocalizedString("always", comment: ""), style: .default) { _ in policy?.evaluateResult = .permitAlways }) - self.present(alertView, animated: true) {} + + alertView.addAction(UIAlertAction(title: NSLocalizedString("abort", comment: ""), style: .default) { _ in + continuation.resume(returning: .deny) + }) + alertView.addAction(UIAlertAction(title: NSLocalizedString("once", comment: ""), style: .default) { _ in + continuation.resume(returning: .permitOnce) + }) + alertView.addAction(UIAlertAction(title: NSLocalizedString("always", comment: ""), style: .default) { _ in + continuation.resume(returning: .permitAlways) + }) + + self.present(alertView, animated: true, completion: nil) } } // certificate received from openHAB doesn't match our record, ask user for a decision - func evaluateCertificateMismatch(_ policy: ServerCertificateManager?, summary certificateSummary: String?, forDomain domain: String?) { - DispatchQueue.main.async { + @MainActor + func evaluateCertificateMismatch(summary certificateSummary: String?, forDomain domain: String?) async -> OpenHABCore.ServerCertificateManager.EvaluateResult { + await withCheckedContinuation { continuation in let title = NSLocalizedString("ssl_certificate_warning", comment: "") let message = String(format: NSLocalizedString("ssl_certificate_no_match", comment: ""), certificateSummary ?? "", domain ?? "") let alertView = UIAlertController(title: title, message: message, preferredStyle: .alert) - alertView.addAction(UIAlertAction(title: NSLocalizedString("abort", comment: ""), style: .default) { _ in policy?.evaluateResult = .deny }) - alertView.addAction(UIAlertAction(title: NSLocalizedString("once", comment: ""), style: .default) { _ in policy?.evaluateResult = .permitOnce }) - alertView.addAction(UIAlertAction(title: NSLocalizedString("always", comment: ""), style: .default) { _ in policy?.evaluateResult = .permitAlways }) - self.present(alertView, animated: true) {} + + alertView.addAction(UIAlertAction(title: NSLocalizedString("abort", comment: ""), style: .default) { _ in + continuation.resume(returning: .deny) + }) + alertView.addAction(UIAlertAction(title: NSLocalizedString("once", comment: ""), style: .default) { _ in + continuation.resume(returning: .permitOnce) + }) + alertView.addAction(UIAlertAction(title: NSLocalizedString("always", comment: ""), style: .default) { _ in + continuation.resume(returning: .permitAlways) + }) + + self.present(alertView, animated: true, completion: nil) } } - func acceptedServerCertificatesChanged(_ policy: ServerCertificateManager?) { + @MainActor + func acceptedServerCertificatesChanged() { // User's decision about trusting server certificates has changed. Send updates to the paired watch. WatchMessageService.singleton.syncPreferencesToWatch() } diff --git a/openHAB/OpenHABWebViewController.swift b/openHAB/OpenHABWebViewController.swift index 980cc1977..c7cdbf7d8 100644 --- a/openHAB/OpenHABWebViewController.swift +++ b/openHAB/OpenHABWebViewController.swift @@ -377,7 +377,7 @@ extension OpenHABWebViewController: WKNavigationDelegate { if challenge.protectionSpace.authenticationMethod.isAny(of: NSURLAuthenticationMethodHTTPBasic, NSURLAuthenticationMethodDefault) { return onReceiveSessionTaskChallenge(with: challenge) } else { - return onReceiveSessionChallenge(with: challenge) + return await onReceiveSessionChallenge(with: challenge) } } } diff --git a/openHAB/WebUITableViewCell.swift b/openHAB/WebUITableViewCell.swift index 3f57188db..49305e355 100644 --- a/openHAB/WebUITableViewCell.swift +++ b/openHAB/WebUITableViewCell.swift @@ -109,7 +109,7 @@ extension WebUITableViewCell: WKNavigationDelegate { // Signature changed on transfer from completion handler to async / from didRecieve to respondTo func webView(_ webView: WKWebView, respondTo challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { - onReceiveSessionChallenge(with: challenge) + await onReceiveSessionChallenge(with: challenge) } } diff --git a/openHABWatch/Extension/OpenHABImageDownloaderOperation.swift b/openHABWatch/Extension/OpenHABImageDownloaderOperation.swift index 840b11f6e..ea554cb4e 100644 --- a/openHABWatch/Extension/OpenHABImageDownloaderOperation.swift +++ b/openHABWatch/Extension/OpenHABImageDownloaderOperation.swift @@ -15,8 +15,10 @@ import SDWebImage class OpenHABImageDownloaderOperation: SDWebImageDownloaderOperation, @unchecked Sendable { override func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { - let (disposition, credential) = onReceiveSessionChallenge(with: challenge) - completionHandler(disposition, credential) + Task { + let (disposition, credential) = await onReceiveSessionChallenge(with: challenge) + completionHandler(disposition, credential) + } } override func urlSession(_ session: URLSession, task: URLSessionTask, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { From 84ca2f42be584d748baa9473238a257eb017c17d Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Sat, 29 Mar 2025 19:28:56 +0100 Subject: [PATCH 115/476] Reworked ClientCertificateManager Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../Util/ClientCertificateManager.swift | 170 +++++++++++------- .../Util/ServerCertificateManager.swift | 18 -- .../ClientCertificateManagerTests.swift | 105 +++++++++++ .../Tests/OpenHABCoreTests/Resources/test.p12 | Bin 0 -> 2531 bytes cert.pem | 19 ++ key.pem | 28 +++ openHAB/OpenHABViewController.swift | 81 +++++---- .../Extension/OpenHABWatchAppDelegate.swift | 12 +- test.p12 | Bin 0 -> 2531 bytes 9 files changed, 318 insertions(+), 115 deletions(-) create mode 100644 OpenHABCore/Tests/OpenHABCoreTests/ClientCertificateManagerTests.swift create mode 100644 OpenHABCore/Tests/OpenHABCoreTests/Resources/test.p12 create mode 100644 cert.pem create mode 100644 key.pem create mode 100644 test.p12 diff --git a/OpenHABCore/Sources/OpenHABCore/Util/ClientCertificateManager.swift b/OpenHABCore/Sources/OpenHABCore/Util/ClientCertificateManager.swift index 40ae0f932..2cc135a7d 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/ClientCertificateManager.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/ClientCertificateManager.swift @@ -13,25 +13,36 @@ import Foundation import os.log import Security +// public protocol ClientCertificateManagerDelegate: AnyObject { +// // delegate should ask user for a decision on whether to import the client certificate into the keychain +// func askForClientCertificateImport(_ clientCertificateManager: ClientCertificateManager?) +// // delegate should ask user for a decision on whether to import the client certificate into the keychain +// func askForCertificatePassword(_ clientCertificateManager: ClientCertificateManager?) +// // delegate should alert the user that an error occured importing the certificate +// func alertClientCertificateError(_ clientCertificateManager: ClientCertificateManager?, errMsg: String) +// } + public protocol ClientCertificateManagerDelegate: AnyObject { // delegate should ask user for a decision on whether to import the client certificate into the keychain - func askForClientCertificateImport(_ clientCertificateManager: ClientCertificateManager?) - // delegate should ask user for the export password used to decode the PKCS#12 - func askForCertificatePassword(_ clientCertificateManager: ClientCertificateManager?) + func askForClientCertificateImport() async -> Bool + // delegate should ask user for a decision on whether to import the client certificate into the keychain + func askForCertificatePassword() async -> String? // delegate should alert the user that an error occured importing the certificate - func alertClientCertificateError(_ clientCertificateManager: ClientCertificateManager?, errMsg: String) + func alertClientCertificateError(errMsg: String) async } public class ClientCertificateManager { - private var importingRawCert: Data? - private var importingIdentity: SecIdentity? + public var importingRawCert: Data? + public var importingIdentity: SecIdentity? private var importingCertChain: [SecCertificate]? - private var importingPassword: String? + public var importingPassword: String? weak var delegate: ClientCertificateManagerDelegate? public var clientIdentities: [SecIdentity] = [] + private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "ClientCertificateManager", category: "ClientCert") + init() { loadFromKeychain() } @@ -148,35 +159,39 @@ public class ClientCertificateManager { return refCounts } - public func startImportClientCertificate(url: URL) -> Bool { + public func startImportClientCertificate(url: URL) async -> Bool { do { // Import PKCS12 client cert importingRawCert = try Data(contentsOf: url) - if let delegate { - delegate.askForClientCertificateImport(self) - } else { - return false - } + guard let delegate else { return false } + let shouldImport = await delegate.askForClientCertificateImport() + return shouldImport } catch { - os_log("Unable to read certificate from URL", log: .default, type: .info) + logger.error("Failed to read certificate from URL: \(error.localizedDescription)") return false } - return true } - public func clientCertificateAccepted(password: String?) { - // Import PKCS12 client cert + @MainActor + public func clientCertificateAccepted(password: String) async { importingPassword = password let status = decodePKCS12() + switch status { case noErr: - addClientCertificateToKeychain() + await addClientCertificateToKeychain() + case errSecAuthFailed: - delegate?.askForCertificatePassword(self) + guard let retryPassword = await delegate?.askForCertificatePassword() else { + logger.warning("Password prompt cancelled after auth failure") + return + } + await clientCertificateAccepted(password: retryPassword) + default: let errMsg = String(format: NSLocalizedString("unable_to_decode_certificate", comment: ""), "\(status)") - delegate?.alertClientCertificateError(self, errMsg: errMsg) + await delegate?.alertClientCertificateError(errMsg: errMsg) } } @@ -186,65 +201,90 @@ public class ClientCertificateManager { importingPassword = nil } - func addClientCertificateToKeychain() { + func addClientCertificateToKeychain() async { + guard let identity = importingIdentity else { + logger.error("No identity available to import") + return + } + var clientCert: SecCertificate? var clientKey: SecKey? - SecIdentityCopyPrivateKey(importingIdentity!, &clientKey) - SecIdentityCopyCertificate(importingIdentity!, &clientCert) + SecIdentityCopyCertificate(identity, &clientCert) + SecIdentityCopyPrivateKey(identity, &clientKey) + + guard let cert = clientCert, let key = clientKey else { + logger.error("Failed to extract cert or key from identity") + return + } - // Add identity's cert - let addCertQuery: [String: Any] = [ + let certAddQuery: [String: Any] = [ kSecClass as String: kSecClassCertificate, - kSecValueRef as String: clientCert! + kSecValueRef as String: cert ] - var status = SecItemAdd(addCertQuery as NSDictionary, nil) - os_log("SecItemAdd(cert) result=%{PUBLIC}d", log: .default, type: .info, status) - if status == noErr { - let addKeyQuery: [String: Any] = [ + + var status = SecItemAdd(certAddQuery as CFDictionary, nil) + logger.info("SecItemAdd(cert) result=\(status)") + + if status == errSecDuplicateItem { + logger.warning("Certificate already exists in Keychain") + status = noErr // Treat as success, do not trigger error path later + } + + if status == errSecSuccess { + let keyAddQuery: [String: Any] = [ kSecClass as String: kSecClassKey, kSecAttrIsPermanent as String: true, - kSecValueRef as String: clientKey! + kSecValueRef as String: key ] - status = SecItemAdd(addKeyQuery as NSDictionary, nil) - os_log("SecItemAdd(key) result=%{PUBLIC}d", log: .default, type: .info, status) - // Add the cert chain - if let importingCertChain { - for cert in importingCertChain where cert != clientCert { - let addCertQuery: [String: Any] = [ + status = SecItemAdd(keyAddQuery as CFDictionary, nil) + logger.info("SecItemAdd(key) result=\(status)") + + if let certChain = importingCertChain { + for chainCert in certChain where chainCert != cert { + let chainCertQuery: [String: Any] = [ kSecClass as String: kSecClassCertificate, - kSecValueRef as String: cert + kSecValueRef as String: chainCert ] - status = SecItemAdd(addCertQuery as NSDictionary, nil) - os_log("SecItemAdd(certChain) result=%{PUBLIC}d", log: .default, type: .info, status) - if status == errSecDuplicateItem { - // Ignore duplicates as there may already be other client certs with an overlapping issuer chain - status = noErr - } else if status != noErr { + let chainStatus = SecItemAdd(chainCertQuery as CFDictionary, nil) + logger.info("SecItemAdd(certChain) result=\(chainStatus)") + + if chainStatus == errSecDuplicateItem { + logger.info("Cert chain item already exists; skipping") + + continue // Ignore duplicates + } else if chainStatus != errSecSuccess { + status = chainStatus break } } } } - // Refresh identities from the keychain + // Refresh the list of client identities loadFromKeychain() - if status != noErr { - _ = deleteFromKeychain(importingIdentity!) + if status != errSecSuccess { + _ = deleteFromKeychain(identity) - var errMsg = String(format: NSLocalizedString("unable_to_add_certificate", comment: ""), "\(status)") + var errorMessage = String(format: NSLocalizedString("unable_to_add_certificate", comment: ""), "\(status)") if status == errSecDuplicateItem { - errMsg = NSLocalizedString("certficate_exists", comment: "") + errorMessage = NSLocalizedString("certficate_exists", comment: "") } - delegate?.alertClientCertificateError(self, errMsg: errMsg) + + await delegate?.alertClientCertificateError(errMsg: errorMessage) } } - private func decodePKCS12() -> OSStatus { + public func decodePKCS12() -> OSStatus { // Import PKCS12 client cert var importResult: CFArray? - let status = SecPKCS12Import(importingRawCert! as CFData, [kSecImportExportPassphrase as String: importingPassword ?? ""] as NSDictionary, &importResult) + guard let importingRawCert else { + logger.error("No raw cert data to decode") + return errSecParam + } + let status = SecPKCS12Import(importingRawCert as CFData, [kSecImportExportPassphrase as String: importingPassword ?? ""] as NSDictionary, &importResult) + if status == noErr { // Extract the certifcate and private key let identityDictionaries = importResult as! [[String: Any]] @@ -257,18 +297,22 @@ public class ClientCertificateManager { } func evaluateTrust(with challenge: URLAuthenticationChallenge) -> (URLSession.AuthChallengeDisposition, URLCredential?) { - let dns = challenge.protectionSpace.distinguishedNames - if let dns { - let identity = evaluateTrust(distinguishedNames: dns) - if let identity { - var cert: SecCertificate? - SecIdentityCopyCertificate(identity, &cert) - let certChain = buildIdentityCertChain(cert: cert!) - let credential = URLCredential(identity: identity, certificates: certChain, persistence: URLCredential.Persistence.forSession) - return (.useCredential, credential) - } + guard let dns = challenge.protectionSpace.distinguishedNames, + let identity = evaluateTrust(distinguishedNames: dns) else { + return (.cancelAuthenticationChallenge, nil) + } + + var cert: SecCertificate? + SecIdentityCopyCertificate(identity, &cert) + + guard let cert else { + logger.error("Failed to extract certificate from identity") + return (.cancelAuthenticationChallenge, nil) } - return (.cancelAuthenticationChallenge, nil) + + let certChain = buildIdentityCertChain(cert: cert) + let credential = URLCredential(identity: identity, certificates: certChain, persistence: .forSession) + return (.useCredential, credential) } func buildIdentityCertChain(cert: SecCertificate) -> [SecCertificate]? { diff --git a/OpenHABCore/Sources/OpenHABCore/Util/ServerCertificateManager.swift b/OpenHABCore/Sources/OpenHABCore/Util/ServerCertificateManager.swift index 01f54e22c..623159406 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/ServerCertificateManager.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/ServerCertificateManager.swift @@ -12,15 +12,6 @@ import Foundation import os.log -// public protocol ServerCertificateManagerDelegate: NSObjectProtocol { -// // delegate should ask user for a decision on what to do with invalid certificate -// func evaluateServerTrust(_ policy: ServerCertificateManager?, summary certificateSummary: String?, forDomain domain: String?) -// // certificate received from openHAB doesn't match our record, ask user for a decision -// func evaluateCertificateMismatch(_ policy: ServerCertificateManager?, summary certificateSummary: String?, forDomain domain: String?) -// // notify delegate that the certificagtes that a user is willing to trust has changed -// func acceptedServerCertificatesChanged(_ policy: ServerCertificateManager?) -// } - public protocol ServerCertificateManagerDelegate: AnyObject { // delegate should ask user for a decision on what to do with invalid certificate func evaluateServerTrust(summary certificateSummary: String?, forDomain domain: String?) async -> ServerCertificateManager.EvaluateResult @@ -43,14 +34,6 @@ public class ServerCertificateManager { // ServerTrustManager, ServerTrustEvalua case permitAlways } -// public var evaluateResult: EvaluateResult = .undecided { -// didSet { -// if evaluateResult != .undecided { -// evaluateResultSemaphore.signal() -// } -// } -// } - weak var delegate: ServerCertificateManagerDelegate? // ignoreSSL is a synonym for allowInvalidCertificates, ignoreCertificates public var ignoreSSL = false @@ -58,7 +41,6 @@ public class ServerCertificateManager { // ServerTrustManager, ServerTrustEvalua // Init a ServerCertificateManager and set ignore certificates setting public init(ignoreSSL: Bool = false) { -// super.init(evaluators: [:]) self.ignoreSSL = ignoreSSL os_log("Initializing cert store", log: .remoteAccess, type: .info) diff --git a/OpenHABCore/Tests/OpenHABCoreTests/ClientCertificateManagerTests.swift b/OpenHABCore/Tests/OpenHABCoreTests/ClientCertificateManagerTests.swift new file mode 100644 index 000000000..ebd441789 --- /dev/null +++ b/OpenHABCore/Tests/OpenHABCoreTests/ClientCertificateManagerTests.swift @@ -0,0 +1,105 @@ +// Copyright (c) 2010-2025 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 Foundation + +@testable import OpenHABCore +import XCTest + +final class MockClientCertDelegate: ClientCertificateManagerDelegate { + var shouldImport = true + var password: String? = "test1234" + var receivedErrorMessage: String? + + func askForClientCertificateImport() async -> Bool { + shouldImport + } + + func askForCertificatePassword() async -> String? { + password + } + + var receivedErrorCode: OSStatus? + func alertClientCertificateError(errMsg: String) async { + receivedErrorMessage = errMsg + if let code = Int(errMsg.components(separatedBy: CharacterSet.decimalDigits.inverted).joined()) { + receivedErrorCode = OSStatus(code) + } + } +} + +// MARK: Instructions + +// To create test.p12 +// openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem -days 365 -nodes -subj "/CN=TestCert" +// openssl pkcs12 -export -out test.p12 -inkey key.pem -in cert.pem -password pass:password + +final class ClientCertificateManagerTests: XCTestCase { + var manager: ClientCertificateManager! + var delegate: MockClientCertDelegate! + + override func setUp() { + super.setUp() + manager = ClientCertificateManager() + delegate = MockClientCertDelegate() + manager.delegate = delegate + } + + override func tearDown() { + manager = nil + delegate = nil + super.tearDown() + } + + func testStartImportCertificateReturnsFalseIfDataMissing() async { + let result = await manager.startImportClientCertificate(url: URL(fileURLWithPath: "/nonexistent.p12")) + XCTAssertFalse(result) + } + + func testStartImportCertificateReturnsTrueIfDelegateApproves() async throws { + // Use a real P12 file in your test bundle if needed + guard let url = Bundle.module.url(forResource: "test", withExtension: "p12") else { + return XCTFail("Test PKCS#12 file not found.") + } + + let result = await manager.startImportClientCertificate(url: url) + XCTAssertTrue(result) + } + +// func testClientCertificateAcceptedSuccessPath() async { +// manager.importingRawCert = loadMockPKCS12Data() +// delegate.password = "password" +// +// await manager.clientCertificateAccepted(password: "password") +// XCTAssertNil(delegate.receivedErrorMessage) +// } + + func testPKCS12DecodeReturnsIdentity() { + manager.importingRawCert = loadMockPKCS12Data() + manager.importingPassword = "password" + + let status = manager.decodePKCS12() + XCTAssertEqual(status, errSecSuccess) + XCTAssertNotNil(manager.importingIdentity) + } + + func testClientCertificateAcceptedFailsAndAlerts() async { + manager.importingRawCert = Data([0x00, 0x01, 0x02]) // invalid cert + await manager.clientCertificateAccepted(password: "badpassword") + XCTAssertNotNil(delegate.receivedErrorMessage) + } + + // Helper to load valid PKCS#12 mock + private func loadMockPKCS12Data() -> Data? { + guard let url = Bundle.module.url(forResource: "test", withExtension: "p12") else { return nil } + return try? Data(contentsOf: url) + } +} diff --git a/OpenHABCore/Tests/OpenHABCoreTests/Resources/test.p12 b/OpenHABCore/Tests/OpenHABCoreTests/Resources/test.p12 new file mode 100644 index 0000000000000000000000000000000000000000..61aa62d37dd2790606f8b0d218e4c9e6d4fe0930 GIT binary patch literal 2531 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 literal 0 HcmV?d00001 diff --git a/cert.pem b/cert.pem new file mode 100644 index 000000000..1fdf047dc --- /dev/null +++ b/cert.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDBzCCAe+gAwIBAgIUDl49gZnj6UzaW01yb3a5riGnshAwDQYJKoZIhvcNAQEL +BQAwEzERMA8GA1UEAwwIVGVzdENlcnQwHhcNMjUwMzI5MTcwNTE3WhcNMjYwMzI5 +MTcwNTE3WjATMREwDwYDVQQDDAhUZXN0Q2VydDCCASIwDQYJKoZIhvcNAQEBBQAD +ggEPADCCAQoCggEBAKpSNZphUy7ySZIlzO/g9JGNZsUl4D08X5tHSKvH6fwkoZ4i +nfjun7dbFj4rc8eg44ilt29UyjFWk0366yp5+zOhfjmp7ZiJtIZMCfYt08QO6RVO +CZ3e+rKIstJinp+ChfP0TFV2MN4yP5/Nvra5adxh6oLgydnalS6IIL+bsWJDBH8s +G6rhvEZ6Q/GoCk9ajyN5kBufWEOogM/hUORZW0G647fue+vnuqn7LRiSu2bgNcz5 +jvBKhwGKxEWDBj14BN1Yeu35KI1HCxVWHA47EpxlbJOYtC6KOHm3UXqMOiInQwZh +PkTbZ9M8N30eGesI6jV/tVHcAiR27Fj8IttY6iECAwEAAaNTMFEwHQYDVR0OBBYE +FGqm0KnUdkN/MdeMB526hC08DVniMB8GA1UdIwQYMBaAFGqm0KnUdkN/MdeMB526 +hC08DVniMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAFNplFqF +1hwzd+xLhbwnzuqd5IZmxUDUES+caZpjmJ6A5blgo5NyJgxpH1AkVjUoezZKcSvq +M+dMkavErUNSzAhwCIw0BuEAC3OQe5Vfl+7J0m6DOznE6gIPaKWaY5nU4eD1FVMR +fBU3/rXH61dJr2NAj9XQU2gzoAOomNzkY3EqNyCq8bo4O0jUcu1GmXnymiwHHVHn +I5f9neEJ8ryFquE6brISq8PdHTKhBY1Bb4vJ3ErQF+nPph6UBfrUiN5GllCEP/ea +Mp9GXxfgKSz55dEoQxyUN5aoMN2t9IZeGVzfjAy6+7HJfPDCRWndVhdpllykVl0w +bi0e4IMWxpXhGE8= +-----END CERTIFICATE----- diff --git a/key.pem b/key.pem new file mode 100644 index 000000000..0e77822e8 --- /dev/null +++ b/key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCqUjWaYVMu8kmS +Jczv4PSRjWbFJeA9PF+bR0irx+n8JKGeIp347p+3WxY+K3PHoOOIpbdvVMoxVpNN ++usqefszoX45qe2YibSGTAn2LdPEDukVTgmd3vqyiLLSYp6fgoXz9ExVdjDeMj+f +zb62uWncYeqC4MnZ2pUuiCC/m7FiQwR/LBuq4bxGekPxqApPWo8jeZAbn1hDqIDP +4VDkWVtBuuO37nvr57qp+y0Ykrtm4DXM+Y7wSocBisRFgwY9eATdWHrt+SiNRwsV +VhwOOxKcZWyTmLQuijh5t1F6jDoiJ0MGYT5E22fTPDd9HhnrCOo1f7VR3AIkduxY +/CLbWOohAgMBAAECggEADh6qxnMMk28EdeBP3SVpU7q6UHGWJYV13e3a2AYxVJT7 +LVyse3iu1s4Z6eADem3RByDz3tUo6PkG1Qy48Qhz8Iu8vDjlPgN71YlZZxEABg2A +Dw3X4sBbngWSoOSKRr9/mDdCb8WmAfmo8s16oskmvfLZH0NVleEBg+jG9ujdm6oU +iOaSuOES7TR5EnasL7ssCUvb2dWt+wpsMl6r0ARNQqUrFiN5RUJCU8Q/mlDKJ9Ry +6SCnyKedX4NKF7mpNSGZqfQniZIaXW+S3DoNh/B5QLsXS+pnFhCUZFXWj9DDEZvJ +JqjeYg1OyDV2oWlsHlV5vLQBZZQwpOJGxuA25fz6FQKBgQDvcZEz1SolKCfalRec +wFLmXorIObgM/aiRaKWPfkY0nwm2aL07Pzcsx9wG0eHccFk4Z4eICQJDV2VB41N8 +Ik+qkqsDk2a6pNxXNq6CKpqCRaH3Fve+jrmfv1P26QuEj+W3M7iB8EVGTef/fDTN +g/42MxicPVB4SuYPHTZRRtUwzQKBgQC2GRfSx2gWKzexqA92+6o20WqSCkhmE9mv +56NgbWh9UR0BUUFC9u6C5UNm2W56Y+mWErSrNyU88rip+3Jyg2i1+XARJ4kpaD20 +uzCuUcZeF8XrarbYlcBHpAf80IJCAshNJ1OeGoLwGK5mWchZaDAXqzrAJ8b23gq+ +s4N8c1pOpQKBgDFpKiGcF3pbcv30Tk8WkQTg9Zqj7osfvS1kfuXBlRM+zm5J5uLX +BLfE9m6h2Q34UTEGjD1SPplO66JRGuU+31m/snKmdEiHxMBqlFBgIkpHvEiVAMLe +CQgiH12QccQFPc40ahrGTkVXxkw+gVb3qfndSXLUZEquihMMYC0dhNjxAoGBAJMD +YzZqguAS+B3X3tRijaNAItfQsW6n7AGV81KwQcGasN4VaajUju7ihS4NsKHi8/yT +EYWBOfEgzHF/bYCCExGHVjCjSJtDaz30OnMh1hK1ArbzKrdk/x1XkpNLCz6b3HZd +n6ZvJPMOGg7LwXJdKSaSuRXVh05bKTWY3DinMGt5AoGBALPZlyir0DQ1TAyIDTek +e5olmTbhl5NggZf6MNpNjeT7occRmHIPkafmC35yoLAywW8RY9feAWKHgDV4LNoz +EBM+upyMPwkQdLYWcIpgfyqwtR7bSHzFcPKak4+jMhAAbIq+35LgwdHbE1Gm2cfW +8ve0bgfiU8hmsv1Rq3/72++L +-----END PRIVATE KEY----- diff --git a/openHAB/OpenHABViewController.swift b/openHAB/OpenHABViewController.swift index 38522662d..c7f43991b 100644 --- a/openHAB/OpenHABViewController.swift +++ b/openHAB/OpenHABViewController.swift @@ -130,52 +130,73 @@ extension OpenHABViewController: ServerCertificateManagerDelegate { // MARK: - ClientCertificateManagerDelegate +@MainActor extension OpenHABViewController: ClientCertificateManagerDelegate { - // delegate should ask user for a decision on whether to import the client certificate into the keychain - func askForClientCertificateImport(_ clientCertificateManager: ClientCertificateManager?) { - DispatchQueue.main.async { - let alertController = UIAlertController(title: NSLocalizedString("certificate_import_title", comment: ""), message: NSLocalizedString("certificate_import_text", comment: ""), preferredStyle: .alert) - let okay = UIAlertAction(title: NSLocalizedString("okay", comment: ""), style: .default) { (_: UIAlertAction) in - clientCertificateManager!.clientCertificateAccepted(password: nil) + // Ask user whether to import the certificate + func askForClientCertificateImport() async -> Bool { + await withCheckedContinuation { continuation in + let alertController = UIAlertController( + title: NSLocalizedString("certificate_import_title", comment: ""), + message: NSLocalizedString("certificate_import_text", comment: ""), + preferredStyle: .alert + ) + + let okay = UIAlertAction(title: NSLocalizedString("okay", comment: ""), style: .default) { _ in + continuation.resume(returning: true) } - let cancel = UIAlertAction(title: NSLocalizedString("cancel", comment: ""), style: .cancel) { (_: UIAlertAction) in - clientCertificateManager!.clientCertificateRejected() + + let cancel = UIAlertAction(title: NSLocalizedString("cancel", comment: ""), style: .cancel) { _ in + continuation.resume(returning: false) } + alertController.addAction(okay) alertController.addAction(cancel) - self.present(alertController, animated: true, completion: nil) + + self.present(alertController, animated: true) } } - // delegate should ask user for the export password used to decode the PKCS#12 - func askForCertificatePassword(_ clientCertificateManager: ClientCertificateManager?) { - DispatchQueue.main.async { - let alertController = UIAlertController(title: NSLocalizedString("certificate_import_title", comment: ""), message: NSLocalizedString("certificate_import_password", comment: ""), preferredStyle: .alert) - let okay = UIAlertAction(title: NSLocalizedString("okay", comment: ""), style: .default) { (_: UIAlertAction) in - let txtField = alertController.textFields?.first - let password = txtField?.text - clientCertificateManager!.clientCertificateAccepted(password: password) - } - let cancel = UIAlertAction(title: NSLocalizedString("cancel", comment: ""), style: .cancel) { (_: UIAlertAction) in - clientCertificateManager!.clientCertificateRejected() - } + // Ask user for password to decode PKCS#12 + func askForCertificatePassword() async -> String? { + await withCheckedContinuation { continuation in + let alertController = UIAlertController( + title: NSLocalizedString("certificate_import_title", comment: ""), + message: NSLocalizedString("certificate_import_password", comment: ""), + preferredStyle: .alert + ) + alertController.addTextField { textField in textField.placeholder = NSLocalizedString("password", comment: "") textField.isSecureTextEntry = true } + + let okay = UIAlertAction(title: NSLocalizedString("okay", comment: ""), style: .default) { _ in + let password = alertController.textFields?.first?.text + continuation.resume(returning: password) + } + + let cancel = UIAlertAction(title: NSLocalizedString("cancel", comment: ""), style: .cancel) { _ in + continuation.resume(returning: nil) + } + alertController.addAction(okay) alertController.addAction(cancel) - self.present(alertController, animated: true, completion: nil) + + self.present(alertController, animated: true) } } - // delegate should alert the user that an error occured importing the certificate - func alertClientCertificateError(_ clientCertificateManager: ClientCertificateManager?, errMsg: String) { - DispatchQueue.main.async { - let alertController = UIAlertController(title: NSLocalizedString("certificate_import_title", comment: ""), message: errMsg, preferredStyle: .alert) - let okay = UIAlertAction(title: NSLocalizedString("okay", comment: ""), style: .default) - alertController.addAction(okay) - self.present(alertController, animated: true, completion: nil) - } + // Show alert if certificate import failed + func alertClientCertificateError(errMsg: String) async { + let alertController = UIAlertController( + title: NSLocalizedString("certificate_import_title", comment: ""), + message: errMsg, + preferredStyle: .alert + ) + + let okay = UIAlertAction(title: NSLocalizedString("okay", comment: ""), style: .default) + alertController.addAction(okay) + + present(alertController, animated: true) } } diff --git a/openHABWatch/Extension/OpenHABWatchAppDelegate.swift b/openHABWatch/Extension/OpenHABWatchAppDelegate.swift index b7487346e..bf808be9d 100644 --- a/openHABWatch/Extension/OpenHABWatchAppDelegate.swift +++ b/openHABWatch/Extension/OpenHABWatchAppDelegate.swift @@ -64,11 +64,15 @@ extension OpenHABWatchAppDelegate: WKApplicationDelegate { extension OpenHABWatchAppDelegate: ClientCertificateManagerDelegate { // delegate should ask user for a decision on whether to import the client certificate into the keychain - func askForClientCertificateImport(_ clientCertificateManager: ClientCertificateManager?) {} + func askForClientCertificateImport() async -> Bool { + true + } // delegate should ask user for the export password used to decode the PKCS#12 - func askForCertificatePassword(_ clientCertificateManager: ClientCertificateManager?) {} + func askForCertificatePassword() async -> String? { + nil + } - // delegate should alert the user that an error occured importing the certificate - func alertClientCertificateError(_ clientCertificateManager: ClientCertificateManager?, errMsg: String) {} + // delegate should ask user for the export password used to decode the PKCS#12 + func alertClientCertificateError(errMsg: String) {} } diff --git a/test.p12 b/test.p12 new file mode 100644 index 0000000000000000000000000000000000000000..61aa62d37dd2790606f8b0d218e4c9e6d4fe0930 GIT binary patch literal 2531 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 literal 0 HcmV?d00001 From eb8d387059a7693f557fad728a7b41066a637256 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Sat, 29 Mar 2025 21:45:30 +0100 Subject: [PATCH 116/476] Added tests for ServerCertificateManager Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../Util/ServerCertificateManager.swift | 24 ++- .../Tests/OpenHABCoreTests/Resources/cert.pem | 19 +++ .../Tests/OpenHABCoreTests/Resources/key.pem | 28 ++++ .../OpenHABCoreTests/Resources/test-cert.cer | Bin 0 -> 779 bytes .../ServerCertificateManagerTests.swift | 157 ++++++++++++++++++ cert.pem | 19 --- key.pem | 28 ---- test.p12 | Bin 2531 -> 0 bytes 8 files changed, 220 insertions(+), 55 deletions(-) create mode 100644 OpenHABCore/Tests/OpenHABCoreTests/Resources/cert.pem create mode 100644 OpenHABCore/Tests/OpenHABCoreTests/Resources/key.pem create mode 100644 OpenHABCore/Tests/OpenHABCoreTests/Resources/test-cert.cer create mode 100644 OpenHABCore/Tests/OpenHABCoreTests/ServerCertificateManagerTests.swift delete mode 100644 cert.pem delete mode 100644 key.pem delete mode 100644 test.p12 diff --git a/OpenHABCore/Sources/OpenHABCore/Util/ServerCertificateManager.swift b/OpenHABCore/Sources/OpenHABCore/Util/ServerCertificateManager.swift index 623159406..f46b6d15d 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/ServerCertificateManager.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/ServerCertificateManager.swift @@ -39,19 +39,21 @@ public class ServerCertificateManager { // ServerTrustManager, ServerTrustEvalua public var ignoreSSL = false public var trustedCertificates: [String: Data] = [:] + private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "ServerCertificateManager", category: "ServerCert") + // Init a ServerCertificateManager and set ignore certificates setting public init(ignoreSSL: Bool = false) { self.ignoreSSL = ignoreSSL - os_log("Initializing cert store", log: .remoteAccess, type: .info) + logger.info("Initializing cert store") loadTrustedCertificates() if trustedCertificates.isEmpty { - os_log("No cert store, creating", log: .remoteAccess, type: .info) + logger.info("No cert store, creating") trustedCertificates = [:] // [trustedCertificates setObject:@"Bulk" forKey:@"Bulk id to make it non-empty"]; saveTrustedCertificates() } else { - os_log("Loaded existing cert store", log: .remoteAccess, type: .info) + logger.info("Loaded existing cert store") } } @@ -69,7 +71,7 @@ public class ServerCertificateManager { // ServerTrustManager, ServerTrustEvalua let data = try PropertyListEncoder().encode(trustedCertificates) try data.write(to: getPersistensePath()) } catch { - os_log("Could not save trusted certificates", log: .default) + logger.info("Could not save trusted certificates") } } @@ -102,9 +104,9 @@ public class ServerCertificateManager { // ServerTrustManager, ServerTrustEvalua return } } catch { - os_log("Could not load trusted unarchived certificates", log: .default) + logger.info("Could not load trusted unarchived certificates") } - os_log("Could not load trusted codable certificates", log: .default) + logger.info("Could not load trusted codable certificates") } } @@ -149,7 +151,9 @@ public class ServerCertificateManager { // ServerTrustManager, ServerTrustEvalua // If we have a certificate for this domain // Obtain certificate we have and compare it with the certificate presented by the server - if let previousData = self.certificateData(forDomain: domain), CFEqual(previousData, certificateData) { + let previousData = self.certificateData(forDomain: domain) + + if let previousData, CFEqual(previousData, certificateData) { // If certificate matched one in our store - permit this connection return // trusted } @@ -158,7 +162,8 @@ public class ServerCertificateManager { // ServerTrustManager, ServerTrustEvalua throw ServerCertificateManagerError.serverTrustEvaluationFailed } - let decision: EvaluateResult = if self.certificateData(forDomain: domain) != nil { + logger.info("Server trust not valid for \(domain), asking delegate...") + let decision: EvaluateResult = if previousData != nil { // mismatch, we have a certificate for this domain in our memory of decisions, but the certificate we've got now // differs. We need to warn user about possible MiM attack and wait for users decision. await delegate.evaluateCertificateMismatch(summary: certificateSummary as String?, forDomain: domain) @@ -179,7 +184,10 @@ public class ServerCertificateManager { // ServerTrustManager, ServerTrustEvalua // Add certificate to storage storeCertificateData(certificateData, forDomain: domain) delegate.acceptedServerCertificatesChanged() + logger.info("User chose to trust cert for \(domain) permanently") return + @unknown default: + throw ServerCertificateManagerError.serverTrustEvaluationFailed } } diff --git a/OpenHABCore/Tests/OpenHABCoreTests/Resources/cert.pem b/OpenHABCore/Tests/OpenHABCoreTests/Resources/cert.pem new file mode 100644 index 000000000..aec0e56f0 --- /dev/null +++ b/OpenHABCore/Tests/OpenHABCoreTests/Resources/cert.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDBzCCAe+gAwIBAgIUU1ES09EhXXpBfxsGRSHTJp4ioG4wDQYJKoZIhvcNAQEL +BQAwEzERMA8GA1UEAwwIVGVzdENlcnQwHhcNMjUwMzI5MTg0NTM3WhcNMjYwMzI5 +MTg0NTM3WjATMREwDwYDVQQDDAhUZXN0Q2VydDCCASIwDQYJKoZIhvcNAQEBBQAD +ggEPADCCAQoCggEBAKj35+saVpvLMR1rykzS3AsA1VOOrFWD7DiAONnim+MUgjdi +7F06ILiK3l8AowWjEm5mLlzGdWxg7/J5kURhWwfHv0PZUxGy6FfPaoTR24eWLnmR +mfPXeoSkVEYU5jecx/g0p+VoVh1twbtNInQWG3CcHQkAfxu1gdMUPPR/6+FdauB4 +06A0gHmyn27hrQKju1f7szs3uawWk9jqQMLQk4gIfxltbHqTw5Yo4xoPSwJ3dkmm +K9ueOuEQTbuHoJemhcEi+kr9UfZhbeINd1fN73Y0GJPdSPgHeifBpncf5CoP/EXb +0VeHsN70YZIFCyMiHKJJNo0QYPAUiYfCrZK1muMCAwEAAaNTMFEwHQYDVR0OBBYE +FJyMTg77fRWDtSyDa32Alvm36FQNMB8GA1UdIwQYMBaAFJyMTg77fRWDtSyDa32A +lvm36FQNMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAJKwB9Ea +ykpI7jpDKKORCsDR9B5L9nhJ8SauAvZpA6Jrx6rFVw20LuJVKSWqqJna2ws48ZoH +nUX3HZkR+rvR7QRaD/5RJ0LuyHejzJTD39OROe+/evzyUeOlEo/n8ylqginiCLnN +X3vsgZuJnmfMUOAHSkkQw4pFlqlK/MsqHDOBe8auNgEo7I73XnElCT1BYLHPO84L +/vIJvqOXN/NQ6KF6FbWHtF+9aVN0RP8TxViStYsyfC1KkA2vjFWEGnbs2Z8K2UtK +qRU+Woxu0OF+vuIICNQyWcJMg2WRv+wKWKlmBe4D6RUpil2arp9DuKsGOHb7rDPx +P/RaLkhnNbVArDk= +-----END CERTIFICATE----- diff --git a/OpenHABCore/Tests/OpenHABCoreTests/Resources/key.pem b/OpenHABCore/Tests/OpenHABCoreTests/Resources/key.pem new file mode 100644 index 000000000..8d4dd6244 --- /dev/null +++ b/OpenHABCore/Tests/OpenHABCoreTests/Resources/key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCo9+frGlabyzEd +a8pM0twLANVTjqxVg+w4gDjZ4pvjFII3YuxdOiC4it5fAKMFoxJuZi5cxnVsYO/y +eZFEYVsHx79D2VMRsuhXz2qE0duHli55kZnz13qEpFRGFOY3nMf4NKflaFYdbcG7 +TSJ0FhtwnB0JAH8btYHTFDz0f+vhXWrgeNOgNIB5sp9u4a0Co7tX+7M7N7msFpPY +6kDC0JOICH8ZbWx6k8OWKOMaD0sCd3ZJpivbnjrhEE27h6CXpoXBIvpK/VH2YW3i +DXdXze92NBiT3Uj4B3onwaZ3H+QqD/xF29FXh7De9GGSBQsjIhyiSTaNEGDwFImH +wq2StZrjAgMBAAECggEABNiDOq9ZL4Oex3i5ZwWfEOO3uns7fwJXCnHG75y7msIS +T/NS0wOhP5mf8fqLMlSwaSEeEEQSxwZqqzoIS/08xpbYaeI4KyhF2IrWw7NfB97r +O0pNL3yNA3PRBOeBiLPC2l/sNryMx7BHn48BP3II3GxlusXcX1ePvxV0ABERGzfu +LVAiA5gtt+Sf35jpfb1HhJSQIj9ZwHj1awBsr70ndoYcnd4XEpeyZ/XhaQD5g31J +RLlMjiGDbqs5cRxkPazMXmdVgt0qFW3/kVjp4PMPaifyUjI82aAHBr/bPVO4o7cr +kFXFIafaO7lIR6dfHZANFCwfWjcq8+y3gJFV0DLfBQKBgQDekl9fHFCP9seQv/qo +H9qxhRG2Xf8/ka6ER9rxlOOQDjpCUXicKTwcZgyEMZlnxVEerKjwXvAHXfgWw5MY +ku6HaPXVKiacZohPUXh6hqS2VZWdau7caD05r4ZO1YKH0ITppWP/ubX4172MW6KX +GCRYt7cBpDvUYMzXxEwReK949QKBgQDCWIuNNKj81Azvu/B5L4AzdD1RIjYX7CK/ +JGN1kT3195UC9tnrznq59oZJ8rERJTRnjEtx6TpH6xEd7dSccLmfPq2Za+7BvVR0 +ZVbHTnqxb7SvlHVUijjmIIfkTr0Ihfj0DDre+Y5ysE/2Yy0GELBy6T/vQxB0+77R +Hs/XWMo9dwKBgGF+9NAQ8yldDKZXslEbQw0oIU4ldr497ph7cWkJs3VTBiouzgp5 +9Z7rBOiTYYOXZ6VsNMS2kaZof1hZa4IEOwCN9cHeqPMx5tv38DKb8GL+sn82bFOY +RdmTDd5SoM8lhFNk450NmKzctGdbbKmLJcDHGR9a9epeTNznW/r7RsJRAoGAH/Ma +jg44MchSkB7fe6y5cLwNUIdFSU2CsNW0nCFShFs9Xg1i6gc52dSTYxjIzz7EjLld +tc5XzHbyi28L4uQNQAa3118NE0Ci37fBOmbGeOEaXTsUxt8qV3rFnFztardLpCNW +kk2Ig4ZEvway4ipgN6Ps5NKro++3AP4FwStRpCUCgYA4AQve0+d0VUntRqfvSXsJ +fN12iECmntZj2qqB0QGG+gA9blCDtfmuNNJERKelXy16h3QTGnHZHqTdlkWHYS8z +xgaHNEatYWbMFXbxXhobZYQIU3ijjrhnaMFkL45CDlt27aOFy8FZ4+lODQjHklSM +8zWKAJ39o1Fye6FS6JnhXQ== +-----END PRIVATE KEY----- diff --git a/OpenHABCore/Tests/OpenHABCoreTests/Resources/test-cert.cer b/OpenHABCore/Tests/OpenHABCoreTests/Resources/test-cert.cer new file mode 100644 index 0000000000000000000000000000000000000000..66aa993ce54b88e05d9829b4f1c45eb8e026568e GIT binary patch literal 779 zcmXqLVrDmJVtl`VnTe5!NhCN>=<-Fy*eb_*X*O5I%WCtK7UUW5vT8`<-tF3vPN5em<+^;_dcndX*Dr ze!gDSvLwV!sHec#rKa9eRZcf$Y!_zb#OYwU%m6v%g)4n~= z>Y;$|?)C-Km$e>L`sMXE@LOW;Bi{1xv+v7HBqrbW_`zPKesEd2{1YwyKd!eghPQ9H z_a$)>E4Q+e%py;-UV(%UBAxAr)=t_w>oF5EBLm~&V1qydSzvg}^0A1qh|KBn6~b>QL`Iqz>3o*&iLF@4KqUX*=&)zNU?EqaeaHC0!wn0f0qx5dX< z>~mee%gz-1wfo{*mMH#zf$C20PLwY`Gv)C8%M&f%@2~pvDe&=9q5kKeHM5#DA93tF z8(;mVadzjt^fLhu*u6Xj4tKduTj}-Zw3duotLQeNSkMRN0%G^SN$%d#x0; zi|Wa{@UU**BMy!$Mv;enno}q4f5R2AGL7{e^Gi|9uGm@Y<~#3L&1O;ddyVl&`!7*? M9_glA9oAR^0F#kF(*OVf literal 0 HcmV?d00001 diff --git a/OpenHABCore/Tests/OpenHABCoreTests/ServerCertificateManagerTests.swift b/OpenHABCore/Tests/OpenHABCoreTests/ServerCertificateManagerTests.swift new file mode 100644 index 000000000..5efb34b63 --- /dev/null +++ b/OpenHABCore/Tests/OpenHABCoreTests/ServerCertificateManagerTests.swift @@ -0,0 +1,157 @@ +// Copyright (c) 2010-2025 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 + +@testable import OpenHABCore +import XCTest + +func XCTAssertThrowsErrorAsync( + _ expression: @escaping () async throws -> some Any, + _ message: @autoclosure () -> String = "Expected async error but got success", + file: StaticString = #filePath, + line: UInt = #line +) async { + do { + _ = try await expression() + XCTFail(message(), file: file, line: line) + } catch { + // ✅ Success – an error was thrown + } +} + +final class MockServerCertificateDelegate: ServerCertificateManagerDelegate { + var lastCall: String = "" + var expectedResult: ServerCertificateManager.EvaluateResult = .permitOnce + var acceptedChangedCalled = false + + func evaluateServerTrust(summary: String?, forDomain domain: String?) async -> ServerCertificateManager.EvaluateResult { + lastCall = "evaluateServerTrust" + return expectedResult + } + + func evaluateCertificateMismatch(summary: String?, forDomain domain: String?) async -> ServerCertificateManager.EvaluateResult { + lastCall = "evaluateCertificateMismatch" + return expectedResult + } + + func acceptedServerCertificatesChanged() { + acceptedChangedCalled = true + } +} + +// Create a X.509 certificate in DER format as test-cert.cer +// openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem -days 365 -nodes -subj "/CN=TestCert" +// openssl x509 -outform der -in cert.pem -out test-cert.cer + +final class ServerCertificateManagerTests: XCTestCase { + var manager: ServerCertificateManager! + var delegate: MockServerCertificateDelegate! + + override func setUp() { + super.setUp() + delegate = MockServerCertificateDelegate() + manager = ServerCertificateManager() + manager.delegate = delegate + } + + override func tearDown() { + manager = nil + delegate = nil + super.tearDown() + } + + func testIgnoresSSLIfConfigured() async throws { + manager.ignoreSSL = true + let trust = try dummyTrust() + + do { + try await manager.evaluate(trust, forHost: "test.openhab.org") + } catch { + XCTFail("Expected no error, but got: \(error)") + } + } + + func testAcceptsPreviouslyStoredCertificate() async throws { + let trust = try dummyTrust() + let domain = "test.openhab.org" + let cert = manager.getLeafCertificate(trust: trust)! + let certData = SecCertificateCopyData(cert) as Data + manager.trustedCertificates[domain] = certData + + do { + try await manager.evaluate(trust, forHost: domain) + } catch { + XCTFail("Expected no error, but got: \(error)") + } + } + + func testTriggersMismatchDelegateWhenCertsDiffer() async throws { + let trust = try dummyTrust() + let domain = "test.openhab.org" + manager.trustedCertificates[domain] = Data([0x01, 0x02]) // fake cert + + delegate.expectedResult = .permitOnce + try await manager.evaluate(trust, forHost: domain) + + XCTAssertEqual(delegate.lastCall, "evaluateCertificateMismatch") + } + + func testTriggersServerTrustDelegateForNewCert() async throws { + let trust = try dummyTrust() + let domain = "unknown.openhab.org" + + delegate.expectedResult = .permitOnce + try await manager.evaluate(trust, forHost: domain) + + XCTAssertEqual(delegate.lastCall, "evaluateServerTrust") + } + + func testThrowsWhenUserDeniesTrust() async throws { + let trust = try dummyTrust() + let domain = "deny.openhab.org" + + manager.trustedCertificates.removeAll() + XCTAssertNil(manager.trustedCertificates[domain]) // Sanity check + + delegate.expectedResult = .deny + + await XCTAssertThrowsErrorAsync { + try await self.manager.evaluate(trust, forHost: domain) + } + } + + func testStoresCertWhenUserAcceptsAlways() async throws { + let trust = try dummyTrust() + let domain = "persist.openhab.org" + + manager.trustedCertificates.removeAll() + XCTAssertNil(manager.trustedCertificates[domain]) // Sanity check + + delegate.expectedResult = .permitAlways + try await manager.evaluate(trust, forHost: domain) + + XCTAssertNotNil(manager.trustedCertificates[domain]) + XCTAssertTrue(delegate.acceptedChangedCalled, "Delegate should be notified when cert is stored") + } + + // MARK: - Helper + + func dummyTrust() throws -> SecTrust { + let certPath = Bundle.module.url(forResource: "test-cert", withExtension: "cer")! + let certData = try Data(contentsOf: certPath) + let cert = SecCertificateCreateWithData(nil, certData as CFData)! + let policy = SecPolicyCreateSSL(true, nil) + + var trust: SecTrust? + let status = SecTrustCreateWithCertificates(cert, policy, &trust) + guard status == errSecSuccess else { throw NSError(domain: "TrustCreate", code: Int(status)) } + return trust! + } +} diff --git a/cert.pem b/cert.pem deleted file mode 100644 index 1fdf047dc..000000000 --- a/cert.pem +++ /dev/null @@ -1,19 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIDBzCCAe+gAwIBAgIUDl49gZnj6UzaW01yb3a5riGnshAwDQYJKoZIhvcNAQEL -BQAwEzERMA8GA1UEAwwIVGVzdENlcnQwHhcNMjUwMzI5MTcwNTE3WhcNMjYwMzI5 -MTcwNTE3WjATMREwDwYDVQQDDAhUZXN0Q2VydDCCASIwDQYJKoZIhvcNAQEBBQAD -ggEPADCCAQoCggEBAKpSNZphUy7ySZIlzO/g9JGNZsUl4D08X5tHSKvH6fwkoZ4i -nfjun7dbFj4rc8eg44ilt29UyjFWk0366yp5+zOhfjmp7ZiJtIZMCfYt08QO6RVO -CZ3e+rKIstJinp+ChfP0TFV2MN4yP5/Nvra5adxh6oLgydnalS6IIL+bsWJDBH8s -G6rhvEZ6Q/GoCk9ajyN5kBufWEOogM/hUORZW0G647fue+vnuqn7LRiSu2bgNcz5 -jvBKhwGKxEWDBj14BN1Yeu35KI1HCxVWHA47EpxlbJOYtC6KOHm3UXqMOiInQwZh -PkTbZ9M8N30eGesI6jV/tVHcAiR27Fj8IttY6iECAwEAAaNTMFEwHQYDVR0OBBYE -FGqm0KnUdkN/MdeMB526hC08DVniMB8GA1UdIwQYMBaAFGqm0KnUdkN/MdeMB526 -hC08DVniMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAFNplFqF -1hwzd+xLhbwnzuqd5IZmxUDUES+caZpjmJ6A5blgo5NyJgxpH1AkVjUoezZKcSvq -M+dMkavErUNSzAhwCIw0BuEAC3OQe5Vfl+7J0m6DOznE6gIPaKWaY5nU4eD1FVMR -fBU3/rXH61dJr2NAj9XQU2gzoAOomNzkY3EqNyCq8bo4O0jUcu1GmXnymiwHHVHn -I5f9neEJ8ryFquE6brISq8PdHTKhBY1Bb4vJ3ErQF+nPph6UBfrUiN5GllCEP/ea -Mp9GXxfgKSz55dEoQxyUN5aoMN2t9IZeGVzfjAy6+7HJfPDCRWndVhdpllykVl0w -bi0e4IMWxpXhGE8= ------END CERTIFICATE----- diff --git a/key.pem b/key.pem deleted file mode 100644 index 0e77822e8..000000000 --- a/key.pem +++ /dev/null @@ -1,28 +0,0 @@ ------BEGIN PRIVATE KEY----- -MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCqUjWaYVMu8kmS -Jczv4PSRjWbFJeA9PF+bR0irx+n8JKGeIp347p+3WxY+K3PHoOOIpbdvVMoxVpNN -+usqefszoX45qe2YibSGTAn2LdPEDukVTgmd3vqyiLLSYp6fgoXz9ExVdjDeMj+f -zb62uWncYeqC4MnZ2pUuiCC/m7FiQwR/LBuq4bxGekPxqApPWo8jeZAbn1hDqIDP -4VDkWVtBuuO37nvr57qp+y0Ykrtm4DXM+Y7wSocBisRFgwY9eATdWHrt+SiNRwsV -VhwOOxKcZWyTmLQuijh5t1F6jDoiJ0MGYT5E22fTPDd9HhnrCOo1f7VR3AIkduxY -/CLbWOohAgMBAAECggEADh6qxnMMk28EdeBP3SVpU7q6UHGWJYV13e3a2AYxVJT7 -LVyse3iu1s4Z6eADem3RByDz3tUo6PkG1Qy48Qhz8Iu8vDjlPgN71YlZZxEABg2A -Dw3X4sBbngWSoOSKRr9/mDdCb8WmAfmo8s16oskmvfLZH0NVleEBg+jG9ujdm6oU -iOaSuOES7TR5EnasL7ssCUvb2dWt+wpsMl6r0ARNQqUrFiN5RUJCU8Q/mlDKJ9Ry -6SCnyKedX4NKF7mpNSGZqfQniZIaXW+S3DoNh/B5QLsXS+pnFhCUZFXWj9DDEZvJ -JqjeYg1OyDV2oWlsHlV5vLQBZZQwpOJGxuA25fz6FQKBgQDvcZEz1SolKCfalRec -wFLmXorIObgM/aiRaKWPfkY0nwm2aL07Pzcsx9wG0eHccFk4Z4eICQJDV2VB41N8 -Ik+qkqsDk2a6pNxXNq6CKpqCRaH3Fve+jrmfv1P26QuEj+W3M7iB8EVGTef/fDTN -g/42MxicPVB4SuYPHTZRRtUwzQKBgQC2GRfSx2gWKzexqA92+6o20WqSCkhmE9mv -56NgbWh9UR0BUUFC9u6C5UNm2W56Y+mWErSrNyU88rip+3Jyg2i1+XARJ4kpaD20 -uzCuUcZeF8XrarbYlcBHpAf80IJCAshNJ1OeGoLwGK5mWchZaDAXqzrAJ8b23gq+ -s4N8c1pOpQKBgDFpKiGcF3pbcv30Tk8WkQTg9Zqj7osfvS1kfuXBlRM+zm5J5uLX -BLfE9m6h2Q34UTEGjD1SPplO66JRGuU+31m/snKmdEiHxMBqlFBgIkpHvEiVAMLe -CQgiH12QccQFPc40ahrGTkVXxkw+gVb3qfndSXLUZEquihMMYC0dhNjxAoGBAJMD -YzZqguAS+B3X3tRijaNAItfQsW6n7AGV81KwQcGasN4VaajUju7ihS4NsKHi8/yT -EYWBOfEgzHF/bYCCExGHVjCjSJtDaz30OnMh1hK1ArbzKrdk/x1XkpNLCz6b3HZd -n6ZvJPMOGg7LwXJdKSaSuRXVh05bKTWY3DinMGt5AoGBALPZlyir0DQ1TAyIDTek -e5olmTbhl5NggZf6MNpNjeT7occRmHIPkafmC35yoLAywW8RY9feAWKHgDV4LNoz -EBM+upyMPwkQdLYWcIpgfyqwtR7bSHzFcPKak4+jMhAAbIq+35LgwdHbE1Gm2cfW -8ve0bgfiU8hmsv1Rq3/72++L ------END PRIVATE KEY----- diff --git a/test.p12 b/test.p12 deleted file mode 100644 index 61aa62d37dd2790606f8b0d218e4c9e6d4fe0930..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2531 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 From a545e2e120113648b2d7a6aa5eea210be5ba47a7 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Sat, 29 Mar 2025 22:17:01 +0100 Subject: [PATCH 117/476] Get rid of SVGKit and replace by SDWebImageSVGCoder Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../ClientCertificateManagerTests.swift | 2 +- openHAB.xcodeproj/project.pbxproj | 33 +++---- .../xcshareddata/swiftpm/Package.resolved | 29 +----- openHAB/AppDelegate.swift | 4 + openHAB/OpenHABImageProcessor.swift | 16 +-- openHAB/OpenHABSitemapViewController.swift | 1 - openHAB/legal.rtf | 13 ++- openHABTestsSwift/OpenHABSVGTests.swift | 97 +++++-------------- 8 files changed, 59 insertions(+), 136 deletions(-) diff --git a/OpenHABCore/Tests/OpenHABCoreTests/ClientCertificateManagerTests.swift b/OpenHABCore/Tests/OpenHABCoreTests/ClientCertificateManagerTests.swift index ebd441789..92d9bc7a6 100644 --- a/OpenHABCore/Tests/OpenHABCoreTests/ClientCertificateManagerTests.swift +++ b/OpenHABCore/Tests/OpenHABCoreTests/ClientCertificateManagerTests.swift @@ -18,6 +18,7 @@ final class MockClientCertDelegate: ClientCertificateManagerDelegate { var shouldImport = true var password: String? = "test1234" var receivedErrorMessage: String? + var receivedErrorCode: OSStatus? func askForClientCertificateImport() async -> Bool { shouldImport @@ -27,7 +28,6 @@ final class MockClientCertDelegate: ClientCertificateManagerDelegate { password } - var receivedErrorCode: OSStatus? func alertClientCertificateError(errMsg: String) async { receivedErrorMessage = errMsg if let code = Int(errMsg.components(separatedBy: CharacterSet.decimalDigits.inverted).joined()) { diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index cc91b74ac..60ee96c3e 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -62,7 +62,6 @@ 93F8064727AE7A050035A6B0 /* SwiftMessages in Frameworks */ = {isa = PBXBuildFile; productRef = 93F8064627AE7A050035A6B0 /* SwiftMessages */; }; 93F8064A27AE7A2E0035A6B0 /* FlexColorPicker in Frameworks */ = {isa = PBXBuildFile; productRef = 93F8064927AE7A2E0035A6B0 /* FlexColorPicker */; }; 93F8065027AE7A830035A6B0 /* SideMenu in Frameworks */ = {isa = PBXBuildFile; productRef = 93F8064F27AE7A830035A6B0 /* SideMenu */; }; - 93F8065327AE7B580035A6B0 /* SVGKit in Frameworks */ = {isa = PBXBuildFile; productRef = 93F8065227AE7B580035A6B0 /* SVGKit */; }; A3F4C3A51A49A5940019A09F /* MainLaunchScreen.xib in Resources */ = {isa = PBXBuildFile; fileRef = A3F4C3A41A49A5940019A09F /* MainLaunchScreen.xib */; }; B7D5ECE121499E55001B0EC6 /* MapViewTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7D5ECE021499E55001B0EC6 /* MapViewTableViewCell.swift */; }; DA0749DE23E0B5950057FA83 /* ColorPickerRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA0749DD23E0B5950057FA83 /* ColorPickerRow.swift */; }; @@ -126,6 +125,8 @@ DAA42BAA21DC983B00244B2A /* VideoUITableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAA42BA921DC983B00244B2A /* VideoUITableViewCell.swift */; }; DAA42BAC21DC984A00244B2A /* WebUITableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAA42BAB21DC984A00244B2A /* WebUITableViewCell.swift */; }; DAAC30872CBBF0420041927F /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA0775262346705F0086C685 /* ContentView.swift */; }; + DABB5E332D98972F009A4B8A /* SDWebImageSVGCoder in Frameworks */ = {isa = PBXBuildFile; productRef = DABB5E322D98972F009A4B8A /* SDWebImageSVGCoder */; }; + DABB5E352D98973F009A4B8A /* SDWebImage in Frameworks */ = {isa = PBXBuildFile; productRef = DABB5E342D98973F009A4B8A /* SDWebImage */; }; DAC65FC7236EDF3900F4501E /* SpinnerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC65FC6236EDF3900F4501E /* SpinnerViewController.swift */; }; DAC6608D236F771600F4501E /* PreferencesSwiftUIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC6608C236F771600F4501E /* PreferencesSwiftUIView.swift */; }; DAC9395522B00E7600C5F423 /* XCTestCaseExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC9395422B00E7600C5F423 /* XCTestCaseExtension.swift */; }; @@ -575,10 +576,11 @@ 6557AF922C039D140094D0C8 /* FirebaseMessaging in Frameworks */, DFB2622B18830A3600D3244D /* Foundation.framework in Frameworks */, 937E4485270B379900A98C26 /* DeviceKit in Frameworks */, + DABB5E332D98972F009A4B8A /* SDWebImageSVGCoder in Frameworks */, DFB2622F18830A3600D3244D /* UIKit.framework in Frameworks */, + DABB5E352D98973F009A4B8A /* SDWebImage in Frameworks */, DACE664A2C63B0760069E514 /* OpenAPIURLSession in Frameworks */, 93F8064A27AE7A2E0035A6B0 /* FlexColorPicker in Frameworks */, - 93F8065327AE7B580035A6B0 /* SVGKit in Frameworks */, DA28C362225241DE00AB409C /* WebKit.framework in Frameworks */, DACE664D2C63B0840069E514 /* OpenAPIRuntime in Frameworks */, 93F8065027AE7A830035A6B0 /* SideMenu in Frameworks */, @@ -1254,11 +1256,12 @@ 93F8064627AE7A050035A6B0 /* SwiftMessages */, 93F8064927AE7A2E0035A6B0 /* FlexColorPicker */, 93F8064F27AE7A830035A6B0 /* SideMenu */, - 93F8065227AE7B580035A6B0 /* SVGKit */, 6557AF912C039D140094D0C8 /* FirebaseMessaging */, DACE66492C63B0760069E514 /* OpenAPIURLSession */, DACE664C2C63B0840069E514 /* OpenAPIRuntime */, DA9A7EFE2D66915900824156 /* SFSafeSymbols */, + DABB5E322D98972F009A4B8A /* SDWebImageSVGCoder */, + DABB5E342D98973F009A4B8A /* SDWebImage */, ); productName = openHAB; productReference = DFB2622718830A3600D3244D /* openHAB.app */; @@ -1348,7 +1351,6 @@ 93F8064527AE7A050035A6B0 /* XCRemoteSwiftPackageReference "SwiftMessages" */, 93F8064827AE7A2E0035A6B0 /* XCRemoteSwiftPackageReference "FlexColorPicker" */, 93F8064E27AE7A820035A6B0 /* XCRemoteSwiftPackageReference "SideMenu" */, - 93F8065127AE7B580035A6B0 /* XCRemoteSwiftPackageReference "SVGKit" */, DACE66482C63B0760069E514 /* XCRemoteSwiftPackageReference "swift-openapi-urlsession" */, DACE664B2C63B0840069E514 /* XCRemoteSwiftPackageReference "swift-openapi-runtime" */, DA3B75AC2C59729200E219AB /* XCRemoteSwiftPackageReference "SFSafeSymbols" */, @@ -2742,14 +2744,6 @@ minimumVersion = 6.5.0; }; }; - 93F8065127AE7B580035A6B0 /* XCRemoteSwiftPackageReference "SVGKit" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/SVGKit/SVGKit.git"; - requirement = { - branch = 3.x; - kind = branch; - }; - }; DA2C4FCB2B4F55D600D1C533 /* XCRemoteSwiftPackageReference "SDWebImage" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/SDWebImage/SDWebImage.git"; @@ -2881,11 +2875,6 @@ package = 93F8064E27AE7A820035A6B0 /* XCRemoteSwiftPackageReference "SideMenu" */; productName = SideMenu; }; - 93F8065227AE7B580035A6B0 /* SVGKit */ = { - isa = XCSwiftPackageProductDependency; - package = 93F8065127AE7B580035A6B0 /* XCRemoteSwiftPackageReference "SVGKit" */; - productName = SVGKit; - }; DA2C4FCC2B4F55D700D1C533 /* SDWebImage */ = { isa = XCSwiftPackageProductDependency; package = DA2C4FCB2B4F55D600D1C533 /* XCRemoteSwiftPackageReference "SDWebImage" */; @@ -2916,6 +2905,16 @@ package = DA3B75AC2C59729200E219AB /* XCRemoteSwiftPackageReference "SFSafeSymbols" */; productName = SFSafeSymbols; }; + DABB5E322D98972F009A4B8A /* SDWebImageSVGCoder */ = { + isa = XCSwiftPackageProductDependency; + package = DA2C4FD32B4F573300D1C533 /* XCRemoteSwiftPackageReference "SDWebImageSVGCoder" */; + productName = SDWebImageSVGCoder; + }; + DABB5E342D98973F009A4B8A /* SDWebImage */ = { + isa = XCSwiftPackageProductDependency; + package = DA2C4FCB2B4F55D600D1C533 /* XCRemoteSwiftPackageReference "SDWebImage" */; + productName = SDWebImage; + }; DACE66492C63B0760069E514 /* OpenAPIURLSession */ = { isa = XCSwiftPackageProductDependency; package = DACE66482C63B0760069E514 /* XCRemoteSwiftPackageReference "swift-openapi-urlsession" */; diff --git a/openHAB.xcworkspace/xcshareddata/swiftpm/Package.resolved b/openHAB.xcworkspace/xcshareddata/swiftpm/Package.resolved index 7fa39b7bf..7d9a90dc0 100644 --- a/openHAB.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/openHAB.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "f60c1a47f871d46f44f67bd143f8264999978e92660c6e1cb2f2c922d2b6cf6b", + "originHash" : "a7d4f8386b1234b98fded7877ba21eb9f89b13361d34e36c212915c8b2c816cc", "pins" : [ { "identity" : "abseil-cpp-binary", @@ -19,15 +19,6 @@ "version" : "10.19.2" } }, - { - "identity" : "cocoalumberjack", - "kind" : "remoteSourceControl", - "location" : "https://github.com/CocoaLumberjack/CocoaLumberjack.git", - "state" : { - "revision" : "4b8714a7fb84d42393314ce897127b3939885ec3", - "version" : "3.8.5" - } - }, { "identity" : "devicekit", "kind" : "remoteSourceControl", @@ -190,15 +181,6 @@ "version" : "6.5.0" } }, - { - "identity" : "svgkit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/SVGKit/SVGKit.git", - "state" : { - "branch" : "3.x", - "revision" : "026d2168d07b621e4701a8b2aac6c1eaf05c1df5" - } - }, { "identity" : "swift-collections", "kind" : "remoteSourceControl", @@ -217,15 +199,6 @@ "version" : "1.3.1" } }, - { - "identity" : "swift-log", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-log", - "state" : { - "revision" : "96a2f8a0fa41e9e09af4585e2724c4e825410b91", - "version" : "1.6.2" - } - }, { "identity" : "swift-openapi-runtime", "kind" : "remoteSourceControl", diff --git a/openHAB/AppDelegate.swift b/openHAB/AppDelegate.swift index 037f4fd5a..7adc773ba 100644 --- a/openHAB/AppDelegate.swift +++ b/openHAB/AppDelegate.swift @@ -15,6 +15,7 @@ import FirebaseMessaging import Kingfisher import OpenHABCore import os.log +import SDWebImageSVGCoder import SwiftMessages import UIKit import UserNotifications @@ -99,6 +100,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate { activateWatchConnectivity() + let SVGCoder = SDImageSVGCoder.shared + SDImageCodersManager.shared.addCoder(SVGCoder) + return true } diff --git a/openHAB/OpenHABImageProcessor.swift b/openHAB/OpenHABImageProcessor.swift index 8492e4933..289ef8113 100644 --- a/openHAB/OpenHABImageProcessor.swift +++ b/openHAB/OpenHABImageProcessor.swift @@ -12,7 +12,8 @@ import Foundation import Kingfisher import os.log -import SVGKit +import SDWebImage +import SDWebImageSVGCoder struct OpenHABImageProcessor: ImageProcessor { // `identifier` should be the same for processors with the same properties/functionality @@ -32,15 +33,14 @@ struct OpenHABImageProcessor: ImageProcessor { case 0x3C: // svg // // 1000 || image.size.height > 1000 { - return UIImage(systemSymbol: .exclamationmarkTriangle).withTintColor(.orange) + if let image = SDImageSVGCoder.shared.decodedImage(with: data, options: nil) { + let size = image.size + if size.width > 1000 || size.height > 1000 { + return UIImage(systemName: "exclamationmark.triangle")?.withTintColor(.orange, renderingMode: .alwaysOriginal) } - return image.uiImage + return image } else { - return UIImage(systemSymbol: .exclamationmarkTriangle).withTintColor(.orange) + return UIImage(systemName: "exclamationmark.triangle")?.withTintColor(.orange, renderingMode: .alwaysOriginal) } default: return Kingfisher.DefaultImageProcessor().process(item: item, options: KingfisherParsedOptionsInfo(KingfisherManager.shared.defaultOptions)) diff --git a/openHAB/OpenHABSitemapViewController.swift b/openHAB/OpenHABSitemapViewController.swift index f974eb1ee..83f66d33a 100644 --- a/openHAB/OpenHABSitemapViewController.swift +++ b/openHAB/OpenHABSitemapViewController.swift @@ -19,7 +19,6 @@ import OpenAPIURLSession import OpenHABCore import os.log import SafariServices -import SVGKit import SwiftUI import UIKit diff --git a/openHAB/legal.rtf b/openHAB/legal.rtf index d99ae7870..5a4976d47 100644 --- a/openHAB/legal.rtf +++ b/openHAB/legal.rtf @@ -1,8 +1,8 @@ -{\rtf1\ansi\ansicpg1252\cocoartf2761 +{\rtf1\ansi\ansicpg1252\cocoartf2821 \cocoatextscaling0\cocoaplatform0{\fonttbl\f0\fnil\fcharset0 Menlo-Regular;} {\colortbl;\red255\green255\blue255;} {\*\expandedcolortbl;;} -\margl1440\margr1440\vieww17380\viewh8400\viewkind0 +\paperw12240\paperh15840\margl1440\margr1440\vieww17380\viewh8400\viewkind0 \pard\tx560\tx1120\tx1680\tx2240\tx2800\tx3360\tx3920\tx4480\tx5040\tx5600\tx6160\tx6720\pardirnatural\partightenfactor0 \f0\fs24 \cf0 Copyright (c) 2010-2024, Contributors to the openHAB project\ @@ -571,28 +571,27 @@ Copyright [yyyy][name of copyright owner]\ Licensed under the Apache License, Version 2.0 (the \'93License\'94); you may not use this file except in compliance with the License. You may obtain a copy of the License at\ http://www.apache.org/licenses/LICENSE-2.0\ Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an \'93AS IS\'94 BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.\ -SVGKit\ -Copyright (c) 2010\'962011 Matt Rajca, 2011\'962015 various authors Parts Copyright (c) Tipbit Inc\ -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the \'93Software\'94), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:\ -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\ -THE SOFTWARE IS PROVIDED \'93AS IS\'94, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\ +\ SwiftFormat\ MIT License\ Copyright (c) 2016 Nick Lockwood\ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the \'93Software\'94), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:\ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\ THE SOFTWARE IS PROVIDED \'93AS IS\'94, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\ +\ SwiftLint\ The MIT License (MIT)\ Copyright (c) 2015 Realm Inc.\ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the \'93Software\'94), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:\ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\ THE SOFTWARE IS PROVIDED \'93AS IS\'94, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\ +\ SwiftMessages\ Copyright (c) 2016 SwiftKick Mobile LLC\ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the \'93Software\'94), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:\ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\ THE SOFTWARE IS PROVIDED \'93AS IS\'94, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\ +\ nanopb\ Copyright (c) 2011 Petteri Aimonen \ This software is provided \'91as-is\'92, without any express or implied warranty. In no event will the authors be held liable for any damages arising from the use of this software.\ diff --git a/openHABTestsSwift/OpenHABSVGTests.swift b/openHABTestsSwift/OpenHABSVGTests.swift index af10dc851..bbeb781c6 100644 --- a/openHABTestsSwift/OpenHABSVGTests.swift +++ b/openHABTestsSwift/OpenHABSVGTests.swift @@ -9,96 +9,45 @@ // // SPDX-License-Identifier: EPL-2.0 -import SVGKit +import SDWebImageSVGCoder import XCTest class OpenHABSVGTests: XCTestCase { override func setUpWithError() throws { - // Put setup code here. This method is called before the invocation of each test method in the class. + SDImageCodersManager.shared.addCoder(SDImageSVGCoder.shared) } override func tearDownWithError() throws { - // Put teardown code here. This method is called after the invocation of each test method in the class. + // Optional: Remove coder if needed } - /// Invalid SVG - func testInvalidXMLNS() throws { - let svgTestFile = "invalid_xmlns" - // xmlns is defined by referring to DTD ENTITY. - - do { - let url = Bundle(for: Self.self).url(forResource: svgTestFile, withExtension: "svg") - let data = try Data(contentsOf: url!) - let svgkSourceNSData = SVGKSourceNSData.source(from: data, urlForRelativeLinks: nil) - let parseResults = SVGKParser.parseSource(usingDefaultSVGKParser: svgkSourceNSData) - - XCTAssertEqual(parseResults?.parsedDocument, nil, "parsedDocument not empty though it was expected to be because XML is invalid") - XCTAssertEqual(parseResults?.errorsFatal.count ?? 0, 0, "No errorsFatal expected") - - if let firstWarning = parseResults?.warnings[0] as? Error { - XCTAssertEqual(firstWarning.localizedDescription, "xmlns: URI &ns_svg; is not absolute\n") - } else { - XCTFail("Expected a warning but found none or an unexpected type") - } - } catch { - XCTFail("Whoops, an unexpected error occurred while unit testing SVG rendering: \(error)") + func decodeSVG(named name: String) throws -> UIImage? { + guard let url = Bundle(for: Self.self).url(forResource: name, withExtension: "svg") else { + throw NSError(domain: "TestError", code: 1, userInfo: [NSLocalizedDescriptionKey: "SVG file not found"]) } + let data = try Data(contentsOf: url) + return SDImageSVGCoder.shared.decodedImage(with: data, options: nil) } - func testUseTagPoints2NonExistentElement() throws { - let svgTestFile = "pantryUseTagPoints2NonExistentElement" - - do { - let url = Bundle(for: Self.self).url(forResource: svgTestFile, withExtension: "svg") - let data = try Data(contentsOf: url!) - let svgkSourceNSData = SVGKSourceNSData.source(from: data, urlForRelativeLinks: nil) - let parseResults = SVGKParser.parseSource(usingDefaultSVGKParser: svgkSourceNSData) - XCTAssertNotEqual(parseResults?.parsedDocument, nil, "Non nil parsedDocument expected") - XCTAssertNotEqual(parseResults?.errorsFatal.count, 0, "errorsFatal are 0") - if let fatalError = parseResults?.errorsFatal[0] as? Error { - XCTAssertEqual(fatalError.localizedDescription, "Exception = Found an SVG tag that points to a non-existent element. Missing element: id = e") - } else { - XCTFail("Expected a fatal error but found none or an unexpected type") - } - } catch { - XCTFail("Whoops, an unexpected error occured while unit testing SVG rendering") - } + // ✅ Valid SVG test + func testValidSVGWithEmbeddedPNG() throws { + let image = try decodeSVG(named: "embeddedpng_valid") + XCTAssertNotNil(image, "Expected image to be decoded successfully") } - /// Valid SVG - /// - - func testValidEmbeddedPNG() throws { - let svgTestFile = "embeddedpng_valid" - - do { - let url = Bundle(for: Self.self).url(forResource: svgTestFile, withExtension: "svg") - let data = try Data(contentsOf: url!) - let svgkSourceNSData = SVGKSourceNSData.source(from: data, urlForRelativeLinks: nil) - let parseResults = SVGKParser.parseSource(usingDefaultSVGKParser: svgkSourceNSData) - let image = SVGKImage(parsedSVG: parseResults, from: svgkSourceNSData) - XCTAssertNotEqual(parseResults?.parsedDocument, nil, "Non nil parsedDocument expected") - XCTAssertEqual(parseResults?.errorsFatal.count, 0, "No errorsFatal expected") - XCTAssertNotEqual(image, nil, "Conversion to image not feasible") - } catch { - XCTFail("Whoops, an unexpected error occured while unit testing SVG rendering") - } + func testValidSVGWithXMLNS() throws { + let image = try decodeSVG(named: "valid_xmlns") + XCTAssertNotNil(image, "Expected image to be decoded successfully") } - func testValidXMLNS() throws { - let svgTestFile = "valid_xmlns" + // ❌ Invalid SVGs for SVGKit. They are working for SDWebImageSVGCoder + func testInvalidXMLNS() throws { + let image = try decodeSVG(named: "invalid_xmlns") + XCTAssertNotNil(image, "Expected image to be decoded successfully") + } - do { - let url = Bundle(for: Self.self).url(forResource: svgTestFile, withExtension: "svg") - let data = try Data(contentsOf: url!) - let svgkSourceNSData = SVGKSourceNSData.source(from: data, urlForRelativeLinks: nil) - let parseResults = SVGKParser.parseSource(usingDefaultSVGKParser: svgkSourceNSData) - let image = SVGKImage(parsedSVG: parseResults, from: svgkSourceNSData) - XCTAssertNotEqual(parseResults?.parsedDocument, nil, "Non nil parsedDocument expected") - XCTAssertEqual(parseResults?.errorsFatal.count, 0, "No errorsFatal expected") - XCTAssertNotEqual(image, nil, "Conversion to image not feasible") - } catch { - XCTFail("Whoops, an unexpected error occured while unit testing SVG rendering") - } + func testUseTagPoints2NonExistentElement() throws { + let image = try decodeSVG(named: "pantryUseTagPoints2NonExistentElement") + XCTAssertNotNil(image, "Expected image to be decoded successfully") } } From 4038bd022703cff9008d07cfe0dae1a22abcfa27 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Sun, 30 Mar 2025 23:14:08 +0200 Subject: [PATCH 118/476] Expanding testability of NetworkTracker Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../OpenHABCore/Util/NetworkTracker.swift | 74 ++++--- .../OpenHABCore/Util/OpenAPIService.swift | 13 ++ .../OpenHABCore/Util/PathMonitoring.swift | 55 ++++++ .../ConnectionFailureTrackerTests.swift | 49 +++++ .../ConnectionPoolTests.swift | 27 +++ .../NetworkTrackerTests.swift | 183 ++++++++++++++++++ 6 files changed, 374 insertions(+), 27 deletions(-) create mode 100644 OpenHABCore/Sources/OpenHABCore/Util/PathMonitoring.swift create mode 100644 OpenHABCore/Tests/OpenHABCoreTests/ConnectionFailureTrackerTests.swift create mode 100644 OpenHABCore/Tests/OpenHABCoreTests/ConnectionPoolTests.swift create mode 100644 OpenHABCore/Tests/OpenHABCoreTests/NetworkTrackerTests.swift diff --git a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift index dd8d56ec6..b5253f7fa 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift @@ -47,25 +47,30 @@ public enum NetworkTrackerError: Error, CustomDebugStringConvertible { // Prevent race conditions. // Ensure thread-safe dictionary access. // Avoid memory corruption errors like unrecognized selector. -actor ConnectionPool { - private var services: [ConnectionConfiguration: OpenAPIService] = [:] +public actor ConnectionPool { + private var services: [ConnectionConfiguration: OpenAPIServiceProtocol] = [:] + private let serviceFactory: (ConnectionConfiguration) -> OpenAPIServiceProtocol + + // Initializer allowing the injection of mocked OpenAPIServiceProtocol + init(serviceFactory: @escaping (ConnectionConfiguration) -> OpenAPIServiceProtocol = { + OpenAPIService(connectionConfiguration: $0, configuration: .shortTerm) + }) { + self.serviceFactory = serviceFactory + } @discardableResult - func getOrCreateService(for configuration: ConnectionConfiguration) async -> OpenAPIService { - if let existingService = services[configuration] { - return existingService + func getOrCreateService(for configuration: ConnectionConfiguration) async -> OpenAPIServiceProtocol { + if let existing = services[configuration] { + return existing } - let newService = OpenAPIService( - connectionConfiguration: configuration, - configuration: .shortTerm - ) + let newService = serviceFactory(configuration) services[configuration] = newService return newService } } // Ensures a thread safe access to failureCounts dictionary -actor ConnectionFailureTracker { +public actor ConnectionFailureTracker { private var failureCounts: [ConnectionConfiguration: Int] = [:] private let maxFailures = 3 @@ -98,14 +103,14 @@ public final class NetworkTracker: ObservableObject { // @MainActor @Published public private(set) var status: NetworkStatus = .connecting - private let monitor = NWPathMonitor() - private let monitorQueue = DispatchQueue.global(qos: .background) - private var connectionPool: ConnectionPool = .init() + private var pathMonitor: NWPathMonitoring + private var monitorQueue: DispatchQueue + private var connectionPool: ConnectionPool private var connectionConfigurations: [ConnectionConfiguration] = [] private var retryTask: Task? private let disconnectedRetryInterval: UInt64 = 30 // / amount of time we scan when not connected - private var failureTracker = ConnectionFailureTracker() + private var failureTracker: ConnectionFailureTracker // TODO: remove public var clientCertificateManager = ClientCertificateManager() @@ -115,21 +120,36 @@ public final class NetworkTracker: ObservableObject { private let logger = Logger(subsystem: "org.openhab.core", category: "NetworkTracker") private init() { -// if #available(iOS 17, watchOS 10, *) { -// // The `for await` loop automatically handles updates from NWPathMonitor, so there’s no need for a callback. -// Task { -// let monitor = NWPathMonitor() -// for await path in monitor { -// await handleNetworkChange(isConnected: path.status == .satisfied) -// } -// } -// } else { - monitor.pathUpdateHandler = { [weak self] path in + monitorQueue = DispatchQueue.global(qos: .background) + pathMonitor = RealPathMonitor() + connectionPool = ConnectionPool() + failureTracker = ConnectionFailureTracker() + + pathMonitor.setUpdateHandler { [weak self] isConnected in + Task.detached(priority: .utility) { + await self?.handleNetworkChange(isConnected: isConnected) + } + } + pathMonitor.start(queue: monitorQueue) + } + + // MARK: - Injectable initializer for testing + + init(monitor: NWPathMonitoring, + monitorQueue: DispatchQueue, + connectionPool: ConnectionPool, + failureTracker: ConnectionFailureTracker) { + pathMonitor = monitor + self.monitorQueue = monitorQueue + self.connectionPool = connectionPool + self.failureTracker = failureTracker + + pathMonitor.setUpdateHandler { [weak self] isConnected in Task.detached(priority: .utility) { - await self?.handleNetworkChange(isConnected: path.status == .satisfied) + await self?.handleNetworkChange(isConnected: isConnected) } } - monitor.start(queue: monitorQueue) + pathMonitor.start(queue: monitorQueue) } public func startTracking(connectionConfigurations: [ConnectionConfiguration]) { @@ -283,7 +303,7 @@ public final class NetworkTracker: ObservableObject { return nil } catch let error as OpenAPIServiceError { switch error { - case let .undocumented(statusCode, payload): + case let .undocumented(statusCode, _): logger.info("Undocumented status code: ") // \(statusCode), ") // payload: \(String(describing: payload))") return nil default: diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift index 3af15a9a2..8e5de67f7 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift @@ -28,6 +28,17 @@ public enum OpenAPIServiceConfiguration { case longTerm } +protocol OpenAPIServiceProtocol: AnyObject, Sendable { + func getRootVersion() async throws -> Int + func getRoot() async throws -> OpenHABServerProperties + func sendItemCommand(itemname: String, command: String) async throws + func updateItemState(itemname: String, with: String) async throws + func getItems() async throws -> [OpenHABItem] + func getItemByName(id: String) async throws -> OpenHABItem? + func pollDataForPage(sitemapname: String, pageId: String, longPolling: Bool) async throws -> OpenHABPage? + func runNow(ruleUID: String, payload: [String: any Sendable]) async throws +} + // The generated OpenAPI client is wrapped by this curated API. // The library leaks the fact that it uses Swift OpenAPI Generator under the hood in 'openHABSitemapWidgetEvents'. // It will require the migration to Swift 6.1 before this can be changed. @@ -296,3 +307,5 @@ public extension OpenAPIService { _ = try response.ok } } + +extension OpenAPIService: OpenAPIServiceProtocol {} diff --git a/OpenHABCore/Sources/OpenHABCore/Util/PathMonitoring.swift b/OpenHABCore/Sources/OpenHABCore/Util/PathMonitoring.swift new file mode 100644 index 000000000..9118d6876 --- /dev/null +++ b/OpenHABCore/Sources/OpenHABCore/Util/PathMonitoring.swift @@ -0,0 +1,55 @@ +// Copyright (c) 2010-2025 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 Foundation +import Network + +// MARK: - Protocol + +public protocol NWPathMonitoring: AnyObject { + /// Called with `true` when connected, `false` otherwise. + func setUpdateHandler(_ handler: @escaping (Bool) -> Void) + func start(queue: DispatchQueue) + func cancel() +} + +// Wrap real NWPathMonitor +final class RealPathMonitor: NWPathMonitoring { + private let monitor: NWPathMonitor + private var task: Task? + + init() { + monitor = NWPathMonitor() + } + + func setUpdateHandler(_ handler: @escaping (Bool) -> Void) { + if #available(iOS 17, watchOS 10, *) { + task = Task { + for await path in monitor { + handler(path.status == .satisfied) + } + } + } else { + monitor.pathUpdateHandler = { path in + handler(path.status == .satisfied) + } + } + } + + func start(queue: DispatchQueue) { + monitor.start(queue: queue) + } + + func cancel() { + monitor.cancel() + task?.cancel() + } +} diff --git a/OpenHABCore/Tests/OpenHABCoreTests/ConnectionFailureTrackerTests.swift b/OpenHABCore/Tests/OpenHABCoreTests/ConnectionFailureTrackerTests.swift new file mode 100644 index 000000000..bc5b7d6ee --- /dev/null +++ b/OpenHABCore/Tests/OpenHABCoreTests/ConnectionFailureTrackerTests.swift @@ -0,0 +1,49 @@ +// Copyright (c) 2010-2025 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 Foundation + +@testable import OpenHABCore +import XCTest + +final class ConnectionFailureTrackerTests: XCTestCase { + func testShouldAttemptLogic() async { + let tracker = ConnectionFailureTracker() + let config = ConnectionConfiguration(url: "http://test", username: "", password: "", priority: 1) + + var result = await tracker.shouldAttempt(config) + XCTAssertTrue(result) + + await tracker.recordFailure(config) + await tracker.recordFailure(config) + await tracker.recordFailure(config) + + result = await tracker.shouldAttempt(config) + XCTAssertFalse(result) + + await tracker.reset(config) + result = await tracker.shouldAttempt(config) + XCTAssertTrue(result) + } + + func testMaxFailureCount() async { + let tracker = ConnectionFailureTracker() + let config1 = ConnectionConfiguration(url: "http://a", username: "", password: "", priority: 0) + let config2 = ConnectionConfiguration(url: "http://b", username: "", password: "", priority: 1) + + await tracker.recordFailure(config1) + await tracker.recordFailure(config1) + await tracker.recordFailure(config2) + + let max = await tracker.maxFailureCount() + XCTAssertEqual(max, 2) + } +} diff --git a/OpenHABCore/Tests/OpenHABCoreTests/ConnectionPoolTests.swift b/OpenHABCore/Tests/OpenHABCoreTests/ConnectionPoolTests.swift new file mode 100644 index 000000000..4a86e68f1 --- /dev/null +++ b/OpenHABCore/Tests/OpenHABCoreTests/ConnectionPoolTests.swift @@ -0,0 +1,27 @@ +// Copyright (c) 2010-2025 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 Foundation + +@testable import OpenHABCore +import XCTest + +final class ConnectionPoolTests: XCTestCase { + func testGetOrCreateServiceReturnsSameInstance() async { + let pool = ConnectionPool() + let config = ConnectionConfiguration(url: "http://test", username: "", password: "", priority: 1) + + let service1 = await pool.getOrCreateService(for: config) + let service2 = await pool.getOrCreateService(for: config) + + XCTAssertTrue(service1 === service2) + } +} diff --git a/OpenHABCore/Tests/OpenHABCoreTests/NetworkTrackerTests.swift b/OpenHABCore/Tests/OpenHABCoreTests/NetworkTrackerTests.swift new file mode 100644 index 000000000..99b5b5fdb --- /dev/null +++ b/OpenHABCore/Tests/OpenHABCoreTests/NetworkTrackerTests.swift @@ -0,0 +1,183 @@ +// Copyright (c) 2010-2025 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 Combine +import Foundation +import Network + +@testable import OpenHABCore +import XCTest + +final actor MockOpenAPIService: OpenAPIServiceProtocol { + var shouldFail = false + var returnedVersion = 123 + var mockServerProperties = OpenHABServerProperties(version: "", links: []) + + init(returnedVersion: Int = 123, shouldFail: Bool = false, mockServerProperties: OpenHABServerProperties = .init(version: "", links: [])) { + self.returnedVersion = returnedVersion + self.shouldFail = shouldFail + self.mockServerProperties = mockServerProperties + } + + func sendItemCommand(itemname: String, command: String) async throws { + if shouldFail { + throw NetworkTrackerError.failedConnection("http://mock") + } + } + + func updateItemState(itemname: String, with: String) async throws { + if shouldFail { + throw NetworkTrackerError.failedConnection("http://mock") + } + } + + func getItems() async throws -> [OpenHABCore.OpenHABItem] { + if shouldFail { + throw NetworkTrackerError.failedConnection("http://mock") + } + return [] + } + + func getItemByName(id: String) async throws -> OpenHABCore.OpenHABItem? { + if shouldFail { + throw NetworkTrackerError.failedConnection("http://mock") + } + return nil + } + + func pollDataForPage(sitemapname: String, pageId: String, longPolling: Bool) async throws -> OpenHABCore.OpenHABPage? { + if shouldFail { + throw NetworkTrackerError.failedConnection("http://mock") + } + return nil + } + + func runNow(ruleUID: String, payload: [String: any Sendable]) async throws { + if shouldFail { + throw NetworkTrackerError.failedConnection("http://mock") + } + } + + func getRootVersion() async throws -> Int { + if shouldFail { + throw NetworkTrackerError.failedConnection("http://mock") + } + return returnedVersion + } + + @discardableResult + func getRoot() async throws -> OpenHABServerProperties { + if shouldFail { + throw NetworkTrackerError.failedConnection("http://mock") + } + return mockServerProperties + } +} + +final class MockPathMonitor: PathMonitoring { + private var handler: ((Bool) -> Void)? + + init() {} + + func setUpdateHandler(_ handler: @escaping (Bool) -> Void) { + self.handler = handler + } + + func start(queue: DispatchQueue) { + // no-op + } + + func cancel() { + // no-op + } + + /// Call this in your tests to simulate a connection status change + func simulateConnection(isConnected: Bool) { + handler?(isConnected) + } +} + +final class NetworkTrackerTests: XCTestCase { + func testTrackerSetsConnectedStatusOnNetworkUp() async { + let expectation = XCTestExpectation(description: "Status becomes .connected") + let config = ConnectionConfiguration( + url: "http://mock", + username: "", + password: "", + priority: 0 + ) + + // Inject mock service + let mockService = MockOpenAPIService(returnedVersion: 8) + + let mockPool = ConnectionPool { _ in mockService } + let mockMonitor = MockPathMonitor() + + let tracker = NetworkTracker( + monitor: mockMonitor, + monitorQueue: .main, + connectionPool: mockPool, + failureTracker: ConnectionFailureTracker() + ) + + var cancellables = Set() + + tracker.$status + .dropFirst() // skip initial `.connecting` + .sink { status in + if status == .connected { + expectation.fulfill() + } + } + .store(in: &cancellables) + + // Start tracking with your mock config + tracker.startTracking(connectionConfigurations: [config]) + + // Simulate the network becoming available + mockMonitor.simulateConnection(isConnected: true) + + wait(for: [expectation], timeout: 2.0) + } + + func testTrackerGoesOfflineOnNetworkLoss() async { + let expectation = XCTestExpectation(description: "Status becomes .notConnected") + + let mockMonitor = MockPathMonitor() // ⬅️ Hold on to this + let tracker = NetworkTracker( + monitor: mockMonitor, + monitorQueue: .main, + connectionPool: ConnectionPool(serviceFactory: { _ in MockOpenAPIService() }), + failureTracker: ConnectionFailureTracker() + ) + + var cancellables = Set() + + tracker.$status + .dropFirst() + .sink { status in + if status == .notConnected { + expectation.fulfill() + } + } + .store(in: &cancellables) + + // Start tracking first to initialize properly + tracker.startTracking(connectionConfigurations: [ + ConnectionConfiguration(url: "http://mock", username: "", password: "", priority: 0) + ]) + + // Simulate loss of network + mockMonitor.simulateConnection(isConnected: false) // ✅ use directly + + wait(for: [expectation], timeout: 2.0) + } +} From b75bc1df44720a3c6167ad470b312dae463117f2 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Mon, 31 Mar 2025 13:07:07 +0200 Subject: [PATCH 119/476] Patch for NWPathMonitoring in test Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- OpenHABCore/Tests/OpenHABCoreTests/NetworkTrackerTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/OpenHABCore/Tests/OpenHABCoreTests/NetworkTrackerTests.swift b/OpenHABCore/Tests/OpenHABCoreTests/NetworkTrackerTests.swift index 99b5b5fdb..c43cd444b 100644 --- a/OpenHABCore/Tests/OpenHABCoreTests/NetworkTrackerTests.swift +++ b/OpenHABCore/Tests/OpenHABCoreTests/NetworkTrackerTests.swift @@ -82,7 +82,7 @@ final actor MockOpenAPIService: OpenAPIServiceProtocol { } } -final class MockPathMonitor: PathMonitoring { +final class MockPathMonitor: NWPathMonitoring { private var handler: ((Bool) -> Void)? init() {} From f38e4a6c50fac4530565d8221946e74f2509e5de Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Mon, 31 Mar 2025 19:38:20 +0200 Subject: [PATCH 120/476] Renamed file Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../Util/{PathMonitoring.swift => NWPathMonitoring.swift} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename OpenHABCore/Sources/OpenHABCore/Util/{PathMonitoring.swift => NWPathMonitoring.swift} (100%) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/PathMonitoring.swift b/OpenHABCore/Sources/OpenHABCore/Util/NWPathMonitoring.swift similarity index 100% rename from OpenHABCore/Sources/OpenHABCore/Util/PathMonitoring.swift rename to OpenHABCore/Sources/OpenHABCore/Util/NWPathMonitoring.swift From 723599e6ed85d10a6aa8abc953039636db4c6389 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Mon, 31 Mar 2025 20:38:53 +0200 Subject: [PATCH 121/476] Setting sitemap for watch in Settings and DrawerView Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- openHAB/DrawerView.swift | 1 + openHAB/SettingsView/SettingsView.swift | 37 +++++++++++++++++-- .../SingleConnectionSettingsView.swift | 4 +- .../SettingsView/SitemapSettingsView.swift | 13 ++++--- 4 files changed, 44 insertions(+), 11 deletions(-) diff --git a/openHAB/DrawerView.swift b/openHAB/DrawerView.swift index 8634431f7..578500d4c 100644 --- a/openHAB/DrawerView.swift +++ b/openHAB/DrawerView.swift @@ -267,6 +267,7 @@ struct DrawerView: View { .task { let activeConnection = networkTracker.activeConnection await updateSitemapsAndUITiles(activeConnection: activeConnection) + sitemapForWatch = Preferences.sitemapForWatch } .onReceive(networkTracker.$activeConnection) { activeConnection in Task { diff --git a/openHAB/SettingsView/SettingsView.swift b/openHAB/SettingsView/SettingsView.swift index 4492b7fb2..386bb5ee0 100644 --- a/openHAB/SettingsView/SettingsView.swift +++ b/openHAB/SettingsView/SettingsView.swift @@ -31,7 +31,7 @@ struct SettingsView: View { @State var settingsAlwaysAllowWebRTC = true @State var settingsSitemapForWatch = "" - @State private var sitemaps: [OpenHABSitemap] = [] + @State var sitemaps: [OpenHABSitemap] = [] @State var settingsLocalConnectionConfiguration = ConnectionConfiguration(url: "", username: "", password: "") @State var settingsRemoteConnectionConfiguration = ConnectionConfiguration(url: "", username: "", password: "") @@ -89,10 +89,32 @@ struct SettingsView: View { } .task { loadSettings() + let activeConfiguration = settingsLocalConnectionConfiguration + await updateSitemaps(activeConfiguration: activeConfiguration) } } - func loadSettings() { + private func updateSitemaps(activeConfiguration: ConnectionConfiguration) async { + let openAPIService = OpenAPIService(connectionConfiguration: activeConfiguration) + + do { + sitemaps = try await openAPIService.openHABSitemaps() + if sitemaps.last?.name == "_default", sitemaps.count > 1 { + sitemaps = Array(sitemaps.dropLast()) + } + + // Sort the sitemaps according to Settings selection. + switch SortSitemapsOrder(rawValue: Preferences.sortSitemapsby) ?? .label { + case .label: sitemaps.sort { $0.label < $1.label } + case .name: sitemaps.sort { $0.name < $1.name } + } + } catch { + os_log("%{PUBLIC}@", log: .default, type: .error, error.localizedDescription) + sitemaps = [] + } + } + + private func loadSettings() { #if !DEBUG logger.debug("Loading Settings") #endif @@ -171,14 +193,14 @@ extension UIApplication { icon: "", label: "Home", link: "http://192.168.1.100/rest/sitemaps/home", - page: nil // Replace with actual OpenHABPage if needed + page: nil ), OpenHABSitemap( name: "office", icon: "", label: "Office", link: "http://192.168.1.100/rest/sitemaps/office", - page: nil // Replace with actual OpenHABPage if needed + page: nil ) ] @State var localConnectionConfiguration = ConnectionConfiguration( @@ -210,10 +232,17 @@ extension UIApplication { settingsDefaultMainUIPath: settingsDefaultMainUIPath, settingsAlwaysAllowWebRTC: settingsAlwaysAllowWebRTC, settingsSitemapForWatch: settingsSitemapForWatch, + sitemaps: sitemaps, settingsLocalConnectionConfiguration: localConnectionConfiguration, settingsRemoteConnectionConfiguration: remoteConnectionConfiguration ) } + .onAppear { + // Mock behavior of updateSitemaps + if settingsSitemapForWatch.isEmpty, let first = sitemaps.first { + settingsSitemapForWatch = first.name + } + } } } return PreviewWrapper() diff --git a/openHAB/SettingsView/SingleConnectionSettingsView.swift b/openHAB/SettingsView/SingleConnectionSettingsView.swift index 168c3eb68..0199a1fa5 100644 --- a/openHAB/SettingsView/SingleConnectionSettingsView.swift +++ b/openHAB/SettingsView/SingleConnectionSettingsView.swift @@ -143,7 +143,7 @@ struct SingleConnectionSettingsView: View { private func friendlyMessage(for error: URLError) -> String { switch error.code { case .badURL: - "The URL is invalid. Please check the format (e.g., http://192.168.2.1)." + "The URL is invalid. Please check the format (e.g., http://192.168.2.1:8080)." case .cannotFindHost: "Cannot find the server. Is the URL correct?" case .cannotConnectToHost: @@ -164,7 +164,7 @@ struct SingleConnectionSettingsView: View { #Preview { struct PreviewWrapper: View { @State var connectionConfig = ConnectionConfiguration( - url: "http://192.168.2.1", + url: "http://192.168.2.1:8080", username: "user", password: "password123" ) diff --git a/openHAB/SettingsView/SitemapSettingsView.swift b/openHAB/SettingsView/SitemapSettingsView.swift index eac507edb..fc28972a9 100644 --- a/openHAB/SettingsView/SitemapSettingsView.swift +++ b/openHAB/SettingsView/SitemapSettingsView.swift @@ -57,13 +57,16 @@ struct SitemapSettingsView: View { Text("Sort sitemaps by") } - Picker(selection: $settingsSitemapForWatch) { - ForEach(sitemaps, id: \.name) { sitemap in - Text(sitemap.label) + Picker("Sitemap For Apple Watch", selection: $settingsSitemapForWatch) { + if sitemaps.isEmpty { + Text("No sitemaps available").tag("").foregroundColor(.secondary) + } else { + ForEach(sitemaps, id: \.name) { sitemap in + Text(sitemap.label).tag(sitemap.name) + } } - } label: { - Text("Sitemap For Apple Watch") } + .disabled(sitemaps.isEmpty) } } From dd214edd4182b71f0396e2212698cc66b9c30ea6 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Mon, 31 Mar 2025 22:34:50 +0200 Subject: [PATCH 122/476] Rebuilding the communication between watch and ios app Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../OpenHABCore/Util/WatchPreferences.swift | 54 +++++++++++++ openHAB/WatchMessageService.swift | 75 ++++++++++++------- openHABWatch/External/AppMessageService.swift | 45 ++++------- openHABWatch/OpenHABWatch.swift | 2 +- 4 files changed, 118 insertions(+), 58 deletions(-) create mode 100644 OpenHABCore/Sources/OpenHABCore/Util/WatchPreferences.swift diff --git a/OpenHABCore/Sources/OpenHABCore/Util/WatchPreferences.swift b/OpenHABCore/Sources/OpenHABCore/Util/WatchPreferences.swift new file mode 100644 index 000000000..2e9fe72e2 --- /dev/null +++ b/OpenHABCore/Sources/OpenHABCore/Util/WatchPreferences.swift @@ -0,0 +1,54 @@ +// Copyright (c) 2010-2025 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 Foundation +import os.log + +public struct WatchPreferences: Codable { + public init(localUrl: String, remoteUrl: String, username: String, password: String, alwaysSendCreds: Bool, defaultSitemap: String, ignoreSSL: Bool, sitemapForWatch: String, iconType: Int, demoMode: Bool, localConnectionConfiguration: ConnectionConfiguration? = nil, remoteConnectionConfiguration: ConnectionConfiguration? = nil) { + self.localUrl = localUrl + self.remoteUrl = remoteUrl + self.username = username + self.password = password + self.alwaysSendCreds = alwaysSendCreds + self.defaultSitemap = defaultSitemap + self.ignoreSSL = ignoreSSL + self.sitemapForWatch = sitemapForWatch + self.iconType = iconType + self.demoMode = demoMode + self.localConnectionConfiguration = localConnectionConfiguration + self.remoteConnectionConfiguration = remoteConnectionConfiguration + } + + public var localUrl: String + public var remoteUrl: String + public var username: String + public var password: String + public var alwaysSendCreds: Bool + public var defaultSitemap: String + public var ignoreSSL: Bool + public var sitemapForWatch: String + public var iconType: Int + public var demoMode: Bool + public var localConnectionConfiguration: ConnectionConfiguration? + public var remoteConnectionConfiguration: ConnectionConfiguration? + + public func encodedWatchPreferences() -> [String: Data] { + do { + let data = try JSONEncoder().encode(self) + return ["watchPreferences": data] + } catch { + Logger(subsystem: "org.openhab.app", category: "WatchPreferences") + .error("Failed to encode WatchPreferences: \(error.localizedDescription)") + return [:] + } + } +} diff --git a/openHAB/WatchMessageService.swift b/openHAB/WatchMessageService.swift index 8de052163..5f3f8c07d 100644 --- a/openHAB/WatchMessageService.swift +++ b/openHAB/WatchMessageService.swift @@ -19,54 +19,73 @@ import WatchConnectivity class WatchMessageService: NSObject, WCSessionDelegate { static let singleton = WatchMessageService() + private lazy var logger = Logger(subsystem: "org.openhab.app", category: "WatchMessageService") + // This method gets called when the watch requests the data func session(_ session: WCSession, didReceiveMessage message: [String: Any], replyHandler: @escaping ([String: Any]) -> Void) { - os_log("didReceiveMessage %{PUBLIC}@", log: .watch, type: .info, "\(message)") + logger.info("Received message with reply handler: \(message, privacy: .public)") - if message["request"] != nil { - let applicationDict = buildApplicationDict() - replyHandler(applicationDict) + guard message["request"] != nil else { + logger.warning("Invalid message: no 'request' key.") + return } + + let prefs = WatchPreferences(fromPreferences: Preferences.self) + replyHandler(prefs.encodedWatchPreferences()) + logger.debug("Sent WatchPreferences in replyHandler.") } func session(_ session: WCSession, didReceiveMessage message: [String: Any]) { - os_log("Received message: %{PUBLIC}@", log: .watch, type: .info, message) + logger.info("Received message (no reply): \(message, privacy: .public)") } func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) { - os_log("activationDidCompleteWith activationState %{PUBLIC}@ error: %{PUBLIC}@", log: .watch, type: .info, "\(activationState)", "\(String(describing: error))") + logger.info("WCSession activation completed. State: \(String(describing: activationState)), Error: \(String(describing: error))") } func sessionDidBecomeInactive(_ session: WCSession) { - os_log("sessionDidBecomeInactive", log: .watch, type: .info) + logger.info("WCSession became inactive.") } func sessionDidDeactivate(_ session: WCSession) { - os_log("sessionDidDeactivate", log: .watch, type: .info) + logger.info("WCSession deactivated.") } - func buildApplicationDict() -> [String: Any] { - let applicationDict: [String: Any] = - [ - "localUrl": Preferences.localUrl, - "remoteUrl": Preferences.remoteUrl, - "username": Preferences.username, - "password": Preferences.password, - "alwaysSendCreds": Preferences.alwaysSendCreds, - "defaultSitemap": Preferences.defaultSitemap, - "ignoreSSL": Preferences.ignoreSSL, - // "trustedCertificates": NetworkConnection.shared.serverCertificateManager.trustedCertificates, - "sitemapForWatch": Preferences.sitemapForWatch, - "iconType": Preferences.iconType - ] - - return applicationDict - } + // MARK: - Sync Preferences public func syncPreferencesToWatch() { - if WCSession.default.activationState == .activated { - let applicationDict = buildApplicationDict() - try? WCSession.default.updateApplicationContext(applicationDict) + guard WCSession.default.activationState == .activated else { + logger.warning("WCSession not activated; skipping sync.") + return + } + + let prefs = WatchPreferences(fromPreferences: Preferences.self) + let context = prefs.encodedWatchPreferences() + + do { + try WCSession.default.updateApplicationContext(context) + logger.debug("Successfully updated application context with WatchPreferences.") + } catch { + logger.error("Failed to encode or update watch context: \(error.localizedDescription)") } } } + +public extension WatchPreferences { + init(fromPreferences preferences: Preferences.Type) { + self.init( + localUrl: preferences.localUrl, + remoteUrl: preferences.remoteUrl, + username: preferences.username, + password: preferences.password, + alwaysSendCreds: preferences.alwaysSendCreds, + defaultSitemap: preferences.defaultSitemap, + ignoreSSL: preferences.ignoreSSL, + sitemapForWatch: preferences.sitemapForWatch, + iconType: preferences.iconType, + demoMode: preferences.demomode, + localConnectionConfiguration: preferences.localConnectionConfig, + remoteConnectionConfiguration: preferences.remoteConnectionConfig + ) + } +} diff --git a/openHABWatch/External/AppMessageService.swift b/openHABWatch/External/AppMessageService.swift index 8ffb3d6ec..e0c28a2a9 100644 --- a/openHABWatch/External/AppMessageService.swift +++ b/openHABWatch/External/AppMessageService.swift @@ -22,38 +22,27 @@ class AppMessageService: NSObject, WCSessionDelegate { private let logger = Logger(subsystem: "org.openhab.app.watchkitapp", category: "AppMessageService") func updateValuesFromApplicationContext(_ applicationContext: [String: AnyObject]) { - guard !applicationContext.isEmpty else { return } + guard let data = applicationContext["watchPreferences"] as? Data else { + logger.warning("⚠️ No 'watchPreferences' data found in applicationContext.") + return + } do { // Decode the connection payload - if let connectionPayloadDict = applicationContext["connectionPayload"] as? [String: Any] { - let data = try JSONSerialization.data(withJSONObject: connectionPayloadDict, options: []) - let payload = try JSONDecoder().decode(ConnectionPayload.self, from: data) - - ObservableOpenHABDataObject.shared.localConnectionConfig = payload.local - ObservableOpenHABDataObject.shared.remoteConnectionConfig = payload.remote - } - - if let sitemapName = applicationContext["defaultSitemap"] as? String { - ObservableOpenHABDataObject.shared.sitemapName = sitemapName - } - - if let sitemapForWatch = applicationContext["sitemapForWatch"] as? String { - ObservableOpenHABDataObject.shared.sitemapForWatch = sitemapForWatch - } - - if let trustedCertificates = applicationContext["trustedCertificates"] as? [String: Data] { - // do we need to do anything here? We load from the shared keychain. - } - - if let iconType = applicationContext["iconType"] as? IconType { - ObservableOpenHABDataObject.shared.iconType = iconType - } - + let prefs = try JSONDecoder().decode(WatchPreferences.self, from: data) + ObservableOpenHABDataObject.shared.localConnectionConfig = prefs.localConnectionConfiguration ?? .localDefault + ObservableOpenHABDataObject.shared.remoteConnectionConfig = prefs.remoteConnectionConfiguration ?? .remoteDefault + ObservableOpenHABDataObject.shared.sitemapName = prefs.defaultSitemap + ObservableOpenHABDataObject.shared.sitemapForWatch = prefs.sitemapForWatch + ObservableOpenHABDataObject.shared.iconType = IconType(rawValue: prefs.iconType) ?? .svg ObservableOpenHABDataObject.shared.haveReceivedAppContext = true - + // if let trustedCertificates = applicationContext["trustedCertificates"] as? [String: Data] { + // // do we need to do anything here? We load from the shared keychain. + // } + ObservableOpenHABDataObject.shared.haveReceivedAppContext = true + logger.info("✅ Applied WatchPreferences to ObservableOpenHABDataObject") } catch { - logger.error("Failed to decode ConnectionPayload: \(error.localizedDescription)") + logger.error("❌ Failed to decode WatchPreferences: \(error.localizedDescription)") } } @@ -61,8 +50,6 @@ class AppMessageService: NSObject, WCSessionDelegate { WCSession.default.sendMessage( ["request": "Preferences"], replyHandler: { response in - let filteredMessages = response.filter { ["remoteUrl", "localUrl", "username"].contains($0.key) } - self.logger.info("Received \(filteredMessages)") DispatchQueue.main.async { () in self.updateValuesFromApplicationContext(response as [String: AnyObject]) diff --git a/openHABWatch/OpenHABWatch.swift b/openHABWatch/OpenHABWatch.swift index ff71728c8..49eb3c648 100644 --- a/openHABWatch/OpenHABWatch.swift +++ b/openHABWatch/OpenHABWatch.swift @@ -20,7 +20,7 @@ struct OpenHABWatch: App { @ObservedObject var settings = ObservableOpenHABDataObject.shared // https://developer.apple.com/documentation/watchkit/wkapplicationdelegate @WKApplicationDelegateAdaptor(OpenHABWatchAppDelegate.self) var appDelegate - @ObservedObject var userData = UserData(sitemapName: ObservableOpenHABDataObject.shared.sitemapName) + @ObservedObject var userData = ObservableOpenHABDataObject(sitemapName: ObservableOpenHABDataObject.shared.sitemapName) var body: some Scene { WindowGroup { From d299a1e70d0e630604d946ff1c5ff6bec948e644 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Mon, 31 Mar 2025 23:23:34 +0200 Subject: [PATCH 123/476] Renamed ObservableOpenHABDataObject into AppSettings Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../OpenHABCore/Model/OpenHABWidget.swift | 2 +- openHAB.xcodeproj/project.pbxproj | 8 +-- openHABWatch/Domain/UserData.swift | 51 ++++++++++--------- openHABWatch/External/AppMessageService.swift | 14 ++--- ...nHABDataObject.swift => AppSettings.swift} | 11 +--- .../Model/OpenHABWidgetExtension.swift | 2 +- openHABWatch/OpenHABWatch.swift | 5 +- openHABWatch/Views/ContentView.swift | 4 +- .../Views/PreferencesSwiftUIView.swift | 4 +- openHABWatch/Views/Rows/ColorPickerRow.swift | 4 +- openHABWatch/Views/Rows/FrameRow.swift | 4 +- openHABWatch/Views/Rows/GenericRow.swift | 4 +- openHABWatch/Views/Rows/ImageRawRow.swift | 4 +- openHABWatch/Views/Rows/ImageRow.swift | 4 +- openHABWatch/Views/Rows/MapViewRow.swift | 4 +- .../Views/Rows/RollershutterRow.swift | 4 +- openHABWatch/Views/Rows/SegmentRow.swift | 4 +- openHABWatch/Views/Rows/SetpointRow.swift | 4 +- openHABWatch/Views/Rows/SliderRow.swift | 4 +- .../Rows/SliderWithSwitchSupportRow.swift | 4 +- openHABWatch/Views/Rows/SwitchRow.swift | 4 +- openHABWatch/Views/Utils/IconView.swift | 43 ++++++++++++++-- 22 files changed, 112 insertions(+), 80 deletions(-) rename openHABWatch/Model/{ObservableOpenHABDataObject.swift => AppSettings.swift} (74%) diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift index 6685f4d5e..6299d5aec 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift @@ -255,7 +255,7 @@ public class OpenHABWidget: NSObject, MKAnnotation, Identifiable, ObservableObje extension OpenHABWidget { // This is an ugly initializer - convenience init(widgetId: String, label: String, icon: String, type: WidgetType, url: String?, period: String?, minValue: Double?, maxValue: Double?, step: Double?, refresh: Int?, height: Double?, isLeaf: Bool?, iconColor: String?, labelColor: String?, valueColor: String?, service: String?, state: String?, text: String?, legend: Bool?, inputHint: InputHint?, encoding: String?, item: OpenHABItem?, linkedPage: OpenHABPage?, mappings: [OpenHABWidgetMapping], widgets: [OpenHABWidget], visibility: Bool?, switchSupport: Bool?, forceAsItem: Bool?) { + public convenience init(widgetId: String, label: String, icon: String, type: WidgetType, url: String?, period: String?, minValue: Double?, maxValue: Double?, step: Double?, refresh: Int?, height: Double?, isLeaf: Bool?, iconColor: String?, labelColor: String?, valueColor: String?, service: String?, state: String?, text: String?, legend: Bool?, inputHint: InputHint?, encoding: String?, item: OpenHABItem?, linkedPage: OpenHABPage?, mappings: [OpenHABWidgetMapping], widgets: [OpenHABWidget], visibility: Bool?, switchSupport: Bool?, forceAsItem: Bool?) { self.init() id = widgetId self.widgetId = widgetId diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index 60ee96c3e..b6f56b6d8 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -74,7 +74,7 @@ DA07764A234683BC0086C685 /* SwitchRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA077649234683BC0086C685 /* SwitchRow.swift */; }; DA0776F0234788010086C685 /* UserData.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA0776EF234788010086C685 /* UserData.swift */; }; DA0F37D023D4ACC7007EAB48 /* SliderRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA0F37CF23D4ACC7007EAB48 /* SliderRow.swift */; }; - DA15BFBD23C6726400BD8ADA /* ObservableOpenHABDataObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA15BFBC23C6726400BD8ADA /* ObservableOpenHABDataObject.swift */; }; + DA15BFBD23C6726400BD8ADA /* AppSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA15BFBC23C6726400BD8ADA /* AppSettings.swift */; }; DA162BEC2CD3B53E0040DAE5 /* LogsViewer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA162BEB2CD3B53E0040DAE5 /* LogsViewer.swift */; }; DA19E25B22FD801D002F8F2F /* OpenHABGeneralTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA19E25A22FD801D002F8F2F /* OpenHABGeneralTests.swift */; }; DA21EAE22339621C001AB415 /* Throttler.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA21EAE12339621C001AB415 /* Throttler.swift */; }; @@ -358,7 +358,7 @@ DA077649234683BC0086C685 /* SwitchRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwitchRow.swift; sourceTree = ""; }; DA0776EF234788010086C685 /* UserData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserData.swift; sourceTree = ""; }; DA0F37CF23D4ACC7007EAB48 /* SliderRow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SliderRow.swift; sourceTree = ""; }; - DA15BFBC23C6726400BD8ADA /* ObservableOpenHABDataObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObservableOpenHABDataObject.swift; sourceTree = ""; }; + DA15BFBC23C6726400BD8ADA /* AppSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettings.swift; sourceTree = ""; }; DA162BEB2CD3B53E0040DAE5 /* LogsViewer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogsViewer.swift; sourceTree = ""; }; DA19E25A22FD801D002F8F2F /* OpenHABGeneralTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenHABGeneralTests.swift; sourceTree = ""; }; DA1C2E4B230DC28F00FACFB0 /* Appfile */ = {isa = PBXFileReference; lastKnownFileType = text; path = Appfile; sourceTree = ""; }; @@ -679,7 +679,7 @@ 93F38D4623803731001B1451 /* Model */ = { isa = PBXGroup; children = ( - DA15BFBC23C6726400BD8ADA /* ObservableOpenHABDataObject.swift */, + DA15BFBC23C6726400BD8ADA /* AppSettings.swift */, DA9721C224E29A8F0092CCFD /* UserDefaultsBacked.swift */, DAC9AF4624F9669F006DAE93 /* OpenHABWidgetExtension.swift */, DAC9AF4824F966FA006DAE93 /* LazyView.swift */, @@ -1518,7 +1518,7 @@ DACA368E2D7440B9003CD237 /* OpenHABWidgetExtension.swift in Sources */, 65DAE9122D6FCB1A00E99582 /* DownloadableImageView.swift in Sources */, DA2E0B0E23DCC153009B0A99 /* MapView.swift in Sources */, - DA15BFBD23C6726400BD8ADA /* ObservableOpenHABDataObject.swift in Sources */, + DA15BFBD23C6726400BD8ADA /* AppSettings.swift in Sources */, DA2E0B1023DCC439009B0A99 /* MapViewRow.swift in Sources */, DA0F37D023D4ACC7007EAB48 /* SliderRow.swift in Sources */, DA32D1B42C8C98C40018D974 /* IconWithAction.swift in Sources */, diff --git a/openHABWatch/Domain/UserData.swift b/openHABWatch/Domain/UserData.swift index 45625d25a..3fc60cf38 100644 --- a/openHABWatch/Domain/UserData.swift +++ b/openHABWatch/Domain/UserData.swift @@ -9,17 +9,7 @@ // // SPDX-License-Identifier: EPL-2.0 -//// Copyright (c) 2010-2025 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 Combine import Foundation import OpenHABCore import os.log @@ -40,8 +30,10 @@ final class UserData: ObservableObject { var currentClient: HTTPClient? private let logger = Logger(subsystem: "org.openhab.app.watchkitapp", category: "UserData") + + private var cancellables = Set() - init(sitemapName: String = "watch") { + init() { NotificationCenter.default.addObserver( forName: .evaluateServerTrust, object: nil, @@ -82,10 +74,19 @@ final class UserData: ObservableObject { NetworkTracker.shared.restartTracking() } - Task { - await updateNetwork() - await observeNetworkChanges() - } + AppSettings.shared.$haveReceivedAppContext + .removeDuplicates() + .filter { $0 == true } + .sink { [weak self] _ in + Task { + await self?.updateNetwork() + } + } + .store(in: &cancellables) + + Task { + await observeNetworkChanges() + } } /// Observes network connection changes and updates state @@ -95,15 +96,15 @@ final class UserData: ObservableObject { logger.info("openHABTracked: \(activeConnection.configuration.url)") - if !ObservableOpenHABDataObject.shared.haveReceivedAppContext { + if !AppSettings.shared.haveReceivedAppContext { AppMessageService.singleton.requestApplicationContext() errorDescription = NSLocalizedString("settings_not_received", comment: "") showAlert = true continue } - ObservableOpenHABDataObject.shared.openHABRootUrl = activeConnection.configuration.url - ObservableOpenHABDataObject.shared.openHABVersion = activeConnection.version + AppSettings.shared.openHABRootUrl = activeConnection.configuration.url + AppSettings.shared.openHABVersion = activeConnection.version let alwaysSendBasicAuth = activeConnection.configuration.alwaysSendBasicAuth let username = activeConnection.configuration.username @@ -126,13 +127,13 @@ final class UserData: ObservableObject { } SDWebImageDownloader.shared.requestModifier = requestModifier - await loadPage(sitemapName: ObservableOpenHABDataObject.shared.sitemapForWatch, longPolling: false, refresh: true) + await loadPage(sitemapName: AppSettings.shared.sitemapForWatch, longPolling: false, refresh: true) } } func updateNetwork() async { - guard let connection1 = ObservableOpenHABDataObject.shared.localConnectionConfig, - let connection2 = ObservableOpenHABDataObject.shared.remoteConnectionConfig else { + guard let connection1 = AppSettings.shared.localConnectionConfig, + let connection2 = AppSettings.shared.remoteConnectionConfig else { logger.info("No connections defined") return } @@ -172,10 +173,10 @@ final class UserData: ObservableObject { } func refreshUrl() async { - guard ObservableOpenHABDataObject.shared.haveReceivedAppContext, - !ObservableOpenHABDataObject.shared.openHABRootUrl.isEmpty else { return } + guard AppSettings.shared.haveReceivedAppContext, + !AppSettings.shared.openHABRootUrl.isEmpty else { return } showAlert = false - await loadPage(sitemapName: ObservableOpenHABDataObject.shared.sitemapForWatch, longPolling: false, refresh: true) + await loadPage(sitemapName: AppSettings.shared.sitemapForWatch, longPolling: false, refresh: true) } } diff --git a/openHABWatch/External/AppMessageService.swift b/openHABWatch/External/AppMessageService.swift index e0c28a2a9..9f1032cde 100644 --- a/openHABWatch/External/AppMessageService.swift +++ b/openHABWatch/External/AppMessageService.swift @@ -30,16 +30,16 @@ class AppMessageService: NSObject, WCSessionDelegate { do { // Decode the connection payload let prefs = try JSONDecoder().decode(WatchPreferences.self, from: data) - ObservableOpenHABDataObject.shared.localConnectionConfig = prefs.localConnectionConfiguration ?? .localDefault - ObservableOpenHABDataObject.shared.remoteConnectionConfig = prefs.remoteConnectionConfiguration ?? .remoteDefault - ObservableOpenHABDataObject.shared.sitemapName = prefs.defaultSitemap - ObservableOpenHABDataObject.shared.sitemapForWatch = prefs.sitemapForWatch - ObservableOpenHABDataObject.shared.iconType = IconType(rawValue: prefs.iconType) ?? .svg - ObservableOpenHABDataObject.shared.haveReceivedAppContext = true + AppSettings.shared.localConnectionConfig = prefs.localConnectionConfiguration ?? .localDefault + AppSettings.shared.remoteConnectionConfig = prefs.remoteConnectionConfiguration ?? .remoteDefault + AppSettings.shared.sitemapName = prefs.defaultSitemap + AppSettings.shared.sitemapForWatch = prefs.sitemapForWatch + AppSettings.shared.iconType = IconType(rawValue: prefs.iconType) ?? .svg + AppSettings.shared.haveReceivedAppContext = true // if let trustedCertificates = applicationContext["trustedCertificates"] as? [String: Data] { // // do we need to do anything here? We load from the shared keychain. // } - ObservableOpenHABDataObject.shared.haveReceivedAppContext = true + AppSettings.shared.haveReceivedAppContext = true logger.info("✅ Applied WatchPreferences to ObservableOpenHABDataObject") } catch { logger.error("❌ Failed to decode WatchPreferences: \(error.localizedDescription)") diff --git a/openHABWatch/Model/ObservableOpenHABDataObject.swift b/openHABWatch/Model/AppSettings.swift similarity index 74% rename from openHABWatch/Model/ObservableOpenHABDataObject.swift rename to openHABWatch/Model/AppSettings.swift index 589b88dbc..f28c49dc9 100644 --- a/openHABWatch/Model/ObservableOpenHABDataObject.swift +++ b/openHABWatch/Model/AppSettings.swift @@ -13,8 +13,8 @@ import Combine import Foundation import OpenHABCore -final class ObservableOpenHABDataObject: ObservableObject { - static let shared = ObservableOpenHABDataObject() +final class AppSettings: ObservableObject { + static let shared = AppSettings() var openHABVersion: Int = 2 @@ -27,10 +27,3 @@ final class ObservableOpenHABDataObject: ObservableObject { @Published var sitemapForWatch = "" @Published var iconType: IconType = .svg } - -extension ObservableOpenHABDataObject { - convenience init(openHABRootUrl: String) { - self.init() - self.openHABRootUrl = openHABRootUrl - } -} diff --git a/openHABWatch/Model/OpenHABWidgetExtension.swift b/openHABWatch/Model/OpenHABWidgetExtension.swift index 17c197e7e..57a5ee3f8 100644 --- a/openHABWatch/Model/OpenHABWidgetExtension.swift +++ b/openHABWatch/Model/OpenHABWidgetExtension.swift @@ -15,7 +15,7 @@ import os.log import SwiftUI extension OpenHABWidget { - @ViewBuilder func makeView(settings: ObservableOpenHABDataObject) -> some View { + @ViewBuilder func makeView(settings: AppSettings) -> some View { if let linkedPage { let title = linkedPage.title.components(separatedBy: "[")[0] let pageUrl = linkedPage.link diff --git a/openHABWatch/OpenHABWatch.swift b/openHABWatch/OpenHABWatch.swift index 49eb3c648..4bb139503 100644 --- a/openHABWatch/OpenHABWatch.swift +++ b/openHABWatch/OpenHABWatch.swift @@ -17,10 +17,10 @@ import UserNotifications @main struct OpenHABWatch: App { - @ObservedObject var settings = ObservableOpenHABDataObject.shared + @ObservedObject var settings = AppSettings.shared // https://developer.apple.com/documentation/watchkit/wkapplicationdelegate @WKApplicationDelegateAdaptor(OpenHABWatchAppDelegate.self) var appDelegate - @ObservedObject var userData = ObservableOpenHABDataObject(sitemapName: ObservableOpenHABDataObject.shared.sitemapName) + @ObservedObject var userData = UserData.shared //(sitemapName: ObservableOpenHABDataObject.shared.sitemapName) var body: some Scene { WindowGroup { @@ -55,6 +55,7 @@ struct OpenHABWatch: App { let SVGCoder = SDImageSVGCoder.shared SDImageCodersManager.shared.addCoder(SVGCoder) SDWebImageDownloader.shared.config.operationClass = OpenHABImageDownloaderOperation.self + DispatchQueue.main.async { AppMessageService.singleton.requestApplicationContext() } diff --git a/openHABWatch/Views/ContentView.swift b/openHABWatch/Views/ContentView.swift index b907bd618..a1cdcda2f 100644 --- a/openHABWatch/Views/ContentView.swift +++ b/openHABWatch/Views/ContentView.swift @@ -15,7 +15,7 @@ import SwiftUI struct ContentView: View { @ObservedObject var viewModel: UserData - @EnvironmentObject var settings: ObservableOpenHABDataObject + @EnvironmentObject var settings: AppSettings @State var title = "openHAB" var body: some View { @@ -118,5 +118,5 @@ struct ContentView: View { ContentView(viewModel: UserData()) } - .environmentObject(ObservableOpenHABDataObject()) + .environmentObject(AppSettings()) } diff --git a/openHABWatch/Views/PreferencesSwiftUIView.swift b/openHABWatch/Views/PreferencesSwiftUIView.swift index 2def30de9..1fbc82915 100644 --- a/openHABWatch/Views/PreferencesSwiftUIView.swift +++ b/openHABWatch/Views/PreferencesSwiftUIView.swift @@ -15,7 +15,7 @@ import SwiftUI import WatchConnectivity struct PreferencesSwiftUIView: View { - @EnvironmentObject var settings: ObservableOpenHABDataObject + @EnvironmentObject var settings: AppSettings var applicationVersionNumber: String = { let appBuildString = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String @@ -41,5 +41,5 @@ struct PreferencesSwiftUIView: View { #Preview { PreferencesSwiftUIView() - .environmentObject(ObservableOpenHABDataObject()) + .environmentObject(AppSettings()) } diff --git a/openHABWatch/Views/Rows/ColorPickerRow.swift b/openHABWatch/Views/Rows/ColorPickerRow.swift index 1e1baca8c..95b586a6b 100644 --- a/openHABWatch/Views/Rows/ColorPickerRow.swift +++ b/openHABWatch/Views/Rows/ColorPickerRow.swift @@ -15,7 +15,7 @@ import SwiftUI struct ColorPickerRow: View { @ObservedObject var widget: OpenHABWidget - @ObservedObject var settings = ObservableOpenHABDataObject.shared + @ObservedObject var settings = AppSettings.shared var body: some View { let uiColor = widget.item?.stateAsUIColor() @@ -67,5 +67,5 @@ struct ColorPickerRow: View { #Preview { let widget = UserData().widgets[10] ColorPickerRow(widget: widget) - .environmentObject(ObservableOpenHABDataObject()) + .environmentObject(AppSettings()) } diff --git a/openHABWatch/Views/Rows/FrameRow.swift b/openHABWatch/Views/Rows/FrameRow.swift index 2930ec3bc..7ecc28375 100644 --- a/openHABWatch/Views/Rows/FrameRow.swift +++ b/openHABWatch/Views/Rows/FrameRow.swift @@ -14,7 +14,7 @@ import SwiftUI struct FrameRow: View { @ObservedObject var widget: OpenHABWidget - @EnvironmentObject var settings: ObservableOpenHABDataObject + @EnvironmentObject var settings: AppSettings var body: some View { HStack { @@ -29,5 +29,5 @@ struct FrameRow: View { #Preview { let widget = UserData().widgets[6] FrameRow(widget: widget) - .environmentObject(ObservableOpenHABDataObject()) + .environmentObject(AppSettings()) } diff --git a/openHABWatch/Views/Rows/GenericRow.swift b/openHABWatch/Views/Rows/GenericRow.swift index 817e8695d..3f2068178 100644 --- a/openHABWatch/Views/Rows/GenericRow.swift +++ b/openHABWatch/Views/Rows/GenericRow.swift @@ -15,7 +15,7 @@ import SwiftUI struct GenericRow: View { @ObservedObject var widget: OpenHABWidget - @ObservedObject var settings = ObservableOpenHABDataObject.shared + @ObservedObject var settings = AppSettings.shared var body: some View { HStack { @@ -31,5 +31,5 @@ struct GenericRow: View { #Preview { let widget = UserData().widgets[6] GenericRow(widget: widget) - .environmentObject(ObservableOpenHABDataObject()) + .environmentObject(AppSettings()) } diff --git a/openHABWatch/Views/Rows/ImageRawRow.swift b/openHABWatch/Views/Rows/ImageRawRow.swift index 0c1456994..3dcdca7f5 100644 --- a/openHABWatch/Views/Rows/ImageRawRow.swift +++ b/openHABWatch/Views/Rows/ImageRawRow.swift @@ -15,7 +15,7 @@ import SwiftUI struct ImageRawRow: View { @ObservedObject var widget: OpenHABWidget - @EnvironmentObject var settings: ObservableOpenHABDataObject + @EnvironmentObject var settings: AppSettings var body: some View { if let data = widget.item?.state?.components(separatedBy: ",")[safe: 1], @@ -33,5 +33,5 @@ struct ImageRawRow: View { #Preview { let widget = UserData().widgets[4] ImageRawRow(widget: widget) - .environmentObject(ObservableOpenHABDataObject()) + .environmentObject(AppSettings()) } diff --git a/openHABWatch/Views/Rows/ImageRow.swift b/openHABWatch/Views/Rows/ImageRow.swift index 69085eaae..8e246d227 100644 --- a/openHABWatch/Views/Rows/ImageRow.swift +++ b/openHABWatch/Views/Rows/ImageRow.swift @@ -15,7 +15,7 @@ import SwiftUI struct ImageRow: View { @State var url: URL? - @EnvironmentObject var settings: ObservableOpenHABDataObject + @EnvironmentObject var settings: AppSettings var body: some View { DownloadableImageView(url: url) @@ -33,5 +33,5 @@ struct ImageRow: View { iconColor: "" ).url return ImageRow(url: iconUrl) - .environmentObject(ObservableOpenHABDataObject()) + .environmentObject(AppSettings()) } diff --git a/openHABWatch/Views/Rows/MapViewRow.swift b/openHABWatch/Views/Rows/MapViewRow.swift index 474917e8b..ccde7ab2c 100644 --- a/openHABWatch/Views/Rows/MapViewRow.swift +++ b/openHABWatch/Views/Rows/MapViewRow.swift @@ -14,7 +14,7 @@ import SwiftUI struct MapViewRow: View { @ObservedObject var widget: OpenHABWidget - @EnvironmentObject var settings: ObservableOpenHABDataObject + @EnvironmentObject var settings: AppSettings var body: some View { VStack { @@ -29,5 +29,5 @@ struct MapViewRow: View { #Preview { let widget = UserData().widgets[9] MapViewRow(widget: widget) - .environmentObject(ObservableOpenHABDataObject()) + .environmentObject(AppSettings()) } diff --git a/openHABWatch/Views/Rows/RollershutterRow.swift b/openHABWatch/Views/Rows/RollershutterRow.swift index 2e6d4c578..e4850671a 100644 --- a/openHABWatch/Views/Rows/RollershutterRow.swift +++ b/openHABWatch/Views/Rows/RollershutterRow.swift @@ -14,7 +14,7 @@ import SwiftUI struct RollershutterRow: View { @ObservedObject var widget: OpenHABWidget - @EnvironmentObject var settings: ObservableOpenHABDataObject + @EnvironmentObject var settings: AppSettings var body: some View { VStack(spacing: -5) { @@ -47,5 +47,5 @@ struct RollershutterRow: View { #Preview { let widget = UserData().widgets[5] RollershutterRow(widget: widget) - .environmentObject(ObservableOpenHABDataObject()) + .environmentObject(AppSettings()) } diff --git a/openHABWatch/Views/Rows/SegmentRow.swift b/openHABWatch/Views/Rows/SegmentRow.swift index 9064da134..d96e36d66 100644 --- a/openHABWatch/Views/Rows/SegmentRow.swift +++ b/openHABWatch/Views/Rows/SegmentRow.swift @@ -15,7 +15,7 @@ import SwiftUI struct SegmentRow: View { @ObservedObject var widget: OpenHABWidget - @EnvironmentObject var settings: ObservableOpenHABDataObject + @EnvironmentObject var settings: AppSettings @State private var pendingValue: String? var valueBinding: Binding { @@ -67,5 +67,5 @@ struct SegmentRow: View { SegmentRow(widget: widget) SegmentRow(widget: widget) } - .environmentObject(ObservableOpenHABDataObject()) + .environmentObject(AppSettings()) } diff --git a/openHABWatch/Views/Rows/SetpointRow.swift b/openHABWatch/Views/Rows/SetpointRow.swift index 451bff7a0..b21c4effb 100644 --- a/openHABWatch/Views/Rows/SetpointRow.swift +++ b/openHABWatch/Views/Rows/SetpointRow.swift @@ -15,7 +15,7 @@ import SwiftUI struct SetpointRow: View { @ObservedObject var widget: OpenHABWidget - @EnvironmentObject var settings: ObservableOpenHABDataObject + @EnvironmentObject var settings: AppSettings private var isIntStep: Bool { widget.step.truncatingRemainder(dividingBy: 1) == 0 @@ -84,5 +84,5 @@ struct SetpointRow: View { #Preview { let widget = UserData().widgets[3] SetpointRow(widget: widget) - .environmentObject(ObservableOpenHABDataObject()) + .environmentObject(AppSettings()) } diff --git a/openHABWatch/Views/Rows/SliderRow.swift b/openHABWatch/Views/Rows/SliderRow.swift index 9fb6e42da..7632af865 100644 --- a/openHABWatch/Views/Rows/SliderRow.swift +++ b/openHABWatch/Views/Rows/SliderRow.swift @@ -15,7 +15,7 @@ import SwiftUI struct SliderRow: View { @ObservedObject var widget: OpenHABWidget - @EnvironmentObject var settings: ObservableOpenHABDataObject + @EnvironmentObject var settings: AppSettings @State private var pendingValue: Double? var valueBinding: Binding { .init( @@ -65,5 +65,5 @@ struct SliderRow: View { SliderRow(widget: widget) SliderRow(widget: widget) } - .environmentObject(ObservableOpenHABDataObject()) + .environmentObject(AppSettings()) } diff --git a/openHABWatch/Views/Rows/SliderWithSwitchSupportRow.swift b/openHABWatch/Views/Rows/SliderWithSwitchSupportRow.swift index 3348d815a..2e057b8cd 100644 --- a/openHABWatch/Views/Rows/SliderWithSwitchSupportRow.swift +++ b/openHABWatch/Views/Rows/SliderWithSwitchSupportRow.swift @@ -15,7 +15,7 @@ import SwiftUI struct SliderWithSwitchSupportRow: View { @ObservedObject var widget: OpenHABWidget - @EnvironmentObject var settings: ObservableOpenHABDataObject + @EnvironmentObject var settings: AppSettings @State private var pendingValue: Double? var body: some View { @@ -90,5 +90,5 @@ struct SliderWithSwitchSupportRow: View { SliderRow(widget: widget) SliderRow(widget: widget) } - .environmentObject(ObservableOpenHABDataObject()) + .environmentObject(AppSettings()) } diff --git a/openHABWatch/Views/Rows/SwitchRow.swift b/openHABWatch/Views/Rows/SwitchRow.swift index d114abddc..37b5c0f11 100644 --- a/openHABWatch/Views/Rows/SwitchRow.swift +++ b/openHABWatch/Views/Rows/SwitchRow.swift @@ -15,7 +15,7 @@ import SwiftUI struct SwitchRow: View { @ObservedObject var widget: OpenHABWidget - @EnvironmentObject var settings: ObservableOpenHABDataObject + @EnvironmentObject var settings: AppSettings // https://stackoverflow.com/questions/59395501/do-something-when-toggle-state-changes var stateBinding: Binding { @@ -53,5 +53,5 @@ struct SwitchRow: View { #Preview { let widget = UserData().widgets[2] SwitchRow(widget: widget) - .environmentObject(ObservableOpenHABDataObject()) + .environmentObject(AppSettings()) } diff --git a/openHABWatch/Views/Utils/IconView.swift b/openHABWatch/Views/Utils/IconView.swift index 97b434465..a07b06a31 100644 --- a/openHABWatch/Views/Utils/IconView.swift +++ b/openHABWatch/Views/Utils/IconView.swift @@ -16,7 +16,7 @@ import SwiftUI struct IconView: View { @ObservedObject var widget: OpenHABWidget - @ObservedObject var settings = ObservableOpenHABDataObject.shared + @ObservedObject var settings = AppSettings.shared var iconURL: URL? { var iconColor = widget.iconColor @@ -42,6 +42,43 @@ struct IconView: View { } #Preview { - let widget = UserData().widgets[3] - IconView(widget: widget, settings: ObservableOpenHABDataObject(openHABRootUrl: PreviewConstants.remoteURLString)) + + let item = OpenHABItem(name: "PreviewItem", type: "Preview Light", state: "Switch", link: "ON", label: nil, groupType: nil, stateDescription: nil, commandDescription: nil, members: [], category: nil, options: nil) + let widget = OpenHABWidget(widgetId: "00", + label: "Lights", + icon: "lightbulb", + type: .slider, + url: nil, + period: nil, + minValue: nil, + maxValue: nil, + step: nil, + refresh: nil, + height: nil, + isLeaf: nil, + iconColor: nil, + labelColor: nil, + valueColor: nil, + service: nil, + state: nil, + text: nil, + legend: true, + inputHint: nil, + encoding: nil, + item: item, + linkedPage: nil, + mappings: [], + widgets: [], + visibility: nil, + switchSupport: nil, + forceAsItem: nil) + + + let mockSettings = { + let obj = AppSettings() + obj.openHABRootUrl = PreviewConstants.remoteURLString + return obj + }() + + IconView(widget: widget, settings: mockSettings) } From 08d7aacab63981ffa5ee5339955961b9872a611d Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Tue, 1 Apr 2025 20:56:21 +0200 Subject: [PATCH 124/476] Move OpenHABImageProcessor with SDWebImageSVGCoder to OpenHABCore Carry over startPageLoading from OpenHABSitemapViewController to UserData in SwiftUI watch app Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- OpenHABCore/Package.swift | 6 +- .../OpenHABCore/Model/OpenHABWidget.swift | 4 +- .../Util}/OpenHABImageProcessor.swift | 11 ++- openHAB.xcodeproj/project.pbxproj | 4 - openHABWatch/Domain/UserData.swift | 90 +++++++++++++++---- openHABWatch/OpenHABWatch.swift | 10 +-- openHABWatch/Views/ContentView.swift | 65 +++++++------- openHABWatch/Views/Rows/ImageRow.swift | 14 ++- openHABWatch/Views/Utils/IconView.swift | 89 ++++++++++-------- 9 files changed, 192 insertions(+), 101 deletions(-) rename {openHAB => OpenHABCore/Sources/OpenHABCore/Util}/OpenHABImageProcessor.swift (87%) diff --git a/OpenHABCore/Package.swift b/OpenHABCore/Package.swift index 803b48068..ff7b2c405 100644 --- a/OpenHABCore/Package.swift +++ b/OpenHABCore/Package.swift @@ -18,7 +18,8 @@ let package = Package( .package(url: "https://github.com/onevcat/Kingfisher.git", from: "8.0.0"), .package(url: "https://github.com/apple/swift-openapi-runtime", from: "1.0.0"), .package(url: "https://github.com/apple/swift-openapi-urlsession", from: "1.0.0"), - .package(url: "https://github.com/apple/swift-http-types.git", from: "1.0.0") + .package(url: "https://github.com/apple/swift-http-types.git", from: "1.0.0"), + .package(url: "https://github.com/SDWebImage/SDWebImageSVGCoder.git", from: "1.4.0") ], targets: [ // Targets are the basic building blocks of a package. A target can define a module or a test suite. @@ -29,7 +30,8 @@ let package = Package( .product(name: "Kingfisher", package: "Kingfisher", condition: .when(platforms: [.iOS, .watchOS])), .product(name: "OpenAPIRuntime", package: "swift-openapi-runtime"), .product(name: "OpenAPIURLSession", package: "swift-openapi-urlsession"), - .product(name: "HTTPTypes", package: "swift-http-types") // ✅ From `swift-http-types` + .product(name: "HTTPTypes", package: "swift-http-types"), // ✅ From `swift-http-types` + .product(name: "SDWebImageSVGCoder", package: "SDWebImageSVGCoder") ], swiftSettings: [ .enableUpcomingFeature("BareSlashRegexLiterals"), diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift index 6299d5aec..f650ea901 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift @@ -253,9 +253,9 @@ public class OpenHABWidget: NSObject, MKAnnotation, Identifiable, ObservableObje } } -extension OpenHABWidget { +public extension OpenHABWidget { // This is an ugly initializer - public convenience init(widgetId: String, label: String, icon: String, type: WidgetType, url: String?, period: String?, minValue: Double?, maxValue: Double?, step: Double?, refresh: Int?, height: Double?, isLeaf: Bool?, iconColor: String?, labelColor: String?, valueColor: String?, service: String?, state: String?, text: String?, legend: Bool?, inputHint: InputHint?, encoding: String?, item: OpenHABItem?, linkedPage: OpenHABPage?, mappings: [OpenHABWidgetMapping], widgets: [OpenHABWidget], visibility: Bool?, switchSupport: Bool?, forceAsItem: Bool?) { + convenience init(widgetId: String, label: String, icon: String, type: WidgetType, url: String?, period: String?, minValue: Double?, maxValue: Double?, step: Double?, refresh: Int?, height: Double?, isLeaf: Bool?, iconColor: String?, labelColor: String?, valueColor: String?, service: String?, state: String?, text: String?, legend: Bool?, inputHint: InputHint?, encoding: String?, item: OpenHABItem?, linkedPage: OpenHABPage?, mappings: [OpenHABWidgetMapping], widgets: [OpenHABWidget], visibility: Bool?, switchSupport: Bool?, forceAsItem: Bool?) { self.init() id = widgetId self.widgetId = widgetId diff --git a/openHAB/OpenHABImageProcessor.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABImageProcessor.swift similarity index 87% rename from openHAB/OpenHABImageProcessor.swift rename to OpenHABCore/Sources/OpenHABCore/Util/OpenHABImageProcessor.swift index 289ef8113..f396381f8 100644 --- a/openHAB/OpenHABImageProcessor.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABImageProcessor.swift @@ -12,16 +12,19 @@ import Foundation import Kingfisher import os.log -import SDWebImage + +// import SDWebImage import SDWebImageSVGCoder -struct OpenHABImageProcessor: ImageProcessor { +public struct OpenHABImageProcessor: ImageProcessor { // `identifier` should be the same for processors with the same properties/functionality // It will be used when storing and retrieving the image to/from cache. - let identifier = "org.openhab.svgprocessor" + public let identifier = "org.openhab.svgprocessor" + + public init() {} // Convert input data/image to target image and return it. - func process(item: ImageProcessItem, options: KingfisherParsedOptionsInfo) -> KFCrossPlatformImage? { + public func process(item: ImageProcessItem, options: KingfisherParsedOptionsInfo) -> KFCrossPlatformImage? { switch item { case let .image(image): os_log("already an image", log: .default, type: .info) diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index b6f56b6d8..f27c89efc 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -80,7 +80,6 @@ DA21EAE22339621C001AB415 /* Throttler.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA21EAE12339621C001AB415 /* Throttler.swift */; }; DA242C622C83588600AFB10D /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA242C612C83588600AFB10D /* SettingsView.swift */; }; DA28C362225241DE00AB409C /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DA28C361225241DE00AB409C /* WebKit.framework */; settings = {ATTRIBUTES = (Required, ); }; }; - DA2AEB6C2D92BA3F00897D80 /* OpenHABImageProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2AEB6B2D92BA3F00897D80 /* OpenHABImageProcessor.swift */; }; DA2AEB6E2D92BAD800897D80 /* PageLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2AEB6D2D92BAD800897D80 /* PageLoader.swift */; }; DA2AEB702D92CF3E00897D80 /* WidgetIconRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2AEB6F2D92CF3E00897D80 /* WidgetIconRenderer.swift */; }; DA2AEBA02D92FB6500897D80 /* NoIconDisplayableCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2AEB9F2D92FB6500897D80 /* NoIconDisplayableCell.swift */; }; @@ -397,7 +396,6 @@ DA21EAE12339621C001AB415 /* Throttler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Throttler.swift; sourceTree = ""; }; DA242C612C83588600AFB10D /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; DA28C361225241DE00AB409C /* WebKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WebKit.framework; path = System/Library/Frameworks/WebKit.framework; sourceTree = SDKROOT; }; - DA2AEB6B2D92BA3F00897D80 /* OpenHABImageProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenHABImageProcessor.swift; sourceTree = ""; }; DA2AEB6D2D92BAD800897D80 /* PageLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageLoader.swift; sourceTree = ""; }; DA2AEB6F2D92CF3E00897D80 /* WidgetIconRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetIconRenderer.swift; sourceTree = ""; }; DA2AEB9F2D92FB6500897D80 /* NoIconDisplayableCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoIconDisplayableCell.swift; sourceTree = ""; }; @@ -934,7 +932,6 @@ 1224F78B228A89E300750965 /* Watch */, DF4B84101886DA9900F34902 /* Widgets */, DFFD8FCE18EDBD30003B502A /* Util */, - DA2AEB6B2D92BA3F00897D80 /* OpenHABImageProcessor.swift */, DA2AEB6F2D92CF3E00897D80 /* WidgetIconRenderer.swift */, ); name = UI; @@ -1634,7 +1631,6 @@ 653B54C2285E714900298ECD /* OpenHABViewController.swift in Sources */, DA2AEB6E2D92BAD800897D80 /* PageLoader.swift in Sources */, DFA16EC118898A8400EDB0BB /* SegmentedUITableViewCell.swift in Sources */, - DA2AEB6C2D92BA3F00897D80 /* OpenHABImageProcessor.swift in Sources */, DAF0A28D2C56EF8900A14A6A /* SetpointCell.swift in Sources */, DAEAA89D21E6B06400267EA3 /* ReusableView.swift in Sources */, DF05FF231896BD2D00FF2F9B /* SelectionUITableViewCell.swift in Sources */, diff --git a/openHABWatch/Domain/UserData.swift b/openHABWatch/Domain/UserData.swift index 3fc60cf38..1d93dc2f7 100644 --- a/openHABWatch/Domain/UserData.swift +++ b/openHABWatch/Domain/UserData.swift @@ -25,12 +25,16 @@ final class UserData: ObservableObject { @Published var errorDescription = "" @Published var showCertificateAlert = false @Published var certificateErrorDescription = "" + @Published var isLoadingSitemap = false + + private var pageHandlingTask: Task? + @Published var isPolling = false var openHABSitemapPage: OpenHABPage? var currentClient: HTTPClient? private let logger = Logger(subsystem: "org.openhab.app.watchkitapp", category: "UserData") - + private var cancellables = Set() init() { @@ -75,18 +79,18 @@ final class UserData: ObservableObject { } AppSettings.shared.$haveReceivedAppContext - .removeDuplicates() - .filter { $0 == true } - .sink { [weak self] _ in - Task { - await self?.updateNetwork() - } - } - .store(in: &cancellables) - - Task { - await observeNetworkChanges() - } + .removeDuplicates() + .filter { $0 == true } + .sink { [weak self] _ in + Task { + await self?.updateNetwork() + } + } + .store(in: &cancellables) + + Task { + await observeNetworkChanges() + } } /// Observes network connection changes and updates state @@ -127,7 +131,7 @@ final class UserData: ObservableObject { } SDWebImageDownloader.shared.requestModifier = requestModifier - await loadPage(sitemapName: AppSettings.shared.sitemapForWatch, longPolling: false, refresh: true) + startPageHandling(sitemapName: AppSettings.shared.sitemapForWatch) } } @@ -143,6 +147,9 @@ final class UserData: ObservableObject { func loadPage(sitemapName: String, longPolling: Bool, refresh: Bool) async { logger.info("Loading page: \(sitemapName) longPolling: \(longPolling) refresh: \(refresh)") + isLoadingSitemap = true + defer { isLoadingSitemap = false } + do { openHABSitemapPage = try await NetworkTracker.shared.pollDataForPage(sitemapname: sitemapName, longPolling: longPolling) @@ -160,9 +167,61 @@ final class UserData: ObservableObject { logger.error("Polling failed with error \(error.localizedDescription)") widgets = [] showAlert = true + errorDescription = error.localizedDescription + } + } + + func startPageHandling(sitemapName: String, pageId: String = "") { + pageHandlingTask?.cancel() + + pageHandlingTask = Task { + do { + isLoadingSitemap = true + let service = OpenAPIService(connectionConfiguration: NetworkTracker.shared.activeConnection?.configuration ?? ConnectionConfiguration.remoteDefault) + + let initialPage = try await service.pollDataForPage(sitemapname: sitemapName, pageId: pageId, longPolling: false) + try Task.checkCancellation() + + await MainActor.run { + self.openHABSitemapPage = initialPage + self.widgets = initialPage?.widgets ?? [] + openHABSitemapPage?.sendCommand = { [weak self] item, command in + Task { await self?.sendCommand(item, command: command) } + } + self.isLoadingSitemap = false + } + + // Long polling loop + while !Task.isCancelled { + let page = try await service.pollDataForPage(sitemapname: sitemapName, pageId: pageId, longPolling: true) + try Task.checkCancellation() + + await MainActor.run { + self.openHABSitemapPage = page + openHABSitemapPage?.sendCommand = { [weak self] item, command in + Task { await self?.sendCommand(item, command: command) } + } + self.widgets = page?.widgets ?? [] + } + } + } catch { + await MainActor.run { + self.widgets = [] + self.errorDescription = error.localizedDescription + self.showAlert = true + self.isLoadingSitemap = false + } + } } } + func stopLongPolling() { + pageHandlingTask?.cancel() + pageHandlingTask = nil + isPolling = false + isLoadingSitemap = false + } + func sendCommand(_ item: OpenHABItem?, command: String?) async { guard let item, let command else { return } do { @@ -177,6 +236,7 @@ final class UserData: ObservableObject { !AppSettings.shared.openHABRootUrl.isEmpty else { return } showAlert = false - await loadPage(sitemapName: AppSettings.shared.sitemapForWatch, longPolling: false, refresh: true) +// await loadPage(sitemapName: AppSettings.shared.sitemapForWatch, longPolling: false, refresh: true) + startPageHandling(sitemapName: AppSettings.shared.sitemapForWatch) } } diff --git a/openHABWatch/OpenHABWatch.swift b/openHABWatch/OpenHABWatch.swift index 4bb139503..736264669 100644 --- a/openHABWatch/OpenHABWatch.swift +++ b/openHABWatch/OpenHABWatch.swift @@ -20,7 +20,7 @@ struct OpenHABWatch: App { @ObservedObject var settings = AppSettings.shared // https://developer.apple.com/documentation/watchkit/wkapplicationdelegate @WKApplicationDelegateAdaptor(OpenHABWatchAppDelegate.self) var appDelegate - @ObservedObject var userData = UserData.shared //(sitemapName: ObservableOpenHABDataObject.shared.sitemapName) + @ObservedObject var userData = UserData.shared // (sitemapName: ObservableOpenHABDataObject.shared.sitemapName) var body: some Scene { WindowGroup { @@ -52,10 +52,10 @@ struct OpenHABWatch: App { init() { // Initialize SVGCoder - let SVGCoder = SDImageSVGCoder.shared - SDImageCodersManager.shared.addCoder(SVGCoder) - SDWebImageDownloader.shared.config.operationClass = OpenHABImageDownloaderOperation.self - +// let SVGCoder = SDImageSVGCoder.shared +// SDImageCodersManager.shared.addCoder(SVGCoder) +// SDWebImageDownloader.shared.config.operationClass = OpenHABImageDownloaderOperation.self + DispatchQueue.main.async { AppMessageService.singleton.requestApplicationContext() } diff --git a/openHABWatch/Views/ContentView.swift b/openHABWatch/Views/ContentView.swift index a1cdcda2f..66077c5e9 100644 --- a/openHABWatch/Views/ContentView.swift +++ b/openHABWatch/Views/ContentView.swift @@ -20,42 +20,45 @@ struct ContentView: View { var body: some View { ZStack { - ScrollView { - HStack { - Text(viewModel.openHABSitemapPage?.title ?? "Waiting...") - .font(.body) - .lineLimit(1) - Spacer() + if viewModel.isLoadingSitemap { + ProgressView("Loading sitemap...") + .progressViewStyle(CircularProgressViewStyle()) + } else if viewModel.widgets.isEmpty { + VStack { + Text("No widgets available.") + .font(.footnote) + .foregroundStyle(.secondary) } - ForEach(viewModel.widgets) { widget in - rowWidget(widget: widget) + } else { + ScrollView { + HStack { + Text(viewModel.openHABSitemapPage?.title ?? "Waiting...") + .font(.body) + .lineLimit(1) + Spacer() + } + ForEach(viewModel.widgets) { widget in + rowWidget(widget: widget) + } } + .navigationBarTitle(Text(title)) } - .navigationBarTitle(Text(title)) - .alert(isPresented: $viewModel.showCertificateAlert) { - Alert( - title: Text(NSLocalizedString("ssl_certificate_warning", comment: "")), - message: Text(viewModel.certificateErrorDescription), - primaryButton: .default(Text(NSLocalizedString("always", comment: ""))) { - if let client = viewModel.currentClient { - client.completeEvaluation(.permitAlways) - } - }, - secondaryButton: .destructive(Text(NSLocalizedString("deny", comment: ""))) { - if let client = viewModel.currentClient { - client.completeEvaluation(.deny) - } + } + .alert(isPresented: $viewModel.showCertificateAlert) { + Alert( + title: Text(NSLocalizedString("ssl_certificate_warning", comment: "")), + message: Text(viewModel.certificateErrorDescription), + primaryButton: .default(Text(NSLocalizedString("always", comment: ""))) { + if let client = viewModel.currentClient { + client.completeEvaluation(.permitAlways) } - ) - } - if viewModel.showAlert { - Text("Refreshing...") - .task { - await viewModel.refreshUrl() - os_log("reload after alert", log: .default, type: .info) - viewModel.showAlert = false + }, + secondaryButton: .destructive(Text(NSLocalizedString("deny", comment: ""))) { + if let client = viewModel.currentClient { + client.completeEvaluation(.deny) } - } + } + ) } } diff --git a/openHABWatch/Views/Rows/ImageRow.swift b/openHABWatch/Views/Rows/ImageRow.swift index 8e246d227..23888bec4 100644 --- a/openHABWatch/Views/Rows/ImageRow.swift +++ b/openHABWatch/Views/Rows/ImageRow.swift @@ -9,6 +9,7 @@ // // SPDX-License-Identifier: EPL-2.0 +import Kingfisher import OpenHABCore import os.log import SwiftUI @@ -18,8 +19,17 @@ struct ImageRow: View { @EnvironmentObject var settings: AppSettings var body: some View { - DownloadableImageView(url: url) - .transition(.fade(duration: 0.3)).id(url?.absoluteString ?? "") + KFImage(url) + .placeholder { + ProgressView() + .frame(width: 20, height: 20) + } + .fade(duration: 0.25) + .resizable() + .aspectRatio(contentMode: .fit) + .id(url?.absoluteString ?? "") +// DownloadableImageView(url: url) +// .transition(.fade(duration: 0.3)).id(url?.absoluteString ?? "") } } diff --git a/openHABWatch/Views/Utils/IconView.swift b/openHABWatch/Views/Utils/IconView.swift index a07b06a31..1dc014396 100644 --- a/openHABWatch/Views/Utils/IconView.swift +++ b/openHABWatch/Views/Utils/IconView.swift @@ -9,9 +9,9 @@ // // SPDX-License-Identifier: EPL-2.0 +import Kingfisher import OpenHABCore import os.log -import SDWebImageSwiftUI import SwiftUI struct IconView: View { @@ -34,51 +34,68 @@ struct IconView: View { } var body: some View { - DownloadableImageView(url: iconURL) - .transition(.fade(duration: 0.3)) - .frame(width: 20.0, height: 20.0) + KFImage(iconURL) + .placeholder { + ProgressView() + .frame(width: 20, height: 20) + } + .setProcessor(OpenHABImageProcessor()) + .fade(duration: 0.25) + .resizable() + .onSuccess { result in + print("Image loaded from cache: \(result.cacheType)") + } + .onFailure { error in + print("Error: \(error)") + } + .aspectRatio(contentMode: .fit) + .frame(width: 20, height: 20) .id(iconURL?.absoluteString ?? "") +// DownloadableImageView(url: iconURL) +// .transition(.fade(duration: 0.3)) +// .frame(width: 20.0, height: 20.0) +// .id(iconURL?.absoluteString ?? "") } } #Preview { - let item = OpenHABItem(name: "PreviewItem", type: "Preview Light", state: "Switch", link: "ON", label: nil, groupType: nil, stateDescription: nil, commandDescription: nil, members: [], category: nil, options: nil) - let widget = OpenHABWidget(widgetId: "00", - label: "Lights", - icon: "lightbulb", - type: .slider, - url: nil, - period: nil, - minValue: nil, - maxValue: nil, - step: nil, - refresh: nil, - height: nil, - isLeaf: nil, - iconColor: nil, - labelColor: nil, - valueColor: nil, - service: nil, - state: nil, - text: nil, - legend: true, - inputHint: nil, - encoding: nil, - item: item, - linkedPage: nil, - mappings: [], - widgets: [], - visibility: nil, - switchSupport: nil, - forceAsItem: nil) - - + let widget = OpenHABWidget( + widgetId: "00", + label: "Lights", + icon: "lightbulb", + type: .slider, + url: nil, + period: nil, + minValue: nil, + maxValue: nil, + step: nil, + refresh: nil, + height: nil, + isLeaf: nil, + iconColor: nil, + labelColor: nil, + valueColor: nil, + service: nil, + state: nil, + text: nil, + legend: true, + inputHint: nil, + encoding: nil, + item: item, + linkedPage: nil, + mappings: [], + widgets: [], + visibility: nil, + switchSupport: nil, + forceAsItem: nil + ) + let mockSettings = { let obj = AppSettings() obj.openHABRootUrl = PreviewConstants.remoteURLString return obj }() - + IconView(widget: widget, settings: mockSettings) } From 76f72c41b014be4acf9ef113e583b96a4fc9bb1f Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Tue, 1 Apr 2025 23:32:34 +0200 Subject: [PATCH 125/476] Getting previews on watch app to work Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../Sources/OpenHABCore/Model/OpenHABPage.swift | 2 +- .../OpenHABCore/Util/OpenHABImageProcessor.swift | 2 -- openHABWatch/Domain/UserData.swift | 16 ++++++++++++++++ .../Extension/ComplicationController.swift | 2 +- openHABWatch/OpenHABWatch.swift | 7 ------- openHABWatch/Views/Rows/ColorPickerRow.swift | 2 +- openHABWatch/Views/Rows/FrameRow.swift | 2 +- openHABWatch/Views/Rows/GenericRow.swift | 2 +- openHABWatch/Views/Rows/ImageRawRow.swift | 2 +- openHABWatch/Views/Rows/MapViewRow.swift | 2 +- openHABWatch/Views/Rows/RollershutterRow.swift | 2 +- openHABWatch/Views/Rows/SegmentRow.swift | 2 +- openHABWatch/Views/Rows/SetpointRow.swift | 2 +- openHABWatch/Views/Rows/SliderRow.swift | 2 +- .../Views/Rows/SliderWithSwitchSupportRow.swift | 2 +- openHABWatch/Views/Rows/SwitchRow.swift | 2 +- .../Views/Utils/DetailTextLabelView.swift | 2 +- openHABWatch/Views/Utils/MapView.swift | 2 +- openHABWatch/Views/Utils/TextLabelView.swift | 2 +- 19 files changed, 32 insertions(+), 25 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABPage.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABPage.swift index 8e96dc28f..1562be4f9 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABPage.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABPage.swift @@ -88,7 +88,7 @@ public extension OpenHABPage.CodingData { } extension OpenHABPage { - convenience init?(_ page: Components.Schemas.PageDTO?) { + public convenience init?(_ page: Components.Schemas.PageDTO?) { if let page { self.init( pageId: page.id.orEmpty, diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OpenHABImageProcessor.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABImageProcessor.swift index f396381f8..dfc17ba73 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/OpenHABImageProcessor.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABImageProcessor.swift @@ -12,8 +12,6 @@ import Foundation import Kingfisher import os.log - -// import SDWebImage import SDWebImageSVGCoder public struct OpenHABImageProcessor: ImageProcessor { diff --git a/openHABWatch/Domain/UserData.swift b/openHABWatch/Domain/UserData.swift index 1d93dc2f7..58eb5c585 100644 --- a/openHABWatch/Domain/UserData.swift +++ b/openHABWatch/Domain/UserData.swift @@ -37,6 +37,22 @@ final class UserData: ObservableObject { private var cancellables = Set() + #if DEBUG + init(preview: Bool = false) { + let data = PreviewConstants.sitemapJson + do { + let sitemapPage = try data.decoded(as: Components.Schemas.PageDTO.self) + openHABSitemapPage = OpenHABPage(sitemapPage) + widgets = openHABSitemapPage?.widgets ?? [] + // openHABSitemapPage?.sendCommand = { [weak self] item, command in + // self?.sendCommand(item, command: command) + // } + } catch { + logger.error("Should not throw \(error.localizedDescription)") + } + } + #endif + init() { NotificationCenter.default.addObserver( forName: .evaluateServerTrust, diff --git a/openHABWatch/Extension/ComplicationController.swift b/openHABWatch/Extension/ComplicationController.swift index df5efbe3e..100577da1 100644 --- a/openHABWatch/Extension/ComplicationController.swift +++ b/openHABWatch/Extension/ComplicationController.swift @@ -17,7 +17,7 @@ class ComplicationController: NSObject, CLKComplicationDataSource { // MARK: - Complication Configuration func getComplicationDescriptors(handler: @escaping ([CLKComplicationDescriptor]) -> Void) { - // watchOS7: replacing depreciated CLKComplicationSupportedFamilies + // watchOS7: replacing deprecated CLKComplicationSupportedFamilies let descriptors = [ CLKComplicationDescriptor(identifier: "complication", displayName: Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as! String, supportedFamilies: CLKComplicationFamily.allCases) // Multiple complication support can be added here with more descriptors diff --git a/openHABWatch/OpenHABWatch.swift b/openHABWatch/OpenHABWatch.swift index 736264669..8a4217f05 100644 --- a/openHABWatch/OpenHABWatch.swift +++ b/openHABWatch/OpenHABWatch.swift @@ -10,8 +10,6 @@ // SPDX-License-Identifier: EPL-2.0 import OpenHABCore -import SDWebImage -import SDWebImageSVGCoder import SwiftUI import UserNotifications @@ -51,11 +49,6 @@ struct OpenHABWatch: App { } init() { - // Initialize SVGCoder -// let SVGCoder = SDImageSVGCoder.shared -// SDImageCodersManager.shared.addCoder(SVGCoder) -// SDWebImageDownloader.shared.config.operationClass = OpenHABImageDownloaderOperation.self - DispatchQueue.main.async { AppMessageService.singleton.requestApplicationContext() } diff --git a/openHABWatch/Views/Rows/ColorPickerRow.swift b/openHABWatch/Views/Rows/ColorPickerRow.swift index 95b586a6b..8ad5c0e13 100644 --- a/openHABWatch/Views/Rows/ColorPickerRow.swift +++ b/openHABWatch/Views/Rows/ColorPickerRow.swift @@ -65,7 +65,7 @@ struct ColorPickerRow: View { } #Preview { - let widget = UserData().widgets[10] + let widget = UserData(preview: true).widgets[10] ColorPickerRow(widget: widget) .environmentObject(AppSettings()) } diff --git a/openHABWatch/Views/Rows/FrameRow.swift b/openHABWatch/Views/Rows/FrameRow.swift index 7ecc28375..cf74be7dc 100644 --- a/openHABWatch/Views/Rows/FrameRow.swift +++ b/openHABWatch/Views/Rows/FrameRow.swift @@ -27,7 +27,7 @@ struct FrameRow: View { } #Preview { - let widget = UserData().widgets[6] + let widget = UserData(preview: true).widgets[6] FrameRow(widget: widget) .environmentObject(AppSettings()) } diff --git a/openHABWatch/Views/Rows/GenericRow.swift b/openHABWatch/Views/Rows/GenericRow.swift index 3f2068178..58ee3fa0c 100644 --- a/openHABWatch/Views/Rows/GenericRow.swift +++ b/openHABWatch/Views/Rows/GenericRow.swift @@ -29,7 +29,7 @@ struct GenericRow: View { } #Preview { - let widget = UserData().widgets[6] + let widget = UserData(preview: true).widgets[6] GenericRow(widget: widget) .environmentObject(AppSettings()) } diff --git a/openHABWatch/Views/Rows/ImageRawRow.swift b/openHABWatch/Views/Rows/ImageRawRow.swift index 3dcdca7f5..7e26b92bf 100644 --- a/openHABWatch/Views/Rows/ImageRawRow.swift +++ b/openHABWatch/Views/Rows/ImageRawRow.swift @@ -31,7 +31,7 @@ struct ImageRawRow: View { } #Preview { - let widget = UserData().widgets[4] + let widget = UserData(preview: true).widgets[4] ImageRawRow(widget: widget) .environmentObject(AppSettings()) } diff --git a/openHABWatch/Views/Rows/MapViewRow.swift b/openHABWatch/Views/Rows/MapViewRow.swift index ccde7ab2c..08a49154b 100644 --- a/openHABWatch/Views/Rows/MapViewRow.swift +++ b/openHABWatch/Views/Rows/MapViewRow.swift @@ -27,7 +27,7 @@ struct MapViewRow: View { } #Preview { - let widget = UserData().widgets[9] + let widget = UserData(preview: true).widgets[9] MapViewRow(widget: widget) .environmentObject(AppSettings()) } diff --git a/openHABWatch/Views/Rows/RollershutterRow.swift b/openHABWatch/Views/Rows/RollershutterRow.swift index e4850671a..80d0a1d75 100644 --- a/openHABWatch/Views/Rows/RollershutterRow.swift +++ b/openHABWatch/Views/Rows/RollershutterRow.swift @@ -45,7 +45,7 @@ struct RollershutterRow: View { } #Preview { - let widget = UserData().widgets[5] + let widget = UserData(preview: true).widgets[5] RollershutterRow(widget: widget) .environmentObject(AppSettings()) } diff --git a/openHABWatch/Views/Rows/SegmentRow.swift b/openHABWatch/Views/Rows/SegmentRow.swift index d96e36d66..7c82c7ce2 100644 --- a/openHABWatch/Views/Rows/SegmentRow.swift +++ b/openHABWatch/Views/Rows/SegmentRow.swift @@ -62,7 +62,7 @@ struct SegmentRow: View { } #Preview { - let widget = UserData().widgets[4] + let widget = UserData(preview: true).widgets[4] Group { SegmentRow(widget: widget) SegmentRow(widget: widget) diff --git a/openHABWatch/Views/Rows/SetpointRow.swift b/openHABWatch/Views/Rows/SetpointRow.swift index b21c4effb..8f952f420 100644 --- a/openHABWatch/Views/Rows/SetpointRow.swift +++ b/openHABWatch/Views/Rows/SetpointRow.swift @@ -82,7 +82,7 @@ struct SetpointRow: View { } #Preview { - let widget = UserData().widgets[3] + let widget = UserData(preview: true).widgets[3] SetpointRow(widget: widget) .environmentObject(AppSettings()) } diff --git a/openHABWatch/Views/Rows/SliderRow.swift b/openHABWatch/Views/Rows/SliderRow.swift index 7632af865..993394826 100644 --- a/openHABWatch/Views/Rows/SliderRow.swift +++ b/openHABWatch/Views/Rows/SliderRow.swift @@ -60,7 +60,7 @@ struct SliderRow: View { } #Preview { - let widget = UserData().widgets[3] + let widget = UserData(preview: true).widgets[3] Group { SliderRow(widget: widget) SliderRow(widget: widget) diff --git a/openHABWatch/Views/Rows/SliderWithSwitchSupportRow.swift b/openHABWatch/Views/Rows/SliderWithSwitchSupportRow.swift index 2e057b8cd..fa5d97622 100644 --- a/openHABWatch/Views/Rows/SliderWithSwitchSupportRow.swift +++ b/openHABWatch/Views/Rows/SliderWithSwitchSupportRow.swift @@ -85,7 +85,7 @@ struct SliderWithSwitchSupportRow: View { } #Preview { - let widget = UserData().widgets[3] + let widget = UserData(preview: true).widgets[3] Group { SliderRow(widget: widget) SliderRow(widget: widget) diff --git a/openHABWatch/Views/Rows/SwitchRow.swift b/openHABWatch/Views/Rows/SwitchRow.swift index 37b5c0f11..9dad0e70d 100644 --- a/openHABWatch/Views/Rows/SwitchRow.swift +++ b/openHABWatch/Views/Rows/SwitchRow.swift @@ -51,7 +51,7 @@ struct SwitchRow: View { } #Preview { - let widget = UserData().widgets[2] + let widget = UserData(preview: true).widgets[2] SwitchRow(widget: widget) .environmentObject(AppSettings()) } diff --git a/openHABWatch/Views/Utils/DetailTextLabelView.swift b/openHABWatch/Views/Utils/DetailTextLabelView.swift index 26d7a5f76..99acca81a 100644 --- a/openHABWatch/Views/Utils/DetailTextLabelView.swift +++ b/openHABWatch/Views/Utils/DetailTextLabelView.swift @@ -26,6 +26,6 @@ struct DetailTextLabelView: View { } #Preview { - let widget = UserData().widgets[2] + let widget = UserData(preview: true).widgets[2] DetailTextLabelView(widget: widget) } diff --git a/openHABWatch/Views/Utils/MapView.swift b/openHABWatch/Views/Utils/MapView.swift index 81fe12741..c6fc56487 100644 --- a/openHABWatch/Views/Utils/MapView.swift +++ b/openHABWatch/Views/Utils/MapView.swift @@ -39,6 +39,6 @@ struct MapView: View { } #Preview { - let widget = UserData().widgets[9] + let widget = UserData(preview: true).widgets[9] MapView(widget: widget) } diff --git a/openHABWatch/Views/Utils/TextLabelView.swift b/openHABWatch/Views/Utils/TextLabelView.swift index 3fa0d35c9..10729846e 100644 --- a/openHABWatch/Views/Utils/TextLabelView.swift +++ b/openHABWatch/Views/Utils/TextLabelView.swift @@ -24,6 +24,6 @@ struct TextLabelView: View { } #Preview { - let widget = UserData().widgets[2] + let widget = UserData(preview: true).widgets[2] TextLabelView(widget: widget) } From e0b306cdf6edf43ef694dc0a1517c010f283198e Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Tue, 1 Apr 2025 23:50:31 +0200 Subject: [PATCH 126/476] Getting previews on watch app to work / show icons Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- openHABWatch/Views/Rows/ColorPickerRow.swift | 7 +++- openHABWatch/Views/Rows/FrameRow.swift | 7 +++- openHABWatch/Views/Rows/GenericRow.swift | 7 +++- openHABWatch/Views/Rows/ImageRawRow.swift | 7 +++- openHABWatch/Views/Rows/ImageRow.swift | 7 +++- openHABWatch/Views/Rows/MapViewRow.swift | 7 +++- .../Views/Rows/RollershutterRow.swift | 7 +++- openHABWatch/Views/Rows/SegmentRow.swift | 7 +++- openHABWatch/Views/Rows/SetpointRow.swift | 7 +++- openHABWatch/Views/Rows/SliderRow.swift | 7 +++- .../Rows/SliderWithSwitchSupportRow.swift | 7 +++- openHABWatch/Views/Rows/SwitchRow.swift | 7 +++- openHABWatch/Views/Utils/IconView.swift | 40 +------------------ 13 files changed, 74 insertions(+), 50 deletions(-) diff --git a/openHABWatch/Views/Rows/ColorPickerRow.swift b/openHABWatch/Views/Rows/ColorPickerRow.swift index 8ad5c0e13..118380f49 100644 --- a/openHABWatch/Views/Rows/ColorPickerRow.swift +++ b/openHABWatch/Views/Rows/ColorPickerRow.swift @@ -66,6 +66,11 @@ struct ColorPickerRow: View { #Preview { let widget = UserData(preview: true).widgets[10] + let mockSettings = { + let obj = AppSettings() + obj.openHABRootUrl = PreviewConstants.remoteURLString + return obj + }() ColorPickerRow(widget: widget) - .environmentObject(AppSettings()) + .environmentObject(mockSettings) } diff --git a/openHABWatch/Views/Rows/FrameRow.swift b/openHABWatch/Views/Rows/FrameRow.swift index cf74be7dc..c82af96a9 100644 --- a/openHABWatch/Views/Rows/FrameRow.swift +++ b/openHABWatch/Views/Rows/FrameRow.swift @@ -28,6 +28,11 @@ struct FrameRow: View { #Preview { let widget = UserData(preview: true).widgets[6] + let mockSettings = { + let obj = AppSettings() + obj.openHABRootUrl = PreviewConstants.remoteURLString + return obj + }() FrameRow(widget: widget) - .environmentObject(AppSettings()) + .environmentObject(mockSettings) } diff --git a/openHABWatch/Views/Rows/GenericRow.swift b/openHABWatch/Views/Rows/GenericRow.swift index 58ee3fa0c..554e7f0b1 100644 --- a/openHABWatch/Views/Rows/GenericRow.swift +++ b/openHABWatch/Views/Rows/GenericRow.swift @@ -30,6 +30,11 @@ struct GenericRow: View { #Preview { let widget = UserData(preview: true).widgets[6] + let mockSettings = { + let obj = AppSettings() + obj.openHABRootUrl = PreviewConstants.remoteURLString + return obj + }() GenericRow(widget: widget) - .environmentObject(AppSettings()) + .environmentObject(mockSettings) } diff --git a/openHABWatch/Views/Rows/ImageRawRow.swift b/openHABWatch/Views/Rows/ImageRawRow.swift index 7e26b92bf..f87e0be0a 100644 --- a/openHABWatch/Views/Rows/ImageRawRow.swift +++ b/openHABWatch/Views/Rows/ImageRawRow.swift @@ -32,6 +32,11 @@ struct ImageRawRow: View { #Preview { let widget = UserData(preview: true).widgets[4] + let mockSettings = { + let obj = AppSettings() + obj.openHABRootUrl = PreviewConstants.remoteURLString + return obj + }() ImageRawRow(widget: widget) - .environmentObject(AppSettings()) + .environmentObject(mockSettings) } diff --git a/openHABWatch/Views/Rows/ImageRow.swift b/openHABWatch/Views/Rows/ImageRow.swift index 23888bec4..3964391c9 100644 --- a/openHABWatch/Views/Rows/ImageRow.swift +++ b/openHABWatch/Views/Rows/ImageRow.swift @@ -42,6 +42,11 @@ struct ImageRow: View { iconType: .svg, iconColor: "" ).url + let mockSettings = { + let obj = AppSettings() + obj.openHABRootUrl = PreviewConstants.remoteURLString + return obj + }() return ImageRow(url: iconUrl) - .environmentObject(AppSettings()) + .environmentObject(mockSettings) } diff --git a/openHABWatch/Views/Rows/MapViewRow.swift b/openHABWatch/Views/Rows/MapViewRow.swift index 08a49154b..637b1d600 100644 --- a/openHABWatch/Views/Rows/MapViewRow.swift +++ b/openHABWatch/Views/Rows/MapViewRow.swift @@ -28,6 +28,11 @@ struct MapViewRow: View { #Preview { let widget = UserData(preview: true).widgets[9] + let mockSettings = { + let obj = AppSettings() + obj.openHABRootUrl = PreviewConstants.remoteURLString + return obj + }() MapViewRow(widget: widget) - .environmentObject(AppSettings()) + .environmentObject(mockSettings) } diff --git a/openHABWatch/Views/Rows/RollershutterRow.swift b/openHABWatch/Views/Rows/RollershutterRow.swift index 80d0a1d75..602db0447 100644 --- a/openHABWatch/Views/Rows/RollershutterRow.swift +++ b/openHABWatch/Views/Rows/RollershutterRow.swift @@ -46,6 +46,11 @@ struct RollershutterRow: View { #Preview { let widget = UserData(preview: true).widgets[5] + let mockSettings = { + let obj = AppSettings() + obj.openHABRootUrl = PreviewConstants.remoteURLString + return obj + }() RollershutterRow(widget: widget) - .environmentObject(AppSettings()) + .environmentObject(mockSettings) } diff --git a/openHABWatch/Views/Rows/SegmentRow.swift b/openHABWatch/Views/Rows/SegmentRow.swift index 7c82c7ce2..c39884a3d 100644 --- a/openHABWatch/Views/Rows/SegmentRow.swift +++ b/openHABWatch/Views/Rows/SegmentRow.swift @@ -63,9 +63,14 @@ struct SegmentRow: View { #Preview { let widget = UserData(preview: true).widgets[4] + let mockSettings = { + let obj = AppSettings() + obj.openHABRootUrl = PreviewConstants.remoteURLString + return obj + }() Group { SegmentRow(widget: widget) SegmentRow(widget: widget) } - .environmentObject(AppSettings()) + .environmentObject(mockSettings) } diff --git a/openHABWatch/Views/Rows/SetpointRow.swift b/openHABWatch/Views/Rows/SetpointRow.swift index 8f952f420..deb72a915 100644 --- a/openHABWatch/Views/Rows/SetpointRow.swift +++ b/openHABWatch/Views/Rows/SetpointRow.swift @@ -83,6 +83,11 @@ struct SetpointRow: View { #Preview { let widget = UserData(preview: true).widgets[3] + let mockSettings = { + let obj = AppSettings() + obj.openHABRootUrl = PreviewConstants.remoteURLString + return obj + }() SetpointRow(widget: widget) - .environmentObject(AppSettings()) + .environmentObject(mockSettings) } diff --git a/openHABWatch/Views/Rows/SliderRow.swift b/openHABWatch/Views/Rows/SliderRow.swift index 993394826..49e62ffb3 100644 --- a/openHABWatch/Views/Rows/SliderRow.swift +++ b/openHABWatch/Views/Rows/SliderRow.swift @@ -61,9 +61,14 @@ struct SliderRow: View { #Preview { let widget = UserData(preview: true).widgets[3] + let mockSettings = { + let obj = AppSettings() + obj.openHABRootUrl = PreviewConstants.remoteURLString + return obj + }() Group { SliderRow(widget: widget) SliderRow(widget: widget) } - .environmentObject(AppSettings()) + .environmentObject(mockSettings) } diff --git a/openHABWatch/Views/Rows/SliderWithSwitchSupportRow.swift b/openHABWatch/Views/Rows/SliderWithSwitchSupportRow.swift index fa5d97622..2000491a5 100644 --- a/openHABWatch/Views/Rows/SliderWithSwitchSupportRow.swift +++ b/openHABWatch/Views/Rows/SliderWithSwitchSupportRow.swift @@ -86,9 +86,14 @@ struct SliderWithSwitchSupportRow: View { #Preview { let widget = UserData(preview: true).widgets[3] + let mockSettings = { + let obj = AppSettings() + obj.openHABRootUrl = PreviewConstants.remoteURLString + return obj + }() Group { SliderRow(widget: widget) SliderRow(widget: widget) } - .environmentObject(AppSettings()) + .environmentObject(mockSettings) } diff --git a/openHABWatch/Views/Rows/SwitchRow.swift b/openHABWatch/Views/Rows/SwitchRow.swift index 9dad0e70d..eecb12e3c 100644 --- a/openHABWatch/Views/Rows/SwitchRow.swift +++ b/openHABWatch/Views/Rows/SwitchRow.swift @@ -52,6 +52,11 @@ struct SwitchRow: View { #Preview { let widget = UserData(preview: true).widgets[2] + let mockSettings = { + let obj = AppSettings() + obj.openHABRootUrl = PreviewConstants.remoteURLString + return obj + }() SwitchRow(widget: widget) - .environmentObject(AppSettings()) + .environmentObject(mockSettings) } diff --git a/openHABWatch/Views/Utils/IconView.swift b/openHABWatch/Views/Utils/IconView.swift index 1dc014396..85dc4ce72 100644 --- a/openHABWatch/Views/Utils/IconView.swift +++ b/openHABWatch/Views/Utils/IconView.swift @@ -51,51 +51,15 @@ struct IconView: View { .aspectRatio(contentMode: .fit) .frame(width: 20, height: 20) .id(iconURL?.absoluteString ?? "") -// DownloadableImageView(url: iconURL) -// .transition(.fade(duration: 0.3)) -// .frame(width: 20.0, height: 20.0) -// .id(iconURL?.absoluteString ?? "") } } #Preview { - let item = OpenHABItem(name: "PreviewItem", type: "Preview Light", state: "Switch", link: "ON", label: nil, groupType: nil, stateDescription: nil, commandDescription: nil, members: [], category: nil, options: nil) - let widget = OpenHABWidget( - widgetId: "00", - label: "Lights", - icon: "lightbulb", - type: .slider, - url: nil, - period: nil, - minValue: nil, - maxValue: nil, - step: nil, - refresh: nil, - height: nil, - isLeaf: nil, - iconColor: nil, - labelColor: nil, - valueColor: nil, - service: nil, - state: nil, - text: nil, - legend: true, - inputHint: nil, - encoding: nil, - item: item, - linkedPage: nil, - mappings: [], - widgets: [], - visibility: nil, - switchSupport: nil, - forceAsItem: nil - ) - let mockSettings = { let obj = AppSettings() obj.openHABRootUrl = PreviewConstants.remoteURLString return obj }() - - IconView(widget: widget, settings: mockSettings) + let widget2 = UserData(preview: true).widgets[3] + IconView(widget: widget2, settings: mockSettings) } From 3c25561764e2cb28dfb84bf2663242a87c0da4e0 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Wed, 2 Apr 2025 09:35:12 +0200 Subject: [PATCH 127/476] Kicked out SDWebImage but kept SDWebImageSVGCoder / Shortens list of dependencies. for the moment keep Kingfisher which is more in line with Swift development Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- openHAB.xcodeproj/project.pbxproj | 58 ------- .../xcshareddata/swiftpm/Package.resolved | 11 +- openHABWatch/Domain/UserData.swift | 59 +------ .../OpenHABImageDownloaderOperation.swift | 28 ---- openHABWatch/Views/Rows/ImageRow.swift | 2 - .../Views/Utils/DownloadableImageView.swift | 147 ------------------ 6 files changed, 7 insertions(+), 298 deletions(-) delete mode 100644 openHABWatch/Extension/OpenHABImageDownloaderOperation.swift delete mode 100644 openHABWatch/Views/Utils/DownloadableImageView.swift diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index f27c89efc..9d6f55f17 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -21,7 +21,6 @@ 657144512C1E438700C8A1F3 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 657144502C1E438700C8A1F3 /* NotificationService.swift */; }; 657144552C1E438700C8A1F3 /* NotificationService.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 6571444E2C1E438700C8A1F3 /* NotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 657144962C30A16700C8A1F3 /* OpenHABCore in Frameworks */ = {isa = PBXBuildFile; productRef = 657144952C30A16700C8A1F3 /* OpenHABCore */; }; - 65DAE9122D6FCB1A00E99582 /* DownloadableImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65DAE9112D6FCB1A00E99582 /* DownloadableImageView.swift */; }; 932602EE2382892B00EAD685 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DAC6608B236F6F4200F4501E /* Assets.xcassets */; }; 933D7F0722E7015100621A03 /* OpenHABUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 933D7F0622E7015000621A03 /* OpenHABUITests.swift */; }; 933D7F0F22E7030600621A03 /* SnapshotHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 933D7F0E22E7030600621A03 /* SnapshotHelper.swift */; }; @@ -83,9 +82,6 @@ DA2AEB6E2D92BAD800897D80 /* PageLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2AEB6D2D92BAD800897D80 /* PageLoader.swift */; }; DA2AEB702D92CF3E00897D80 /* WidgetIconRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2AEB6F2D92CF3E00897D80 /* WidgetIconRenderer.swift */; }; DA2AEBA02D92FB6500897D80 /* NoIconDisplayableCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2AEB9F2D92FB6500897D80 /* NoIconDisplayableCell.swift */; }; - DA2C4FCD2B4F55D700D1C533 /* SDWebImage in Frameworks */ = {isa = PBXBuildFile; productRef = DA2C4FCC2B4F55D700D1C533 /* SDWebImage */; }; - DA2C4FCF2B4F55D700D1C533 /* SDWebImageMapKit in Frameworks */ = {isa = PBXBuildFile; productRef = DA2C4FCE2B4F55D700D1C533 /* SDWebImageMapKit */; }; - DA2C4FD22B4F56D000D1C533 /* SDWebImageSwiftUI in Frameworks */ = {isa = PBXBuildFile; productRef = DA2C4FD12B4F56D000D1C533 /* SDWebImageSwiftUI */; }; DA2C4FD52B4F573300D1C533 /* SDWebImageSVGCoder in Frameworks */ = {isa = PBXBuildFile; productRef = DA2C4FD42B4F573300D1C533 /* SDWebImageSVGCoder */; }; DA2E0AA423DC96E9009B0A99 /* ImageWithAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2E0AA323DC96E9009B0A99 /* ImageWithAction.swift */; }; DA2E0B0E23DCC153009B0A99 /* MapView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2E0B0D23DCC152009B0A99 /* MapView.swift */; }; @@ -119,13 +115,11 @@ DA9A7EFD2D668D5900824156 /* SFSafeSymbols in Frameworks */ = {isa = PBXBuildFile; productRef = DA9A7EFC2D668D5900824156 /* SFSafeSymbols */; }; DA9A7EFF2D66915900824156 /* SFSafeSymbols in Frameworks */ = {isa = PBXBuildFile; productRef = DA9A7EFE2D66915900824156 /* SFSafeSymbols */; }; DA9F81872C85020F00B47B72 /* RTFTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA9F81862C85020F00B47B72 /* RTFTextView.swift */; }; - DAA070932B5181210060BB0E /* OpenHABImageDownloaderOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAA070922B5181210060BB0E /* OpenHABImageDownloaderOperation.swift */; }; DAA42BA821DC97E000244B2A /* NotificationTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAA42BA721DC97DF00244B2A /* NotificationTableViewCell.swift */; }; DAA42BAA21DC983B00244B2A /* VideoUITableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAA42BA921DC983B00244B2A /* VideoUITableViewCell.swift */; }; DAA42BAC21DC984A00244B2A /* WebUITableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAA42BAB21DC984A00244B2A /* WebUITableViewCell.swift */; }; DAAC30872CBBF0420041927F /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA0775262346705F0086C685 /* ContentView.swift */; }; DABB5E332D98972F009A4B8A /* SDWebImageSVGCoder in Frameworks */ = {isa = PBXBuildFile; productRef = DABB5E322D98972F009A4B8A /* SDWebImageSVGCoder */; }; - DABB5E352D98973F009A4B8A /* SDWebImage in Frameworks */ = {isa = PBXBuildFile; productRef = DABB5E342D98973F009A4B8A /* SDWebImage */; }; DAC65FC7236EDF3900F4501E /* SpinnerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC65FC6236EDF3900F4501E /* SpinnerViewController.swift */; }; DAC6608D236F771600F4501E /* PreferencesSwiftUIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC6608C236F771600F4501E /* PreferencesSwiftUIView.swift */; }; DAC9395522B00E7600C5F423 /* XCTestCaseExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC9395422B00E7600C5F423 /* XCTestCaseExtension.swift */; }; @@ -302,7 +296,6 @@ 657144502C1E438700C8A1F3 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; 657144522C1E438700C8A1F3 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 657144972C30A3E300C8A1F3 /* NotificationService.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = NotificationService.entitlements; sourceTree = ""; }; - 65DAE9112D6FCB1A00E99582 /* DownloadableImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadableImageView.swift; sourceTree = ""; }; 931384B324F259BC00A73AB5 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = ""; }; 931384B424F259BD00A73AB5 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/InfoPlist.strings; sourceTree = ""; }; 931384BB24F2691B00A73AB5 /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/Localizable.strings; sourceTree = ""; }; @@ -432,7 +425,6 @@ DA88F8C522EC377100B408E5 /* ReleaseNotes.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = ReleaseNotes.md; sourceTree = ""; }; DA9721C224E29A8F0092CCFD /* UserDefaultsBacked.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaultsBacked.swift; sourceTree = ""; }; DA9F81862C85020F00B47B72 /* RTFTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RTFTextView.swift; sourceTree = ""; }; - DAA070922B5181210060BB0E /* OpenHABImageDownloaderOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenHABImageDownloaderOperation.swift; sourceTree = ""; }; DAA42BA721DC97DF00244B2A /* NotificationTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NotificationTableViewCell.swift; sourceTree = ""; }; DAA42BA921DC983B00244B2A /* VideoUITableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VideoUITableViewCell.swift; sourceTree = ""; }; DAA42BAB21DC984A00244B2A /* WebUITableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WebUITableViewCell.swift; sourceTree = ""; }; @@ -514,9 +506,6 @@ 934E592528F16EBA00162004 /* OpenHABCore in Frameworks */, DA9A7EFD2D668D5900824156 /* SFSafeSymbols in Frameworks */, 937E448E270B37D200A98C26 /* DeviceKit in Frameworks */, - DA2C4FCF2B4F55D700D1C533 /* SDWebImageMapKit in Frameworks */, - DA2C4FCD2B4F55D700D1C533 /* SDWebImage in Frameworks */, - DA2C4FD22B4F56D000D1C533 /* SDWebImageSwiftUI in Frameworks */, 934E592928F16EBA00162004 /* DeviceKit in Frameworks */, 937E448C270B37CA00A98C26 /* Kingfisher in Frameworks */, ); @@ -576,7 +565,6 @@ 937E4485270B379900A98C26 /* DeviceKit in Frameworks */, DABB5E332D98972F009A4B8A /* SDWebImageSVGCoder in Frameworks */, DFB2622F18830A3600D3244D /* UIKit.framework in Frameworks */, - DABB5E352D98973F009A4B8A /* SDWebImage in Frameworks */, DACE664A2C63B0760069E514 /* OpenAPIURLSession in Frameworks */, 93F8064A27AE7A2E0035A6B0 /* FlexColorPicker in Frameworks */, DA28C362225241DE00AB409C /* WebKit.framework in Frameworks */, @@ -712,7 +700,6 @@ DA0775372346705F0086C685 /* Info.plist */, DA65871E236F83CD007E2E7F /* UserDefaultsExtension.swift */, DA0775382346705F0086C685 /* PushNotificationPayload.apns */, - DAA070922B5181210060BB0E /* OpenHABImageDownloaderOperation.swift */, ); path = Extension; sourceTree = ""; @@ -899,7 +886,6 @@ DAF457A723DBA2C40018B495 /* Utils */ = { isa = PBXGroup; children = ( - 65DAE9112D6FCB1A00E99582 /* DownloadableImageView.swift */, DA7224D123828D3300712D20 /* PreviewConstants.swift */, 934B610B2348D2F9009112D5 /* Color+Extension.swift */, DAF4578123D630C70018B495 /* IconView.swift */, @@ -1158,9 +1144,6 @@ 934E592428F16EBA00162004 /* OpenHABCore */, 934E592628F16EBA00162004 /* Kingfisher */, 934E592828F16EBA00162004 /* DeviceKit */, - DA2C4FCC2B4F55D700D1C533 /* SDWebImage */, - DA2C4FCE2B4F55D700D1C533 /* SDWebImageMapKit */, - DA2C4FD12B4F56D000D1C533 /* SDWebImageSwiftUI */, DA2C4FD42B4F573300D1C533 /* SDWebImageSVGCoder */, DA9A7EFC2D668D5900824156 /* SFSafeSymbols */, ); @@ -1258,7 +1241,6 @@ DACE664C2C63B0840069E514 /* OpenAPIRuntime */, DA9A7EFE2D66915900824156 /* SFSafeSymbols */, DABB5E322D98972F009A4B8A /* SDWebImageSVGCoder */, - DABB5E342D98973F009A4B8A /* SDWebImage */, ); productName = openHAB; productReference = DFB2622718830A3600D3244D /* openHAB.app */; @@ -1351,8 +1333,6 @@ DACE66482C63B0760069E514 /* XCRemoteSwiftPackageReference "swift-openapi-urlsession" */, DACE664B2C63B0840069E514 /* XCRemoteSwiftPackageReference "swift-openapi-runtime" */, DA3B75AC2C59729200E219AB /* XCRemoteSwiftPackageReference "SFSafeSymbols" */, - DA2C4FCB2B4F55D600D1C533 /* XCRemoteSwiftPackageReference "SDWebImage" */, - DA2C4FD02B4F56CF00D1C533 /* XCRemoteSwiftPackageReference "SDWebImageSwiftUI" */, DA2C4FD32B4F573300D1C533 /* XCRemoteSwiftPackageReference "SDWebImageSVGCoder" */, ); productRefGroup = DFB2622818830A3600D3244D /* Products */; @@ -1513,7 +1493,6 @@ buildActionMask = 2147483647; files = ( DACA368E2D7440B9003CD237 /* OpenHABWidgetExtension.swift in Sources */, - 65DAE9122D6FCB1A00E99582 /* DownloadableImageView.swift in Sources */, DA2E0B0E23DCC153009B0A99 /* MapView.swift in Sources */, DA15BFBD23C6726400BD8ADA /* AppSettings.swift in Sources */, DA2E0B1023DCC439009B0A99 /* MapViewRow.swift in Sources */, @@ -1543,7 +1522,6 @@ DAC6608D236F771600F4501E /* PreferencesSwiftUIView.swift in Sources */, DA50C7BD2B0A51BD0009F716 /* SliderWithSwitchSupportRow.swift in Sources */, DAF457A623DB9CE00018B495 /* SetpointRow.swift in Sources */, - DAA070932B5181210060BB0E /* OpenHABImageDownloaderOperation.swift in Sources */, DAF4581823DC4A050018B495 /* ImageRow.swift in Sources */, DA07752F2346705F0086C685 /* NotificationView.swift in Sources */, DA0775312346705F0086C685 /* ComplicationController.swift in Sources */, @@ -2740,22 +2718,6 @@ minimumVersion = 6.5.0; }; }; - DA2C4FCB2B4F55D600D1C533 /* XCRemoteSwiftPackageReference "SDWebImage" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/SDWebImage/SDWebImage.git"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 5.19.7; - }; - }; - DA2C4FD02B4F56CF00D1C533 /* XCRemoteSwiftPackageReference "SDWebImageSwiftUI" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/SDWebImage/SDWebImageSwiftUI.git"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 3.1.2; - }; - }; DA2C4FD32B4F573300D1C533 /* XCRemoteSwiftPackageReference "SDWebImageSVGCoder" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/SDWebImage/SDWebImageSVGCoder.git"; @@ -2871,21 +2833,6 @@ package = 93F8064E27AE7A820035A6B0 /* XCRemoteSwiftPackageReference "SideMenu" */; productName = SideMenu; }; - DA2C4FCC2B4F55D700D1C533 /* SDWebImage */ = { - isa = XCSwiftPackageProductDependency; - package = DA2C4FCB2B4F55D600D1C533 /* XCRemoteSwiftPackageReference "SDWebImage" */; - productName = SDWebImage; - }; - DA2C4FCE2B4F55D700D1C533 /* SDWebImageMapKit */ = { - isa = XCSwiftPackageProductDependency; - package = DA2C4FCB2B4F55D600D1C533 /* XCRemoteSwiftPackageReference "SDWebImage" */; - productName = SDWebImageMapKit; - }; - DA2C4FD12B4F56D000D1C533 /* SDWebImageSwiftUI */ = { - isa = XCSwiftPackageProductDependency; - package = DA2C4FD02B4F56CF00D1C533 /* XCRemoteSwiftPackageReference "SDWebImageSwiftUI" */; - productName = SDWebImageSwiftUI; - }; DA2C4FD42B4F573300D1C533 /* SDWebImageSVGCoder */ = { isa = XCSwiftPackageProductDependency; package = DA2C4FD32B4F573300D1C533 /* XCRemoteSwiftPackageReference "SDWebImageSVGCoder" */; @@ -2906,11 +2853,6 @@ package = DA2C4FD32B4F573300D1C533 /* XCRemoteSwiftPackageReference "SDWebImageSVGCoder" */; productName = SDWebImageSVGCoder; }; - DABB5E342D98973F009A4B8A /* SDWebImage */ = { - isa = XCSwiftPackageProductDependency; - package = DA2C4FCB2B4F55D600D1C533 /* XCRemoteSwiftPackageReference "SDWebImage" */; - productName = SDWebImage; - }; DACE66492C63B0760069E514 /* OpenAPIURLSession */ = { isa = XCSwiftPackageProductDependency; package = DACE66482C63B0760069E514 /* XCRemoteSwiftPackageReference "swift-openapi-urlsession" */; diff --git a/openHAB.xcworkspace/xcshareddata/swiftpm/Package.resolved b/openHAB.xcworkspace/xcshareddata/swiftpm/Package.resolved index 7d9a90dc0..e5b639bf6 100644 --- a/openHAB.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/openHAB.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "a7d4f8386b1234b98fded7877ba21eb9f89b13361d34e36c212915c8b2c816cc", + "originHash" : "2fe6c641cce35f65394a66cdabfb28515dc17a2fa5ac6d25b309d91df4edd6dd", "pins" : [ { "identity" : "abseil-cpp-binary", @@ -154,15 +154,6 @@ "version" : "1.8.0" } }, - { - "identity" : "sdwebimageswiftui", - "kind" : "remoteSourceControl", - "location" : "https://github.com/SDWebImage/SDWebImageSwiftUI.git", - "state" : { - "revision" : "451c6dfd5ecec2cf626d1d9ca81c2d4a60355172", - "version" : "3.1.3" - } - }, { "identity" : "sfsafesymbols", "kind" : "remoteSourceControl", diff --git a/openHABWatch/Domain/UserData.swift b/openHABWatch/Domain/UserData.swift index 58eb5c585..fd883cfc2 100644 --- a/openHABWatch/Domain/UserData.swift +++ b/openHABWatch/Domain/UserData.swift @@ -13,7 +13,6 @@ import Combine import Foundation import OpenHABCore import os.log -import SDWebImage import SwiftUI @MainActor @@ -44,9 +43,9 @@ final class UserData: ObservableObject { let sitemapPage = try data.decoded(as: Components.Schemas.PageDTO.self) openHABSitemapPage = OpenHABPage(sitemapPage) widgets = openHABSitemapPage?.widgets ?? [] - // openHABSitemapPage?.sendCommand = { [weak self] item, command in - // self?.sendCommand(item, command: command) - // } + openHABSitemapPage?.sendCommand = { [weak self] item, command in + Task { await self?.sendCommand(item, command: command) } + } } catch { logger.error("Should not throw \(error.localizedDescription)") } @@ -125,27 +124,8 @@ final class UserData: ObservableObject { AppSettings.shared.openHABRootUrl = activeConnection.configuration.url AppSettings.shared.openHABVersion = activeConnection.version - - let alwaysSendBasicAuth = activeConnection.configuration.alwaysSendBasicAuth - let username = activeConnection.configuration.username - let password = activeConnection.configuration.password - let requestModifier = SDWebImageDownloaderRequestModifier { (request) -> URLRequest? in - guard alwaysSendBasicAuth || request.url?.host?.hasSuffix("myopenhab.org") == true else { - return request - } - guard !username.isEmpty, !password.isEmpty else { - return request - } - var request = request - - // We are handling URLRequests here, so we need to set the header fields - // to the request object with String and cannot use the type safe way of HTTPRequest - // like request.headerFields[.authorization] = basicAuthHeader() - // TODO: revert this - request.setValue(basicAuthHeader(username: username, password: password), forHTTPHeaderField: "Authorization") - return request - } - SDWebImageDownloader.shared.requestModifier = requestModifier + + // TODO: Check whether there is need to setup requestModifier for Kingfisher startPageHandling(sitemapName: AppSettings.shared.sitemapForWatch) } @@ -160,33 +140,6 @@ final class UserData: ObservableObject { NetworkTracker.shared.startTracking(connectionConfigurations: [connection1, connection2]) } - func loadPage(sitemapName: String, longPolling: Bool, refresh: Bool) async { - logger.info("Loading page: \(sitemapName) longPolling: \(longPolling) refresh: \(refresh)") - - isLoadingSitemap = true - defer { isLoadingSitemap = false } - - do { - openHABSitemapPage = try await NetworkTracker.shared.pollDataForPage(sitemapname: sitemapName, longPolling: longPolling) - - openHABSitemapPage?.sendCommand = { [weak self] item, command in - Task { await self?.sendCommand(item, command: command) } - } - - widgets = openHABSitemapPage?.widgets ?? [] - showAlert = widgets.isEmpty - - if refresh { - await loadPage(sitemapName: sitemapName, longPolling: true, refresh: true) - } - } catch { - logger.error("Polling failed with error \(error.localizedDescription)") - widgets = [] - showAlert = true - errorDescription = error.localizedDescription - } - } - func startPageHandling(sitemapName: String, pageId: String = "") { pageHandlingTask?.cancel() @@ -222,6 +175,7 @@ final class UserData: ObservableObject { } } catch { await MainActor.run { + logger.error("Page handling failed with error \(error.localizedDescription)") self.widgets = [] self.errorDescription = error.localizedDescription self.showAlert = true @@ -252,7 +206,6 @@ final class UserData: ObservableObject { !AppSettings.shared.openHABRootUrl.isEmpty else { return } showAlert = false -// await loadPage(sitemapName: AppSettings.shared.sitemapForWatch, longPolling: false, refresh: true) startPageHandling(sitemapName: AppSettings.shared.sitemapForWatch) } } diff --git a/openHABWatch/Extension/OpenHABImageDownloaderOperation.swift b/openHABWatch/Extension/OpenHABImageDownloaderOperation.swift deleted file mode 100644 index ea554cb4e..000000000 --- a/openHABWatch/Extension/OpenHABImageDownloaderOperation.swift +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright (c) 2010-2025 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 Foundation -import OpenHABCore -import SDWebImage - -class OpenHABImageDownloaderOperation: SDWebImageDownloaderOperation, @unchecked Sendable { - override func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { - Task { - let (disposition, credential) = await onReceiveSessionChallenge(with: challenge) - completionHandler(disposition, credential) - } - } - - override func urlSession(_ session: URLSession, task: URLSessionTask, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { - let (disposition, credential) = onReceiveSessionTaskChallenge(with: challenge) - completionHandler(disposition, credential) - } -} diff --git a/openHABWatch/Views/Rows/ImageRow.swift b/openHABWatch/Views/Rows/ImageRow.swift index 3964391c9..a21de963f 100644 --- a/openHABWatch/Views/Rows/ImageRow.swift +++ b/openHABWatch/Views/Rows/ImageRow.swift @@ -28,8 +28,6 @@ struct ImageRow: View { .resizable() .aspectRatio(contentMode: .fit) .id(url?.absoluteString ?? "") -// DownloadableImageView(url: url) -// .transition(.fade(duration: 0.3)).id(url?.absoluteString ?? "") } } diff --git a/openHABWatch/Views/Utils/DownloadableImageView.swift b/openHABWatch/Views/Utils/DownloadableImageView.swift deleted file mode 100644 index f50bd432f..000000000 --- a/openHABWatch/Views/Utils/DownloadableImageView.swift +++ /dev/null @@ -1,147 +0,0 @@ -// Copyright (c) 2010-2025 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 OpenHABCore -import OSLog -import SDWebImage -import SDWebImageSVGCoder -import SwiftUI -import WatchKit - -enum DownloadableImageError: Error { - case failedToDecode - case failedToLoad - case nohttpClient -} - -class SVGImageLoader: ObservableObject { - @Published var uiImage: UIImage? - - func updateImage(_ image: UIImage?) { - DispatchQueue.main.async { - self.uiImage = image - } - } -} - -class ImageCacheManager { - static let shared = ImageCacheManager() - - private let cache = NSCache() - private let expirationTime: TimeInterval = 300 // 5 minutes - - private init() {} - - func getCachedImage(for url: URL) -> UIImage? { - guard let cachedImage = cache.object(forKey: url as NSURL) else { - return nil - } - - if Date().timeIntervalSince(cachedImage.timestamp) > expirationTime { - cache.removeObject(forKey: url as NSURL) // Expired, remove it - return nil - } - - return cachedImage.image - } - - func cacheImage(_ image: UIImage, for url: URL) { - let cachedImage = CachedImage(image: image, timestamp: Date()) - cache.setObject(cachedImage, forKey: url as NSURL) - } -} - -// A wrapper for storing images with timestamps -class CachedImage: NSObject { - let image: UIImage - let timestamp: Date - - init(image: UIImage, timestamp: Date) { - self.image = image - self.timestamp = timestamp - } -} - -struct DownloadableImageView: View { - let url: URL? - @StateObject private var imageLoader = SVGImageLoader() - @State private var isLoading = true - @State private var asyncOperation: Task? - private let logger = Logger(subsystem: "org.openhab.app", category: "DownloadableImageView") - - var body: some View { - Group { - if let uiImage = imageLoader.uiImage { - Image(uiImage: uiImage) - .resizable() - .scaledToFit() - .id(uiImage) // Forces re-render - } else if isLoading { - ProgressView() - } else { - Image(systemSymbol: .arrowTriangle2CirclepathCircle) - .font(.callout) - .opacity(0.3) - } - } - .task { - await fetchImage() - } - .scaledToFit() - } - - // Add an explicit initializer - init(url: URL?) { - self.url = url - } - - private func fetchImage() async { - print("Fetching Image from \(String(describing: url))") - guard let url else { - print("fetchImage() skipped: URL is nil") - isLoading = false - return - } - - // Check cache first - if let cachedImage = ImageCacheManager.shared.getCachedImage(for: url) { - print("Loaded from cache: \(url)") - imageLoader.updateImage(cachedImage) - return - } - - print("Fetching fresh image from \(url)") - - do { - guard let client = NetworkTracker.shared.httpClient else { - throw DownloadableImageError.nohttpClient - } - let (data, _): (Data, URLResponse) = try await client.doRequest(baseURL: url, type: .data) - try await MainActor.run { - let scaleFactor = WKInterfaceDevice.current().screenScale - let options: [SDImageCoderOption: Any] = [ - .decodeScaleFactor: scaleFactor, - .decodeThumbnailPixelSize: CGSize(width: 200, height: 200) - ] - - if let image = SDImageCodersManager.shared.decodedImage(with: data, options: options) { - logger.info("Downloaded and decoded image from \(url)") - ImageCacheManager.shared.cacheImage(image, for: url) // Cache it - imageLoader.updateImage(image) - } else { - throw DownloadableImageError.failedToDecode - } - } - } catch { - logger.error("Image loading failed") - } - } -} From 0cd137cef9a416f89d36e1436dc44e147223a5f4 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Thu, 3 Apr 2025 13:41:18 +0200 Subject: [PATCH 128/476] Sync selection of sitemapForWatch in DrawerView Introduce backoff Attempts in apple Watch Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../OpenHABCore/Model/OpenHABPage.swift | 4 +- .../Sources/OpenHABCore/Util/Endpoint.swift | 2 +- .../OpenHABCore/Util/OpenHABItemCache.swift | 5 ++ openHAB/DrawerView.swift | 1 + openHABWatch/Domain/UserData.swift | 46 +++++++--- openHABWatch/External/AppMessageService.swift | 1 - openHABWatch/Model/AppSettings.swift | 86 +++++++++++++++++-- 7 files changed, 125 insertions(+), 20 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABPage.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABPage.swift index 1562be4f9..c14667f5e 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABPage.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABPage.swift @@ -87,8 +87,8 @@ public extension OpenHABPage.CodingData { } } -extension OpenHABPage { - public convenience init?(_ page: Components.Schemas.PageDTO?) { +public extension OpenHABPage { + convenience init?(_ page: Components.Schemas.PageDTO?) { if let page { self.init( pageId: page.id.orEmpty, diff --git a/OpenHABCore/Sources/OpenHABCore/Util/Endpoint.swift b/OpenHABCore/Sources/OpenHABCore/Util/Endpoint.swift index 794ea9764..c78061817 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/Endpoint.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/Endpoint.swift @@ -17,7 +17,7 @@ public enum ChartStyle { case light } -public enum IconType: Int, CaseIterable, Identifiable, CustomStringConvertible { +public enum IconType: Int, CaseIterable, Identifiable, CustomStringConvertible, Codable { case png case svg diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift index 812527ce6..2b7fc75c2 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift @@ -15,6 +15,7 @@ import os.log public actor OpenHABItemCache { public static let instance = OpenHABItemCache() + public var items: [OpenHABItem]? var cancellables = Set() var timeout: Double = 20 @@ -29,6 +30,10 @@ public actor OpenHABItemCache { NetworkTracker.shared.startTracking(connectionConfigurations: [connection1, connection2]) } + private init(connections: [ConnectionConfiguration]) { + NetworkTracker.shared.startTracking(connectionConfigurations: connections) + } + public func getItemNames(searchTerm: String?, types: [OpenHABItem.ItemType]?) -> [String] { guard let items else { return [] diff --git a/openHAB/DrawerView.swift b/openHAB/DrawerView.swift index 578500d4c..80a31e9b9 100644 --- a/openHAB/DrawerView.swift +++ b/openHAB/DrawerView.swift @@ -150,6 +150,7 @@ struct DrawerView: View { sitemapForWatch = sitemap.name Preferences.sitemapForWatch = sitemap.name } + WatchMessageService.singleton.syncPreferencesToWatch() } } } diff --git a/openHABWatch/Domain/UserData.swift b/openHABWatch/Domain/UserData.swift index fd883cfc2..2fa6e5e23 100644 --- a/openHABWatch/Domain/UserData.swift +++ b/openHABWatch/Domain/UserData.swift @@ -103,6 +103,14 @@ final class UserData: ObservableObject { } .store(in: &cancellables) + AppSettings.shared.$sitemapForWatch + .removeDuplicates() + .sink { [weak self] newValue in + guard !newValue.isEmpty else { return } + self?.startPageHandling(sitemapName: newValue) + } + .store(in: &cancellables) + Task { await observeNetworkChanges() } @@ -124,7 +132,7 @@ final class UserData: ObservableObject { AppSettings.shared.openHABRootUrl = activeConnection.configuration.url AppSettings.shared.openHABVersion = activeConnection.version - + // TODO: Check whether there is need to setup requestModifier for Kingfisher startPageHandling(sitemapName: AppSettings.shared.sitemapForWatch) @@ -160,17 +168,35 @@ final class UserData: ObservableObject { self.isLoadingSitemap = false } - // Long polling loop - while !Task.isCancelled { - let page = try await service.pollDataForPage(sitemapname: sitemapName, pageId: pageId, longPolling: true) - try Task.checkCancellation() + // Long polling loop with backoff + var backoffAttempt = 0 + let maxBackoffDelay: UInt64 = 30_000_000_000 // 30 seconds - await MainActor.run { - self.openHABSitemapPage = page - openHABSitemapPage?.sendCommand = { [weak self] item, command in - Task { await self?.sendCommand(item, command: command) } + while !Task.isCancelled { + do { + let page = try await service.pollDataForPage(sitemapname: sitemapName, pageId: pageId, longPolling: true) + try Task.checkCancellation() + + await MainActor.run { + self.openHABSitemapPage = page + openHABSitemapPage?.sendCommand = { [weak self] item, command in + Task { await self?.sendCommand(item, command: command) } + } + self.widgets = page?.widgets ?? [] } - self.widgets = page?.widgets ?? [] + + // Reset backoff after success + backoffAttempt = 0 + + } catch { + backoffAttempt += 1 + let baseDelay = min(UInt64(pow(2.0, Double(backoffAttempt))) * 1_000_000_000, maxBackoffDelay) + let jitter = UInt64.random(in: 0 ..< (baseDelay / 2)) + let totalDelay = baseDelay + jitter + + logger.warning("Polling failed: \(error.localizedDescription). Retrying in \(Double(totalDelay) / 1_000_000_000.0) seconds.") + + try await Task.sleep(nanoseconds: totalDelay) } } } catch { diff --git a/openHABWatch/External/AppMessageService.swift b/openHABWatch/External/AppMessageService.swift index 9f1032cde..1a1f3a0cd 100644 --- a/openHABWatch/External/AppMessageService.swift +++ b/openHABWatch/External/AppMessageService.swift @@ -39,7 +39,6 @@ class AppMessageService: NSObject, WCSessionDelegate { // if let trustedCertificates = applicationContext["trustedCertificates"] as? [String: Data] { // // do we need to do anything here? We load from the shared keychain. // } - AppSettings.shared.haveReceivedAppContext = true logger.info("✅ Applied WatchPreferences to ObservableOpenHABDataObject") } catch { logger.error("❌ Failed to decode WatchPreferences: \(error.localizedDescription)") diff --git a/openHABWatch/Model/AppSettings.swift b/openHABWatch/Model/AppSettings.swift index f28c49dc9..9f377c8ab 100644 --- a/openHABWatch/Model/AppSettings.swift +++ b/openHABWatch/Model/AppSettings.swift @@ -12,18 +12,92 @@ import Combine import Foundation import OpenHABCore +import SwiftUI final class AppSettings: ObservableObject { static let shared = AppSettings() - var openHABVersion: Int = 2 + var cancellables = Set() @Published var localConnectionConfig: ConnectionConfiguration? @Published var remoteConnectionConfig: ConnectionConfiguration? - @Published var haveReceivedAppContext: Bool = false + @Published var openHABRootUrl: String + @Published var sitemapName: String + @Published var sitemapForWatch: String + @Published var iconType: IconType + @Published var haveReceivedAppContext = false + + init() { + let store = UserDefaults(suiteName: "group.openhab.shared")! + + if let data = store.data(forKey: "localConnectionConfig"), + let decoded = try? JSONDecoder().decode(ConnectionConfiguration.self, from: data) { + localConnectionConfig = decoded + } else { + localConnectionConfig = nil + } + + if let data = store.data(forKey: "remoteConnectionConfig"), + let decoded = try? JSONDecoder().decode(ConnectionConfiguration.self, from: data) { + remoteConnectionConfig = decoded + } else { + remoteConnectionConfig = nil + } + + openHABRootUrl = store.string(forKey: "openHABRootUrl") ?? "" + sitemapName = store.string(forKey: "sitemapName") ?? "" + sitemapForWatch = store.string(forKey: "sitemapForWatch") ?? "" + iconType = IconType(rawValue: store.integer(forKey: "iconType")) ?? .svg + + // Observe changes and write back to UserDefaults + $localConnectionConfig + .sink { newValue in + if let newValue, + let data = try? JSONEncoder().encode(newValue) { + store.set(data, forKey: "localConnectionConfig") + } else { + store.removeObject(forKey: "localConnectionConfig") + } + } + .store(in: &cancellables) + + $remoteConnectionConfig + .sink { newValue in + if let newValue, + let data = try? JSONEncoder().encode(newValue) { + store.set(data, forKey: "remoteConnectionConfig") + } else { + store.removeObject(forKey: "remoteConnectionConfig") + } + } + .store(in: &cancellables) + + $openHABRootUrl + .removeDuplicates() + .sink { newValue in + store.set(newValue, forKey: "openHABRootUrl") + } + .store(in: &cancellables) + + $sitemapName + .removeDuplicates() + .sink { newValue in + store.set(newValue, forKey: "sitemapName") + } + .store(in: &cancellables) + + $sitemapForWatch + .removeDuplicates() + .sink { newValue in + store.set(newValue, forKey: "sitemapForWatch") + } + .store(in: &cancellables) - @Published var openHABRootUrl = "" - @Published var sitemapName = "" - @Published var sitemapForWatch = "" - @Published var iconType: IconType = .svg + $iconType + .removeDuplicates() + .sink { newValue in + store.set(newValue.rawValue, forKey: "iconType") + } + .store(in: &cancellables) + } } From b328cebeb7c60522906738334f2b3f0e5df2c88b Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Thu, 3 Apr 2025 20:29:12 +0200 Subject: [PATCH 129/476] Showing sitemap label in watch app Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../OpenHABCore/Util/Preferences.swift | 1 + .../OpenHABCore/Util/WatchPreferences.swift | 4 +- openHAB/DrawerView.swift | 2 + openHAB/SettingsView/SettingsView.swift | 1 + openHAB/WatchMessageService.swift | 1 + openHABWatch/Domain/UserData.swift | 3 +- openHABWatch/External/AppMessageService.swift | 1 + openHABWatch/Model/AppSettings.swift | 9 ++++ openHABWatch/Views/ContentView.swift | 28 ++++++---- .../Views/PreferencesSwiftUIView.swift | 52 +++++++++++++++++-- openHABWatch/Views/Rows/ColorPickerRow.swift | 7 +-- openHABWatch/Views/Rows/FrameRow.swift | 7 +-- openHABWatch/Views/Rows/GenericRow.swift | 7 +-- openHABWatch/Views/Rows/ImageRawRow.swift | 7 +-- openHABWatch/Views/Rows/ImageRow.swift | 7 +-- openHABWatch/Views/Rows/MapViewRow.swift | 7 +-- .../Views/Rows/RollershutterRow.swift | 7 +-- openHABWatch/Views/Rows/SegmentRow.swift | 7 +-- openHABWatch/Views/Rows/SetpointRow.swift | 7 +-- openHABWatch/Views/Rows/SliderRow.swift | 7 +-- .../Rows/SliderWithSwitchSupportRow.swift | 7 +-- openHABWatch/Views/Rows/SwitchRow.swift | 7 +-- openHABWatch/Views/Utils/IconView.swift | 7 +-- 23 files changed, 98 insertions(+), 95 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift index a0788fb17..951fcb95f 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift @@ -160,6 +160,7 @@ public enum Preferences { @UserDefault("sitemapForWatch", defaultValue: "watch") public static var sitemapForWatch: String @UserDefaultObject("localConnectionConfig", defaultValue: ConnectionConfiguration.localDefault) public static var localConnectionConfig: ConnectionConfiguration @UserDefaultObject("remoteConnectionConfig", defaultValue: ConnectionConfiguration.remoteDefault) public static var remoteConnectionConfig: ConnectionConfiguration + @UserDefault("sitemapForWatchLabel", defaultValue: "watch") public static var sitemapForWatchLabel: String // MARK: - Private diff --git a/OpenHABCore/Sources/OpenHABCore/Util/WatchPreferences.swift b/OpenHABCore/Sources/OpenHABCore/Util/WatchPreferences.swift index 2e9fe72e2..7f06fd99b 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/WatchPreferences.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/WatchPreferences.swift @@ -13,7 +13,7 @@ import Foundation import os.log public struct WatchPreferences: Codable { - public init(localUrl: String, remoteUrl: String, username: String, password: String, alwaysSendCreds: Bool, defaultSitemap: String, ignoreSSL: Bool, sitemapForWatch: String, iconType: Int, demoMode: Bool, localConnectionConfiguration: ConnectionConfiguration? = nil, remoteConnectionConfiguration: ConnectionConfiguration? = nil) { + public init(localUrl: String, remoteUrl: String, username: String, password: String, alwaysSendCreds: Bool, defaultSitemap: String, ignoreSSL: Bool, sitemapForWatch: String, sitemapForWatchLabel: String, iconType: Int, demoMode: Bool, localConnectionConfiguration: ConnectionConfiguration? = nil, remoteConnectionConfiguration: ConnectionConfiguration? = nil) { self.localUrl = localUrl self.remoteUrl = remoteUrl self.username = username @@ -22,6 +22,7 @@ public struct WatchPreferences: Codable { self.defaultSitemap = defaultSitemap self.ignoreSSL = ignoreSSL self.sitemapForWatch = sitemapForWatch + self.sitemapForWatchLabel = sitemapForWatchLabel self.iconType = iconType self.demoMode = demoMode self.localConnectionConfiguration = localConnectionConfiguration @@ -36,6 +37,7 @@ public struct WatchPreferences: Codable { public var defaultSitemap: String public var ignoreSSL: Bool public var sitemapForWatch: String + public var sitemapForWatchLabel: String public var iconType: Int public var demoMode: Bool public var localConnectionConfiguration: ConnectionConfiguration? diff --git a/openHAB/DrawerView.swift b/openHAB/DrawerView.swift index 80a31e9b9..acba4d1fa 100644 --- a/openHAB/DrawerView.swift +++ b/openHAB/DrawerView.swift @@ -146,9 +146,11 @@ struct DrawerView: View { if sitemap.name == sitemapForWatch { sitemapForWatch = nil Preferences.sitemapForWatch = "" + Preferences.sitemapForWatchLabel = "" } else { sitemapForWatch = sitemap.name Preferences.sitemapForWatch = sitemap.name + Preferences.sitemapForWatchLabel = sitemap.label } WatchMessageService.singleton.syncPreferencesToWatch() } diff --git a/openHAB/SettingsView/SettingsView.swift b/openHAB/SettingsView/SettingsView.swift index 386bb5ee0..de6868424 100644 --- a/openHAB/SettingsView/SettingsView.swift +++ b/openHAB/SettingsView/SettingsView.swift @@ -153,6 +153,7 @@ struct SettingsView: View { Preferences.defaultMainUIPath = settingsDefaultMainUIPath Preferences.alwaysAllowWebRTC = settingsAlwaysAllowWebRTC Preferences.sitemapForWatch = settingsSitemapForWatch + Preferences.sitemapForWatchLabel = sitemaps.first(where: { $0.name == settingsSitemapForWatch })?.label ?? "unknown" Preferences.localConnectionConfig = settingsLocalConnectionConfiguration Preferences.remoteConnectionConfig = settingsRemoteConnectionConfiguration WatchMessageService.singleton.syncPreferencesToWatch() diff --git a/openHAB/WatchMessageService.swift b/openHAB/WatchMessageService.swift index 5f3f8c07d..fcd49e94e 100644 --- a/openHAB/WatchMessageService.swift +++ b/openHAB/WatchMessageService.swift @@ -82,6 +82,7 @@ public extension WatchPreferences { defaultSitemap: preferences.defaultSitemap, ignoreSSL: preferences.ignoreSSL, sitemapForWatch: preferences.sitemapForWatch, + sitemapForWatchLabel: preferences.sitemapForWatchLabel, iconType: preferences.iconType, demoMode: preferences.demomode, localConnectionConfiguration: preferences.localConnectionConfig, diff --git a/openHABWatch/Domain/UserData.swift b/openHABWatch/Domain/UserData.swift index 2fa6e5e23..b157a91e4 100644 --- a/openHABWatch/Domain/UserData.swift +++ b/openHABWatch/Domain/UserData.swift @@ -228,8 +228,7 @@ final class UserData: ObservableObject { } func refreshUrl() async { - guard AppSettings.shared.haveReceivedAppContext, - !AppSettings.shared.openHABRootUrl.isEmpty else { return } + guard AppSettings.shared.haveReceivedAppContext, !AppSettings.shared.openHABRootUrl.isEmpty else { return } showAlert = false startPageHandling(sitemapName: AppSettings.shared.sitemapForWatch) diff --git a/openHABWatch/External/AppMessageService.swift b/openHABWatch/External/AppMessageService.swift index 1a1f3a0cd..d9f811984 100644 --- a/openHABWatch/External/AppMessageService.swift +++ b/openHABWatch/External/AppMessageService.swift @@ -34,6 +34,7 @@ class AppMessageService: NSObject, WCSessionDelegate { AppSettings.shared.remoteConnectionConfig = prefs.remoteConnectionConfiguration ?? .remoteDefault AppSettings.shared.sitemapName = prefs.defaultSitemap AppSettings.shared.sitemapForWatch = prefs.sitemapForWatch + AppSettings.shared.sitemapForWatchLabel = prefs.sitemapForWatchLabel AppSettings.shared.iconType = IconType(rawValue: prefs.iconType) ?? .svg AppSettings.shared.haveReceivedAppContext = true // if let trustedCertificates = applicationContext["trustedCertificates"] as? [String: Data] { diff --git a/openHABWatch/Model/AppSettings.swift b/openHABWatch/Model/AppSettings.swift index 9f377c8ab..b8d2a38e1 100644 --- a/openHABWatch/Model/AppSettings.swift +++ b/openHABWatch/Model/AppSettings.swift @@ -24,6 +24,7 @@ final class AppSettings: ObservableObject { @Published var openHABRootUrl: String @Published var sitemapName: String @Published var sitemapForWatch: String + @Published var sitemapForWatchLabel: String @Published var iconType: IconType @Published var haveReceivedAppContext = false @@ -47,6 +48,7 @@ final class AppSettings: ObservableObject { openHABRootUrl = store.string(forKey: "openHABRootUrl") ?? "" sitemapName = store.string(forKey: "sitemapName") ?? "" sitemapForWatch = store.string(forKey: "sitemapForWatch") ?? "" + sitemapForWatchLabel = store.string(forKey: "sitemapForWatchLabel") ?? "" iconType = IconType(rawValue: store.integer(forKey: "iconType")) ?? .svg // Observe changes and write back to UserDefaults @@ -93,6 +95,13 @@ final class AppSettings: ObservableObject { } .store(in: &cancellables) + $sitemapForWatchLabel + .removeDuplicates() + .sink { newValue in + store.set(newValue, forKey: "sitemapForWatchLabel") + } + .store(in: &cancellables) + $iconType .removeDuplicates() .sink { newValue in diff --git a/openHABWatch/Views/ContentView.swift b/openHABWatch/Views/ContentView.swift index 66077c5e9..60810f3c4 100644 --- a/openHABWatch/Views/ContentView.swift +++ b/openHABWatch/Views/ContentView.swift @@ -19,31 +19,41 @@ struct ContentView: View { @State var title = "openHAB" var body: some View { - ZStack { + Group { if viewModel.isLoadingSitemap { - ProgressView("Loading sitemap...") - .progressViewStyle(CircularProgressViewStyle()) - } else if viewModel.widgets.isEmpty { VStack { - Text("No widgets available.") + Spacer() + ProgressView("Loading sitemap...") + .progressViewStyle(CircularProgressViewStyle()) .font(.footnote) - .foregroundStyle(.secondary) + Spacer() } - } else { + } else if !viewModel.widgets.isEmpty { ScrollView { HStack { - Text(viewModel.openHABSitemapPage?.title ?? "Waiting...") - .font(.body) + Text(viewModel.openHABSitemapPage?.title ?? "Sitemap") + .font(.headline) .lineLimit(1) Spacer() } + .padding(.horizontal) + ForEach(viewModel.widgets) { widget in rowWidget(widget: widget) } } .navigationBarTitle(Text(title)) + } else { + VStack { + Spacer() + Text("No widgets available.") + .font(.footnote) + .foregroundStyle(.secondary) + Spacer() + } } } + .animation(.easeInOut(duration: 0.2), value: viewModel.isLoadingSitemap) .alert(isPresented: $viewModel.showCertificateAlert) { Alert( title: Text(NSLocalizedString("ssl_certificate_warning", comment: "")), diff --git a/openHABWatch/Views/PreferencesSwiftUIView.swift b/openHABWatch/Views/PreferencesSwiftUIView.swift index 1fbc82915..12606c052 100644 --- a/openHABWatch/Views/PreferencesSwiftUIView.swift +++ b/openHABWatch/Views/PreferencesSwiftUIView.swift @@ -25,17 +25,59 @@ struct PreferencesSwiftUIView: View { var body: some View { List { - LabeledContent(LocalizedStringKey("active_url"), value: settings.openHABRootUrl) LabeledContent(LocalizedStringKey("local_url"), value: settings.localConnectionConfig?.url ?? "empty") + .highlightDotRow(if: settings.localConnectionConfig?.url == settings.openHABRootUrl) LabeledContent(LocalizedStringKey("remote_url"), value: settings.remoteConnectionConfig?.url ?? "empty") - LabeledContent(LocalizedStringKey("sitemap"), value: settings.sitemapForWatch) + .highlightDotRow(if: settings.remoteConnectionConfig?.url == settings.openHABRootUrl) + LabeledContent(LocalizedStringKey("sitemap"), value: settings.sitemapForWatchLabel) + .listRowInsets(EdgeInsets(top: 0, leading: 5, bottom: 0, trailing: 5)) LabeledContent(LocalizedStringKey("version"), value: applicationVersionNumber) + .listRowInsets(EdgeInsets(top: 0, leading: 5, bottom: 0, trailing: 5)) } + .listRowInsets(EdgeInsets(top: 0, leading: 5, bottom: 0, trailing: 5)) + .listStyle(.plain) + .environment(\.defaultMinListRowHeight, 10) + .labeledContentStyle(CompactLabeledContentStyle()) // 👈 Apply custom style + .refreshable { + AppMessageService.singleton.requestApplicationContext() + } + } +} + +struct HighlightDotRowModifier: ViewModifier { + let showDot: Bool + + func body(content: Content) -> some View { + content + .listRowInsets(EdgeInsets(top: 0, leading: 5, bottom: 0, trailing: 5)) + .overlay(alignment: .topTrailing) { + if showDot { + Circle() + .fill(Color.red.opacity(0.7)) + .frame(width: 6, height: 6) + .offset(x: 0, y: 0) + } + } + } +} + +extension View { + func highlightDotRow(if condition: Bool) -> some View { + modifier(HighlightDotRowModifier(showDot: condition)) + } +} - Button { AppMessageService.singleton.requestApplicationContext() - } label: { Label("sync_prefs", systemSymbol: .arrowTriangle2Circlepath) +struct CompactLabeledContentStyle: LabeledContentStyle { + func makeBody(configuration: Configuration) -> some View { + HStack { + configuration.label + .font(.footnote) + Spacer() + configuration.content + .font(.footnote) + .foregroundColor(.secondary) } - .buttonStyle(.borderedProminent) +// .padding(.vertical, 4) // Reduces vertical space } } diff --git a/openHABWatch/Views/Rows/ColorPickerRow.swift b/openHABWatch/Views/Rows/ColorPickerRow.swift index 118380f49..8ad5c0e13 100644 --- a/openHABWatch/Views/Rows/ColorPickerRow.swift +++ b/openHABWatch/Views/Rows/ColorPickerRow.swift @@ -66,11 +66,6 @@ struct ColorPickerRow: View { #Preview { let widget = UserData(preview: true).widgets[10] - let mockSettings = { - let obj = AppSettings() - obj.openHABRootUrl = PreviewConstants.remoteURLString - return obj - }() ColorPickerRow(widget: widget) - .environmentObject(mockSettings) + .environmentObject(AppSettings()) } diff --git a/openHABWatch/Views/Rows/FrameRow.swift b/openHABWatch/Views/Rows/FrameRow.swift index c82af96a9..cf74be7dc 100644 --- a/openHABWatch/Views/Rows/FrameRow.swift +++ b/openHABWatch/Views/Rows/FrameRow.swift @@ -28,11 +28,6 @@ struct FrameRow: View { #Preview { let widget = UserData(preview: true).widgets[6] - let mockSettings = { - let obj = AppSettings() - obj.openHABRootUrl = PreviewConstants.remoteURLString - return obj - }() FrameRow(widget: widget) - .environmentObject(mockSettings) + .environmentObject(AppSettings()) } diff --git a/openHABWatch/Views/Rows/GenericRow.swift b/openHABWatch/Views/Rows/GenericRow.swift index 554e7f0b1..58ee3fa0c 100644 --- a/openHABWatch/Views/Rows/GenericRow.swift +++ b/openHABWatch/Views/Rows/GenericRow.swift @@ -30,11 +30,6 @@ struct GenericRow: View { #Preview { let widget = UserData(preview: true).widgets[6] - let mockSettings = { - let obj = AppSettings() - obj.openHABRootUrl = PreviewConstants.remoteURLString - return obj - }() GenericRow(widget: widget) - .environmentObject(mockSettings) + .environmentObject(AppSettings()) } diff --git a/openHABWatch/Views/Rows/ImageRawRow.swift b/openHABWatch/Views/Rows/ImageRawRow.swift index f87e0be0a..7e26b92bf 100644 --- a/openHABWatch/Views/Rows/ImageRawRow.swift +++ b/openHABWatch/Views/Rows/ImageRawRow.swift @@ -32,11 +32,6 @@ struct ImageRawRow: View { #Preview { let widget = UserData(preview: true).widgets[4] - let mockSettings = { - let obj = AppSettings() - obj.openHABRootUrl = PreviewConstants.remoteURLString - return obj - }() ImageRawRow(widget: widget) - .environmentObject(mockSettings) + .environmentObject(AppSettings()) } diff --git a/openHABWatch/Views/Rows/ImageRow.swift b/openHABWatch/Views/Rows/ImageRow.swift index a21de963f..fe8ec65d1 100644 --- a/openHABWatch/Views/Rows/ImageRow.swift +++ b/openHABWatch/Views/Rows/ImageRow.swift @@ -40,11 +40,6 @@ struct ImageRow: View { iconType: .svg, iconColor: "" ).url - let mockSettings = { - let obj = AppSettings() - obj.openHABRootUrl = PreviewConstants.remoteURLString - return obj - }() return ImageRow(url: iconUrl) - .environmentObject(mockSettings) + .environmentObject(AppSettings()) } diff --git a/openHABWatch/Views/Rows/MapViewRow.swift b/openHABWatch/Views/Rows/MapViewRow.swift index 637b1d600..08a49154b 100644 --- a/openHABWatch/Views/Rows/MapViewRow.swift +++ b/openHABWatch/Views/Rows/MapViewRow.swift @@ -28,11 +28,6 @@ struct MapViewRow: View { #Preview { let widget = UserData(preview: true).widgets[9] - let mockSettings = { - let obj = AppSettings() - obj.openHABRootUrl = PreviewConstants.remoteURLString - return obj - }() MapViewRow(widget: widget) - .environmentObject(mockSettings) + .environmentObject(AppSettings()) } diff --git a/openHABWatch/Views/Rows/RollershutterRow.swift b/openHABWatch/Views/Rows/RollershutterRow.swift index 602db0447..80d0a1d75 100644 --- a/openHABWatch/Views/Rows/RollershutterRow.swift +++ b/openHABWatch/Views/Rows/RollershutterRow.swift @@ -46,11 +46,6 @@ struct RollershutterRow: View { #Preview { let widget = UserData(preview: true).widgets[5] - let mockSettings = { - let obj = AppSettings() - obj.openHABRootUrl = PreviewConstants.remoteURLString - return obj - }() RollershutterRow(widget: widget) - .environmentObject(mockSettings) + .environmentObject(AppSettings()) } diff --git a/openHABWatch/Views/Rows/SegmentRow.swift b/openHABWatch/Views/Rows/SegmentRow.swift index c39884a3d..7c82c7ce2 100644 --- a/openHABWatch/Views/Rows/SegmentRow.swift +++ b/openHABWatch/Views/Rows/SegmentRow.swift @@ -63,14 +63,9 @@ struct SegmentRow: View { #Preview { let widget = UserData(preview: true).widgets[4] - let mockSettings = { - let obj = AppSettings() - obj.openHABRootUrl = PreviewConstants.remoteURLString - return obj - }() Group { SegmentRow(widget: widget) SegmentRow(widget: widget) } - .environmentObject(mockSettings) + .environmentObject(AppSettings()) } diff --git a/openHABWatch/Views/Rows/SetpointRow.swift b/openHABWatch/Views/Rows/SetpointRow.swift index deb72a915..8f952f420 100644 --- a/openHABWatch/Views/Rows/SetpointRow.swift +++ b/openHABWatch/Views/Rows/SetpointRow.swift @@ -83,11 +83,6 @@ struct SetpointRow: View { #Preview { let widget = UserData(preview: true).widgets[3] - let mockSettings = { - let obj = AppSettings() - obj.openHABRootUrl = PreviewConstants.remoteURLString - return obj - }() SetpointRow(widget: widget) - .environmentObject(mockSettings) + .environmentObject(AppSettings()) } diff --git a/openHABWatch/Views/Rows/SliderRow.swift b/openHABWatch/Views/Rows/SliderRow.swift index 49e62ffb3..993394826 100644 --- a/openHABWatch/Views/Rows/SliderRow.swift +++ b/openHABWatch/Views/Rows/SliderRow.swift @@ -61,14 +61,9 @@ struct SliderRow: View { #Preview { let widget = UserData(preview: true).widgets[3] - let mockSettings = { - let obj = AppSettings() - obj.openHABRootUrl = PreviewConstants.remoteURLString - return obj - }() Group { SliderRow(widget: widget) SliderRow(widget: widget) } - .environmentObject(mockSettings) + .environmentObject(AppSettings()) } diff --git a/openHABWatch/Views/Rows/SliderWithSwitchSupportRow.swift b/openHABWatch/Views/Rows/SliderWithSwitchSupportRow.swift index 2000491a5..fa5d97622 100644 --- a/openHABWatch/Views/Rows/SliderWithSwitchSupportRow.swift +++ b/openHABWatch/Views/Rows/SliderWithSwitchSupportRow.swift @@ -86,14 +86,9 @@ struct SliderWithSwitchSupportRow: View { #Preview { let widget = UserData(preview: true).widgets[3] - let mockSettings = { - let obj = AppSettings() - obj.openHABRootUrl = PreviewConstants.remoteURLString - return obj - }() Group { SliderRow(widget: widget) SliderRow(widget: widget) } - .environmentObject(mockSettings) + .environmentObject(AppSettings()) } diff --git a/openHABWatch/Views/Rows/SwitchRow.swift b/openHABWatch/Views/Rows/SwitchRow.swift index eecb12e3c..9dad0e70d 100644 --- a/openHABWatch/Views/Rows/SwitchRow.swift +++ b/openHABWatch/Views/Rows/SwitchRow.swift @@ -52,11 +52,6 @@ struct SwitchRow: View { #Preview { let widget = UserData(preview: true).widgets[2] - let mockSettings = { - let obj = AppSettings() - obj.openHABRootUrl = PreviewConstants.remoteURLString - return obj - }() SwitchRow(widget: widget) - .environmentObject(mockSettings) + .environmentObject(AppSettings()) } diff --git a/openHABWatch/Views/Utils/IconView.swift b/openHABWatch/Views/Utils/IconView.swift index 85dc4ce72..d728b8619 100644 --- a/openHABWatch/Views/Utils/IconView.swift +++ b/openHABWatch/Views/Utils/IconView.swift @@ -55,11 +55,6 @@ struct IconView: View { } #Preview { - let mockSettings = { - let obj = AppSettings() - obj.openHABRootUrl = PreviewConstants.remoteURLString - return obj - }() let widget2 = UserData(preview: true).widgets[3] - IconView(widget: widget2, settings: mockSettings) + IconView(widget: widget2, settings: AppSettings()) } From 02db2897fad3100342d24cf457c9412041780fb4 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Sat, 5 Apr 2025 14:38:26 +0200 Subject: [PATCH 130/476] Consider previousFailureCount in urlession( didReceive) Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../GeneratedSources/openapi/Client.swift | 2 + .../GeneratedSources/openapi/Types.swift | 35 ++++++++++++++++ .../OpenHABCore/Util/AuthAttemptTracker.swift | 26 ------------ .../Sources/OpenHABCore/Util/HTTPClient.swift | 41 ++++++++----------- .../OpenHABCore/Util/NetworkConnection.swift | 18 ++++---- .../OpenHABCore/Util/NetworkTracker.swift | 2 +- .../OpenHABCore/Util/OpenAPIService.swift | 7 +++- .../Util/OpenAPIServiceDelegate.swift | 33 ++++++++------- .../Sources/OpenHABCore/openapi/openapi.json | 3 ++ .../SingleConnectionSettingsView.swift | 2 +- openHABWatch/Views/Rows/SwitchRow.swift | 11 +++++ openHABWatch/Views/Utils/IconView.swift | 15 +++---- 12 files changed, 109 insertions(+), 86 deletions(-) delete mode 100644 OpenHABCore/Sources/OpenHABCore/Util/AuthAttemptTracker.swift diff --git a/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Client.swift b/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Client.swift index cdcb172c4..ef46695f0 100644 --- a/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Client.swift +++ b/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Client.swift @@ -2092,6 +2092,8 @@ public struct Client: APIProtocol { preconditionFailure("bestContentType chose an invalid content type.") } return .ok(.init(body: body)) + case 401: + return .unauthorized(.init()) default: return .undocumented( statusCode: response.status.code, diff --git a/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Types.swift b/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Types.swift index 1bb1d980f..1641a18a3 100644 --- a/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Types.swift +++ b/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Types.swift @@ -9545,6 +9545,41 @@ public enum Operations { } } } + public struct Unauthorized: Sendable, Hashable { + /// Creates a new `Unauthorized`. + public init() {} + } + /// User is not authenticated + /// + /// - Remark: Generated from `#/paths///get(getRoot)/responses/401`. + /// + /// HTTP response code: `401 unauthorized`. + case unauthorized(Operations.getRoot.Output.Unauthorized) + /// User is not authenticated + /// + /// - Remark: Generated from `#/paths///get(getRoot)/responses/401`. + /// + /// HTTP response code: `401 unauthorized`. + public static var unauthorized: Self { + .unauthorized(.init()) + } + /// The associated value of the enum case if `self` is `.unauthorized`. + /// + /// - Throws: An error if `self` is not `.unauthorized`. + /// - SeeAlso: `.unauthorized`. + public var unauthorized: Operations.getRoot.Output.Unauthorized { + get throws { + switch self { + case let .unauthorized(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "unauthorized", + response: self + ) + } + } + } /// Undocumented response. /// /// A response with a code that is not documented in the OpenAPI document. diff --git a/OpenHABCore/Sources/OpenHABCore/Util/AuthAttemptTracker.swift b/OpenHABCore/Sources/OpenHABCore/Util/AuthAttemptTracker.swift deleted file mode 100644 index f132a72fc..000000000 --- a/OpenHABCore/Sources/OpenHABCore/Util/AuthAttemptTracker.swift +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright (c) 2010-2025 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 Foundation - -public actor AuthAttemptTracker { - private var attemptCounts: [URLSessionTask: Int] = [:] - - func incrementAttempt(for task: URLSessionTask) -> Int { - attemptCounts[task, default: 0] += 1 - return attemptCounts[task]! - } - - func resetAttempt(for task: URLSessionTask?) { - guard let task else { return } - attemptCounts[task] = 0 - } -} diff --git a/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift b/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift index 0f04e0cc3..616ca818c 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift @@ -70,7 +70,6 @@ public class HTTPClient: NSObject { private let ignoreSSL: Bool private var evaluateContinuation: CheckedContinuation? private var trustedCertificates: [String: Data] = [:] - private let authAttemptTracker = AuthAttemptTracker() private let logger = Logger(subsystem: "org.openhab.core", category: "HTTPClient") @@ -319,31 +318,25 @@ extension HTTPClient: URLSessionDelegate, URLSessionTaskDelegate { } private func urlSessionInternal(_ session: URLSession, task: URLSessionTask?, didReceive challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { - os_log("URLAuthenticationChallenge: %{public}@", log: .networking, type: .info, challenge.protectionSpace.authenticationMethod) let authenticationMethod = challenge.protectionSpace.authenticationMethod - switch authenticationMethod { - case NSURLAuthenticationMethodServerTrust: - let result = await handleServerTrust(challenge: challenge) - await authAttemptTracker.resetAttempt(for: task) // ✅ Reset on success - return result - case NSURLAuthenticationMethodDefault, NSURLAuthenticationMethodHTTPBasic: - if let task { - let attemptCount = await authAttemptTracker.incrementAttempt(for: task) // ✅ Call actor asynchronously - if attemptCount > 1 { - return (.cancelAuthenticationChallenge, nil) - } else { - let result = await handleBasicAuth(challenge: challenge) - return result - } - } else { - return await handleBasicAuth(challenge: challenge) + logger.debug("URLAuthenticationChallenge: \(authenticationMethod)") + + if challenge.previousFailureCount > 0 { + return (.cancelAuthenticationChallenge, nil) + } else { + switch authenticationMethod { + case NSURLAuthenticationMethodServerTrust: + let result = await handleServerTrust(challenge: challenge) + return result + case NSURLAuthenticationMethodDefault, NSURLAuthenticationMethodHTTPBasic: + let result = await handleBasicAuth(challenge: challenge) + return result + case NSURLAuthenticationMethodClientCertificate: + let result = await handleClientCertificateAuth(challenge: challenge) + return result + default: + return (.performDefaultHandling, nil) } - case NSURLAuthenticationMethodClientCertificate: - let result = await handleClientCertificateAuth(challenge: challenge) - await authAttemptTracker.resetAttempt(for: task) // ✅ Reset on success - return result - default: - return (.performDefaultHandling, nil) } } diff --git a/OpenHABCore/Sources/OpenHABCore/Util/NetworkConnection.swift b/OpenHABCore/Sources/OpenHABCore/Util/NetworkConnection.swift index 43c9f6e85..6dee8839b 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/NetworkConnection.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/NetworkConnection.swift @@ -10,29 +10,31 @@ // SPDX-License-Identifier: EPL-2.0 import Foundation -import os.log + +private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "org.openhab.app", category: "SessionChallenge") public func onReceiveSessionTaskChallenge(with challenge: URLAuthenticationChallenge) -> (URLSession.AuthChallengeDisposition, URLCredential?) { - os_log("onReceiveSessionTaskChallenge host:'%{PUBLIC}@'", log: .default, type: .error, challenge.protectionSpace.host) + logger.info("onReceiveSessionTaskChallenge host: \(String(describing: challenge.protectionSpace.host))") var disposition: URLSession.AuthChallengeDisposition = .performDefaultHandling var credential: URLCredential? if challenge.previousFailureCount > 0 { return (.cancelAuthenticationChallenge, credential) } else if challenge.protectionSpace.authenticationMethod.isAny(of: NSURLAuthenticationMethodHTTPBasic, NSURLAuthenticationMethodDefault) { - let localUrl = URL(string: Preferences.localUrl) - let remoteUrl = URL(string: Preferences.remoteUrl) - if challenge.protectionSpace.host == localUrl?.host || challenge.protectionSpace.host == remoteUrl?.host || challenge.protectionSpace.host == "home.myopenhab.org" { - credential = URLCredential(user: Preferences.username, password: Preferences.password, persistence: .forSession) + guard let configuration = NetworkTracker.shared.activeConnection?.configuration else { return (.cancelAuthenticationChallenge, credential) } + if challenge.protectionSpace.host == URL(string: configuration.url)?.host || challenge.protectionSpace.host == "home.myopenhab.org" { + credential = URLCredential(user: configuration.username, password: configuration.password, persistence: .forSession) disposition = .useCredential - os_log("HTTP BasicAuth host:'%{PUBLIC}@'", log: .default, type: .error, challenge.protectionSpace.host) + logger.info(".useCredential") + } else { + logger.error("No match \(challenge.protectionSpace.host) <> \(configuration.url)") } } return (disposition, credential) } public func onReceiveSessionChallenge(with challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { - os_log("onReceiveSessionChallenge host:'%{PUBLIC}@'", log: .default, type: .info, challenge.protectionSpace.host) + logger.info("onReceiveSessionChallenge host: \(String(describing: challenge.protectionSpace.host))") var disposition: URLSession.AuthChallengeDisposition = .performDefaultHandling var credential: URLCredential? diff --git a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift index b5253f7fa..7a5431a80 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift @@ -53,7 +53,7 @@ public actor ConnectionPool { // Initializer allowing the injection of mocked OpenAPIServiceProtocol init(serviceFactory: @escaping (ConnectionConfiguration) -> OpenAPIServiceProtocol = { - OpenAPIService(connectionConfiguration: $0, configuration: .shortTerm) + OpenAPIService(connectionConfiguration: $0, serviceConfiguration: .shortTerm) }) { self.serviceFactory = serviceFactory } diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift index 8e5de67f7..b8fbfaf11 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift @@ -20,6 +20,7 @@ public enum OpenAPIServiceError: Error { case noRootURL case badRequest case notFound + case unAuthorized } public enum OpenAPIServiceConfiguration { @@ -57,11 +58,11 @@ public actor OpenAPIService { } public init(connectionConfiguration: ConnectionConfiguration, - configuration: OpenAPIServiceConfiguration = .asDefault) { + serviceConfiguration: OpenAPIServiceConfiguration = .asDefault) { self.connectionConfiguration = connectionConfiguration let delegate = OpenAPIServiceDelegate(with: connectionConfiguration) let config = URLSessionConfiguration.default - switch configuration { + switch serviceConfiguration { case .asDefault: break case .longTerm: @@ -160,6 +161,8 @@ public extension OpenAPIService { case let .ok(okresponse): let result = try okresponse.body.json return OpenHABServerProperties(result) + case .unauthorized: + throw OpenAPIServiceError.unAuthorized case let .undocumented(statusCode, undocumentedPayload): throw OpenAPIServiceError.undocumented(statusCode: statusCode, undocumentedPayload: undocumentedPayload) } diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIServiceDelegate.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIServiceDelegate.swift index 61a8f776d..bd8972194 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIServiceDelegate.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIServiceDelegate.swift @@ -16,7 +16,6 @@ import os final class OpenAPIServiceDelegate: NSObject, URLSessionDelegate, URLSessionTaskDelegate { private let connectionConfiguration: ConnectionConfiguration - private let authTracker = AuthAttemptTracker() // ✅ Use an actor instead of a dictionary private var trustedCertificates: [String: Data] = [:] private var evaluateContinuation: CheckedContinuation? @@ -35,21 +34,25 @@ final class OpenAPIServiceDelegate: NSObject, URLSessionDelegate, URLSessionTask } private func urlSessionInternal(_ session: URLSession, task: URLSessionTask?, didReceive challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { - logger.debug("URLAuthenticationChallenge: \(challenge.protectionSpace.authenticationMethod)") let authenticationMethod = challenge.protectionSpace.authenticationMethod - switch authenticationMethod { - case NSURLAuthenticationMethodServerTrust: - let result = await handleServerTrust(challenge: challenge) - return result - case NSURLAuthenticationMethodDefault, NSURLAuthenticationMethodHTTPBasic: - - let result = handleBasicAuth(challenge: challenge) - return result - case NSURLAuthenticationMethodClientCertificate: - let result = handleClientCertificateAuth(challenge: challenge) - return result - default: - return (.performDefaultHandling, nil) + logger.debug("URLAuthenticationChallenge: \(authenticationMethod)") + + if challenge.previousFailureCount > 0 { + return (.cancelAuthenticationChallenge, nil) + } else { + switch authenticationMethod { + case NSURLAuthenticationMethodServerTrust: + let result = await handleServerTrust(challenge: challenge) + return result + case NSURLAuthenticationMethodDefault, NSURLAuthenticationMethodHTTPBasic: + let result = handleBasicAuth(challenge: challenge) + return result + case NSURLAuthenticationMethodClientCertificate: + let result = handleClientCertificateAuth(challenge: challenge) + return result + default: + return (.performDefaultHandling, nil) + } } } diff --git a/OpenHABCore/Sources/OpenHABCore/openapi/openapi.json b/OpenHABCore/Sources/OpenHABCore/openapi/openapi.json index 72feff20c..7c3ab44c7 100644 --- a/OpenHABCore/Sources/OpenHABCore/openapi/openapi.json +++ b/OpenHABCore/Sources/OpenHABCore/openapi/openapi.json @@ -5431,6 +5431,9 @@ } } } + }, + "401": { + "description": "User is not authenticated" } } } diff --git a/openHAB/SettingsView/SingleConnectionSettingsView.swift b/openHAB/SettingsView/SingleConnectionSettingsView.swift index 0199a1fa5..a00c4245f 100644 --- a/openHAB/SettingsView/SingleConnectionSettingsView.swift +++ b/openHAB/SettingsView/SingleConnectionSettingsView.swift @@ -136,7 +136,7 @@ struct SingleConnectionSettingsView: View { private func testConnection() async throws { try connectionConfig.url.testAsValidOpenHABURL() - let connection = OpenAPIService(connectionConfiguration: connectionConfig) + let connection = OpenAPIService(connectionConfiguration: connectionConfig, serviceConfiguration: .shortTerm) try await connection.getRootVersion() } diff --git a/openHABWatch/Views/Rows/SwitchRow.swift b/openHABWatch/Views/Rows/SwitchRow.swift index 9dad0e70d..aea12ee89 100644 --- a/openHABWatch/Views/Rows/SwitchRow.swift +++ b/openHABWatch/Views/Rows/SwitchRow.swift @@ -55,3 +55,14 @@ struct SwitchRow: View { SwitchRow(widget: widget) .environmentObject(AppSettings()) } + +#Preview { + let widget = UserData(preview: true).widgets[2] + let mockSettings = { + let obj = AppSettings() + obj.openHABRootUrl = PreviewConstants.remoteURLString + return obj + }() + SwitchRow(widget: widget) + .environmentObject(mockSettings) +} diff --git a/openHABWatch/Views/Utils/IconView.swift b/openHABWatch/Views/Utils/IconView.swift index d728b8619..05aeeb434 100644 --- a/openHABWatch/Views/Utils/IconView.swift +++ b/openHABWatch/Views/Utils/IconView.swift @@ -36,18 +36,12 @@ struct IconView: View { var body: some View { KFImage(iconURL) .placeholder { - ProgressView() + Image(systemSymbol: .circle) .frame(width: 20, height: 20) } .setProcessor(OpenHABImageProcessor()) .fade(duration: 0.25) .resizable() - .onSuccess { result in - print("Image loaded from cache: \(result.cacheType)") - } - .onFailure { error in - print("Error: \(error)") - } .aspectRatio(contentMode: .fit) .frame(width: 20, height: 20) .id(iconURL?.absoluteString ?? "") @@ -55,6 +49,9 @@ struct IconView: View { } #Preview { - let widget2 = UserData(preview: true).widgets[3] - IconView(widget: widget2, settings: AppSettings()) + let widget2 = UserData(preview: true).widgets[4] + IconView( + widget: widget2, + settings: AppSettings() + ) } From 1e6b500b9d8d740face820377adcb24f8650315f Mon Sep 17 00:00:00 2001 From: Dan Cunningham Date: Sat, 5 Apr 2025 14:35:48 -0700 Subject: [PATCH 131/476] Sitemaps were not loading by default Signed-off-by: Dan Cunningham --- openHAB/OpenHABSitemapViewController.swift | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/openHAB/OpenHABSitemapViewController.swift b/openHAB/OpenHABSitemapViewController.swift index 83f66d33a..e41a78116 100644 --- a/openHAB/OpenHABSitemapViewController.swift +++ b/openHAB/OpenHABSitemapViewController.swift @@ -240,10 +240,8 @@ class OpenHABSitemapViewController: OpenHABViewController { case .notConnected: 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 .connected: + case .connected, .allConnected, .someConnected: self.hidePopupMessages() - default: - break } } } @@ -253,22 +251,15 @@ class OpenHABSitemapViewController: OpenHABViewController { func startWatchingActiveServer() { let task = Task { - var isFirst = true // Track first value - for await activeConnection in NetworkTracker.shared.$activeConnection.values { - // we only want our watcher to notify us about changes, and not the inital value - if isFirst { - isFirst = false - continue - } - - await MainActor.run { - if let activeConnection { + if let activeConnection { + await MainActor.run { os_log("OpenHABSitemapViewController tracker URL %{PUBLIC}@", log: .viewCycle, type: .info, activeConnection.configuration.url) self.openHABRootUrl = activeConnection.configuration.url self.activeConnectionInfo = activeConnection self.selectSitemap() } + break } } } From 088a47852e38f221e0e4d427de6420131a98c9c3 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Sun, 6 Apr 2025 11:06:12 +0200 Subject: [PATCH 132/476] URL.documentsDirectory instead of NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true)[0] Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../Sources/OpenHABCore/Util/OpenAPIServiceDelegate.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIServiceDelegate.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIServiceDelegate.swift index bd8972194..13ea83213 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIServiceDelegate.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIServiceDelegate.swift @@ -165,10 +165,10 @@ final class OpenAPIServiceDelegate: NSObject, URLSessionDelegate, URLSessionTask private func getPersistencePath() -> URL { #if os(watchOS) - let documentsDirectory = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true)[0] - return URL(fileURLWithPath: documentsDirectory).appendingPathComponent("trustedCertificates") + return URL.documentsDirectory.appendingPathComponent("trustedCertificates") #else - FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.org.openhab.app")!.appendingPathComponent("trustedCertificates") + let appGroupURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.org.openhab.app")! + return appGroupURL.appendingPathComponent("trustedCertificates") #endif } From cb8d1aee8b9e2da142844efe8733bd9518a3cef4 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Sun, 6 Apr 2025 11:06:12 +0200 Subject: [PATCH 133/476] URL.documentsDirectory instead of NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true)[0] Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../Sources/OpenHABCore/Util/OpenAPIServiceDelegate.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIServiceDelegate.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIServiceDelegate.swift index bd8972194..13ea83213 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIServiceDelegate.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIServiceDelegate.swift @@ -165,10 +165,10 @@ final class OpenAPIServiceDelegate: NSObject, URLSessionDelegate, URLSessionTask private func getPersistencePath() -> URL { #if os(watchOS) - let documentsDirectory = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true)[0] - return URL(fileURLWithPath: documentsDirectory).appendingPathComponent("trustedCertificates") + return URL.documentsDirectory.appendingPathComponent("trustedCertificates") #else - FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.org.openhab.app")!.appendingPathComponent("trustedCertificates") + let appGroupURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.org.openhab.app")! + return appGroupURL.appendingPathComponent("trustedCertificates") #endif } From 0c6be679a489e5eb971bc7e71be8729d627e01d9 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Sun, 6 Apr 2025 12:08:55 +0200 Subject: [PATCH 134/476] Address warnings in NotificationsView: NSURLErrorDomain Code=-1002 unsupported URL error. - we now have transparency it is caused by icons Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- openHAB/NotificationsView.swift | 41 ++++++++++++++++++++++----------- 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/openHAB/NotificationsView.swift b/openHAB/NotificationsView.swift index 15566c4c3..cc4b2efd2 100644 --- a/openHAB/NotificationsView.swift +++ b/openHAB/NotificationsView.swift @@ -46,17 +46,24 @@ struct NotificationRow: View { } private var iconUrl: URL? { - if let appData { - return Endpoint.icon( - rootUrl: appData.openHABRootUrl, - version: appData.openHABVersion, - icon: notification.icon, - state: "", - iconType: .png, - iconColor: "" - ).url + guard let appData else { return nil } + + let endpoint = Endpoint.icon( + rootUrl: appData.openHABRootUrl, + version: appData.openHABVersion, + icon: notification.icon, + state: "", + iconType: .png, + iconColor: "" + ) + + guard let url = endpoint.url, url.scheme != nil else { + Logger(subsystem: "org.openhab.app", category: "NotificationRow") + .warning("Invalid icon URL for icon: \(notification.icon ?? "nil", privacy: .public)") + return nil } - return nil + + return url } private func dateString(from date: Date) -> String { @@ -92,13 +99,21 @@ struct NotificationsView: View { private func loadNotifications() async -> [OpenHABNotification] { do { - guard let config = Preferences.getLowestPriorityOpenHABConnection() else { return [] } + guard let config = Preferences.getLowestPriorityOpenHABConnection() else { + logger.warning("No openHAB configuration found.") + return [] + } + + guard let url = URL(string: config.url), url.scheme != nil else { + logger.error("Invalid URL: \(config.url, privacy: .public)") + return [] + } let client = HTTPClient(configuration: config) return try await client.notification(urlString: config.url) } catch { - logger.error("\(error.localizedDescription)") + logger.error("Failed to load notifications: \(error.localizedDescription, privacy: .public)") + return [] } - return [] } } From e343988db81c1523428cb82fb91fd917d6c7b668 Mon Sep 17 00:00:00 2001 From: Dan Cunningham Date: Sun, 6 Apr 2025 12:24:31 -0700 Subject: [PATCH 135/476] Fixes crash, removes redundant call to RootView (#867) Signed-off-by: Dan Cunningham --- openHAB/AppDelegate.swift | 30 +++++++++++++------------ openHAB/OpenHABRootViewController.swift | 16 ------------- 2 files changed, 16 insertions(+), 30 deletions(-) diff --git a/openHAB/AppDelegate.swift b/openHAB/AppDelegate.swift index 7adc773ba..60bc3a939 100644 --- a/openHAB/AppDelegate.swift +++ b/openHAB/AppDelegate.swift @@ -223,23 +223,25 @@ extension AppDelegate: UNUserNotificationCenterDelegate { } // this is called when clicking a notification while in the background - nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async { - var userInfo = response.notification.request.content.userInfo + func userNotificationCenter(_ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void) { let actionIdentifier = response.actionIdentifier - logger.info("Notification clicked: action \(actionIdentifier) userInfo \(userInfo)") - - if actionIdentifier != UNNotificationDismissActionIdentifier { - if actionIdentifier != UNNotificationDefaultActionIdentifier { - userInfo["actionIdentifier"] = actionIdentifier + var userInfo = response.notification.request.content.userInfo + print("Notification clicked: \(actionIdentifier)") + for (key, value) in userInfo { + print("userInfo: \(key) = \(value)") + } + Task { @MainActor in + if actionIdentifier != UNNotificationDismissActionIdentifier { + if actionIdentifier != UNNotificationDefaultActionIdentifier { + userInfo["actionIdentifier"] = actionIdentifier + } + let action = userInfo["actionIdentifier"] as? String ?? userInfo["on-click"] as? String + notifyNotificationListeners(action: action) } - NotificationCenter.default.post( - name: .openHABDidReceiveNotification, - object: nil, - userInfo: userInfo - ) - let action = userInfo["actionIdentifier"] as? String ?? userInfo["on-click"] as? String - await notifyNotificationListeners(action: action) } + completionHandler() } private func displayNotification(message: String, action: String?) async { diff --git a/openHAB/OpenHABRootViewController.swift b/openHAB/OpenHABRootViewController.swift index e9deb4db9..eef085478 100644 --- a/openHAB/OpenHABRootViewController.swift +++ b/openHAB/OpenHABRootViewController.swift @@ -99,22 +99,6 @@ class OpenHABRootViewController: UIViewController { isDemoMode = Preferences.demomode switchToSavedView() setupTracker() - // check if we were launched with a notification - // ✅ Observe notifications (in-app or from AppDelegate) - NotificationCenter.default.addObserver( - forName: .openHABDidReceiveNotification, - object: nil, - queue: .main - ) { [weak self] notification in - guard let userInfo = notification.userInfo else { return } - - // ✅ Extract action identifier (from button press or notification click) *before* entering the Task - let action = userInfo["actionIdentifier"] as? String ?? userInfo["on-click"] as? String - - Task { @MainActor in - self?.handleNotification(action: action) - } - } } override func viewWillAppear(_ animated: Bool) { From cfd87ace8247f78331dde2e66a3b42374ba2c108 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Sun, 6 Apr 2025 21:20:10 +0200 Subject: [PATCH 136/476] Using userNotificationCenter(_:didReceive:) async version, removing print statements Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- openHAB/AppDelegate.swift | 26 ++++++++++---------------- 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/openHAB/AppDelegate.swift b/openHAB/AppDelegate.swift index 60bc3a939..29c0bfadf 100644 --- a/openHAB/AppDelegate.swift +++ b/openHAB/AppDelegate.swift @@ -223,25 +223,19 @@ extension AppDelegate: UNUserNotificationCenterDelegate { } // this is called when clicking a notification while in the background - func userNotificationCenter(_ center: UNUserNotificationCenter, - didReceive response: UNNotificationResponse, - withCompletionHandler completionHandler: @escaping () -> Void) { - let actionIdentifier = response.actionIdentifier + @MainActor + func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async { var userInfo = response.notification.request.content.userInfo - print("Notification clicked: \(actionIdentifier)") - for (key, value) in userInfo { - print("userInfo: \(key) = \(value)") - } - Task { @MainActor in - if actionIdentifier != UNNotificationDismissActionIdentifier { - if actionIdentifier != UNNotificationDefaultActionIdentifier { - userInfo["actionIdentifier"] = actionIdentifier - } - let action = userInfo["actionIdentifier"] as? String ?? userInfo["on-click"] as? String - notifyNotificationListeners(action: action) + let actionIdentifier = response.actionIdentifier + logger.info("Notification clicked: action \(actionIdentifier) userInfo \(userInfo)") + + if actionIdentifier != UNNotificationDismissActionIdentifier { + if actionIdentifier != UNNotificationDefaultActionIdentifier { + userInfo["actionIdentifier"] = actionIdentifier } + let action = userInfo["actionIdentifier"] as? String ?? userInfo["on-click"] as? String + notifyNotificationListeners(action: action) } - completionHandler() } private func displayNotification(message: String, action: String?) async { From 15bde63f844013907e27e589963127d2a711c8e9 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Sun, 6 Apr 2025 23:36:13 +0200 Subject: [PATCH 137/476] Reworked SetSwitchStateIntentHandler Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../OpenHABCore/Util/OpenHABItemCache.swift | 12 ++- openHAB.xcodeproj/project.pbxproj | 18 ++++- .../SetSwitchStateIntentHandler.swift | 73 +++++++++-------- .../SetSwitchStateIntentHandlerTests.swift | 79 +++++++++++++++++++ 4 files changed, 145 insertions(+), 37 deletions(-) create mode 100644 openHABIntentsTests/SetSwitchStateIntentHandlerTests.swift diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift index 2b7fc75c2..55a3455c9 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift @@ -13,6 +13,12 @@ import Combine import Foundation import os.log +public protocol ItemCacheProtocol { + func getItem(name: String) async -> OpenHABItem? + func sendCommand(_ item: OpenHABItem, commandToSend: String) async + func getItemNames(searchTerm: String?, types: [OpenHABItem.ItemType]?) async -> [String] +} + public actor OpenHABItemCache { public static let instance = OpenHABItemCache() @@ -34,7 +40,7 @@ public actor OpenHABItemCache { NetworkTracker.shared.startTracking(connectionConfigurations: connections) } - public func getItemNames(searchTerm: String?, types: [OpenHABItem.ItemType]?) -> [String] { + public func getItemNames(searchTerm: String?, types: [OpenHABItem.ItemType]?) async -> [String] { guard let items else { return [] } @@ -84,7 +90,7 @@ public actor OpenHABItemCache { do { items = try await NetworkTracker.shared.getItems() os_log("Loaded items to cache: %{PUBLIC}d", log: .default, type: .info, self.items?.count ?? 0) - return getItemNames(searchTerm: searchTerm, types: types) + return await getItemNames(searchTerm: searchTerm, types: types) } catch { os_log("OpenHABItemCache %{PUBLIC}@ ", log: .default, type: .error, error.localizedDescription) return [] @@ -102,3 +108,5 @@ public actor OpenHABItemCache { } } } + +extension OpenHABItemCache: ItemCacheProtocol {} diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index 9d6f55f17..c1725214b 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -56,7 +56,6 @@ 93AEE42927D9D790008EB207 /* SetStringValueIntentHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D38D959256897770039DA6E /* SetStringValueIntentHandler.swift */; }; 93AEE42A27D9D792008EB207 /* SetColorValueIntentHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D38D9612568978E0039DA6E /* SetColorValueIntentHandler.swift */; }; 93AEE42B27D9D796008EB207 /* SetContactStateValueIntentHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D38D969256897AD0039DA6E /* SetContactStateValueIntentHandler.swift */; }; - 93B7B33128018301009EB296 /* Intents.intentdefinition in Sources */ = {isa = PBXBuildFile; fileRef = 935D340A257B7DC00020A404 /* Intents.intentdefinition */; }; 93F8063527AE6C620035A6B0 /* FirebaseCrashlytics in Frameworks */ = {isa = PBXBuildFile; productRef = 93F8063427AE6C620035A6B0 /* FirebaseCrashlytics */; }; 93F8064727AE7A050035A6B0 /* SwiftMessages in Frameworks */ = {isa = PBXBuildFile; productRef = 93F8064627AE7A050035A6B0 /* SwiftMessages */; }; 93F8064A27AE7A2E0035A6B0 /* FlexColorPicker in Frameworks */ = {isa = PBXBuildFile; productRef = 93F8064927AE7A2E0035A6B0 /* FlexColorPicker */; }; @@ -120,6 +119,7 @@ DAA42BAC21DC984A00244B2A /* WebUITableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAA42BAB21DC984A00244B2A /* WebUITableViewCell.swift */; }; DAAC30872CBBF0420041927F /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA0775262346705F0086C685 /* ContentView.swift */; }; DABB5E332D98972F009A4B8A /* SDWebImageSVGCoder in Frameworks */ = {isa = PBXBuildFile; productRef = DABB5E322D98972F009A4B8A /* SDWebImageSVGCoder */; }; + DAC131112DA3213100075AE2 /* SetSwitchStateIntentHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC131102DA3213100075AE2 /* SetSwitchStateIntentHandlerTests.swift */; }; DAC65FC7236EDF3900F4501E /* SpinnerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC65FC6236EDF3900F4501E /* SpinnerViewController.swift */; }; DAC6608D236F771600F4501E /* PreferencesSwiftUIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC6608C236F771600F4501E /* PreferencesSwiftUIView.swift */; }; DAC9395522B00E7600C5F423 /* XCTestCaseExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC9395422B00E7600C5F423 /* XCTestCaseExtension.swift */; }; @@ -428,6 +428,7 @@ DAA42BA721DC97DF00244B2A /* NotificationTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NotificationTableViewCell.swift; sourceTree = ""; }; DAA42BA921DC983B00244B2A /* VideoUITableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VideoUITableViewCell.swift; sourceTree = ""; }; DAA42BAB21DC984A00244B2A /* WebUITableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WebUITableViewCell.swift; sourceTree = ""; }; + DAC131102DA3213100075AE2 /* SetSwitchStateIntentHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetSwitchStateIntentHandlerTests.swift; sourceTree = ""; }; DAC65FC6236EDF3900F4501E /* SpinnerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpinnerViewController.swift; sourceTree = ""; }; DAC6608B236F6F4200F4501E /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; DAC6608C236F771600F4501E /* PreferencesSwiftUIView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesSwiftUIView.swift; sourceTree = ""; }; @@ -811,6 +812,14 @@ path = Views; sourceTree = ""; }; + DAC1310F2DA3208E00075AE2 /* openHABIntentsTests */ = { + isa = PBXGroup; + children = ( + DAC131102DA3213100075AE2 /* SetSwitchStateIntentHandlerTests.swift */, + ); + path = openHABIntentsTests; + sourceTree = ""; + }; DACE66522C63B2070069E514 /* openapitest */ = { isa = PBXGroup; children = ( @@ -975,6 +984,7 @@ 933D7F0522E7015000621A03 /* openHABUITests */, DA0775162346705D0086C685 /* openHABWatch */, 4D6470D42561F935007B03FC /* openHABIntents */, + DAC1310F2DA3208E00075AE2 /* openHABIntentsTests */, DAD0856F2AE4782D001D36BE /* openHABWatchSwiftUI Watch AppTests */, DAD085792AE4782F001D36BE /* openHABWatchSwiftUI Watch AppUITests */, 6571444F2C1E438700C8A1F3 /* NotificationService */, @@ -1563,7 +1573,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 93B7B33128018301009EB296 /* Intents.intentdefinition in Sources */, DA242C622C83588600AFB10D /* SettingsView.swift in Sources */, DA7E1E4B2233986E002AEFD8 /* PlayerView.swift in Sources */, 65570A7D2476D16A00D524EA /* OpenHABWebViewController.swift in Sources */, @@ -1585,6 +1594,7 @@ DFB2623B18830A3600D3244D /* AppDelegate.swift in Sources */, DA6B2EF72C8B92E800DF77CF /* SelectionView.swift in Sources */, DA4800212D839D3A009CF127 /* AnimatedSecureTextField.swift in Sources */, + DAC131112DA3213100075AE2 /* SetSwitchStateIntentHandlerTests.swift in Sources */, DF4B84041885A53700F34902 /* OpenHABDataObject.swift in Sources */, DAC65FC7236EDF3900F4501E /* SpinnerViewController.swift in Sources */, DA9F81872C85020F00B47B72 /* RTFTextView.swift in Sources */, @@ -1752,6 +1762,7 @@ SKIP_INSTALL = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_STRICT_CONCURRENCY = complete; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; @@ -1795,6 +1806,7 @@ "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "match AppStore org.openhab.app.openHABIntents"; SKIP_INSTALL = YES; SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_STRICT_CONCURRENCY = complete; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; @@ -1842,6 +1854,7 @@ SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_STRICT_CONCURRENCY = complete; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; @@ -1889,6 +1902,7 @@ "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "match AppStore org.openhab.app.NotificationService"; SKIP_INSTALL = YES; SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_STRICT_CONCURRENCY = complete; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; diff --git a/openHABIntents/SetSwitchStateIntentHandler.swift b/openHABIntents/SetSwitchStateIntentHandler.swift index d89d51e1b..72d6f6367 100644 --- a/openHABIntents/SetSwitchStateIntentHandler.swift +++ b/openHABIntents/SetSwitchStateIntentHandler.swift @@ -12,70 +12,77 @@ import Foundation import Intents import OpenHABCore -import os.log +import os -class SetSwitchStateIntentHandler: NSObject, OpenHABSetSwitchStateIntentHandling { - static let ON = NSLocalizedString("on", comment: "").capitalized // User language - static let OFF = NSLocalizedString("off", comment: "").capitalized // User language - static let ACTION_NAMES = [ON, OFF] - static let ACTION_MAP = [ON: "ON", OFF: "OFF"] // these are the sent items - do not translate this text +final class SetSwitchStateIntentHandler: NSObject, OpenHABSetSwitchStateIntentHandling { + private static let onLabel = NSLocalizedString("on", comment: "").capitalized + private static let offLabel = NSLocalizedString("off", comment: "").capitalized - func provideActionOptionsCollection(for intent: OpenHABSetSwitchStateIntent, with completion: @escaping (INObjectCollection?, Error?) -> Void) { - let actions = INObjectCollection(items: SetSwitchStateIntentHandler.ACTION_NAMES as [NSString]) + private static let localizedActions = [onLabel, offLabel] + private static let actionMap: [String: String] = [ + onLabel: "ON", + offLabel: "OFF" + ] + + private let logger = Logger(subsystem: "org.openhab.app", category: "SetSwitchStateIntent") + + private let itemCache: ItemCacheProtocol + + init(itemCache: ItemCacheProtocol = OpenHABItemCache.instance) { + self.itemCache = itemCache + } - // Call the completion handler, passing the collection. - completion(actions, nil) + func provideActionOptionsCollection(for intent: OpenHABSetSwitchStateIntent, with completion: @escaping (INObjectCollection?, Error?) -> Void) { + let collection = INObjectCollection(items: Self.localizedActions as [NSString]) + completion(collection, nil) } func provideItemOptionsCollection(for intent: OpenHABSetSwitchStateIntent, searchTerm: String?, with completion: @escaping (INObjectCollection?, Error?) -> Void) { Task { - let items = await OpenHABItemCache.instance.getItemNames(searchTerm: searchTerm, types: [OpenHABItem.ItemType.switchItem]).map(NSString.init) - let retItems = INObjectCollection(items: items) - // Call the completion handler, passing the collection. - completion(retItems, nil) + let itemNames = await self.itemCache.getItemNames( + searchTerm: searchTerm, + types: [.switchItem] + ).map(NSString.init) + + completion(INObjectCollection(items: itemNames), nil) } } func provideItemOptionsCollection(for intent: OpenHABSetSwitchStateIntent, with completion: @escaping (INObjectCollection?, Error?) -> Void) { - Task { - let items = await OpenHABItemCache.instance.getItemNames(searchTerm: nil, types: [OpenHABItem.ItemType.switchItem]).map(NSString.init) - let retItems = INObjectCollection(items: items) - // Call the completion handler, passing the collection. - completion(retItems, nil) - } + provideItemOptionsCollection(for: intent, searchTerm: nil, with: completion) } func confirm(intent: OpenHABSetSwitchStateIntent, completion: @escaping (OpenHABSetSwitchStateIntentResponse) -> Void) { - completion(OpenHABSetSwitchStateIntentResponse(code: .ready, userActivity: nil)) + completion(.init(code: .ready, userActivity: nil)) } func handle(intent: OpenHABSetSwitchStateIntent, completion: @escaping (OpenHABSetSwitchStateIntentResponse) -> Void) { - os_log("SetSwitchStateIntent for %{PUBLIC}@", log: .default, type: .info, intent.item ?? "") + let itemName = intent.item ?? "" + logger.info("SetSwitchStateIntent for item: \(intent.item ?? "", privacy: .public)") - guard let itemName = intent.item else { - completion(OpenHABSetSwitchStateIntentResponse.failureInvalidItem(NSLocalizedString("empty", comment: "empty item name"))) + guard !itemName.isEmpty else { + completion(.failureInvalidItem(NSLocalizedString("empty", comment: "empty item name"))) return } guard let action = intent.action else { - completion(OpenHABSetSwitchStateIntentResponse.failureInvalidAction(NSLocalizedString("empty", comment: "empty action"), item: itemName)) + completion(.failureInvalidAction(NSLocalizedString("empty", comment: "empty action"), item: itemName)) return } - // Map user language to real action - guard let realAction = SetSwitchStateIntentHandler.ACTION_MAP[action] else { - completion(OpenHABSetSwitchStateIntentResponse.failureInvalidAction(action, item: itemName)) + guard let command = Self.actionMap[action] else { + completion(.failureInvalidAction(action, item: itemName)) return } + Task { - let item = await OpenHABItemCache.instance.getItem(name: itemName) - guard let item else { - completion(OpenHABSetSwitchStateIntentResponse.failureInvalidItem(itemName)) + guard let item = await itemCache.getItem(name: itemName) else { + completion(.failureInvalidItem(itemName)) return } - await OpenHABItemCache.instance.sendCommand(item, commandToSend: realAction) - completion(OpenHABSetSwitchStateIntentResponse.success(action: action, item: itemName)) + await itemCache.sendCommand(item, commandToSend: command) + completion(.success(action: action, item: itemName)) } } } diff --git a/openHABIntentsTests/SetSwitchStateIntentHandlerTests.swift b/openHABIntentsTests/SetSwitchStateIntentHandlerTests.swift new file mode 100644 index 000000000..cc1ccd488 --- /dev/null +++ b/openHABIntentsTests/SetSwitchStateIntentHandlerTests.swift @@ -0,0 +1,79 @@ +// Copyright (c) 2010-2025 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 + +//// Copyright (c) 2010-2025 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 XCTest +// @testable import openHAB // Replace with actual module name +// import Intents +// import OpenHABCore +// +// final class SetSwitchStateIntentHandlerTests: XCTestCase { +// +// final class MockItemCache: ItemCacheProtocol { +// var lastCommand: String? +// +// func getItem(name: String) async -> OpenHABItem? { +// .mockSwitch(name: name) +// } +// +// func sendCommand(_ item: OpenHABItem, commandToSend: String) async { +// lastCommand = commandToSend +// } +// +// func getItemNames(searchTerm: String?, types: [OpenHABItem.ItemType]?) -> [String] { +// return ["MockSwitch"] +// } +// } +// +// func testHandleIntentMissingItem() { +// let intent = OpenHABSetSwitchStateIntent() +// intent.item = nil +// intent.action = "On" +// +// let handler = SetSwitchStateIntentHandler(itemCache: MockItemCache()) +// let expectation = expectation(description: "Handle failure") +// +// handler.handle(intent: intent) { response in +// XCTAssertEqual(response.code, .failureInvalidItem) +// expectation.fulfill() +// } +// +// wait(for: [expectation], timeout: 1) +// } +// } +// +// extension OpenHABItem { +// static func mockSwitch(name: String = "MockSwitch", state: String = "OFF") -> OpenHABItem { +// return OpenHABItem( +// name: name, +// type: "Switch", +// state: state, +// link: "http://mock/api/items/\(name)", +// label: name, +// groupType: nil, +// stateDescription: nil, +// commandDescription: nil, +// members: [], +// category: nil, +// options: nil +// ) +// } +// } From c9b694ed47c04a8f9f7dc9da1fa8b54bba12eb73 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Mon, 7 Apr 2025 14:31:36 +0200 Subject: [PATCH 138/476] Reworked OpenHABItemCache Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../OpenHABCore/Util/OpenHABItemCache.swift | 37 ++++++++++++------- openHAB.xcodeproj/project.pbxproj | 2 + .../SetSwitchStateIntentHandler.swift | 6 +++ 3 files changed, 31 insertions(+), 14 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift index 55a3455c9..216362759 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift @@ -41,20 +41,18 @@ public actor OpenHABItemCache { } public func getItemNames(searchTerm: String?, types: [OpenHABItem.ItemType]?) async -> [String] { + logger.info("getItemNames") guard let items else { - return [] + return await reload(searchTerm: searchTerm, types: types) } - return items - .filter { - (searchTerm == nil || $0.name.contains(searchTerm.orEmpty)) && - (types == nil || ($0.type != nil && types!.contains($0.type!))) - } + return items.filtered(by: searchTerm, for: types) .sorted(by: \.name) .map(\.name) } public func getItem(name: String) async -> OpenHABItem? { + logger.info("getItem") let now = Date().timeIntervalSince1970 if items == nil || (now - lastLoad) > 10 { @@ -84,29 +82,40 @@ public actor OpenHABItemCache { } public func reload(searchTerm: String?, types: [OpenHABItem.ItemType]?) async -> [String] { - os_log("OpenHABItemCache Loading items ") + logger.info("OpenHABItemCache Loading items ") lastLoad = Date().timeIntervalSince1970 do { - items = try await NetworkTracker.shared.getItems() - os_log("Loaded items to cache: %{PUBLIC}d", log: .default, type: .info, self.items?.count ?? 0) - return await getItemNames(searchTerm: searchTerm, types: types) + items = try await NetworkTracker.shared.getItems().filter{ $0.type != .group} + logger.info("Loaded \(self.items?.count ?? 0) items to cache") + return items?.filtered(by: searchTerm, for: types).sorted(by: \.name).map(\.name) ?? [] } catch { - os_log("OpenHABItemCache %{PUBLIC}@ ", log: .default, type: .error, error.localizedDescription) + logger.error("Could not reload \(error.localizedDescription)") return [] } } public func reload(name: String) async -> OpenHABItem? { do { - items = try await NetworkTracker.shared.getItems() - os_log("Loaded items to cache: %{PUBLIC}d", log: .default, type: .info, self.items?.count ?? 0) + items = try await NetworkTracker.shared.getItems().filter{ $0.type != .group} return items?.first { $0.name == name } } catch { - os_log("OpenHABItemCache %{PUBLIC}@ ", log: .default, type: .error, error.localizedDescription) + logger.error("Could not reload \(error.localizedDescription)") return nil } } + } extension OpenHABItemCache: ItemCacheProtocol {} + +private extension Array where Element == OpenHABItem { + func filtered(by searchTerm: String?, for types: [OpenHABItem.ItemType]?) -> [OpenHABItem] { + self.filter { + (searchTerm == nil || $0.name.contains(searchTerm.orEmpty)) && + (types == nil || ($0.type != nil && types!.contains($0.type!))) + } + } +} + + diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index c1725214b..6d2993d92 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -120,6 +120,7 @@ DAAC30872CBBF0420041927F /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA0775262346705F0086C685 /* ContentView.swift */; }; DABB5E332D98972F009A4B8A /* SDWebImageSVGCoder in Frameworks */ = {isa = PBXBuildFile; productRef = DABB5E322D98972F009A4B8A /* SDWebImageSVGCoder */; }; DAC131112DA3213100075AE2 /* SetSwitchStateIntentHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC131102DA3213100075AE2 /* SetSwitchStateIntentHandlerTests.swift */; }; + DAC131122DA32F5D00075AE2 /* Intents.intentdefinition in Resources */ = {isa = PBXBuildFile; fileRef = 935D340A257B7DC00020A404 /* Intents.intentdefinition */; }; DAC65FC7236EDF3900F4501E /* SpinnerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC65FC6236EDF3900F4501E /* SpinnerViewController.swift */; }; DAC6608D236F771600F4501E /* PreferencesSwiftUIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC6608C236F771600F4501E /* PreferencesSwiftUIView.swift */; }; DAC9395522B00E7600C5F423 /* XCTestCaseExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC9395422B00E7600C5F423 /* XCTestCaseExtension.swift */; }; @@ -1424,6 +1425,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + DAC131122DA32F5D00075AE2 /* Intents.intentdefinition in Resources */, 938BF9D024EFCCC000E6B52F /* Localizable.strings in Resources */, DFB2624618830A3600D3244D /* Images.xcassets in Resources */, 6557AF8F2C0241C10094D0C8 /* PrivacyInfo.xcprivacy in Resources */, diff --git a/openHABIntents/SetSwitchStateIntentHandler.swift b/openHABIntents/SetSwitchStateIntentHandler.swift index 72d6f6367..841f7aca3 100644 --- a/openHABIntents/SetSwitchStateIntentHandler.swift +++ b/openHABIntents/SetSwitchStateIntentHandler.swift @@ -33,11 +33,15 @@ final class SetSwitchStateIntentHandler: NSObject, OpenHABSetSwitchStateIntentHa } func provideActionOptionsCollection(for intent: OpenHABSetSwitchStateIntent, with completion: @escaping (INObjectCollection?, Error?) -> Void) { + logger.info("SetSwitchStateIntentHandler provideActionOptionsCollection") + let collection = INObjectCollection(items: Self.localizedActions as [NSString]) completion(collection, nil) } func provideItemOptionsCollection(for intent: OpenHABSetSwitchStateIntent, searchTerm: String?, with completion: @escaping (INObjectCollection?, Error?) -> Void) { + logger.info("SetSwitchStateIntentHandler provideItemOptionsCollection with searchTerm: \(searchTerm ?? "", privacy: .public)") + Task { let itemNames = await self.itemCache.getItemNames( searchTerm: searchTerm, @@ -49,6 +53,8 @@ final class SetSwitchStateIntentHandler: NSObject, OpenHABSetSwitchStateIntentHa } func provideItemOptionsCollection(for intent: OpenHABSetSwitchStateIntent, with completion: @escaping (INObjectCollection?, Error?) -> Void) { + logger.info("SetSwitchStateIntentHandler provideItemOptionsCollection") + provideItemOptionsCollection(for: intent, searchTerm: nil, with: completion) } From 76bf4dd7118a9e0b4abecfa11eafd56c06e05e28 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Mon, 7 Apr 2025 16:37:45 +0200 Subject: [PATCH 139/476] Small patch on OpenHABItemCache Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../OpenHABCore/Util/OpenHABItemCache.swift | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift index 216362759..1aebc4d24 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift @@ -86,8 +86,8 @@ public actor OpenHABItemCache { lastLoad = Date().timeIntervalSince1970 do { - items = try await NetworkTracker.shared.getItems().filter{ $0.type != .group} - logger.info("Loaded \(self.items?.count ?? 0) items to cache") + items = try await NetworkTracker.shared.getItems().filter { $0.type != .group } + logger.info("Loaded \(items?.count ?? 0) items to cache") return items?.filtered(by: searchTerm, for: types).sorted(by: \.name).map(\.name) ?? [] } catch { logger.error("Could not reload \(error.localizedDescription)") @@ -97,25 +97,22 @@ public actor OpenHABItemCache { public func reload(name: String) async -> OpenHABItem? { do { - items = try await NetworkTracker.shared.getItems().filter{ $0.type != .group} + items = try await NetworkTracker.shared.getItems().filter { $0.type != .group } return items?.first { $0.name == name } } catch { logger.error("Could not reload \(error.localizedDescription)") return nil } } - } extension OpenHABItemCache: ItemCacheProtocol {} -private extension Array where Element == OpenHABItem { +private extension [OpenHABItem] { func filtered(by searchTerm: String?, for types: [OpenHABItem.ItemType]?) -> [OpenHABItem] { - self.filter { + filter { (searchTerm == nil || $0.name.contains(searchTerm.orEmpty)) && - (types == nil || ($0.type != nil && types!.contains($0.type!))) + (types == nil || ($0.type != nil && types!.contains($0.type!))) } } } - - From 98aeeed603b9810202c7119c43936940acf8e0de Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Mon, 7 Apr 2025 17:06:33 +0200 Subject: [PATCH 140/476] Another small patch on OpenHABItemCache Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift index 1aebc4d24..9f0a96f10 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift @@ -87,7 +87,7 @@ public actor OpenHABItemCache { do { items = try await NetworkTracker.shared.getItems().filter { $0.type != .group } - logger.info("Loaded \(items?.count ?? 0) items to cache") + logger.info("Loaded \(self.items?.count ?? 0) items to cache") return items?.filtered(by: searchTerm, for: types).sorted(by: \.name).map(\.name) ?? [] } catch { logger.error("Could not reload \(error.localizedDescription)") From 462f6705e28114536b6113527769fe4a1277842b Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Mon, 7 Apr 2025 21:48:34 +0200 Subject: [PATCH 141/476] swiftformat disable in OpenHABItemCache for redundantSelf Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift index 9f0a96f10..67c95852e 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift @@ -87,6 +87,7 @@ public actor OpenHABItemCache { do { items = try await NetworkTracker.shared.getItems().filter { $0.type != .group } + // swiftformat:disable next redundantSelf logger.info("Loaded \(self.items?.count ?? 0) items to cache") return items?.filtered(by: searchTerm, for: types).sorted(by: \.name).map(\.name) ?? [] } catch { From 45f271a8a067df316f8c217fcfaf3491bf7dce8d Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Mon, 7 Apr 2025 22:28:14 +0200 Subject: [PATCH 142/476] Migrated SetSwitchStateIntentHandler to async Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../SetSwitchStateIntentHandler.swift | 48 ++++++++----------- 1 file changed, 21 insertions(+), 27 deletions(-) diff --git a/openHABIntents/SetSwitchStateIntentHandler.swift b/openHABIntents/SetSwitchStateIntentHandler.swift index 841f7aca3..7c9efc918 100644 --- a/openHABIntents/SetSwitchStateIntentHandler.swift +++ b/openHABIntents/SetSwitchStateIntentHandler.swift @@ -32,63 +32,57 @@ final class SetSwitchStateIntentHandler: NSObject, OpenHABSetSwitchStateIntentHa self.itemCache = itemCache } - func provideActionOptionsCollection(for intent: OpenHABSetSwitchStateIntent, with completion: @escaping (INObjectCollection?, Error?) -> Void) { + func provideActionOptionsCollection(for intent: OpenHABSetSwitchStateIntent) async throws -> INObjectCollection { logger.info("SetSwitchStateIntentHandler provideActionOptionsCollection") let collection = INObjectCollection(items: Self.localizedActions as [NSString]) - completion(collection, nil) + return collection } - func provideItemOptionsCollection(for intent: OpenHABSetSwitchStateIntent, searchTerm: String?, with completion: @escaping (INObjectCollection?, Error?) -> Void) { + func provideItemOptionsCollection(for intent: OpenHABSetSwitchStateIntent, searchTerm: String?) async throws -> INObjectCollection { logger.info("SetSwitchStateIntentHandler provideItemOptionsCollection with searchTerm: \(searchTerm ?? "", privacy: .public)") - Task { - let itemNames = await self.itemCache.getItemNames( + let itemNames = await itemCache + .getItemNames( searchTerm: searchTerm, types: [.switchItem] - ).map(NSString.init) + ) + .map(NSString.init) - completion(INObjectCollection(items: itemNames), nil) - } + return INObjectCollection(items: itemNames) } - func provideItemOptionsCollection(for intent: OpenHABSetSwitchStateIntent, with completion: @escaping (INObjectCollection?, Error?) -> Void) { + func provideItemOptionsCollection(for intent: OpenHABSetSwitchStateIntent) async throws -> INObjectCollection { logger.info("SetSwitchStateIntentHandler provideItemOptionsCollection") - provideItemOptionsCollection(for: intent, searchTerm: nil, with: completion) + return try await provideItemOptionsCollection(for: intent, searchTerm: nil) } - func confirm(intent: OpenHABSetSwitchStateIntent, completion: @escaping (OpenHABSetSwitchStateIntentResponse) -> Void) { - completion(.init(code: .ready, userActivity: nil)) + func confirm(intent: OpenHABSetSwitchStateIntent) async -> OpenHABSetSwitchStateIntentResponse { + .init(code: .ready, userActivity: nil) } - func handle(intent: OpenHABSetSwitchStateIntent, completion: @escaping (OpenHABSetSwitchStateIntentResponse) -> Void) { + func handle(intent: OpenHABSetSwitchStateIntent) async -> OpenHABSetSwitchStateIntentResponse { let itemName = intent.item ?? "" logger.info("SetSwitchStateIntent for item: \(intent.item ?? "", privacy: .public)") guard !itemName.isEmpty else { - completion(.failureInvalidItem(NSLocalizedString("empty", comment: "empty item name"))) - return + return .failureInvalidItem(NSLocalizedString("empty", comment: "empty item name")) } guard let action = intent.action else { - completion(.failureInvalidAction(NSLocalizedString("empty", comment: "empty action"), item: itemName)) - return + return .failureInvalidAction(NSLocalizedString("empty", comment: "empty action"), item: itemName) } guard let command = Self.actionMap[action] else { - completion(.failureInvalidAction(action, item: itemName)) - return + return .failureInvalidAction(action, item: itemName) } - Task { - guard let item = await itemCache.getItem(name: itemName) else { - completion(.failureInvalidItem(itemName)) - return - } - - await itemCache.sendCommand(item, commandToSend: command) - completion(.success(action: action, item: itemName)) + guard let item = await itemCache.getItem(name: itemName) else { + return .failureInvalidItem(itemName) } + + await itemCache.sendCommand(item, commandToSend: command) + return .success(action: action, item: itemName) } } From 4629e2faf2d93557d753efb9f9dad8c8850bdc85 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Mon, 7 Apr 2025 23:03:01 +0200 Subject: [PATCH 143/476] Migrated all other IntentHandler to async Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../GetItemStateIntentHandler.swift | 51 +++++++------ .../SetColorValueIntentHandler.swift | 72 +++++++++---------- .../SetContactStateValueIntentHandler.swift | 68 ++++++++---------- .../SetDimmerRollerValueIntentHandler.swift | 63 ++++++++-------- .../SetNumberValueIntentHandler.swift | 58 +++++++-------- .../SetStringValueIntentHandler.swift | 57 +++++++-------- 6 files changed, 169 insertions(+), 200 deletions(-) diff --git a/openHABIntents/GetItemStateIntentHandler.swift b/openHABIntents/GetItemStateIntentHandler.swift index 51674ae41..176978d90 100644 --- a/openHABIntents/GetItemStateIntentHandler.swift +++ b/openHABIntents/GetItemStateIntentHandler.swift @@ -15,43 +15,40 @@ import OpenHABCore import os.log class GetItemStateIntentHandler: NSObject, OpenHABGetItemStateIntentHandling { - func provideItemOptionsCollection(for intent: OpenHABGetItemStateIntent, searchTerm: String?, with completion: @escaping (INObjectCollection?, Error?) -> Void) { - Task { - let items = await OpenHABItemCache.instance.getItemNames(searchTerm: searchTerm, types: nil).map(NSString.init) - let retItems = INObjectCollection(items: items) - // Call the completion handler, passing the collection. - completion(retItems, nil) - } + private let logger = Logger(subsystem: "org.openhab.app", category: "GetItemStateIntent") + + func provideItemOptionsCollection(for intent: OpenHABGetItemStateIntent, searchTerm: String?) async throws -> INObjectCollection { + let items = await OpenHABItemCache.instance.getItemNames(searchTerm: searchTerm, types: nil).map(NSString.init) + return INObjectCollection(items: items) } - func provideItemOptionsCollection(for intent: OpenHABGetItemStateIntent, with completion: @escaping (INObjectCollection?, Error?) -> Void) { - Task { - let items = await OpenHABItemCache.instance.getItemNames(searchTerm: nil, types: nil).map(NSString.init) - let retItems = INObjectCollection(items: items) - // Call the completion handler, passing the collection. - completion(retItems, nil) - } + func provideItemOptionsCollection(for intent: OpenHABGetItemStateIntent) async throws -> INObjectCollection { + let items = await OpenHABItemCache.instance.getItemNames(searchTerm: nil, types: nil).map(NSString.init) + return INObjectCollection(items: items) } - func confirm(intent: OpenHABGetItemStateIntent, completion: @escaping (OpenHABGetItemStateIntentResponse) -> Void) { - completion(OpenHABGetItemStateIntentResponse(code: .ready, userActivity: nil)) + func confirm(intent: OpenHABGetItemStateIntent) async -> OpenHABGetItemStateIntentResponse { + OpenHABGetItemStateIntentResponse(code: .ready, userActivity: nil) } - func handle(intent: OpenHABGetItemStateIntent, completion: @escaping (OpenHABGetItemStateIntentResponse) -> Void) { - os_log("GetItemStateIntent for %{PUBLIC}@", log: .default, type: .info, intent.item ?? "") + func handle(intent: OpenHABGetItemStateIntent) async -> OpenHABGetItemStateIntentResponse { + logger.info("GetItemStateIntent for \(intent.item ?? "")") guard let itemName = intent.item else { - completion(OpenHABGetItemStateIntentResponse.failureInvalidItem(NSLocalizedString("empty", comment: "empty item name"))) - return + return .failureInvalidItem( + NSLocalizedString("empty", comment: "empty item name") + ) } - Task { - let item = await OpenHABItemCache.instance.getItem(name: itemName) - guard let item else { - completion(OpenHABGetItemStateIntentResponse.failureInvalidItem(itemName)) - return - } - completion(OpenHABGetItemStateIntentResponse.success(item: itemName, state: item.state ?? NSLocalizedString("unknown", comment: "unknown item"))) + let item = await OpenHABItemCache.instance.getItem(name: itemName) + + guard let item else { + return .failureInvalidItem(itemName) } + + return .success( + item: itemName, + state: item.state ?? NSLocalizedString("unknown", comment: "unknown item") + ) } } diff --git a/openHABIntents/SetColorValueIntentHandler.swift b/openHABIntents/SetColorValueIntentHandler.swift index 5ef06fa06..60ba80802 100644 --- a/openHABIntents/SetColorValueIntentHandler.swift +++ b/openHABIntents/SetColorValueIntentHandler.swift @@ -15,64 +15,56 @@ import OpenHABCore import os.log class SetColorValueIntentHandler: NSObject, OpenHABSetColorValueIntentHandling { - func provideItemOptionsCollection(for intent: OpenHABSetColorValueIntent, searchTerm: String?, with completion: @escaping (INObjectCollection?, Error?) -> Void) { - Task { - let items = await OpenHABItemCache.instance.getItemNames(searchTerm: searchTerm, types: [OpenHABItem.ItemType.color]).map(NSString.init) - let retItems = INObjectCollection(items: items) - // Call the completion handler, passing the collection. - completion(retItems, nil) - } + private let logger = Logger(subsystem: "org.openhab.app", category: "SetColorValueIntent") + + func provideItemOptionsCollection(for intent: OpenHABSetColorValueIntent, searchTerm: String?) async throws -> INObjectCollection { + let items = await OpenHABItemCache.instance + .getItemNames(searchTerm: searchTerm, types: [.color]) + .map(NSString.init) + return INObjectCollection(items: items) } - func provideItemOptionsCollection(for intent: OpenHABSetColorValueIntent, with completion: @escaping (INObjectCollection?, Error?) -> Void) { - Task { - let items = await OpenHABItemCache.instance.getItemNames(searchTerm: nil, types: [OpenHABItem.ItemType.color]).map(NSString.init) - let retItems = INObjectCollection(items: items) - // Call the completion handler, passing the collection. - completion(retItems, nil) - } + func provideItemOptionsCollection(for intent: OpenHABSetColorValueIntent) async throws -> INObjectCollection { + let items = await OpenHABItemCache.instance + .getItemNames(searchTerm: nil, types: [.color]) + .map(NSString.init) + return INObjectCollection(items: items) } - func confirm(intent: OpenHABSetColorValueIntent, completion: @escaping (OpenHABSetColorValueIntentResponse) -> Void) { - completion(OpenHABSetColorValueIntentResponse(code: .ready, userActivity: nil)) + func confirm(intent: OpenHABSetColorValueIntent) async -> OpenHABSetColorValueIntentResponse { + OpenHABSetColorValueIntentResponse(code: .ready, userActivity: nil) } - func handle(intent: OpenHABSetColorValueIntent, completion: @escaping (OpenHABSetColorValueIntentResponse) -> Void) { - os_log("SetColorValueIntent for %{PUBLIC}@", log: .default, type: .info, intent.item ?? "") + func handle(intent: OpenHABSetColorValueIntent) async -> OpenHABSetColorValueIntentResponse { + logger.info("SetColorValueIntent for \(intent.item ?? "")") guard let itemName = intent.item else { - completion(OpenHABSetColorValueIntentResponse.failureInvalidItem(NSLocalizedString("empty", comment: "empty item name"))) - return + return .failureInvalidItem(NSLocalizedString("empty", comment: "empty item name")) } guard var value = intent.value else { - completion(OpenHABSetColorValueIntentResponse.failureInvalidValue(NSLocalizedString("empty", comment: "empty value"), item: intent.item!)) - return + return .failureInvalidValue( + NSLocalizedString("empty", comment: "empty value"), + item: itemName + ) } let hsb = value.split(separator: ",") - if hsb.count != 3 { - completion(OpenHABSetColorValueIntentResponse.failureInvalidValue(value, item: intent.item!)) - return + guard hsb.count == 3, + let hue = Int(hsb[0]), (0 ... 360).contains(hue), + let sat = Int(hsb[1]), (0 ... 100).contains(sat), + let val = Int(hsb[2]), (0 ... 100).contains(val) else { + return .failureInvalidValue(value, item: itemName) } - let hue = Int(hsb[0]) ?? 0 - let sat = Int(hsb[1]) ?? 0 - let val = Int(hsb[2]) ?? 0 - if hue < 0 || hue > 360 || sat < 0 || sat > 100 || val < 0 || val > 100 { - completion(OpenHABSetColorValueIntentResponse.failureInvalidValue(value, item: intent.item!)) - return - } value = "\(hue),\(sat),\(val)" - Task { - let item = await OpenHABItemCache.instance.getItem(name: itemName) - guard let item else { - completion(OpenHABSetColorValueIntentResponse.failureInvalidItem(itemName)) - return - } - await OpenHABItemCache.instance.sendCommand(item, commandToSend: value) - completion(OpenHABSetColorValueIntentResponse.success(value: value, item: itemName)) + guard let item = await OpenHABItemCache.instance.getItem(name: itemName) else { + return .failureInvalidItem(itemName) } + + await OpenHABItemCache.instance.sendCommand(item, commandToSend: value) + + return .success(value: value, item: itemName) } } diff --git a/openHABIntents/SetContactStateValueIntentHandler.swift b/openHABIntents/SetContactStateValueIntentHandler.swift index 306d818c0..debb9fb3d 100644 --- a/openHABIntents/SetContactStateValueIntentHandler.swift +++ b/openHABIntents/SetContactStateValueIntentHandler.swift @@ -20,62 +20,54 @@ class SetContactStateValueIntentHandler: NSObject, OpenHABSetContactStateValueIn static let ACTION_NAMES = [OPEN, CLOSED] static let ACTION_MAP = [OPEN: "OPEN", CLOSED: "CLOSED"] // these are the sent items - do not translate this text - func provideStateOptionsCollection(for intent: OpenHABSetContactStateValueIntent, with completion: @escaping (INObjectCollection?, Error?) -> Void) { - let actions = INObjectCollection(items: SetContactStateValueIntentHandler.ACTION_NAMES as [NSString]) + private let logger = Logger(subsystem: "org.openhab.app", category: "SetColorValueIntent") - // Call the completion handler, passing the collection. - completion(actions, nil) + func provideStateOptionsCollection(for intent: OpenHABSetContactStateValueIntent) async throws -> INObjectCollection { + INObjectCollection(items: Self.ACTION_NAMES.map(NSString.init)) } - func provideItemOptionsCollection(for intent: OpenHABSetContactStateValueIntent, searchTerm: String?, with completion: @escaping (INObjectCollection?, Error?) -> Void) { - Task { - let items = await OpenHABItemCache.instance.getItemNames(searchTerm: searchTerm, types: [OpenHABItem.ItemType.contact]).map(NSString.init) - let retItems = INObjectCollection(items: items) - // Call the completion handler, passing the collection. - completion(retItems, nil) - } + func provideItemOptionsCollection(for intent: OpenHABSetContactStateValueIntent, searchTerm: String?) async throws -> INObjectCollection { + let items = await OpenHABItemCache.instance + .getItemNames(searchTerm: searchTerm, types: [.contact]) + .map(NSString.init) + return INObjectCollection(items: items) } - func provideItemOptionsCollection(for intent: OpenHABSetContactStateValueIntent, with completion: @escaping (INObjectCollection?, Error?) -> Void) { - Task { - let items = await OpenHABItemCache.instance.getItemNames(searchTerm: nil, types: [OpenHABItem.ItemType.contact]).map(NSString.init) - let retItems = INObjectCollection(items: items) - // Call the completion handler, passing the collection. - completion(retItems, nil) - } + func provideItemOptionsCollection(for intent: OpenHABSetContactStateValueIntent) async throws -> INObjectCollection { + let items = await OpenHABItemCache.instance + .getItemNames(searchTerm: nil, types: [.contact]) + .map(NSString.init) + return INObjectCollection(items: items) } - func confirm(intent: OpenHABSetContactStateValueIntent, completion: @escaping (OpenHABSetContactStateValueIntentResponse) -> Void) { - completion(OpenHABSetContactStateValueIntentResponse(code: .ready, userActivity: nil)) + func confirm(intent: OpenHABSetContactStateValueIntent) async -> OpenHABSetContactStateValueIntentResponse { + OpenHABSetContactStateValueIntentResponse(code: .ready, userActivity: nil) } - func handle(intent: OpenHABSetContactStateValueIntent, completion: @escaping (OpenHABSetContactStateValueIntentResponse) -> Void) { - os_log("SetContactStateValueIntent for %{PUBLIC}@", log: .default, type: .info, intent.item ?? "") + func handle(intent: OpenHABSetContactStateValueIntent) async -> OpenHABSetContactStateValueIntentResponse { + logger.info("SetContactStateValueIntent for \(intent.item ?? "")") guard let itemName = intent.item else { - completion(OpenHABSetContactStateValueIntentResponse.failureInvalidItem(NSLocalizedString("empty", comment: "empty item name"))) - return + return .failureInvalidItem(NSLocalizedString("empty", comment: "empty item name")) } guard let state = intent.state else { - completion(OpenHABSetContactStateValueIntentResponse.failureInvalidAction(state: NSLocalizedString("empty", comment: "empty value"), item: itemName)) - return + return .failureInvalidAction( + state: NSLocalizedString("empty", comment: "empty value"), + item: itemName + ) } - // Map user language to real action - guard let realState = SetContactStateValueIntentHandler.ACTION_MAP[state] else { - completion(OpenHABSetContactStateValueIntentResponse.failureInvalidAction(state: state, item: itemName)) - return + guard let realState = Self.ACTION_MAP[state] else { + return .failureInvalidAction(state: state, item: itemName) } - Task { - let item = await OpenHABItemCache.instance.getItem(name: itemName) - guard let item else { - completion(OpenHABSetContactStateValueIntentResponse.failureInvalidItem(itemName)) - return - } - await OpenHABItemCache.instance.sendState(item, stateToSend: realState) - completion(OpenHABSetContactStateValueIntentResponse.success(item: itemName, state: state)) + guard let item = await OpenHABItemCache.instance.getItem(name: itemName) else { + return .failureInvalidItem(itemName) } + + await OpenHABItemCache.instance.sendState(item, stateToSend: realState) + + return .success(item: itemName, state: state) } } diff --git a/openHABIntents/SetDimmerRollerValueIntentHandler.swift b/openHABIntents/SetDimmerRollerValueIntentHandler.swift index aae58d57f..641fc5341 100644 --- a/openHABIntents/SetDimmerRollerValueIntentHandler.swift +++ b/openHABIntents/SetDimmerRollerValueIntentHandler.swift @@ -15,56 +15,53 @@ import OpenHABCore import os.log class SetDimmerRollerValueIntentHandler: NSObject, OpenHABSetDimmerRollerValueIntentHandling { - func provideItemOptionsCollection(for intent: OpenHABSetDimmerRollerValueIntent, searchTerm: String?, with completion: @escaping (INObjectCollection?, Error?) -> Void) { - Task { - let items = await OpenHABItemCache.instance.getItemNames(searchTerm: searchTerm, types: [OpenHABItem.ItemType.dimmer, OpenHABItem.ItemType.rollershutter]).map(NSString.init) - let retItems = INObjectCollection(items: items) - // Call the completion handler, passing the collection. - completion(retItems, nil) - } + private let logger = Logger(subsystem: "org.openhab.app", category: "SetDimmerRollerValueIntent") + + func provideItemOptionsCollection(for intent: OpenHABSetDimmerRollerValueIntent, searchTerm: String?) async throws -> INObjectCollection { + let items = await OpenHABItemCache.instance.getItemNames( + searchTerm: searchTerm, + types: [.dimmer, .rollershutter] + ).map(NSString.init) + return INObjectCollection(items: items) } - func provideItemOptionsCollection(for intent: OpenHABSetDimmerRollerValueIntent, with completion: @escaping (INObjectCollection?, Error?) -> Void) { - Task { - let items = await OpenHABItemCache.instance.getItemNames(searchTerm: nil, types: [OpenHABItem.ItemType.dimmer, OpenHABItem.ItemType.rollershutter]).map(NSString.init) - let retItems = INObjectCollection(items: items) - // Call the completion handler, passing the collection. - completion(retItems, nil) - } + func provideItemOptionsCollection(for intent: OpenHABSetDimmerRollerValueIntent) async throws -> INObjectCollection { + let items = await OpenHABItemCache.instance.getItemNames( + searchTerm: nil, + types: [.dimmer, .rollershutter] + ).map(NSString.init) + return INObjectCollection(items: items) } - func confirm(intent: OpenHABSetDimmerRollerValueIntent, completion: @escaping (OpenHABSetDimmerRollerValueIntentResponse) -> Void) { - completion(OpenHABSetDimmerRollerValueIntentResponse(code: .ready, userActivity: nil)) + func confirm(intent: OpenHABSetDimmerRollerValueIntent) async -> OpenHABSetDimmerRollerValueIntentResponse { + OpenHABSetDimmerRollerValueIntentResponse(code: .ready, userActivity: nil) } - func handle(intent: OpenHABSetDimmerRollerValueIntent, completion: @escaping (OpenHABSetDimmerRollerValueIntentResponse) -> Void) { - os_log("SetDimmerRollerValueIntent for %{PUBLIC}@", log: .default, type: .info, intent.item ?? "") + func handle(intent: OpenHABSetDimmerRollerValueIntent) async -> OpenHABSetDimmerRollerValueIntentResponse { + logger.info("SetDimmerRollerValueIntent for \(intent.item ?? "")") guard let itemName = intent.item else { - completion(OpenHABSetDimmerRollerValueIntentResponse.failureInvalidItem(NSLocalizedString("empty", comment: "empty item name"))) - return + return .failureInvalidItem( + NSLocalizedString("empty", comment: "empty item name") + ) } guard let value = intent.value else { - completion(OpenHABSetDimmerRollerValueIntentResponse.failureEmptyValue(item: itemName)) - return + return .failureEmptyValue(item: itemName) } let number = Int(truncating: value) - if number < 0 || number > 100 { - completion(OpenHABSetDimmerRollerValueIntentResponse.failureInvalidValue(value, item: itemName)) - return + guard (0 ... 100).contains(number) else { + return .failureInvalidValue(value, item: itemName) } - Task { - let item = await OpenHABItemCache.instance.getItem(name: itemName) - guard let item else { - completion(OpenHABSetDimmerRollerValueIntentResponse.failureInvalidItem(itemName)) - return - } - await OpenHABItemCache.instance.sendCommand(item, commandToSend: "\(number)") - completion(OpenHABSetDimmerRollerValueIntentResponse.success(value: NSNumber(value: number), item: itemName)) + guard let item = await OpenHABItemCache.instance.getItem(name: itemName) else { + return .failureInvalidItem(itemName) } + + await OpenHABItemCache.instance.sendCommand(item, commandToSend: "\(number)") + + return .success(value: NSNumber(value: number), item: itemName) } } diff --git a/openHABIntents/SetNumberValueIntentHandler.swift b/openHABIntents/SetNumberValueIntentHandler.swift index 1e999ef59..71f497a2e 100644 --- a/openHABIntents/SetNumberValueIntentHandler.swift +++ b/openHABIntents/SetNumberValueIntentHandler.swift @@ -15,49 +15,45 @@ import OpenHABCore import os.log class SetNumberValueIntentHandler: NSObject, OpenHABSetNumberValueIntentHandling { - func provideItemOptionsCollection(for intent: OpenHABSetNumberValueIntent, searchTerm: String?, with completion: @escaping (INObjectCollection?, Error?) -> Void) { - Task { - let items = await OpenHABItemCache.instance.getItemNames(searchTerm: searchTerm, types: [OpenHABItem.ItemType.number]).map(NSString.init) - let retItems = INObjectCollection(items: items) - // Call the completion handler, passing the collection. - completion(retItems, nil) - } + private let logger = Logger(subsystem: "org.openhab.app", category: "SetNumberValueIntent") + + func provideItemOptionsCollection(for intent: OpenHABSetNumberValueIntent, searchTerm: String?) async throws -> INObjectCollection { + let items = await OpenHABItemCache.instance + .getItemNames(searchTerm: searchTerm, types: [.number]) + .map(NSString.init) + return INObjectCollection(items: items) } - func provideItemOptionsCollection(for intent: OpenHABSetNumberValueIntent, with completion: @escaping (INObjectCollection?, Error?) -> Void) { - Task { - let items = await OpenHABItemCache.instance.getItemNames(searchTerm: nil, types: [OpenHABItem.ItemType.number]).map(NSString.init) - let retItems = INObjectCollection(items: items) - // Call the completion handler, passing the collection. - completion(retItems, nil) - } + func provideItemOptionsCollection(for intent: OpenHABSetNumberValueIntent) async throws -> INObjectCollection { + let items = await OpenHABItemCache.instance + .getItemNames(searchTerm: nil, types: [.number]) + .map(NSString.init) + return INObjectCollection(items: items) } - func confirm(intent: OpenHABSetNumberValueIntent, completion: @escaping (OpenHABSetNumberValueIntentResponse) -> Void) { - completion(OpenHABSetNumberValueIntentResponse(code: .ready, userActivity: nil)) + func confirm(intent: OpenHABSetNumberValueIntent) async -> OpenHABSetNumberValueIntentResponse { + OpenHABSetNumberValueIntentResponse(code: .ready, userActivity: nil) } - func handle(intent: OpenHABSetNumberValueIntent, completion: @escaping (OpenHABSetNumberValueIntentResponse) -> Void) { - os_log("SetNumberValueIntent for %{PUBLIC}@", log: .default, type: .info, intent.item ?? "") + func handle(intent: OpenHABSetNumberValueIntent) async -> OpenHABSetNumberValueIntentResponse { + logger.info("SetNumberValueIntent for \(intent.item ?? "")") guard let itemName = intent.item else { - completion(OpenHABSetNumberValueIntentResponse.failureInvalidItem(NSLocalizedString("empty", comment: "empty item name"))) - return + return .failureInvalidItem( + NSLocalizedString("empty", comment: "empty item name") + ) } guard let value = intent.value else { - completion(OpenHABSetNumberValueIntentResponse.failureEmptyValue(item: itemName)) - return + return .failureEmptyValue(item: itemName) } - Task { - let item = await OpenHABItemCache.instance.getItem(name: itemName) - guard let item else { - completion(OpenHABSetNumberValueIntentResponse.failureInvalidItem(itemName)) - return - } - await OpenHABItemCache.instance.sendCommand(item, commandToSend: value.stringValue) - - completion(OpenHABSetNumberValueIntentResponse.success(value: value, item: itemName)) + + guard let item = await OpenHABItemCache.instance.getItem(name: itemName) else { + return .failureInvalidItem(itemName) } + + await OpenHABItemCache.instance.sendCommand(item, commandToSend: value.stringValue) + + return .success(value: value, item: itemName) } } diff --git a/openHABIntents/SetStringValueIntentHandler.swift b/openHABIntents/SetStringValueIntentHandler.swift index b08c78dea..f3874fd49 100644 --- a/openHABIntents/SetStringValueIntentHandler.swift +++ b/openHABIntents/SetStringValueIntentHandler.swift @@ -15,50 +15,45 @@ import OpenHABCore import os.log class SetStringValueIntentHandler: NSObject, OpenHABSetStringValueIntentHandling { - func provideItemOptionsCollection(for intent: OpenHABSetStringValueIntent, searchTerm: String?, with completion: @escaping (INObjectCollection?, Error?) -> Void) { - Task { - let items = await OpenHABItemCache.instance.getItemNames(searchTerm: searchTerm, types: [OpenHABItem.ItemType.stringItem]).map(NSString.init) - let retItems = INObjectCollection(items: items) - // Call the completion handler, passing the collection. - completion(retItems, nil) - } + private let logger = Logger(subsystem: "org.openhab.app", category: "SetStringValueIntent") + + func provideItemOptionsCollection(for intent: OpenHABSetStringValueIntent, searchTerm: String?) async throws -> INObjectCollection { + let items = await OpenHABItemCache.instance + .getItemNames(searchTerm: searchTerm, types: [.stringItem]) + .map(NSString.init) + return INObjectCollection(items: items) } - func provideItemOptionsCollection(for intent: OpenHABSetStringValueIntent, with completion: @escaping (INObjectCollection?, Error?) -> Void) { - Task { - let items = await OpenHABItemCache.instance.getItemNames(searchTerm: nil, types: [OpenHABItem.ItemType.stringItem]).map(NSString.init) - let retItems = INObjectCollection(items: items) - // Call the completion handler, passing the collection. - completion(retItems, nil) - } + func provideItemOptionsCollection(for intent: OpenHABSetStringValueIntent) async throws -> INObjectCollection { + let items = await OpenHABItemCache.instance + .getItemNames(searchTerm: nil, types: [.stringItem]) + .map(NSString.init) + return INObjectCollection(items: items) } - func confirm(intent: OpenHABSetStringValueIntent, completion: @escaping (OpenHABSetStringValueIntentResponse) -> Void) { - completion(OpenHABSetStringValueIntentResponse(code: .ready, userActivity: nil)) + func confirm(intent: OpenHABSetStringValueIntent) async -> OpenHABSetStringValueIntentResponse { + OpenHABSetStringValueIntentResponse(code: .ready, userActivity: nil) } - func handle(intent: OpenHABSetStringValueIntent, completion: @escaping (OpenHABSetStringValueIntentResponse) -> Void) { - os_log("SetStringValueIntent for %{PUBLIC}@", log: .default, type: .info, intent.item ?? "") + func handle(intent: OpenHABSetStringValueIntent) async -> OpenHABSetStringValueIntentResponse { + logger.info("SetStringValueIntent for \(intent.item ?? "")") guard let itemName = intent.item else { - completion(OpenHABSetStringValueIntentResponse.failureInvalidItem(NSLocalizedString("empty", comment: "empty item name"))) - return + return .failureInvalidItem( + NSLocalizedString("empty", comment: "empty item name") + ) } guard let value = intent.value else { - completion(OpenHABSetStringValueIntentResponse.failureEmptyValue(item: itemName)) - return + return .failureEmptyValue(item: itemName) } - Task { - let item = await OpenHABItemCache.instance.getItem(name: itemName) - guard let item else { - completion(OpenHABSetStringValueIntentResponse.failureInvalidItem(itemName)) - return - } - await OpenHABItemCache.instance.sendCommand(item, commandToSend: value) - - completion(OpenHABSetStringValueIntentResponse.success(value: value, item: itemName)) + guard let item = await OpenHABItemCache.instance.getItem(name: itemName) else { + return .failureInvalidItem(itemName) } + + await OpenHABItemCache.instance.sendCommand(item, commandToSend: value) + + return .success(value: value, item: itemName) } } From 7e70a9dec6c01de6e58919b809eceeede260c958 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Mon, 7 Apr 2025 23:43:41 +0200 Subject: [PATCH 144/476] Make OpenHABItemCache injectable on all hanlers Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../OpenHABCore/Util/OpenHABItemCache.swift | 1 + .../GetItemStateIntentHandler.swift | 11 +++++-- .../SetColorValueIntentHandler.swift | 13 ++++++--- .../SetContactStateValueIntentHandler.swift | 29 ++++++++++++------- .../SetDimmerRollerValueIntentHandler.swift | 13 ++++++--- .../SetNumberValueIntentHandler.swift | 13 ++++++--- .../SetStringValueIntentHandler.swift | 13 ++++++--- .../SetSwitchStateIntentHandler.swift | 4 +-- 8 files changed, 65 insertions(+), 32 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift index 67c95852e..2f330bb42 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift @@ -17,6 +17,7 @@ public protocol ItemCacheProtocol { func getItem(name: String) async -> OpenHABItem? func sendCommand(_ item: OpenHABItem, commandToSend: String) async func getItemNames(searchTerm: String?, types: [OpenHABItem.ItemType]?) async -> [String] + func sendState(_ item: OpenHABItem, stateToSend: String) async } public actor OpenHABItemCache { diff --git a/openHABIntents/GetItemStateIntentHandler.swift b/openHABIntents/GetItemStateIntentHandler.swift index 176978d90..f082446ad 100644 --- a/openHABIntents/GetItemStateIntentHandler.swift +++ b/openHABIntents/GetItemStateIntentHandler.swift @@ -16,14 +16,19 @@ import os.log class GetItemStateIntentHandler: NSObject, OpenHABGetItemStateIntentHandling { private let logger = Logger(subsystem: "org.openhab.app", category: "GetItemStateIntent") + private let itemCache: ItemCacheProtocol + + init(itemCache: ItemCacheProtocol = OpenHABItemCache.instance) { + self.itemCache = itemCache + } func provideItemOptionsCollection(for intent: OpenHABGetItemStateIntent, searchTerm: String?) async throws -> INObjectCollection { - let items = await OpenHABItemCache.instance.getItemNames(searchTerm: searchTerm, types: nil).map(NSString.init) + let items = await itemCache.getItemNames(searchTerm: searchTerm, types: nil).map(NSString.init) return INObjectCollection(items: items) } func provideItemOptionsCollection(for intent: OpenHABGetItemStateIntent) async throws -> INObjectCollection { - let items = await OpenHABItemCache.instance.getItemNames(searchTerm: nil, types: nil).map(NSString.init) + let items = await itemCache.getItemNames(searchTerm: nil, types: nil).map(NSString.init) return INObjectCollection(items: items) } @@ -40,7 +45,7 @@ class GetItemStateIntentHandler: NSObject, OpenHABGetItemStateIntentHandling { ) } - let item = await OpenHABItemCache.instance.getItem(name: itemName) + let item = await itemCache.getItem(name: itemName) guard let item else { return .failureInvalidItem(itemName) diff --git a/openHABIntents/SetColorValueIntentHandler.swift b/openHABIntents/SetColorValueIntentHandler.swift index 60ba80802..75565a207 100644 --- a/openHABIntents/SetColorValueIntentHandler.swift +++ b/openHABIntents/SetColorValueIntentHandler.swift @@ -16,16 +16,21 @@ import os.log class SetColorValueIntentHandler: NSObject, OpenHABSetColorValueIntentHandling { private let logger = Logger(subsystem: "org.openhab.app", category: "SetColorValueIntent") + private let itemCache: ItemCacheProtocol + + init(itemCache: ItemCacheProtocol = OpenHABItemCache.instance) { + self.itemCache = itemCache + } func provideItemOptionsCollection(for intent: OpenHABSetColorValueIntent, searchTerm: String?) async throws -> INObjectCollection { - let items = await OpenHABItemCache.instance + let items = await itemCache .getItemNames(searchTerm: searchTerm, types: [.color]) .map(NSString.init) return INObjectCollection(items: items) } func provideItemOptionsCollection(for intent: OpenHABSetColorValueIntent) async throws -> INObjectCollection { - let items = await OpenHABItemCache.instance + let items = await itemCache .getItemNames(searchTerm: nil, types: [.color]) .map(NSString.init) return INObjectCollection(items: items) @@ -59,11 +64,11 @@ class SetColorValueIntentHandler: NSObject, OpenHABSetColorValueIntentHandling { value = "\(hue),\(sat),\(val)" - guard let item = await OpenHABItemCache.instance.getItem(name: itemName) else { + guard let item = await itemCache.getItem(name: itemName) else { return .failureInvalidItem(itemName) } - await OpenHABItemCache.instance.sendCommand(item, commandToSend: value) + await itemCache.sendCommand(item, commandToSend: value) return .success(value: value, item: itemName) } diff --git a/openHABIntents/SetContactStateValueIntentHandler.swift b/openHABIntents/SetContactStateValueIntentHandler.swift index debb9fb3d..9bd694b3c 100644 --- a/openHABIntents/SetContactStateValueIntentHandler.swift +++ b/openHABIntents/SetContactStateValueIntentHandler.swift @@ -15,26 +15,35 @@ import OpenHABCore import os.log class SetContactStateValueIntentHandler: NSObject, OpenHABSetContactStateValueIntentHandling { - static let OPEN = NSLocalizedString("open", comment: "").capitalized // User language - static let CLOSED = NSLocalizedString("closed", comment: "").capitalized // User language - static let ACTION_NAMES = [OPEN, CLOSED] - static let ACTION_MAP = [OPEN: "OPEN", CLOSED: "CLOSED"] // these are the sent items - do not translate this text + private static let onLabel = NSLocalizedString("on", comment: "").capitalized + private static let offLabel = NSLocalizedString("off", comment: "").capitalized + + private static let localizedActions = [onLabel, offLabel] + private static let actionMap: [String: String] = [ + onLabel: "ON", + offLabel: "OFF" + ] private let logger = Logger(subsystem: "org.openhab.app", category: "SetColorValueIntent") + private let itemCache: ItemCacheProtocol + + init(itemCache: ItemCacheProtocol = OpenHABItemCache.instance) { + self.itemCache = itemCache + } func provideStateOptionsCollection(for intent: OpenHABSetContactStateValueIntent) async throws -> INObjectCollection { - INObjectCollection(items: Self.ACTION_NAMES.map(NSString.init)) + INObjectCollection(items: Self.localizedActions as [NSString]) } func provideItemOptionsCollection(for intent: OpenHABSetContactStateValueIntent, searchTerm: String?) async throws -> INObjectCollection { - let items = await OpenHABItemCache.instance + let items = await itemCache .getItemNames(searchTerm: searchTerm, types: [.contact]) .map(NSString.init) return INObjectCollection(items: items) } func provideItemOptionsCollection(for intent: OpenHABSetContactStateValueIntent) async throws -> INObjectCollection { - let items = await OpenHABItemCache.instance + let items = await itemCache .getItemNames(searchTerm: nil, types: [.contact]) .map(NSString.init) return INObjectCollection(items: items) @@ -58,15 +67,15 @@ class SetContactStateValueIntentHandler: NSObject, OpenHABSetContactStateValueIn ) } - guard let realState = Self.ACTION_MAP[state] else { + guard let realState = Self.actionMap[state] else { return .failureInvalidAction(state: state, item: itemName) } - guard let item = await OpenHABItemCache.instance.getItem(name: itemName) else { + guard let item = await itemCache.getItem(name: itemName) else { return .failureInvalidItem(itemName) } - await OpenHABItemCache.instance.sendState(item, stateToSend: realState) + await itemCache.sendState(item, stateToSend: realState) return .success(item: itemName, state: state) } diff --git a/openHABIntents/SetDimmerRollerValueIntentHandler.swift b/openHABIntents/SetDimmerRollerValueIntentHandler.swift index 641fc5341..ddf5a57dd 100644 --- a/openHABIntents/SetDimmerRollerValueIntentHandler.swift +++ b/openHABIntents/SetDimmerRollerValueIntentHandler.swift @@ -16,9 +16,14 @@ import os.log class SetDimmerRollerValueIntentHandler: NSObject, OpenHABSetDimmerRollerValueIntentHandling { private let logger = Logger(subsystem: "org.openhab.app", category: "SetDimmerRollerValueIntent") + private let itemCache: ItemCacheProtocol + + init(itemCache: ItemCacheProtocol = OpenHABItemCache.instance) { + self.itemCache = itemCache + } func provideItemOptionsCollection(for intent: OpenHABSetDimmerRollerValueIntent, searchTerm: String?) async throws -> INObjectCollection { - let items = await OpenHABItemCache.instance.getItemNames( + let items = await itemCache.getItemNames( searchTerm: searchTerm, types: [.dimmer, .rollershutter] ).map(NSString.init) @@ -26,7 +31,7 @@ class SetDimmerRollerValueIntentHandler: NSObject, OpenHABSetDimmerRollerValueIn } func provideItemOptionsCollection(for intent: OpenHABSetDimmerRollerValueIntent) async throws -> INObjectCollection { - let items = await OpenHABItemCache.instance.getItemNames( + let items = await itemCache.getItemNames( searchTerm: nil, types: [.dimmer, .rollershutter] ).map(NSString.init) @@ -56,11 +61,11 @@ class SetDimmerRollerValueIntentHandler: NSObject, OpenHABSetDimmerRollerValueIn return .failureInvalidValue(value, item: itemName) } - guard let item = await OpenHABItemCache.instance.getItem(name: itemName) else { + guard let item = await itemCache.getItem(name: itemName) else { return .failureInvalidItem(itemName) } - await OpenHABItemCache.instance.sendCommand(item, commandToSend: "\(number)") + await itemCache.sendCommand(item, commandToSend: "\(number)") return .success(value: NSNumber(value: number), item: itemName) } diff --git a/openHABIntents/SetNumberValueIntentHandler.swift b/openHABIntents/SetNumberValueIntentHandler.swift index 71f497a2e..f142bb31c 100644 --- a/openHABIntents/SetNumberValueIntentHandler.swift +++ b/openHABIntents/SetNumberValueIntentHandler.swift @@ -16,16 +16,21 @@ import os.log class SetNumberValueIntentHandler: NSObject, OpenHABSetNumberValueIntentHandling { private let logger = Logger(subsystem: "org.openhab.app", category: "SetNumberValueIntent") + private let itemCache: ItemCacheProtocol + + init(itemCache: ItemCacheProtocol = OpenHABItemCache.instance) { + self.itemCache = itemCache + } func provideItemOptionsCollection(for intent: OpenHABSetNumberValueIntent, searchTerm: String?) async throws -> INObjectCollection { - let items = await OpenHABItemCache.instance + let items = await itemCache .getItemNames(searchTerm: searchTerm, types: [.number]) .map(NSString.init) return INObjectCollection(items: items) } func provideItemOptionsCollection(for intent: OpenHABSetNumberValueIntent) async throws -> INObjectCollection { - let items = await OpenHABItemCache.instance + let items = await itemCache .getItemNames(searchTerm: nil, types: [.number]) .map(NSString.init) return INObjectCollection(items: items) @@ -48,11 +53,11 @@ class SetNumberValueIntentHandler: NSObject, OpenHABSetNumberValueIntentHandling return .failureEmptyValue(item: itemName) } - guard let item = await OpenHABItemCache.instance.getItem(name: itemName) else { + guard let item = await itemCache.getItem(name: itemName) else { return .failureInvalidItem(itemName) } - await OpenHABItemCache.instance.sendCommand(item, commandToSend: value.stringValue) + await itemCache.sendCommand(item, commandToSend: value.stringValue) return .success(value: value, item: itemName) } diff --git a/openHABIntents/SetStringValueIntentHandler.swift b/openHABIntents/SetStringValueIntentHandler.swift index f3874fd49..55edf6226 100644 --- a/openHABIntents/SetStringValueIntentHandler.swift +++ b/openHABIntents/SetStringValueIntentHandler.swift @@ -16,16 +16,21 @@ import os.log class SetStringValueIntentHandler: NSObject, OpenHABSetStringValueIntentHandling { private let logger = Logger(subsystem: "org.openhab.app", category: "SetStringValueIntent") + private let itemCache: ItemCacheProtocol + + init(itemCache: ItemCacheProtocol = OpenHABItemCache.instance) { + self.itemCache = itemCache + } func provideItemOptionsCollection(for intent: OpenHABSetStringValueIntent, searchTerm: String?) async throws -> INObjectCollection { - let items = await OpenHABItemCache.instance + let items = await itemCache .getItemNames(searchTerm: searchTerm, types: [.stringItem]) .map(NSString.init) return INObjectCollection(items: items) } func provideItemOptionsCollection(for intent: OpenHABSetStringValueIntent) async throws -> INObjectCollection { - let items = await OpenHABItemCache.instance + let items = await itemCache .getItemNames(searchTerm: nil, types: [.stringItem]) .map(NSString.init) return INObjectCollection(items: items) @@ -48,11 +53,11 @@ class SetStringValueIntentHandler: NSObject, OpenHABSetStringValueIntentHandling return .failureEmptyValue(item: itemName) } - guard let item = await OpenHABItemCache.instance.getItem(name: itemName) else { + guard let item = await itemCache.getItem(name: itemName) else { return .failureInvalidItem(itemName) } - await OpenHABItemCache.instance.sendCommand(item, commandToSend: value) + await itemCache.sendCommand(item, commandToSend: value) return .success(value: value, item: itemName) } diff --git a/openHABIntents/SetSwitchStateIntentHandler.swift b/openHABIntents/SetSwitchStateIntentHandler.swift index 7c9efc918..cc3a02476 100644 --- a/openHABIntents/SetSwitchStateIntentHandler.swift +++ b/openHABIntents/SetSwitchStateIntentHandler.swift @@ -34,9 +34,7 @@ final class SetSwitchStateIntentHandler: NSObject, OpenHABSetSwitchStateIntentHa func provideActionOptionsCollection(for intent: OpenHABSetSwitchStateIntent) async throws -> INObjectCollection { logger.info("SetSwitchStateIntentHandler provideActionOptionsCollection") - - let collection = INObjectCollection(items: Self.localizedActions as [NSString]) - return collection + return INObjectCollection(items: Self.localizedActions as [NSString]) } func provideItemOptionsCollection(for intent: OpenHABSetSwitchStateIntent, searchTerm: String?) async throws -> INObjectCollection { From 6bf6bcbb7d540fb08c5d3b335608382ed6ea703f Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Tue, 22 Apr 2025 14:22:00 +0200 Subject: [PATCH 145/476] Eliminate using AppDelegate.appDelegate.appData in NotificationsView and OpenHABWebViewController Make NotificationsView run in #Preview Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../OpenHABCore/Util/NetworkTracker.swift | 20 ++++ openHAB/NotificationsView.swift | 113 +++++++++++++----- openHAB/OpenHABWebViewController.swift | 4 - openHAB/SettingsView/SettingsView.swift | 2 +- 4 files changed, 103 insertions(+), 36 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift index 7a5431a80..ec6640d6f 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift @@ -28,6 +28,12 @@ public enum NetworkStatus: String { public struct ConnectionInfo: Equatable, Sendable { public let configuration: ConnectionConfiguration public let version: Int + + // Explicit public memberwise initializer + public init(configuration: ConnectionConfiguration, version: Int) { + self.configuration = configuration + self.version = version + } } public enum NetworkTrackerError: Error, CustomDebugStringConvertible { @@ -370,6 +376,12 @@ public final class NetworkTracker: ObservableObject { } } +public protocol NetworkTracking: ObservableObject { + var activeConnection: ConnectionInfo? { get } +} + +extension NetworkTracker: NetworkTracking {} + public extension NetworkTracker { func send(to item: OpenHABItem, command: String) async throws { try await send(to: item.name, command: command) @@ -428,3 +440,11 @@ public extension NetworkTracker { } } } + +#if DEBUG +public extension NetworkTracker { + func setMockConnection(_ connection: ConnectionInfo) { + activeConnection = connection + } +} +#endif diff --git a/openHAB/NotificationsView.swift b/openHAB/NotificationsView.swift index cc4b2efd2..4cc225bf9 100644 --- a/openHAB/NotificationsView.swift +++ b/openHAB/NotificationsView.swift @@ -16,11 +16,7 @@ import SwiftUI struct NotificationRow: View { var notification: OpenHABNotification - - // App wide data access - var appData: OpenHABDataObject? { - AppDelegate.appDelegate.appData - } + var connection: ConnectionInfo var body: some View { HStack { @@ -46,11 +42,9 @@ struct NotificationRow: View { } private var iconUrl: URL? { - guard let appData else { return nil } - let endpoint = Endpoint.icon( - rootUrl: appData.openHABRootUrl, - version: appData.openHABVersion, + rootUrl: connection.configuration.url, + version: connection.version, icon: notification.icon, state: "", iconType: .png, @@ -75,14 +69,20 @@ struct NotificationRow: View { } } -struct NotificationsView: View { - private let logger = Logger(subsystem: "org.openhab.app", category: "NotificationView") +typealias NotificationLoader = () async -> [OpenHABNotification] +struct NotificationsView: View where Tracker: ObservableObject { + @ObservedObject var networkTracker: Tracker @State var notifications: [OpenHABNotification] = [] + let loadNotifications: NotificationLoader + + private let logger = Logger(subsystem: "org.openhab.app", category: "NotificationView") var body: some View { List(notifications, id: \.id) { notification in - NotificationRow(notification: notification) + if let connection = networkTracker.activeConnection { + NotificationRow(notification: notification, connection: connection) + } } .refreshable { await notifications = loadNotifications() @@ -92,35 +92,86 @@ struct NotificationsView: View { await notifications = loadNotifications() } } +} +extension NotificationsView where Tracker == NetworkTracker { init(notifications: [OpenHABNotification] = []) { + networkTracker = NetworkTracker.shared _notifications = State(initialValue: notifications) - } + loadNotifications = { + let logger = Logger(subsystem: "org.openhab.app", category: "NotificationView") - private func loadNotifications() async -> [OpenHABNotification] { - do { - guard let config = Preferences.getLowestPriorityOpenHABConnection() else { - logger.warning("No openHAB configuration found.") - return [] - } + do { + guard let config = Preferences.getLowestPriorityOpenHABConnection() else { + logger.warning("No openHAB configuration found.") + return [] + } + + guard let url = URL(string: config.url), url.scheme != nil else { + logger.error("Invalid URL: \(config.url, privacy: .public)") + return [] + } - guard let url = URL(string: config.url), url.scheme != nil else { - logger.error("Invalid URL: \(config.url, privacy: .public)") + let client = HTTPClient(configuration: config) + return try await client.notification(urlString: config.url) + } catch { + logger.error("Failed to load notifications: \(error.localizedDescription, privacy: .public)") return [] } - let client = HTTPClient(configuration: config) - return try await client.notification(urlString: config.url) - } catch { - logger.error("Failed to load notifications: \(error.localizedDescription, privacy: .public)") - return [] } } } +#if DEBUG +public extension ConnectionInfo { + static var mock: ConnectionInfo { + ConnectionInfo( + configuration: ConnectionConfiguration( + url: "http://mock.local:8080", + username: "demo", + password: "demo", + alwaysSendBasicAuth: true + ), + version: 3 + ) + } +} + +final class MockNetworkTracker: NetworkTracking, ObservableObject { + @Published var activeConnection: ConnectionInfo? + + init(connection: ConnectionInfo?) { + activeConnection = connection + } +} + +struct NotificationsViewPreview: View { + var body: some View { + let mockTracker = MockNetworkTracker(connection: .mock) + return NotificationsView( + networkTracker: mockTracker, + notifications: [], + loadNotifications: { + [ + OpenHABNotification( + message: "Preview Notification 1", + created: .now, + icon: "sun", + id: UUID().uuidString + ), + OpenHABNotification( + message: "Preview Notification 2", + created: .now.addingTimeInterval(-3600), + icon: "moon", + id: UUID().uuidString + ) + ] + } + ) + } +} + #Preview { - NotificationsView(notifications: [ - OpenHABNotification(message: "message1", created: Date.now, id: UUID().uuidString), - OpenHABNotification(message: "message2", created: Date.now, id: UUID().uuidString) - ] - ) + NotificationsViewPreview() } +#endif diff --git a/openHAB/OpenHABWebViewController.swift b/openHAB/OpenHABWebViewController.swift index c7cdbf7d8..38e206521 100644 --- a/openHAB/OpenHABWebViewController.swift +++ b/openHAB/OpenHABWebViewController.swift @@ -44,10 +44,6 @@ class OpenHABWebViewController: OpenHABViewController { } """ - var appData: OpenHABDataObject? { - AppDelegate.appDelegate.appData - } - override open var shouldAutorotate: Bool { true } diff --git a/openHAB/SettingsView/SettingsView.swift b/openHAB/SettingsView/SettingsView.swift index de6868424..3e6ecb6df 100644 --- a/openHAB/SettingsView/SettingsView.swift +++ b/openHAB/SettingsView/SettingsView.swift @@ -25,7 +25,7 @@ struct SettingsView: View { @State var settingsIgnoreSSL = true @State var settingsRealTimeSliders = true @State var settingsSendCrashReports = false - @State var settingsIconType: IconType = .png + @State var settingsIconType: IconType = .svg @State var settingsSortSitemapsBy: SortSitemapsOrder = .label @State var settingsDefaultMainUIPath = "" @State var settingsAlwaysAllowWebRTC = true From 1df87f15b6b7027c78961ba1a6995fd07246a572 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Fri, 25 Apr 2025 12:59:42 +0200 Subject: [PATCH 146/476] Eliminate using AppDelegate.appDelegate.appData - step 2 Delete DataObject protocol. Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../OpenHABCore/Model/DataObject.swift | 20 ----------- openHAB.xcodeproj/project.pbxproj | 4 --- openHAB/AppDelegate.swift | 2 -- openHAB/NewImageUITableViewCell.swift | 10 +++--- openHAB/OpenHABDataObject.swift | 33 ------------------- openHAB/OpenHABRootViewController.swift | 30 ++++++++--------- openHAB/OpenHABSitemapViewController.swift | 19 +++-------- .../AnimatedSecureTextField.swift | 3 +- openHAB/SettingsView/MainUISettingsView.swift | 1 - openHABWatch/External/AppMessageService.swift | 2 +- openHABWatch/OpenHABWatch.swift | 2 +- 11 files changed, 28 insertions(+), 98 deletions(-) delete mode 100644 OpenHABCore/Sources/OpenHABCore/Model/DataObject.swift delete mode 100644 openHAB/OpenHABDataObject.swift diff --git a/OpenHABCore/Sources/OpenHABCore/Model/DataObject.swift b/OpenHABCore/Sources/OpenHABCore/Model/DataObject.swift deleted file mode 100644 index 3f200083b..000000000 --- a/OpenHABCore/Sources/OpenHABCore/Model/DataObject.swift +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright (c) 2010-2025 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 Foundation - -public protocol DataObject: AnyObject { - var openHABRootUrl: String { get set } - var openHABUsername: String { get set } - var openHABPassword: String { get set } - var openHABVersion: Int { get set } - var openHABAlwaysSendCreds: Bool { get set } -} diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index 6d2993d92..13d442db9 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -156,7 +156,6 @@ DAF4F6C0222734D300C24876 /* NewImageUITableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF4F6BF222734D200C24876 /* NewImageUITableViewCell.swift */; }; DF05FF231896BD2D00FF2F9B /* SelectionUITableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF05FF221896BD2D00FF2F9B /* SelectionUITableViewCell.swift */; }; DF06F1FC18FEC2020011E7B9 /* ColorPickerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF06F1FB18FEC2020011E7B9 /* ColorPickerViewController.swift */; }; - DF4B84041885A53700F34902 /* OpenHABDataObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF4B84031885A53700F34902 /* OpenHABDataObject.swift */; }; DF4B84131886DAC400F34902 /* FrameUITableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF4B84121886DAC400F34902 /* FrameUITableViewCell.swift */; }; DF4B84161886EACA00F34902 /* GenericUITableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF4B84151886EACA00F34902 /* GenericUITableViewCell.swift */; }; DFA13CB418872EBD006355C3 /* SwitchUITableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFA13CB318872EBD006355C3 /* SwitchUITableViewCell.swift */; }; @@ -472,7 +471,6 @@ DAF6F4112C67E83B0083883E /* openapiCorrected.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = openapiCorrected.json; sourceTree = ""; }; DF05FF221896BD2D00FF2F9B /* SelectionUITableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SelectionUITableViewCell.swift; sourceTree = ""; }; DF06F1FB18FEC2020011E7B9 /* ColorPickerViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ColorPickerViewController.swift; sourceTree = ""; }; - DF4B84031885A53700F34902 /* OpenHABDataObject.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OpenHABDataObject.swift; sourceTree = ""; }; DF4B84121886DAC400F34902 /* FrameUITableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FrameUITableViewCell.swift; sourceTree = ""; }; DF4B84151886EACA00F34902 /* GenericUITableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GenericUITableViewCell.swift; sourceTree = ""; }; DFA13CB318872EBD006355C3 /* SwitchUITableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SwitchUITableViewCell.swift; sourceTree = ""; }; @@ -1057,7 +1055,6 @@ DFDEE3FE1883228C008B26AC /* Models */ = { isa = PBXGroup; children = ( - DF4B84031885A53700F34902 /* OpenHABDataObject.swift */, DA5ED9BD2C850955004875E0 /* ClientCertificatesViewModel.swift */, ); name = Models; @@ -1597,7 +1594,6 @@ DA6B2EF72C8B92E800DF77CF /* SelectionView.swift in Sources */, DA4800212D839D3A009CF127 /* AnimatedSecureTextField.swift in Sources */, DAC131112DA3213100075AE2 /* SetSwitchStateIntentHandlerTests.swift in Sources */, - DF4B84041885A53700F34902 /* OpenHABDataObject.swift in Sources */, DAC65FC7236EDF3900F4501E /* SpinnerViewController.swift in Sources */, DA9F81872C85020F00B47B72 /* RTFTextView.swift in Sources */, DA6B2EF12C87B59000DF77CF /* NotificationsView.swift in Sources */, diff --git a/openHAB/AppDelegate.swift b/openHAB/AppDelegate.swift index 29c0bfadf..e36631dc5 100644 --- a/openHAB/AppDelegate.swift +++ b/openHAB/AppDelegate.swift @@ -54,7 +54,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate { let audioPlayer = AudioPlayerActor() var window: UIWindow? - var appData: OpenHABDataObject // Delegate Requests from the Watch to the WatchMessageService var session: WCSession? { @@ -68,7 +67,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } override init() { - appData = OpenHABDataObject() super.init() AppDelegate.appDelegate = self } diff --git a/openHAB/NewImageUITableViewCell.swift b/openHAB/NewImageUITableViewCell.swift index cfac9d522..995eca0b1 100644 --- a/openHAB/NewImageUITableViewCell.swift +++ b/openHAB/NewImageUITableViewCell.swift @@ -27,9 +27,7 @@ class NewImageUITableViewCell: GenericUITableViewCell, NoIconDisplayableCell { private var chartStyle: ChartStyle = .light private var activeTask: Task? - private var appData: OpenHABDataObject? { - AppDelegate.appDelegate.appData - } + var openHABRootUrl: String? private var shouldCache: Bool { widget?.refresh == 0 @@ -40,8 +38,12 @@ class NewImageUITableViewCell: GenericUITableViewCell, NoIconDisplayableCell { switch widget.type { case .chart: + guard let openHABRootUrl else { + os_log("Missing openHABRootUrl in NewImageUITableViewCell", log: .urlComposition, type: .error) + return .empty + } return .link(url: Endpoint.chart( - rootUrl: appData!.openHABRootUrl, + rootUrl: openHABRootUrl, period: widget.period, type: widget.item?.type, service: widget.service, diff --git a/openHAB/OpenHABDataObject.swift b/openHAB/OpenHABDataObject.swift deleted file mode 100644 index 6197400ea..000000000 --- a/openHAB/OpenHABDataObject.swift +++ /dev/null @@ -1,33 +0,0 @@ -// Copyright (c) 2010-2025 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 Foundation -import OpenHABCore -import UIKit - -class OpenHABDataObject: NSObject, DataObject { - var openHABRootUrl = "" - var openHABUsername = "" - var openHABPassword = "" - var openHABAlwaysSendCreds = false - var sitemapViewController: OpenHABSitemapViewController? - var openHABVersion: Int = 0 - var currentWebViewPath = "" - var currentView: TargetController? - var connectionInfo: ConnectionInfo? -} - -extension OpenHABDataObject { - convenience init(openHABRootUrl: String) { - self.init() - self.openHABRootUrl = openHABRootUrl - } -} diff --git a/openHAB/OpenHABRootViewController.swift b/openHAB/OpenHABRootViewController.swift index eef085478..5923d6f00 100644 --- a/openHAB/OpenHABRootViewController.swift +++ b/openHAB/OpenHABRootViewController.swift @@ -52,9 +52,7 @@ class OpenHABRootViewController: UIViewController { return viewController }() - var appData: OpenHABDataObject? { - AppDelegate.appDelegate.appData - } + private var activeConnection: ConnectionInfo? override func viewDidLoad() { super.viewDidLoad() @@ -174,12 +172,7 @@ class OpenHABRootViewController: UIViewController { .receive(on: DispatchQueue.main) .sink { [weak self] activeConnection in if let activeConnection { - self?.appData?.openHABRootUrl = activeConnection.configuration.url - self?.appData?.openHABUsername = activeConnection.configuration.username - self?.appData?.openHABPassword = activeConnection.configuration.password - self?.appData?.openHABAlwaysSendCreds = activeConnection.configuration.alwaysSendBasicAuth - self?.appData?.openHABVersion = activeConnection.version - self?.appData?.connectionInfo = activeConnection + self?.activeConnection = activeConnection } } .store(in: &cancellables) @@ -227,14 +220,19 @@ class OpenHABRootViewController: UIViewController { // Use SFSafariViewController in SwiftUI with UIViewControllerRepresentable // Dependent on $OPENHAB_CONF/services/runtime.cfg // Can either be an absolute URL, a path (sometimes malformed) - if !urlString.isEmpty { - let url: URL? = if urlString.hasPrefix("http") { - URL(string: urlString) - } else { - Endpoint.resource(openHABRootUrl: appData?.openHABRootUrl ?? "", path: urlString.prepare()).url + guard !urlString.isEmpty else { return } + + let url: URL? + if urlString.hasPrefix("http") || urlString.hasPrefix("https") { + url = URL(string: urlString) + } else { + guard let rootUrl = activeConnection?.configuration.url else { + os_log("openTileURL failed: no active connection URL", log: .default, type: .error) + return } - openURL(url: url) + url = Endpoint.resource(openHABRootUrl: rootUrl, path: urlString.prepare()).url } + openURL(url: url) } private func openURL(url: URL?) { @@ -260,7 +258,6 @@ class OpenHABRootViewController: UIViewController { } case let .sitemap(sitemap): Preferences.defaultSitemap = sitemap - appData?.sitemapViewController?.pageUrl = "" SideMenuManager.default.rightMenuNavigationController?.dismiss(animated: true) { self.modalDismissed(to: .sitemap(sitemap)) } @@ -512,7 +509,6 @@ class OpenHABRootViewController: UIViewController { } addView(viewController: targetView) currentView = targetView - appData?.currentView = target // Don't save our view in demo mode if !Preferences.demomode { Preferences.defaultView = currentView.viewName() diff --git a/openHAB/OpenHABSitemapViewController.swift b/openHAB/OpenHABSitemapViewController.swift index e41a78116..ff5c33bd1 100644 --- a/openHAB/OpenHABSitemapViewController.swift +++ b/openHAB/OpenHABSitemapViewController.swift @@ -63,9 +63,8 @@ class OpenHABSitemapViewController: OpenHABViewController { // App wide data access // https://stackoverflow.com/questions/45832155/how-do-i-refactor-my-code-to-call-appdelegate-on-the-main-thread - var appData: OpenHABDataObject? { - AppDelegate.appDelegate.appData - } + + var sitemapViewController: OpenHABSitemapViewController? // MARK: - Private instance methods @@ -151,7 +150,7 @@ class OpenHABSitemapViewController: OpenHABViewController { // if pageUrl is empty, it means we are the first opened OpenHABSitemapViewController if pageUrl.isEmpty { - appData?.sitemapViewController = self + sitemapViewController = self // if navigationController?.viewControllers.first == self { // This is the first sitemap opened if currentPage != nil { @@ -321,11 +320,11 @@ extension OpenHABSitemapViewController { } func restart() { - if appData?.sitemapViewController == self { + if sitemapViewController == self { os_log("I am a rootViewController!", log: .viewCycle, type: .info) } else { - appData?.sitemapViewController?.pageUrl = "" + sitemapViewController?.pageUrl = "" navigationController?.popToRootViewController(animated: true) } } @@ -731,14 +730,6 @@ extension OpenHABSitemapViewController: UITableViewDelegate, UITableViewDataSour } } } - -// cell.loadWidgetIcon( -// widget: widget, -// traitCollection: traitCollection, -// openHABRootUrl: openHABRootUrl, -// openHABVersion: appData?.openHABVersion ?? 2, -// iconType: iconType -// ) } if cell is FrameUITableViewCell { diff --git a/openHAB/SettingsView/AnimatedSecureTextField.swift b/openHAB/SettingsView/AnimatedSecureTextField.swift index 8d898dac3..e848ce9ba 100644 --- a/openHAB/SettingsView/AnimatedSecureTextField.swift +++ b/openHAB/SettingsView/AnimatedSecureTextField.swift @@ -29,11 +29,12 @@ struct AnimatedSecureTextField: View { SecureField(titleKey, text: $text) .textContentType(.password) .multilineTextAlignment(.trailing) // Ensures text aligns to the right - + .disableAutocorrection(true) } else { TextField(titleKey, text: $text) .textContentType(.password) .multilineTextAlignment(.trailing) // Ensures text aligns to the right + .disableAutocorrection(true) } } .frame(maxWidth: .infinity, alignment: .trailing) // Push to the right diff --git a/openHAB/SettingsView/MainUISettingsView.swift b/openHAB/SettingsView/MainUISettingsView.swift index 198cb430a..ae1b33cf4 100644 --- a/openHAB/SettingsView/MainUISettingsView.swift +++ b/openHAB/SettingsView/MainUISettingsView.swift @@ -16,7 +16,6 @@ import WebKit struct MainUISettingsView: View { @Binding var settingsAlwaysAllowWebRTC: Bool @Binding var settingsDefaultMainUIPath: String -// @Binding var currentWebViewPath: String @State var showUselastPathAlert = false @State var showingCacheAlert = false diff --git a/openHABWatch/External/AppMessageService.swift b/openHABWatch/External/AppMessageService.swift index d9f811984..75d2b7836 100644 --- a/openHABWatch/External/AppMessageService.swift +++ b/openHABWatch/External/AppMessageService.swift @@ -40,7 +40,7 @@ class AppMessageService: NSObject, WCSessionDelegate { // if let trustedCertificates = applicationContext["trustedCertificates"] as? [String: Data] { // // do we need to do anything here? We load from the shared keychain. // } - logger.info("✅ Applied WatchPreferences to ObservableOpenHABDataObject") + logger.info("✅ Applied WatchPreferences") } catch { logger.error("❌ Failed to decode WatchPreferences: \(error.localizedDescription)") } diff --git a/openHABWatch/OpenHABWatch.swift b/openHABWatch/OpenHABWatch.swift index 8a4217f05..51287366b 100644 --- a/openHABWatch/OpenHABWatch.swift +++ b/openHABWatch/OpenHABWatch.swift @@ -18,7 +18,7 @@ struct OpenHABWatch: App { @ObservedObject var settings = AppSettings.shared // https://developer.apple.com/documentation/watchkit/wkapplicationdelegate @WKApplicationDelegateAdaptor(OpenHABWatchAppDelegate.self) var appDelegate - @ObservedObject var userData = UserData.shared // (sitemapName: ObservableOpenHABDataObject.shared.sitemapName) + @ObservedObject var userData = UserData.shared var body: some Scene { WindowGroup { From 38fd071c58aca4ad490a2b61c5ff61c200a2b494 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Fri, 25 Apr 2025 13:42:36 +0200 Subject: [PATCH 147/476] No crash when no activeConnection: no forced unwrap Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- openHAB.xcodeproj/project.pbxproj | 2 +- openHAB/OpenHABSitemapViewController.swift | 30 ++++++++++++++++--- .../Resources/en.lproj/Localizable.strings | 1 + 3 files changed, 28 insertions(+), 5 deletions(-) diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index 13d442db9..93c55e8ce 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -922,11 +922,11 @@ DA9F81862C85020F00B47B72 /* RTFTextView.swift */, DAC65FC6236EDF3900F4501E /* SpinnerViewController.swift */, DA6B2EF62C8B92E800DF77CF /* SelectionView.swift */, + DA2AEB6F2D92CF3E00897D80 /* WidgetIconRenderer.swift */, DA48001F2D837CD8009CF127 /* SettingsView */, 1224F78B228A89E300750965 /* Watch */, DF4B84101886DA9900F34902 /* Widgets */, DFFD8FCE18EDBD30003B502A /* Util */, - DA2AEB6F2D92CF3E00897D80 /* WidgetIconRenderer.swift */, ); name = UI; sourceTree = ""; diff --git a/openHAB/OpenHABSitemapViewController.swift b/openHAB/OpenHABSitemapViewController.swift index ff5c33bd1..5c479c3dd 100644 --- a/openHAB/OpenHABSitemapViewController.swift +++ b/openHAB/OpenHABSitemapViewController.swift @@ -22,6 +22,17 @@ import SafariServices import SwiftUI import UIKit +enum OpenHABSitemapError: LocalizedError { + case noActiveConnection + + var errorDescription: String? { + switch self { + case .noActiveConnection: + return NSLocalizedString("no_active_connection", comment: "No active connection available.") + } + } +} + // swiftlint:disable type_body_length class OpenHABSitemapViewController: OpenHABViewController { var pageUrl = "" @@ -368,11 +379,12 @@ extension OpenHABSitemapViewController { func selectSitemap() { Task { do { - logger.debug("Running selectSitemap for URL: \(NetworkTracker.shared.activeConnection?.configuration.url ?? "")") - - openAPIService = OpenAPIService( - connectionConfiguration: NetworkTracker.shared.activeConnection!.configuration) + guard let activeConnection = NetworkTracker.shared.activeConnection else { + throw OpenHABSitemapError.noActiveConnection + } + logger.debug("Running selectSitemap for URL: \(activeConnection.configuration.url)") + openAPIService = OpenAPIService(connectionConfiguration: activeConnection.configuration) sitemaps = try await openAPIService?.openHABSitemaps() ?? [] guard let openAPIService else { @@ -407,6 +419,16 @@ extension OpenHABSitemapViewController { widgetTableView.reloadData() } catch _ as OpenAPIServiceError { logger.debug("OpenAPIService Error on OpenHABSitemapViewController") + } catch let error as OpenHABSitemapError { + logger.error("OpenHABSitemap Error: \(error.localizedDescription)") + DispatchQueue.main.async { + self.showPopupMessage( + seconds: 5, + title: NSLocalizedString("error", comment: ""), + message: error.localizedDescription, + theme: .error + ) + } } catch { os_log("%{PUBLIC}@", log: .default, type: .error, error.localizedDescription) DispatchQueue.main.async { diff --git a/openHAB/Resources/en.lproj/Localizable.strings b/openHAB/Resources/en.lproj/Localizable.strings index 7c6d649e9..70690af13 100644 --- a/openHAB/Resources/en.lproj/Localizable.strings +++ b/openHAB/Resources/en.lproj/Localizable.strings @@ -78,3 +78,4 @@ "about_settings" = "About"; "uselastpath_settings" = "Use last visited path?"; "always_allow_webrtc" = "Always Allow WebRTC"; +"no_active_connection" = "No active connection available. Please check your settings."; From 0d735816d7deccac429133921a66a7e85b9e549f Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Sat, 26 Apr 2025 08:59:51 +0200 Subject: [PATCH 148/476] Make OpenAPIService init throwing Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../OpenHABCore/Util/NetworkTracker.swift | 31 ++++++---- .../OpenHABCore/Util/OpenAPIService.swift | 5 +- .../Util/OpenHABSitemapError.swift | 26 ++++++++ openHAB/DrawerView.swift | 45 ++++++++------ openHAB/OpenHABSitemapViewController.swift | 60 +++++++++++-------- openHAB/SettingsView/SettingsView.swift | 4 +- .../SingleConnectionSettingsView.swift | 2 +- openHABWatch/Domain/UserData.swift | 2 +- 8 files changed, 114 insertions(+), 61 deletions(-) create mode 100644 OpenHABCore/Sources/OpenHABCore/Util/OpenHABSitemapError.swift diff --git a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift index ec6640d6f..d7b29cade 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift @@ -55,21 +55,21 @@ public enum NetworkTrackerError: Error, CustomDebugStringConvertible { // Avoid memory corruption errors like unrecognized selector. public actor ConnectionPool { private var services: [ConnectionConfiguration: OpenAPIServiceProtocol] = [:] - private let serviceFactory: (ConnectionConfiguration) -> OpenAPIServiceProtocol + private let serviceFactory: (ConnectionConfiguration) throws -> OpenAPIServiceProtocol // Initializer allowing the injection of mocked OpenAPIServiceProtocol - init(serviceFactory: @escaping (ConnectionConfiguration) -> OpenAPIServiceProtocol = { - OpenAPIService(connectionConfiguration: $0, serviceConfiguration: .shortTerm) + init(serviceFactory: @escaping (ConnectionConfiguration) throws -> OpenAPIServiceProtocol = { + try OpenAPIService(connectionConfiguration: $0, serviceConfiguration: .shortTerm) }) { self.serviceFactory = serviceFactory } @discardableResult - func getOrCreateService(for configuration: ConnectionConfiguration) async -> OpenAPIServiceProtocol { + func getOrCreateService(for configuration: ConnectionConfiguration) async throws -> OpenAPIServiceProtocol { if let existing = services[configuration] { return existing } - let newService = serviceFactory(configuration) + let newService = try serviceFactory(configuration) services[configuration] = newService return newService } @@ -163,7 +163,12 @@ public final class NetworkTracker: ObservableObject { self.connectionConfigurations = connectionConfigurations Task { for configuration in connectionConfigurations { - await connectionPool.getOrCreateService(for: configuration) + do { + _ = try await connectionPool.getOrCreateService(for: configuration) + } catch { + logger.error("Failed to create service for config: \(configuration.url, privacy: .public) — \(error.localizedDescription)") + // Optionally: show a UI popup or skip to next config + } } await setActiveConnection(nil) await attemptConnection() @@ -295,7 +300,7 @@ public final class NetworkTracker: ObservableObject { do { logger.info("testConnection for \(configuration.url)") - let connection = await connectionPool.getOrCreateService(for: configuration) + let connection = try await connectionPool.getOrCreateService(for: configuration) let version = try await connection.getRootVersion() let connectionInfo = ConnectionInfo(configuration: configuration, version: version) @@ -390,42 +395,42 @@ public extension NetworkTracker { func send(to item: String, command: String) async throws { guard let activeConnection = await NetworkTracker.shared.waitForActiveConnection() else { return } let configuration = activeConnection.configuration - let service = await connectionPool.getOrCreateService(for: configuration) + let service = try await connectionPool.getOrCreateService(for: configuration) try await service.sendItemCommand(itemname: item, command: command) } func updateState(for item: OpenHABItem, state: String) async throws { guard let activeConnection = await NetworkTracker.shared.waitForActiveConnection() else { return } let configuration = activeConnection.configuration - let service = await connectionPool.getOrCreateService(for: configuration) + let service = try await connectionPool.getOrCreateService(for: configuration) try await service.updateItemState(itemname: item.name, with: state) } func getItems() async throws -> [OpenHABItem] { guard let activeConnection = await NetworkTracker.shared.waitForActiveConnection() else { return [] } let configuration = activeConnection.configuration - let service = await connectionPool.getOrCreateService(for: configuration) + let service = try await connectionPool.getOrCreateService(for: configuration) return try await service.getItems() } func getItemByName(id: String) async throws -> OpenHABItem? { guard let activeConnection = await NetworkTracker.shared.waitForActiveConnection() else { return nil } let configuration = activeConnection.configuration - let service = await connectionPool.getOrCreateService(for: configuration) + let service = try await connectionPool.getOrCreateService(for: configuration) return try await service.getItemByName(id: id) } func pollDataForPage(sitemapname: String, pageId: String = "", longPolling: Bool = false) async throws -> OpenHABPage? { guard let activeConnection = await NetworkTracker.shared.waitForActiveConnection() else { return nil } let configuration = activeConnection.configuration - let service = await connectionPool.getOrCreateService(for: configuration) + let service = try await connectionPool.getOrCreateService(for: configuration) return try await service.pollDataForPage(sitemapname: sitemapname, pageId: pageId, longPolling: longPolling) } func runNow(ruleUID: String, payload: [String: String]) async throws { guard let activeConnection = await NetworkTracker.shared.waitForActiveConnection() else { throw NetworkTrackerError.noActiveConnection } let configuration = activeConnection.configuration - let service = await connectionPool.getOrCreateService(for: configuration) + let service = try await connectionPool.getOrCreateService(for: configuration) try await service.runNow(ruleUID: ruleUID, payload: payload) } } diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift index b8fbfaf11..e406cbbd0 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift @@ -58,7 +58,10 @@ public actor OpenAPIService { } public init(connectionConfiguration: ConnectionConfiguration, - serviceConfiguration: OpenAPIServiceConfiguration = .asDefault) { + serviceConfiguration: OpenAPIServiceConfiguration = .asDefault) throws { + guard !connectionConfiguration.url.isEmpty else { + throw OpenHABSitemapError.invalidConnectionConfiguration + } self.connectionConfiguration = connectionConfiguration let delegate = OpenAPIServiceDelegate(with: connectionConfiguration) let config = URLSessionConfiguration.default diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OpenHABSitemapError.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABSitemapError.swift new file mode 100644 index 000000000..66fe17d08 --- /dev/null +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABSitemapError.swift @@ -0,0 +1,26 @@ +// Copyright (c) 2010-2025 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 Foundation + +public enum OpenHABSitemapError: LocalizedError { + case noActiveConnection + case invalidConnectionConfiguration + + public var errorDescription: String? { + switch self { + case .noActiveConnection: + NSLocalizedString("no_active_connection", comment: "No active connection available.") + case .invalidConnectionConfiguration: + NSLocalizedString("invalid_connection_configuration", comment: "Invalid connection configuration.") + } + } +} diff --git a/openHAB/DrawerView.swift b/openHAB/DrawerView.swift index acba4d1fa..00d1078fc 100644 --- a/openHAB/DrawerView.swift +++ b/openHAB/DrawerView.swift @@ -16,6 +16,8 @@ import SafariServices import SFSafeSymbols import SwiftUI +private let logger = Logger(subsystem: "org.openhab.app", category: "DrawerView") + enum DrawerViewError: Error, CustomDebugStringConvertible { case noRootURL @@ -282,29 +284,38 @@ struct DrawerView: View { private func updateSitemapsAndUITiles(activeConnection: ConnectionInfo?) async { guard let activeConnection else { return } - let openAPIService = OpenAPIService(connectionConfiguration: activeConnection.configuration) - do { - sitemaps = try await openAPIService.openHABSitemaps() - if sitemaps.last?.name == "_default", sitemaps.count > 1 { - sitemaps = Array(sitemaps.dropLast()) + let openAPIService = try OpenAPIService(connectionConfiguration: activeConnection.configuration) + + do { + sitemaps = try await openAPIService.openHABSitemaps() + if sitemaps.last?.name == "_default", sitemaps.count > 1 { + sitemaps = Array(sitemaps.dropLast()) + } + + switch SortSitemapsOrder(rawValue: Preferences.sortSitemapsby) ?? .label { + case .label: + sitemaps.sort { $0.label < $1.label } + case .name: + sitemaps.sort { $0.name < $1.name } + } + + } catch { + logger.error("Failed to fetch sitemaps: \(error.localizedDescription)") + sitemaps = [] } - // Sort the sitemaps according to Settings selection. - switch SortSitemapsOrder(rawValue: Preferences.sortSitemapsby) ?? .label { - case .label: sitemaps.sort { $0.label < $1.label } - case .name: sitemaps.sort { $0.name < $1.name } + do { + uiTiles = try await openAPIService.getUITiles() + logger.info("Fetched UI tiles successfully") + } catch { + logger.error("Failed to fetch UI tiles: \(error.localizedDescription)") + uiTiles = [] } - } catch { - os_log("%{PUBLIC}@", log: .default, type: .error, error.localizedDescription) - sitemaps = [] - } - do { - uiTiles = try await openAPIService.getUITiles() - os_log("ui tiles response", log: .viewCycle, type: .info) } catch { - os_log("%{PUBLIC}@", log: .default, type: .error, error.localizedDescription) + logger.error("Failed to initialize OpenAPIService: \(error.localizedDescription)") + sitemaps = [] uiTiles = [] } } diff --git a/openHAB/OpenHABSitemapViewController.swift b/openHAB/OpenHABSitemapViewController.swift index 5c479c3dd..6bce8eae4 100644 --- a/openHAB/OpenHABSitemapViewController.swift +++ b/openHAB/OpenHABSitemapViewController.swift @@ -22,19 +22,8 @@ import SafariServices import SwiftUI import UIKit -enum OpenHABSitemapError: LocalizedError { - case noActiveConnection - - var errorDescription: String? { - switch self { - case .noActiveConnection: - return NSLocalizedString("no_active_connection", comment: "No active connection available.") - } - } -} - // swiftlint:disable type_body_length -class OpenHABSitemapViewController: OpenHABViewController { +class OpenHABSitemapViewController: OpenHABViewController, UISearchControllerDelegate { var pageUrl = "" private var iconType: IconType = .png private var openHABRootUrl = "" @@ -53,7 +42,7 @@ class OpenHABSitemapViewController: OpenHABViewController { private var pageNetworkStatusAvailable = false private var refreshControl: UIRefreshControl? private var filteredPage: OpenHABPage? - private let search = UISearchController(searchResultsController: nil) + private let searchController = UISearchController(searchResultsController: nil) private var isUserInteracting = false private var isWaitingToReload = false // Properties in your view controller: @@ -81,11 +70,11 @@ class OpenHABSitemapViewController: OpenHABViewController { var searchBarIsEmpty: Bool { // Returns true if the text is empty or nil - search.searchBar.text?.isEmpty ?? true + searchController.searchBar.text?.isEmpty ?? true } var isFiltering: Bool { - search.isActive && !searchBarIsEmpty + searchController.isActive && !searchBarIsEmpty } private var openAPIService: OpenAPIService? @@ -104,7 +93,11 @@ class OpenHABSitemapViewController: OpenHABViewController { widgetTableView.tableFooterView = UIView() guard let initialConfiguration = activeConnectionInfo?.configuration else { return } - openAPIService = OpenAPIService(connectionConfiguration: initialConfiguration) + do { + openAPIService = try OpenAPIService(connectionConfiguration: initialConfiguration) + } catch { + logger.error("Could not create OpenAPIService: \(error)") + } guard let openAPIService else { return } // ✅ Initialize PageLoader @@ -123,9 +116,12 @@ class OpenHABSitemapViewController: OpenHABViewController { widgetTableView.refreshControl = refreshControl } - search.searchResultsUpdater = self - search.obscuresBackgroundDuringPresentation = false - search.searchBar.placeholder = NSLocalizedString("search_items", comment: "") + searchController.delegate = self + searchController.searchResultsUpdater = self + searchController.obscuresBackgroundDuringPresentation = false + searchController.searchBar.autocapitalizationType = .none + searchController.searchBar.delegate = self // Monitor when the search button is tapped. + searchController.searchBar.placeholder = NSLocalizedString("search_items", comment: "") definesPresentationContext = true #if DEBUG @@ -141,7 +137,7 @@ class OpenHABSitemapViewController: OpenHABViewController { // NOTE: workaround for https://github.com/openhab/openhab-ios/issues/420 if parent?.navigationItem.searchController == nil { DispatchQueue.main.async { - self.parent?.navigationItem.searchController = self.search + self.parent?.navigationItem.searchController = self.searchController } } } @@ -195,8 +191,8 @@ class OpenHABSitemapViewController: OpenHABViewController { super.viewWillDisappear(animated) if #unavailable(iOS 13.0) { - if animated, !search.isActive, !search.isEditing, navigationController.map({ $0.viewControllers.last != self }) ?? false, - let searchBarSuperview = search.searchBar.superview, + if animated, !searchController.isActive, !searchController.isEditing, navigationController.map({ $0.viewControllers.last != self }) ?? false, + let searchBarSuperview = searchController.searchBar.superview, let searchBarHeightConstraint = searchBarSuperview.constraints.first(where: { $0.firstAttribute == .height && $0.secondItem == nil @@ -355,7 +351,7 @@ extension OpenHABSitemapViewController { currentPage = page if isFiltering { - filterContentForSearchText(search.searchBar.text) + filterContentForSearchText(searchController.searchBar.text) } currentPage?.sendCommand = { [weak self] item, command in @@ -384,7 +380,7 @@ extension OpenHABSitemapViewController { } logger.debug("Running selectSitemap for URL: \(activeConnection.configuration.url)") - openAPIService = OpenAPIService(connectionConfiguration: activeConnection.configuration) + openAPIService = try OpenAPIService(connectionConfiguration: activeConnection.configuration) sitemaps = try await openAPIService?.openHABSitemaps() ?? [] guard let openAPIService else { @@ -496,7 +492,7 @@ extension OpenHABSitemapViewController { // Initial page load if openAPIService == nil { - openAPIService = OpenAPIService( + openAPIService = try OpenAPIService( connectionConfiguration: NetworkTracker.shared.activeConnection!.configuration) } @@ -650,6 +646,18 @@ extension OpenHABSitemapViewController: UISearchResultsUpdating { } } +// MARK: - UISearchBarDelegate + +extension OpenHABSitemapViewController: UISearchBarDelegate { + func searchBarSearchButtonClicked(_ searchBar: UISearchBar) { + searchBar.resignFirstResponder() + } + +// func searchBar(_ searchBar: UISearchBar, selectedScopeButtonIndexDidChange selectedScope: Int) { +// updateSearchResults(for: searchController) +// } +} + // MARK: - ColorPickerCellDelegate extension OpenHABSitemapViewController: ColorPickerCellDelegate { @@ -675,6 +683,7 @@ extension OpenHABSitemapViewController: UITableViewDelegate, UITableViewDataSour return filteredPage?.widgets.count ?? 0 } return currentPage?.widgets.count ?? 0 +// relevantPage?.widgets.count ?? 0 } else { return 0 } @@ -799,7 +808,6 @@ extension OpenHABSitemapViewController: UITableViewDelegate, UITableViewDataSour if let linkedPage = widget.linkedPage { logger.info("Selected linked page: \(linkedPage.link)") stopAllTasks() -// pageId = linkedPage.pageId let newViewController = (storyboard?.instantiateViewController(withIdentifier: "OpenHABPageViewController") as? OpenHABSitemapViewController)! newViewController.title = linkedPage.title.components(separatedBy: "[")[0] newViewController.pageId = linkedPage.pageId diff --git a/openHAB/SettingsView/SettingsView.swift b/openHAB/SettingsView/SettingsView.swift index 3e6ecb6df..8ba9f18eb 100644 --- a/openHAB/SettingsView/SettingsView.swift +++ b/openHAB/SettingsView/SettingsView.swift @@ -95,9 +95,9 @@ struct SettingsView: View { } private func updateSitemaps(activeConfiguration: ConnectionConfiguration) async { - let openAPIService = OpenAPIService(connectionConfiguration: activeConfiguration) - do { + let openAPIService = try OpenAPIService(connectionConfiguration: activeConfiguration) + sitemaps = try await openAPIService.openHABSitemaps() if sitemaps.last?.name == "_default", sitemaps.count > 1 { sitemaps = Array(sitemaps.dropLast()) diff --git a/openHAB/SettingsView/SingleConnectionSettingsView.swift b/openHAB/SettingsView/SingleConnectionSettingsView.swift index a00c4245f..f42c03a75 100644 --- a/openHAB/SettingsView/SingleConnectionSettingsView.swift +++ b/openHAB/SettingsView/SingleConnectionSettingsView.swift @@ -136,7 +136,7 @@ struct SingleConnectionSettingsView: View { private func testConnection() async throws { try connectionConfig.url.testAsValidOpenHABURL() - let connection = OpenAPIService(connectionConfiguration: connectionConfig, serviceConfiguration: .shortTerm) + let connection = try OpenAPIService(connectionConfiguration: connectionConfig, serviceConfiguration: .shortTerm) try await connection.getRootVersion() } diff --git a/openHABWatch/Domain/UserData.swift b/openHABWatch/Domain/UserData.swift index b157a91e4..501ed98e7 100644 --- a/openHABWatch/Domain/UserData.swift +++ b/openHABWatch/Domain/UserData.swift @@ -154,7 +154,7 @@ final class UserData: ObservableObject { pageHandlingTask = Task { do { isLoadingSitemap = true - let service = OpenAPIService(connectionConfiguration: NetworkTracker.shared.activeConnection?.configuration ?? ConnectionConfiguration.remoteDefault) + let service = try OpenAPIService(connectionConfiguration: NetworkTracker.shared.activeConnection?.configuration ?? ConnectionConfiguration.remoteDefault) let initialPage = try await service.pollDataForPage(sitemapname: sitemapName, pageId: pageId, longPolling: false) try Task.checkCancellation() From 12ff79bd1f2d0d1d0df7621453cf97bb64671da0 Mon Sep 17 00:00:00 2001 From: Dan Cunningham Date: Sat, 26 Apr 2025 09:55:38 -0700 Subject: [PATCH 149/476] Fixes WebRTC permissions not working Signed-off-by: Dan Cunningham --- openHAB/OpenHABWebViewController.swift | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/openHAB/OpenHABWebViewController.swift b/openHAB/OpenHABWebViewController.swift index 38e206521..c7e6f2a1a 100644 --- a/openHAB/OpenHABWebViewController.swift +++ b/openHAB/OpenHABWebViewController.swift @@ -327,7 +327,6 @@ extension OpenHABWebViewController: WKNavigationDelegate { func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse) async -> WKNavigationResponsePolicy { if let response = navigationResponse.response as? HTTPURLResponse { -// dump(response.allHeaderFields) logger.info("navigationResponse: \(response.statusCode)") if response.statusCode >= 400 { @@ -384,10 +383,6 @@ extension OpenHABWebViewController: WKNavigationDelegate { logger.warning("webViewWebContentProcessDidTerminate - reloading view") reloadView() } - - func webView(_ webView: WKWebView, requestMediaCapturePermissionFor origin: WKSecurityOrigin, initiatedByFrame frame: WKFrameInfo, type: WKMediaCaptureType, decisionHandler: @escaping (WKPermissionDecision) -> Void) { - decisionHandler(Preferences.alwaysAllowWebRTC ? .grant : .prompt) - } } extension OpenHABWebViewController: WKUIDelegate { @@ -406,4 +401,8 @@ extension OpenHABWebViewController: WKUIDelegate { return nil } + + func webView(_ webView: WKWebView, requestMediaCapturePermissionFor origin: WKSecurityOrigin, initiatedByFrame frame: WKFrameInfo, type: WKMediaCaptureType, decisionHandler: @escaping @MainActor @Sendable (WKPermissionDecision) -> Void) { + decisionHandler(Preferences.alwaysAllowWebRTC ? .grant : .prompt) + } } From 299c643650357f27c115985148177b5fe3a0a2d4 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Sun, 27 Apr 2025 21:58:20 +0200 Subject: [PATCH 150/476] Migrate OpenHABSitemapViewController to SwiftUI - Introduce SitemapPageView, SitemapPageViewModel, WidgetRow as a first row, Wrapping SitemapPageView in HostingViewController. Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- openHAB.xcodeproj/project.pbxproj | 12 ++ openHAB/OpenHABRootViewController.swift | 41 ++++-- openHAB/OpenHABSitemapViewController.swift | 65 +++++----- openHAB/OpenHABViewController.swift | 12 +- openHAB/SitemapPageView.swift | 46 +++++++ openHAB/SitemapPageViewModel.swift | 138 +++++++++++++++++++++ openHAB/WidgetRow.swift | 39 ++++++ 7 files changed, 313 insertions(+), 40 deletions(-) create mode 100644 openHAB/SitemapPageView.swift create mode 100644 openHAB/SitemapPageViewModel.swift create mode 100644 openHAB/WidgetRow.swift diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index 93c55e8ce..c5f4fe2b2 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -99,6 +99,9 @@ DA50C7BF2B0A65300009F716 /* SliderWithSwitchSupportUITableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA50C7BE2B0A652F0009F716 /* SliderWithSwitchSupportUITableViewCell.swift */; }; DA5ED9BE2C850955004875E0 /* ClientCertificatesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5ED9BD2C850955004875E0 /* ClientCertificatesViewModel.swift */; }; DA5ED9C02C8509C2004875E0 /* ClientCertificatesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5ED9BF2C8509C2004875E0 /* ClientCertificatesView.swift */; }; + DA64ACA62DBEAD5600294F60 /* SitemapPageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA64ACA52DBEAD5600294F60 /* SitemapPageViewModel.swift */; }; + DA64ACA82DBEAD8300294F60 /* SitemapPageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA64ACA72DBEAD8300294F60 /* SitemapPageView.swift */; }; + DA64ACAA2DBEADB000294F60 /* WidgetRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA64ACA92DBEADB000294F60 /* WidgetRow.swift */; }; DA65871F236F83CE007E2E7F /* UserDefaultsExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA65871E236F83CD007E2E7F /* UserDefaultsExtension.swift */; }; DA6B2EEF2C861BC900DF77CF /* DrawerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA6B2EEE2C861BC900DF77CF /* DrawerView.swift */; }; DA6B2EF12C87B59000DF77CF /* NotificationsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA6B2EF02C87B59000DF77CF /* NotificationsView.swift */; }; @@ -412,6 +415,9 @@ DA50C7BE2B0A652F0009F716 /* SliderWithSwitchSupportUITableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SliderWithSwitchSupportUITableViewCell.swift; sourceTree = ""; }; DA5ED9BD2C850955004875E0 /* ClientCertificatesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientCertificatesViewModel.swift; sourceTree = ""; }; DA5ED9BF2C8509C2004875E0 /* ClientCertificatesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientCertificatesView.swift; sourceTree = ""; }; + DA64ACA52DBEAD5600294F60 /* SitemapPageViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SitemapPageViewModel.swift; sourceTree = ""; }; + DA64ACA72DBEAD8300294F60 /* SitemapPageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SitemapPageView.swift; sourceTree = ""; }; + DA64ACA92DBEADB000294F60 /* WidgetRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetRow.swift; sourceTree = ""; }; DA65871E236F83CD007E2E7F /* UserDefaultsExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaultsExtension.swift; sourceTree = ""; }; DA6B2EEE2C861BC900DF77CF /* DrawerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DrawerView.swift; sourceTree = ""; }; DA6B2EF02C87B59000DF77CF /* NotificationsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsView.swift; sourceTree = ""; }; @@ -927,6 +933,8 @@ 1224F78B228A89E300750965 /* Watch */, DF4B84101886DA9900F34902 /* Widgets */, DFFD8FCE18EDBD30003B502A /* Util */, + DA64ACA72DBEAD8300294F60 /* SitemapPageView.swift */, + DA64ACA92DBEADB000294F60 /* WidgetRow.swift */, ); name = UI; sourceTree = ""; @@ -1056,6 +1064,7 @@ isa = PBXGroup; children = ( DA5ED9BD2C850955004875E0 /* ClientCertificatesViewModel.swift */, + DA64ACA52DBEAD5600294F60 /* SitemapPageViewModel.swift */, ); name = Models; sourceTree = ""; @@ -1577,6 +1586,7 @@ 65570A7D2476D16A00D524EA /* OpenHABWebViewController.swift in Sources */, DA48001E2D837905009CF127 /* ApplicationSettingsView.swift in Sources */, DAF0A28B2C56E3A300A14A6A /* RollershutterCell.swift in Sources */, + DA64ACA62DBEAD5600294F60 /* SitemapPageViewModel.swift in Sources */, DF06F1FC18FEC2020011E7B9 /* ColorPickerViewController.swift in Sources */, DA4642322D7EE6CA006C3908 /* LoggerView.swift in Sources */, 1224F78F228A89FD00750965 /* WatchMessageService.swift in Sources */, @@ -1592,6 +1602,7 @@ DAA42BAA21DC983B00244B2A /* VideoUITableViewCell.swift in Sources */, DFB2623B18830A3600D3244D /* AppDelegate.swift in Sources */, DA6B2EF72C8B92E800DF77CF /* SelectionView.swift in Sources */, + DA64ACA82DBEAD8300294F60 /* SitemapPageView.swift in Sources */, DA4800212D839D3A009CF127 /* AnimatedSecureTextField.swift in Sources */, DAC131112DA3213100075AE2 /* SetSwitchStateIntentHandlerTests.swift in Sources */, DAC65FC7236EDF3900F4501E /* SpinnerViewController.swift in Sources */, @@ -1599,6 +1610,7 @@ DA6B2EF12C87B59000DF77CF /* NotificationsView.swift in Sources */, DA50C7BF2B0A65300009F716 /* SliderWithSwitchSupportUITableViewCell.swift in Sources */, DA5ED9BE2C850955004875E0 /* ClientCertificatesViewModel.swift in Sources */, + DA64ACAA2DBEADB000294F60 /* WidgetRow.swift in Sources */, DA21EAE22339621C001AB415 /* Throttler.swift in Sources */, DAF4F6C0222734D300C24876 /* NewImageUITableViewCell.swift in Sources */, DA6B2EEF2C861BC900DF77CF /* DrawerView.swift in Sources */, diff --git a/openHAB/OpenHABRootViewController.swift b/openHAB/OpenHABRootViewController.swift index 5923d6f00..87ea9b942 100644 --- a/openHAB/OpenHABRootViewController.swift +++ b/openHAB/OpenHABRootViewController.swift @@ -34,9 +34,38 @@ protocol ModalHandler: AnyObject { private let logger = Logger(subsystem: "org.openhab.UI", category: "OpenHABRootViewController") +class HostingSitemapViewController: UIHostingController, OpenHABViewable { + private let viewModel: SitemapPageViewModel + + init() { + let viewModel = SitemapPageViewModel() + self.viewModel = viewModel + super.init(rootView: SitemapPageView(viewModel: viewModel)) + } + + @available(*, unavailable) + @objc dynamic required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func viewName() -> String { "sitemap" } + + func reloadView() { + // Maybe call viewModel.reload() if you wire a viewModel refresh + Task { + await rootView.viewModel.reload() + } + } + + func pushSitemap(name: String, path: String?) async { + // Implement pushing logic into SitemapPageViewModel + await viewModel.pushSitemap(name: name, path: path) + } +} + // swiftlint:disable type_body_length class OpenHABRootViewController: UIViewController { - var currentView: OpenHABViewController! + var currentView: (UIViewController & OpenHABViewable)! var isDemoMode = false var cancellables = Set() @@ -46,11 +75,7 @@ class OpenHABRootViewController: UIViewController { return viewController }() - private lazy var sitemapViewController: OpenHABSitemapViewController = { - let storyboard = UIStoryboard(name: "Main", bundle: Bundle.main) - var viewController = storyboard.instantiateViewController(withIdentifier: "OpenHABPageViewController") as! OpenHABSitemapViewController - return viewController - }() + private lazy var sitemapViewController: (UIViewController & OpenHABViewable) = HostingSitemapViewController() private var activeConnection: ConnectionInfo? @@ -338,7 +363,7 @@ class OpenHABRootViewController: UIViewController { let path = String(firstMatch.1) os_log("navigateCommandAction path: %{PUBLIC}@", log: .notifications, type: .info, path) if path.starts(with: "/basicui/app?") { - if currentView != sitemapViewController { + if currentView !== sitemapViewController { switchView(target: .sitemap("")) } if let urlComponents = URLComponents(string: path) { @@ -503,7 +528,7 @@ class OpenHABRootViewController: UIViewController { webViewController } - if currentView != targetView { + if currentView !== targetView { if currentView != nil { removeView(viewController: currentView) } diff --git a/openHAB/OpenHABSitemapViewController.swift b/openHAB/OpenHABSitemapViewController.swift index 6bce8eae4..744b59a67 100644 --- a/openHAB/OpenHABSitemapViewController.swift +++ b/openHAB/OpenHABSitemapViewController.swift @@ -135,7 +135,7 @@ class OpenHABSitemapViewController: OpenHABViewController, UISearchControllerDel super.viewDidAppear(animated) // NOTE: workaround for https://github.com/openhab/openhab-ios/issues/420 - if parent?.navigationItem.searchController == nil { + if navigationItem.searchController == nil { DispatchQueue.main.async { self.parent?.navigationItem.searchController = self.searchController } @@ -290,6 +290,33 @@ class OpenHABSitemapViewController: OpenHABViewController, UISearchControllerDel override func viewName() -> String { "sitemap" } + + // This is mainly used for navigting to a specific sitemap and path from notifications + override func pushSitemap(name: String, path: String?) async { + do { + guard let activeConnection = await NetworkTracker.shared.waitForActiveConnection() else { + logger.error("pushSiteMap: No active connection available") + return + } + + logger.info("pushSitemap: pushing page") + + guard let newViewController = storyboard?.instantiateViewController(withIdentifier: "OpenHABPageViewController") as? OpenHABSitemapViewController else { + os_log("pushSitemap: Failed to instantiate OpenHABSitemapViewController", log: .default, type: .error) + return + } + let openHABUrl = activeConnection.configuration.url + + newViewController.pageUrl = path != nil + ? "\(openHABUrl)/rest/sitemaps/\(name)/\(path!)" + : "\(openHABUrl)/rest/sitemaps/\(name)" + newViewController.openHABRootUrl = openHABUrl + + navigationController?.pushViewController(newViewController, animated: true) + } catch { + os_log("pushSitemap: Error waiting for active connection: %{PUBLIC}@", log: .default, type: .error, error.localizedDescription) + } + } } extension OpenHABSitemapViewController: GenericUITableViewCellTouchEventDelegate { @@ -448,35 +475,6 @@ extension OpenHABSitemapViewController { } } - // This is mainly used for navigting to a specific sitemap and path from notifications - - // This is mainly used for navigating to a specific sitemap and path from notifications - func pushSitemap(name: String, path: String?) async { - do { - guard let activeConnection = await NetworkTracker.shared.waitForActiveConnection() else { - logger.error("pushSiteMap: No active connection available") - return - } - - logger.info("pushSitemap: pushing page") - - guard let newViewController = storyboard?.instantiateViewController(withIdentifier: "OpenHABPageViewController") as? OpenHABSitemapViewController else { - os_log("pushSitemap: Failed to instantiate OpenHABSitemapViewController", log: .default, type: .error) - return - } - let openHABUrl = activeConnection.configuration.url - - newViewController.pageUrl = path != nil - ? "\(openHABUrl)/rest/sitemaps/\(name)/\(path!)" - : "\(openHABUrl)/rest/sitemaps/\(name)" - newViewController.openHABRootUrl = openHABUrl - - navigationController?.pushViewController(newViewController, animated: true) - } catch { - os_log("pushSitemap: Error waiting for active connection: %{PUBLIC}@", log: .default, type: .error, error.localizedDescription) - } - } - func startPageHandling() { pageHandlingTask?.cancel() @@ -619,10 +617,14 @@ extension OpenHABSitemapViewController { filteredPage = currentPage?.filter { $0.label.lowercased().contains(searchText.lowercased()) && $0.type != .frame } + filteredPage?.sendCommand = { [weak self] item, command in self?.sendCommand(item, commandToSend: command) } - widgetTableView.reloadData() + + UIView.performWithoutAnimation { + widgetTableView.reloadData() + } } func sendCommand(_ item: OpenHABItem?, commandToSend command: String?) { @@ -650,6 +652,7 @@ extension OpenHABSitemapViewController: UISearchResultsUpdating { extension OpenHABSitemapViewController: UISearchBarDelegate { func searchBarSearchButtonClicked(_ searchBar: UISearchBar) { + filterContentForSearchText(searchBar.text) searchBar.resignFirstResponder() } diff --git a/openHAB/OpenHABViewController.swift b/openHAB/OpenHABViewController.swift index c7f43991b..14526e007 100644 --- a/openHAB/OpenHABViewController.swift +++ b/openHAB/OpenHABViewController.swift @@ -15,7 +15,13 @@ import SideMenu import SwiftMessages import UIKit -class OpenHABViewController: UIViewController { +protocol OpenHABViewable: AnyObject { + func reloadView() + func viewName() -> String + func pushSitemap(name: String, path: String?) async +} + +class OpenHABViewController: UIViewController, OpenHABViewable { var trackerCancellables = Set() var activeTasks = Set>() @@ -72,6 +78,10 @@ class OpenHABViewController: UIViewController { func viewName() -> String { "default" } + + func pushSitemap(name: String, path: String?) async { + // No-op for non-sitemap view controllers + } } // MARK: - ServerCertificateManagerDelegate diff --git a/openHAB/SitemapPageView.swift b/openHAB/SitemapPageView.swift new file mode 100644 index 000000000..e88e7cfef --- /dev/null +++ b/openHAB/SitemapPageView.swift @@ -0,0 +1,46 @@ +// Copyright (c) 2010-2025 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 SwiftUI + +struct SitemapPageView: View { + @StateObject public var viewModel = SitemapPageViewModel() + + var body: some View { + NavigationStack { + List(viewModel.relevantWidgets) { widget in + WidgetRow(widget: widget) + .onTapGesture { + viewModel.widgetTapped(widget) + } + } + .navigationTitle(viewModel.pageTitle) + .searchable(text: $viewModel.searchText) + .refreshable { + await viewModel.reload() + } + .task { + await viewModel.startPolling() + } + .alert("Error", isPresented: .constant(viewModel.error != nil), actions: { + Button("OK", role: .cancel) {} + }, message: { + if let error = viewModel.error { + Text(error.localizedDescription) + } + }) + } + } + + init(viewModel: SitemapPageViewModel) { + _viewModel = StateObject(wrappedValue: viewModel) + } +} diff --git a/openHAB/SitemapPageViewModel.swift b/openHAB/SitemapPageViewModel.swift new file mode 100644 index 000000000..ca347d257 --- /dev/null +++ b/openHAB/SitemapPageViewModel.swift @@ -0,0 +1,138 @@ +// Copyright (c) 2010-2025 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 Combine +import OpenAPIRuntime +import OpenHABCore +import SwiftUI + +@MainActor +class SitemapPageViewModel: ObservableObject { + @Published var currentPage: OpenHABPage? + @Published var filteredWidgets: [OpenHABWidget] = [] + @Published var searchText: String = "" + @Published var error: LocalizedError? + @Published var isLoading: Bool = false + + private var openAPIService: OpenAPIService? + private var activeConnectionInfo: ConnectionInfo? + private var pageHandlingTask: Task? + private var defaultSitemap: String = "" + private var pageId: String = "" + + init() { + loadSettings() + } + + var relevantWidgets: [OpenHABWidget] { + if searchText.isEmpty { + currentPage?.widgets ?? [] + } else { + filteredWidgets + } + } + + var pageTitle: String { + currentPage?.title.components(separatedBy: "[")[0] ?? "Sitemap" + } + + func loadSettings() { + defaultSitemap = Preferences.defaultSitemap + } + + func startPolling() async { + guard pageHandlingTask == nil else { return } + + pageHandlingTask = Task { + await reload() + + while !Task.isCancelled { + do { + try await Task.sleep(nanoseconds: 20 * 1_000_000_000) // 20s polling + try await loadCurrentPage() + } catch { + self.error = error as? LocalizedError + } + } + } + } + + func reload() async { + do { + isLoading = true + try await setupConnection() + try await loadCurrentPage() + } catch { + self.error = error as? LocalizedError + } + isLoading = false + } + + private func setupConnection() async throws { + guard let activeConnection = await NetworkTracker.shared.waitForActiveConnection() else { + throw SitemapPageError.noActiveConnection + } + + activeConnectionInfo = activeConnection + openAPIService = try OpenAPIService(connectionConfiguration: activeConnection.configuration) + } + + private func loadCurrentPage() async throws { + guard let service = openAPIService else { throw SitemapPageError.serviceUnavailable } + + let page = try await service.pollDataForPage( + sitemapname: defaultSitemap, + pageId: pageId, + longPolling: false + ) + + currentPage = page + filterWidgets() + } + + func filterWidgets() { + if searchText.isEmpty { + filteredWidgets = [] + } else { + filteredWidgets = currentPage?.widgets.filter { + $0.label.lowercased().contains(searchText.lowercased()) && $0.type != .frame + } ?? [] + } + } + + func widgetTapped(_ widget: OpenHABWidget) { + if let linkedPage = widget.linkedPage { + // Push a new view (handled in the SwiftUI view) + } + // handle other widget types + } + + @MainActor + func pushSitemap(name: String, path: String?) async { + defaultSitemap = name + pageId = path ?? "" + await reload() + } +} + +enum SitemapPageError: LocalizedError { + case noActiveConnection + case serviceUnavailable + + var errorDescription: String? { + switch self { + case .noActiveConnection: + "No active connection available." + case .serviceUnavailable: + "Service unavailable." + } + } +} diff --git a/openHAB/WidgetRow.swift b/openHAB/WidgetRow.swift new file mode 100644 index 000000000..1b705200f --- /dev/null +++ b/openHAB/WidgetRow.swift @@ -0,0 +1,39 @@ +// Copyright (c) 2010-2025 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 Kingfisher +import OpenHABCore +import os.log +import SwiftUI + +struct WidgetRow: View { + let widget: OpenHABWidget + + var body: some View { + HStack { +// if let iconUrl = widget.iconUrl { +// KFImage(iconUrl) +// .resizable() +// .scaledToFit() +// .frame(width: 30, height: 30) +// } + VStack(alignment: .leading) { + Text(widget.labelText ?? "") + .font(.headline) + if let value = widget.labelValue, !value.isEmpty { + Text(value) + .font(.subheadline) + .foregroundColor(.secondary) + } + } + } + } +} From 88a06d23e809d1761c40b2c2f19c323923a188c0 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Sun, 27 Apr 2025 22:36:44 +0200 Subject: [PATCH 151/476] Address failing tests Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../Tests/OpenHABCoreTests/ConnectionPoolTests.swift | 6 +++--- openHAB/Resources/de.lproj/Localizable.strings | 2 ++ openHAB/Resources/es.lproj/Localizable.strings | 1 + openHAB/Resources/fi.lproj/Localizable.strings | 1 + openHAB/Resources/fr.lproj/Localizable.strings | 1 + openHAB/Resources/it.lproj/Localizable.strings | 1 + openHAB/Resources/nb.lproj/Localizable.strings | 1 + openHAB/Resources/nl.lproj/Localizable.strings | 1 + openHAB/Resources/ru.lproj/Localizable.strings | 1 + 9 files changed, 12 insertions(+), 3 deletions(-) diff --git a/OpenHABCore/Tests/OpenHABCoreTests/ConnectionPoolTests.swift b/OpenHABCore/Tests/OpenHABCoreTests/ConnectionPoolTests.swift index 4a86e68f1..75d5fd61a 100644 --- a/OpenHABCore/Tests/OpenHABCoreTests/ConnectionPoolTests.swift +++ b/OpenHABCore/Tests/OpenHABCoreTests/ConnectionPoolTests.swift @@ -15,12 +15,12 @@ import Foundation import XCTest final class ConnectionPoolTests: XCTestCase { - func testGetOrCreateServiceReturnsSameInstance() async { + func testGetOrCreateServiceReturnsSameInstance() async throws { let pool = ConnectionPool() let config = ConnectionConfiguration(url: "http://test", username: "", password: "", priority: 1) - let service1 = await pool.getOrCreateService(for: config) - let service2 = await pool.getOrCreateService(for: config) + let service1 = try await pool.getOrCreateService(for: config) + let service2 = try await pool.getOrCreateService(for: config) XCTAssertTrue(service1 === service2) } diff --git a/openHAB/Resources/de.lproj/Localizable.strings b/openHAB/Resources/de.lproj/Localizable.strings index 221d1bdeb..430832131 100644 --- a/openHAB/Resources/de.lproj/Localizable.strings +++ b/openHAB/Resources/de.lproj/Localizable.strings @@ -78,3 +78,5 @@ "about_settings" = "Über"; "uselastpath_settings" = "Zuletzt besuchten Pfad verwenden?"; "always_allow_webrtc" = "WebRTC immer erlauben"; +"no_active_connection" = "Keine aktive Verbindung vorhanden. Bitte Einstellungen prüfen"; + diff --git a/openHAB/Resources/es.lproj/Localizable.strings b/openHAB/Resources/es.lproj/Localizable.strings index 463c908a4..e2644b0e3 100644 --- a/openHAB/Resources/es.lproj/Localizable.strings +++ b/openHAB/Resources/es.lproj/Localizable.strings @@ -78,3 +78,4 @@ "about_settings" = "Acerca de"; "uselastpath_settings" = "¿Usar la última ruta visitada?"; "always_allow_webrtc" = "Consenti sempre WebRTC"; +"no_active_connection" = "No active connection available. Please check your settings."; diff --git a/openHAB/Resources/fi.lproj/Localizable.strings b/openHAB/Resources/fi.lproj/Localizable.strings index fad0af249..d66f16259 100644 --- a/openHAB/Resources/fi.lproj/Localizable.strings +++ b/openHAB/Resources/fi.lproj/Localizable.strings @@ -78,3 +78,4 @@ "about_settings" = "About"; "uselastpath_settings" = "Käytä edellistä polkua?"; "always_allow_webrtc" = "Always Allow WebRTC"; +"no_active_connection" = "No active connection available. Please check your settings."; diff --git a/openHAB/Resources/fr.lproj/Localizable.strings b/openHAB/Resources/fr.lproj/Localizable.strings index acac85857..f66077f37 100644 --- a/openHAB/Resources/fr.lproj/Localizable.strings +++ b/openHAB/Resources/fr.lproj/Localizable.strings @@ -78,3 +78,4 @@ "about_settings" = "A propos"; "uselastpath_settings" = "Utiliser le chemin de la dernière visite?"; "always_allow_webrtc" = "Toujours autoriser WebRTC"; +"no_active_connection" = "No active connection available. Please check your settings."; diff --git a/openHAB/Resources/it.lproj/Localizable.strings b/openHAB/Resources/it.lproj/Localizable.strings index c58dbb8ab..b644cc839 100644 --- a/openHAB/Resources/it.lproj/Localizable.strings +++ b/openHAB/Resources/it.lproj/Localizable.strings @@ -78,3 +78,4 @@ "about_settings" = "Info"; "uselastpath_settings" = "Usare L'Ultimo Percorso Visitato?"; "always_allow_webrtc" = "Consenti sempre WebRTC"; +"no_active_connection" = "No active connection available. Please check your settings."; diff --git a/openHAB/Resources/nb.lproj/Localizable.strings b/openHAB/Resources/nb.lproj/Localizable.strings index 41958b2db..94e3709da 100644 --- a/openHAB/Resources/nb.lproj/Localizable.strings +++ b/openHAB/Resources/nb.lproj/Localizable.strings @@ -78,3 +78,4 @@ "about_settings" = "Om"; "uselastpath_settings" = "Bruk sist brukte bane?"; "always_allow_webrtc" = "Always Allow WebRTC"; +"no_active_connection" = "No active connection available. Please check your settings."; diff --git a/openHAB/Resources/nl.lproj/Localizable.strings b/openHAB/Resources/nl.lproj/Localizable.strings index 67c26120d..a9c0fc7bf 100644 --- a/openHAB/Resources/nl.lproj/Localizable.strings +++ b/openHAB/Resources/nl.lproj/Localizable.strings @@ -78,3 +78,4 @@ "about_settings" = "Over"; "uselastpath_settings" = "Laatst bezochte pad gebruiken?"; "always_allow_webrtc" = "WebRTC altijd toestaan"; +"no_active_connection" = "No active connection available. Please check your settings."; diff --git a/openHAB/Resources/ru.lproj/Localizable.strings b/openHAB/Resources/ru.lproj/Localizable.strings index 05509fbfe..d21e88de1 100644 --- a/openHAB/Resources/ru.lproj/Localizable.strings +++ b/openHAB/Resources/ru.lproj/Localizable.strings @@ -78,3 +78,4 @@ "about_settings" = "About"; "uselastpath_settings" = "Use last visited path?"; "always_allow_webrtc" = "Always Allow WebRTC"; +"no_active_connection" = "No active connection available. Please check your settings."; From 10c7afdbe2d2a8c3ee488e327f394472f8fcd5cc Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Sun, 27 Apr 2025 22:36:44 +0200 Subject: [PATCH 152/476] Address failing tests Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../Tests/OpenHABCoreTests/ConnectionPoolTests.swift | 6 +++--- openHAB/Resources/de.lproj/Localizable.strings | 2 ++ openHAB/Resources/es.lproj/Localizable.strings | 1 + openHAB/Resources/fi.lproj/Localizable.strings | 1 + openHAB/Resources/fr.lproj/Localizable.strings | 1 + openHAB/Resources/it.lproj/Localizable.strings | 1 + openHAB/Resources/nb.lproj/Localizable.strings | 1 + openHAB/Resources/nl.lproj/Localizable.strings | 1 + openHAB/Resources/ru.lproj/Localizable.strings | 1 + 9 files changed, 12 insertions(+), 3 deletions(-) diff --git a/OpenHABCore/Tests/OpenHABCoreTests/ConnectionPoolTests.swift b/OpenHABCore/Tests/OpenHABCoreTests/ConnectionPoolTests.swift index 4a86e68f1..75d5fd61a 100644 --- a/OpenHABCore/Tests/OpenHABCoreTests/ConnectionPoolTests.swift +++ b/OpenHABCore/Tests/OpenHABCoreTests/ConnectionPoolTests.swift @@ -15,12 +15,12 @@ import Foundation import XCTest final class ConnectionPoolTests: XCTestCase { - func testGetOrCreateServiceReturnsSameInstance() async { + func testGetOrCreateServiceReturnsSameInstance() async throws { let pool = ConnectionPool() let config = ConnectionConfiguration(url: "http://test", username: "", password: "", priority: 1) - let service1 = await pool.getOrCreateService(for: config) - let service2 = await pool.getOrCreateService(for: config) + let service1 = try await pool.getOrCreateService(for: config) + let service2 = try await pool.getOrCreateService(for: config) XCTAssertTrue(service1 === service2) } diff --git a/openHAB/Resources/de.lproj/Localizable.strings b/openHAB/Resources/de.lproj/Localizable.strings index 221d1bdeb..430832131 100644 --- a/openHAB/Resources/de.lproj/Localizable.strings +++ b/openHAB/Resources/de.lproj/Localizable.strings @@ -78,3 +78,5 @@ "about_settings" = "Über"; "uselastpath_settings" = "Zuletzt besuchten Pfad verwenden?"; "always_allow_webrtc" = "WebRTC immer erlauben"; +"no_active_connection" = "Keine aktive Verbindung vorhanden. Bitte Einstellungen prüfen"; + diff --git a/openHAB/Resources/es.lproj/Localizable.strings b/openHAB/Resources/es.lproj/Localizable.strings index 463c908a4..e2644b0e3 100644 --- a/openHAB/Resources/es.lproj/Localizable.strings +++ b/openHAB/Resources/es.lproj/Localizable.strings @@ -78,3 +78,4 @@ "about_settings" = "Acerca de"; "uselastpath_settings" = "¿Usar la última ruta visitada?"; "always_allow_webrtc" = "Consenti sempre WebRTC"; +"no_active_connection" = "No active connection available. Please check your settings."; diff --git a/openHAB/Resources/fi.lproj/Localizable.strings b/openHAB/Resources/fi.lproj/Localizable.strings index fad0af249..d66f16259 100644 --- a/openHAB/Resources/fi.lproj/Localizable.strings +++ b/openHAB/Resources/fi.lproj/Localizable.strings @@ -78,3 +78,4 @@ "about_settings" = "About"; "uselastpath_settings" = "Käytä edellistä polkua?"; "always_allow_webrtc" = "Always Allow WebRTC"; +"no_active_connection" = "No active connection available. Please check your settings."; diff --git a/openHAB/Resources/fr.lproj/Localizable.strings b/openHAB/Resources/fr.lproj/Localizable.strings index acac85857..f66077f37 100644 --- a/openHAB/Resources/fr.lproj/Localizable.strings +++ b/openHAB/Resources/fr.lproj/Localizable.strings @@ -78,3 +78,4 @@ "about_settings" = "A propos"; "uselastpath_settings" = "Utiliser le chemin de la dernière visite?"; "always_allow_webrtc" = "Toujours autoriser WebRTC"; +"no_active_connection" = "No active connection available. Please check your settings."; diff --git a/openHAB/Resources/it.lproj/Localizable.strings b/openHAB/Resources/it.lproj/Localizable.strings index c58dbb8ab..b644cc839 100644 --- a/openHAB/Resources/it.lproj/Localizable.strings +++ b/openHAB/Resources/it.lproj/Localizable.strings @@ -78,3 +78,4 @@ "about_settings" = "Info"; "uselastpath_settings" = "Usare L'Ultimo Percorso Visitato?"; "always_allow_webrtc" = "Consenti sempre WebRTC"; +"no_active_connection" = "No active connection available. Please check your settings."; diff --git a/openHAB/Resources/nb.lproj/Localizable.strings b/openHAB/Resources/nb.lproj/Localizable.strings index 41958b2db..94e3709da 100644 --- a/openHAB/Resources/nb.lproj/Localizable.strings +++ b/openHAB/Resources/nb.lproj/Localizable.strings @@ -78,3 +78,4 @@ "about_settings" = "Om"; "uselastpath_settings" = "Bruk sist brukte bane?"; "always_allow_webrtc" = "Always Allow WebRTC"; +"no_active_connection" = "No active connection available. Please check your settings."; diff --git a/openHAB/Resources/nl.lproj/Localizable.strings b/openHAB/Resources/nl.lproj/Localizable.strings index 67c26120d..a9c0fc7bf 100644 --- a/openHAB/Resources/nl.lproj/Localizable.strings +++ b/openHAB/Resources/nl.lproj/Localizable.strings @@ -78,3 +78,4 @@ "about_settings" = "Over"; "uselastpath_settings" = "Laatst bezochte pad gebruiken?"; "always_allow_webrtc" = "WebRTC altijd toestaan"; +"no_active_connection" = "No active connection available. Please check your settings."; diff --git a/openHAB/Resources/ru.lproj/Localizable.strings b/openHAB/Resources/ru.lproj/Localizable.strings index 05509fbfe..d21e88de1 100644 --- a/openHAB/Resources/ru.lproj/Localizable.strings +++ b/openHAB/Resources/ru.lproj/Localizable.strings @@ -78,3 +78,4 @@ "about_settings" = "About"; "uselastpath_settings" = "Use last visited path?"; "always_allow_webrtc" = "Always Allow WebRTC"; +"no_active_connection" = "No active connection available. Please check your settings."; From 2c9a145b70e49d7ef88dcc8b8363b568a582fc1c Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Sun, 27 Apr 2025 22:46:11 +0200 Subject: [PATCH 153/476] Migrated requestMediaCapturePermissionFor to its async version / Now called decideMediaCapturePermissionsFor Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- openHAB/OpenHABWebViewController.swift | 11 ++++++++--- openHAB/WebUITableViewCell.swift | 14 +++++++------- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/openHAB/OpenHABWebViewController.swift b/openHAB/OpenHABWebViewController.swift index c7e6f2a1a..ed61003e3 100644 --- a/openHAB/OpenHABWebViewController.swift +++ b/openHAB/OpenHABWebViewController.swift @@ -401,8 +401,13 @@ extension OpenHABWebViewController: WKUIDelegate { return nil } - - func webView(_ webView: WKWebView, requestMediaCapturePermissionFor origin: WKSecurityOrigin, initiatedByFrame frame: WKFrameInfo, type: WKMediaCaptureType, decisionHandler: @escaping @MainActor @Sendable (WKPermissionDecision) -> Void) { - decisionHandler(Preferences.alwaysAllowWebRTC ? .grant : .prompt) + + func webView( + _ webView: WKWebView, + decideMediaCapturePermissionsFor origin: WKSecurityOrigin, + initiatedBy frame: WKFrameInfo, + type: WKMediaCaptureType + ) async -> WKPermissionDecision { + Preferences.alwaysAllowWebRTC ? .grant : .prompt } } diff --git a/openHAB/WebUITableViewCell.swift b/openHAB/WebUITableViewCell.swift index 49305e355..4bc3e071a 100644 --- a/openHAB/WebUITableViewCell.swift +++ b/openHAB/WebUITableViewCell.swift @@ -114,12 +114,12 @@ extension WebUITableViewCell: WKNavigationDelegate { } extension WebUITableViewCell: WKUIDelegate { - // Matches https://developer.apple.com/documentation/webkit/wkuidelegate/webview(_:requestdeviceorientationandmotionpermissionfor:initiatedbyframe:decisionhandler:) - func webView(_ webView: WKWebView, - requestMediaCapturePermissionFor origin: WKSecurityOrigin, - initiatedByFrame frame: WKFrameInfo, - type: WKMediaCaptureType, - decisionHandler: @escaping @MainActor (WKPermissionDecision) -> Void) { - decisionHandler(.grant) + func webView( + _ webView: WKWebView, + decideMediaCapturePermissionsFor origin: WKSecurityOrigin, + initiatedBy frame: WKFrameInfo, + type: WKMediaCaptureType + ) async -> WKPermissionDecision { + .grant } } From 3c72c19eb4e363483ba2dfe91bf513998f6c620f Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Mon, 28 Apr 2025 06:58:07 +0200 Subject: [PATCH 154/476] Update OpenHABCore/Sources/OpenHABCore/Model/OpenHABItem.swift Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- OpenHABCore/Sources/OpenHABCore/Model/OpenHABItem.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABItem.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABItem.swift index 95fab8adc..7d01d05a1 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABItem.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABItem.swift @@ -28,7 +28,7 @@ public struct OpenHABItem: Sendable { case rollershutter = "Rollershutter" case stringItem = "String" case switchItem = "Switch" - case undetermind = "" // Relevant only for SitemapWidgetEvent + case undetermined = "" // Relevant only for SitemapWidgetEvent } public var type: ItemType? From 2e9f074f3a6ebc5aea5f4d0195aa6e7d3b3b8190 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Mon, 28 Apr 2025 13:07:28 +0200 Subject: [PATCH 155/476] Intermittent state Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- openHAB.xcodeproj/project.pbxproj | 20 ++ openHAB/OpenHABRootViewController.swift | 48 +++-- openHAB/OpenHABSitemapViewController.swift | 41 ++-- openHAB/OpenHABWebViewController.swift | 12 +- openHAB/SitemapPageView.swift | 4 +- openHAB/SitemapPageViewModel.swift | 214 ++++++++++++++++++--- openHAB/WebUITableViewCell.swift | 10 +- openHAB/WidgetGenericView.swift | 41 ++++ openHAB/WidgetSliderView.swift | 41 ++++ openHAB/WidgetSwitchView.swift | 32 +++ openHAB/WidgetTextView.swift | 34 ++++ openHAB/WidgetViewFactory.swift | 31 +++ 12 files changed, 447 insertions(+), 81 deletions(-) create mode 100644 openHAB/WidgetGenericView.swift create mode 100644 openHAB/WidgetSliderView.swift create mode 100644 openHAB/WidgetSwitchView.swift create mode 100644 openHAB/WidgetTextView.swift create mode 100644 openHAB/WidgetViewFactory.swift diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index c5f4fe2b2..cafcffde4 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -135,6 +135,11 @@ DAD0857B2AE4782F001D36BE /* OpenHABWatchUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAD0857A2AE4782F001D36BE /* OpenHABWatchUITests.swift */; }; DAD0857D2AE4782F001D36BE /* OpenHABWatchLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAD0857C2AE4782F001D36BE /* OpenHABWatchLaunchTests.swift */; }; DAD0858B2AE56F0E001D36BE /* OpenHABWatch.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAD0855F2AE47824001D36BE /* OpenHABWatch.swift */; }; + DAEA21D82DBF472D00D54342 /* WidgetViewFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAEA21D72DBF472D00D54342 /* WidgetViewFactory.swift */; }; + DAEA21DA2DBF477E00D54342 /* WidgetSwitchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAEA21D92DBF477E00D54342 /* WidgetSwitchView.swift */; }; + DAEA21DC2DBF47DA00D54342 /* WidgetSliderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAEA21DB2DBF47DA00D54342 /* WidgetSliderView.swift */; }; + DAEA21DE2DBF481300D54342 /* WidgetTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAEA21DD2DBF481300D54342 /* WidgetTextView.swift */; }; + DAEA21E02DBF483E00D54342 /* WidgetGenericView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAEA21DF2DBF483E00D54342 /* WidgetGenericView.swift */; }; DAEAA89D21E6B06400267EA3 /* ReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAEAA89C21E6B06300267EA3 /* ReusableView.swift */; }; DAEAA89F21E6B16600267EA3 /* UITableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAEAA89E21E6B16600267EA3 /* UITableView.swift */; }; DAF0A28B2C56E3A300A14A6A /* RollershutterCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF0A28A2C56E3A300A14A6A /* RollershutterCell.swift */; }; @@ -453,6 +458,11 @@ DAD488B3287DDDFE00414693 /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = Resources/nb.lproj/Intents.strings; sourceTree = ""; }; DAD488B4287DDDFF00414693 /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = nb.lproj/Localizable.strings; sourceTree = ""; }; DAD488B5287DDDFF00414693 /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = nb.lproj/InfoPlist.strings; sourceTree = ""; }; + DAEA21D72DBF472D00D54342 /* WidgetViewFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetViewFactory.swift; sourceTree = ""; }; + DAEA21D92DBF477E00D54342 /* WidgetSwitchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetSwitchView.swift; sourceTree = ""; }; + DAEA21DB2DBF47DA00D54342 /* WidgetSliderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetSliderView.swift; sourceTree = ""; }; + DAEA21DD2DBF481300D54342 /* WidgetTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetTextView.swift; sourceTree = ""; }; + DAEA21DF2DBF483E00D54342 /* WidgetGenericView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetGenericView.swift; sourceTree = ""; }; DAEAA89C21E6B06300267EA3 /* ReusableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReusableView.swift; sourceTree = ""; }; DAEAA89E21E6B16600267EA3 /* UITableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITableView.swift; sourceTree = ""; }; DAF0A28A2C56E3A300A14A6A /* RollershutterCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RollershutterCell.swift; sourceTree = ""; }; @@ -934,6 +944,7 @@ DF4B84101886DA9900F34902 /* Widgets */, DFFD8FCE18EDBD30003B502A /* Util */, DA64ACA72DBEAD8300294F60 /* SitemapPageView.swift */, + DAEA21D72DBF472D00D54342 /* WidgetViewFactory.swift */, DA64ACA92DBEADB000294F60 /* WidgetRow.swift */, ); name = UI; @@ -1073,10 +1084,14 @@ isa = PBXGroup; children = ( 938EDCE022C4FEB800661CA1 /* ScaleAspectFitImageView.swift */, + DAEA21DF2DBF483E00D54342 /* WidgetGenericView.swift */, DFFD8FD018EDBD4F003B502A /* UICircleButton.swift */, 938BF9C524EFCC0700E6B52F /* UILabel+Localization.swift */, 938BF9D224EFD0B700E6B52F /* UIViewController+Localization.swift */, 935B484525342B8E00E44CF0 /* URL+Static.swift */, + DAEA21D92DBF477E00D54342 /* WidgetSwitchView.swift */, + DAEA21DB2DBF47DA00D54342 /* WidgetSliderView.swift */, + DAEA21DD2DBF481300D54342 /* WidgetTextView.swift */, ); name = Util; sourceTree = ""; @@ -1594,7 +1609,9 @@ DF4B84131886DAC400F34902 /* FrameUITableViewCell.swift in Sources */, DF4B84161886EACA00F34902 /* GenericUITableViewCell.swift in Sources */, 935B484625342B8E00E44CF0 /* URL+Static.swift in Sources */, + DAEA21DA2DBF477E00D54342 /* WidgetSwitchView.swift in Sources */, B7D5ECE121499E55001B0EC6 /* MapViewTableViewCell.swift in Sources */, + DAEA21DC2DBF47DA00D54342 /* WidgetSliderView.swift in Sources */, DA77E19B2D886D9B007CFF0F /* SingleConnectionSettingsView.swift in Sources */, DA6B2EF52C89F8F200DF77CF /* ColorPickerView.swift in Sources */, DA4800142D836892009CF127 /* ConnectionSettingsView.swift in Sources */, @@ -1610,6 +1627,7 @@ DA6B2EF12C87B59000DF77CF /* NotificationsView.swift in Sources */, DA50C7BF2B0A65300009F716 /* SliderWithSwitchSupportUITableViewCell.swift in Sources */, DA5ED9BE2C850955004875E0 /* ClientCertificatesViewModel.swift in Sources */, + DAEA21D82DBF472D00D54342 /* WidgetViewFactory.swift in Sources */, DA64ACAA2DBEADB000294F60 /* WidgetRow.swift in Sources */, DA21EAE22339621C001AB415 /* Throttler.swift in Sources */, DAF4F6C0222734D300C24876 /* NewImageUITableViewCell.swift in Sources */, @@ -1623,9 +1641,11 @@ DA2AEBA02D92FB6500897D80 /* NoIconDisplayableCell.swift in Sources */, DA2AEB702D92CF3E00897D80 /* WidgetIconRenderer.swift in Sources */, DA4800162D836EF0009CF127 /* MainUISettingsView.swift in Sources */, + DAEA21DE2DBF481300D54342 /* WidgetTextView.swift in Sources */, DAEAA89F21E6B16600267EA3 /* UITableView.swift in Sources */, DFB2624418830A3600D3244D /* OpenHABSitemapViewController.swift in Sources */, DA4800182D837221009CF127 /* AboutSettingsView.swift in Sources */, + DAEA21E02DBF483E00D54342 /* WidgetGenericView.swift in Sources */, 653B54C2285E714900298ECD /* OpenHABViewController.swift in Sources */, DA2AEB6E2D92BAD800897D80 /* PageLoader.swift in Sources */, DFA16EC118898A8400EDB0BB /* SegmentedUITableViewCell.swift in Sources */, diff --git a/openHAB/OpenHABRootViewController.swift b/openHAB/OpenHABRootViewController.swift index 87ea9b942..4a54db913 100644 --- a/openHAB/OpenHABRootViewController.swift +++ b/openHAB/OpenHABRootViewController.swift @@ -521,29 +521,37 @@ class OpenHABRootViewController: UIViewController { } private func switchView(target: TargetController) { - let targetView = - if case .sitemap = target { - sitemapViewController - } else { - webViewController - } + let targetView: (UIViewController & OpenHABViewable) - if currentView !== targetView { - if currentView != nil { - removeView(viewController: currentView) - } - addView(viewController: targetView) - currentView = targetView - // Don't save our view in demo mode - if !Preferences.demomode { - Preferences.defaultView = currentView.viewName() - } - } else { - // if we hit the menu item again while on the view, trigger a reload + switch target { + case .sitemap: + targetView = sitemapViewController + case .webview: + targetView = webViewController + default: + // For nowfatalError because we are handling only sitemap+webview + fatalError("Unhandled target: \(target)") + } + + guard currentView !== targetView else { + // Same view tapped again -> reload it currentView.reloadView() + currentView.navigationController?.popToRootViewController(animated: true) + return + } + + if let currentView { + removeView(viewController: currentView) } - // make sure we reset any views that may be pushed - currentView.navigationController?.popToRootViewController(animated: true) + + addView(viewController: targetView) + currentView = targetView + + if !Preferences.demomode { + Preferences.defaultView = targetView.viewName() + } + + targetView.navigationController?.popToRootViewController(animated: true) } private func switchToSavedView() { diff --git a/openHAB/OpenHABSitemapViewController.swift b/openHAB/OpenHABSitemapViewController.swift index 744b59a67..8971aa675 100644 --- a/openHAB/OpenHABSitemapViewController.swift +++ b/openHAB/OpenHABSitemapViewController.swift @@ -293,29 +293,34 @@ class OpenHABSitemapViewController: OpenHABViewController, UISearchControllerDel // This is mainly used for navigting to a specific sitemap and path from notifications override func pushSitemap(name: String, path: String?) async { - do { - guard let activeConnection = await NetworkTracker.shared.waitForActiveConnection() else { - logger.error("pushSiteMap: No active connection available") - return - } + guard let activeConnection = await NetworkTracker.shared.waitForActiveConnection() else { + logger.error("pushSitemap: No active connection available") + return + } - logger.info("pushSitemap: pushing page") + logger.info("pushSitemap: pushing page") - guard let newViewController = storyboard?.instantiateViewController(withIdentifier: "OpenHABPageViewController") as? OpenHABSitemapViewController else { - os_log("pushSitemap: Failed to instantiate OpenHABSitemapViewController", log: .default, type: .error) - return - } - let openHABUrl = activeConnection.configuration.url + guard let baseUrl = URL(string: activeConnection.configuration.url) else { + logger.error("pushSitemap: Invalid base URL") + return + } - newViewController.pageUrl = path != nil - ? "\(openHABUrl)/rest/sitemaps/\(name)/\(path!)" - : "\(openHABUrl)/rest/sitemaps/\(name)" - newViewController.openHABRootUrl = openHABUrl + var url = baseUrl.appendingPathComponent("rest") + .appendingPathComponent("sitemaps") + .appendingPathComponent(name) - navigationController?.pushViewController(newViewController, animated: true) - } catch { - os_log("pushSitemap: Error waiting for active connection: %{PUBLIC}@", log: .default, type: .error, error.localizedDescription) + if let subpath = path { + url.appendPathComponent(subpath) } + + guard let newViewController = storyboard?.instantiateViewController(withIdentifier: "OpenHABPageViewController") as? OpenHABSitemapViewController else { + logger.error("pushSitemap: Failed to instantiate OpenHABSitemapViewController") + return + } + + newViewController.pageUrl = url.absoluteString + newViewController.openHABRootUrl = activeConnection.configuration.url + navigationController?.pushViewController(newViewController, animated: true) } } diff --git a/openHAB/OpenHABWebViewController.swift b/openHAB/OpenHABWebViewController.swift index ed61003e3..14d9f829a 100644 --- a/openHAB/OpenHABWebViewController.swift +++ b/openHAB/OpenHABWebViewController.swift @@ -401,13 +401,11 @@ extension OpenHABWebViewController: WKUIDelegate { return nil } - - func webView( - _ webView: WKWebView, - decideMediaCapturePermissionsFor origin: WKSecurityOrigin, - initiatedBy frame: WKFrameInfo, - type: WKMediaCaptureType - ) async -> WKPermissionDecision { + + func webView(_ webView: WKWebView, + decideMediaCapturePermissionsFor origin: WKSecurityOrigin, + initiatedBy frame: WKFrameInfo, + type: WKMediaCaptureType) async -> WKPermissionDecision { Preferences.alwaysAllowWebRTC ? .grant : .prompt } } diff --git a/openHAB/SitemapPageView.swift b/openHAB/SitemapPageView.swift index e88e7cfef..70e922c85 100644 --- a/openHAB/SitemapPageView.swift +++ b/openHAB/SitemapPageView.swift @@ -17,7 +17,7 @@ struct SitemapPageView: View { var body: some View { NavigationStack { List(viewModel.relevantWidgets) { widget in - WidgetRow(widget: widget) + WidgetViewFactory.view(for: widget) .onTapGesture { viewModel.widgetTapped(widget) } @@ -28,7 +28,7 @@ struct SitemapPageView: View { await viewModel.reload() } .task { - await viewModel.startPolling() + viewModel.startPageHandling() } .alert("Error", isPresented: .constant(viewModel.error != nil), actions: { Button("OK", role: .cancel) {} diff --git a/openHAB/SitemapPageViewModel.swift b/openHAB/SitemapPageViewModel.swift index ca347d257..7f6279add 100644 --- a/openHAB/SitemapPageViewModel.swift +++ b/openHAB/SitemapPageViewModel.swift @@ -12,25 +12,40 @@ import Combine import OpenAPIRuntime import OpenHABCore +import os.log import SwiftUI +private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "org.openhab.app", category: "SitemapPageViewModel") + +enum SitemapPageError: LocalizedError { + case noActiveConnection + case serviceUnavailable + + var errorDescription: String? { + switch self { + case .noActiveConnection: + "No active connection available." + case .serviceUnavailable: + "Service unavailable." + } + } +} + @MainActor class SitemapPageViewModel: ObservableObject { @Published var currentPage: OpenHABPage? @Published var filteredWidgets: [OpenHABWidget] = [] - @Published var searchText: String = "" + @Published var searchText = "" @Published var error: LocalizedError? - @Published var isLoading: Bool = false + @Published var isLoading = false + @Published var openHABRootUrl: String? private var openAPIService: OpenAPIService? private var activeConnectionInfo: ConnectionInfo? private var pageHandlingTask: Task? - private var defaultSitemap: String = "" - private var pageId: String = "" - - init() { - loadSettings() - } + private var defaultSitemap = "" + private var pageId = "" + private var trackerTask: Task? var relevantWidgets: [OpenHABWidget] { if searchText.isEmpty { @@ -44,27 +59,105 @@ class SitemapPageViewModel: ObservableObject { currentPage?.title.components(separatedBy: "[")[0] ?? "Sitemap" } + init() { + loadSettings() + startWatchingActiveServer() + } + func loadSettings() { defaultSitemap = Preferences.defaultSitemap } - func startPolling() async { - guard pageHandlingTask == nil else { return } + func startPageHandling() { + pageHandlingTask?.cancel() + + guard !defaultSitemap.isEmpty else { + logger.error("startPageHandling: Cannot run with empty sitemap") + return + } + + logger.info("🚀 Starting page load and long polling flow...") pageHandlingTask = Task { - await reload() - - while !Task.isCancelled { - do { - try await Task.sleep(nanoseconds: 20 * 1_000_000_000) // 20s polling - try await loadCurrentPage() - } catch { - self.error = error as? LocalizedError + do { + // Setup service if needed +// if openAPIService == nil { +// guard let activeConnection = NetworkTracker.shared.activeConnection else { +// throw SitemapPageError.noActiveConnection +// } +// openAPIService = try OpenAPIService( +// connectionConfiguration: activeConnection.configuration +// ) +// } + + if openAPIService == nil { + openAPIService = try OpenAPIService( + connectionConfiguration: NetworkTracker.shared.activeConnection!.configuration) + } + + // 1. Initial page load (longPolling: false) + let initialPage = try await openAPIService?.pollDataForPage( + sitemapname: defaultSitemap, + pageId: pageId, + longPolling: false + ) + + try Task.checkCancellation() + + if let page = initialPage { + updateUI(with: page) + } + + // 2. Start long polling loop + while !Task.isCancelled { + let page = try await openAPIService?.pollDataForPage( + sitemapname: defaultSitemap, + pageId: pageId, + longPolling: true + ) + try Task.checkCancellation() + + if let page { + updateUI(with: page) + } + } + + } catch is CancellationError { + logger.info("🔁 pageHandlingTask was cancelled") + } catch let error as DecodingError { + logger.error("Decoding error: \(error.localizedDescription)") + await MainActor.run { + self.error = SitemapPageError.serviceUnavailable + } + } catch let error as ClientError { + if let urlError = error.underlyingError as? URLError, urlError.code == .cancelled { + logger.info("Task cancelled (URLError: cancelled)") + } else if let urlError = error.underlyingError as? URLError, urlError.code == .timedOut { + logger.info("Task timed out (URLError: timedOut)") + } else { + logger.error("ClientError: \(error.localizedDescription)") + await MainActor.run { + self.error = SitemapPageError.serviceUnavailable + } + } + } catch let openAPIError as OpenAPIServiceError { + logger.error("OpenAPIServiceError: \(openAPIError.localizedDescription)") + } catch { + logger.error("❌ Unhandled pageHandlingTask error: \(error.localizedDescription)") + await MainActor.run { + self.error = SitemapPageError.serviceUnavailable } } } } + @MainActor + private func updateUI(with page: OpenHABPage) { + injectSendCommand(for: page.widgets) + currentPage = page + filterWidgets() + } + func reload() async { do { isLoading = true @@ -94,10 +187,22 @@ class SitemapPageViewModel: ObservableObject { longPolling: false ) + injectSendCommand(for: page!.widgets) currentPage = page filterWidgets() } + private func injectSendCommand(for widgets: [OpenHABWidget]) { + for widget in widgets { + widget.sendCommand = { [weak self] item, command in + self?.sendCommand(item, commandToSend: command) + } + + // If widget has nested children (e.g., frames/groups), inject recursively + injectSendCommand(for: widget.widgets) + } + } + func filterWidgets() { if searchText.isEmpty { filteredWidgets = [] @@ -119,20 +224,73 @@ class SitemapPageViewModel: ObservableObject { func pushSitemap(name: String, path: String?) async { defaultSitemap = name pageId = path ?? "" + await startPageHandling() + } + + deinit { + trackerTask?.cancel() + } + + func startWatchingActiveServer() { + trackerTask = Task { + for await activeConnection in NetworkTracker.shared.$activeConnection.stream() { + if let activeConnection { + logger.info("Tracker URL \(activeConnection.configuration.url)") + await handleActiveConnection(activeConnection) + break + } + } + } + } + + private func handleActiveConnection(_ connection: ConnectionInfo) async { + // Save the active connection information + activeConnectionInfo = connection + openHABRootUrl = connection.configuration.url + + do { + // Setup the OpenAPI service based on the new connection + openAPIService = try OpenAPIService(connectionConfiguration: connection.configuration) + // Reload the sitemap data + await selectSitemap() + } catch { + self.error = error as? LocalizedError + } + } + + func selectSitemap() async { await reload() } -} -enum SitemapPageError: LocalizedError { - case noActiveConnection - case serviceUnavailable + // MARK: - Command Sending - var errorDescription: String? { - switch self { - case .noActiveConnection: - "No active connection available." - case .serviceUnavailable: - "Service unavailable." + func sendCommand(_ item: OpenHABItem?, commandToSend command: String?) { + if let item, let command { + sendCommand(itemname: item.name, command: command) + } + } + + func sendCommand(itemname: String, command: String) { + Task { + do { + try await openAPIService?.sendItemCommand(itemname: itemname, command: command) + os_log("SitemapPageViewModel: Successfully sent command %{PUBLIC}@ to %{PUBLIC}@", log: .default, type: .info, command, itemname) + } catch { + os_log("SitemapPageViewModel: Failed to send command %{PUBLIC}@ to %{PUBLIC}@ — %{PUBLIC}@", log: .default, type: .error, command, itemname, error.localizedDescription) + } + } + } +} + +extension Published.Publisher { + func stream() -> AsyncStream { + AsyncStream { continuation in + let cancellable = self.sink { value in + continuation.yield(value) + } + continuation.onTermination = { _ in + cancellable.cancel() + } } } } diff --git a/openHAB/WebUITableViewCell.swift b/openHAB/WebUITableViewCell.swift index 4bc3e071a..33c48dc23 100644 --- a/openHAB/WebUITableViewCell.swift +++ b/openHAB/WebUITableViewCell.swift @@ -114,12 +114,10 @@ extension WebUITableViewCell: WKNavigationDelegate { } extension WebUITableViewCell: WKUIDelegate { - func webView( - _ webView: WKWebView, - decideMediaCapturePermissionsFor origin: WKSecurityOrigin, - initiatedBy frame: WKFrameInfo, - type: WKMediaCaptureType - ) async -> WKPermissionDecision { + func webView(_ webView: WKWebView, + decideMediaCapturePermissionsFor origin: WKSecurityOrigin, + initiatedBy frame: WKFrameInfo, + type: WKMediaCaptureType) async -> WKPermissionDecision { .grant } } diff --git a/openHAB/WidgetGenericView.swift b/openHAB/WidgetGenericView.swift new file mode 100644 index 000000000..4a5e3e175 --- /dev/null +++ b/openHAB/WidgetGenericView.swift @@ -0,0 +1,41 @@ +// Copyright (c) 2010-2025 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 OpenHABCore +import SwiftUI + +struct WidgetGenericView: View { + let widget: OpenHABWidget + + var body: some View { + HStack { +// if let iconUrl = widget.iconUrl { +// KFImage(iconUrl) +// .resizable() +// .frame(width: 30, height: 30) +// } + VStack(alignment: .leading) { + Text(widget.labelText ?? "") + .font(.headline) + if let value = widget.labelValue { + Text(value) + .font(.subheadline) + .foregroundColor(.secondary) + } + } + } + .padding() + } +} + +// #Preview { +// WidgetGenericView() +// } diff --git a/openHAB/WidgetSliderView.swift b/openHAB/WidgetSliderView.swift new file mode 100644 index 000000000..c5fe8e548 --- /dev/null +++ b/openHAB/WidgetSliderView.swift @@ -0,0 +1,41 @@ +// Copyright (c) 2010-2025 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 OpenHABCore +import SwiftUI + +struct WidgetSliderView: View { + let widget: OpenHABWidget + + // Example: assuming widget has a numeric value as text + var currentValue: Double { + Double(widget.labelValue ?? "") ?? 0.0 + } + + var body: some View { + VStack(alignment: .leading) { + Text(widget.labelText ?? "") + .font(.headline) + Slider(value: .constant(currentValue), in: 0 ... 100) + .disabled(true) // unless you want editable + if let value = widget.labelValue { + Text(value) + .font(.caption) + .foregroundColor(.secondary) + } + } + .padding() + } +} + +// #Preview { +// WidgetSliderView() +// } diff --git a/openHAB/WidgetSwitchView.swift b/openHAB/WidgetSwitchView.swift new file mode 100644 index 000000000..1831a261c --- /dev/null +++ b/openHAB/WidgetSwitchView.swift @@ -0,0 +1,32 @@ +// Copyright (c) 2010-2025 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 OpenHABCore +import SwiftUI + +struct WidgetSwitchView: View { + @ObservedObject var widget: OpenHABWidget + + var body: some View { + Toggle(isOn: Binding( + get: { + widget.state.uppercased() == "ON" + }, + set: { newValue in + let newState = newValue ? "ON" : "OFF" + widget.state = newState // 1. Update local state immediately + widget.sendCommand(newState) // 2. Send to server + } + )) { + Text(widget.labelText ?? widget.label) + } + } +} diff --git a/openHAB/WidgetTextView.swift b/openHAB/WidgetTextView.swift new file mode 100644 index 000000000..bd2bf93a4 --- /dev/null +++ b/openHAB/WidgetTextView.swift @@ -0,0 +1,34 @@ +// Copyright (c) 2010-2025 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 OpenHABCore +import SwiftUI + +struct WidgetTextView: View { + let widget: OpenHABWidget + + var body: some View { + VStack(alignment: .leading) { + Text(widget.labelText ?? "") + .font(.headline) + if let value = widget.labelValue { + Text(value) + .font(.subheadline) + .foregroundColor(.secondary) + } + } + .padding() + } +} + +// #Preview { +// WidgetTextView() +// } diff --git a/openHAB/WidgetViewFactory.swift b/openHAB/WidgetViewFactory.swift new file mode 100644 index 000000000..7e7666ae1 --- /dev/null +++ b/openHAB/WidgetViewFactory.swift @@ -0,0 +1,31 @@ +// Copyright (c) 2010-2025 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 OpenHABCore +import SwiftUI + +enum WidgetViewFactory { + @ViewBuilder + static func view(for widget: OpenHABWidget) -> some View { + switch widget.type { + case .switchWidget: + WidgetSwitchView(widget: widget) + case .slider: + WidgetSliderView(widget: widget) + case .text: + WidgetTextView(widget: widget) + case .frame: + EmptyView() // ignore frames + default: + WidgetGenericView(widget: widget) + } + } +} From 5deb9d88ae6dded77cd0fc58acb39f5bdae75bc7 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Mon, 28 Apr 2025 21:11:12 +0200 Subject: [PATCH 156/476] Cleaning up some warnings Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../Util/ConnectionConfiguration.swift | 22 +++++++-------- .../OpenHABCore/Util/NWPathMonitoring.swift | 18 ++++++------ .../OpenHABCore/Util/OpenAPIService.swift | 2 +- ...on.swift => SessionChallengeHandler.swift} | 4 +-- .../OpenHABCore/Util/WatchPreferences.swift | 28 +++++++++---------- .../NetworkTrackerTests.swift | 6 ++-- openHAB/OpenHABWebViewController.swift | 12 ++++---- openHAB/WebUITableViewCell.swift | 10 +++---- 8 files changed, 48 insertions(+), 54 deletions(-) rename OpenHABCore/Sources/OpenHABCore/Util/{NetworkConnection.swift => SessionChallengeHandler.swift} (99%) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/ConnectionConfiguration.swift b/OpenHABCore/Sources/OpenHABCore/Util/ConnectionConfiguration.swift index a2333866c..3c904f3b8 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/ConnectionConfiguration.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/ConnectionConfiguration.swift @@ -48,17 +48,6 @@ public struct ConnectionConfiguration: Hashable, Sendable, Codable { priority = try container.decode(Int.self, forKey: .priority) } - // 🔹 Ensure normalization on encoding (optional, since we store it normalized) - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(url, forKey: .url) // Already normalized - try container.encode(username, forKey: .username) - try container.encode(password, forKey: .password) - try container.encode(alwaysSendBasicAuth, forKey: .alwaysSendBasicAuth) - try container.encode(ignoreSSL, forKey: .ignoreSSL) - try container.encode(priority, forKey: .priority) - } - // 🔹 Normalize a URL (removes trailing slashes, trims spaces, redirects openHAB cloud) private static func normalizeURL(_ url: String) -> String { var cleanedURL = url.trimmingCharacters(in: .whitespacesAndNewlines) // Trim whitespace @@ -75,4 +64,15 @@ public struct ConnectionConfiguration: Hashable, Sendable, Codable { } return newUrl } + + // 🔹 Ensure normalization on encoding (optional, since we store it normalized) + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(url, forKey: .url) // Already normalized + try container.encode(username, forKey: .username) + try container.encode(password, forKey: .password) + try container.encode(alwaysSendBasicAuth, forKey: .alwaysSendBasicAuth) + try container.encode(ignoreSSL, forKey: .ignoreSSL) + try container.encode(priority, forKey: .priority) + } } diff --git a/OpenHABCore/Sources/OpenHABCore/Util/NWPathMonitoring.swift b/OpenHABCore/Sources/OpenHABCore/Util/NWPathMonitoring.swift index 9118d6876..2bb362a87 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/NWPathMonitoring.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/NWPathMonitoring.swift @@ -12,15 +12,6 @@ import Foundation import Network -// MARK: - Protocol - -public protocol NWPathMonitoring: AnyObject { - /// Called with `true` when connected, `false` otherwise. - func setUpdateHandler(_ handler: @escaping (Bool) -> Void) - func start(queue: DispatchQueue) - func cancel() -} - // Wrap real NWPathMonitor final class RealPathMonitor: NWPathMonitoring { private let monitor: NWPathMonitor @@ -53,3 +44,12 @@ final class RealPathMonitor: NWPathMonitoring { task?.cancel() } } + +// MARK: - Protocol + +public protocol NWPathMonitoring: AnyObject { + /// Called with `true` when connected, `false` otherwise. + func setUpdateHandler(_ handler: @escaping (Bool) -> Void) + func start(queue: DispatchQueue) + func cancel() +} diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift index e406cbbd0..e6c3ba30c 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift @@ -31,7 +31,7 @@ public enum OpenAPIServiceConfiguration { protocol OpenAPIServiceProtocol: AnyObject, Sendable { func getRootVersion() async throws -> Int - func getRoot() async throws -> OpenHABServerProperties + @discardableResult func getRoot() async throws -> OpenHABServerProperties func sendItemCommand(itemname: String, command: String) async throws func updateItemState(itemname: String, with: String) async throws func getItems() async throws -> [OpenHABItem] diff --git a/OpenHABCore/Sources/OpenHABCore/Util/NetworkConnection.swift b/OpenHABCore/Sources/OpenHABCore/Util/SessionChallengeHandler.swift similarity index 99% rename from OpenHABCore/Sources/OpenHABCore/Util/NetworkConnection.swift rename to OpenHABCore/Sources/OpenHABCore/Util/SessionChallengeHandler.swift index 6dee8839b..feadd5198 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/NetworkConnection.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/SessionChallengeHandler.swift @@ -10,6 +10,7 @@ // SPDX-License-Identifier: EPL-2.0 import Foundation +import os private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "org.openhab.app", category: "SessionChallenge") @@ -58,9 +59,6 @@ public func onReceiveSessionChallenge(with challenge: URLAuthenticationChallenge } } -import Foundation -import os - final class SessionChallengeHandler { private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "SessionChallengeHandler", category: "Auth") diff --git a/OpenHABCore/Sources/OpenHABCore/Util/WatchPreferences.swift b/OpenHABCore/Sources/OpenHABCore/Util/WatchPreferences.swift index 7f06fd99b..1472264c6 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/WatchPreferences.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/WatchPreferences.swift @@ -13,6 +13,20 @@ import Foundation import os.log public struct WatchPreferences: Codable { + public var localUrl: String + public var remoteUrl: String + public var username: String + public var password: String + public var alwaysSendCreds: Bool + public var defaultSitemap: String + public var ignoreSSL: Bool + public var sitemapForWatch: String + public var sitemapForWatchLabel: String + public var iconType: Int + public var demoMode: Bool + public var localConnectionConfiguration: ConnectionConfiguration? + public var remoteConnectionConfiguration: ConnectionConfiguration? + public init(localUrl: String, remoteUrl: String, username: String, password: String, alwaysSendCreds: Bool, defaultSitemap: String, ignoreSSL: Bool, sitemapForWatch: String, sitemapForWatchLabel: String, iconType: Int, demoMode: Bool, localConnectionConfiguration: ConnectionConfiguration? = nil, remoteConnectionConfiguration: ConnectionConfiguration? = nil) { self.localUrl = localUrl self.remoteUrl = remoteUrl @@ -29,20 +43,6 @@ public struct WatchPreferences: Codable { self.remoteConnectionConfiguration = remoteConnectionConfiguration } - public var localUrl: String - public var remoteUrl: String - public var username: String - public var password: String - public var alwaysSendCreds: Bool - public var defaultSitemap: String - public var ignoreSSL: Bool - public var sitemapForWatch: String - public var sitemapForWatchLabel: String - public var iconType: Int - public var demoMode: Bool - public var localConnectionConfiguration: ConnectionConfiguration? - public var remoteConnectionConfiguration: ConnectionConfiguration? - public func encodedWatchPreferences() -> [String: Data] { do { let data = try JSONEncoder().encode(self) diff --git a/OpenHABCore/Tests/OpenHABCoreTests/NetworkTrackerTests.swift b/OpenHABCore/Tests/OpenHABCoreTests/NetworkTrackerTests.swift index c43cd444b..c4e01385e 100644 --- a/OpenHABCore/Tests/OpenHABCoreTests/NetworkTrackerTests.swift +++ b/OpenHABCore/Tests/OpenHABCoreTests/NetworkTrackerTests.swift @@ -145,7 +145,7 @@ final class NetworkTrackerTests: XCTestCase { // Simulate the network becoming available mockMonitor.simulateConnection(isConnected: true) - wait(for: [expectation], timeout: 2.0) + await fulfillment(of: [expectation], timeout: 2.0) } func testTrackerGoesOfflineOnNetworkLoss() async { @@ -155,7 +155,7 @@ final class NetworkTrackerTests: XCTestCase { let tracker = NetworkTracker( monitor: mockMonitor, monitorQueue: .main, - connectionPool: ConnectionPool(serviceFactory: { _ in MockOpenAPIService() }), + connectionPool: ConnectionPool { _ in MockOpenAPIService() }, failureTracker: ConnectionFailureTracker() ) @@ -178,6 +178,6 @@ final class NetworkTrackerTests: XCTestCase { // Simulate loss of network mockMonitor.simulateConnection(isConnected: false) // ✅ use directly - wait(for: [expectation], timeout: 2.0) + await fulfillment(of: [expectation], timeout: 2.0) } } diff --git a/openHAB/OpenHABWebViewController.swift b/openHAB/OpenHABWebViewController.swift index ed61003e3..14d9f829a 100644 --- a/openHAB/OpenHABWebViewController.swift +++ b/openHAB/OpenHABWebViewController.swift @@ -401,13 +401,11 @@ extension OpenHABWebViewController: WKUIDelegate { return nil } - - func webView( - _ webView: WKWebView, - decideMediaCapturePermissionsFor origin: WKSecurityOrigin, - initiatedBy frame: WKFrameInfo, - type: WKMediaCaptureType - ) async -> WKPermissionDecision { + + func webView(_ webView: WKWebView, + decideMediaCapturePermissionsFor origin: WKSecurityOrigin, + initiatedBy frame: WKFrameInfo, + type: WKMediaCaptureType) async -> WKPermissionDecision { Preferences.alwaysAllowWebRTC ? .grant : .prompt } } diff --git a/openHAB/WebUITableViewCell.swift b/openHAB/WebUITableViewCell.swift index 4bc3e071a..33c48dc23 100644 --- a/openHAB/WebUITableViewCell.swift +++ b/openHAB/WebUITableViewCell.swift @@ -114,12 +114,10 @@ extension WebUITableViewCell: WKNavigationDelegate { } extension WebUITableViewCell: WKUIDelegate { - func webView( - _ webView: WKWebView, - decideMediaCapturePermissionsFor origin: WKSecurityOrigin, - initiatedBy frame: WKFrameInfo, - type: WKMediaCaptureType - ) async -> WKPermissionDecision { + func webView(_ webView: WKWebView, + decideMediaCapturePermissionsFor origin: WKSecurityOrigin, + initiatedBy frame: WKFrameInfo, + type: WKMediaCaptureType) async -> WKPermissionDecision { .grant } } From 54dedda6c081eb3cd36b00cb4b9d827a27d1f47b Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Mon, 28 Apr 2025 22:35:53 +0200 Subject: [PATCH 157/476] Swift 6 conformance and clean-up Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- NotificationService/NotificationService.swift | 2 +- .../OpenHABCore/Util/OpenHABItemCache.swift | 7 ++++--- .../OpenHABCore/Util/Preferences.swift | 20 ++++++------------- openHAB/OpenHABSitemapViewController.swift | 6 ------ openHAB/WatchMessageService.swift | 10 +++++++--- openHABIntents/IntentHandler.swift | 8 ++++++++ 6 files changed, 26 insertions(+), 27 deletions(-) diff --git a/NotificationService/NotificationService.swift b/NotificationService/NotificationService.swift index 30e116d13..8f0cef110 100644 --- a/NotificationService/NotificationService.swift +++ b/NotificationService/NotificationService.swift @@ -163,7 +163,7 @@ class NotificationService: UNNotificationServiceExtension { } private func downloadAndAttachMedia(url: String) async throws -> UNNotificationAttachment? { - NetworkTracker.shared.startTracking(connectionConfigurations: [Preferences.localConnectionConfig, Preferences.remoteConnectionConfig]) + await NetworkTracker.shared.startTracking(connectionConfigurations: [Preferences.localConnectionConfig, Preferences.remoteConnectionConfig]) guard let fullURL = await resolveFullURL(from: url) else { return nil } diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift index 2f330bb42..9d91eff4a 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift @@ -30,10 +30,11 @@ public actor OpenHABItemCache { private let logger = Logger(subsystem: "org.openhab.app.watchkitapp", category: "OpenHABItemCache") - private init() { - let connection1 = Preferences.localConnectionConfig - let connection2 = Preferences.remoteConnectionConfig + private init() {} + public func setup() async { + let connection1 = await Preferences.localConnectionConfig + let connection2 = await Preferences.remoteConnectionConfig NetworkTracker.shared.startTracking(connectionConfigurations: [connection1, connection2]) } diff --git a/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift index 951fcb95f..f85ee1089 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift @@ -21,13 +21,11 @@ public struct UserDefault { public var wrappedValue: T { get { - let value = Preferences.sharedDefaults.object(forKey: key) as? T ?? defaultValue - return value + Preferences.sharedDefaults.object(forKey: key) as? T ?? defaultValue } set { Preferences.sharedDefaults.set(newValue, forKey: key) - let subject = subject - DispatchQueue.main.async { + DispatchQueue.main.async { [subject] in subject.send(newValue) } } @@ -63,8 +61,7 @@ public struct UserDefaultObject { if let encoded = try? JSONEncoder().encode(newValue) { Preferences.sharedDefaults.set(encoded, forKey: key) // Relevant for Combine publication - let subject = subject - DispatchQueue.main.async { + DispatchQueue.main.async { [subject] in subject.send(newValue) } } @@ -103,11 +100,10 @@ public struct UserDefaultURL { } set { 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 { + DispatchQueue.main.async { [subject] in if trimmedUri.isValidURL { subject.send(trimmedUri) } else { @@ -129,13 +125,11 @@ public struct UserDefaultURL { } private func uriWithoutTrailingSlashes(_ hostUri: String) -> String { - if hostUri.hasSuffix("/") { - return String(hostUri[.. Any { switch intent { case is OpenHABGetItemStateIntent: GetItemStateIntentHandler() From bd340013d9ce4fd9228b5e2fd9fa1e580f23bf54 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Mon, 28 Apr 2025 23:04:28 +0200 Subject: [PATCH 158/476] Cleanup Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- openHAB/OpenHABSitemapViewController.swift | 40 +++++++------------ .../ApplicationSettingsView.swift | 2 - 2 files changed, 15 insertions(+), 27 deletions(-) diff --git a/openHAB/OpenHABSitemapViewController.swift b/openHAB/OpenHABSitemapViewController.swift index 2379caa08..51426ccd9 100644 --- a/openHAB/OpenHABSitemapViewController.swift +++ b/openHAB/OpenHABSitemapViewController.swift @@ -445,33 +445,27 @@ extension OpenHABSitemapViewController { } } - // This is mainly used for navigting to a specific sitemap and path from notifications - // This is mainly used for navigating to a specific sitemap and path from notifications func pushSitemap(name: String, path: String?) async { - do { - guard let activeConnection = await NetworkTracker.shared.waitForActiveConnection() else { - logger.error("pushSiteMap: No active connection available") - return - } + guard let activeConnection = await NetworkTracker.shared.waitForActiveConnection() else { + logger.error("pushSiteMap: No active connection available") + return + } - logger.info("pushSitemap: pushing page") + logger.info("pushSitemap: pushing page") - guard let newViewController = storyboard?.instantiateViewController(withIdentifier: "OpenHABPageViewController") as? OpenHABSitemapViewController else { - os_log("pushSitemap: Failed to instantiate OpenHABSitemapViewController", log: .default, type: .error) - return - } - let openHABUrl = activeConnection.configuration.url + guard let newViewController = storyboard?.instantiateViewController(withIdentifier: "OpenHABPageViewController") as? OpenHABSitemapViewController else { + os_log("pushSitemap: Failed to instantiate OpenHABSitemapViewController", log: .default, type: .error) + return + } + let openHABUrl = activeConnection.configuration.url - newViewController.pageUrl = path != nil - ? "\(openHABUrl)/rest/sitemaps/\(name)/\(path!)" - : "\(openHABUrl)/rest/sitemaps/\(name)" - newViewController.openHABRootUrl = openHABUrl + newViewController.pageUrl = path != nil + ? "\(openHABUrl)/rest/sitemaps/\(name)/\(path!)" + : "\(openHABUrl)/rest/sitemaps/\(name)" + newViewController.openHABRootUrl = openHABUrl - navigationController?.pushViewController(newViewController, animated: true) - } catch { - os_log("pushSitemap: Error waiting for active connection: %{PUBLIC}@", log: .default, type: .error, error.localizedDescription) - } + navigationController?.pushViewController(newViewController, animated: true) } func startPageHandling() { @@ -646,10 +640,6 @@ extension OpenHABSitemapViewController: UISearchBarDelegate { func searchBarSearchButtonClicked(_ searchBar: UISearchBar) { searchBar.resignFirstResponder() } - -// func searchBar(_ searchBar: UISearchBar, selectedScopeButtonIndexDidChange selectedScope: Int) { -// updateSearchResults(for: searchController) -// } } // MARK: - ColorPickerCellDelegate diff --git a/openHAB/SettingsView/ApplicationSettingsView.swift b/openHAB/SettingsView/ApplicationSettingsView.swift index 9a1333d91..29de5f1bd 100644 --- a/openHAB/SettingsView/ApplicationSettingsView.swift +++ b/openHAB/SettingsView/ApplicationSettingsView.swift @@ -31,9 +31,7 @@ struct ApplicationSettingsView: View { #Preview { struct PreviewWrapper: View { - @State private var ignoreSSL = true @State private var idleOff = false - @State private var sendCrashReports = false var body: some View { Form { From 6764536238a8f368848c38c8ef6c3cc50a2b5959 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Tue, 29 Apr 2025 07:16:41 +0200 Subject: [PATCH 159/476] Refactored pushSitemap with URLComponents, uriWithoutTrailingSlashes to ExtensionString Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../OpenHABCore/Util/Preferences.swift | 8 ++---- .../OpenHABCore/Util/StringExtension.swift | 4 +++ openHAB/OpenHABSitemapViewController.swift | 25 +++++++++++++------ 3 files changed, 23 insertions(+), 14 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift index f85ee1089..e665e62e0 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift @@ -95,14 +95,14 @@ public struct UserDefaultURL { public var wrappedValue: String { get { let storedValue = Preferences.sharedDefaults.string(forKey: key) ?? defaultValue - let trimmedUri = uriWithoutTrailingSlashes(storedValue).trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedUri = storedValue.uriWithoutTrailingSlashes().trimmingCharacters(in: .whitespacesAndNewlines) return trimmedUri.isValidURL ? trimmedUri : defaultValue } set { Preferences.sharedDefaults.set(newValue, forKey: key) let defaultValue = defaultValue // Trim and validate the new URL - let trimmedUri = uriWithoutTrailingSlashes(newValue).trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedUri = newValue.uriWithoutTrailingSlashes().trimmingCharacters(in: .whitespacesAndNewlines) DispatchQueue.main.async { [subject] in if trimmedUri.isValidURL { subject.send(trimmedUri) @@ -123,10 +123,6 @@ public struct UserDefaultURL { let currentValue = Preferences.sharedDefaults.string(forKey: key) ?? defaultValue subject = CurrentValueSubject(currentValue) } - - private func uriWithoutTrailingSlashes(_ hostUri: String) -> String { - hostUri.replacing(/\/+$/, with: "") - } } @MainActor diff --git a/OpenHABCore/Sources/OpenHABCore/Util/StringExtension.swift b/OpenHABCore/Sources/OpenHABCore/Util/StringExtension.swift index 091eac292..61efbed52 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/StringExtension.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/StringExtension.swift @@ -169,6 +169,10 @@ public extension String { throw URLError(.badURL) } } + + func uriWithoutTrailingSlashes() -> String { + replacing(/\/+$/, with: "") + } } public extension String? { diff --git a/openHAB/OpenHABSitemapViewController.swift b/openHAB/OpenHABSitemapViewController.swift index 51426ccd9..8b31e447f 100644 --- a/openHAB/OpenHABSitemapViewController.swift +++ b/openHAB/OpenHABSitemapViewController.swift @@ -448,23 +448,32 @@ extension OpenHABSitemapViewController { // This is mainly used for navigating to a specific sitemap and path from notifications func pushSitemap(name: String, path: String?) async { guard let activeConnection = await NetworkTracker.shared.waitForActiveConnection() else { - logger.error("pushSiteMap: No active connection available") + logger.error("pushSitemap: No active connection available") return } logger.info("pushSitemap: pushing page") - guard let newViewController = storyboard?.instantiateViewController(withIdentifier: "OpenHABPageViewController") as? OpenHABSitemapViewController else { - os_log("pushSitemap: Failed to instantiate OpenHABSitemapViewController", log: .default, type: .error) + guard let baseUrl = URL(string: activeConnection.configuration.url) else { + logger.error("pushSitemap: Invalid base URL") return } - let openHABUrl = activeConnection.configuration.url - newViewController.pageUrl = path != nil - ? "\(openHABUrl)/rest/sitemaps/\(name)/\(path!)" - : "\(openHABUrl)/rest/sitemaps/\(name)" - newViewController.openHABRootUrl = openHABUrl + var url = baseUrl.appendingPathComponent("rest") + .appendingPathComponent("sitemaps") + .appendingPathComponent(name) + + if let subpath = path { + url.appendPathComponent(subpath) + } + + guard let newViewController = storyboard?.instantiateViewController(withIdentifier: "OpenHABPageViewController") as? OpenHABSitemapViewController else { + logger.error("pushSitemap: Failed to instantiate OpenHABSitemapViewController") + return + } + newViewController.pageUrl = url.absoluteString + newViewController.openHABRootUrl = activeConnection.configuration.url navigationController?.pushViewController(newViewController, animated: true) } From ddf88fc71593475bb894bb1f9a6ae70fabd78631 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Tue, 29 Apr 2025 16:02:56 +0200 Subject: [PATCH 160/476] Make it testable again Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- OpenHABCore/Tests/OpenHABCoreTests/UserDefaultsTests.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/OpenHABCore/Tests/OpenHABCoreTests/UserDefaultsTests.swift b/OpenHABCore/Tests/OpenHABCoreTests/UserDefaultsTests.swift index 1ed181350..8308173dd 100644 --- a/OpenHABCore/Tests/OpenHABCoreTests/UserDefaultsTests.swift +++ b/OpenHABCore/Tests/OpenHABCoreTests/UserDefaultsTests.swift @@ -13,6 +13,7 @@ import XCTest +@MainActor final class UserDefaultsTests: XCTestCase { let data = UserDefaults(suiteName: "group.org.openhab.app")! From 398fd08965c7749f7de3aef80135322994266a71 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Tue, 29 Apr 2025 20:24:50 +0200 Subject: [PATCH 161/476] Cleanup Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../OpenHABCore/Util/Preferences.swift | 4 +-- .../OpenHABCore/Util/StringExtension.swift | 2 +- .../StringExtensionTests.swift | 25 +++++++++++++++++++ .../OpenHABCoreTests/TestOpenAPIClient.swift | 2 +- 4 files changed, 29 insertions(+), 4 deletions(-) create mode 100644 OpenHABCore/Tests/OpenHABCoreTests/StringExtensionTests.swift diff --git a/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift index e665e62e0..6b48cf490 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift @@ -95,14 +95,14 @@ public struct UserDefaultURL { public var wrappedValue: String { get { let storedValue = Preferences.sharedDefaults.string(forKey: key) ?? defaultValue - let trimmedUri = storedValue.uriWithoutTrailingSlashes().trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedUri = storedValue.removeTrailingSlashes().trimmingCharacters(in: .whitespacesAndNewlines) return trimmedUri.isValidURL ? trimmedUri : defaultValue } set { Preferences.sharedDefaults.set(newValue, forKey: key) let defaultValue = defaultValue // Trim and validate the new URL - let trimmedUri = newValue.uriWithoutTrailingSlashes().trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedUri = newValue.removeTrailingSlashes().trimmingCharacters(in: .whitespacesAndNewlines) DispatchQueue.main.async { [subject] in if trimmedUri.isValidURL { subject.send(trimmedUri) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/StringExtension.swift b/OpenHABCore/Sources/OpenHABCore/Util/StringExtension.swift index 61efbed52..c6ca386d5 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/StringExtension.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/StringExtension.swift @@ -170,7 +170,7 @@ public extension String { } } - func uriWithoutTrailingSlashes() -> String { + func removeTrailingSlashes() -> String { replacing(/\/+$/, with: "") } } diff --git a/OpenHABCore/Tests/OpenHABCoreTests/StringExtensionTests.swift b/OpenHABCore/Tests/OpenHABCoreTests/StringExtensionTests.swift new file mode 100644 index 000000000..b297c7fd6 --- /dev/null +++ b/OpenHABCore/Tests/OpenHABCoreTests/StringExtensionTests.swift @@ -0,0 +1,25 @@ +// Copyright (c) 2010-2025 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 Testing + +struct StringExtensionTests { + @Test func testRemoveTrailingSlashes() async throws { + #expect("example/".removeTrailingSlashes() == "example") + #expect("example//".removeTrailingSlashes() == "example") + #expect("example/path//".removeTrailingSlashes() == "example/path") + #expect("example/path/".removeTrailingSlashes() == "example/path") + #expect("example/path".removeTrailingSlashes() == "example/path") + #expect("/".removeTrailingSlashes() == "") + #expect("///".removeTrailingSlashes() == "") + #expect("".removeTrailingSlashes() == "") + } +} diff --git a/OpenHABCore/Tests/OpenHABCoreTests/TestOpenAPIClient.swift b/OpenHABCore/Tests/OpenHABCoreTests/TestOpenAPIClient.swift index fb6f54d2b..22fd38e81 100644 --- a/OpenHABCore/Tests/OpenHABCoreTests/TestOpenAPIClient.swift +++ b/OpenHABCore/Tests/OpenHABCoreTests/TestOpenAPIClient.swift @@ -32,7 +32,7 @@ final class TestOpenAPIClient: XCTestCase { continueAfterFailure = false } - func testgetRoot() async throws { + func testGetRoot() async throws { transport = .init { (request: HTTPRequest, body: HTTPBody?, baseURL: URL, operationID: String) in XCTAssertEqual(operationID, "getRoot") XCTAssertEqual( From 3583bd455bd29fcd86146fb368ef33e7fb8364c2 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Tue, 29 Apr 2025 22:15:45 +0200 Subject: [PATCH 162/476] Make search work again Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- openHAB/Main.storyboard | 49 +++++++-------- openHAB/OpenHABRootViewController.swift | 24 ++++--- openHAB/OpenHABSitemapViewController.swift | 73 ++++++++-------------- 3 files changed, 66 insertions(+), 80 deletions(-) diff --git a/openHAB/Main.storyboard b/openHAB/Main.storyboard index b3caea893..0cea1c05d 100644 --- a/openHAB/Main.storyboard +++ b/openHAB/Main.storyboard @@ -1,9 +1,9 @@ - + - + @@ -57,12 +57,12 @@ - + @@ -113,7 +113,7 @@ - + @@ -147,7 +147,7 @@ - + @@ -209,7 +209,7 @@ - + @@ -274,7 +274,7 @@ - + @@ -313,7 +313,7 @@ - + @@ -367,7 +367,7 @@ - + @@ -403,7 +403,7 @@ - + @@ -452,7 +452,7 @@ - + @@ -474,7 +474,7 @@ - + @@ -499,7 +499,7 @@ - + @@ -573,7 +573,7 @@ - + @@ -584,7 +584,7 @@ - + @@ -595,13 +595,13 @@ - + - + @@ -616,19 +616,19 @@ - @@ -669,7 +669,6 @@ - @@ -701,7 +700,7 @@ - + @@ -739,7 +738,7 @@ - + @@ -776,7 +775,7 @@ - + diff --git a/openHAB/OpenHABRootViewController.swift b/openHAB/OpenHABRootViewController.swift index 5923d6f00..a4d9bb0b2 100644 --- a/openHAB/OpenHABRootViewController.swift +++ b/openHAB/OpenHABRootViewController.swift @@ -496,19 +496,24 @@ class OpenHABRootViewController: UIViewController { } private func switchView(target: TargetController) { - let targetView = - if case .sitemap = target { - sitemapViewController - } else { - webViewController - } + let targetView: OpenHABViewController + + switch target { + case .sitemap: + targetView = sitemapViewController + case .webview: + targetView = webViewController + default: + return + } if currentView != targetView { - if currentView != nil { + if let currentView { removeView(viewController: currentView) } addView(viewController: targetView) currentView = targetView + // Don't save our view in demo mode if !Preferences.demomode { Preferences.defaultView = currentView.viewName() @@ -517,8 +522,9 @@ class OpenHABRootViewController: UIViewController { // if we hit the menu item again while on the view, trigger a reload currentView.reloadView() } - // make sure we reset any views that may be pushed - currentView.navigationController?.popToRootViewController(animated: true) + + // Make sure we reset any views that may be pushed + navigationController?.popToRootViewController(animated: true) } private func switchToSavedView() { diff --git a/openHAB/OpenHABSitemapViewController.swift b/openHAB/OpenHABSitemapViewController.swift index 8b31e447f..da261334c 100644 --- a/openHAB/OpenHABSitemapViewController.swift +++ b/openHAB/OpenHABSitemapViewController.swift @@ -58,9 +58,6 @@ class OpenHABSitemapViewController: OpenHABViewController, UISearchControllerDel } } - // App wide data access - // https://stackoverflow.com/questions/45832155/how-do-i-refactor-my-code-to-call-appdelegate-on-the-main-thread - var sitemapViewController: OpenHABSitemapViewController? // MARK: - Private instance methods @@ -78,51 +75,44 @@ class OpenHABSitemapViewController: OpenHABViewController, UISearchControllerDel @IBOutlet private var widgetTableView: UITableView! - // Here goes everything about view loading, appearing, disappearing, entering background and becoming active override func viewDidLoad() { super.viewDidLoad() os_log("OpenHABSitemapViewController viewDidLoad", log: .default, type: .info) registerTableViewCells() - - pageNetworkStatus = nil - sitemaps = [] - widgetTableView.tableFooterView = UIView() - - guard let initialConfiguration = activeConnectionInfo?.configuration else { return } - do { - openAPIService = try OpenAPIService(connectionConfiguration: initialConfiguration) - } catch { - logger.error("Could not create OpenAPIService: \(error)") - } - - guard let openAPIService else { return } - // ✅ Initialize PageLoader - pageLoader = PageLoader( - service: openAPIService, - pageId: "", - defaultSitemap: "" - ) - configureTableView() + widgetTableView.tableFooterView = UIView() refreshControl = UIRefreshControl() + refreshControl?.addTarget(self, action: #selector(handleRefresh(_:)), for: .valueChanged) + widgetTableView.refreshControl = refreshControl - refreshControl?.addTarget(self, action: #selector(OpenHABSitemapViewController.handleRefresh(_:)), for: .valueChanged) - if let refreshControl { - widgetTableView.refreshControl = refreshControl - } - - searchController.delegate = self + // Setup search controller searchController.searchResultsUpdater = self searchController.obscuresBackgroundDuringPresentation = false searchController.searchBar.autocapitalizationType = .none - searchController.searchBar.delegate = self // Monitor when the search button is tapped. + searchController.searchBar.delegate = self + searchController.delegate = self searchController.searchBar.placeholder = NSLocalizedString("search_items", comment: "") definesPresentationContext = true + // Assign to navigation item (must be in navigation stack) + navigationItem.searchController = searchController + navigationItem.hidesSearchBarWhenScrolling = false + + // Setup active connection + guard let config = activeConnectionInfo?.configuration else { return } + do { + openAPIService = try OpenAPIService(connectionConfiguration: config) + } catch { + logger.error("Failed to create OpenAPIService: \(error.localizedDescription)") + } + + if let service = openAPIService { + pageLoader = PageLoader(service: service, pageId: "", defaultSitemap: "") + } + #if DEBUG - // setup accessibilityIdentifiers for UITest widgetTableView.accessibilityIdentifier = "OpenHABSitemapViewControllerWidgetTableView" #endif } @@ -131,11 +121,9 @@ class OpenHABSitemapViewController: OpenHABViewController, UISearchControllerDel os_log("OpenHABSitemapViewController viewDidAppear", log: .viewCycle, type: .info) super.viewDidAppear(animated) - // NOTE: workaround for https://github.com/openhab/openhab-ios/issues/420 - if parent?.navigationItem.searchController == nil { - DispatchQueue.main.async { - self.parent?.navigationItem.searchController = self.searchController - } + if parent?.navigationItem.searchController !== searchController { + parent?.navigationItem.searchController = searchController + parent?.navigationItem.hidesSearchBarWhenScrolling = true } } @@ -639,6 +627,7 @@ extension OpenHABSitemapViewController { extension OpenHABSitemapViewController: UISearchResultsUpdating { func updateSearchResults(for searchController: UISearchController) { + logger.info("Search updated: \(searchController.searchBar.text ?? "")") filterContentForSearchText(searchController.searchBar.text) } } @@ -671,15 +660,7 @@ extension OpenHABSitemapViewController: ColorPickerCellDelegate { extension OpenHABSitemapViewController: UITableViewDelegate, UITableViewDataSource { func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - if currentPage != nil { - if isFiltering { - return filteredPage?.widgets.count ?? 0 - } - return currentPage?.widgets.count ?? 0 -// relevantPage?.widgets.count ?? 0 - } else { - return 0 - } + relevantPage?.widgets.count ?? 0 } func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { From c8b7d5dd793a952f3537d42e66949f6fcc303c40 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Wed, 30 Apr 2025 12:50:56 +0200 Subject: [PATCH 163/476] Avoiding a forced optional Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../Sources/OpenHABCore/Model/OpenHABItem.swift | 16 +++++++++++++++- .../Model/OpenHABStateDescription.swift | 9 ++++++++- .../OpenHABCore/Model/OpenHABWidget.swift | 5 +++++ openHAB/OpenHABSitemapViewController.swift | 7 +++++-- 4 files changed, 33 insertions(+), 4 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABItem.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABItem.swift index 7d01d05a1..31b3297f9 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABItem.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABItem.swift @@ -150,9 +150,23 @@ public extension OpenHABItem.CodingData { extension OpenHABItem { init?(_ item: Components.Schemas.EnrichedItemDTO?) { + // unitSymbol + // tags if let item { // swiftlint:disable:next line_length - self.init(name: item.name.orEmpty, type: item._type.orEmpty, state: item.state.orEmpty, link: item.link.orEmpty, label: item.label.orEmpty, groupType: nil, stateDescription: OpenHABStateDescription(item.stateDescription), commandDescription: OpenHABCommandDescription(item.commandDescription), members: [], category: item.category, options: []) + self.init( + name: item.name.orEmpty, + type: item._type.orEmpty, + state: item.state.orEmpty, + link: item.link.orEmpty, + label: item.label.orEmpty, + groupType: nil, + stateDescription: OpenHABStateDescription(item.stateDescription), + commandDescription: OpenHABCommandDescription(item.commandDescription), + members: [], + category: item.category, + options: [] + ) } else { return nil } diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABStateDescription.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABStateDescription.swift index b3687383e..926855125 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABStateDescription.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABStateDescription.swift @@ -63,7 +63,14 @@ extension OpenHABStateDescription.CodingData { extension OpenHABStateDescription { init?(_ state: Components.Schemas.StateDescription?) { if let state { - self.init(minimum: state.minimum, maximum: state.maximum, step: state.step, readOnly: state.readOnly, options: state.options?.compactMap { OpenHABOptions($0) }, pattern: state.pattern) + self.init( + minimum: state.minimum, + maximum: state.maximum, + step: state.step, + readOnly: state.readOnly, + options: state.options?.compactMap { OpenHABOptions($0) }, + pattern: state.pattern + ) } else { return nil } diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift index f650ea901..2f1df82d5 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift @@ -354,6 +354,11 @@ extension [OpenHABWidget] { extension OpenHABWidget { convenience init(_ widget: Components.Schemas.WidgetDTO) { +// widget.unit +// widget.staticIcon +// widget.visibility +// widget.labelSource +// widget.pattern self.init( widgetId: widget.widgetId.orEmpty, label: widget.label.orEmpty, diff --git a/openHAB/OpenHABSitemapViewController.swift b/openHAB/OpenHABSitemapViewController.swift index da261334c..0a073ac59 100644 --- a/openHAB/OpenHABSitemapViewController.swift +++ b/openHAB/OpenHABSitemapViewController.swift @@ -479,9 +479,12 @@ extension OpenHABSitemapViewController { do { // Initial page load + guard let configuration = NetworkTracker.shared.activeConnection?.configuration else { + throw NetworkTrackerError.noActiveConnection + } + if openAPIService == nil { - openAPIService = try OpenAPIService( - connectionConfiguration: NetworkTracker.shared.activeConnection!.configuration) + openAPIService = try OpenAPIService(connectionConfiguration: configuration) } let initialPage = try await openAPIService?.pollDataForPage( From b94e2c9e32e08d5c2180d5254822104e11aedc5e Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Wed, 30 Apr 2025 21:58:28 +0200 Subject: [PATCH 164/476] Port progress on openapigen Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- openHAB/OpenHABRootViewController.swift | 1 + openHAB/SitemapPageView.swift | 40 ++++++++++++------------- openHAB/SitemapPageViewModel.swift | 7 +++-- 3 files changed, 25 insertions(+), 23 deletions(-) diff --git a/openHAB/OpenHABRootViewController.swift b/openHAB/OpenHABRootViewController.swift index d18f04130..89e3f4702 100644 --- a/openHAB/OpenHABRootViewController.swift +++ b/openHAB/OpenHABRootViewController.swift @@ -41,6 +41,7 @@ class HostingSitemapViewController: UIHostingController, OpenHA let viewModel = SitemapPageViewModel() self.viewModel = viewModel super.init(rootView: SitemapPageView(viewModel: viewModel)) + navigationItem.title = "Test" // viewModel.currentPage?.title.components(separatedBy: "[")[0] } @available(*, unavailable) diff --git a/openHAB/SitemapPageView.swift b/openHAB/SitemapPageView.swift index 70e922c85..9b8570f47 100644 --- a/openHAB/SitemapPageView.swift +++ b/openHAB/SitemapPageView.swift @@ -15,29 +15,27 @@ struct SitemapPageView: View { @StateObject public var viewModel = SitemapPageViewModel() var body: some View { - NavigationStack { - List(viewModel.relevantWidgets) { widget in - WidgetViewFactory.view(for: widget) - .onTapGesture { - viewModel.widgetTapped(widget) - } - } - .navigationTitle(viewModel.pageTitle) - .searchable(text: $viewModel.searchText) - .refreshable { - await viewModel.reload() - } - .task { - viewModel.startPageHandling() - } - .alert("Error", isPresented: .constant(viewModel.error != nil), actions: { - Button("OK", role: .cancel) {} - }, message: { - if let error = viewModel.error { - Text(error.localizedDescription) + List(viewModel.relevantWidgets) { widget in + WidgetViewFactory.view(for: widget) + .onTapGesture { + viewModel.widgetTapped(widget) } - }) } + .navigationTitle(viewModel.pageTitle) + .searchable(text: $viewModel.searchText) + .refreshable { + await viewModel.reload() + } + .task { + viewModel.startPageHandling() + } + .alert("Error", isPresented: .constant(viewModel.error != nil), actions: { + Button("OK", role: .cancel) {} + }, message: { + if let error = viewModel.error { + Text(error.localizedDescription) + } + }) } init(viewModel: SitemapPageViewModel) { diff --git a/openHAB/SitemapPageViewModel.swift b/openHAB/SitemapPageViewModel.swift index 7f6279add..e482ce255 100644 --- a/openHAB/SitemapPageViewModel.swift +++ b/openHAB/SitemapPageViewModel.swift @@ -90,9 +90,12 @@ class SitemapPageViewModel: ObservableObject { // ) // } + guard let configuration = NetworkTracker.shared.activeConnection?.configuration else { + throw NetworkTrackerError.noActiveConnection + } + if openAPIService == nil { - openAPIService = try OpenAPIService( - connectionConfiguration: NetworkTracker.shared.activeConnection!.configuration) + openAPIService = try OpenAPIService(connectionConfiguration: configuration) } // 1. Initial page load (longPolling: false) From 9209c2c5edb31db0490837f8d2e89247ee000632 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Thu, 1 May 2025 21:12:19 +0200 Subject: [PATCH 165/476] Better error handling in ConnectionSettingsView Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../OpenHABCore/Model/OpenHABItem.swift | 1 - .../OpenHABCore/Util/NetworkTracker.swift | 2 +- openHAB.xcodeproj/project.pbxproj | 8 ++++ openHAB/OpenHABRootViewController.swift | 1 - openHAB/OpenHABSitemapViewController.swift | 1 - .../SingleConnectionSettingsView.swift | 45 +++++++++++++++++-- 6 files changed, 50 insertions(+), 8 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABItem.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABItem.swift index 31b3297f9..774011112 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABItem.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABItem.swift @@ -153,7 +153,6 @@ extension OpenHABItem { // unitSymbol // tags if let item { - // swiftlint:disable:next line_length self.init( name: item.name.orEmpty, type: item._type.orEmpty, diff --git a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift index d7b29cade..a5066945e 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift @@ -9,7 +9,7 @@ // // SPDX-License-Identifier: EPL-2.0 -import Combine +@preconcurrency import Combine import Foundation import Kingfisher import Network diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index 93c55e8ce..297d2cd70 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -107,6 +107,7 @@ DA7224D223828D3400712D20 /* PreviewConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA7224D123828D3300712D20 /* PreviewConstants.swift */; }; DA72E1B8236DEA0900B8EF3A /* AppMessageService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA72E1B5236DEA0900B8EF3A /* AppMessageService.swift */; }; DA77E19B2D886D9B007CFF0F /* SingleConnectionSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA77E19A2D886D9B007CFF0F /* SingleConnectionSettingsView.swift */; }; + DA7ACD5F2DC3DB130055CFC7 /* SFSafeSymbols in Frameworks */ = {isa = PBXBuildFile; productRef = DA7ACD5E2DC3DB130055CFC7 /* SFSafeSymbols */; settings = {ATTRIBUTES = (Required, ); }; }; 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 */; }; @@ -515,6 +516,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + DA7ACD5F2DC3DB130055CFC7 /* SFSafeSymbols in Frameworks */, 937E4492270B37FE00A98C26 /* Kingfisher in Frameworks */, 937E44E2270B393C00A98C26 /* OpenHABCore in Frameworks */, ); @@ -1091,6 +1093,7 @@ packageProductDependencies = ( 937E4491270B37FE00A98C26 /* Kingfisher */, 937E44E1270B393C00A98C26 /* OpenHABCore */, + DA7ACD5E2DC3DB130055CFC7 /* SFSafeSymbols */, ); productName = openHABIntents; productReference = 4D6470D32561F935007B03FC /* openHABIntents.appex */; @@ -2850,6 +2853,11 @@ package = DA2C4FD32B4F573300D1C533 /* XCRemoteSwiftPackageReference "SDWebImageSVGCoder" */; productName = SDWebImageSVGCoder; }; + DA7ACD5E2DC3DB130055CFC7 /* SFSafeSymbols */ = { + isa = XCSwiftPackageProductDependency; + package = DA3B75AC2C59729200E219AB /* XCRemoteSwiftPackageReference "SFSafeSymbols" */; + productName = SFSafeSymbols; + }; DA9A7EFC2D668D5900824156 /* SFSafeSymbols */ = { isa = XCSwiftPackageProductDependency; package = DA3B75AC2C59729200E219AB /* XCRemoteSwiftPackageReference "SFSafeSymbols" */; diff --git a/openHAB/OpenHABRootViewController.swift b/openHAB/OpenHABRootViewController.swift index a4d9bb0b2..cbe959772 100644 --- a/openHAB/OpenHABRootViewController.swift +++ b/openHAB/OpenHABRootViewController.swift @@ -34,7 +34,6 @@ protocol ModalHandler: AnyObject { private let logger = Logger(subsystem: "org.openhab.UI", category: "OpenHABRootViewController") -// swiftlint:disable type_body_length class OpenHABRootViewController: UIViewController { var currentView: OpenHABViewController! var isDemoMode = false diff --git a/openHAB/OpenHABSitemapViewController.swift b/openHAB/OpenHABSitemapViewController.swift index 0a073ac59..e9c953e02 100644 --- a/openHAB/OpenHABSitemapViewController.swift +++ b/openHAB/OpenHABSitemapViewController.swift @@ -22,7 +22,6 @@ import SafariServices import SwiftUI import UIKit -// swiftlint:disable type_body_length class OpenHABSitemapViewController: OpenHABViewController, UISearchControllerDelegate { var pageUrl = "" private var iconType: IconType = .png diff --git a/openHAB/SettingsView/SingleConnectionSettingsView.swift b/openHAB/SettingsView/SingleConnectionSettingsView.swift index f42c03a75..ff0ee41fe 100644 --- a/openHAB/SettingsView/SingleConnectionSettingsView.swift +++ b/openHAB/SettingsView/SingleConnectionSettingsView.swift @@ -9,10 +9,28 @@ // // SPDX-License-Identifier: EPL-2.0 +import OpenAPIRuntime import OpenHABCore import SFSafeSymbols import SwiftUI +struct SpinningSymbol: View { + @State private var isAnimating = false + + var body: some View { + Image(systemName: "arrow.triangle.2.circlepath") + .rotationEffect(.degrees(isAnimating ? 360 : 0)) + .animation( + Animation.linear(duration: 1.0) + .repeatForever(autoreverses: false), + value: isAnimating + ) + .onAppear { + isAnimating = true + } + } +} + struct SingleConnectionSettingsView: View { var headerText: String @Binding var connectionConfig: ConnectionConfiguration @@ -39,15 +57,16 @@ struct SingleConnectionSettingsView: View { HStack { Text("URL") if isTestingConnection { - ProgressView() - .scaleEffect(0.5) + SpinningSymbol() + .scaleEffect(0.8) } else { Button { Task { await handleTestConnection() } } label: { - Image(systemName: "arrow.clockwise") + Image(systemSymbol: .wifiCircle) + } .buttonStyle(.plain) .foregroundColor(.accentColor) @@ -62,7 +81,7 @@ struct SingleConnectionSettingsView: View { if let message = connectionTestMessage, let success = connectionTestSuccess { HStack(spacing: 4) { - Image(systemName: success ? "checkmark.circle" : "xmark.octagon") + Image(systemSymbol: success ? .checkmarkCircle : .xmarkOctagon) .foregroundColor(success ? .green : .red) Text(message) .foregroundColor(success ? .green : .red) @@ -122,6 +141,24 @@ struct SingleConnectionSettingsView: View { try await testConnection() connectionTestMessage = "Connection successful" connectionTestSuccess = true + } catch is CancellationError { + connectionTestMessage = "Cancellation occurred" + connectionTestSuccess = false + } catch let error as DecodingError { + connectionTestMessage = "Unexpected error: \(error.localizedDescription)" + connectionTestSuccess = false + } catch let error as ClientError { + if let urlError = error.underlyingError as? URLError { + connectionTestMessage = friendlyMessage(for: urlError) + } else { + connectionTestMessage = """ + \(String(describing: error.underlyingError)) + """ + } + connectionTestSuccess = false + } catch let openAPIError as OpenAPIServiceError { + connectionTestMessage = "\(openAPIError.localizedDescription)" + connectionTestSuccess = false } catch let urlError as URLError { connectionTestMessage = friendlyMessage(for: urlError) connectionTestSuccess = false From f77cb369ac36d4c90688d296cc40e95771d6b69f Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Thu, 1 May 2025 22:33:20 +0200 Subject: [PATCH 166/476] Structured concurrency for NWPathMonitor Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../OpenHABCore/Util/NWPathMonitoring.swift | 40 +++++++++++-------- .../OpenHABCore/Util/NetworkTracker.swift | 14 ++----- .../NetworkTrackerTests.swift | 15 +++---- 3 files changed, 34 insertions(+), 35 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/NWPathMonitoring.swift b/OpenHABCore/Sources/OpenHABCore/Util/NWPathMonitoring.swift index 2bb362a87..255d48255 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/NWPathMonitoring.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/NWPathMonitoring.swift @@ -15,41 +15,49 @@ import Network // Wrap real NWPathMonitor final class RealPathMonitor: NWPathMonitoring { private let monitor: NWPathMonitor - private var task: Task? init() { monitor = NWPathMonitor() } - func setUpdateHandler(_ handler: @escaping (Bool) -> Void) { + func startMonitoring(handler: @escaping (Bool) async -> Void) async { if #available(iOS 17, watchOS 10, *) { - task = Task { - for await path in monitor { - handler(path.status == .satisfied) - } + for await path in monitor { + await handler(path.status == .satisfied) } } else { - monitor.pathUpdateHandler = { path in - handler(path.status == .satisfied) + for await path in monitor.paths() { + await handler(path.status == .satisfied) } } } - func start(queue: DispatchQueue) { - monitor.start(queue: queue) - } - func cancel() { monitor.cancel() - task?.cancel() } } // MARK: - Protocol public protocol NWPathMonitoring: AnyObject { - /// Called with `true` when connected, `false` otherwise. - func setUpdateHandler(_ handler: @escaping (Bool) -> Void) - func start(queue: DispatchQueue) + /// Continuously monitors network connectivity status. + /// Calls the handler with `true` when connected, `false` otherwise. + func startMonitoring(handler: @escaping (Bool) async -> Void) async func cancel() } + +// MARK: Extension for version iOS <17 + +extension NWPathMonitor { + func paths() -> AsyncStream { + AsyncStream { continuation in + pathUpdateHandler = { path in + continuation.yield(path) + } + continuation.onTermination = { [weak self] _ in + self?.cancel() + } + start(queue: DispatchQueue(label: "NSPathMonitor.paths")) + } + } +} diff --git a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift index a5066945e..298d4886e 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift @@ -110,7 +110,6 @@ public final class NetworkTracker: ObservableObject { @Published public private(set) var status: NetworkStatus = .connecting private var pathMonitor: NWPathMonitoring - private var monitorQueue: DispatchQueue private var connectionPool: ConnectionPool private var connectionConfigurations: [ConnectionConfiguration] = [] private var retryTask: Task? @@ -126,36 +125,31 @@ public final class NetworkTracker: ObservableObject { private let logger = Logger(subsystem: "org.openhab.core", category: "NetworkTracker") private init() { - monitorQueue = DispatchQueue.global(qos: .background) pathMonitor = RealPathMonitor() connectionPool = ConnectionPool() failureTracker = ConnectionFailureTracker() - pathMonitor.setUpdateHandler { [weak self] isConnected in - Task.detached(priority: .utility) { + Task.detached(priority: .utility) { [weak self] in + await self?.pathMonitor.startMonitoring { isConnected in await self?.handleNetworkChange(isConnected: isConnected) } } - pathMonitor.start(queue: monitorQueue) } // MARK: - Injectable initializer for testing init(monitor: NWPathMonitoring, - monitorQueue: DispatchQueue, connectionPool: ConnectionPool, failureTracker: ConnectionFailureTracker) { pathMonitor = monitor - self.monitorQueue = monitorQueue self.connectionPool = connectionPool self.failureTracker = failureTracker - pathMonitor.setUpdateHandler { [weak self] isConnected in - Task.detached(priority: .utility) { + Task.detached(priority: .utility) { [weak self] in + await self?.pathMonitor.startMonitoring { isConnected in await self?.handleNetworkChange(isConnected: isConnected) } } - pathMonitor.start(queue: monitorQueue) } public func startTracking(connectionConfigurations: [ConnectionConfiguration]) { diff --git a/OpenHABCore/Tests/OpenHABCoreTests/NetworkTrackerTests.swift b/OpenHABCore/Tests/OpenHABCoreTests/NetworkTrackerTests.swift index c4e01385e..fa53adfd7 100644 --- a/OpenHABCore/Tests/OpenHABCoreTests/NetworkTrackerTests.swift +++ b/OpenHABCore/Tests/OpenHABCoreTests/NetworkTrackerTests.swift @@ -83,25 +83,24 @@ final actor MockOpenAPIService: OpenAPIServiceProtocol { } final class MockPathMonitor: NWPathMonitoring { - private var handler: ((Bool) -> Void)? + private var handler: ((Bool) async -> Void)? init() {} - func setUpdateHandler(_ handler: @escaping (Bool) -> Void) { + func startMonitoring(handler: @escaping (Bool) async -> Void) async { self.handler = handler } - func start(queue: DispatchQueue) { - // no-op - } - func cancel() { // no-op } /// Call this in your tests to simulate a connection status change func simulateConnection(isConnected: Bool) { - handler?(isConnected) + guard let handler else { return } + Task { + await handler(isConnected) + } } } @@ -123,7 +122,6 @@ final class NetworkTrackerTests: XCTestCase { let tracker = NetworkTracker( monitor: mockMonitor, - monitorQueue: .main, connectionPool: mockPool, failureTracker: ConnectionFailureTracker() ) @@ -154,7 +152,6 @@ final class NetworkTrackerTests: XCTestCase { let mockMonitor = MockPathMonitor() // ⬅️ Hold on to this let tracker = NetworkTracker( monitor: mockMonitor, - monitorQueue: .main, connectionPool: ConnectionPool { _ in MockOpenAPIService() }, failureTracker: ConnectionFailureTracker() ) From d8c7edf0425eed5ec076936a43ce28587313c79b Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Fri, 2 May 2025 11:19:27 +0200 Subject: [PATCH 167/476] Improve tests Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- OpenHABCore/Tests/OpenHABCoreTests/NetworkTrackerTests.swift | 5 +++-- openHAB/SettingsView/SingleConnectionSettingsView.swift | 1 - 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/OpenHABCore/Tests/OpenHABCoreTests/NetworkTrackerTests.swift b/OpenHABCore/Tests/OpenHABCoreTests/NetworkTrackerTests.swift index fa53adfd7..e69a94f6b 100644 --- a/OpenHABCore/Tests/OpenHABCoreTests/NetworkTrackerTests.swift +++ b/OpenHABCore/Tests/OpenHABCoreTests/NetworkTrackerTests.swift @@ -146,6 +146,7 @@ final class NetworkTrackerTests: XCTestCase { await fulfillment(of: [expectation], timeout: 2.0) } + @MainActor func testTrackerGoesOfflineOnNetworkLoss() async { let expectation = XCTestExpectation(description: "Status becomes .notConnected") @@ -168,13 +169,13 @@ final class NetworkTrackerTests: XCTestCase { .store(in: &cancellables) // Start tracking first to initialize properly - tracker.startTracking(connectionConfigurations: [ + await tracker.startTracking(connectionConfigurations: [ ConnectionConfiguration(url: "http://mock", username: "", password: "", priority: 0) ]) // Simulate loss of network mockMonitor.simulateConnection(isConnected: false) // ✅ use directly - await fulfillment(of: [expectation], timeout: 2.0) + await fulfillment(of: [expectation], timeout: 4.0) } } diff --git a/openHAB/SettingsView/SingleConnectionSettingsView.swift b/openHAB/SettingsView/SingleConnectionSettingsView.swift index ff0ee41fe..af3a4cedf 100644 --- a/openHAB/SettingsView/SingleConnectionSettingsView.swift +++ b/openHAB/SettingsView/SingleConnectionSettingsView.swift @@ -66,7 +66,6 @@ struct SingleConnectionSettingsView: View { } } label: { Image(systemSymbol: .wifiCircle) - } .buttonStyle(.plain) .foregroundColor(.accentColor) From e051fb367aec29471aa570b811c004504702f71c Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Fri, 2 May 2025 22:18:40 +0200 Subject: [PATCH 168/476] Works locally including tests Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../OpenHABCore/Util/NetworkTracker.swift | 46 +++++++------------ .../OpenHABCore/Util/OpenHABItemCache.swift | 6 +-- .../NetworkTrackerTests.swift | 2 +- openHAB/OpenHABRootViewController.swift | 28 ++++++----- openHABWatch/Domain/UserData.swift | 2 +- 5 files changed, 37 insertions(+), 47 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift index 298d4886e..5147e78f4 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift @@ -124,49 +124,37 @@ public final class NetworkTracker: ObservableObject { private let logger = Logger(subsystem: "org.openhab.core", category: "NetworkTracker") - private init() { - pathMonitor = RealPathMonitor() - connectionPool = ConnectionPool() - failureTracker = ConnectionFailureTracker() - - Task.detached(priority: .utility) { [weak self] in - await self?.pathMonitor.startMonitoring { isConnected in - await self?.handleNetworkChange(isConnected: isConnected) - } - } - } - // MARK: - Injectable initializer for testing - init(monitor: NWPathMonitoring, - connectionPool: ConnectionPool, - failureTracker: ConnectionFailureTracker) { + init(monitor: NWPathMonitoring = RealPathMonitor(), + connectionPool: ConnectionPool = ConnectionPool(), + failureTracker: ConnectionFailureTracker = ConnectionFailureTracker()) { pathMonitor = monitor self.connectionPool = connectionPool self.failureTracker = failureTracker + } + + public func startTracking(connectionConfigurations: [ConnectionConfiguration]) async { + logger.info("Start Network Tracking") + self.connectionConfigurations = connectionConfigurations Task.detached(priority: .utility) { [weak self] in await self?.pathMonitor.startMonitoring { isConnected in await self?.handleNetworkChange(isConnected: isConnected) } } - } - public func startTracking(connectionConfigurations: [ConnectionConfiguration]) { - logger.info("Start Network Tracking") - self.connectionConfigurations = connectionConfigurations - Task { - for configuration in connectionConfigurations { - do { - _ = try await connectionPool.getOrCreateService(for: configuration) - } catch { - logger.error("Failed to create service for config: \(configuration.url, privacy: .public) — \(error.localizedDescription)") - // Optionally: show a UI popup or skip to next config - } + for configuration in connectionConfigurations { + do { + _ = try await connectionPool.getOrCreateService(for: configuration) + } catch { + logger.error("Failed to create service for config: \(configuration.url, privacy: .public) — \(error.localizedDescription)") + // Optionally: show a UI popup or skip to next config } - await setActiveConnection(nil) - await attemptConnection() } + + await setActiveConnection(nil) + await attemptConnection() } public func waitForActiveConnection(timeout: TimeInterval = 10) async -> ConnectionInfo? { diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift index 9d91eff4a..92e3bc4cc 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift @@ -35,11 +35,7 @@ public actor OpenHABItemCache { public func setup() async { let connection1 = await Preferences.localConnectionConfig let connection2 = await Preferences.remoteConnectionConfig - NetworkTracker.shared.startTracking(connectionConfigurations: [connection1, connection2]) - } - - private init(connections: [ConnectionConfiguration]) { - NetworkTracker.shared.startTracking(connectionConfigurations: connections) + await NetworkTracker.shared.startTracking(connectionConfigurations: [connection1, connection2]) } public func getItemNames(searchTerm: String?, types: [OpenHABItem.ItemType]?) async -> [String] { diff --git a/OpenHABCore/Tests/OpenHABCoreTests/NetworkTrackerTests.swift b/OpenHABCore/Tests/OpenHABCoreTests/NetworkTrackerTests.swift index e69a94f6b..ea1d7eac2 100644 --- a/OpenHABCore/Tests/OpenHABCoreTests/NetworkTrackerTests.swift +++ b/OpenHABCore/Tests/OpenHABCoreTests/NetworkTrackerTests.swift @@ -138,7 +138,7 @@ final class NetworkTrackerTests: XCTestCase { .store(in: &cancellables) // Start tracking with your mock config - tracker.startTracking(connectionConfigurations: [config]) + await tracker.startTracking(connectionConfigurations: [config]) // Simulate the network becoming available mockMonitor.simulateConnection(isConnected: true) diff --git a/openHAB/OpenHABRootViewController.swift b/openHAB/OpenHABRootViewController.swift index cbe959772..da64ef894 100644 --- a/openHAB/OpenHABRootViewController.swift +++ b/openHAB/OpenHABRootViewController.swift @@ -152,17 +152,23 @@ class OpenHABRootViewController: UIViewController { .sink { (serverInfoTuple, miscTuple) in let (localConnectionConfig, remoteConnectionConfig) = serverInfoTuple let (demomode) = miscTuple - if demomode { - NetworkTracker.shared.startTracking(connectionConfigurations: [ - ConnectionConfiguration( - url: "https://demo.openhab.org", - username: "", - password: "", - priority: 0 - ) - ]) - } else { - NetworkTracker.shared.startTracking(connectionConfigurations: [localConnectionConfig, remoteConnectionConfig]) + + Task { + if demomode { + await NetworkTracker.shared.startTracking(connectionConfigurations: [ + ConnectionConfiguration( + url: "https://demo.openhab.org", + username: "", + password: "", + priority: 0 + ) + ]) + } else { + await NetworkTracker.shared.startTracking(connectionConfigurations: [ + localConnectionConfig, + remoteConnectionConfig + ]) + } } } .store(in: &cancellables) diff --git a/openHABWatch/Domain/UserData.swift b/openHABWatch/Domain/UserData.swift index 501ed98e7..fd01f95f5 100644 --- a/openHABWatch/Domain/UserData.swift +++ b/openHABWatch/Domain/UserData.swift @@ -145,7 +145,7 @@ final class UserData: ObservableObject { logger.info("No connections defined") return } - NetworkTracker.shared.startTracking(connectionConfigurations: [connection1, connection2]) + await NetworkTracker.shared.startTracking(connectionConfigurations: [connection1, connection2]) } func startPageHandling(sitemapName: String, pageId: String = "") { From 146542efd0bdb5dc2da42be3abbbaf9074add10d Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Sun, 4 May 2025 13:25:29 +0200 Subject: [PATCH 169/476] Playing around with CTRL + M to refactor long lines Some small code additions Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../Model/OpenHABSitemapWidgetEvent.swift | 16 ++++- .../OpenHABCore/Model/OpenHABWidget.swift | 60 ++++++++++++++++++- .../OpenHABCore/Util/NetworkTracker.swift | 9 +-- .../OpenHABCore/Util/Preferences.swift | 2 +- .../NetworkTrackerTests.swift | 3 + .../SettingsView/ConnectionSettingsView.swift | 4 +- 6 files changed, 84 insertions(+), 10 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABSitemapWidgetEvent.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABSitemapWidgetEvent.swift index f90a54aab..d13562107 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABSitemapWidgetEvent.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABSitemapWidgetEvent.swift @@ -88,6 +88,20 @@ public extension OpenHABSitemapWidgetEvent { extension OpenHABSitemapWidgetEvent.CodingData { var openHABSitemapWidgetEvent: OpenHABSitemapWidgetEvent { // swiftlint:disable:next line_length - OpenHABSitemapWidgetEvent(sitemapName: sitemapName, pageId: pageId, widgetId: widgetId, label: label, labelSource: labelSource, icon: icon, reloadIcon: reloadIcon, labelcolor: labelcolor, valuecolor: valuecolor, iconcolor: iconcolor, visibility: visibility, enrichedItem: item?.openHABItem, descriptionChanged: descriptionChanged) + OpenHABSitemapWidgetEvent( + sitemapName: sitemapName, + pageId: pageId, + widgetId: widgetId, + label: label, + labelSource: labelSource, + icon: icon, + reloadIcon: reloadIcon, + labelcolor: labelcolor, + valuecolor: valuecolor, + iconcolor: iconcolor, + visibility: visibility, + enrichedItem: item?.openHABItem, + descriptionChanged: descriptionChanged + ) } } diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift index 2f1df82d5..2920b674f 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift @@ -255,7 +255,34 @@ public class OpenHABWidget: NSObject, MKAnnotation, Identifiable, ObservableObje public extension OpenHABWidget { // This is an ugly initializer - convenience init(widgetId: String, label: String, icon: String, type: WidgetType, url: String?, period: String?, minValue: Double?, maxValue: Double?, step: Double?, refresh: Int?, height: Double?, isLeaf: Bool?, iconColor: String?, labelColor: String?, valueColor: String?, service: String?, state: String?, text: String?, legend: Bool?, inputHint: InputHint?, encoding: String?, item: OpenHABItem?, linkedPage: OpenHABPage?, mappings: [OpenHABWidgetMapping], widgets: [OpenHABWidget], visibility: Bool?, switchSupport: Bool?, forceAsItem: Bool?) { + convenience init(widgetId: String, + label: String, + icon: String, + type: WidgetType, + url: String?, + period: String?, + minValue: Double?, + maxValue: Double?, + step: Double?, + refresh: Int?, + height: Double?, + isLeaf: Bool?, + iconColor: String?, + labelColor: String?, + valueColor: String?, + service: String?, + state: String?, + text: String?, + legend: Bool?, + inputHint: InputHint?, + encoding: String?, + item: OpenHABItem?, + linkedPage: OpenHABPage?, + mappings: [OpenHABWidgetMapping], + widgets: [OpenHABWidget], + visibility: Bool?, + switchSupport: Bool?, + forceAsItem: Bool?) { self.init() id = widgetId self.widgetId = widgetId @@ -338,7 +365,36 @@ public extension OpenHABWidget.CodingData { var openHABWidget: OpenHABWidget { let mappedWidgets = widgets.map(\.openHABWidget) // swiftlint:disable:next line_length - return OpenHABWidget(widgetId: widgetId, label: label, icon: icon, type: type, url: url, period: period, minValue: minValue, maxValue: maxValue, step: step, refresh: refresh, height: height, isLeaf: isLeaf, iconColor: iconcolor, labelColor: labelcolor, valueColor: valuecolor, service: service, state: state, text: text, legend: legend, inputHint: inputHint, encoding: encoding, item: item?.openHABItem, linkedPage: linkedPage?.openHABSitemapPage, mappings: mappings, widgets: mappedWidgets, visibility: visibility, switchSupport: switchSupport, forceAsItem: forceAsItem) + return OpenHABWidget( + widgetId: widgetId, + label: label, + icon: icon, + type: type, + url: url, + period: period, + minValue: minValue, + maxValue: maxValue, + step: step, + refresh: refresh, + height: height, + isLeaf: isLeaf, + iconColor: iconcolor, + labelColor: labelcolor, + valueColor: valuecolor, + service: service, + state: state, + text: text, + legend: legend, + inputHint: inputHint, + encoding: encoding, + item: item?.openHABItem, + linkedPage: linkedPage?.openHABSitemapPage, + mappings: mappings, + widgets: mappedWidgets, + visibility: visibility, + switchSupport: switchSupport, + forceAsItem: forceAsItem + ) } } diff --git a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift index 5147e78f4..9705d1ee0 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift @@ -36,7 +36,7 @@ public struct ConnectionInfo: Equatable, Sendable { } } -public enum NetworkTrackerError: Error, CustomDebugStringConvertible { +public enum NetworkTrackerError: Error, CustomDebugStringConvertible, Sendable { case invalidServerVersion case failedConnection(String) case noActiveConnection @@ -314,7 +314,8 @@ public final class NetworkTracker: ObservableObject { private func startRetryTask(_ initialRetryInterval: UInt64) { retryTask?.cancel() - retryTask = Task { + retryTask = Task { [weak self] in + guard let self else { return } let backoffMultiplier = await UInt64(failureTracker.maxFailureCount()) let safeBackoff = min(backoffMultiplier, 10) // 2^10 = 1024 let delay = min(initialRetryInterval * (1 << safeBackoff), 300) @@ -363,7 +364,7 @@ public final class NetworkTracker: ObservableObject { } } -public protocol NetworkTracking: ObservableObject { +public protocol NetworkTracking: ObservableObject, Sendable { var activeConnection: ConnectionInfo? { get } } @@ -423,7 +424,7 @@ public extension NetworkTracker { let cancellable = self.$activeConnection .sink { continuation.yield($0) } - continuation.onTermination = { _ in cancellable.cancel() } + continuation.onTermination = { [cancellable] _ in cancellable.cancel() } } } } diff --git a/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift index 6b48cf490..025ba48ff 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift @@ -9,7 +9,7 @@ // // SPDX-License-Identifier: EPL-2.0 -import Combine +@preconcurrency import Combine import os.log import UIKit diff --git a/OpenHABCore/Tests/OpenHABCoreTests/NetworkTrackerTests.swift b/OpenHABCore/Tests/OpenHABCoreTests/NetworkTrackerTests.swift index ea1d7eac2..de46d9177 100644 --- a/OpenHABCore/Tests/OpenHABCoreTests/NetworkTrackerTests.swift +++ b/OpenHABCore/Tests/OpenHABCoreTests/NetworkTrackerTests.swift @@ -173,6 +173,9 @@ final class NetworkTrackerTests: XCTestCase { ConnectionConfiguration(url: "http://mock", username: "", password: "", priority: 0) ]) + // Add small delay to let Combine attach the sink before simulating + try? await Task.sleep(nanoseconds: 100_000_000) // 100ms + // Simulate loss of network mockMonitor.simulateConnection(isConnected: false) // ✅ use directly diff --git a/openHAB/SettingsView/ConnectionSettingsView.swift b/openHAB/SettingsView/ConnectionSettingsView.swift index 6deda2f93..dbe92651f 100644 --- a/openHAB/SettingsView/ConnectionSettingsView.swift +++ b/openHAB/SettingsView/ConnectionSettingsView.swift @@ -22,8 +22,8 @@ struct ConnectionSettingsView: View { Toggle("Demo Mode", isOn: $settingsDemomode) if !settingsDemomode { - SingleConnectionSettingsView(headerText: "Configuration for local server", connectionConfig: $localConnectionConfiguration) - SingleConnectionSettingsView(headerText: "Configuration for remote server", connectionConfig: $remoteConnectionConfiguration) + SingleConnectionSettingsView(headerText: "Local server", connectionConfig: $localConnectionConfiguration) + SingleConnectionSettingsView(headerText: "Remote server", connectionConfig: $remoteConnectionConfiguration) } } } From 537c1044f1f27684b23e42bc27a61b5b53b12048 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Sun, 4 May 2025 17:21:31 +0200 Subject: [PATCH 170/476] Preparing for Swift 6: Setting SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY, SWIFT_UPCOMING_FEATURE_NONFROZEN_ENUM_EXHAUSTIVITY, SWIFT_UPCOMING_FEATURE_EXISTENTIAL_ANY to YES Systematic usage of SFSafeSymbols with .swiftlint custom rule Code refactored to align with swiftlint rules Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- BuildTools/.swiftlint.yml | 5 ++ .../Model/OpenHABSitemapWidgetEvent.swift | 1 - .../OpenHABCore/Model/OpenHABWidget.swift | 1 - .../Sources/OpenHABCore/Util/HTTPClient.swift | 2 +- .../OpenHABCore/Util/NetworkTracker.swift | 12 ++-- .../Util/OpenHABAccessTokenAdapter.swift | 4 +- .../Util/OpenHABImageProcessor.swift | 5 +- openHAB.xcodeproj/project.pbxproj | 24 +++++-- openHAB/AppDelegate.swift | 2 +- ....swift => RollershutterCellProvider.swift} | 0 openHAB/Cells/WidgetCellProvider.swift | 2 +- openHAB/ClientCertificatesViewModel.swift | 1 + openHAB/ColorPickerCell.swift | 2 +- openHAB/ColorPickerViewController.swift | 4 +- openHAB/DrawerView.swift | 1 + openHAB/GenericUITableViewCell.swift | 2 +- openHAB/LoggerView.swift | 3 +- openHAB/NewImageUITableViewCell.swift | 1 + openHAB/NotificationTableViewCell.swift | 1 + openHAB/NotificationsView.swift | 67 +++++++++---------- openHAB/OpenHABRootViewController.swift | 3 +- openHAB/OpenHABSitemapViewController.swift | 8 +-- openHAB/OpenHABWebViewController.swift | 4 +- openHAB/RTFTextView.swift | 1 + openHAB/SegmentedUITableViewCell.swift | 1 + openHAB/SelectionUITableViewCell.swift | 1 + openHAB/SelectionView.swift | 1 + .../AnimatedSecureTextField.swift | 1 + openHAB/SettingsView/DebugSettingsView.swift | 1 + openHAB/SettingsView/MainUISettingsView.swift | 1 + openHAB/SettingsView/SettingsView.swift | 2 +- .../SingleConnectionSettingsView.swift | 2 +- ...iderWithSwitchSupportUITableViewCell.swift | 1 + openHAB/Throttler.swift | 1 + ...r.swift => UITableViewCellExtension.swift} | 0 openHAB/UIViewController+Localization.swift | 1 + openHAB/WatchMessageService.swift | 2 +- openHAB/WebUITableViewCell.swift | 4 +- .../SetDimmerRollerValueIntentHandler.swift | 6 +- openHABWatch/External/AppMessageService.swift | 12 ++-- .../Views/PreferencesSwiftUIView.swift | 60 ++++++++--------- .../Views/Utils/ImageWithAction.swift | 6 +- 42 files changed, 145 insertions(+), 114 deletions(-) rename openHAB/Cells/Providers/{RollerShutterCellProvider.swift => RollershutterCellProvider.swift} (100%) rename openHAB/{WidgetIconRenderer.swift => UITableViewCellExtension.swift} (100%) diff --git a/BuildTools/.swiftlint.yml b/BuildTools/.swiftlint.yml index 3f51b9aa9..8267e37de 100644 --- a/BuildTools/.swiftlint.yml +++ b/BuildTools/.swiftlint.yml @@ -93,6 +93,11 @@ custom_rules: regex: '((?:\s*\n){3,})' message: "There are too many line breaks" severity: error + sf_safe_symbol: + name: "Safe SFSymbol" + message: "Use `SFSafeSymbols` via `systemSymbol` parameters for type safety." + regex: "(Image\\(systemName:)|(NSImage\\(symbolName:)|(Label[^,]+?,\\s*systemImage:)|(UIApplicationShortcutIcon\\(systemImageName:)" + severity: warning file_name: suffix_pattern: "Extension?|\\+.*" diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABSitemapWidgetEvent.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABSitemapWidgetEvent.swift index d13562107..6dbafd9ed 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABSitemapWidgetEvent.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABSitemapWidgetEvent.swift @@ -87,7 +87,6 @@ public extension OpenHABSitemapWidgetEvent { extension OpenHABSitemapWidgetEvent.CodingData { var openHABSitemapWidgetEvent: OpenHABSitemapWidgetEvent { - // swiftlint:disable:next line_length OpenHABSitemapWidgetEvent( sitemapName: sitemapName, pageId: pageId, diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift index 2920b674f..e90becec9 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift @@ -364,7 +364,6 @@ public extension OpenHABWidget { public extension OpenHABWidget.CodingData { var openHABWidget: OpenHABWidget { let mappedWidgets = widgets.map(\.openHABWidget) - // swiftlint:disable:next line_length return OpenHABWidget( widgetId: widgetId, label: label, diff --git a/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift b/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift index 616ca818c..e76d0be7b 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift @@ -51,7 +51,7 @@ public enum CertificateEvaluateResult { case permitAlways } -public class HTTPClient: NSObject { +public final class HTTPClient: NSObject { // MARK: - Properties public enum SessionType { diff --git a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift index 9705d1ee0..3187d6e52 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift @@ -101,6 +101,10 @@ public actor ConnectionFailureTracker { } } +public protocol NetworkTracking: ObservableObject, Sendable { + var activeConnection: ConnectionInfo? { get } +} + public final class NetworkTracker: ObservableObject { public static let shared = NetworkTracker() @@ -296,8 +300,8 @@ public final class NetworkTracker: ObservableObject { return nil } catch let error as OpenAPIServiceError { switch error { - case let .undocumented(statusCode, _): - logger.info("Undocumented status code: ") // \(statusCode), ") // payload: \(String(describing: payload))") + case let .undocumented(statusCode, payload): + logger.info("Undocumented status code: \(statusCode), payload: \(String(describing: payload))") return nil default: return nil @@ -364,10 +368,6 @@ public final class NetworkTracker: ObservableObject { } } -public protocol NetworkTracking: ObservableObject, Sendable { - var activeConnection: ConnectionInfo? { get } -} - extension NetworkTracker: NetworkTracking {} public extension NetworkTracker { diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OpenHABAccessTokenAdapter.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABAccessTokenAdapter.swift index b84e37c55..ec4354429 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/OpenHABAccessTokenAdapter.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABAccessTokenAdapter.swift @@ -12,8 +12,8 @@ import Foundation import Kingfisher -public class OpenHABAccessTokenAdapter { - var connectionConfiguration: ConnectionConfiguration? +public final class OpenHABAccessTokenAdapter { + let connectionConfiguration: ConnectionConfiguration? public init(connectionConfiguration: ConnectionConfiguration) { self.connectionConfiguration = connectionConfiguration diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OpenHABImageProcessor.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABImageProcessor.swift index dfc17ba73..dabd491a3 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/OpenHABImageProcessor.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABImageProcessor.swift @@ -13,6 +13,7 @@ import Foundation import Kingfisher import os.log import SDWebImageSVGCoder +import SFSafeSymbols public struct OpenHABImageProcessor: ImageProcessor { // `identifier` should be the same for processors with the same properties/functionality @@ -37,11 +38,11 @@ public struct OpenHABImageProcessor: ImageProcessor { if let image = SDImageSVGCoder.shared.decodedImage(with: data, options: nil) { let size = image.size if size.width > 1000 || size.height > 1000 { - return UIImage(systemName: "exclamationmark.triangle")?.withTintColor(.orange, renderingMode: .alwaysOriginal) + return UIImage(systemSymbol: .exclamationmarkTriangle).withTintColor(.orange, renderingMode: .alwaysOriginal) } return image } else { - return UIImage(systemName: "exclamationmark.triangle")?.withTintColor(.orange, renderingMode: .alwaysOriginal) + return UIImage(systemSymbol: .exclamationmarkTriangle).withTintColor(.orange, renderingMode: .alwaysOriginal) } default: return Kingfisher.DefaultImageProcessor().process(item: item, options: KingfisherParsedOptionsInfo(KingfisherManager.shared.defaultOptions)) diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index 297d2cd70..3752ec689 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -72,6 +72,7 @@ DA07764A234683BC0086C685 /* SwitchRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA077649234683BC0086C685 /* SwitchRow.swift */; }; DA0776F0234788010086C685 /* UserData.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA0776EF234788010086C685 /* UserData.swift */; }; DA0F37D023D4ACC7007EAB48 /* SliderRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA0F37CF23D4ACC7007EAB48 /* SliderRow.swift */; }; + DA10161B2DC7BAE500552D14 /* SFSafeSymbols in Frameworks */ = {isa = PBXBuildFile; productRef = DA10161A2DC7BAE500552D14 /* SFSafeSymbols */; }; DA15BFBD23C6726400BD8ADA /* AppSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA15BFBC23C6726400BD8ADA /* AppSettings.swift */; }; DA162BEC2CD3B53E0040DAE5 /* LogsViewer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA162BEB2CD3B53E0040DAE5 /* LogsViewer.swift */; }; DA19E25B22FD801D002F8F2F /* OpenHABGeneralTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA19E25A22FD801D002F8F2F /* OpenHABGeneralTests.swift */; }; @@ -79,7 +80,7 @@ DA242C622C83588600AFB10D /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA242C612C83588600AFB10D /* SettingsView.swift */; }; DA28C362225241DE00AB409C /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DA28C361225241DE00AB409C /* WebKit.framework */; settings = {ATTRIBUTES = (Required, ); }; }; DA2AEB6E2D92BAD800897D80 /* PageLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2AEB6D2D92BAD800897D80 /* PageLoader.swift */; }; - DA2AEB702D92CF3E00897D80 /* WidgetIconRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2AEB6F2D92CF3E00897D80 /* WidgetIconRenderer.swift */; }; + DA2AEB702D92CF3E00897D80 /* UITableViewCellExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2AEB6F2D92CF3E00897D80 /* UITableViewCellExtension.swift */; }; DA2AEBA02D92FB6500897D80 /* NoIconDisplayableCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2AEB9F2D92FB6500897D80 /* NoIconDisplayableCell.swift */; }; DA2C4FD52B4F573300D1C533 /* SDWebImageSVGCoder in Frameworks */ = {isa = PBXBuildFile; productRef = DA2C4FD42B4F573300D1C533 /* SDWebImageSVGCoder */; }; DA2E0AA423DC96E9009B0A99 /* ImageWithAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2E0AA323DC96E9009B0A99 /* ImageWithAction.swift */; }; @@ -391,7 +392,7 @@ DA242C612C83588600AFB10D /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; DA28C361225241DE00AB409C /* WebKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WebKit.framework; path = System/Library/Frameworks/WebKit.framework; sourceTree = SDKROOT; }; DA2AEB6D2D92BAD800897D80 /* PageLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageLoader.swift; sourceTree = ""; }; - DA2AEB6F2D92CF3E00897D80 /* WidgetIconRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetIconRenderer.swift; sourceTree = ""; }; + DA2AEB6F2D92CF3E00897D80 /* UITableViewCellExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITableViewCellExtension.swift; sourceTree = ""; }; DA2AEB9F2D92FB6500897D80 /* NoIconDisplayableCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoIconDisplayableCell.swift; sourceTree = ""; }; DA2DC22F21F2736C00830730 /* openHABTestsSwift.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = openHABTestsSwift.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; DA2DC23321F2736C00830730 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -526,6 +527,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + DA10161B2DC7BAE500552D14 /* SFSafeSymbols in Frameworks */, 657144962C30A16700C8A1F3 /* OpenHABCore in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -924,7 +926,7 @@ DA9F81862C85020F00B47B72 /* RTFTextView.swift */, DAC65FC6236EDF3900F4501E /* SpinnerViewController.swift */, DA6B2EF62C8B92E800DF77CF /* SelectionView.swift */, - DA2AEB6F2D92CF3E00897D80 /* WidgetIconRenderer.swift */, + DA2AEB6F2D92CF3E00897D80 /* UITableViewCellExtension.swift */, DA48001F2D837CD8009CF127 /* SettingsView */, 1224F78B228A89E300750965 /* Watch */, DF4B84101886DA9900F34902 /* Widgets */, @@ -1114,6 +1116,7 @@ name = NotificationService; packageProductDependencies = ( 657144952C30A16700C8A1F3 /* OpenHABCore */, + DA10161A2DC7BAE500552D14 /* SFSafeSymbols */, ); productName = NotificationService; productReference = 6571444E2C1E438700C8A1F3 /* NotificationService.appex */; @@ -1612,7 +1615,7 @@ 2FEFD8F62BE7C5BE00E387B9 /* TextInputUITableViewCell.swift in Sources */, 938EDCE122C4FEB800661CA1 /* ScaleAspectFitImageView.swift in Sources */, DA2AEBA02D92FB6500897D80 /* NoIconDisplayableCell.swift in Sources */, - DA2AEB702D92CF3E00897D80 /* WidgetIconRenderer.swift in Sources */, + DA2AEB702D92CF3E00897D80 /* UITableViewCellExtension.swift in Sources */, DA4800162D836EF0009CF127 /* MainUISettingsView.swift in Sources */, DAEAA89F21E6B16600267EA3 /* UITableView.swift in Sources */, DFB2624418830A3600D3244D /* OpenHABSitemapViewController.swift in Sources */, @@ -2523,6 +2526,8 @@ SWIFT_UPCOMING_FEATURE_CONCISE_MAGIC_FILE = YES; SWIFT_UPCOMING_FEATURE_DEPRECATE_APPLICATION_MAIN = YES; SWIFT_UPCOMING_FEATURE_DISABLE_OUTWARD_ACTOR_ISOLATION = YES; + SWIFT_UPCOMING_FEATURE_DYNAMIC_ACTOR_ISOLATION = NO; + SWIFT_UPCOMING_FEATURE_EXISTENTIAL_ANY = YES; SWIFT_UPCOMING_FEATURE_FORWARD_TRAILING_CLOSURES = YES; SWIFT_UPCOMING_FEATURE_GLOBAL_CONCURRENCY = YES; SWIFT_UPCOMING_FEATURE_IMPLICIT_OPEN_EXISTENTIALS = YES; @@ -2530,6 +2535,8 @@ SWIFT_UPCOMING_FEATURE_INFER_SENDABLE_FROM_CAPTURES = YES; SWIFT_UPCOMING_FEATURE_INTERNAL_IMPORTS_BY_DEFAULT = NO; SWIFT_UPCOMING_FEATURE_ISOLATED_DEFAULT_VALUES = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_UPCOMING_FEATURE_NONFROZEN_ENUM_EXHAUSTIVITY = YES; SWIFT_UPCOMING_FEATURE_REGION_BASED_ISOLATION = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -2583,6 +2590,8 @@ SWIFT_UPCOMING_FEATURE_CONCISE_MAGIC_FILE = YES; SWIFT_UPCOMING_FEATURE_DEPRECATE_APPLICATION_MAIN = YES; SWIFT_UPCOMING_FEATURE_DISABLE_OUTWARD_ACTOR_ISOLATION = YES; + SWIFT_UPCOMING_FEATURE_DYNAMIC_ACTOR_ISOLATION = NO; + SWIFT_UPCOMING_FEATURE_EXISTENTIAL_ANY = YES; SWIFT_UPCOMING_FEATURE_FORWARD_TRAILING_CLOSURES = YES; SWIFT_UPCOMING_FEATURE_GLOBAL_CONCURRENCY = YES; SWIFT_UPCOMING_FEATURE_IMPLICIT_OPEN_EXISTENTIALS = YES; @@ -2590,6 +2599,8 @@ SWIFT_UPCOMING_FEATURE_INFER_SENDABLE_FROM_CAPTURES = YES; SWIFT_UPCOMING_FEATURE_INTERNAL_IMPORTS_BY_DEFAULT = NO; SWIFT_UPCOMING_FEATURE_ISOLATED_DEFAULT_VALUES = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_UPCOMING_FEATURE_NONFROZEN_ENUM_EXHAUSTIVITY = YES; SWIFT_UPCOMING_FEATURE_REGION_BASED_ISOLATION = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -2848,6 +2859,11 @@ package = 93F8064E27AE7A820035A6B0 /* XCRemoteSwiftPackageReference "SideMenu" */; productName = SideMenu; }; + DA10161A2DC7BAE500552D14 /* SFSafeSymbols */ = { + isa = XCSwiftPackageProductDependency; + package = DA3B75AC2C59729200E219AB /* XCRemoteSwiftPackageReference "SFSafeSymbols" */; + productName = SFSafeSymbols; + }; DA2C4FD42B4F573300D1C533 /* SDWebImageSVGCoder */ = { isa = XCSwiftPackageProductDependency; package = DA2C4FD32B4F573300D1C533 /* XCRemoteSwiftPackageReference "SDWebImageSVGCoder" */; diff --git a/openHAB/AppDelegate.swift b/openHAB/AppDelegate.swift index e36631dc5..fd4aa5907 100644 --- a/openHAB/AppDelegate.swift +++ b/openHAB/AppDelegate.swift @@ -165,7 +165,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // Do nothing now, we are using FCM } - func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { + func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: any Error) { os_log("Failed to get token for notifications: %{PUBLIC}@", log: .notifications, type: .error, error.localizedDescription) } diff --git a/openHAB/Cells/Providers/RollerShutterCellProvider.swift b/openHAB/Cells/Providers/RollershutterCellProvider.swift similarity index 100% rename from openHAB/Cells/Providers/RollerShutterCellProvider.swift rename to openHAB/Cells/Providers/RollershutterCellProvider.swift diff --git a/openHAB/Cells/WidgetCellProvider.swift b/openHAB/Cells/WidgetCellProvider.swift index 9847cf119..b2ac83d54 100644 --- a/openHAB/Cells/WidgetCellProvider.swift +++ b/openHAB/Cells/WidgetCellProvider.swift @@ -14,7 +14,7 @@ import OpenHABCore import UIKit enum WidgetCellFactory { - static func provider(for widget: OpenHABWidget) -> WidgetCellProvider { + static func provider(for widget: OpenHABWidget) -> any WidgetCellProvider { switch widget.type { case .switchWidget: if !widget.mappings.isEmpty { diff --git a/openHAB/ClientCertificatesViewModel.swift b/openHAB/ClientCertificatesViewModel.swift index 5416d9208..99da977c6 100644 --- a/openHAB/ClientCertificatesViewModel.swift +++ b/openHAB/ClientCertificatesViewModel.swift @@ -9,6 +9,7 @@ // // SPDX-License-Identifier: EPL-2.0 +import Combine import OpenHABCore import os.log import SwiftUI diff --git a/openHAB/ColorPickerCell.swift b/openHAB/ColorPickerCell.swift index a0c5591ee..cd19aaa74 100644 --- a/openHAB/ColorPickerCell.swift +++ b/openHAB/ColorPickerCell.swift @@ -18,7 +18,7 @@ protocol ColorPickerCellDelegate: NSObjectProtocol { } class ColorPickerCell: GenericUITableViewCell { - weak var delegate: ColorPickerCellDelegate? + weak var delegate: (any ColorPickerCellDelegate)? @IBOutlet private var downButton: UIButton! @IBOutlet private var upButton: UIButton! diff --git a/openHAB/ColorPickerViewController.swift b/openHAB/ColorPickerViewController.swift index 9e2b232ea..cf9750220 100644 --- a/openHAB/ColorPickerViewController.swift +++ b/openHAB/ColorPickerViewController.swift @@ -73,7 +73,7 @@ class ColorPickerViewController: DefaultColorPickerViewController { } extension ColorPickerViewController: ColorPickerDelegate { - func colorPicker(_ colorPicker: ColorPickerController, selectedColor: UIColor, usingControl: ColorControl) { + func colorPicker(_ colorPicker: ColorPickerController, selectedColor: UIColor, usingControl: any ColorControl) { if let throttler { throttler.throttle { DispatchQueue.main.async { self.sendColorUpdate(color: selectedColor) } } } else { @@ -81,7 +81,7 @@ extension ColorPickerViewController: ColorPickerDelegate { } } - func colorPicker(_ colorPicker: ColorPickerController, confirmedColor: UIColor, usingControl: ColorControl) { + func colorPicker(_ colorPicker: ColorPickerController, confirmedColor: UIColor, usingControl: any ColorControl) { sendColorUpdate(color: confirmedColor) } } diff --git a/openHAB/DrawerView.swift b/openHAB/DrawerView.swift index 00d1078fc..592a5a414 100644 --- a/openHAB/DrawerView.swift +++ b/openHAB/DrawerView.swift @@ -9,6 +9,7 @@ // // SPDX-License-Identifier: EPL-2.0 +import Combine import Kingfisher import OpenHABCore import os.log diff --git a/openHAB/GenericUITableViewCell.swift b/openHAB/GenericUITableViewCell.swift index e6111d825..7fae0c873 100644 --- a/openHAB/GenericUITableViewCell.swift +++ b/openHAB/GenericUITableViewCell.swift @@ -27,7 +27,7 @@ class GenericUITableViewCell: UITableViewCell { private var _widget: OpenHABWidget! // optional event callback if table cells neeed to notify on touch up or down events - weak var touchEventDelegate: GenericUITableViewCellTouchEventDelegate? + weak var touchEventDelegate: (any GenericUITableViewCellTouchEventDelegate)? var widget: OpenHABWidget! { get { diff --git a/openHAB/LoggerView.swift b/openHAB/LoggerView.swift index 4d939500f..9a59bfb5b 100644 --- a/openHAB/LoggerView.swift +++ b/openHAB/LoggerView.swift @@ -10,6 +10,7 @@ // SPDX-License-Identifier: EPL-2.0 import OSLog +import SFSafeSymbols import SwiftUI struct LogEntry: Identifiable, Hashable { @@ -58,7 +59,7 @@ struct LoggerView: View { item: shareLogs(), preview: SharePreview("Share Log Data") ) { - Label("Share", systemImage: "square.and.arrow.up") + Label("Share", systemSymbol: .squareAndArrowUp) } .disabled(logs.isEmpty) } diff --git a/openHAB/NewImageUITableViewCell.swift b/openHAB/NewImageUITableViewCell.swift index 995eca0b1..042e945db 100644 --- a/openHAB/NewImageUITableViewCell.swift +++ b/openHAB/NewImageUITableViewCell.swift @@ -9,6 +9,7 @@ // // SPDX-License-Identifier: EPL-2.0 +import Combine import OpenHABCore import os.log import UIKit diff --git a/openHAB/NotificationTableViewCell.swift b/openHAB/NotificationTableViewCell.swift index f5357cb86..4b924e497 100644 --- a/openHAB/NotificationTableViewCell.swift +++ b/openHAB/NotificationTableViewCell.swift @@ -9,6 +9,7 @@ // // SPDX-License-Identifier: EPL-2.0 +import OpenHABCore import os.log import UIKit diff --git a/openHAB/NotificationsView.swift b/openHAB/NotificationsView.swift index 4cc225bf9..bcdedb129 100644 --- a/openHAB/NotificationsView.swift +++ b/openHAB/NotificationsView.swift @@ -9,11 +9,14 @@ // // SPDX-License-Identifier: EPL-2.0 +import Combine import Kingfisher import OpenHABCore import os.log import SwiftUI +typealias NotificationLoader = () async -> [OpenHABNotification] + struct NotificationRow: View { var notification: OpenHABNotification var connection: ConnectionInfo @@ -69,7 +72,35 @@ struct NotificationRow: View { } } -typealias NotificationLoader = () async -> [OpenHABNotification] +final class MockNetworkTracker: NetworkTracking, ObservableObject { + @Published var activeConnection: ConnectionInfo? + + init(connection: ConnectionInfo?) { + activeConnection = connection + } +} + +struct NotificationsViewPreview: View { + var body: some View { + let mockTracker = MockNetworkTracker(connection: .mock) + return NotificationsView(networkTracker: mockTracker, notifications: []) { + [ + OpenHABNotification( + message: "Preview Notification 1", + created: .now, + icon: "sun", + id: UUID().uuidString + ), + OpenHABNotification( + message: "Preview Notification 2", + created: .now.addingTimeInterval(-3600), + icon: "moon", + id: UUID().uuidString + ) + ] + } + } +} struct NotificationsView: View where Tracker: ObservableObject { @ObservedObject var networkTracker: Tracker @@ -137,40 +168,6 @@ public extension ConnectionInfo { } } -final class MockNetworkTracker: NetworkTracking, ObservableObject { - @Published var activeConnection: ConnectionInfo? - - init(connection: ConnectionInfo?) { - activeConnection = connection - } -} - -struct NotificationsViewPreview: View { - var body: some View { - let mockTracker = MockNetworkTracker(connection: .mock) - return NotificationsView( - networkTracker: mockTracker, - notifications: [], - loadNotifications: { - [ - OpenHABNotification( - message: "Preview Notification 1", - created: .now, - icon: "sun", - id: UUID().uuidString - ), - OpenHABNotification( - message: "Preview Notification 2", - created: .now.addingTimeInterval(-3600), - icon: "moon", - id: UUID().uuidString - ) - ] - } - ) - } -} - #Preview { NotificationsViewPreview() } diff --git a/openHAB/OpenHABRootViewController.swift b/openHAB/OpenHABRootViewController.swift index da64ef894..1e47f3b20 100644 --- a/openHAB/OpenHABRootViewController.swift +++ b/openHAB/OpenHABRootViewController.swift @@ -15,6 +15,7 @@ import Foundation import OpenHABCore import os.log import SafariServices +import SFSafeSymbols import SideMenu import SwiftUI import UIKit @@ -572,8 +573,6 @@ class OpenHABRootViewController: UIViewController { } } -// swiftlint:enable type_body_length - // MARK: - UISideMenuNavigationControllerDelegate extension OpenHABRootViewController: SideMenuNavigationControllerDelegate { diff --git a/openHAB/OpenHABSitemapViewController.swift b/openHAB/OpenHABSitemapViewController.swift index e9c953e02..dc9292d94 100644 --- a/openHAB/OpenHABSitemapViewController.swift +++ b/openHAB/OpenHABSitemapViewController.swift @@ -19,6 +19,8 @@ import OpenAPIURLSession import OpenHABCore import os.log import SafariServices +import SFSafeSymbols +import SwiftMessages import SwiftUI import UIKit @@ -623,8 +625,6 @@ extension OpenHABSitemapViewController { } } -// swiftlint:enable type_body_length - // MARK: - UISearchResultsUpdating extension OpenHABSitemapViewController: UISearchResultsUpdating { @@ -709,7 +709,7 @@ extension OpenHABSitemapViewController: UITableViewDelegate, UITableViewDataSour iconColor = "white" } // No icon will be displazed for cells that conform to NoIconDisplayableCell protocol - if !(cell is NoIconDisplayableCell) { + if !(cell is any NoIconDisplayableCell) { if !widget.icon.isEmpty { if let urlc = Endpoint.icon( rootUrl: openHABRootUrl, @@ -855,7 +855,7 @@ extension OpenHABSitemapViewController: UITableViewDelegate, UITableViewDataSour } func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { - if let cell = cell as? GenericCellCacheProtocol { + if let cell = cell as? any GenericCellCacheProtocol { // invalidate cache only if the cell is not visible or the datasource is empty (eg. sitemap change) if tableView.indexPathsForVisibleRows == nil || !tableView.indexPathsForVisibleRows!.contains(indexPath) || currentPage == nil || currentPage!.widgets.isEmpty { cell.invalidateCache() diff --git a/openHAB/OpenHABWebViewController.swift b/openHAB/OpenHABWebViewController.swift index 14d9f829a..de2b25de8 100644 --- a/openHAB/OpenHABWebViewController.swift +++ b/openHAB/OpenHABWebViewController.swift @@ -9,10 +9,12 @@ // // SPDX-License-Identifier: EPL-2.0 +import Combine import OpenHABCore import os.log import SafariServices import SideMenu +import SwiftMessages import UIKit import WebKit @@ -342,7 +344,7 @@ extension OpenHABWebViewController: WKNavigationDelegate { showActivityIndicator(show: true) } - func webView(_ webView: WKWebView, didFail navigation: WKNavigation?, withError error: Error) { + func webView(_ webView: WKWebView, didFail navigation: WKNavigation?, withError error: any Error) { logger.error("didFail - webView.url: \(String(describing: webView.url?.description))") if let urlError = error as? URLError, urlError.code == .cancelled { diff --git a/openHAB/RTFTextView.swift b/openHAB/RTFTextView.swift index 0de0af0d3..a9b609aba 100644 --- a/openHAB/RTFTextView.swift +++ b/openHAB/RTFTextView.swift @@ -9,6 +9,7 @@ // // SPDX-License-Identifier: EPL-2.0 +import OpenHABCore import SwiftUI import UIKit diff --git a/openHAB/SegmentedUITableViewCell.swift b/openHAB/SegmentedUITableViewCell.swift index 09249a2e5..fa044b08d 100644 --- a/openHAB/SegmentedUITableViewCell.swift +++ b/openHAB/SegmentedUITableViewCell.swift @@ -9,6 +9,7 @@ // // SPDX-License-Identifier: EPL-2.0 +import OpenHABCore import os.log import UIKit diff --git a/openHAB/SelectionUITableViewCell.swift b/openHAB/SelectionUITableViewCell.swift index 48f0f406b..df904cb90 100644 --- a/openHAB/SelectionUITableViewCell.swift +++ b/openHAB/SelectionUITableViewCell.swift @@ -10,6 +10,7 @@ // SPDX-License-Identifier: EPL-2.0 import OpenHABCore +import UIKit class SelectionUITableViewCell: GenericUITableViewCell { override var widget: OpenHABWidget! { diff --git a/openHAB/SelectionView.swift b/openHAB/SelectionView.swift index 5543d1be5..6730b5b04 100644 --- a/openHAB/SelectionView.swift +++ b/openHAB/SelectionView.swift @@ -11,6 +11,7 @@ import OpenHABCore import os.log +import SFSafeSymbols import SwiftUI struct SelectionView: View { diff --git a/openHAB/SettingsView/AnimatedSecureTextField.swift b/openHAB/SettingsView/AnimatedSecureTextField.swift index e848ce9ba..f8d4e7869 100644 --- a/openHAB/SettingsView/AnimatedSecureTextField.swift +++ b/openHAB/SettingsView/AnimatedSecureTextField.swift @@ -9,6 +9,7 @@ // // SPDX-License-Identifier: EPL-2.0 +import SFSafeSymbols import SwiftUI struct AnimatedSecureTextField: View { diff --git a/openHAB/SettingsView/DebugSettingsView.swift b/openHAB/SettingsView/DebugSettingsView.swift index 020a87060..5407b1042 100644 --- a/openHAB/SettingsView/DebugSettingsView.swift +++ b/openHAB/SettingsView/DebugSettingsView.swift @@ -9,6 +9,7 @@ // // SPDX-License-Identifier: EPL-2.0 +import Combine import OpenHABCore import SafariServices import SwiftUI diff --git a/openHAB/SettingsView/MainUISettingsView.swift b/openHAB/SettingsView/MainUISettingsView.swift index ae1b33cf4..f8a845710 100644 --- a/openHAB/SettingsView/MainUISettingsView.swift +++ b/openHAB/SettingsView/MainUISettingsView.swift @@ -10,6 +10,7 @@ // SPDX-License-Identifier: EPL-2.0 import OpenHABCore +import SFSafeSymbols import SwiftUI import WebKit diff --git a/openHAB/SettingsView/SettingsView.swift b/openHAB/SettingsView/SettingsView.swift index 8ba9f18eb..5108757a1 100644 --- a/openHAB/SettingsView/SettingsView.swift +++ b/openHAB/SettingsView/SettingsView.swift @@ -153,7 +153,7 @@ struct SettingsView: View { Preferences.defaultMainUIPath = settingsDefaultMainUIPath Preferences.alwaysAllowWebRTC = settingsAlwaysAllowWebRTC Preferences.sitemapForWatch = settingsSitemapForWatch - Preferences.sitemapForWatchLabel = sitemaps.first(where: { $0.name == settingsSitemapForWatch })?.label ?? "unknown" + Preferences.sitemapForWatchLabel = sitemaps.first { $0.name == settingsSitemapForWatch }?.label ?? "unknown" Preferences.localConnectionConfig = settingsLocalConnectionConfiguration Preferences.remoteConnectionConfig = settingsRemoteConnectionConfiguration WatchMessageService.singleton.syncPreferencesToWatch() diff --git a/openHAB/SettingsView/SingleConnectionSettingsView.swift b/openHAB/SettingsView/SingleConnectionSettingsView.swift index af3a4cedf..97cbb8c0d 100644 --- a/openHAB/SettingsView/SingleConnectionSettingsView.swift +++ b/openHAB/SettingsView/SingleConnectionSettingsView.swift @@ -18,7 +18,7 @@ struct SpinningSymbol: View { @State private var isAnimating = false var body: some View { - Image(systemName: "arrow.triangle.2.circlepath") + Image(systemSymbol: .arrowTriangle2Circlepath) .rotationEffect(.degrees(isAnimating ? 360 : 0)) .animation( Animation.linear(duration: 1.0) diff --git a/openHAB/SliderWithSwitchSupportUITableViewCell.swift b/openHAB/SliderWithSwitchSupportUITableViewCell.swift index 49a33373d..dfe401e50 100644 --- a/openHAB/SliderWithSwitchSupportUITableViewCell.swift +++ b/openHAB/SliderWithSwitchSupportUITableViewCell.swift @@ -11,6 +11,7 @@ import AVFoundation import AVKit +import Combine import OpenHABCore import os.log diff --git a/openHAB/Throttler.swift b/openHAB/Throttler.swift index 4c58aa200..65a2eadcd 100644 --- a/openHAB/Throttler.swift +++ b/openHAB/Throttler.swift @@ -9,6 +9,7 @@ // // SPDX-License-Identifier: EPL-2.0 +import Combine import Foundation // Inspired by http://danielemargutti.com/2017/10/19/throttle-in-swift/ diff --git a/openHAB/WidgetIconRenderer.swift b/openHAB/UITableViewCellExtension.swift similarity index 100% rename from openHAB/WidgetIconRenderer.swift rename to openHAB/UITableViewCellExtension.swift diff --git a/openHAB/UIViewController+Localization.swift b/openHAB/UIViewController+Localization.swift index 326b05e93..74b202543 100644 --- a/openHAB/UIViewController+Localization.swift +++ b/openHAB/UIViewController+Localization.swift @@ -9,6 +9,7 @@ // // SPDX-License-Identifier: EPL-2.0 +import Combine import Foundation import UIKit diff --git a/openHAB/WatchMessageService.swift b/openHAB/WatchMessageService.swift index 3df9476fe..e470c1dcf 100644 --- a/openHAB/WatchMessageService.swift +++ b/openHAB/WatchMessageService.swift @@ -41,7 +41,7 @@ class WatchMessageService: NSObject, WCSessionDelegate { logger.info("Received message (no reply): \(message, privacy: .public)") } - func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) { + func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: (any Error)?) { logger.info("WCSession activation completed. State: \(String(describing: activationState)), Error: \(String(describing: error))") } diff --git a/openHAB/WebUITableViewCell.swift b/openHAB/WebUITableViewCell.swift index 33c48dc23..373e59a84 100644 --- a/openHAB/WebUITableViewCell.swift +++ b/openHAB/WebUITableViewCell.swift @@ -96,12 +96,12 @@ extension WebUITableViewCell: WKNavigationDelegate { return .allow } - func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { + func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: any Error) { os_log("webview failed with error: %{PUBLIC}s", log: .urlComposition, type: .debug, error.localizedDescription) url = nil } - func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { + func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: any Error) { os_log("webview failed with error: %{PUBLIC}s", log: .urlComposition, type: .debug, error.localizedDescription) url = nil } diff --git a/openHABIntents/SetDimmerRollerValueIntentHandler.swift b/openHABIntents/SetDimmerRollerValueIntentHandler.swift index ddf5a57dd..56779b2fc 100644 --- a/openHABIntents/SetDimmerRollerValueIntentHandler.swift +++ b/openHABIntents/SetDimmerRollerValueIntentHandler.swift @@ -26,7 +26,8 @@ class SetDimmerRollerValueIntentHandler: NSObject, OpenHABSetDimmerRollerValueIn let items = await itemCache.getItemNames( searchTerm: searchTerm, types: [.dimmer, .rollershutter] - ).map(NSString.init) + ) + .map(NSString.init) return INObjectCollection(items: items) } @@ -34,7 +35,8 @@ class SetDimmerRollerValueIntentHandler: NSObject, OpenHABSetDimmerRollerValueIn let items = await itemCache.getItemNames( searchTerm: nil, types: [.dimmer, .rollershutter] - ).map(NSString.init) + ) + .map(NSString.init) return INObjectCollection(items: items) } diff --git a/openHABWatch/External/AppMessageService.swift b/openHABWatch/External/AppMessageService.swift index 75d2b7836..0813709fc 100644 --- a/openHABWatch/External/AppMessageService.swift +++ b/openHABWatch/External/AppMessageService.swift @@ -47,15 +47,11 @@ class AppMessageService: NSObject, WCSessionDelegate { } func requestApplicationContext() { - WCSession.default.sendMessage( - ["request": "Preferences"], - replyHandler: { response in - - DispatchQueue.main.async { () in - self.updateValuesFromApplicationContext(response as [String: AnyObject]) - } + WCSession.default.sendMessage(["request": "Preferences"]) { response in + DispatchQueue.main.async { () in + self.updateValuesFromApplicationContext(response as [String: AnyObject]) } - ) { error in + } errorHandler: { error in self.logger.error("Error sending message \(error.localizedDescription)") } } diff --git a/openHABWatch/Views/PreferencesSwiftUIView.swift b/openHABWatch/Views/PreferencesSwiftUIView.swift index 12606c052..37391b51f 100644 --- a/openHABWatch/Views/PreferencesSwiftUIView.swift +++ b/openHABWatch/Views/PreferencesSwiftUIView.swift @@ -14,36 +14,6 @@ import os.log import SwiftUI import WatchConnectivity -struct PreferencesSwiftUIView: View { - @EnvironmentObject var settings: AppSettings - - var applicationVersionNumber: String = { - let appBuildString = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String - let appVersionString = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String - return "\(appVersionString ?? "") (\(appBuildString ?? ""))" - }() - - var body: some View { - List { - LabeledContent(LocalizedStringKey("local_url"), value: settings.localConnectionConfig?.url ?? "empty") - .highlightDotRow(if: settings.localConnectionConfig?.url == settings.openHABRootUrl) - LabeledContent(LocalizedStringKey("remote_url"), value: settings.remoteConnectionConfig?.url ?? "empty") - .highlightDotRow(if: settings.remoteConnectionConfig?.url == settings.openHABRootUrl) - LabeledContent(LocalizedStringKey("sitemap"), value: settings.sitemapForWatchLabel) - .listRowInsets(EdgeInsets(top: 0, leading: 5, bottom: 0, trailing: 5)) - LabeledContent(LocalizedStringKey("version"), value: applicationVersionNumber) - .listRowInsets(EdgeInsets(top: 0, leading: 5, bottom: 0, trailing: 5)) - } - .listRowInsets(EdgeInsets(top: 0, leading: 5, bottom: 0, trailing: 5)) - .listStyle(.plain) - .environment(\.defaultMinListRowHeight, 10) - .labeledContentStyle(CompactLabeledContentStyle()) // 👈 Apply custom style - .refreshable { - AppMessageService.singleton.requestApplicationContext() - } - } -} - struct HighlightDotRowModifier: ViewModifier { let showDot: Bool @@ -81,6 +51,36 @@ struct CompactLabeledContentStyle: LabeledContentStyle { } } +struct PreferencesSwiftUIView: View { + @EnvironmentObject var settings: AppSettings + + var applicationVersionNumber: String = { + let appBuildString = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String + let appVersionString = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String + return "\(appVersionString ?? "") (\(appBuildString ?? ""))" + }() + + var body: some View { + List { + LabeledContent(LocalizedStringKey("local_url"), value: settings.localConnectionConfig?.url ?? "empty") + .highlightDotRow(if: settings.localConnectionConfig?.url == settings.openHABRootUrl) + LabeledContent(LocalizedStringKey("remote_url"), value: settings.remoteConnectionConfig?.url ?? "empty") + .highlightDotRow(if: settings.remoteConnectionConfig?.url == settings.openHABRootUrl) + LabeledContent(LocalizedStringKey("sitemap"), value: settings.sitemapForWatchLabel) + .listRowInsets(EdgeInsets(top: 0, leading: 5, bottom: 0, trailing: 5)) + LabeledContent(LocalizedStringKey("version"), value: applicationVersionNumber) + .listRowInsets(EdgeInsets(top: 0, leading: 5, bottom: 0, trailing: 5)) + } + .listRowInsets(EdgeInsets(top: 0, leading: 5, bottom: 0, trailing: 5)) + .listStyle(.plain) + .environment(\.defaultMinListRowHeight, 10) + .labeledContentStyle(CompactLabeledContentStyle()) // 👈 Apply custom style + .refreshable { + AppMessageService.singleton.requestApplicationContext() + } + } +} + #Preview { PreferencesSwiftUIView() .environmentObject(AppSettings()) diff --git a/openHABWatch/Views/Utils/ImageWithAction.swift b/openHABWatch/Views/Utils/ImageWithAction.swift index c523f70a8..d09d95204 100644 --- a/openHABWatch/Views/Utils/ImageWithAction.swift +++ b/openHABWatch/Views/Utils/ImageWithAction.swift @@ -13,10 +13,10 @@ import SFSafeSymbols import SwiftUI struct ImageWithAction: View { - var systemName: String + var systemSymbol: SFSymbol var action: () -> Void var body: some View { - Image(systemName: systemName) + Image(systemSymbol: systemSymbol) .font(.system(size: 25)) .colorMultiply(.blue) .saturation(0.8) @@ -27,5 +27,5 @@ struct ImageWithAction: View { } #Preview { - ImageWithAction(systemName: "chevron.up.circle.fill") {} + ImageWithAction(systemSymbol: .chevronUpCircleFill) {} } From a26c210935448129aa7d772aa086256509bf7d37 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Sun, 4 May 2025 17:53:55 +0200 Subject: [PATCH 171/476] Upgrading SFSafeSymbols to 6.2, Also OpenHABCoreTests now run with SFSafeSymbols Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- OpenHABCore/Package.swift | 15 +++++++++++---- openHAB.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/swiftpm/Package.resolved | 6 +++--- 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/OpenHABCore/Package.swift b/OpenHABCore/Package.swift index ff7b2c405..1c3ef5fe2 100644 --- a/OpenHABCore/Package.swift +++ b/OpenHABCore/Package.swift @@ -19,7 +19,8 @@ let package = Package( .package(url: "https://github.com/apple/swift-openapi-runtime", from: "1.0.0"), .package(url: "https://github.com/apple/swift-openapi-urlsession", from: "1.0.0"), .package(url: "https://github.com/apple/swift-http-types.git", from: "1.0.0"), - .package(url: "https://github.com/SDWebImage/SDWebImageSVGCoder.git", from: "1.4.0") + .package(url: "https://github.com/SDWebImage/SDWebImageSVGCoder.git", from: "1.4.0"), + .package(url: "https://github.com/SFSafeSymbols/SFSafeSymbols.git", from: "6.2.0") ], targets: [ // Targets are the basic building blocks of a package. A target can define a module or a test suite. @@ -31,12 +32,14 @@ let package = Package( .product(name: "OpenAPIRuntime", package: "swift-openapi-runtime"), .product(name: "OpenAPIURLSession", package: "swift-openapi-urlsession"), .product(name: "HTTPTypes", package: "swift-http-types"), // ✅ From `swift-http-types` - .product(name: "SDWebImageSVGCoder", package: "SDWebImageSVGCoder") + .product(name: "SDWebImageSVGCoder", package: "SDWebImageSVGCoder"), + .product(name: "SFSafeSymbols", package: "SFSafeSymbols") ], swiftSettings: [ .enableUpcomingFeature("BareSlashRegexLiterals"), .enableExperimentalFeature("StrictConcurrency") // , .unsafeFlags(["-strict-concurrency=targeted"]) +// .enableUpcomingFeature("all") ] ), .testTarget( @@ -44,12 +47,16 @@ let package = Package( dependencies: [ "OpenHABCore", .product(name: "OpenAPIRuntime", package: "swift-openapi-runtime"), - .product(name: "HTTPTypes", package: "swift-http-types") // ✅ From `swift-http-types` + .product(name: "HTTPTypes", package: "swift-http-types"), // ✅ From `swift-http-types` + .product(name: "SFSafeSymbols", package: "SFSafeSymbols") ], resources: [ .process("Resources") ], - swiftSettings: [.enableUpcomingFeature("BareSlashRegexLiterals")] + swiftSettings: [ + .enableUpcomingFeature("BareSlashRegexLiterals"), + .enableExperimentalFeature("StrictConcurrency") + ] ) ] ) diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index 3752ec689..54443bcc3 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -2757,7 +2757,7 @@ repositoryURL = "https://github.com/SFSafeSymbols/SFSafeSymbols"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 5.3.0; + minimumVersion = 6.2.0; }; }; DACE66482C63B0760069E514 /* XCRemoteSwiftPackageReference "swift-openapi-urlsession" */ = { diff --git a/openHAB.xcworkspace/xcshareddata/swiftpm/Package.resolved b/openHAB.xcworkspace/xcshareddata/swiftpm/Package.resolved index e5b639bf6..554628bf7 100644 --- a/openHAB.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/openHAB.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "2fe6c641cce35f65394a66cdabfb28515dc17a2fa5ac6d25b309d91df4edd6dd", + "originHash" : "27b7b7918dd83e11dc35701776bc900cdd733957e39dcf2141ed75b293c511bc", "pins" : [ { "identity" : "abseil-cpp-binary", @@ -159,8 +159,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/SFSafeSymbols/SFSafeSymbols", "state" : { - "revision" : "e2e28f4e56e1769c2ec3c61c9355fc64eb7a535a", - "version" : "5.3.0" + "revision" : "3dd282d3269b061853a3b3bcd23a509d2aa166ce", + "version" : "6.2.0" } }, { From 470e50b5c53c01324e913e133a1de2e04fa263cf Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Sun, 4 May 2025 18:36:02 +0200 Subject: [PATCH 172/476] Switched SWIFT_UPCOMING_FEATURE_INTERNAL_IMPORTS_BY_DEFAULT to YES. Made the necessary changes Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- openHAB.xcodeproj/project.pbxproj | 4 ++-- openHAB/NotificationsView.swift | 2 +- openHAB/ScaleAspectFitImageView.swift | 2 +- openHAB/Throttler.swift | 8 ++++---- openHAB/WatchMessageService.swift | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index 54443bcc3..41930529c 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -2533,7 +2533,7 @@ SWIFT_UPCOMING_FEATURE_IMPLICIT_OPEN_EXISTENTIALS = YES; SWIFT_UPCOMING_FEATURE_IMPORT_OBJC_FORWARD_DECLS = YES; SWIFT_UPCOMING_FEATURE_INFER_SENDABLE_FROM_CAPTURES = YES; - SWIFT_UPCOMING_FEATURE_INTERNAL_IMPORTS_BY_DEFAULT = NO; + SWIFT_UPCOMING_FEATURE_INTERNAL_IMPORTS_BY_DEFAULT = YES; SWIFT_UPCOMING_FEATURE_ISOLATED_DEFAULT_VALUES = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_UPCOMING_FEATURE_NONFROZEN_ENUM_EXHAUSTIVITY = YES; @@ -2597,7 +2597,7 @@ SWIFT_UPCOMING_FEATURE_IMPLICIT_OPEN_EXISTENTIALS = YES; SWIFT_UPCOMING_FEATURE_IMPORT_OBJC_FORWARD_DECLS = YES; SWIFT_UPCOMING_FEATURE_INFER_SENDABLE_FROM_CAPTURES = YES; - SWIFT_UPCOMING_FEATURE_INTERNAL_IMPORTS_BY_DEFAULT = NO; + SWIFT_UPCOMING_FEATURE_INTERNAL_IMPORTS_BY_DEFAULT = YES; SWIFT_UPCOMING_FEATURE_ISOLATED_DEFAULT_VALUES = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_UPCOMING_FEATURE_NONFROZEN_ENUM_EXHAUSTIVITY = YES; diff --git a/openHAB/NotificationsView.swift b/openHAB/NotificationsView.swift index bcdedb129..2821d08c3 100644 --- a/openHAB/NotificationsView.swift +++ b/openHAB/NotificationsView.swift @@ -154,7 +154,7 @@ extension NotificationsView where Tracker == NetworkTracker { } #if DEBUG -public extension ConnectionInfo { +extension ConnectionInfo { static var mock: ConnectionInfo { ConnectionInfo( configuration: ConnectionConfiguration( diff --git a/openHAB/ScaleAspectFitImageView.swift b/openHAB/ScaleAspectFitImageView.swift index 9f439ac1e..047463cac 100644 --- a/openHAB/ScaleAspectFitImageView.swift +++ b/openHAB/ScaleAspectFitImageView.swift @@ -11,7 +11,7 @@ import UIKit -public class ScaleAspectFitImageView: UIImageView { +class ScaleAspectFitImageView: UIImageView { private var aspectRatioConstraint: NSLayoutConstraint? override public var image: UIImage? { didSet { diff --git a/openHAB/Throttler.swift b/openHAB/Throttler.swift index 65a2eadcd..d6f0a773e 100644 --- a/openHAB/Throttler.swift +++ b/openHAB/Throttler.swift @@ -12,8 +12,8 @@ import Combine import Foundation -// Inspired by http://danielemargutti.com/2017/10/19/throttle-in-swift/ -public class Throttler: ObservableObject { +// Inspired by http://danielemargutti.com/2017/10/19/throttle-in-swift/\ +class Throttler: ObservableObject { private let queue: DispatchQueue = .global(qos: .background) private var job = DispatchWorkItem {} @@ -36,8 +36,8 @@ public class Throttler: ObservableObject { } } -// Inspired by https://ericasadun.com/2017/05/23/5-easy-dispatch-tricks/ -public extension DispatchTime { +// Inspired by https://ericasadun.com/2017/05/23/5-easy-dispatch-tricks/\ +extension DispatchTime { static func secondsFromNow(_ amount: Double) -> DispatchTime { DispatchTime.now() + amount } diff --git a/openHAB/WatchMessageService.swift b/openHAB/WatchMessageService.swift index e470c1dcf..fbf299e6b 100644 --- a/openHAB/WatchMessageService.swift +++ b/openHAB/WatchMessageService.swift @@ -75,7 +75,7 @@ class WatchMessageService: NSObject, WCSessionDelegate { } @MainActor -public extension WatchPreferences { +extension WatchPreferences { init(fromPreferences preferences: Preferences.Type) { self.init( localUrl: preferences.localUrl, From 94af42716e3733be648861a48a20499ac316ad6d Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Sun, 4 May 2025 18:41:50 +0200 Subject: [PATCH 173/476] Switched SWIFT_UPCOMING_FEATURE_GLOBAL_ACTOR_ISOLATED_TYPES_USABILITY to YES. Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- openHAB.xcodeproj/project.pbxproj | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index 41930529c..53d2d09c1 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -2529,6 +2529,7 @@ SWIFT_UPCOMING_FEATURE_DYNAMIC_ACTOR_ISOLATION = NO; SWIFT_UPCOMING_FEATURE_EXISTENTIAL_ANY = YES; SWIFT_UPCOMING_FEATURE_FORWARD_TRAILING_CLOSURES = YES; + SWIFT_UPCOMING_FEATURE_GLOBAL_ACTOR_ISOLATED_TYPES_USABILITY = YES; SWIFT_UPCOMING_FEATURE_GLOBAL_CONCURRENCY = YES; SWIFT_UPCOMING_FEATURE_IMPLICIT_OPEN_EXISTENTIALS = YES; SWIFT_UPCOMING_FEATURE_IMPORT_OBJC_FORWARD_DECLS = YES; @@ -2593,6 +2594,7 @@ SWIFT_UPCOMING_FEATURE_DYNAMIC_ACTOR_ISOLATION = NO; SWIFT_UPCOMING_FEATURE_EXISTENTIAL_ANY = YES; SWIFT_UPCOMING_FEATURE_FORWARD_TRAILING_CLOSURES = YES; + SWIFT_UPCOMING_FEATURE_GLOBAL_ACTOR_ISOLATED_TYPES_USABILITY = YES; SWIFT_UPCOMING_FEATURE_GLOBAL_CONCURRENCY = YES; SWIFT_UPCOMING_FEATURE_IMPLICIT_OPEN_EXISTENTIALS = YES; SWIFT_UPCOMING_FEATURE_IMPORT_OBJC_FORWARD_DECLS = YES; From cfbde75e99dfc7fac2e2c8cd51744f67a8d12f75 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Sun, 4 May 2025 21:09:27 +0200 Subject: [PATCH 174/476] Upgrade of firebase-ios-sdk to >=11.0 and DeviceKit to >= 5.0 Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../OpenHABCore/Util/Preferences.swift | 10 ++++- openHAB.xcodeproj/project.pbxproj | 4 +- .../xcshareddata/swiftpm/Package.resolved | 38 +++++++++---------- 3 files changed, 29 insertions(+), 23 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift index 025ba48ff..c1b1b572c 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift @@ -14,7 +14,7 @@ import os.log import UIKit @propertyWrapper -public struct UserDefault { +public struct UserDefault { private let key: String private let defaultValue: T private let subject: CurrentValueSubject @@ -28,6 +28,12 @@ public struct UserDefault { DispatchQueue.main.async { [subject] in subject.send(newValue) } +// Preferences.sharedDefaults.set(newValue, forKey: key) +// let valueToSend = newValue +// let subjectCopy = subject +// Task.detached { @MainActor in +// subjectCopy.send(valueToSend) +// } } } @@ -44,7 +50,7 @@ public struct UserDefault { } @propertyWrapper -public struct UserDefaultObject { +public struct UserDefaultObject { private let key: String private let defaultValue: T private let subject: CurrentValueSubject diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index 53d2d09c1..539b58726 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -2703,7 +2703,7 @@ repositoryURL = "https://github.com/devicekit/DeviceKit.git"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 4.0.0; + minimumVersion = 5.0.0; }; }; 937E4486270B37A600A98C26 /* XCRemoteSwiftPackageReference "Kingfisher" */ = { @@ -2719,7 +2719,7 @@ repositoryURL = "https://github.com/firebase/firebase-ios-sdk.git"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 10.0.0; + minimumVersion = 11.0.0; }; }; 93F8064527AE7A050035A6B0 /* XCRemoteSwiftPackageReference "SwiftMessages" */ = { diff --git a/openHAB.xcworkspace/xcshareddata/swiftpm/Package.resolved b/openHAB.xcworkspace/xcshareddata/swiftpm/Package.resolved index 554628bf7..d98176df9 100644 --- a/openHAB.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/openHAB.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "27b7b7918dd83e11dc35701776bc900cdd733957e39dcf2141ed75b293c511bc", + "originHash" : "8bdfc8eeb7fcbc48ed390776694834a01e4fcdb093404ab31c58f64918915df4", "pins" : [ { "identity" : "abseil-cpp-binary", "kind" : "remoteSourceControl", "location" : "https://github.com/google/abseil-cpp-binary.git", "state" : { - "revision" : "194a6706acbd25e4ef639bcaddea16e8758a3e27", - "version" : "1.2024011602.0" + "revision" : "bbe8b69694d7873315fd3a4ad41efe043e1c07c5", + "version" : "1.2024072200.0" } }, { @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/google/app-check.git", "state" : { - "revision" : "3b62f154d00019ae29a71e9738800bb6f18b236d", - "version" : "10.19.2" + "revision" : "61b85103a1aeed8218f17c794687781505fbbef5", + "version" : "11.2.0" } }, { @@ -24,8 +24,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/devicekit/DeviceKit.git", "state" : { - "revision" : "d37e70cb2646666dcf276d7d3d4a9760a41ff8a6", - "version" : "4.9.0" + "revision" : "513b9d7e7a1bd46504a1009bbab943b75ce2f195", + "version" : "5.6.0" } }, { @@ -33,8 +33,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/firebase/firebase-ios-sdk.git", "state" : { - "revision" : "eca84fd638116dd6adb633b5a3f31cc7befcbb7d", - "version" : "10.29.0" + "revision" : "fbd463894af94d90eb4d6a4e54080459a8179519", + "version" : "11.12.0" } }, { @@ -51,8 +51,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/google/GoogleAppMeasurement.git", "state" : { - "revision" : "fe727587518729046fc1465625b9afd80b5ab361", - "version" : "10.28.0" + "revision" : "f7460ea630bddf172115c28493ae8b3798d95ce3", + "version" : "11.12.0" } }, { @@ -60,8 +60,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/google/GoogleDataTransport.git", "state" : { - "revision" : "a637d318ae7ae246b02d7305121275bc75ed5565", - "version" : "9.4.0" + "revision" : "617af071af9aa1d6a091d59a202910ac482128f9", + "version" : "10.1.0" } }, { @@ -69,8 +69,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/google/GoogleUtilities.git", "state" : { - "revision" : "57a1d307f42df690fdef2637f3e5b776da02aad6", - "version" : "7.13.3" + "revision" : "60da361632d0de02786f709bdc0c4df340f7613e", + "version" : "8.1.0" } }, { @@ -78,8 +78,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/google/grpc-binary.git", "state" : { - "revision" : "e9fad491d0673bdda7063a0341fb6b47a30c5359", - "version" : "1.62.2" + "revision" : "cc0001a0cf963aa40501d9c2b181e7fc9fd8ec71", + "version" : "1.69.0" } }, { @@ -96,8 +96,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/google/interop-ios-for-google-sdks.git", "state" : { - "revision" : "2d12673670417654f08f5f90fdd62926dc3a2648", - "version" : "100.0.0" + "revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe", + "version" : "101.0.0" } }, { From 810a3a701ac5995a2fd0afb5ee40169b9b5f5abb Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Sun, 4 May 2025 22:10:30 +0200 Subject: [PATCH 175/476] No popupMessage anymore on switching network connection Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- openHAB/OpenHABSitemapViewController.swift | 24 +++++++++------------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/openHAB/OpenHABSitemapViewController.swift b/openHAB/OpenHABSitemapViewController.swift index dc9292d94..c41a47a3e 100644 --- a/openHAB/OpenHABSitemapViewController.swift +++ b/openHAB/OpenHABSitemapViewController.swift @@ -520,26 +520,22 @@ extension OpenHABSitemapViewController { } } } - } catch is CancellationError { logger.info("🔁 pageHandlingTask was cancelled") } catch let error as DecodingError { - os_log("DecodingError %{PUBLIC}@", log: .default, type: .error, error.localizedDescription) + logger.error("DecodingError \(error.localizedDescription)") } catch let error as ClientError { - if let urlError = error.underlyingError as? URLError, urlError.code == .cancelled { - logger.info("Task was cancelled - URLError code: .cancelled") - } else if let urlError = error.underlyingError as? URLError, urlError.code == .timedOut { - logger.info("Task timed out - URLError code: .timedOut") + if let urlError = error.underlyingError as? URLError { + switch urlError.code { + case .cancelled: + logger.info("Task was cancelled - URLError code: .cancelled") + case .timedOut: + logger.info("Task timed out - URLError code: .timedOut") + default: + logger.info("URLError: \(urlError.localizedDescription)") + } } else { logger.error("\(error.localizedDescription)") - await MainActor.run { - self.showPopupMessage( - seconds: 5, - title: NSLocalizedString("error", comment: ""), - message: error.localizedDescription, - theme: .error - ) - } } } catch let openAPIError as OpenAPIServiceError { logger.info("On pageHandling \(openAPIError)") From 29623b6abcc0df9d8d20b0078abc29ae9b26421c Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Mon, 5 May 2025 07:14:03 +0200 Subject: [PATCH 176/476] empty to retrigger tests that are hanging on github Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- OpenHABCore/Package.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/OpenHABCore/Package.swift b/OpenHABCore/Package.swift index 1c3ef5fe2..631a7b681 100644 --- a/OpenHABCore/Package.swift +++ b/OpenHABCore/Package.swift @@ -31,7 +31,7 @@ let package = Package( .product(name: "Kingfisher", package: "Kingfisher", condition: .when(platforms: [.iOS, .watchOS])), .product(name: "OpenAPIRuntime", package: "swift-openapi-runtime"), .product(name: "OpenAPIURLSession", package: "swift-openapi-urlsession"), - .product(name: "HTTPTypes", package: "swift-http-types"), // ✅ From `swift-http-types` + .product(name: "HTTPTypes", package: "swift-http-types"), .product(name: "SDWebImageSVGCoder", package: "SDWebImageSVGCoder"), .product(name: "SFSafeSymbols", package: "SFSafeSymbols") ], @@ -47,7 +47,7 @@ let package = Package( dependencies: [ "OpenHABCore", .product(name: "OpenAPIRuntime", package: "swift-openapi-runtime"), - .product(name: "HTTPTypes", package: "swift-http-types"), // ✅ From `swift-http-types` + .product(name: "HTTPTypes", package: "swift-http-types"), .product(name: "SFSafeSymbols", package: "SFSafeSymbols") ], resources: [ From 2793df9152ca71bd232eb011371064d477c0db25 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Tue, 6 May 2025 20:01:33 +0200 Subject: [PATCH 177/476] Replace key-path based KVO observation with delegate-based tracking in OpenHABWebViewController to prepare for Swift 6 Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- openHAB/OpenHABWebViewController.swift | 27 +++++++++++--------------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/openHAB/OpenHABWebViewController.swift b/openHAB/OpenHABWebViewController.swift index de2b25de8..8c5115acd 100644 --- a/openHAB/OpenHABWebViewController.swift +++ b/openHAB/OpenHABWebViewController.swift @@ -24,7 +24,6 @@ class OpenHABWebViewController: OpenHABViewController { private var activeConfig: ConnectionConfiguration? private var hideNavBar = false private var activityIndicator: UIActivityIndicatorView! - private var observation: NSKeyValueObservation? private var sseTimer: Timer? private var commandQueue: [String] = [] private var acceptsCommands = false @@ -264,22 +263,8 @@ class OpenHABWebViewController: OpenHABViewController { if #available(iOS 16.4, *) { webView.isInspectable = true } - // watch for URL changes so we can store the last visited path - observation = webView.observe(\.url, options: [.new]) { _, _ in - if let webviewURL = webView.url { - let url = URL(string: webviewURL.path, relativeTo: URL(string: self.openHABTrackedRootUrl)) - if let path = url?.path { - os_log("navigation change base: %{PUBLIC}@ path: %{PUBLIC}@", log: OSLog.default, type: .info, self.openHABTrackedRootUrl, path) - // append trailing slash as WebUI/Vue/F7 will try and issue a 302 if the url is navigated to directly, this can be problamatic on myopenHAB - Preferences.currentWebViewPath = path.hasSuffix("/") ? path : path + "/" - } - } - } - return webView - } - deinit { - observation = nil + return webView } } @@ -358,6 +343,16 @@ extension OpenHABWebViewController: WKNavigationDelegate { logger.info("didFinish - webView.url: \(String(describing: webView.url?.description))") showActivityIndicator(show: false) hidePopupMessages() + + // watch for URL changes so we can store the last visited path + if let webviewURL = webView.url { + let url = URL(string: webviewURL.path, relativeTo: URL(string: openHABTrackedRootUrl)) + if let path = url?.path { + let string = openHABTrackedRootUrl + logger.info("navigation change base: \(string) path: \(path)") + Preferences.currentWebViewPath = path.hasSuffix("/") ? path : path + "/" + } + } } func webView(_ webView: WKWebView, respondTo challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { From e7356e44ecfdbe589d8c566d90d77873b848c238 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Tue, 6 May 2025 20:44:50 +0200 Subject: [PATCH 178/476] Upgrade MapView for deprecated interface Making steady progress on Swift 6 compliance Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../Util/ClientCertificateManager.swift | 10 +----- .../Sources/OpenHABCore/Util/HTTPClient.swift | 2 +- openHAB/UICircleButton.swift | 3 -- .../Model/OpenHABWidgetExtension.swift | 2 -- .../Views/PreferencesSwiftUIView.swift | 12 +++---- openHABWatch/Views/Utils/MapView.swift | 31 +++++++++---------- 6 files changed, 22 insertions(+), 38 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/ClientCertificateManager.swift b/OpenHABCore/Sources/OpenHABCore/Util/ClientCertificateManager.swift index 2cc135a7d..6352605fc 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/ClientCertificateManager.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/ClientCertificateManager.swift @@ -13,15 +13,7 @@ import Foundation import os.log import Security -// public protocol ClientCertificateManagerDelegate: AnyObject { -// // delegate should ask user for a decision on whether to import the client certificate into the keychain -// func askForClientCertificateImport(_ clientCertificateManager: ClientCertificateManager?) -// // delegate should ask user for a decision on whether to import the client certificate into the keychain -// func askForCertificatePassword(_ clientCertificateManager: ClientCertificateManager?) -// // delegate should alert the user that an error occured importing the certificate -// func alertClientCertificateError(_ clientCertificateManager: ClientCertificateManager?, errMsg: String) -// } - +@MainActor public protocol ClientCertificateManagerDelegate: AnyObject { // delegate should ask user for a decision on whether to import the client certificate into the keychain func askForClientCertificateImport() async -> Bool diff --git a/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift b/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift index e76d0be7b..a55d37da4 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift @@ -44,7 +44,7 @@ private enum HTTPClientError: Error { } } -public enum CertificateEvaluateResult { +public enum CertificateEvaluateResult: Sendable { case undecided case deny case permitOnce diff --git a/openHAB/UICircleButton.swift b/openHAB/UICircleButton.swift index 2cd7ef8e7..6a4e7880b 100644 --- a/openHAB/UICircleButton.swift +++ b/openHAB/UICircleButton.swift @@ -12,9 +12,6 @@ import os.log import UIKit -var normalBackgroundColor: UIColor? -var normalTextColor: UIColor? - class UICircleButton: UIButton { required init?(coder: NSCoder) { super.init(coder: coder) diff --git a/openHABWatch/Model/OpenHABWidgetExtension.swift b/openHABWatch/Model/OpenHABWidgetExtension.swift index 57a5ee3f8..42c83cebe 100644 --- a/openHABWatch/Model/OpenHABWidgetExtension.swift +++ b/openHABWatch/Model/OpenHABWidgetExtension.swift @@ -17,8 +17,6 @@ import SwiftUI extension OpenHABWidget { @ViewBuilder func makeView(settings: AppSettings) -> some View { if let linkedPage { - let title = linkedPage.title.components(separatedBy: "[")[0] - let pageUrl = linkedPage.link // os_log("Selected %{PUBLIC}@", log: .viewCycle, type: .info, pageUrl) NavigationLink(destination: LazyView( diff --git a/openHABWatch/Views/PreferencesSwiftUIView.swift b/openHABWatch/Views/PreferencesSwiftUIView.swift index 37391b51f..46d254778 100644 --- a/openHABWatch/Views/PreferencesSwiftUIView.swift +++ b/openHABWatch/Views/PreferencesSwiftUIView.swift @@ -31,12 +31,6 @@ struct HighlightDotRowModifier: ViewModifier { } } -extension View { - func highlightDotRow(if condition: Bool) -> some View { - modifier(HighlightDotRowModifier(showDot: condition)) - } -} - struct CompactLabeledContentStyle: LabeledContentStyle { func makeBody(configuration: Configuration) -> some View { HStack { @@ -81,6 +75,12 @@ struct PreferencesSwiftUIView: View { } } +extension View { + func highlightDotRow(if condition: Bool) -> some View { + modifier(HighlightDotRowModifier(showDot: condition)) + } +} + #Preview { PreferencesSwiftUIView() .environmentObject(AppSettings()) diff --git a/openHABWatch/Views/Utils/MapView.swift b/openHABWatch/Views/Utils/MapView.swift index c6fc56487..c8bf4ef19 100644 --- a/openHABWatch/Views/Utils/MapView.swift +++ b/openHABWatch/Views/Utils/MapView.swift @@ -15,26 +15,23 @@ import SwiftUI struct MapView: View { @ObservedObject var widget: OpenHABWidget - @State private var region = MKCoordinateRegion( - center: CLLocationCoordinate2D( - latitude: 40, - longitude: -5 - ), - span: MKCoordinateSpan( - latitudeDelta: 0.02, - longitudeDelta: 0.02 - ) - ) + + @State private var cameraPosition: MapCameraPosition var body: some View { - Map(coordinateRegion: .constant( - MKCoordinateRegion( - center: widget.coordinate, - latitudinalMeters: 1000.0, - longitudinalMeters: 1000.0 - ) - ) + Map(position: $cameraPosition) { + Marker("Location", coordinate: widget.coordinate) + } + } + + init(widget: OpenHABWidget) { + self.widget = widget + let region = MKCoordinateRegion( + center: widget.coordinate, + latitudinalMeters: 1000.0, + longitudinalMeters: 1000.0 ) + _cameraPosition = State(initialValue: .region(region)) } } From 7c091941804bf0f571747545e5ac170c7a35c9e8 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Tue, 6 May 2025 23:06:55 +0200 Subject: [PATCH 179/476] Make Intent Handler work again Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../Sources/OpenHABCore/Util/OpenHABItemCache.swift | 13 +++++++++++-- openHABIntents/GetItemStateIntentHandler.swift | 2 ++ openHABIntents/SetColorValueIntentHandler.swift | 2 ++ .../SetContactStateValueIntentHandler.swift | 2 ++ .../SetDimmerRollerValueIntentHandler.swift | 2 ++ openHABIntents/SetNumberValueIntentHandler.swift | 2 ++ openHABIntents/SetStringValueIntentHandler.swift | 2 ++ openHABIntents/SetSwitchStateIntentHandler.swift | 2 ++ 8 files changed, 25 insertions(+), 2 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift index 92e3bc4cc..795d678f0 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift @@ -23,6 +23,10 @@ public protocol ItemCacheProtocol { public actor OpenHABItemCache { public static let instance = OpenHABItemCache() + private lazy var setupTask: Task = Task { [weak self] in + await self?.setup() + } + public var items: [OpenHABItem]? var cancellables = Set() var timeout: Double = 20 @@ -32,9 +36,14 @@ public actor OpenHABItemCache { private init() {} + public func waitUntilReady() async { + await setupTask.value + } + public func setup() async { - let connection1 = await Preferences.localConnectionConfig - let connection2 = await Preferences.remoteConnectionConfig + let connection1: ConnectionConfiguration = await Preferences.localConnectionConfig + let connection2: ConnectionConfiguration = await Preferences.remoteConnectionConfig + logger.info("Local: \(connection1.url), Remote: \(connection2.url)") await NetworkTracker.shared.startTracking(connectionConfigurations: [connection1, connection2]) } diff --git a/openHABIntents/GetItemStateIntentHandler.swift b/openHABIntents/GetItemStateIntentHandler.swift index f082446ad..9f143c5f6 100644 --- a/openHABIntents/GetItemStateIntentHandler.swift +++ b/openHABIntents/GetItemStateIntentHandler.swift @@ -38,6 +38,8 @@ class GetItemStateIntentHandler: NSObject, OpenHABGetItemStateIntentHandling { func handle(intent: OpenHABGetItemStateIntent) async -> OpenHABGetItemStateIntentResponse { logger.info("GetItemStateIntent for \(intent.item ?? "")") + await OpenHABItemCache.instance.waitUntilReady() + // Proceed to fetch item and complete guard let itemName = intent.item else { return .failureInvalidItem( diff --git a/openHABIntents/SetColorValueIntentHandler.swift b/openHABIntents/SetColorValueIntentHandler.swift index 75565a207..aaedd01a4 100644 --- a/openHABIntents/SetColorValueIntentHandler.swift +++ b/openHABIntents/SetColorValueIntentHandler.swift @@ -43,6 +43,8 @@ class SetColorValueIntentHandler: NSObject, OpenHABSetColorValueIntentHandling { func handle(intent: OpenHABSetColorValueIntent) async -> OpenHABSetColorValueIntentResponse { logger.info("SetColorValueIntent for \(intent.item ?? "")") + await OpenHABItemCache.instance.waitUntilReady() + guard let itemName = intent.item else { return .failureInvalidItem(NSLocalizedString("empty", comment: "empty item name")) } diff --git a/openHABIntents/SetContactStateValueIntentHandler.swift b/openHABIntents/SetContactStateValueIntentHandler.swift index 9bd694b3c..691fd3ce7 100644 --- a/openHABIntents/SetContactStateValueIntentHandler.swift +++ b/openHABIntents/SetContactStateValueIntentHandler.swift @@ -56,6 +56,8 @@ class SetContactStateValueIntentHandler: NSObject, OpenHABSetContactStateValueIn func handle(intent: OpenHABSetContactStateValueIntent) async -> OpenHABSetContactStateValueIntentResponse { logger.info("SetContactStateValueIntent for \(intent.item ?? "")") + await OpenHABItemCache.instance.waitUntilReady() + guard let itemName = intent.item else { return .failureInvalidItem(NSLocalizedString("empty", comment: "empty item name")) } diff --git a/openHABIntents/SetDimmerRollerValueIntentHandler.swift b/openHABIntents/SetDimmerRollerValueIntentHandler.swift index 56779b2fc..6593c949d 100644 --- a/openHABIntents/SetDimmerRollerValueIntentHandler.swift +++ b/openHABIntents/SetDimmerRollerValueIntentHandler.swift @@ -47,6 +47,8 @@ class SetDimmerRollerValueIntentHandler: NSObject, OpenHABSetDimmerRollerValueIn func handle(intent: OpenHABSetDimmerRollerValueIntent) async -> OpenHABSetDimmerRollerValueIntentResponse { logger.info("SetDimmerRollerValueIntent for \(intent.item ?? "")") + await OpenHABItemCache.instance.waitUntilReady() + guard let itemName = intent.item else { return .failureInvalidItem( NSLocalizedString("empty", comment: "empty item name") diff --git a/openHABIntents/SetNumberValueIntentHandler.swift b/openHABIntents/SetNumberValueIntentHandler.swift index f142bb31c..f23eb2260 100644 --- a/openHABIntents/SetNumberValueIntentHandler.swift +++ b/openHABIntents/SetNumberValueIntentHandler.swift @@ -43,6 +43,8 @@ class SetNumberValueIntentHandler: NSObject, OpenHABSetNumberValueIntentHandling func handle(intent: OpenHABSetNumberValueIntent) async -> OpenHABSetNumberValueIntentResponse { logger.info("SetNumberValueIntent for \(intent.item ?? "")") + await OpenHABItemCache.instance.waitUntilReady() + guard let itemName = intent.item else { return .failureInvalidItem( NSLocalizedString("empty", comment: "empty item name") diff --git a/openHABIntents/SetStringValueIntentHandler.swift b/openHABIntents/SetStringValueIntentHandler.swift index 55edf6226..a3247f7fd 100644 --- a/openHABIntents/SetStringValueIntentHandler.swift +++ b/openHABIntents/SetStringValueIntentHandler.swift @@ -43,6 +43,8 @@ class SetStringValueIntentHandler: NSObject, OpenHABSetStringValueIntentHandling func handle(intent: OpenHABSetStringValueIntent) async -> OpenHABSetStringValueIntentResponse { logger.info("SetStringValueIntent for \(intent.item ?? "")") + await OpenHABItemCache.instance.waitUntilReady() + guard let itemName = intent.item else { return .failureInvalidItem( NSLocalizedString("empty", comment: "empty item name") diff --git a/openHABIntents/SetSwitchStateIntentHandler.swift b/openHABIntents/SetSwitchStateIntentHandler.swift index cc3a02476..75beb818a 100644 --- a/openHABIntents/SetSwitchStateIntentHandler.swift +++ b/openHABIntents/SetSwitchStateIntentHandler.swift @@ -64,6 +64,8 @@ final class SetSwitchStateIntentHandler: NSObject, OpenHABSetSwitchStateIntentHa let itemName = intent.item ?? "" logger.info("SetSwitchStateIntent for item: \(intent.item ?? "", privacy: .public)") + await OpenHABItemCache.instance.waitUntilReady() + guard !itemName.isEmpty else { return .failureInvalidItem(NSLocalizedString("empty", comment: "empty item name")) } From 402da3aa7800553c3a71f2f43fcc4864ac297146 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Tue, 6 May 2025 23:44:42 +0200 Subject: [PATCH 180/476] Addressing more swift 6 warnings Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../Sources/OpenHABCore/Util/Preferences.swift | 12 +++--------- .../xcshareddata/xcschemes/openHABIntents.xcscheme | 10 +++++++--- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift index c1b1b572c..355330082 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift @@ -13,7 +13,7 @@ import os.log import UIKit -@propertyWrapper +@propertyWrapper @MainActor public struct UserDefault { private let key: String private let defaultValue: T @@ -28,12 +28,6 @@ public struct UserDefault { DispatchQueue.main.async { [subject] in subject.send(newValue) } -// Preferences.sharedDefaults.set(newValue, forKey: key) -// let valueToSend = newValue -// let subjectCopy = subject -// Task.detached { @MainActor in -// subjectCopy.send(valueToSend) -// } } } @@ -49,7 +43,7 @@ public struct UserDefault { } } -@propertyWrapper +@propertyWrapper @MainActor public struct UserDefaultObject { private let key: String private let defaultValue: T @@ -92,7 +86,7 @@ public struct UserDefaultObject { } } -@propertyWrapper +@propertyWrapper @MainActor public struct UserDefaultURL { private let key: String private let defaultValue: String diff --git a/openHAB.xcodeproj/xcshareddata/xcschemes/openHABIntents.xcscheme b/openHAB.xcodeproj/xcshareddata/xcschemes/openHABIntents.xcscheme index 126a60e4c..6ca9ff665 100644 --- a/openHAB.xcodeproj/xcshareddata/xcschemes/openHABIntents.xcscheme +++ b/openHAB.xcodeproj/xcshareddata/xcschemes/openHABIntents.xcscheme @@ -57,8 +57,12 @@ debugServiceExtension = "internal" allowLocationSimulation = "YES" launchAutomaticallySubstyle = "2"> - + + + - + Date: Wed, 7 May 2025 00:27:55 +0200 Subject: [PATCH 181/476] Make tests run again Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../ClientCertificateManagerTests.swift | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/OpenHABCore/Tests/OpenHABCoreTests/ClientCertificateManagerTests.swift b/OpenHABCore/Tests/OpenHABCoreTests/ClientCertificateManagerTests.swift index 92d9bc7a6..40d6e137c 100644 --- a/OpenHABCore/Tests/OpenHABCoreTests/ClientCertificateManagerTests.swift +++ b/OpenHABCore/Tests/OpenHABCoreTests/ClientCertificateManagerTests.swift @@ -91,10 +91,19 @@ final class ClientCertificateManagerTests: XCTestCase { XCTAssertNotNil(manager.importingIdentity) } +// func testClientCertificateAcceptedFailsAndAlerts() async { +// manager.importingRawCert = Data([0x00, 0x01, 0x02]) // invalid cert +// await manager.clientCertificateAccepted(password: "badpassword") +// XCTAssertNotNil(delegate.receivedErrorMessage) +// } + + @MainActor func testClientCertificateAcceptedFailsAndAlerts() async { manager.importingRawCert = Data([0x00, 0x01, 0x02]) // invalid cert await manager.clientCertificateAccepted(password: "badpassword") - XCTAssertNotNil(delegate.receivedErrorMessage) + + let errorMessage = await MainActor.run { delegate.receivedErrorMessage } + XCTAssertNotNil(errorMessage) } // Helper to load valid PKCS#12 mock From c32681048848900ebb68e08649cacbb9d65f3b86 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Wed, 7 May 2025 21:40:27 +0200 Subject: [PATCH 182/476] Moving @MainActor Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../Sources/OpenHABCore/Util/ClientCertificateManager.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/ClientCertificateManager.swift b/OpenHABCore/Sources/OpenHABCore/Util/ClientCertificateManager.swift index 6352605fc..a0dda804d 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/ClientCertificateManager.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/ClientCertificateManager.swift @@ -151,12 +151,14 @@ public class ClientCertificateManager { return refCounts } + @MainActor public func startImportClientCertificate(url: URL) async -> Bool { do { // Import PKCS12 client cert importingRawCert = try Data(contentsOf: url) guard let delegate else { return false } + let shouldImport = await delegate.askForClientCertificateImport() return shouldImport } catch { @@ -193,6 +195,7 @@ public class ClientCertificateManager { importingPassword = nil } + @MainActor func addClientCertificateToKeychain() async { guard let identity = importingIdentity else { logger.error("No identity available to import") From 1365845c36ac0aea44151d91b0fea5bcbd82ba45 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Thu, 8 May 2025 16:24:30 +0200 Subject: [PATCH 183/476] Addressing more swift 6 warnings: HTTPClient - pulling the conformance with URLSessionDelegate, URLSessionTaskDelegate into HTTPClientDelegate Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- OpenHABCore/Package.swift | 8 +- .../Sources/OpenHABCore/Util/HTTPClient.swift | 333 +++++------------- .../OpenHABCore/Util/HTTPClientDelegate.swift | 175 +++++++++ .../OpenHABCore/Util/NetworkTracker.swift | 2 +- openHAB/NewImageUITableViewCell.swift | 12 +- openHAB/OpenHABRootViewController.swift | 6 +- openHAB/VideoUITableViewCell.swift | 8 +- openHABWatch/Views/ContentView.swift | 4 +- 8 files changed, 285 insertions(+), 263 deletions(-) create mode 100644 OpenHABCore/Sources/OpenHABCore/Util/HTTPClientDelegate.swift diff --git a/OpenHABCore/Package.swift b/OpenHABCore/Package.swift index 631a7b681..4b7b029f8 100644 --- a/OpenHABCore/Package.swift +++ b/OpenHABCore/Package.swift @@ -37,9 +37,11 @@ let package = Package( ], swiftSettings: [ .enableUpcomingFeature("BareSlashRegexLiterals"), - .enableExperimentalFeature("StrictConcurrency") -// , .unsafeFlags(["-strict-concurrency=targeted"]) -// .enableUpcomingFeature("all") + .enableExperimentalFeature("StrictConcurrency"), + .unsafeFlags([ + "-Xfrontend", "-enable-actor-data-race-checks", + "-Xfrontend", "-strict-concurrency=complete" + ]) ] ), .testTarget( diff --git a/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift b/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift index a55d37da4..413252409 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift @@ -9,10 +9,10 @@ // // SPDX-License-Identifier: EPL-2.0 -import Foundation +@preconcurrency import Foundation import os -private enum HTTPClientError: Error { +public enum HTTPClientError: Error { case serverTrustEvaluationFailed(reason: String) case noDataforItem case noDataForProperties @@ -21,6 +21,7 @@ private enum HTTPClientError: Error { case couldNotRegister case couldNotLoadNotification case failedtoFetchMJPEG + case noConfiguration var debugDescription: String { switch self { @@ -40,6 +41,8 @@ private enum HTTPClientError: Error { "Could not load notification" case .failedtoFetchMJPEG: "Failed to fetch MJPEG" + case .noConfiguration: + "No configuration" } } } @@ -51,6 +54,71 @@ public enum CertificateEvaluateResult: Sendable { case permitAlways } +actor CertificateStore { + private var trustedCertificates: [String: Data] = [:] + + private func getPersistencePath() -> URL { + #if os(watchOS) + let documentsDirectory = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true)[0] + return URL(fileURLWithPath: documentsDirectory).appendingPathComponent("trustedCertificates") + #else + FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.org.openhab.app")!.appendingPathComponent("trustedCertificates") + #endif + } + + private func saveTrustedCertificates() { + do { + let data = try PropertyListEncoder().encode(trustedCertificates) + try data.write(to: getPersistencePath()) + } catch { + os_log("Could not save trusted certificates", log: .default) + } + } + + private func loadTrustedCertificates() { + var decodableTrustedCertificates: [String: Data] = [:] + do { + let rawdata = try Data(contentsOf: getPersistencePath()) + let decoder = PropertyListDecoder() + decodableTrustedCertificates = try decoder.decode([String: Data].self, from: rawdata) + trustedCertificates = decodableTrustedCertificates + } catch { + // if Decodable fails, fall back to NSKeyedArchiver + do { + let rawdata = try Data(contentsOf: getPersistencePath()) + if let unarchivedTrustedCertificates = try? NSKeyedUnarchiver.unarchivedObject(ofClasses: [NSDictionary.self, NSString.self, NSData.self], from: rawdata) as? [String: Data] { + trustedCertificates = unarchivedTrustedCertificates + saveTrustedCertificates() // Ensure that data is written in new format + } + } catch { + os_log("Could not load trusted certificates", log: .default) + } + } + } + + private func initializeCertificatesStore() { + os_log("Initializing cert store", log: .default, type: .info) + loadTrustedCertificates() + if trustedCertificates.isEmpty { + os_log("No cert store, creating", log: .default, type: .info) + trustedCertificates = [:] + saveTrustedCertificates() + } else { + os_log("Loaded existing cert store", log: .default, type: .info) + } + } + + public func storeCertificateData(_ certificate: Data?, forDomain domain: String) { + trustedCertificates[domain] = certificate + saveTrustedCertificates() + } + + public func certificateData(forDomain domain: String) -> Data? { + guard let data = trustedCertificates[domain] else { return nil } + return data + } +} + public final class HTTPClient: NSObject { // MARK: - Properties @@ -61,47 +129,21 @@ public final class HTTPClient: NSObject { } // this can be changed if we detect another server - public var baseURL: URL? - - public var session: URLSession! - private let username: String - private let password: String - private let alwaysSendBasicAuth: Bool - private let ignoreSSL: Bool - private var evaluateContinuation: CheckedContinuation? - private var trustedCertificates: [String: Data] = [:] + public let baseURL: URL? private let logger = Logger(subsystem: "org.openhab.core", category: "HTTPClient") - public init(baseURL: URL? = nil, username: String = "", password: String = "", alwaysSendBasicAuth: Bool = false, ignoreSSL: Bool = false) { - self.username = username - self.password = password - self.alwaysSendBasicAuth = alwaysSendBasicAuth - self.ignoreSSL = ignoreSSL - super.init() - - let config = URLSessionConfiguration.default - config.timeoutIntervalForRequest = 10 - config.timeoutIntervalForResource = 60 - - session = URLSession(configuration: config, delegate: self, delegateQueue: nil) - initializeCertificatesStore() - } + private let configuration: ConnectionConfiguration + public let session: URLSession + public let delegate: HTTPClientDelegate public init(baseURL: URL? = nil, configuration: ConnectionConfiguration) { -// public init(baseURL: URL? = nil, username: String = "", password: String = "", alwaysSendBasicAuth: Bool = false, ignoreSSL: Bool = false) { - username = configuration.username - password = configuration.password - alwaysSendBasicAuth = configuration.alwaysSendBasicAuth - ignoreSSL = configuration.ignoreSSL - super.init() - + self.configuration = configuration + self.baseURL = baseURL + delegate = HTTPClientDelegate(with: configuration) let config = URLSessionConfiguration.default - config.timeoutIntervalForRequest = 10 - config.timeoutIntervalForResource = 60 - - session = URLSession(configuration: config, delegate: self, delegateQueue: nil) - initializeCertificatesStore() + session = URLSession(configuration: config, delegate: delegate, delegateQueue: nil) + super.init() } public func processStream(url: URL) async throws -> (URLSession.AsyncBytes, URLResponse) { @@ -207,6 +249,10 @@ public final class HTTPClient: NSObject { private func performRequest(request: URLRequest, type: SessionType = .data) async throws -> (T, URLResponse) { var request = request + let username = configuration.username + let password = configuration.password + let alwaysSendBasicAuth = configuration.alwaysSendBasicAuth + if request.url?.host?.hasSuffix("myopenhab.org") == true || alwaysSendBasicAuth, !username.isEmpty, !password.isEmpty { request.setValue(basicAuthHeader(username: username, password: password), forHTTPHeaderField: "Authorization") } @@ -220,219 +266,6 @@ public final class HTTPClient: NSObject { return try await session.bytes(for: request) as! (T, URLResponse) } } - - // MARK: - SSL Certificate Handling - - private func initializeCertificatesStore() { - os_log("Initializing cert store", log: .default, type: .info) - loadTrustedCertificates() - if trustedCertificates.isEmpty { - os_log("No cert store, creating", log: .default, type: .info) - trustedCertificates = [:] - saveTrustedCertificates() - } else { - os_log("Loaded existing cert store", log: .default, type: .info) - } - } - - private func getPersistencePath() -> URL { - #if os(watchOS) - let documentsDirectory = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true)[0] - return URL(fileURLWithPath: documentsDirectory).appendingPathComponent("trustedCertificates") - #else - FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.org.openhab.app")!.appendingPathComponent("trustedCertificates") - #endif - } - - private func saveTrustedCertificates() { - do { - let data = try PropertyListEncoder().encode(trustedCertificates) - try data.write(to: getPersistencePath()) - } catch { - os_log("Could not save trusted certificates", log: .default) - } - } - - private func loadTrustedCertificates() { - var decodableTrustedCertificates: [String: Data] = [:] - do { - let rawdata = try Data(contentsOf: getPersistencePath()) - let decoder = PropertyListDecoder() - decodableTrustedCertificates = try decoder.decode([String: Data].self, from: rawdata) - trustedCertificates = decodableTrustedCertificates - } catch { - // if Decodable fails, fall back to NSKeyedArchiver - do { - let rawdata = try Data(contentsOf: getPersistencePath()) - if let unarchivedTrustedCertificates = try? NSKeyedUnarchiver.unarchivedObject(ofClasses: [NSDictionary.self, NSString.self, NSData.self], from: rawdata) as? [String: Data] { - trustedCertificates = unarchivedTrustedCertificates - saveTrustedCertificates() // Ensure that data is written in new format - } - } catch { - os_log("Could not load trusted certificates", log: .default) - } - } - } - - private func storeCertificateData(_ certificate: CFData?, forDomain domain: String) { - let certificateData = certificate as Data? - trustedCertificates[domain] = certificateData - saveTrustedCertificates() - } - - private func certificateData(forDomain domain: String) -> CFData? { - guard let certificateData = trustedCertificates[domain] else { return nil } - return certificateData as CFData - } - - private func getLeafCertificate(trust: SecTrust?) -> SecCertificate? { - if let trust, SecTrustGetCertificateCount(trust) > 0, - let certificates = SecTrustCopyCertificateChain(trust) as? [SecCertificate] { - return certificates[0] - } - return nil - } - - private func waitForEvaluation() async -> CertificateEvaluateResult { - await withCheckedContinuation { continuation in - evaluateContinuation = continuation - } - } - - public func completeEvaluation(_ result: CertificateEvaluateResult) { - logger.info("Completing evaluation with result: \(String(describing: result))") - evaluateContinuation?.resume(returning: result) - evaluateContinuation = nil - } -} - -extension HTTPClient: URLSessionDelegate, URLSessionTaskDelegate { - // MARK: - URLSessionDelegate for Client Certificates and Basic Auth - - public func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { - await urlSessionInternal(session, task: nil, didReceive: challenge) - } - - public func urlSession(_ session: URLSession, task: URLSessionTask, didReceive challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { - await urlSessionInternal(session, task: task, didReceive: challenge) - } - - private func urlSessionInternal(_ session: URLSession, task: URLSessionTask?, didReceive challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { - let authenticationMethod = challenge.protectionSpace.authenticationMethod - logger.debug("URLAuthenticationChallenge: \(authenticationMethod)") - - if challenge.previousFailureCount > 0 { - return (.cancelAuthenticationChallenge, nil) - } else { - switch authenticationMethod { - case NSURLAuthenticationMethodServerTrust: - let result = await handleServerTrust(challenge: challenge) - return result - case NSURLAuthenticationMethodDefault, NSURLAuthenticationMethodHTTPBasic: - let result = await handleBasicAuth(challenge: challenge) - return result - case NSURLAuthenticationMethodClientCertificate: - let result = await handleClientCertificateAuth(challenge: challenge) - return result - default: - return (.performDefaultHandling, nil) - } - } - } - - private func handleServerTrust(challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { - let domain = challenge.protectionSpace.host - logger.info("Handling server trust for domain: \(domain)") - - guard let serverTrust = challenge.protectionSpace.serverTrust else { - logger.error("No server trust object available") - return (.cancelAuthenticationChallenge, nil) - } - - var result: SecTrustResultType = .invalid - var error: CFError? - _ = SecTrustEvaluateWithError(serverTrust, &error) - SecTrustGetTrustResult(serverTrust, &result) - logger.info("Trust evaluation result: \(result.rawValue), error: \(String(describing: error))") - - if result.isAny(of: .unspecified, .proceed) || ignoreSSL { - logger.info("Certificate is trusted or SSL verification ignored") - return (.useCredential, URLCredential(trust: serverTrust)) - } - - guard let certificate = getLeafCertificate(trust: serverTrust) else { - logger.error("Could not get leaf certificate") - return (.cancelAuthenticationChallenge, nil) - } - - let certificateSummary = SecCertificateCopySubjectSummary(certificate) - let certificateData = SecCertificateCopyData(certificate) - - // If we have a certificate for this domain - if let previousCertificateData = self.certificateData(forDomain: domain) { - if CFEqual(previousCertificateData, certificateData) { - logger.info("Using previously trusted certificate for domain: \(domain)") - return (.useCredential, URLCredential(trust: serverTrust)) - } else { - logger.warning("Certificate mismatch detected for domain: \(domain)") - // Certificate mismatch - possible MitM attack - NotificationCenter.default.post( - name: .evaluateCertificateMismatch, - object: self, - userInfo: ["summary": certificateSummary as Any, "domain": domain] - ) - let evaluateResult = await waitForEvaluation() - logger.info("User decision for certificate mismatch: \(String(describing: evaluateResult))") - - switch evaluateResult { - case .deny: - return (.cancelAuthenticationChallenge, nil) - case .permitOnce: - return (.useCredential, URLCredential(trust: serverTrust)) - case .permitAlways: - storeCertificateData(certificateData, forDomain: domain) - NotificationCenter.default.post(name: .acceptedServerCertificatesChanged, object: self) - return (.useCredential, URLCredential(trust: serverTrust)) - case .undecided: - return (.cancelAuthenticationChallenge, nil) - } - } - } - - // New certificate - logger.info("New untrusted certificate for domain: \(domain)") - NotificationCenter.default.post( - name: .evaluateServerTrust, - object: self, - userInfo: ["summary": certificateSummary as Any, "domain": domain] - ) - let evaluateResult = await waitForEvaluation() - logger.info("User decision for new certificate: \(String(describing: evaluateResult))") - - switch evaluateResult { - case .deny: - return (.cancelAuthenticationChallenge, nil) - case .permitOnce: - return (.useCredential, URLCredential(trust: serverTrust)) - case .permitAlways: - storeCertificateData(certificateData, forDomain: domain) - NotificationCenter.default.post(name: .acceptedServerCertificatesChanged, object: self) - return (.useCredential, URLCredential(trust: serverTrust)) - case .undecided: - return (.cancelAuthenticationChallenge, nil) - } - } - - private func handleBasicAuth(challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { - let credential = URLCredential(user: username, password: password, persistence: .forSession) - return (.useCredential, credential) - } - - private func handleClientCertificateAuth(challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { - let certificateManager = ClientCertificateManager() - let (disposition, credential) = certificateManager.evaluateTrust(with: challenge) - return (disposition, credential) - } } public extension Notification.Name { diff --git a/OpenHABCore/Sources/OpenHABCore/Util/HTTPClientDelegate.swift b/OpenHABCore/Sources/OpenHABCore/Util/HTTPClientDelegate.swift new file mode 100644 index 000000000..12de08ed9 --- /dev/null +++ b/OpenHABCore/Sources/OpenHABCore/Util/HTTPClientDelegate.swift @@ -0,0 +1,175 @@ +// Copyright (c) 2010-2025 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 Foundation +import os + +// MARK: - URLSessionDelegate for Client Certificates and Basic Auth + +public final class HTTPClientDelegate: NSObject, URLSessionDelegate, URLSessionTaskDelegate { + private let connectionConfiguration: ConnectionConfiguration + private var evaluateContinuation: CheckedContinuation? + + private let logger = Logger(subsystem: "org.openhab.core", category: "HTTPClientDelegate") + + let store = CertificateStore() + + init(with connectionConfiguration: ConnectionConfiguration) { + self.connectionConfiguration = connectionConfiguration + } + + public func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { + await urlSessionInternal(session, task: nil, didReceive: challenge) + } + + public func urlSession(_ session: URLSession, task: URLSessionTask, didReceive challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { + await urlSessionInternal(session, task: task, didReceive: challenge) + } + + private func urlSessionInternal(_ session: URLSession, task: URLSessionTask?, didReceive challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { + let authenticationMethod = challenge.protectionSpace.authenticationMethod + logger.debug("URLAuthenticationChallenge: \(authenticationMethod)") + + if challenge.previousFailureCount > 0 { + return (.cancelAuthenticationChallenge, nil) + } else { + switch authenticationMethod { + case NSURLAuthenticationMethodServerTrust: + let result = await handleServerTrust(challenge: challenge) + return result + case NSURLAuthenticationMethodDefault, NSURLAuthenticationMethodHTTPBasic: + let result = await handleBasicAuth(challenge: challenge) + return result + case NSURLAuthenticationMethodClientCertificate: + let result = await handleClientCertificateAuth(challenge: challenge) + return result + default: + return (.performDefaultHandling, nil) + } + } + } + + private func handleServerTrust(challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { + let domain = challenge.protectionSpace.host + logger.info("Handling server trust for domain: \(domain)") + + guard let serverTrust = challenge.protectionSpace.serverTrust else { + logger.error("No server trust object available") + return (.cancelAuthenticationChallenge, nil) + } + + var result: SecTrustResultType = .invalid + var error: CFError? + _ = SecTrustEvaluateWithError(serverTrust, &error) + SecTrustGetTrustResult(serverTrust, &result) + logger.info("Trust evaluation result: \(result.rawValue), error: \(String(describing: error))") + + if result.isAny(of: .unspecified, .proceed) || connectionConfiguration.ignoreSSL { + logger.info("Certificate is trusted or SSL verification ignored") + return (.useCredential, URLCredential(trust: serverTrust)) + } + + guard let certificate = getLeafCertificate(trust: serverTrust) else { + logger.error("Could not get leaf certificate") + return (.cancelAuthenticationChallenge, nil) + } + + let certificateSummary = SecCertificateCopySubjectSummary(certificate) + let certificateData = SecCertificateCopyData(certificate) + + // If we have a certificate for this domain + if let previousCertificateData = await store.certificateData(forDomain: domain) { + if CFEqual(previousCertificateData as CFData, certificateData) { + logger.info("Using previously trusted certificate for domain: \(domain)") + return (.useCredential, URLCredential(trust: serverTrust)) + } else { + logger.warning("Certificate mismatch detected for domain: \(domain)") + // Certificate mismatch - possible MitM attack + NotificationCenter.default.post( + name: .evaluateCertificateMismatch, + object: self, + userInfo: ["summary": certificateSummary as Any, "domain": domain] + ) + let evaluateResult = await waitForEvaluation() + logger.info("User decision for certificate mismatch: \(String(describing: evaluateResult))") + + switch evaluateResult { + case .deny: + return (.cancelAuthenticationChallenge, nil) + case .permitOnce: + return (.useCredential, URLCredential(trust: serverTrust)) + case .permitAlways: + await store.storeCertificateData(certificateData as Data, forDomain: domain) + NotificationCenter.default.post(name: .acceptedServerCertificatesChanged, object: self) + return (.useCredential, URLCredential(trust: serverTrust)) + case .undecided: + return (.cancelAuthenticationChallenge, nil) + } + } + } + + // New certificate + logger.info("New untrusted certificate for domain: \(domain)") + NotificationCenter.default.post( + name: .evaluateServerTrust, + object: self, + userInfo: ["summary": certificateSummary as Any, "domain": domain] + ) + let evaluateResult = await waitForEvaluation() + logger.info("User decision for new certificate: \(String(describing: evaluateResult))") + + switch evaluateResult { + case .deny: + return (.cancelAuthenticationChallenge, nil) + case .permitOnce: + return (.useCredential, URLCredential(trust: serverTrust)) + case .permitAlways: + await store.storeCertificateData(certificateData as Data, forDomain: domain) + NotificationCenter.default.post(name: .acceptedServerCertificatesChanged, object: self) + return (.useCredential, URLCredential(trust: serverTrust)) + case .undecided: + return (.cancelAuthenticationChallenge, nil) + } + } + + private func handleBasicAuth(challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { + let credential = URLCredential(user: connectionConfiguration.username, password: connectionConfiguration.password, persistence: .forSession) + return (.useCredential, credential) + } + + private func handleClientCertificateAuth(challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { + let certificateManager = ClientCertificateManager() + let (disposition, credential) = certificateManager.evaluateTrust(with: challenge) + return (disposition, credential) + } + + // MARK: - SSL Certificate Handling + + private func getLeafCertificate(trust: SecTrust?) -> SecCertificate? { + if let trust, SecTrustGetCertificateCount(trust) > 0, + let certificates = SecTrustCopyCertificateChain(trust) as? [SecCertificate] { + return certificates[0] + } + return nil + } + + public func waitForEvaluation() async -> CertificateEvaluateResult { + await withCheckedContinuation { continuation in + evaluateContinuation = continuation + } + } + + public func completeEvaluation(_ result: CertificateEvaluateResult) { + logger.info("Completing evaluation with result: \(String(describing: result))") + evaluateContinuation?.resume(returning: result) + evaluateContinuation = nil + } +} diff --git a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift index 3187d6e52..170a52d1e 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift @@ -101,7 +101,7 @@ public actor ConnectionFailureTracker { } } -public protocol NetworkTracking: ObservableObject, Sendable { +public protocol NetworkTracking: ObservableObject { var activeConnection: ConnectionInfo? { get } } diff --git a/openHAB/NewImageUITableViewCell.swift b/openHAB/NewImageUITableViewCell.swift index 042e945db..f87a1b3b7 100644 --- a/openHAB/NewImageUITableViewCell.swift +++ b/openHAB/NewImageUITableViewCell.swift @@ -30,6 +30,8 @@ class NewImageUITableViewCell: GenericUITableViewCell, NoIconDisplayableCell { var openHABRootUrl: String? + private let logger = Logger(subsystem: "org.openhab.app", category: "NewImageUITableViewCell") + private var shouldCache: Bool { widget?.refresh == 0 } @@ -151,7 +153,7 @@ class NewImageUITableViewCell: GenericUITableViewCell, NoIconDisplayableCell { } private func loadRemoteImage(withURL url: URL) { - os_log("Image URL: %{PUBLIC}@", log: OSLog.urlComposition, type: .debug, url.absoluteString) + logger.debug("Image URL: \(url.absoluteString)") if activeTask != nil { activeTask?.cancel() @@ -160,7 +162,11 @@ class NewImageUITableViewCell: GenericUITableViewCell, NoIconDisplayableCell { activeTask = Task { do { - let client = HTTPClient(username: Preferences.username, password: Preferences.username, alwaysSendBasicAuth: Preferences.alwaysSendCreds) + guard let config = Preferences.getLowestPriorityOpenHABConnection() else { + logger.warning("No openHAB configuration found.") + throw HTTPClientError.noConfiguration + } + let client = HTTPClient(configuration: config) let (data, _): (Data, URLResponse) = try await client.doRequest(baseURL: url, timeout: 10.0, type: .data, cacheingPolicy: !shouldCache ? .reloadIgnoringCacheData : .useProtocolCachePolicy) await MainActor.run { self.mainImageView?.image = UIImage(data: data) @@ -168,7 +174,7 @@ class NewImageUITableViewCell: GenericUITableViewCell, NoIconDisplayableCell { self.didLoad?() } } catch { - os_log("Download failed: %{PUBLIC}@", log: .urlComposition, type: .debug, error.localizedDescription) + logger.info("Downloading image failed: \(error.localizedDescription)") } } } diff --git a/openHAB/OpenHABRootViewController.swift b/openHAB/OpenHABRootViewController.swift index 1e47f3b20..6fdee2fa8 100644 --- a/openHAB/OpenHABRootViewController.swift +++ b/openHAB/OpenHABRootViewController.swift @@ -557,15 +557,15 @@ class OpenHABRootViewController: UIViewController { ) alert.addAction(UIAlertAction(title: "Always", style: .default) { _ in - client.completeEvaluation(.permitAlways) + client.delegate.completeEvaluation(.permitAlways) }) alert.addAction(UIAlertAction(title: "Once", style: .default) { _ in - client.completeEvaluation(.permitOnce) + client.delegate.completeEvaluation(.permitOnce) }) alert.addAction(UIAlertAction(title: "Deny", style: .cancel) { _ in - client.completeEvaluation(.deny) + client.delegate.completeEvaluation(.deny) }) self.present(alert, animated: true) diff --git a/openHAB/VideoUITableViewCell.swift b/openHAB/VideoUITableViewCell.swift index 59314a2f4..9d673b604 100644 --- a/openHAB/VideoUITableViewCell.swift +++ b/openHAB/VideoUITableViewCell.swift @@ -34,6 +34,8 @@ class VideoUITableViewCell: GenericUITableViewCell, NoIconDisplayableCell { } } + private let logger = Logger(subsystem: "org.openhab.app", category: "VideoUITableViewCell") + private var playerView: PlayerView! private var mainImageView: UIImageView! private var playerObserver: NSKeyValueObservation? @@ -163,7 +165,11 @@ class VideoUITableViewCell: GenericUITableViewCell, NoIconDisplayableCell { activeTask = Task { do { - let client = HTTPClient(username: Preferences.username, password: Preferences.username, alwaysSendBasicAuth: Preferences.alwaysSendCreds) + guard let config = Preferences.getLowestPriorityOpenHABConnection() else { + logger.warning("No openHAB configuration found.") + throw HTTPClientError.noConfiguration + } + let client = HTTPClient(configuration: config) let (byteStream, _) = try await client.processStream(url: url) await handleMJPEGStream(byteStream) } catch { diff --git a/openHABWatch/Views/ContentView.swift b/openHABWatch/Views/ContentView.swift index 60810f3c4..be8e5fab3 100644 --- a/openHABWatch/Views/ContentView.swift +++ b/openHABWatch/Views/ContentView.swift @@ -60,12 +60,12 @@ struct ContentView: View { message: Text(viewModel.certificateErrorDescription), primaryButton: .default(Text(NSLocalizedString("always", comment: ""))) { if let client = viewModel.currentClient { - client.completeEvaluation(.permitAlways) + client.delegate.completeEvaluation(.permitAlways) } }, secondaryButton: .destructive(Text(NSLocalizedString("deny", comment: ""))) { if let client = viewModel.currentClient { - client.completeEvaluation(.deny) + client.delegate.completeEvaluation(.deny) } } ) From 9242c0a27e64aa9067faa45eb1ed958370b0bfa0 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Thu, 8 May 2025 17:18:17 +0200 Subject: [PATCH 184/476] Removing OpenAPIServiceDelegate in favor of HTTPClientDelegate with identical features Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../OpenHABCore/Util/HTTPClientDelegate.swift | 25 +- .../OpenHABCore/Util/OpenAPIService.swift | 2 +- .../Util/OpenAPIServiceDelegate.swift | 235 ------------------ 3 files changed, 21 insertions(+), 241 deletions(-) delete mode 100644 OpenHABCore/Sources/OpenHABCore/Util/OpenAPIServiceDelegate.swift diff --git a/OpenHABCore/Sources/OpenHABCore/Util/HTTPClientDelegate.swift b/OpenHABCore/Sources/OpenHABCore/Util/HTTPClientDelegate.swift index 12de08ed9..03ab3d667 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/HTTPClientDelegate.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/HTTPClientDelegate.swift @@ -12,11 +12,24 @@ import Foundation import os +actor CertificateEvaluationState { + private var continuation: CheckedContinuation? + + func store(_ continuation: CheckedContinuation) { + self.continuation = continuation + } + + func complete(_ result: CertificateEvaluateResult) { + continuation?.resume(returning: result) + continuation = nil + } +} + // MARK: - URLSessionDelegate for Client Certificates and Basic Auth public final class HTTPClientDelegate: NSObject, URLSessionDelegate, URLSessionTaskDelegate { private let connectionConfiguration: ConnectionConfiguration - private var evaluateContinuation: CheckedContinuation? + private let evaluationState = CertificateEvaluationState() private let logger = Logger(subsystem: "org.openhab.core", category: "HTTPClientDelegate") @@ -163,13 +176,15 @@ public final class HTTPClientDelegate: NSObject, URLSessionDelegate, URLSessionT public func waitForEvaluation() async -> CertificateEvaluateResult { await withCheckedContinuation { continuation in - evaluateContinuation = continuation + Task { + await evaluationState.store(continuation) + } } } public func completeEvaluation(_ result: CertificateEvaluateResult) { - logger.info("Completing evaluation with result: \(String(describing: result))") - evaluateContinuation?.resume(returning: result) - evaluateContinuation = nil + Task { + await evaluationState.complete(result) + } } } diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift index e6c3ba30c..ec821c849 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift @@ -63,7 +63,7 @@ public actor OpenAPIService { throw OpenHABSitemapError.invalidConnectionConfiguration } self.connectionConfiguration = connectionConfiguration - let delegate = OpenAPIServiceDelegate(with: connectionConfiguration) + let delegate = HTTPClientDelegate(with: connectionConfiguration) let config = URLSessionConfiguration.default switch serviceConfiguration { case .asDefault: diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIServiceDelegate.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIServiceDelegate.swift deleted file mode 100644 index 13ea83213..000000000 --- a/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIServiceDelegate.swift +++ /dev/null @@ -1,235 +0,0 @@ -// Copyright (c) 2010-2025 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 Foundation -import os - -// MARK: - URLSessionDelegate for Client Certificates and Basic Auth - -final class OpenAPIServiceDelegate: NSObject, URLSessionDelegate, URLSessionTaskDelegate { - private let connectionConfiguration: ConnectionConfiguration - private var trustedCertificates: [String: Data] = [:] - private var evaluateContinuation: CheckedContinuation? - - private let logger = Logger(subsystem: "org.openhab.core", category: "OpenAPIServiceDelegate") - - init(with connectionConfiguration: ConnectionConfiguration) { - self.connectionConfiguration = connectionConfiguration - } - - public func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { - await urlSessionInternal(session, task: nil, didReceive: challenge) - } - - public func urlSession(_ session: URLSession, task: URLSessionTask, didReceive challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { - await urlSessionInternal(session, task: task, didReceive: challenge) - } - - private func urlSessionInternal(_ session: URLSession, task: URLSessionTask?, didReceive challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { - let authenticationMethod = challenge.protectionSpace.authenticationMethod - logger.debug("URLAuthenticationChallenge: \(authenticationMethod)") - - if challenge.previousFailureCount > 0 { - return (.cancelAuthenticationChallenge, nil) - } else { - switch authenticationMethod { - case NSURLAuthenticationMethodServerTrust: - let result = await handleServerTrust(challenge: challenge) - return result - case NSURLAuthenticationMethodDefault, NSURLAuthenticationMethodHTTPBasic: - let result = handleBasicAuth(challenge: challenge) - return result - case NSURLAuthenticationMethodClientCertificate: - let result = handleClientCertificateAuth(challenge: challenge) - return result - default: - return (.performDefaultHandling, nil) - } - } - } - - private func handleServerTrust(challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { - let domain = challenge.protectionSpace.host - logger.debug("Handling server trust for domain: \(domain)") - - guard let serverTrust = challenge.protectionSpace.serverTrust else { - logger.error("No server trust object available") - return (.cancelAuthenticationChallenge, nil) - } - - var result: SecTrustResultType = .invalid - var error: CFError? - _ = SecTrustEvaluateWithError(serverTrust, &error) - SecTrustGetTrustResult(serverTrust, &result) - - if result.isAny(of: .unspecified, .proceed) || connectionConfiguration.ignoreSSL { - logger.debug("Certificate is trusted or SSL verification ignored") - return (.useCredential, URLCredential(trust: serverTrust)) - } - - guard let certificate = getLeafCertificate(trust: serverTrust) else { - logger.error("Could not get leaf certificate") - return (.cancelAuthenticationChallenge, nil) - } - - let certificateSummary = SecCertificateCopySubjectSummary(certificate) - let certificateData = SecCertificateCopyData(certificate) - - // If we have a certificate for this domain - if let previousCertificateData = self.certificateData(forDomain: domain) { - if CFEqual(previousCertificateData, certificateData) { - logger.info("Using previously trusted certificate for domain: \(domain)") - return (.useCredential, URLCredential(trust: serverTrust)) - } else { - logger.warning("Certificate mismatch detected for domain: \(domain)") - // Certificate mismatch - possible MitM attack - NotificationCenter.default.post( - name: .evaluateCertificateMismatch, - object: self, - userInfo: ["summary": certificateSummary as Any, "domain": domain] - ) - let evaluateResult = await waitForEvaluation() - logger.info("User decision for certificate mismatch: \(String(describing: evaluateResult))") - - switch evaluateResult { - case .deny: - return (.cancelAuthenticationChallenge, nil) - case .permitOnce: - return (.useCredential, URLCredential(trust: serverTrust)) - case .permitAlways: - storeCertificateData(certificateData, forDomain: domain) - NotificationCenter.default.post(name: .acceptedServerCertificatesChanged, object: self) - return (.useCredential, URLCredential(trust: serverTrust)) - case .undecided: - return (.cancelAuthenticationChallenge, nil) - } - } - } - - // New certificate - logger.info("New untrusted certificate for domain: \(domain)") - NotificationCenter.default.post( - name: .evaluateServerTrust, - object: self, - userInfo: ["summary": certificateSummary as Any, "domain": domain] - ) - let evaluateResult = await waitForEvaluation() - logger.info("User decision for new certificate: \(String(describing: evaluateResult))") - - switch evaluateResult { - case .deny: - return (.cancelAuthenticationChallenge, nil) - case .permitOnce: - return (.useCredential, URLCredential(trust: serverTrust)) - case .permitAlways: - storeCertificateData(certificateData, forDomain: domain) - NotificationCenter.default.post(name: .acceptedServerCertificatesChanged, object: self) - return (.useCredential, URLCredential(trust: serverTrust)) - case .undecided: - return (.cancelAuthenticationChallenge, nil) - } - } - - private func handleBasicAuth(challenge: URLAuthenticationChallenge) -> (URLSession.AuthChallengeDisposition, URLCredential?) { - let credential = URLCredential(user: connectionConfiguration.username, password: connectionConfiguration.password, persistence: .forSession) - return (.useCredential, credential) - } - - private func handleClientCertificateAuth(challenge: URLAuthenticationChallenge) -> (URLSession.AuthChallengeDisposition, URLCredential?) { - let certificateManager = ClientCertificateManager() - let (disposition, credential) = certificateManager.evaluateTrust(with: challenge) - return (disposition, credential) - } - - // MARK: - SSL Certificate Handling - - private func initializeCertificatesStore() { - os_log("Initializing cert store", log: .default, type: .info) - loadTrustedCertificates() - if trustedCertificates.isEmpty { - os_log("No cert store, creating", log: .default, type: .info) - trustedCertificates = [:] - saveTrustedCertificates() - } else { - os_log("Loaded existing cert store", log: .default, type: .info) - } - } - - private func getPersistencePath() -> URL { - #if os(watchOS) - return URL.documentsDirectory.appendingPathComponent("trustedCertificates") - #else - let appGroupURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.org.openhab.app")! - return appGroupURL.appendingPathComponent("trustedCertificates") - #endif - } - - private func saveTrustedCertificates() { - do { - let data = try PropertyListEncoder().encode(trustedCertificates) - try data.write(to: getPersistencePath()) - } catch { - os_log("Could not save trusted certificates", log: .default) - } - } - - private func loadTrustedCertificates() { - var decodableTrustedCertificates: [String: Data] = [:] - do { - let rawdata = try Data(contentsOf: getPersistencePath()) - let decoder = PropertyListDecoder() - decodableTrustedCertificates = try decoder.decode([String: Data].self, from: rawdata) - trustedCertificates = decodableTrustedCertificates - } catch { - // if Decodable fails, fall back to NSKeyedArchiver - do { - let rawdata = try Data(contentsOf: getPersistencePath()) - if let unarchivedTrustedCertificates = try? NSKeyedUnarchiver.unarchivedObject(ofClasses: [NSDictionary.self, NSString.self, NSData.self], from: rawdata) as? [String: Data] { - trustedCertificates = unarchivedTrustedCertificates - saveTrustedCertificates() // Ensure that data is written in new format - } - } catch { - os_log("Could not load trusted certificates", log: .default) - } - } - } - - private func storeCertificateData(_ certificate: CFData?, forDomain domain: String) { - let certificateData = certificate as Data? - trustedCertificates[domain] = certificateData - saveTrustedCertificates() - } - - private func certificateData(forDomain domain: String) -> CFData? { - guard let certificateData = trustedCertificates[domain] else { return nil } - return certificateData as CFData - } - - private func getLeafCertificate(trust: SecTrust?) -> SecCertificate? { - if let trust, SecTrustGetCertificateCount(trust) > 0, - let certificates = SecTrustCopyCertificateChain(trust) as? [SecCertificate] { - return certificates[0] - } - return nil - } - - private func waitForEvaluation() async -> CertificateEvaluateResult { - await withCheckedContinuation { continuation in - evaluateContinuation = continuation - } - } - - public func completeEvaluation(_ result: CertificateEvaluateResult) { - logger.info("Completing evaluation with result: \(String(describing: result))") - evaluateContinuation?.resume(returning: result) - evaluateContinuation = nil - } -} From c3cdfbaf4fae913b845d372600fce3300a48e096 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Fri, 9 May 2025 07:49:43 +0200 Subject: [PATCH 185/476] Improving tests Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../OpenHABCoreTests/UserDefaultsTests.swift | 39 +++++++++++-------- openHAB/AppDelegate.swift | 3 +- 2 files changed, 24 insertions(+), 18 deletions(-) diff --git a/OpenHABCore/Tests/OpenHABCoreTests/UserDefaultsTests.swift b/OpenHABCore/Tests/OpenHABCoreTests/UserDefaultsTests.swift index 8308173dd..ecee1706b 100644 --- a/OpenHABCore/Tests/OpenHABCoreTests/UserDefaultsTests.swift +++ b/OpenHABCore/Tests/OpenHABCoreTests/UserDefaultsTests.swift @@ -19,30 +19,37 @@ final class UserDefaultsTests: XCTestCase { override func setUpWithError() throws { super.setUp() - let defaultsName = try XCTUnwrap(Bundle.main.bundleIdentifier) - data.removePersistentDomain(forName: defaultsName) - - Preferences.username = Preferences.username - Preferences.localUrl = Preferences.localUrl - Preferences.remoteUrl = Preferences.remoteUrl - Preferences.password = Preferences.password - Preferences.ignoreSSL = Preferences.ignoreSSL - Preferences.demomode = Preferences.demomode - Preferences.idleOff = Preferences.idleOff - Preferences.iconType = Preferences.iconType - Preferences.defaultSitemap = Preferences.defaultSitemap - Preferences.sitemapForWatch = Preferences.sitemapForWatch + // Set Preferences from MainActor + let expectation = expectation(description: "MainActor setup") + + Task { @MainActor in + // Reset UserDefaults + let defaultsName = try XCTUnwrap(Bundle.main.bundleIdentifier) + data.removePersistentDomain(forName: defaultsName) + + Preferences.username = "testuser" + Preferences.localUrl = "http://local.test" + Preferences.remoteUrl = "http://remote.test" + Preferences.password = "secret" + Preferences.ignoreSSL = true + Preferences.demomode = true + Preferences.idleOff = false + Preferences.iconType = 2 + Preferences.defaultSitemap = "default" + Preferences.sitemapForWatch = "watchmap" + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1.0) } - // Testing the consistency between properties of Preferences and the corresponding entry in UserDefaults + // Testing the consistency between Preferences and UserDefaults func testConsistency() { XCTAssertEqual(Preferences.username, data.string(forKey: "username")) - XCTAssertNotEqual(Preferences.username, data.string(forKey: "usern")) XCTAssertEqual(Preferences.localUrl, data.string(forKey: "localUrl")) XCTAssertEqual(Preferences.remoteUrl, data.string(forKey: "remoteUrl")) XCTAssertEqual(Preferences.password, data.string(forKey: "password")) XCTAssertEqual(Preferences.ignoreSSL, data.bool(forKey: "ignoreSSL")) - // XCTAssertEqual(Preferences.sitemapName, data.string(forKey: "sitemapName")) XCTAssertEqual(Preferences.demomode, data.bool(forKey: "demomode")) XCTAssertEqual(Preferences.idleOff, data.bool(forKey: "idleOff")) XCTAssertEqual(Preferences.iconType, data.integer(forKey: "iconType")) diff --git a/openHAB/AppDelegate.swift b/openHAB/AppDelegate.swift index fd4aa5907..ae82462de 100644 --- a/openHAB/AppDelegate.swift +++ b/openHAB/AppDelegate.swift @@ -130,8 +130,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } #endif - UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { [weak self] granted, _ in - guard let self else { return } + UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, _ in os_log("Permission granted: %{PUBLIC}@", log: .notifications, type: .info, granted ? "YES" : "NO") guard granted else { return } UNUserNotificationCenter.current().getNotificationSettings { settings in From c13e2cd9bef85ba07b9a1a8e3ba4bf6ed302a4f5 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Mon, 12 May 2025 21:43:09 +0200 Subject: [PATCH 186/476] Approaching Swift 6 compatibility: Down to 51 open warnings Make NetworkStatus Sendable Defining serviceFactory as Sendable closure Decomposing NetworkTracker into NetworkTrackerViewModel as ObservableObject and NetworkObserver as actor : currently not is use Using @preconcurrency ColorPickerCellDelegate OpenHABWebViewController and VideoUITableviewCell Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../OpenHABCore/Util/NetworkTracker.swift | 206 +++++++++++++++++- .../Util/ServerCertificateManager.swift | 5 +- .../OpenHABCoreTests/MockURLProtocol.swift | 42 +++- .../ServerCertificateManagerTests.swift | 4 + .../OpenHABCoreTests/UserDefaultsTests.swift | 48 ++-- openHAB/AppDelegate.swift | 1 - openHAB/ColorPickerViewController.swift | 2 +- openHAB/OpenHABRootViewController.swift | 75 ++++++- openHAB/OpenHABSitemapViewController.swift | 2 +- openHAB/OpenHABWebViewController.swift | 12 +- openHAB/VideoUITableViewCell.swift | 19 +- openHAB/WatchMessageService.swift | 24 +- 12 files changed, 363 insertions(+), 77 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift index 170a52d1e..86bc56957 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift @@ -17,7 +17,7 @@ import OpenAPIRuntime import os.log // TODO: these strings should reference Localizable keys -public enum NetworkStatus: String { +public enum NetworkStatus: String, Sendable { case connecting = "Connecting" case connected = "Connected" case notConnected = "Not Connected" @@ -55,10 +55,10 @@ public enum NetworkTrackerError: Error, CustomDebugStringConvertible, Sendable { // Avoid memory corruption errors like unrecognized selector. public actor ConnectionPool { private var services: [ConnectionConfiguration: OpenAPIServiceProtocol] = [:] - private let serviceFactory: (ConnectionConfiguration) throws -> OpenAPIServiceProtocol + private let serviceFactory: @Sendable (ConnectionConfiguration) throws -> OpenAPIServiceProtocol // Initializer allowing the injection of mocked OpenAPIServiceProtocol - init(serviceFactory: @escaping (ConnectionConfiguration) throws -> OpenAPIServiceProtocol = { + init(serviceFactory: @escaping @Sendable (ConnectionConfiguration) throws -> OpenAPIServiceProtocol = { try OpenAPIService(connectionConfiguration: $0, serviceConfiguration: .shortTerm) }) { self.serviceFactory = serviceFactory @@ -105,6 +105,206 @@ public protocol NetworkTracking: ObservableObject { var activeConnection: ConnectionInfo? { get } } +@MainActor +public final class NetworkTrackerViewModel: ObservableObject { + @Published public private(set) var activeConnection: ConnectionInfo? + @Published public private(set) var status: NetworkStatus = .connecting + + private let observer: NetworkObserver + + public init(observer: NetworkObserver = .shared) async { + self.observer = observer + await observer.bind(to: self) // ✅ Now allowed + } + + public func startTracking(with configurations: [ConnectionConfiguration]) async { + await observer.startTracking(connectionConfigurations: configurations) + } + + public func send(to item: OpenHABItem, command: String) async throws { + try await observer.send(to: item.name, command: command) + } + + public func updateState(for item: OpenHABItem, state: String) async throws { + try await observer.updateState(for: item.name, state: state) + } + + public func resetFailures() async { + await observer.resetFailures() + } + + // Internal API for observer updates + func updateStatus(_ status: NetworkStatus, connection: ConnectionInfo?) { + Task { @MainActor in + self.status = status + self.activeConnection = connection + } + } +} + +public actor NetworkObserver { + public static let shared = NetworkObserver() + + private var viewModel: NetworkTrackerViewModel? + + private var pathMonitor: NWPathMonitoring = RealPathMonitor() + private var connectionPool = ConnectionPool() + private var failureTracker = ConnectionFailureTracker() + private var connectionConfigurations: [ConnectionConfiguration] = [] + private var retryTask: Task? + + private let logger = Logger(subsystem: "org.openhab.core", category: "NetworkObserver") + + private static func makeNetworkHandler(for observer: NetworkObserver?) -> @Sendable (Bool) -> Void { + { isConnected in + guard let observer else { return } + Task { + await observer.handleNetworkChange(isConnected: isConnected) + } + } + } + + public func bind(to viewModel: NetworkTrackerViewModel) { + self.viewModel = viewModel + } + + public func startTracking(connectionConfigurations: [ConnectionConfiguration]) { + self.connectionConfigurations = connectionConfigurations + + let pathMonitor = pathMonitor + let handler = Self.makeNetworkHandler(for: self) + + Task.detached(priority: .utility) { + await pathMonitor.startMonitoring(handler: handler) + } + + Task { + await self.attemptConnection() + } + } + + private func attemptConnection() async { + guard !connectionConfigurations.isEmpty else { + await updateUI(status: .notConnected, connection: nil) + return + } + + logger.debug("Checking available connections...") + + let sortedConfigs = connectionConfigurations.sorted { $0.priority < $1.priority } + var bestConnection: ConnectionInfo? + var connectedCount = 0 + + await withTaskGroup(of: ConnectionInfo?.self) { group in + for config in sortedConfigs { + group.addTask { + await self.testConnection(configuration: config) + } + } + + for await connectionInfo in group { + guard let connectionInfo else { continue } + connectedCount += 1 + + if connectionInfo.configuration.priority == 0 { + bestConnection = connectionInfo + group.cancelAll() + break + } + + if bestConnection == nil || connectionInfo.configuration.priority < bestConnection!.configuration.priority { + bestConnection = connectionInfo + } + } + } + + let newStatus: NetworkStatus = switch connectedCount { + case 0: .notConnected + case 1: .someConnected + default: .allConnected + } + + await updateUI(status: newStatus, connection: bestConnection) + + if let best = bestConnection { + KingfisherManager.shared.defaultOptions = [ + .requestModifier(OpenHABAccessTokenAdapter(connectionConfiguration: best.configuration)) + ] + } else { + await startRetryTask() + } + } + + private func updateUI(status: NetworkStatus, connection: ConnectionInfo?) async { + let viewModel = viewModel + await MainActor.run { + viewModel?.updateStatus(status, connection: connection) + } + } + + private func startRetryTask() async { + retryTask?.cancel() + + let backoffMultiplier = await failureTracker.maxFailureCount() + let safeBackoff = min(backoffMultiplier, 10) + let delay: UInt64 = min(30 * (1 << safeBackoff), 300) + + retryTask = Task.detached { + self.logger.info("Retrying in \(delay) seconds") + try? await Task.sleep(nanoseconds: delay * 1_000_000_000) + if !Task.isCancelled { + await self.attemptConnection() + } + } + } + + private func handleNetworkChange(isConnected: Bool) async { + if isConnected { + await attemptConnection() + } else { + await updateUI(status: .notConnected, connection: nil) + await startRetryTask() + } + } + + private func testConnection(configuration: ConnectionConfiguration) async -> ConnectionInfo? { + guard await failureTracker.shouldAttempt(configuration) else { + logger.info("Skipping \(configuration.url) due to failures") + return nil + } + + do { + logger.info("Testing connection for \(configuration.url)") + let service = try await connectionPool.getOrCreateService(for: configuration) + let version = try await service.getRootVersion() + let info = ConnectionInfo(configuration: configuration, version: version) + + await failureTracker.reset(configuration) + return info + } catch { + await failureTracker.recordFailure(configuration) + logger.info("Connection failed: \(configuration.url): \(error.localizedDescription)") + return nil + } + } + + public func resetFailures() { + Task { await failureTracker.resetAll() } + } + + public func send(to item: String, command: String) async throws { + guard let connection = await viewModel?.activeConnection else { return } + let service = try await connectionPool.getOrCreateService(for: connection.configuration) + try await service.sendItemCommand(itemname: item, command: command) + } + + public func updateState(for item: String, state: String) async throws { + guard let connection = await viewModel?.activeConnection else { return } + let service = try await connectionPool.getOrCreateService(for: connection.configuration) + try await service.updateItemState(itemname: item, with: state) + } +} + public final class NetworkTracker: ObservableObject { public static let shared = NetworkTracker() diff --git a/OpenHABCore/Sources/OpenHABCore/Util/ServerCertificateManager.swift b/OpenHABCore/Sources/OpenHABCore/Util/ServerCertificateManager.swift index f46b6d15d..bfea462ec 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/ServerCertificateManager.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/ServerCertificateManager.swift @@ -12,7 +12,8 @@ import Foundation import os.log -public protocol ServerCertificateManagerDelegate: AnyObject { +@MainActor +public protocol ServerCertificateManagerDelegate: AnyObject, Sendable { // delegate should ask user for a decision on what to do with invalid certificate func evaluateServerTrust(summary certificateSummary: String?, forDomain domain: String?) async -> ServerCertificateManager.EvaluateResult // certificate received from openHAB doesn't match our record, ask user for a decision @@ -183,7 +184,7 @@ public class ServerCertificateManager { // ServerTrustManager, ServerTrustEvalua // User decided to accept invalid certificate and remember decision // Add certificate to storage storeCertificateData(certificateData, forDomain: domain) - delegate.acceptedServerCertificatesChanged() + await delegate.acceptedServerCertificatesChanged() logger.info("User chose to trust cert for \(domain) permanently") return @unknown default: diff --git a/OpenHABCore/Tests/OpenHABCoreTests/MockURLProtocol.swift b/OpenHABCore/Tests/OpenHABCoreTests/MockURLProtocol.swift index 08b602bc1..6400eb484 100644 --- a/OpenHABCore/Tests/OpenHABCoreTests/MockURLProtocol.swift +++ b/OpenHABCore/Tests/OpenHABCoreTests/MockURLProtocol.swift @@ -11,13 +11,22 @@ import Foundation +@preconcurrency final class MockURLProtocol: URLProtocol { enum ResponseType { case error(Error) case success(HTTPURLResponse) } - static var responseType: ResponseType! + actor MockURLProtocolState { + var responseType: MockURLProtocol.ResponseType? + + func setResponseType(_ type: MockURLProtocol.ResponseType) { + responseType = type + } + } + + private static let state = MockURLProtocolState() private(set) var activeTask: URLSessionTask? @@ -56,16 +65,19 @@ extension MockURLProtocol: URLSessionDataDelegate { } func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { - switch MockURLProtocol.responseType { - case let .error(error)?: - client?.urlProtocol(self, didFailWithError: error) - case let .success(response)?: - client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) - default: - break + Task { + let responseType = await MockURLProtocol.state.responseType + switch responseType { + case let .error(error)?: + client?.urlProtocol(self, didFailWithError: error) + case let .success(response)?: + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + default: + break + } + + client?.urlProtocolDidFinishLoading(self) } - - client?.urlProtocolDidFinishLoading(self) } } @@ -75,10 +87,16 @@ extension MockURLProtocol { } static func responseWithFailure() { - MockURLProtocol.responseType = MockURLProtocol.ResponseType.error(MockError.none) + Task { + await state.setResponseType(.error(MockError.none)) + } } static func responseWithStatusCode(code: Int) { - MockURLProtocol.responseType = MockURLProtocol.ResponseType.success(HTTPURLResponse(url: URL(string: "http://192.168.2.15")!, statusCode: code, httpVersion: nil, headerFields: nil)!) + let url = URL(string: "http://192.168.2.15")! + let response = HTTPURLResponse(url: url, statusCode: code, httpVersion: nil, headerFields: nil)! + Task { + await state.setResponseType(.success(response)) + } } } diff --git a/OpenHABCore/Tests/OpenHABCoreTests/ServerCertificateManagerTests.swift b/OpenHABCore/Tests/OpenHABCoreTests/ServerCertificateManagerTests.swift index 5efb34b63..c5d0a89aa 100644 --- a/OpenHABCore/Tests/OpenHABCoreTests/ServerCertificateManagerTests.swift +++ b/OpenHABCore/Tests/OpenHABCoreTests/ServerCertificateManagerTests.swift @@ -92,6 +92,7 @@ final class ServerCertificateManagerTests: XCTestCase { } } + @MainActor func testTriggersMismatchDelegateWhenCertsDiffer() async throws { let trust = try dummyTrust() let domain = "test.openhab.org" @@ -103,6 +104,7 @@ final class ServerCertificateManagerTests: XCTestCase { XCTAssertEqual(delegate.lastCall, "evaluateCertificateMismatch") } + @MainActor func testTriggersServerTrustDelegateForNewCert() async throws { let trust = try dummyTrust() let domain = "unknown.openhab.org" @@ -113,6 +115,7 @@ final class ServerCertificateManagerTests: XCTestCase { XCTAssertEqual(delegate.lastCall, "evaluateServerTrust") } + @MainActor func testThrowsWhenUserDeniesTrust() async throws { let trust = try dummyTrust() let domain = "deny.openhab.org" @@ -127,6 +130,7 @@ final class ServerCertificateManagerTests: XCTestCase { } } + @MainActor func testStoresCertWhenUserAcceptsAlways() async throws { let trust = try dummyTrust() let domain = "persist.openhab.org" diff --git a/OpenHABCore/Tests/OpenHABCoreTests/UserDefaultsTests.swift b/OpenHABCore/Tests/OpenHABCoreTests/UserDefaultsTests.swift index ecee1706b..ec858a12c 100644 --- a/OpenHABCore/Tests/OpenHABCoreTests/UserDefaultsTests.swift +++ b/OpenHABCore/Tests/OpenHABCoreTests/UserDefaultsTests.swift @@ -15,36 +15,30 @@ import XCTest @MainActor final class UserDefaultsTests: XCTestCase { - let data = UserDefaults(suiteName: "group.org.openhab.app")! - - override func setUpWithError() throws { - super.setUp() + // Testing the consistency between Preferences and UserDefaults + func testConsistency() { // Set Preferences from MainActor - let expectation = expectation(description: "MainActor setup") - - Task { @MainActor in - // Reset UserDefaults - let defaultsName = try XCTUnwrap(Bundle.main.bundleIdentifier) - data.removePersistentDomain(forName: defaultsName) - - Preferences.username = "testuser" - Preferences.localUrl = "http://local.test" - Preferences.remoteUrl = "http://remote.test" - Preferences.password = "secret" - Preferences.ignoreSSL = true - Preferences.demomode = true - Preferences.idleOff = false - Preferences.iconType = 2 - Preferences.defaultSitemap = "default" - Preferences.sitemapForWatch = "watchmap" - expectation.fulfill() + let defaultsName: String + // Reset UserDefaults + let data = UserDefaults(suiteName: "group.org.openhab.app")! + do { + defaultsName = try XCTUnwrap(Bundle.main.bundleIdentifier) + } catch { + fatalError() } + data.removePersistentDomain(forName: defaultsName) + + Preferences.username = "testuser" + Preferences.localUrl = "http://local.test" + Preferences.remoteUrl = "http://remote.test" + Preferences.password = "secret" + Preferences.ignoreSSL = true + Preferences.demomode = true + Preferences.idleOff = false + Preferences.iconType = 2 + Preferences.defaultSitemap = "default" + Preferences.sitemapForWatch = "watchmap" - wait(for: [expectation], timeout: 1.0) - } - - // Testing the consistency between Preferences and UserDefaults - func testConsistency() { XCTAssertEqual(Preferences.username, data.string(forKey: "username")) XCTAssertEqual(Preferences.localUrl, data.string(forKey: "localUrl")) XCTAssertEqual(Preferences.remoteUrl, data.string(forKey: "remoteUrl")) diff --git a/openHAB/AppDelegate.swift b/openHAB/AppDelegate.swift index ae82462de..5babc2abc 100644 --- a/openHAB/AppDelegate.swift +++ b/openHAB/AppDelegate.swift @@ -220,7 +220,6 @@ extension AppDelegate: UNUserNotificationCenterDelegate { } // this is called when clicking a notification while in the background - @MainActor func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async { var userInfo = response.notification.request.content.userInfo let actionIdentifier = response.actionIdentifier diff --git a/openHAB/ColorPickerViewController.swift b/openHAB/ColorPickerViewController.swift index cf9750220..697b0c96f 100644 --- a/openHAB/ColorPickerViewController.swift +++ b/openHAB/ColorPickerViewController.swift @@ -72,7 +72,7 @@ class ColorPickerViewController: DefaultColorPickerViewController { } } -extension ColorPickerViewController: ColorPickerDelegate { +extension ColorPickerViewController: @preconcurrency ColorPickerDelegate { func colorPicker(_ colorPicker: ColorPickerController, selectedColor: UIColor, usingControl: any ColorControl) { if let throttler { throttler.throttle { DispatchQueue.main.async { self.sendColorUpdate(color: selectedColor) } } diff --git a/openHAB/OpenHABRootViewController.swift b/openHAB/OpenHABRootViewController.swift index 6fdee2fa8..22dfa21b9 100644 --- a/openHAB/OpenHABRootViewController.swift +++ b/openHAB/OpenHABRootViewController.swift @@ -117,18 +117,29 @@ class OpenHABRootViewController: UIViewController { ) .eraseToAnyPublisher() -// let misc = Publishers.CombineLatest3( -// Preferences.$demomode, -// ) -// .eraseToAnyPublisher() - // Register for certificate trust notifications NotificationCenter.default.addObserver( forName: .evaluateServerTrust, object: nil, queue: nil ) { [weak self] notification in - self?.handleCertificateTrust(notification, message: NSLocalizedString("ssl_certificate_invalid", comment: "")) + + guard + let summary = notification.userInfo?["summary"] as? String, + let domain = notification.userInfo?["domain"] as? String, + let client = notification.object as? HTTPClient + else { + return + } + + Task { @MainActor in + self?.handleCertificateTrust( + summary: summary, + domain: domain, + client: client, + messageTemplateKey: "ssl_certificate_invalid" + ) + } } NotificationCenter.default.addObserver( @@ -136,7 +147,23 @@ class OpenHABRootViewController: UIViewController { object: nil, queue: nil ) { [weak self] notification in - self?.handleCertificateTrust(notification, message: NSLocalizedString("ssl_certificate_no_match", comment: "")) + + guard + let summary = notification.userInfo?["summary"] as? String, + let domain = notification.userInfo?["domain"] as? String, + let client = notification.object as? HTTPClient + else { + return + } + + Task { @MainActor in + self?.handleCertificateTrust( + summary: summary, + domain: domain, + client: client, + messageTemplateKey: "ssl_certificate_no_match" + ) + } } NotificationCenter.default.addObserver( @@ -144,8 +171,10 @@ class OpenHABRootViewController: UIViewController { object: nil, queue: nil ) { _ in - WatchMessageService.singleton.syncPreferencesToWatch() - NetworkTracker.shared.restartTracking() + Task { @MainActor in + WatchMessageService.singleton.syncPreferencesToWatch() + NetworkTracker.shared.restartTracking() + } } Publishers.CombineLatest(serverInfo, Preferences.$demomode) @@ -542,6 +571,7 @@ class OpenHABRootViewController: UIViewController { } } + @MainActor @objc func handleCertificateTrust(_ notification: Notification, message: String) { guard let summary = notification.userInfo?["summary"] as? String, let domain = notification.userInfo?["domain"] as? String, @@ -571,6 +601,33 @@ class OpenHABRootViewController: UIViewController { self.present(alert, animated: true) } } + + @MainActor + @objc + func handleCertificateTrust(summary: String, domain: String, client: HTTPClient, messageTemplateKey: String) { + let title = NSLocalizedString("ssl_certificate_warning", comment: "") + let message = String(format: NSLocalizedString(messageTemplateKey, comment: ""), summary, domain) + + let alert = UIAlertController( + title: title, + message: message, + preferredStyle: .alert + ) + + alert.addAction(UIAlertAction(title: "Always", style: .default) { _ in + client.delegate.completeEvaluation(.permitAlways) + }) + + alert.addAction(UIAlertAction(title: "Once", style: .default) { _ in + client.delegate.completeEvaluation(.permitOnce) + }) + + alert.addAction(UIAlertAction(title: "Deny", style: .cancel) { _ in + client.delegate.completeEvaluation(.deny) + }) + + present(alert, animated: true) + } } // MARK: - UISideMenuNavigationControllerDelegate diff --git a/openHAB/OpenHABSitemapViewController.swift b/openHAB/OpenHABSitemapViewController.swift index c41a47a3e..c112b13c0 100644 --- a/openHAB/OpenHABSitemapViewController.swift +++ b/openHAB/OpenHABSitemapViewController.swift @@ -640,7 +640,7 @@ extension OpenHABSitemapViewController: UISearchBarDelegate { // MARK: - ColorPickerCellDelegate -extension OpenHABSitemapViewController: ColorPickerCellDelegate { +extension OpenHABSitemapViewController: @preconcurrency ColorPickerCellDelegate { func didPressColorButton(_ cell: ColorPickerCell?) { let colorPickerViewController = storyboard?.instantiateViewController(withIdentifier: "ColorPickerViewController") as? ColorPickerViewController if let cell { diff --git a/openHAB/OpenHABWebViewController.swift b/openHAB/OpenHABWebViewController.swift index 8c5115acd..55d69606f 100644 --- a/openHAB/OpenHABWebViewController.swift +++ b/openHAB/OpenHABWebViewController.swift @@ -269,6 +269,7 @@ class OpenHABWebViewController: OpenHABViewController { } extension OpenHABWebViewController: WKScriptMessageHandler { + @MainActor func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { os_log("WKScriptMessage %{PUBLIC}@", log: OSLog.remoteAccess, type: .info, message.name) if let callbackName = message.body as? String { @@ -288,11 +289,14 @@ extension OpenHABWebViewController: WKScriptMessageHandler { acceptsCommands = true executeQueuedCommands() case "sseConnected-false": - os_log("WKScriptMessage sseConnected is false", log: OSLog.remoteAccess, type: .info) + logger.info("WKScriptMessage sseConnected is false") sseTimer?.invalidate() - sseTimer = Timer.scheduledTimer(withTimeInterval: 10.0, repeats: false) { _ in - self.showPopupMessage(seconds: 20, title: NSLocalizedString("connecting", comment: ""), message: "", theme: .info) - self.acceptsCommands = false + sseTimer = Timer.scheduledTimer(withTimeInterval: 10.0, repeats: false) { [weak self] _ in + guard let self else { return } + Task { @MainActor in + self.showPopupMessage(seconds: 20, title: NSLocalizedString("connecting", comment: ""), message: "", theme: .info) + self.acceptsCommands = false + } } default: break } diff --git a/openHAB/VideoUITableViewCell.swift b/openHAB/VideoUITableViewCell.swift index 9d673b604..6810d29d5 100644 --- a/openHAB/VideoUITableViewCell.swift +++ b/openHAB/VideoUITableViewCell.swift @@ -126,20 +126,25 @@ class VideoUITableViewCell: GenericUITableViewCell, NoIconDisplayableCell { bringSubviewToFront(playerView) let playerItem = AVPlayerItem(asset: AVAsset(url: url)) playerObserver = playerItem.observe(\.status, options: [.new, .old]) { [weak self] playerItem, _ in + guard let self else { return } + switch playerItem.status { case .failed: os_log("Failed to load video with URL: %{PUBLIC}@", log: .urlComposition, type: .debug, url.absoluteString) - self?.url = nil + Task { @MainActor in + self.url = nil + } case .readyToPlay: os_log("Loaded video with URL: %{PUBLIC}@", log: .urlComposition, type: .debug, url.absoluteString) default: return } - - self?.activityIndicator.isHidden = true - if playerItem.status == .readyToPlay, playerItem.presentationSize != .zero { - let aspectRatio = playerItem.presentationSize.width / playerItem.presentationSize.height - self?.updateAspectRatio(forView: self?.playerView, aspectRatio: aspectRatio) - self?.didLoad?() + Task { @MainActor in + self.activityIndicator.isHidden = true + if playerItem.status == .readyToPlay, playerItem.presentationSize != .zero { + let aspectRatio = playerItem.presentationSize.width / playerItem.presentationSize.height + self.updateAspectRatio(forView: self.playerView, aspectRatio: aspectRatio) + self.didLoad?() + } } } playerView?.playerLayer.player = AVPlayer(playerItem: playerItem) diff --git a/openHAB/WatchMessageService.swift b/openHAB/WatchMessageService.swift index fbf299e6b..b4eb6dc03 100644 --- a/openHAB/WatchMessageService.swift +++ b/openHAB/WatchMessageService.swift @@ -17,24 +17,24 @@ import WatchConnectivity // This class receives Watch Request for the configuration data like localUrl. // The functionality is activated in the AppDelegate. class WatchMessageService: NSObject, WCSessionDelegate { + @MainActor static let singleton = WatchMessageService() private lazy var logger = Logger(subsystem: "org.openhab.app", category: "WatchMessageService") + private var cachedWatchPreferences: [String: Any] = [:] + private let lock = NSLock() + // This method gets called when the watch requests the data + // ⚠️ This is called off the main thread. Do NOT touch @MainActor stuff. func session(_ session: WCSession, didReceiveMessage message: [String: Any], replyHandler: @escaping ([String: Any]) -> Void) { - logger.info("Received message with reply handler: \(message, privacy: .public)") + guard message["request"] != nil else { return } - guard message["request"] != nil else { - logger.warning("Invalid message: no 'request' key.") - return - } + lock.lock() + let reply = cachedWatchPreferences + lock.unlock() - Task { @MainActor in - let prefs = WatchPreferences(fromPreferences: Preferences.self) - replyHandler(prefs.encodedWatchPreferences()) - logger.debug("Sent WatchPreferences in replyHandler.") - } + replyHandler(reply) // ✅ Used synchronously — no concurrency violation } func session(_ session: WCSession, didReceiveMessage message: [String: Any]) { @@ -65,6 +65,10 @@ class WatchMessageService: NSObject, WCSessionDelegate { let prefs = WatchPreferences(fromPreferences: Preferences.self) let context = prefs.encodedWatchPreferences() + lock.lock() + cachedWatchPreferences = context + lock.unlock() + do { try WCSession.default.updateApplicationContext(context) logger.debug("Successfully updated application context with WatchPreferences.") From 9561d912807a4c218fa24a5772e249868949ca3d Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Wed, 14 May 2025 16:15:26 +0200 Subject: [PATCH 187/476] Preparing for Swift 6 Extending functionality of NetworkTrackerViewModel and NetworkObserver Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- NotificationService/NotificationService.swift | 43 ++++++++++++++--- .../OpenHABCore/Util/NetworkTracker.swift | 48 +++++++++++++++++++ openHAB/AppDelegate.swift | 2 +- openHAB/OpenHABRootViewController.swift | 3 ++ .../Model/OpenHABWidgetExtension.swift | 2 +- 5 files changed, 89 insertions(+), 9 deletions(-) diff --git a/NotificationService/NotificationService.swift b/NotificationService/NotificationService.swift index 8f0cef110..5975b9e09 100644 --- a/NotificationService/NotificationService.swift +++ b/NotificationService/NotificationService.swift @@ -14,7 +14,7 @@ import Foundation import OpenHABCore import os.log import UniformTypeIdentifiers -import UserNotifications +@preconcurrency import UserNotifications enum NotificationServiceError: Error { case unknown @@ -57,7 +57,7 @@ class NotificationService: UNNotificationServiceExtension { var notificationActions: [UNNotificationAction] = [] let userInfo = bestAttemptContent.userInfo - os_log("didReceive userInfo %{PUBLIC}@", log: .default, type: .info, userInfo) + logger.info("didReceive userInfo \(userInfo)") if let title = userInfo["title"] as? String { bestAttemptContent.title = title @@ -162,17 +162,40 @@ class NotificationService: UNNotificationServiceExtension { return nil } + private func downloadForAttachment(attachmentURLString: String) -> (URL?, String?) { + var returnValues: (URL?, String?) + Task { + do { + returnValues = if attachmentURLString.starts(with: "item:") { + try await downloadItemImage(itemURI: attachmentURLString) + } else { + try await downloadMedia(url: attachmentURLString) + } + + } catch { + os_log("Error fetching data: %{PUBLIC}@", log: .default, type: .error, error.localizedDescription) + } + } + return returnValues + } + private func downloadAndAttachMedia(url: String) async throws -> UNNotificationAttachment? { + let (localURL, mimeType) = try await downloadMedia(url: url) + guard let localURL else { return nil } + return await attachFile(localURL: localURL, mimeType: mimeType) + } + + private func downloadMedia(url: String) async throws -> (URL?, String?) { await NetworkTracker.shared.startTracking(connectionConfigurations: [Preferences.localConnectionConfig, Preferences.remoteConnectionConfig]) - guard let fullURL = await resolveFullURL(from: url) else { return nil } + guard let fullURL = await resolveFullURL(from: url) else { return (nil, nil) } - guard let activeConfig = await NetworkTracker.shared.waitForActiveConnection()?.configuration else { return nil } + guard let activeConfig = await NetworkTracker.shared.waitForActiveConnection()?.configuration else { return (nil, nil) } let client = HTTPClient(configuration: activeConfig) let (localURL, urlResponse) = try await client.downloadFile(url: fullURL) - return await attachFile(localURL: localURL, mimeType: urlResponse.mimeType) + return (localURL, urlResponse.mimeType) } // 🔹 Extracted helper function to determine full URL @@ -186,6 +209,12 @@ class NotificationService: UNNotificationServiceExtension { } func downloadAndAttachItemImage(itemURI: String) async throws -> UNNotificationAttachment? { + let (tempFileURL, mimeType) = try await downloadItemImage(itemURI: itemURI) + guard let tempFileURL else { return nil } + return await attachFile(localURL: tempFileURL, mimeType: mimeType) + } + + func downloadItemImage(itemURI: String) async throws -> (URL?, String?) { guard let itemURL = URL(string: itemURI), let scheme = itemURL.scheme else { throw NotificationServiceError.noScheme(itemURI) } @@ -193,7 +222,7 @@ class NotificationService: UNNotificationServiceExtension { let itemName = String(itemURL.absoluteString.dropFirst(scheme.count + 1)) let item = try await NetworkTracker.shared.getItemByName(id: itemName) - guard let state = item?.state else { return nil } + guard let state = item?.state else { return (nil, nil) } // Extract MIME type and base64 string let pattern = /^data:(.*?);base64,(.*)$/ @@ -213,7 +242,7 @@ class NotificationService: UNNotificationServiceExtension { try imageData.write(to: tempFileURL) os_log("Image saved to temporary file: %{PUBLIC}@", log: .default, type: .info, tempFileURL.absoluteString) - return await attachFile(localURL: tempFileURL, mimeType: mimeType) + return (tempFileURL, mimeType) } func attachFile(localURL: URL, mimeType: String?) async -> UNNotificationAttachment? { diff --git a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift index 86bc56957..16a004ee2 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift @@ -129,6 +129,22 @@ public final class NetworkTrackerViewModel: ObservableObject { try await observer.updateState(for: item.name, state: state) } + public func getItems() async throws -> [OpenHABItem] { + try await observer.getItems() + } + + public func getItemByName(id: String) async throws -> OpenHABItem? { + try await observer.getItemByName(id: id) + } + + public func pollDataForPage(sitemapname: String, pageId: String = "", longPolling: Bool = false) async throws -> OpenHABPage? { + try await observer.pollDataForPage(sitemapname: sitemapname, pageId: pageId, longPolling: longPolling) + } + + public func runNow(ruleUID: String, payload: [String: String]) async throws { + try await observer.runNow(ruleUID: ruleUID, payload: payload) + } + public func resetFailures() async { await observer.resetFailures() } @@ -303,8 +319,40 @@ public actor NetworkObserver { let service = try await connectionPool.getOrCreateService(for: connection.configuration) try await service.updateItemState(itemname: item, with: state) } + + public func updateState(for item: OpenHABItem, state: String) async throws { + guard let activeConnection = await NetworkTracker.shared.waitForActiveConnection() else { return } + let configuration = activeConnection.configuration + let service = try await connectionPool.getOrCreateService(for: configuration) + try await service.updateItemState(itemname: item.name, with: state) + } + + public func getItems() async throws -> [OpenHABItem] { + guard let connection = await viewModel?.activeConnection else { return [] } + let service = try await connectionPool.getOrCreateService(for: connection.configuration) + return try await service.getItems() + } + + public func getItemByName(id: String) async throws -> OpenHABItem? { + guard let connection = await viewModel?.activeConnection else { return nil } + let service = try await connectionPool.getOrCreateService(for: connection.configuration) + return try await service.getItemByName(id: id) + } + + public func pollDataForPage(sitemapname: String, pageId: String = "", longPolling: Bool = false) async throws -> OpenHABPage? { + guard let connection = await viewModel?.activeConnection else { return nil } + let service = try await connectionPool.getOrCreateService(for: connection.configuration) + return try await service.pollDataForPage(sitemapname: sitemapname, pageId: pageId, longPolling: longPolling) + } + + public func runNow(ruleUID: String, payload: [String: String]) async throws { + guard let connection = await viewModel?.activeConnection else { throw NetworkTrackerError.noActiveConnection } + let service = try await connectionPool.getOrCreateService(for: connection.configuration) + try await service.runNow(ruleUID: ruleUID, payload: payload) + } } +// @available(*, deprecated) public final class NetworkTracker: ObservableObject { public static let shared = NetworkTracker() diff --git a/openHAB/AppDelegate.swift b/openHAB/AppDelegate.swift index 5babc2abc..3f1aec2e6 100644 --- a/openHAB/AppDelegate.swift +++ b/openHAB/AppDelegate.swift @@ -18,7 +18,7 @@ import os.log import SDWebImageSVGCoder import SwiftMessages import UIKit -import UserNotifications +@preconcurrency import UserNotifications import WatchConnectivity actor AudioPlayerActor { diff --git a/openHAB/OpenHABRootViewController.swift b/openHAB/OpenHABRootViewController.swift index 22dfa21b9..9e419eb97 100644 --- a/openHAB/OpenHABRootViewController.swift +++ b/openHAB/OpenHABRootViewController.swift @@ -35,6 +35,7 @@ protocol ModalHandler: AnyObject { private let logger = Logger(subsystem: "org.openhab.UI", category: "OpenHABRootViewController") +// swiftlint:disable type_body_length class OpenHABRootViewController: UIViewController { var currentView: OpenHABViewController! var isDemoMode = false @@ -630,6 +631,8 @@ class OpenHABRootViewController: UIViewController { } } +// swiftlint:enable type_body_length + // MARK: - UISideMenuNavigationControllerDelegate extension OpenHABRootViewController: SideMenuNavigationControllerDelegate { diff --git a/openHABWatch/Model/OpenHABWidgetExtension.swift b/openHABWatch/Model/OpenHABWidgetExtension.swift index 42c83cebe..92cf7feaa 100644 --- a/openHABWatch/Model/OpenHABWidgetExtension.swift +++ b/openHABWatch/Model/OpenHABWidgetExtension.swift @@ -16,7 +16,7 @@ import SwiftUI extension OpenHABWidget { @ViewBuilder func makeView(settings: AppSettings) -> some View { - if let linkedPage { + if linkedPage != nil { // os_log("Selected %{PUBLIC}@", log: .viewCycle, type: .info, pageUrl) NavigationLink(destination: LazyView( From 09ec8fc95e2d40693ff5074039e96c9b15f0ccc2 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Thu, 15 May 2025 14:58:32 +0200 Subject: [PATCH 188/476] Passing the sendable HTTPClientDelegate to address an swift 6 warning Making NWPathMonitoring Sendable Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../OpenHABCore/Util/NWPathMonitoring.swift | 4 +-- .../OpenHABCore/Util/NetworkTracker.swift | 30 +++++++++++-------- openHAB/OpenHABRootViewController.swift | 17 +++++++---- 3 files changed, 30 insertions(+), 21 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/NWPathMonitoring.swift b/OpenHABCore/Sources/OpenHABCore/Util/NWPathMonitoring.swift index 255d48255..c609d048d 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/NWPathMonitoring.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/NWPathMonitoring.swift @@ -13,7 +13,7 @@ import Foundation import Network // Wrap real NWPathMonitor -final class RealPathMonitor: NWPathMonitoring { +final class RealPathMonitor: NWPathMonitoring, Sendable { private let monitor: NWPathMonitor init() { @@ -39,7 +39,7 @@ final class RealPathMonitor: NWPathMonitoring { // MARK: - Protocol -public protocol NWPathMonitoring: AnyObject { +public protocol NWPathMonitoring: AnyObject, Sendable { /// Continuously monitors network connectivity status. /// Calls the handler with `true` when connected, `false` otherwise. func startMonitoring(handler: @escaping (Bool) async -> Void) async diff --git a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift index 16a004ee2..079a90315 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift @@ -156,9 +156,18 @@ public final class NetworkTrackerViewModel: ObservableObject { self.activeConnection = connection } } + + func activeConnectionStream() -> AsyncStream { + AsyncStream { continuation in + let cancellable = self.$activeConnection + .sink { continuation.yield($0) } + + continuation.onTermination = { [cancellable] _ in cancellable.cancel() } + } + } } -public actor NetworkObserver { +public actor NetworkObserver: Sendable { public static let shared = NetworkObserver() private var viewModel: NetworkTrackerViewModel? @@ -173,8 +182,8 @@ public actor NetworkObserver { private static func makeNetworkHandler(for observer: NetworkObserver?) -> @Sendable (Bool) -> Void { { isConnected in - guard let observer else { return } - Task { + Task.detached(priority: .utility) { + guard let observer else { return } await observer.handleNetworkChange(isConnected: isConnected) } } @@ -187,10 +196,12 @@ public actor NetworkObserver { public func startTracking(connectionConfigurations: [ConnectionConfiguration]) { self.connectionConfigurations = connectionConfigurations - let pathMonitor = pathMonitor - let handler = Self.makeNetworkHandler(for: self) +// let pathMonitor = pathMonitor +// let handler = Self.makeNetworkHandler(for: self) - Task.detached(priority: .utility) { + Task { [weak self] in + guard let self else { return } + let handler = Self.makeNetworkHandler(for: self) await pathMonitor.startMonitoring(handler: handler) } @@ -320,13 +331,6 @@ public actor NetworkObserver { try await service.updateItemState(itemname: item, with: state) } - public func updateState(for item: OpenHABItem, state: String) async throws { - guard let activeConnection = await NetworkTracker.shared.waitForActiveConnection() else { return } - let configuration = activeConnection.configuration - let service = try await connectionPool.getOrCreateService(for: configuration) - try await service.updateItemState(itemname: item.name, with: state) - } - public func getItems() async throws -> [OpenHABItem] { guard let connection = await viewModel?.activeConnection else { return [] } let service = try await connectionPool.getOrCreateService(for: connection.configuration) diff --git a/openHAB/OpenHABRootViewController.swift b/openHAB/OpenHABRootViewController.swift index 9e419eb97..d94d89895 100644 --- a/openHAB/OpenHABRootViewController.swift +++ b/openHAB/OpenHABRootViewController.swift @@ -133,11 +133,13 @@ class OpenHABRootViewController: UIViewController { return } + let delegate = client.delegate + Task { @MainActor in self?.handleCertificateTrust( summary: summary, domain: domain, - client: client, + delegate: delegate, messageTemplateKey: "ssl_certificate_invalid" ) } @@ -153,15 +155,18 @@ class OpenHABRootViewController: UIViewController { let summary = notification.userInfo?["summary"] as? String, let domain = notification.userInfo?["domain"] as? String, let client = notification.object as? HTTPClient + else { return } + let delegate = client.delegate + Task { @MainActor in self?.handleCertificateTrust( summary: summary, domain: domain, - client: client, + delegate: delegate, messageTemplateKey: "ssl_certificate_no_match" ) } @@ -605,7 +610,7 @@ class OpenHABRootViewController: UIViewController { @MainActor @objc - func handleCertificateTrust(summary: String, domain: String, client: HTTPClient, messageTemplateKey: String) { + func handleCertificateTrust(summary: String, domain: String, delegate: HTTPClientDelegate, messageTemplateKey: String) { let title = NSLocalizedString("ssl_certificate_warning", comment: "") let message = String(format: NSLocalizedString(messageTemplateKey, comment: ""), summary, domain) @@ -616,15 +621,15 @@ class OpenHABRootViewController: UIViewController { ) alert.addAction(UIAlertAction(title: "Always", style: .default) { _ in - client.delegate.completeEvaluation(.permitAlways) + delegate.completeEvaluation(.permitAlways) }) alert.addAction(UIAlertAction(title: "Once", style: .default) { _ in - client.delegate.completeEvaluation(.permitOnce) + delegate.completeEvaluation(.permitOnce) }) alert.addAction(UIAlertAction(title: "Deny", style: .cancel) { _ in - client.delegate.completeEvaluation(.deny) + delegate.completeEvaluation(.deny) }) present(alert, animated: true) From c9965a1f89b5f6b2795f61728033f71fcd8c96ea Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Fri, 16 May 2025 20:34:05 +0200 Subject: [PATCH 189/476] Pushing on OpenHABItemCache and NetworkTracker Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../Sources/OpenHABCore/Util/NetworkTracker.swift | 11 +++++++---- .../Sources/OpenHABCore/Util/OpenHABItemCache.swift | 11 +++++------ 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift index 079a90315..c6e8bafaf 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift @@ -180,6 +180,12 @@ public actor NetworkObserver: Sendable { private let logger = Logger(subsystem: "org.openhab.core", category: "NetworkObserver") + private let allowUIEffects: Bool + + init(allowUIEffects: Bool = true) { + self.allowUIEffects = allowUIEffects + } + private static func makeNetworkHandler(for observer: NetworkObserver?) -> @Sendable (Bool) -> Void { { isConnected in Task.detached(priority: .utility) { @@ -196,9 +202,6 @@ public actor NetworkObserver: Sendable { public func startTracking(connectionConfigurations: [ConnectionConfiguration]) { self.connectionConfigurations = connectionConfigurations -// let pathMonitor = pathMonitor -// let handler = Self.makeNetworkHandler(for: self) - Task { [weak self] in guard let self else { return } let handler = Self.makeNetworkHandler(for: self) @@ -253,7 +256,7 @@ public actor NetworkObserver: Sendable { await updateUI(status: newStatus, connection: bestConnection) - if let best = bestConnection { + if allowUIEffects, let best = bestConnection { KingfisherManager.shared.defaultOptions = [ .requestModifier(OpenHABAccessTokenAdapter(connectionConfiguration: best.configuration)) ] diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift index 795d678f0..16f3460d1 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift @@ -28,9 +28,8 @@ public actor OpenHABItemCache { } public var items: [OpenHABItem]? - var cancellables = Set() - var timeout: Double = 20 - var lastLoad = Date().timeIntervalSince1970 + private let ttl: TimeInterval = 20 + var lastLoad = Date() private let logger = Logger(subsystem: "org.openhab.app.watchkitapp", category: "OpenHABItemCache") @@ -60,9 +59,9 @@ public actor OpenHABItemCache { public func getItem(name: String) async -> OpenHABItem? { logger.info("getItem") - let now = Date().timeIntervalSince1970 + let now = Date() - if items == nil || (now - lastLoad) > 10 { + if items == nil || now.timeIntervalSince(lastLoad) > ttl { return await reload(name: name) } return getItem(name) @@ -90,11 +89,11 @@ public actor OpenHABItemCache { public func reload(searchTerm: String?, types: [OpenHABItem.ItemType]?) async -> [String] { logger.info("OpenHABItemCache Loading items ") - lastLoad = Date().timeIntervalSince1970 do { items = try await NetworkTracker.shared.getItems().filter { $0.type != .group } // swiftformat:disable next redundantSelf + lastLoad = Date() logger.info("Loaded \(self.items?.count ?? 0) items to cache") return items?.filtered(by: searchTerm, for: types).sorted(by: \.name).map(\.name) ?? [] } catch { From cadee9ef08ca15a509e391d7b2a0c67470c93f24 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Tue, 20 May 2025 17:56:01 +0200 Subject: [PATCH 190/476] enableUpcomingFeatures for OpenHABCore to prepare for Swift 6 Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- OpenHABCore/Package.swift | 15 ++++++++++++++- .../Sources/OpenHABCore/Model/DataExtension.swift | 2 +- .../Sources/OpenHABCore/Model/NumberState.swift | 2 +- .../OpenHABCore/Model/OpenHABSitemap.swift | 2 +- .../Sources/OpenHABCore/Model/OpenHABWidget.swift | 1 + .../Util/ClientCertificateManager.swift | 2 +- .../Util/ConnectionConfiguration.swift | 6 ++++-- .../Sources/OpenHABCore/Util/NetworkTracker.swift | 14 +++++++------- .../Util/ServerCertificateManager.swift | 2 +- 9 files changed, 31 insertions(+), 15 deletions(-) diff --git a/OpenHABCore/Package.swift b/OpenHABCore/Package.swift index 4b7b029f8..72d2e832a 100644 --- a/OpenHABCore/Package.swift +++ b/OpenHABCore/Package.swift @@ -37,7 +37,20 @@ let package = Package( ], swiftSettings: [ .enableUpcomingFeature("BareSlashRegexLiterals"), - .enableExperimentalFeature("StrictConcurrency"), + .enableUpcomingFeature("StrictConcurrency"), + .enableUpcomingFeature("ConciseMagicFile"), + .enableUpcomingFeature("DisableOutwardActorInference"), + .enableUpcomingFeature("DynamicActorIsolation"), + .enableUpcomingFeature("ExistentialAny"), + .enableUpcomingFeature("ForwardTrailingClosures"), + .enableUpcomingFeature("GlobalActorIsolatedTypesUsability"), + .enableUpcomingFeature("GlobalConcurrency"), + .enableUpcomingFeature("ImplicitOpenExistentials"), + .enableUpcomingFeature("InferSendableFromCaptures"), + .enableUpcomingFeature("IsolatedDefaultValues"), + .enableUpcomingFeature("MemberImportVisibility"), + .enableUpcomingFeature("RegionBasedIsolation"), +// .enableUpcomingFeature("InternalImportsByDefault"), .unsafeFlags([ "-Xfrontend", "-enable-actor-data-race-checks", "-Xfrontend", "-strict-concurrency=complete" diff --git a/OpenHABCore/Sources/OpenHABCore/Model/DataExtension.swift b/OpenHABCore/Sources/OpenHABCore/Model/DataExtension.swift index 482a2d401..5e9ddfa35 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/DataExtension.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/DataExtension.swift @@ -23,7 +23,7 @@ public extension Data { /// If no explicit encoder is passed, then the data is decoded as JSON. /// Inspired by https://www.swiftbysundell.com/posts/type-inference-powered-serialization-in-swift func decoded(as type: T.Type = T.self, - using decoder: AnyDecoder = JSONDecoder()) throws -> T { + using decoder: any AnyDecoder = JSONDecoder()) throws -> T { try decoder.decode(T.self, from: self) } } diff --git a/OpenHABCore/Sources/OpenHABCore/Model/NumberState.swift b/OpenHABCore/Sources/OpenHABCore/Model/NumberState.swift index d922471d9..03ff62bac 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/NumberState.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/NumberState.swift @@ -42,7 +42,7 @@ public struct NumberState: CustomStringConvertible, Equatable { // %s in Java is for Strings, but does not work in Swift, see // https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/Strings/Articles/formatSpecifiers.html) .replacingOccurrences(of: "%s", with: "%@") - let formatValue: CVarArg = if format.contains("%d") { + let formatValue: any CVarArg = if format.contains("%d") { intValue } else if format.contains("%s") { stringValue diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABSitemap.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABSitemap.swift index b614b6286..d038c4a56 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABSitemap.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABSitemap.swift @@ -16,7 +16,7 @@ import Foundation public struct ValueOrFalse: Decodable { let value: T? - public init(from decoder: Decoder) throws { + public init(from decoder: any Decoder) throws { let container = try decoder.singleValueContainer() let falseValue = try? container.decode(Bool.self) if falseValue == nil { diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift index e90becec9..eb7c9d641 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift @@ -9,6 +9,7 @@ // // SPDX-License-Identifier: EPL-2.0 +import Combine import Foundation import MapKit import os.log diff --git a/OpenHABCore/Sources/OpenHABCore/Util/ClientCertificateManager.swift b/OpenHABCore/Sources/OpenHABCore/Util/ClientCertificateManager.swift index a0dda804d..fb1f3b3a8 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/ClientCertificateManager.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/ClientCertificateManager.swift @@ -29,7 +29,7 @@ public class ClientCertificateManager { private var importingCertChain: [SecCertificate]? public var importingPassword: String? - weak var delegate: ClientCertificateManagerDelegate? + weak var delegate: (any ClientCertificateManagerDelegate)? public var clientIdentities: [SecIdentity] = [] diff --git a/OpenHABCore/Sources/OpenHABCore/Util/ConnectionConfiguration.swift b/OpenHABCore/Sources/OpenHABCore/Util/ConnectionConfiguration.swift index 3c904f3b8..15cf8c16b 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/ConnectionConfiguration.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/ConnectionConfiguration.swift @@ -9,6 +9,8 @@ // // SPDX-License-Identifier: EPL-2.0 +import Foundation + public struct ConnectionPayload: Codable { public var local: ConnectionConfiguration public var remote: ConnectionConfiguration @@ -37,7 +39,7 @@ public struct ConnectionConfiguration: Hashable, Sendable, Codable { } // 🔹 Ensure normalization on decoding - public init(from decoder: Decoder) throws { + public init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) let rawURL = try container.decode(String.self, forKey: .url) // Decode raw URL url = ConnectionConfiguration.normalizeURL(rawURL) // Normalize it @@ -66,7 +68,7 @@ public struct ConnectionConfiguration: Hashable, Sendable, Codable { } // 🔹 Ensure normalization on encoding (optional, since we store it normalized) - public func encode(to encoder: Encoder) throws { + public func encode(to encoder: any Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(url, forKey: .url) // Already normalized try container.encode(username, forKey: .username) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift index c6e8bafaf..23df5bc8d 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift @@ -54,18 +54,18 @@ public enum NetworkTrackerError: Error, CustomDebugStringConvertible, Sendable { // Ensure thread-safe dictionary access. // Avoid memory corruption errors like unrecognized selector. public actor ConnectionPool { - private var services: [ConnectionConfiguration: OpenAPIServiceProtocol] = [:] - private let serviceFactory: @Sendable (ConnectionConfiguration) throws -> OpenAPIServiceProtocol + private var services: [ConnectionConfiguration: any OpenAPIServiceProtocol] = [:] + private let serviceFactory: @Sendable (ConnectionConfiguration) throws -> any OpenAPIServiceProtocol // Initializer allowing the injection of mocked OpenAPIServiceProtocol - init(serviceFactory: @escaping @Sendable (ConnectionConfiguration) throws -> OpenAPIServiceProtocol = { + init(serviceFactory: @escaping @Sendable (ConnectionConfiguration) throws -> any OpenAPIServiceProtocol = { try OpenAPIService(connectionConfiguration: $0, serviceConfiguration: .shortTerm) }) { self.serviceFactory = serviceFactory } @discardableResult - func getOrCreateService(for configuration: ConnectionConfiguration) async throws -> OpenAPIServiceProtocol { + func getOrCreateService(for configuration: ConnectionConfiguration) async throws -> any OpenAPIServiceProtocol { if let existing = services[configuration] { return existing } @@ -172,7 +172,7 @@ public actor NetworkObserver: Sendable { private var viewModel: NetworkTrackerViewModel? - private var pathMonitor: NWPathMonitoring = RealPathMonitor() + private var pathMonitor: any NWPathMonitoring = RealPathMonitor() private var connectionPool = ConnectionPool() private var failureTracker = ConnectionFailureTracker() private var connectionConfigurations: [ConnectionConfiguration] = [] @@ -368,7 +368,7 @@ public final class NetworkTracker: ObservableObject { // @MainActor @Published public private(set) var status: NetworkStatus = .connecting - private var pathMonitor: NWPathMonitoring + private var pathMonitor: any NWPathMonitoring private var connectionPool: ConnectionPool private var connectionConfigurations: [ConnectionConfiguration] = [] private var retryTask: Task? @@ -385,7 +385,7 @@ public final class NetworkTracker: ObservableObject { // MARK: - Injectable initializer for testing - init(monitor: NWPathMonitoring = RealPathMonitor(), + init(monitor: any NWPathMonitoring = RealPathMonitor(), connectionPool: ConnectionPool = ConnectionPool(), failureTracker: ConnectionFailureTracker = ConnectionFailureTracker()) { pathMonitor = monitor diff --git a/OpenHABCore/Sources/OpenHABCore/Util/ServerCertificateManager.swift b/OpenHABCore/Sources/OpenHABCore/Util/ServerCertificateManager.swift index bfea462ec..5010851d3 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/ServerCertificateManager.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/ServerCertificateManager.swift @@ -35,7 +35,7 @@ public class ServerCertificateManager { // ServerTrustManager, ServerTrustEvalua case permitAlways } - weak var delegate: ServerCertificateManagerDelegate? + weak var delegate: (any ServerCertificateManagerDelegate)? // ignoreSSL is a synonym for allowInvalidCertificates, ignoreCertificates public var ignoreSSL = false public var trustedCertificates: [String: Data] = [:] From b3e458775033d0cbe18a4307cd04ae11a177fd15 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Wed, 21 May 2025 17:24:23 +0200 Subject: [PATCH 191/476] enableUpcomingFeatures for OpenHABCore to prepare for Swift 6 Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- OpenHABCore/Package.swift | 6 ++++-- .../NetworkTrackerTests.swift | 19 +++++++++++++------ .../ServerCertificateManagerTests.swift | 1 + fastlane/SnapshotHelper.swift | 4 ++-- openHAB.xcodeproj/project.pbxproj | 14 ++++++++++++-- openHAB/OpenHABRootViewController.swift | 5 +---- .../GetItemStateIntentHandler.swift | 4 ++-- .../SetColorValueIntentHandler.swift | 4 ++-- .../SetContactStateValueIntentHandler.swift | 4 ++-- .../SetDimmerRollerValueIntentHandler.swift | 4 ++-- .../SetNumberValueIntentHandler.swift | 4 ++-- .../SetStringValueIntentHandler.swift | 4 ++-- .../SetSwitchStateIntentHandler.swift | 4 ++-- openHABTestsSwift/LocalizationTests.swift | 8 ++++---- .../Extension/OpenHABWatchAppDelegate.swift | 2 +- openHABWatch/External/AppMessageService.swift | 2 +- 16 files changed, 53 insertions(+), 36 deletions(-) diff --git a/OpenHABCore/Package.swift b/OpenHABCore/Package.swift index 72d2e832a..3201c0889 100644 --- a/OpenHABCore/Package.swift +++ b/OpenHABCore/Package.swift @@ -37,7 +37,6 @@ let package = Package( ], swiftSettings: [ .enableUpcomingFeature("BareSlashRegexLiterals"), - .enableUpcomingFeature("StrictConcurrency"), .enableUpcomingFeature("ConciseMagicFile"), .enableUpcomingFeature("DisableOutwardActorInference"), .enableUpcomingFeature("DynamicActorIsolation"), @@ -46,11 +45,14 @@ let package = Package( .enableUpcomingFeature("GlobalActorIsolatedTypesUsability"), .enableUpcomingFeature("GlobalConcurrency"), .enableUpcomingFeature("ImplicitOpenExistentials"), + .enableUpcomingFeature("ImportObjcForwardDeclarations"), .enableUpcomingFeature("InferSendableFromCaptures"), + .enableUpcomingFeature("InternalImportsByDefault"), .enableUpcomingFeature("IsolatedDefaultValues"), .enableUpcomingFeature("MemberImportVisibility"), + .enableUpcomingFeature("NonfrozenEnumExhaustivity"), .enableUpcomingFeature("RegionBasedIsolation"), -// .enableUpcomingFeature("InternalImportsByDefault"), + .enableUpcomingFeature("StrictConcurrency"), .unsafeFlags([ "-Xfrontend", "-enable-actor-data-race-checks", "-Xfrontend", "-strict-concurrency=complete" diff --git a/OpenHABCore/Tests/OpenHABCoreTests/NetworkTrackerTests.swift b/OpenHABCore/Tests/OpenHABCoreTests/NetworkTrackerTests.swift index de46d9177..f7bab8681 100644 --- a/OpenHABCore/Tests/OpenHABCoreTests/NetworkTrackerTests.swift +++ b/OpenHABCore/Tests/OpenHABCoreTests/NetworkTrackerTests.swift @@ -148,6 +148,9 @@ final class NetworkTrackerTests: XCTestCase { @MainActor func testTrackerGoesOfflineOnNetworkLoss() async { + let statusSinkAttached = XCTestExpectation(description: "Combine sink attached") + let becameNotConnected = XCTestExpectation(description: "Status becomes .notConnected") + let expectation = XCTestExpectation(description: "Status becomes .notConnected") let mockMonitor = MockPathMonitor() // ⬅️ Hold on to this @@ -159,13 +162,17 @@ final class NetworkTrackerTests: XCTestCase { var cancellables = Set() + // swiftlint:disable:next trailing_closure tracker.$status + .handleEvents(receiveSubscription: { _ in + statusSinkAttached.fulfill() + }) .dropFirst() - .sink { status in + .sink(receiveValue: { status in if status == .notConnected { - expectation.fulfill() + becameNotConnected.fulfill() } - } + }) .store(in: &cancellables) // Start tracking first to initialize properly @@ -173,12 +180,12 @@ final class NetworkTrackerTests: XCTestCase { ConnectionConfiguration(url: "http://mock", username: "", password: "", priority: 0) ]) - // Add small delay to let Combine attach the sink before simulating - try? await Task.sleep(nanoseconds: 100_000_000) // 100ms + // 🚦 Wait until Combine is ready before triggering anything + await fulfillment(of: [statusSinkAttached], timeout: 2.0) // Simulate loss of network mockMonitor.simulateConnection(isConnected: false) // ✅ use directly - await fulfillment(of: [expectation], timeout: 4.0) + await fulfillment(of: [becameNotConnected], timeout: 4.0) } } diff --git a/OpenHABCore/Tests/OpenHABCoreTests/ServerCertificateManagerTests.swift b/OpenHABCore/Tests/OpenHABCoreTests/ServerCertificateManagerTests.swift index c5d0a89aa..278f8c1fe 100644 --- a/OpenHABCore/Tests/OpenHABCoreTests/ServerCertificateManagerTests.swift +++ b/OpenHABCore/Tests/OpenHABCoreTests/ServerCertificateManagerTests.swift @@ -12,6 +12,7 @@ @testable import OpenHABCore import XCTest +@MainActor func XCTAssertThrowsErrorAsync( _ expression: @escaping () async throws -> some Any, _ message: @autoclosure () -> String = "Expected async error but got success", diff --git a/fastlane/SnapshotHelper.swift b/fastlane/SnapshotHelper.swift index 6dec13020..9760b3990 100644 --- a/fastlane/SnapshotHelper.swift +++ b/fastlane/SnapshotHelper.swift @@ -276,7 +276,7 @@ private extension XCUIElementAttributes { private extension XCUIElementQuery { var networkLoadingIndicators: XCUIElementQuery { let isNetworkLoadingIndicator = NSPredicate { (evaluatedObject, _) in - guard let element = evaluatedObject as? XCUIElementAttributes else { return false } + guard let element = evaluatedObject as? any XCUIElementAttributes else { return false } return element.isNetworkLoadingIndicator } @@ -293,7 +293,7 @@ private extension XCUIElementQuery { let deviceWidth = app.windows.firstMatch.frame.width let isStatusBar = NSPredicate { (evaluatedObject, _) in - guard let element = evaluatedObject as? XCUIElementAttributes else { return false } + guard let element = evaluatedObject as? any XCUIElementAttributes else { return false } return element.isStatusBar(deviceWidth) } diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index 539b58726..2d6e69e38 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -2396,15 +2396,20 @@ MARKETING_VERSION = 3.0.9; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; - SWIFT_STRICT_CONCURRENCY = minimal; + SWIFT_STRICT_CONCURRENCY = complete; SWIFT_UPCOMING_FEATURE_CONCISE_MAGIC_FILE = YES; SWIFT_UPCOMING_FEATURE_DEPRECATE_APPLICATION_MAIN = YES; SWIFT_UPCOMING_FEATURE_DISABLE_OUTWARD_ACTOR_ISOLATION = YES; + SWIFT_UPCOMING_FEATURE_DYNAMIC_ACTOR_ISOLATION = YES; + SWIFT_UPCOMING_FEATURE_EXISTENTIAL_ANY = YES; SWIFT_UPCOMING_FEATURE_FORWARD_TRAILING_CLOSURES = YES; + SWIFT_UPCOMING_FEATURE_GLOBAL_ACTOR_ISOLATED_TYPES_USABILITY = YES; + SWIFT_UPCOMING_FEATURE_GLOBAL_CONCURRENCY = YES; SWIFT_UPCOMING_FEATURE_IMPLICIT_OPEN_EXISTENTIALS = YES; SWIFT_UPCOMING_FEATURE_IMPORT_OBJC_FORWARD_DECLS = YES; SWIFT_UPCOMING_FEATURE_INFER_SENDABLE_FROM_CAPTURES = YES; SWIFT_UPCOMING_FEATURE_ISOLATED_DEFAULT_VALUES = YES; + SWIFT_UPCOMING_FEATURE_NONFROZEN_ENUM_EXHAUSTIVITY = YES; SWIFT_UPCOMING_FEATURE_REGION_BASED_ISOLATION = NO; SWIFT_VERSION = ""; TARGETED_DEVICE_FAMILY = "1,2"; @@ -2465,15 +2470,20 @@ ONLY_ACTIVE_ARCH = NO; SDKROOT = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_STRICT_CONCURRENCY = minimal; + SWIFT_STRICT_CONCURRENCY = complete; SWIFT_UPCOMING_FEATURE_CONCISE_MAGIC_FILE = YES; SWIFT_UPCOMING_FEATURE_DEPRECATE_APPLICATION_MAIN = YES; SWIFT_UPCOMING_FEATURE_DISABLE_OUTWARD_ACTOR_ISOLATION = YES; + SWIFT_UPCOMING_FEATURE_DYNAMIC_ACTOR_ISOLATION = YES; + SWIFT_UPCOMING_FEATURE_EXISTENTIAL_ANY = YES; SWIFT_UPCOMING_FEATURE_FORWARD_TRAILING_CLOSURES = YES; + SWIFT_UPCOMING_FEATURE_GLOBAL_ACTOR_ISOLATED_TYPES_USABILITY = YES; + SWIFT_UPCOMING_FEATURE_GLOBAL_CONCURRENCY = YES; SWIFT_UPCOMING_FEATURE_IMPLICIT_OPEN_EXISTENTIALS = YES; SWIFT_UPCOMING_FEATURE_IMPORT_OBJC_FORWARD_DECLS = YES; SWIFT_UPCOMING_FEATURE_INFER_SENDABLE_FROM_CAPTURES = YES; SWIFT_UPCOMING_FEATURE_ISOLATED_DEFAULT_VALUES = YES; + SWIFT_UPCOMING_FEATURE_NONFROZEN_ENUM_EXHAUSTIVITY = YES; SWIFT_UPCOMING_FEATURE_REGION_BASED_ISOLATION = NO; SWIFT_VERSION = ""; TARGETED_DEVICE_FAMILY = "1,2"; diff --git a/openHAB/OpenHABRootViewController.swift b/openHAB/OpenHABRootViewController.swift index d94d89895..9a02f68be 100644 --- a/openHAB/OpenHABRootViewController.swift +++ b/openHAB/OpenHABRootViewController.swift @@ -425,10 +425,7 @@ class OpenHABRootViewController: UIViewController { displayErrorNotification("Could not find server") } catch { displayErrorNotification("Failed to establish a connection: \(error.localizedDescription)") - // TODO: - // logger.error("Could not send data \(error.localizedDescription)") - // - // self.displayErrorNotification("Request to \(url) failed: \(error.localizedDescription)") + logger.error("Could not send data \(error.localizedDescription)") } } } diff --git a/openHABIntents/GetItemStateIntentHandler.swift b/openHABIntents/GetItemStateIntentHandler.swift index 9f143c5f6..11b357ed9 100644 --- a/openHABIntents/GetItemStateIntentHandler.swift +++ b/openHABIntents/GetItemStateIntentHandler.swift @@ -16,9 +16,9 @@ import os.log class GetItemStateIntentHandler: NSObject, OpenHABGetItemStateIntentHandling { private let logger = Logger(subsystem: "org.openhab.app", category: "GetItemStateIntent") - private let itemCache: ItemCacheProtocol + private let itemCache: any ItemCacheProtocol - init(itemCache: ItemCacheProtocol = OpenHABItemCache.instance) { + init(itemCache: any ItemCacheProtocol = OpenHABItemCache.instance) { self.itemCache = itemCache } diff --git a/openHABIntents/SetColorValueIntentHandler.swift b/openHABIntents/SetColorValueIntentHandler.swift index aaedd01a4..97bfae34e 100644 --- a/openHABIntents/SetColorValueIntentHandler.swift +++ b/openHABIntents/SetColorValueIntentHandler.swift @@ -16,9 +16,9 @@ import os.log class SetColorValueIntentHandler: NSObject, OpenHABSetColorValueIntentHandling { private let logger = Logger(subsystem: "org.openhab.app", category: "SetColorValueIntent") - private let itemCache: ItemCacheProtocol + private let itemCache: any ItemCacheProtocol - init(itemCache: ItemCacheProtocol = OpenHABItemCache.instance) { + init(itemCache: any ItemCacheProtocol = OpenHABItemCache.instance) { self.itemCache = itemCache } diff --git a/openHABIntents/SetContactStateValueIntentHandler.swift b/openHABIntents/SetContactStateValueIntentHandler.swift index 691fd3ce7..f7ec4a730 100644 --- a/openHABIntents/SetContactStateValueIntentHandler.swift +++ b/openHABIntents/SetContactStateValueIntentHandler.swift @@ -25,9 +25,9 @@ class SetContactStateValueIntentHandler: NSObject, OpenHABSetContactStateValueIn ] private let logger = Logger(subsystem: "org.openhab.app", category: "SetColorValueIntent") - private let itemCache: ItemCacheProtocol + private let itemCache: any ItemCacheProtocol - init(itemCache: ItemCacheProtocol = OpenHABItemCache.instance) { + init(itemCache: any ItemCacheProtocol = OpenHABItemCache.instance) { self.itemCache = itemCache } diff --git a/openHABIntents/SetDimmerRollerValueIntentHandler.swift b/openHABIntents/SetDimmerRollerValueIntentHandler.swift index 6593c949d..d952c4f2e 100644 --- a/openHABIntents/SetDimmerRollerValueIntentHandler.swift +++ b/openHABIntents/SetDimmerRollerValueIntentHandler.swift @@ -16,9 +16,9 @@ import os.log class SetDimmerRollerValueIntentHandler: NSObject, OpenHABSetDimmerRollerValueIntentHandling { private let logger = Logger(subsystem: "org.openhab.app", category: "SetDimmerRollerValueIntent") - private let itemCache: ItemCacheProtocol + private let itemCache: any ItemCacheProtocol - init(itemCache: ItemCacheProtocol = OpenHABItemCache.instance) { + init(itemCache: any ItemCacheProtocol = OpenHABItemCache.instance) { self.itemCache = itemCache } diff --git a/openHABIntents/SetNumberValueIntentHandler.swift b/openHABIntents/SetNumberValueIntentHandler.swift index f23eb2260..de6203ca0 100644 --- a/openHABIntents/SetNumberValueIntentHandler.swift +++ b/openHABIntents/SetNumberValueIntentHandler.swift @@ -16,9 +16,9 @@ import os.log class SetNumberValueIntentHandler: NSObject, OpenHABSetNumberValueIntentHandling { private let logger = Logger(subsystem: "org.openhab.app", category: "SetNumberValueIntent") - private let itemCache: ItemCacheProtocol + private let itemCache: any ItemCacheProtocol - init(itemCache: ItemCacheProtocol = OpenHABItemCache.instance) { + init(itemCache: any ItemCacheProtocol = OpenHABItemCache.instance) { self.itemCache = itemCache } diff --git a/openHABIntents/SetStringValueIntentHandler.swift b/openHABIntents/SetStringValueIntentHandler.swift index a3247f7fd..830192a24 100644 --- a/openHABIntents/SetStringValueIntentHandler.swift +++ b/openHABIntents/SetStringValueIntentHandler.swift @@ -16,9 +16,9 @@ import os.log class SetStringValueIntentHandler: NSObject, OpenHABSetStringValueIntentHandling { private let logger = Logger(subsystem: "org.openhab.app", category: "SetStringValueIntent") - private let itemCache: ItemCacheProtocol + private let itemCache: any ItemCacheProtocol - init(itemCache: ItemCacheProtocol = OpenHABItemCache.instance) { + init(itemCache: any ItemCacheProtocol = OpenHABItemCache.instance) { self.itemCache = itemCache } diff --git a/openHABIntents/SetSwitchStateIntentHandler.swift b/openHABIntents/SetSwitchStateIntentHandler.swift index 75beb818a..48782f21e 100644 --- a/openHABIntents/SetSwitchStateIntentHandler.swift +++ b/openHABIntents/SetSwitchStateIntentHandler.swift @@ -26,9 +26,9 @@ final class SetSwitchStateIntentHandler: NSObject, OpenHABSetSwitchStateIntentHa private let logger = Logger(subsystem: "org.openhab.app", category: "SetSwitchStateIntent") - private let itemCache: ItemCacheProtocol + private let itemCache: any ItemCacheProtocol - init(itemCache: ItemCacheProtocol = OpenHABItemCache.instance) { + init(itemCache: any ItemCacheProtocol = OpenHABItemCache.instance) { self.itemCache = itemCache } diff --git a/openHABTestsSwift/LocalizationTests.swift b/openHABTestsSwift/LocalizationTests.swift index 4dd1437ce..cb834479b 100644 --- a/openHABTestsSwift/LocalizationTests.swift +++ b/openHABTestsSwift/LocalizationTests.swift @@ -21,7 +21,7 @@ class LocalizationTests: XCTestCase { private static let falsePositives: [String] = [] - private static let localizedFormatStrings: [(key: String, arguments: [CVarArg])] = [ + private let localizedFormatStrings: [(key: String, arguments: [any CVarArg])] = [ (key: "unable_to_decode_certificate", arguments: ["CERTIFICATE_PLACEHOLDER"]), (key: "unable_to_add_certificate", arguments: ["CERTIFICATE_PLACEHOLDER"]), (key: "ssl_certificate_invalid", arguments: ["PRESENTER", "SITE"]), @@ -36,7 +36,7 @@ class LocalizationTests: XCTestCase { for language in LocalizationTests.localizations { print("Testing language: '\(language)'.") - for tuple in LocalizationTests.localizedFormatStrings { + for tuple in localizedFormatStrings { guard let translation = tuple.key.localized(for: language)?.replacingOccurrences(of: "%%", with: "") else { XCTFail("Failed to get translation for key '\(tuple.key)' in language '\(language)'.") continue @@ -166,7 +166,7 @@ class LocalizationTests: XCTestCase { var retVal = true for localizableString in localizableStrings where localizableString.value.range(of: "%") != nil { - guard !LocalizationTests.localizedFormatStrings.contains(where: { $0.key == localizableString.key }) else { continue } + guard !localizedFormatStrings.contains(where: { $0.key == localizableString.key }) else { continue } retVal = false XCTFail("Missing translation with key '\(localizableString.key)' in 'LocalizationTests.localizedFormatStrings'.") @@ -185,7 +185,7 @@ private extension String { return Bundle(path: path)?.localizedString(forKey: self, value: "__MISSING__", table: table) } - func localizedWithFormat(for language: String, arguments: [CVarArg]) -> String? { + func localizedWithFormat(for language: String, arguments: [any CVarArg]) -> String? { if let string = localized(for: language) { return String(format: string, arguments: arguments) } diff --git a/openHABWatch/Extension/OpenHABWatchAppDelegate.swift b/openHABWatch/Extension/OpenHABWatchAppDelegate.swift index bf808be9d..e475da0a6 100644 --- a/openHABWatch/Extension/OpenHABWatchAppDelegate.swift +++ b/openHABWatch/Extension/OpenHABWatchAppDelegate.swift @@ -17,7 +17,7 @@ import WatchKit class OpenHABWatchAppDelegate: NSObject { var session: WCSession - let delegate: WCSessionDelegate + let delegate: any WCSessionDelegate override init() { delegate = AppMessageService.singleton diff --git a/openHABWatch/External/AppMessageService.swift b/openHABWatch/External/AppMessageService.swift index 0813709fc..21ec80d38 100644 --- a/openHABWatch/External/AppMessageService.swift +++ b/openHABWatch/External/AppMessageService.swift @@ -56,7 +56,7 @@ class AppMessageService: NSObject, WCSessionDelegate { } } - func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) { + func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: (any Error)?) { logger.info("activationDidCompleteWith activationState \(activationState.rawValue) error: \(String(describing: error))") DispatchQueue.main.async { () in self.updateValuesFromApplicationContext(session.receivedApplicationContext as [String: AnyObject]) From dd851a68f786a98951be96619debaeaec4bd3c24 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Wed, 21 May 2025 19:42:40 +0200 Subject: [PATCH 192/476] Compile error occurring on GitHub Actions Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- OpenHABCore/Package.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/OpenHABCore/Package.swift b/OpenHABCore/Package.swift index 3201c0889..2711948b0 100644 --- a/OpenHABCore/Package.swift +++ b/OpenHABCore/Package.swift @@ -47,7 +47,7 @@ let package = Package( .enableUpcomingFeature("ImplicitOpenExistentials"), .enableUpcomingFeature("ImportObjcForwardDeclarations"), .enableUpcomingFeature("InferSendableFromCaptures"), - .enableUpcomingFeature("InternalImportsByDefault"), +// .enableUpcomingFeature("InternalImportsByDefault"), .enableUpcomingFeature("IsolatedDefaultValues"), .enableUpcomingFeature("MemberImportVisibility"), .enableUpcomingFeature("NonfrozenEnumExhaustivity"), From 377fa15df3741d28e097de01272aa0ed02876036 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Thu, 29 May 2025 18:47:19 +0200 Subject: [PATCH 193/476] Include swiftlint feedback Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../OpenHABCore/Util/NetworkTracker.swift | 2 +- openHAB.xcodeproj/project.pbxproj | 38 +++++++++---------- openHAB/OpenHABSitemapViewController.swift | 15 ++++---- 3 files changed, 28 insertions(+), 27 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift index 23df5bc8d..8667f06a9 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift @@ -167,7 +167,7 @@ public final class NetworkTrackerViewModel: ObservableObject { } } -public actor NetworkObserver: Sendable { +public actor NetworkObserver { public static let shared = NetworkObserver() private var viewModel: NetworkTrackerViewModel? diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index 2d6e69e38..9ba31b175 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -1741,8 +1741,8 @@ CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_ENTITLEMENTS = openHABIntents/openHABIntents.entitlements; - CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Automatic; + CODE_SIGN_IDENTITY = "Apple Distribution"; + CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 33; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = PBAPXHRAM9; @@ -1762,7 +1762,7 @@ MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = org.openhab.app.openHABIntents; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = ""; + PROVISIONING_PROFILE_SPECIFIER = "match AppStore org.openhab.app.openHABIntents"; SKIP_INSTALL = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; @@ -1827,8 +1827,8 @@ CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements; - CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Automatic; + CODE_SIGN_IDENTITY = "Apple Distribution"; + CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 33; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = PBAPXHRAM9; @@ -1853,7 +1853,7 @@ MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = org.openhab.app.NotificationService; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = ""; + PROVISIONING_PROFILE_SPECIFIER = "match AppStore org.openhab.app.NotificationService"; SKIP_INSTALL = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -1922,8 +1922,8 @@ CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Automatic; + CODE_SIGN_IDENTITY = "Apple Distribution"; + CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 33; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = PBAPXHRAM9; @@ -2012,8 +2012,8 @@ CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_ENTITLEMENTS = "openHABWatch Extension/openHABWatch Extension.entitlements"; - CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Automatic; + CODE_SIGN_IDENTITY = "Apple Distribution"; + CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 33; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = PBAPXHRAM9; @@ -2038,7 +2038,7 @@ MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = org.openhab.app.watchkitapp; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = ""; + PROVISIONING_PROFILE_SPECIFIER = "match AppStore org.openhab.app.watchkitapp"; SDKROOT = watchos; SKIP_INSTALL = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; @@ -2116,8 +2116,8 @@ CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Automatic; + CODE_SIGN_IDENTITY = "Apple Distribution"; + CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 33; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = PBAPXHRAM9; @@ -2199,8 +2199,8 @@ CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Automatic; + CODE_SIGN_IDENTITY = "Apple Distribution"; + CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 33; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = PBAPXHRAM9; @@ -2240,7 +2240,7 @@ CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_IDENTITY = "Apple Distribution"; - "CODE_SIGN_IDENTITY[sdk=watchos*]" = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=watchos*]" = "Apple Distribution"; CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 33; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; @@ -2500,8 +2500,8 @@ CLANG_CXX_LANGUAGE_STANDARD = "$(inherited)"; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = openHAB/openHAB.entitlements; - CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Automatic; + CODE_SIGN_IDENTITY = "Apple Distribution"; + CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 33; DEVELOPMENT_TEAM = PBAPXHRAM9; ENABLE_DEBUG_DYLIB = YES; @@ -2526,7 +2526,7 @@ PRODUCT_BUNDLE_IDENTIFIER = org.openhab.app; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE = ""; - PROVISIONING_PROFILE_SPECIFIER = ""; + PROVISIONING_PROFILE_SPECIFIER = "match AppStore org.openhab.app"; SUPPORTS_MACCATALYST = NO; SWIFT_INSTALL_OBJC_HEADER = NO; SWIFT_OBJC_BRIDGING_HEADER = ""; diff --git a/openHAB/OpenHABSitemapViewController.swift b/openHAB/OpenHABSitemapViewController.swift index c112b13c0..98f745236 100644 --- a/openHAB/OpenHABSitemapViewController.swift +++ b/openHAB/OpenHABSitemapViewController.swift @@ -789,14 +789,15 @@ extension OpenHABSitemapViewController: UITableViewDelegate, UITableViewDataSour } else if widget.type == .selection { let selectionItemState = widget.item?.state logger.info("Selected selection widget in status: \(selectionItemState ?? "unknown")") - let hostingController = UIHostingController(rootView: SelectionView( - mappings: widget.mappingsOrItemOptions, - selectionItemState: selectionItemState, - onSelection: { selectedMappingIndex in + let hostingController = UIHostingController( + rootView: SelectionView( + mappings: widget.mappingsOrItemOptions, + selectionItemState: selectionItemState + ) { selectedMappingIndex in let selectedMapping: OpenHABWidgetMapping = widget.mappingsOrItemOptions[selectedMappingIndex] self.sendCommand(widget.item, commandToSend: selectedMapping.command) } - )) + ) hostingController.title = widget.labelText navigationController?.pushViewController(hostingController, animated: true) } else if widget.type == .input { @@ -840,9 +841,9 @@ extension OpenHABSitemapViewController: UITableViewDelegate, UITableViewDataSour preferredStyle: .alert ) alert.addTextField(configurationHandler: textFieldAdder) - let sendAction = UIAlertAction(title: "Set value", style: .destructive, handler: { [weak self] _ in + let sendAction = UIAlertAction(title: "Set value", style: .destructive) { [weak self] _ in self?.sendCommand(widget.item, commandToSend: textExtractor(alert)) - }) + } alert.addAction(sendAction) alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) alert.preferredAction = sendAction From 497b69803ef0d76c5a835147b27dca42a32838be Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Thu, 29 May 2025 19:29:16 +0200 Subject: [PATCH 194/476] Update bundler version in lockfile to 2.6.9 --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 9ed3e3103..ffdc605e9 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -222,4 +222,4 @@ DEPENDENCIES fastlane-plugin-versioning BUNDLED WITH - 2.6.2 + 2.6.9 From 1733128f8cd6c1710287ec1603f84bbb179e006b Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Thu, 29 May 2025 20:29:52 +0200 Subject: [PATCH 195/476] Update .swiftlint.yml to address error on GitHub Actions Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- BuildTools/.swiftlint.yml | 3 +- openHAB.xcodeproj/project.pbxproj | 57 +++++++++++-------------- openHAB/AppDelegate.swift | 1 - openHAB/OpenHABRootViewController.swift | 2 - 4 files changed, 27 insertions(+), 36 deletions(-) diff --git a/BuildTools/.swiftlint.yml b/BuildTools/.swiftlint.yml index 8267e37de..c67cd9e78 100644 --- a/BuildTools/.swiftlint.yml +++ b/BuildTools/.swiftlint.yml @@ -32,6 +32,7 @@ excluded: - .build - ../OpenHABCore/Sources/OpenHABCore/GeneratedSources/* - ../OpenHABCore/swift-openapi-generator + - ../vendor nesting: type_level: 2 @@ -103,4 +104,4 @@ file_name: suffix_pattern: "Extension?|\\+.*" opening_brace: - allow_multiline_func: true + ignore_multiline_function_signatures: true diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index 5ce401423..9ba31b175 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -1465,7 +1465,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "cd BuildTools\nSDKROOT=(xcrun --sdk macosx --show-sdk-path)\n\nswift package plugin --allow-writing-to-package-directory --allow-writing-to-directory \"$SRCROOT\" swiftformat \"$SRCROOT\" --config ./.swiftformat --cache /private/tmp/\nswift package plugin --allow-writing-to-package-directory --allow-writing-to-directory ../ swiftlint --cache-path /private/tmp/\n\n"; + shellScript = "cd BuildTools\nSDKROOT=(xcrun --sdk macosx --show-sdk-path)\n\nswift package plugin --allow-writing-to-package-directory --allow-writing-to-directory \"$SRCROOT\" swiftformat \"$SRCROOT\" --config ./.swiftformat --cache /private/tmp/\nswift package plugin --allow-writing-to-package-directory --allow-writing-to-directory ../ swiftlint --cache-path /private/tmp/\n"; }; /* End PBXShellScriptBuildPhase section */ @@ -1743,7 +1743,7 @@ CODE_SIGN_ENTITLEMENTS = openHABIntents/openHABIntents.entitlements; CODE_SIGN_IDENTITY = "Apple Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 40; + CURRENT_PROJECT_VERSION = 33; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = PBAPXHRAM9; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -1757,7 +1757,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 3.0.9; + MARKETING_VERSION = 3.0.8; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = org.openhab.app.openHABIntents; @@ -1787,7 +1787,7 @@ CODE_SIGN_IDENTITY = "Apple Distribution"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "Apple Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 40; + CURRENT_PROJECT_VERSION = 33; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = PBAPXHRAM9; @@ -1801,7 +1801,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 3.0.9; + MARKETING_VERSION = 3.0.8; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = org.openhab.app.openHABIntents; @@ -1829,7 +1829,7 @@ CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements; CODE_SIGN_IDENTITY = "Apple Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 40; + CURRENT_PROJECT_VERSION = 33; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = PBAPXHRAM9; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -1848,7 +1848,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 3.0.9; + MARKETING_VERSION = 3.0.8; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = org.openhab.app.NotificationService; @@ -1878,7 +1878,7 @@ CODE_SIGN_IDENTITY = "Apple Distribution"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "Apple Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 40; + CURRENT_PROJECT_VERSION = 33; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = PBAPXHRAM9; @@ -1897,7 +1897,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 3.0.9; + MARKETING_VERSION = 3.0.8; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = org.openhab.app.NotificationService; @@ -1924,7 +1924,7 @@ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_IDENTITY = "Apple Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 40; + CURRENT_PROJECT_VERSION = 33; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = PBAPXHRAM9; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -1970,7 +1970,7 @@ CODE_SIGN_IDENTITY = "Apple Distribution"; CODE_SIGN_STYLE = Manual; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 40; + CURRENT_PROJECT_VERSION = 33; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; @@ -2017,10 +2017,6 @@ CURRENT_PROJECT_VERSION = 33; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = PBAPXHRAM9; - CURRENT_PROJECT_VERSION = 40; - DEBUG_INFORMATION_FORMAT = dwarf; - DEVELOPMENT_TEAM = ""; - "DEVELOPMENT_TEAM[sdk=watchos*]" = PBAPXHRAM9; GCC_C_LANGUAGE_STANDARD = "compiler-default"; GCC_NO_COMMON_BLOCKS = YES; GCC_OPTIMIZATION_LEVEL = 0; @@ -2037,7 +2033,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 3.0.9; + MARKETING_VERSION = 3.0.8; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = org.openhab.app.watchkitapp; @@ -2072,7 +2068,7 @@ "CODE_SIGN_IDENTITY[sdk=watchos*]" = "Apple Distribution"; CODE_SIGN_STYLE = Manual; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 40; + CURRENT_PROJECT_VERSION = 33; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=watchos*]" = PBAPXHRAM9; @@ -2091,7 +2087,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 3.0.9; + MARKETING_VERSION = 3.0.8; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = org.openhab.app.watchkitapp; @@ -2122,7 +2118,7 @@ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_IDENTITY = "Apple Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 40; + CURRENT_PROJECT_VERSION = 33; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = PBAPXHRAM9; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -2165,7 +2161,7 @@ CODE_SIGN_IDENTITY = "Apple Distribution"; CODE_SIGN_STYLE = Manual; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 40; + CURRENT_PROJECT_VERSION = 33; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; @@ -2205,7 +2201,7 @@ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_IDENTITY = "Apple Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 40; + CURRENT_PROJECT_VERSION = 33; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = PBAPXHRAM9; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -2246,7 +2242,7 @@ CODE_SIGN_IDENTITY = "Apple Distribution"; "CODE_SIGN_IDENTITY[sdk=watchos*]" = "Apple Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 40; + CURRENT_PROJECT_VERSION = 33; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = D6A95UZXVC; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -2281,7 +2277,7 @@ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_IDENTITY = "Apple Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 40; + CURRENT_PROJECT_VERSION = 33; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = D6A95UZXVC; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -2319,7 +2315,7 @@ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_IDENTITY = "Apple Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 40; + CURRENT_PROJECT_VERSION = 33; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = D6A95UZXVC; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -2397,7 +2393,7 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 16.0; - MARKETING_VERSION = 3.1.0; + MARKETING_VERSION = 3.0.9; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; SWIFT_STRICT_CONCURRENCY = complete; @@ -2470,7 +2466,7 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 16.0; - MARKETING_VERSION = 3.1.0; + MARKETING_VERSION = 3.0.9; ONLY_ACTIVE_ARCH = NO; SDKROOT = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; @@ -2509,9 +2505,6 @@ CURRENT_PROJECT_VERSION = 33; DEVELOPMENT_TEAM = PBAPXHRAM9; ENABLE_DEBUG_DYLIB = YES; - CURRENT_PROJECT_VERSION = 40; - DEVELOPMENT_TEAM = ""; - "DEVELOPMENT_TEAM[sdk=iphoneos*]" = PBAPXHRAM9; GCC_PRECOMPILE_PREFIX_HEADER = YES; GCC_PREFIX_HEADER = "openHAB/openHAB-Prefix.pch"; INFOPLIST_FILE = "openHAB/openHAB-Info.plist"; @@ -2528,7 +2521,7 @@ "@loader_path/../../Frameworks", ); LIBRARY_SEARCH_PATHS = "$(inherited)"; - MARKETING_VERSION = 3.0.9; + MARKETING_VERSION = 3.0.8; OTHER_SWIFT_FLAGS = "$(inherited) -DDEBUG -Xfrontend -warn-long-expression-type-checking=200 -Xfrontend -warn-long-function-bodies=200"; PRODUCT_BUNDLE_IDENTIFIER = org.openhab.app; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -2573,7 +2566,7 @@ CODE_SIGN_IDENTITY = "Apple Distribution"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "Apple Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 40; + CURRENT_PROJECT_VERSION = 33; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = PBAPXHRAM9; ENABLE_DEBUG_DYLIB = YES; @@ -2593,7 +2586,7 @@ "@loader_path/../../Frameworks", ); LIBRARY_SEARCH_PATHS = "$(inherited)"; - MARKETING_VERSION = 3.0.9; + MARKETING_VERSION = 3.0.8; PRODUCT_BUNDLE_IDENTIFIER = org.openhab.app; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE = ""; diff --git a/openHAB/AppDelegate.swift b/openHAB/AppDelegate.swift index 3f1aec2e6..96de7003f 100644 --- a/openHAB/AppDelegate.swift +++ b/openHAB/AppDelegate.swift @@ -327,7 +327,6 @@ extension AppDelegate { extension AppDelegate: MessagingDelegate { nonisolated func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) { Task { @MainActor in - let safeToken = fcmToken ?? "" let deviceID = UIDevice.current.identifierForVendor?.uuidString ?? "UnknownDeviceID" let deviceName = UIDevice.current.name diff --git a/openHAB/OpenHABRootViewController.swift b/openHAB/OpenHABRootViewController.swift index 9a02f68be..10585fe6e 100644 --- a/openHAB/OpenHABRootViewController.swift +++ b/openHAB/OpenHABRootViewController.swift @@ -124,7 +124,6 @@ class OpenHABRootViewController: UIViewController { object: nil, queue: nil ) { [weak self] notification in - guard let summary = notification.userInfo?["summary"] as? String, let domain = notification.userInfo?["domain"] as? String, @@ -150,7 +149,6 @@ class OpenHABRootViewController: UIViewController { object: nil, queue: nil ) { [weak self] notification in - guard let summary = notification.userInfo?["summary"] as? String, let domain = notification.userInfo?["domain"] as? String, From 6253bb0947b92a919641a442ce3bd2519165afe8 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Thu, 29 May 2025 20:36:40 +0200 Subject: [PATCH 196/476] Update fastlane Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- Gemfile.lock | 39 ++++++++++++++++++++++----------------- 1 file changed, 22 insertions(+), 17 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index ffdc605e9..02498a76c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -9,18 +9,20 @@ GEM public_suffix (>= 2.0.2, < 7.0) artifactory (3.0.17) atomos (0.1.3) - aws-eventstream (1.3.0) - aws-partitions (1.1042.0) - aws-sdk-core (3.216.1) + aws-eventstream (1.3.2) + aws-partitions (1.1108.0) + aws-sdk-core (3.224.1) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) + base64 jmespath (~> 1, >= 1.6.1) - aws-sdk-kms (1.97.0) + logger + aws-sdk-kms (1.101.0) aws-sdk-core (~> 3, >= 3.216.0) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.178.0) - aws-sdk-core (~> 3, >= 3.216.0) + aws-sdk-s3 (1.187.0) + aws-sdk-core (~> 3, >= 3.224.1) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) aws-sigv4 (1.11.0) @@ -33,7 +35,7 @@ GEM commander (4.6.0) highline (~> 2.0.0) declarative (0.0.20) - digest-crc (0.6.5) + digest-crc (0.7.0) rake (>= 12.0.0, < 14.0.0) domain_name (0.6.20240107) dotenv (2.8.1) @@ -68,7 +70,7 @@ GEM faraday_middleware (1.2.1) faraday (~> 1.0) fastimage (2.4.0) - fastlane (2.226.0) + fastlane (2.227.2) CFPropertyList (>= 2.3, < 4.0.0) addressable (>= 2.8, < 3.0.0) artifactory (~> 3.0) @@ -108,7 +110,7 @@ GEM tty-spinner (>= 0.8.0, < 1.0.0) word_wrap (~> 1.0.0) xcodeproj (>= 1.13.0, < 2.0.0) - xcpretty (~> 0.4.0) + xcpretty (~> 0.4.1) xcpretty-travis-formatter (>= 0.0.3, < 2.0.0) fastlane-plugin-changelog (0.16.0) fastlane-plugin-versioning (0.7.1) @@ -131,12 +133,12 @@ GEM google-apis-core (>= 0.11.0, < 2.a) google-apis-storage_v1 (0.31.0) google-apis-core (>= 0.11.0, < 2.a) - google-cloud-core (1.7.1) + google-cloud-core (1.8.0) google-cloud-env (>= 1.0, < 3.a) google-cloud-errors (~> 1.0) google-cloud-env (1.6.0) faraday (>= 0.17.3, < 3.0) - google-cloud-errors (1.4.0) + google-cloud-errors (1.5.0) google-cloud-storage (1.47.0) addressable (~> 2.8) digest-crc (~> 0.4) @@ -154,34 +156,37 @@ GEM highline (2.0.3) http-cookie (1.0.8) domain_name (~> 0.5) - httpclient (2.8.3) + httpclient (2.9.0) + mutex_m jmespath (1.6.2) - json (2.9.1) + json (2.12.2) jwt (2.10.1) base64 + logger (1.7.0) mini_magick (4.13.2) mini_mime (1.1.5) multi_json (1.15.0) multipart-post (2.4.1) + mutex_m (0.3.0) nanaimo (0.4.0) naturally (2.2.1) nkf (0.2.0) optparse (0.6.0) os (1.1.4) plist (3.7.2) - public_suffix (6.0.1) + public_suffix (6.0.2) rake (13.2.1) representable (3.2.0) declarative (< 0.1.0) trailblazer-option (>= 0.1.1, < 0.2.0) uber (< 0.2.0) retriable (3.1.2) - rexml (3.4.0) + rexml (3.4.1) rouge (3.28.0) ruby2_keywords (0.0.5) rubyzip (2.4.1) security (0.1.5) - signet (0.19.0) + signet (0.20.0) addressable (~> 2.8) faraday (>= 0.17.5, < 3.a) jwt (>= 1.5, < 3.0) @@ -208,7 +213,7 @@ GEM colored2 (~> 3.1) nanaimo (~> 0.4.0) rexml (>= 3.3.6, < 4.0) - xcpretty (0.4.0) + xcpretty (0.4.1) rouge (~> 3.28.0) xcpretty-travis-formatter (1.0.1) xcpretty (~> 0.2, >= 0.0.7) From 01b9e4802ce46f03e042f8ca090238f98ae36374 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Thu, 29 May 2025 21:21:49 +0200 Subject: [PATCH 197/476] Excluding Intent generated files from swiftlint Address some swiftlint comments Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- BuildTools/.swiftlint.yml | 3 +++ .../Model/OpenHABNotification.swift | 10 ++++---- .../OpenHABCore/Model/OpenHABPage.swift | 14 +++++------ .../Model/OpenHABSitemapWidgetEvent.swift | 16 ++++++------- .../HTTPResponseExtension.swift | 8 +++++-- .../Tests/OpenHABCoreTests/JSONData.swift | 8 +++---- .../OpenHABCoreTests/JSONParserTests.swift | 24 +++++++++---------- .../OpenHABJSONParserTests.swift | 4 ++-- openHABTestsSwift/OpenHABGeneralTests.swift | 2 +- .../Model/ObservableOpenHABSitemapPage.swift | 12 +++++----- .../Views/Utils/PreviewConstants.swift | 4 ++-- 11 files changed, 56 insertions(+), 49 deletions(-) diff --git a/BuildTools/.swiftlint.yml b/BuildTools/.swiftlint.yml index c67cd9e78..fe563c1c3 100644 --- a/BuildTools/.swiftlint.yml +++ b/BuildTools/.swiftlint.yml @@ -33,6 +33,9 @@ excluded: - ../OpenHABCore/Sources/OpenHABCore/GeneratedSources/* - ../OpenHABCore/swift-openapi-generator - ../vendor + - ../DerivedData + - ../openHAB/Intents/Generated + - ../openHABWatch/Intents/Generated nesting: type_level: 2 diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABNotification.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABNotification.swift index bc1fe4eb0..68d67b8eb 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABNotification.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABNotification.swift @@ -32,17 +32,17 @@ public struct OpenHABNotification: Sendable { // Inspired by https://www.swiftbysundell.com/basics/codable?rq=codingdata public extension OpenHABNotification { struct CodingData: Decodable { - let id: String - let message: String? - let v: Int - let created: Date? - private enum CodingKeys: String, CodingKey { case id = "_id" case message case v = "__v" case created } + + let id: String + let message: String? + let v: Int + let created: Date? } } diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABPage.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABPage.swift index c14667f5e..a12810123 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABPage.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABPage.swift @@ -62,13 +62,6 @@ public extension OpenHABPage { public extension OpenHABPage { struct CodingData: Decodable { - let pageId: String? - let title: String? - let link: String? - let leaf: Bool? - let widgets: [OpenHABWidget.CodingData]? - let icon: String? - private enum CodingKeys: String, CodingKey { case pageId = "id" case title @@ -77,6 +70,13 @@ public extension OpenHABPage { case widgets case icon } + + let pageId: String? + let title: String? + let link: String? + let leaf: Bool? + let widgets: [OpenHABWidget.CodingData]? + let icon: String? } } diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABSitemapWidgetEvent.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABSitemapWidgetEvent.swift index 6dbafd9ed..d045ffbbb 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABSitemapWidgetEvent.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABSitemapWidgetEvent.swift @@ -59,14 +59,6 @@ extension OpenHABSitemapWidgetEvent: CustomStringConvertible { public extension OpenHABSitemapWidgetEvent { struct CodingData: Decodable, Hashable, Equatable { - public static func == (lhs: OpenHABSitemapWidgetEvent.CodingData, rhs: OpenHABSitemapWidgetEvent.CodingData) -> Bool { - lhs.widgetId == rhs.widgetId - } - - public func hash(into hasher: inout Hasher) { - hasher.combine(widgetId) - } - var sitemapName: String? var pageId: String? var widgetId: String? @@ -82,6 +74,14 @@ public extension OpenHABSitemapWidgetEvent { var item: OpenHABItem.CodingData? var descriptionChanged: Bool? var link: String? + + public static func == (lhs: OpenHABSitemapWidgetEvent.CodingData, rhs: OpenHABSitemapWidgetEvent.CodingData) -> Bool { + lhs.widgetId == rhs.widgetId + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(widgetId) + } } } diff --git a/OpenHABCore/Tests/OpenHABCoreTests/HTTPResponseExtension.swift b/OpenHABCore/Tests/OpenHABCoreTests/HTTPResponseExtension.swift index 037afd4ce..0341bfb58 100644 --- a/OpenHABCore/Tests/OpenHABCoreTests/HTTPResponseExtension.swift +++ b/OpenHABCore/Tests/OpenHABCoreTests/HTTPResponseExtension.swift @@ -77,7 +77,9 @@ public extension Data { static let crlf: ArraySlice = [0xD, 0xA] - static var multipartBodyString: String { String(decoding: multipartBodyAsSlice, as: UTF8.self) } + static var multipartBodyString: String? { + String(bytes: multipartBodyAsSlice, encoding: .utf8) + } static var multipartBodyAsSlice: [UInt8] { var bytes: [UInt8] = [] @@ -172,7 +174,9 @@ public extension Data { return bytes } - var pretty: String { String(decoding: self, as: UTF8.self) } + var pretty: String? { + String(data: self, encoding: .utf8) + } } public extension HTTPRequest { diff --git a/OpenHABCore/Tests/OpenHABCoreTests/JSONData.swift b/OpenHABCore/Tests/OpenHABCoreTests/JSONData.swift index e93301c63..53fc6d0dc 100644 --- a/OpenHABCore/Tests/OpenHABCoreTests/JSONData.swift +++ b/OpenHABCore/Tests/OpenHABCoreTests/JSONData.swift @@ -12,7 +12,7 @@ import Foundation // swiftlint:disable line_length -let jsonSitemap = """ +let jsonSitemap = Data(""" {"id":"myHome","title":"myHome","link":"https://myopenhab.org/rest/sitemaps/myHome/myHome","leaf":false,"timeout":false, "widgets":[{"widgetId":"00","type":"Frame","label":"Treppe","icon":"frame","mappings":[], "widgets":[ @@ -21,9 +21,9 @@ let jsonSitemap = """ {"widgetId":"0002","type":"Switch","label":"Licht Treppe 1-2","icon":"switch","mappings":[],"item":{"link":"https://myopenhab.org/rest/items/lcnLightSwitch32_1","state":"OFF","editable":false,"type":"Switch","name":"lcnLightSwitch32_1","label":"Licht Treppe 1-2","tags":["Lighting"],"groupNames":["gLcn"]},"widgets":[]} ]}, {"widgetId":"01","type":"Frame","label":"Eingang","icon":"frame","mappings":[],"widgets":[{"widgetId":"0100","type":"Switch","label":"Licht Eingang","icon":"switch","mappings":[],"item":{"link":"https://myopenhab.org/rest/items/lcnLightSwitch17_1","state":"ON","editable":false,"type":"Switch","name":"lcnLightSwitch17_1","label":"Licht Eingang","tags":["Lighting"],"groupNames":["G_PresenceSimulation","gLcn"]},"widgets":[]},{"widgetId":"0101","type":"Switch","label":"Licht Eingang aussen","icon":"switch","mappings":[],"item":{"link":"https://myopenhab.org/rest/items/lcnLightSwitch17_2","state":"ON","editable":false,"type":"Switch","name":"lcnLightSwitch17_2","label":"Licht Eingang aussen","tags":["Lighting"],"groupNames":["G_PresenceSimulation","gLcn"]},"widgets":[]}]},{"widgetId":"02","type":"Frame","label":"WC","icon":"frame","mappings":[],"widgets":[{"widgetId":"0200","type":"Switch","label":"Licht WC EG","icon":"switch","mappings":[],"item":{"link":"https://myopenhab.org/rest/items/lcnLightSwitch12_1","state":"OFF","editable":false,"type":"Switch","name":"lcnLightSwitch12_1","label":"Licht WC EG","tags":["Lighting"],"groupNames":["gLcn"]},"widgets":[]}]},{"widgetId":"03","type":"Frame","label":"Keller","icon":"frame","mappings":[],"widgets":[{"widgetId":"0300","type":"Switch","label":"Licht Keller WC Decke","icon":"switch","mappings":[],"item":{"link":"https://myopenhab.org/rest/items/lcnLightSwitch6_1","state":"OFF","editable":false,"type":"Switch","name":"lcnLightSwitch6_1","label":"Licht Keller WC Decke","tags":["Lighting"],"groupNames":["gKellerLicht","gLcn"]},"widgets":[]},{"widgetId":"0301","type":"Switch","label":"Licht Keller WC Spiegel","icon":"switch","mappings":[],"item":{"link":"https://myopenhab.org/rest/items/lcnLightSwitch6_2","state":"OFF","editable":false,"type":"Switch","name":"lcnLightSwitch6_2","label":"Licht Keller WC Spiegel","tags":["Lighting"],"groupNames":["gKellerLicht","gLcn"]},"widgets":[]},{"widgetId":"0302","type":"Switch","label":"Licht Keller Lager Decke","icon":"switch","mappings":[],"item":{"link":"https://myopenhab.org/rest/items/lcnLightSwitch8_1","state":"OFF","editable":false,"type":"Switch","name":"lcnLightSwitch8_1","label":"Licht Keller Lager Decke","tags":["Lighting"],"groupNames":["gKellerLicht","gLcn"]},"widgets":[]},{"widgetId":"0303","type":"Switch","label":"Licht Gäste Decke","icon":"switch","mappings":[],"item":{"link":"https://myopenhab.org/rest/items/lcnLightSwitch9_1","state":"ON","editable":false,"type":"Switch","name":"lcnLightSwitch9_1","label":"Licht Gäste Decke","tags":["Lighting"],"groupNames":["gKellerLicht","gLcn"]},"widgets":[]},{"widgetId":"0304","type":"Switch","label":"Licht Keller Heizung Decke","icon":"switch","mappings":[],"item":{"link":"https://myopenhab.org/rest/items/lcnLightSwitch7_1","state":"OFF","editable":false,"type":"Switch","name":"lcnLightSwitch7_1","label":"Licht Keller Heizung Decke","tags":["Lighting"],"groupNames":["gKellerLicht","gLcn"]},"widgets":[]}]},{"widgetId":"04","type":"Frame","label":"DFF","icon":"frame","mappings":[],"widgets":[{"widgetId":"0400","type":"Switch","label":"DFF Emma","icon":"rollershutter","mappings":[],"item":{"link":"https://myopenhab.org/rest/items/lcnDFFWest","state":"100.0","editable":false,"type":"Rollershutter","name":"lcnDFFWest","label":"DFF Emma","tags":[],"groupNames":["gDZ","gDFF","gLcn"]},"widgets":[]},{"widgetId":"0401","type":"Switch","label":"DFF Arbeitszimmer","icon":"rollershutter","mappings":[],"item":{"link":"https://myopenhab.org/rest/items/lcnDFFOst","state":"100.0","editable":false,"type":"Rollershutter","name":"lcnDFFOst","label":"DFF Arbeitszimmer","tags":[],"groupNames":["gDZ","gDFF","gLcn"]},"widgets":[]}]},{"widgetId":"05","type":"Frame","label":"DG","icon":"frame","mappings":[],"widgets":[{"widgetId":"0500","type":"Switch","label":"Kofferabteil","icon":"switch","mappings":[],"item":{"link":"https://myopenhab.org/rest/items/lcnLightSwitchRel42_8","state":"OFF","editable":false,"type":"Switch","name":"lcnLightSwitchRel42_8","label":"Kofferabteil","tags":[],"groupNames":["gLcn"]},"widgets":[]},{"widgetId":"0501","type":"Switch","label":"Licht DG Zimmer","icon":"switch","mappings":[],"item":{"link":"https://myopenhab.org/rest/items/lcnLightSwitch44_2","state":"OFF","editable":false,"type":"Switch","name":"lcnLightSwitch44_2","label":"Licht DG Zimmer","tags":["Lighting"],"groupNames":["gLcn"]},"widgets":[]},{"widgetId":"0502","type":"Switch","label":"Licht DG Büro Decke","icon":"lightbulb","mappings":[],"item":{"link":"https://myopenhab.org/rest/items/lcnLightSwitch43_2","state":"OFF","editable":false,"type":"Switch","name":"lcnLightSwitch43_2","label":"Licht DG Büro Decke","category":"lightbulb","tags":["Lighting"],"groupNames":["gLcn"]},"widgets":[]},{"widgetId":"0503","type":"Switch","label":"Licht DG Zimmer Decke","icon":"switch","mappings":[],"item":{"link":"https://myopenhab.org/rest/items/lcnLightSwitch41_2","state":"ON","editable":false,"type":"Switch","name":"lcnLightSwitch41_2","label":"Licht DG Zimmer Decke","tags":["Lighting"],"groupNames":["gLcn"]},"widgets":[]},{"widgetId":"0504","type":"Switch","label":"Licht DG Zimmer Occhio","icon":"switch","mappings":[],"item":{"link":"https://myopenhab.org/rest/items/lcnLightSwitch44_1","state":"ON","editable":false,"type":"Switch","name":"lcnLightSwitch44_1","label":"Licht DG Zimmer Occhio","tags":["Lighting"],"groupNames":["gLcn"]},"widgets":[]}]},{"widgetId":"06","type":"Frame","label":"Schlafzimmer","icon":"frame","mappings":[],"widgets":[{"widgetId":"0600","type":"Switch","label":"Licht SZ Decke","icon":"switch","mappings":[],"item":{"link":"https://myopenhab.org/rest/items/lcnLightSwitch37_1","state":"OFF","editable":false,"type":"Switch","name":"lcnLightSwitch37_1","label":"Licht SZ Decke","tags":["Lighting"],"groupNames":["G_PresenceSimulation","gLcn"]},"widgets":[]}]},{"widgetId":"07","type":"Frame","label":"Zimmer Paul","icon":"frame","mappings":[],"widgets":[{"widgetId":"0700","type":"Switch","label":"Licht Zimmer Paul","icon":"switch","mappings":[],"item":{"link":"https://myopenhab.org/rest/items/lcnLightSwitch36_2","state":"OFF","editable":false,"type":"Switch","name":"lcnLightSwitch36_2","label":"Licht Zimmer Paul","tags":["Lighting"],"groupNames":["G_PresenceSimulation","gLcn"]},"widgets":[]},{"widgetId":"0701","type":"Switch","label":"Steckdose Zimmer Paul","icon":"poweroutlet","mappings":[],"item":{"link":"https://myopenhab.org/rest/items/lcnRelay36_1","state":"OFF","editable":false,"type":"Switch","name":"lcnRelay36_1","label":"Steckdose Zimmer Paul","category":"poweroutlet","tags":["Switchable"],"groupNames":["gLcn"]},"widgets":[]}]},{"widgetId":"08","type":"Frame","label":"Wohnzimmer","icon":"frame","mappings":[],"widgets":[{"widgetId":"0800","type":"Switch","label":"Steckdose WZ Nord","icon":"poweroutlet","mappings":[],"item":{"link":"https://myopenhab.org/rest/items/lcnRelayWZNord","state":"OFF","editable":false,"type":"Switch","name":"lcnRelayWZNord","label":"Steckdose WZ Nord","category":"poweroutlet","tags":["Switchable"],"groupNames":["gLcn"]},"widgets":[]}]},{"widgetId":"09","type":"Frame","label":"Küche","icon":"frame","mappings":[],"widgets":[{"widgetId":"0900","type":"Switch","label":"Licht Oberlicht","icon":"switch","mappings":[],"item":{"link":"https://myopenhab.org/rest/items/lcnLightSwitch14_1","state":"ON","editable":false,"type":"Switch","name":"lcnLightSwitch14_1","label":"Licht Oberlicht","tags":["Lighting"],"groupNames":["G_PresenceSimulation","gLcn"]},"widgets":[]},{"widgetId":"0901","type":"Switch","label":"Licht Küche Oberlicht","icon":"switch","mappings":[],"item":{"link":"https://myopenhab.org/rest/items/lcnLightSwitch15_1","state":"ON","editable":false,"type":"Switch","name":"lcnLightSwitch15_1","label":"Licht Küche Oberlicht","tags":["Lighting"],"groupNames":["G_PresenceSimulation","gLcn"]},"widgets":[]},{"widgetId":"0902","type":"Switch","label":"Licht Küche Unterlicht","icon":"switch","mappings":[],"item":{"link":"https://myopenhab.org/rest/items/lcnLightSwitch15_2","state":"ON","editable":false,"type":"Switch","name":"lcnLightSwitch15_2","label":"Licht Küche Unterlicht","tags":["Lighting"],"groupNames":["G_PresenceSimulation","gLcn"]},"widgets":[]},{"widgetId":"0903","type":"Switch","label":"Licht Esstisch","icon":"switch","mappings":[],"item":{"link":"https://myopenhab.org/rest/items/lcnLightSwitch20_1","state":"ON","editable":false,"type":"Switch","name":"lcnLightSwitch20_1","label":"Licht Esstisch","tags":[],"groupNames":["G_PresenceSimulation","gLcn"]},"widgets":[]},{"widgetId":"0904","type":"Slider","label":"Esstisch [100]","icon":"slider","mappings":[],"switchSupport":false,"sendFrequency":0,"item":{"link":"https://myopenhab.org/rest/items/lcnLightDimmer","state":"100","stateDescription":{"pattern":"%s","readOnly":false,"options":[]},"editable":false,"type":"Dimmer","name":"lcnLightDimmer","label":"Esstisch","tags":["Lighting"],"groupNames":["gLcn"]},"widgets":[]}]},{"widgetId":"10","type":"Frame","label":"Bad","icon":"frame","mappings":[],"widgets":[{"widgetId":"1000","type":"Switch","label":"Licht Bad Decke","icon":"switch","mappings":[],"item":{"link":"https://myopenhab.org/rest/items/lcnLightSwitch38_1","state":"OFF","editable":false,"type":"Switch","name":"lcnLightSwitch38_1","label":"Licht Bad Decke","tags":["Lighting"],"groupNames":["G_PresenceSimulation","gLcn"]},"widgets":[]},{"widgetId":"1001","type":"Switch","label":"Licht Bad Spiegel","icon":"switch","mappings":[],"item":{"link":"https://myopenhab.org/rest/items/lcnLightSwitch38_2","state":"OFF","editable":false,"type":"Switch","name":"lcnLightSwitch38_2","label":"Licht Bad Spiegel","tags":["Lighting"],"groupNames":["G_PresenceSimulation","gLcn"]},"widgets":[]}]},{"widgetId":"11","type":"Frame","label":"Aussen","icon":"frame","mappings":[],"widgets":[{"widgetId":"1100","type":"Text","label":"Außensteckdose","icon":"poweroutlet","mappings":[],"item":{"link":"https://myopenhab.org/rest/items/lcnRelayVisAussen","state":"OPEN","editable":false,"type":"Contact","name":"lcnRelayVisAussen","label":"Außensteckdose","category":"poweroutlet","tags":[],"groupNames":["gLcn"]},"widgets":[]},{"widgetId":"1101","type":"Switch","label":"Außensteckdose","icon":"poweroutlet","mappings":[],"item":{"link":"https://myopenhab.org/rest/items/lcnRelayAussen","state":"OFF","editable":false,"type":"Switch","name":"lcnRelayAussen","label":"Außensteckdose","category":"poweroutlet","tags":["Switchable"],"groupNames":["gLcn"]},"widgets":[]},{"widgetId":"1102","type":"Switch","label":"Außenlichter Terrassen","icon":"switch","mappings":[],"item":{"link":"https://myopenhab.org/rest/items/lcnLightCommand11A_1","state":"NULL","editable":false,"type":"Switch","name":"lcnLightCommand11A_1","label":"Außenlichter Terrassen","category":"switch","tags":[],"groupNames":[]},"widgets":[]}]},{"widgetId":"12","type":"Frame","label":"Musiccast Wohnzimmer","icon":"frame","mappings":[],"widgets":[{"widgetId":"1200","type":"Switch","label":"Power [ON]","icon":"switch","mappings":[],"item":{"link":"https://myopenhab.org/rest/items/Yamaha_Power","state":"ON","stateDescription":{"pattern":"%s","readOnly":false,"options":[]},"editable":false,"type":"Switch","name":"Yamaha_Power","label":"Power","category":"switch","tags":[],"groupNames":[]},"widgets":[]},{"widgetId":"1201","type":"Switch","label":"Mute [OFF]","icon":"soundvolume_mute","mappings":[],"item":{"link":"https://myopenhab.org/rest/items/Yamaha_Mute","state":"OFF","stateDescription":{"pattern":"%s","readOnly":false,"options":[]},"editable":false,"type":"Switch","name":"Yamaha_Mute","label":"Mute","category":"soundvolume_mute","tags":[],"groupNames":[]},"widgets":[]},{"widgetId":"1202","type":"Slider","label":"Volume [41.0 %]","icon":"soundvolume","mappings":[],"switchSupport":false,"sendFrequency":0,"item":{"link":"https://myopenhab.org/rest/items/Yamaha_Volume","state":"41","stateDescription":{"pattern":"%.1f %%","readOnly":false,"options":[]},"editable":false,"type":"Dimmer","name":"Yamaha_Volume","label":"Volume","category":"soundvolume","tags":[],"groupNames":[]},"widgets":[]}]},{"widgetId":"13","type":"Frame","label":"Jalousien EG","icon":"frame","mappings":[],"widgets":[{"widgetId":"1300","type":"Switch","label":"EGJalousien","icon":"rollershutter","mappings":[],"item":{"link":"https://myopenhab.org/rest/items/EGJalousien","state":"NULL","editable":false,"type":"Rollershutter","name":"EGJalousien","tags":[],"groupNames":[]},"widgets":[]},{"widgetId":"1301","type":"Switch","label":"EGJalousienSued","icon":"rollershutter","mappings":[],"item":{"link":"https://myopenhab.org/rest/items/EGJalousienSued","state":"NULL","editable":false,"type":"Rollershutter","name":"EGJalousienSued","tags":[],"groupNames":[]},"widgets":[]},{"widgetId":"1302","type":"Group","label":"EG Süd","icon":"rollershutter","mappings":[],"item":{"members":[],"link":"https://myopenhab.org/rest/items/gEGJalousienSued","state":"NULL","editable":false,"type":"Group","name":"gEGJalousienSued","label":"EG Jalousien Sued","tags":[],"groupNames":["gEGJalousien","gJalousienSued"]},"linkedPage":{"id":"1302","title":"EG Süd","icon":"rollershutter","link":"https://myopenhab.org/rest/sitemaps/myHome/1302","leaf":true,"timeout":false},"widgets":[]},{"widgetId":"1303","type":"Switch","label":"EGJalousienWest","icon":"rollershutter","mappings":[],"item":{"link":"https://myopenhab.org/rest/items/EGJalousienWest","state":"NULL","editable":false,"type":"Rollershutter","name":"EGJalousienWest","tags":[],"groupNames":[]},"widgets":[]},{"widgetId":"1304","type":"Group","label":"EG West","icon":"rollershutter","mappings":[],"item":{"members":[],"link":"https://myopenhab.org/rest/items/gEGJalousienWest","state":"NULL","editable":false,"type":"Group","name":"gEGJalousienWest","label":"EG Jalousien West","tags":[],"groupNames":["gEGJalousien","gJalousienWest"]},"linkedPage":{"id":"1304","title":"EG West","icon":"rollershutter","link":"https://myopenhab.org/rest/sitemaps/myHome/1304","leaf":true,"timeout":false},"widgets":[]}]},{"widgetId":"14","type":"Frame","label":"Jalousie 1. OG","icon":"frame","mappings":[],"widgets":[{"widgetId":"1400","type":"Switch","label":"KiZ Jalousien","icon":"rollershutter","mappings":[],"item":{"link":"https://myopenhab.org/rest/items/KZJalousien","state":"NULL","editable":false,"type":"Rollershutter","name":"KZJalousien","tags":[],"groupNames":[]},"widgets":[]},{"widgetId":"1401","type":"Group","label":"KiZ","icon":"rollershutter","mappings":[],"item":{"members":[],"link":"https://myopenhab.org/rest/items/gKZJalousien","state":"NULL","editable":false,"type":"Group","name":"gKZJalousien","label":"KiZ Jalousien","tags":[],"groupNames":["gHausJalousien"]},"linkedPage":{"id":"1401","title":"KiZ","icon":"rollershutter","link":"https://myopenhab.org/rest/sitemaps/myHome/1401","leaf":true,"timeout":false},"widgets":[]},{"widgetId":"1402","type":"Switch","label":"Jalousie SZ","icon":"rollershutter","mappings":[],"item":{"link":"https://myopenhab.org/rest/items/lcnJalousieSZ","state":"0.0","editable":false,"type":"Rollershutter","name":"lcnJalousieSZ","label":"Jalousie SZ","tags":[],"groupNames":["gSZ","g1OJalousien","gHausJalousie","gJalousienWest","gLcn"]},"widgets":[]},{"widgetId":"1403","type":"Switch","label":"Jalousie Bad","icon":"rollershutter","mappings":[],"item":{"link":"https://myopenhab.org/rest/items/lcnJalousieBad","state":"NULL","editable":false,"type":"Rollershutter","name":"lcnJalousieBad","label":"Jalousie Bad","tags":[],"groupNames":["gBad","g1OJalousien","gHausJalousie","gLcn"]},"widgets":[]}]},{"widgetId":"15","type":"Frame","label":"Heizung","icon":"frame","mappings":[],"widgets":[{"widgetId":"1500","type":"Switch","label":"Fernsteuerung","icon":"switch","mappings":[{"command":"0","label":"Overwrite"},{"command":"1","label":"Kalender"},{"command":"2","label":"Automatik"}],"item":{"link":"https://myopenhab.org/rest/items/Automatik","state":"1","editable":false,"type":"String","name":"Automatik","tags":[],"groupNames":[]},"widgets":[]},{"widgetId":"1501","type":"Text","label":"Aussentemperatur [1.0 °C]","icon":"temperature","mappings":[],"item":{"link":"https://myopenhab.org/rest/items/TempAussen","state":"1.0","stateDescription":{"pattern":"%.1f °C","readOnly":false,"options":[]},"editable":false,"type":"Number","name":"TempAussen","label":"Aussentemperatur","category":"temperature","tags":[],"groupNames":["gOnewire"]},"widgets":[]},{"widgetId":"1502","type":"Text","label":"Fernverrriegelt? [Nein]","icon":"text","mappings":[],"item":{"link":"https://myopenhab.org/rest/items/oneWireSwitch1","state":"OFF","stateDescription":{"pattern":"%s","readOnly":false,"options":[]},"editable":false,"type":"Switch","name":"oneWireSwitch1","label":"OneWireSwitch 1","tags":[],"groupNames":["gOnewire"]},"widgets":[]},{"widgetId":"1503","type":"Text","label":"NewBindingTest","icon":"text","mappings":[],"item":{"link":"https://myopenhab.org/rest/items/DigitalIOForHeating052CED31000000_DigitalIO0","state":"NULL","stateDescription":{"readOnly":true,"options":[]},"editable":false,"type":"Switch","name":"DigitalIOForHeating052CED31000000_DigitalIO0","label":"Digital I/O 0","tags":[],"groupNames":[]},"widgets":[]},{"widgetId":"1504","type":"Text","label":"Google Kalender Status [-]","icon":"text","mappings":[],"item":{"link":"https://myopenhab.org/rest/items/FernsteuerungHeizung","state":"NULL","editable":false,"type":"Switch","name":"FernsteuerungHeizung","tags":[],"groupNames":[]},"widgets":[]}]},{"widgetId":"16","type":"Frame","label":"Abwesenheit","icon":"frame","mappings":[],"widgets":[{"widgetId":"1600","type":"Switch","label":"Abwesenheitssimulation openhab","icon":"switch","mappings":[],"item":{"link":"https://myopenhab.org/rest/items/PresenceSimulation","state":"NULL","editable":false,"type":"Switch","name":"PresenceSimulation","label":"Abwesenheitssimulation openhab","tags":[],"groupNames":[]},"widgets":[]}]},{"widgetId":"17","type":"Frame","label":"Osram Equipment","icon":"frame","mappings":[],"widgets":[{"widgetId":"1700","type":"Switch","label":"Osram Indoor Plug 01","icon":"switch","mappings":[],"item":{"link":"https://myopenhab.org/rest/items/OSRAMPlug01_Switch","state":"NULL","editable":false,"type":"Switch","name":"OSRAMPlug01_Switch","label":"Osram Indoor Plug 01","tags":[],"groupNames":["gRemoteItems"]},"widgets":[]},{"widgetId":"1701","type":"Switch","label":"Osram Bulb 01","icon":"switch","mappings":[],"item":{"link":"https://myopenhab.org/rest/items/OSRAMClassicA60WClearLIGHTIFY_Switch","state":"NULL","editable":false,"type":"Switch","name":"OSRAMClassicA60WClearLIGHTIFY_Switch","label":"Osram Bulb 01","tags":[],"groupNames":["gRemoteItems"]},"widgets":[]},{"widgetId":"1702","type":"Switch","label":"Osram Outdoor Plug 01","icon":"switch","mappings":[],"item":{"link":"https://myopenhab.org/rest/items/OSRAMPlugOutdoor_Switch","state":"NULL","editable":false,"type":"Switch","name":"OSRAMPlugOutdoor_Switch","label":"Osram Outdoor Plug 01","tags":[],"groupNames":["gRemoteItems"]},"widgets":[]},{"widgetId":"1703","type":"Slider","label":"Osram Bulb 01 Dimmer","icon":"slider","mappings":[],"switchSupport":false,"sendFrequency":0,"item":{"link":"https://myopenhab.org/rest/items/OSRAMClassicA60WClearLIGHTIFY_LevelControl","state":"5","editable":false,"type":"Dimmer","name":"OSRAMClassicA60WClearLIGHTIFY_LevelControl","label":"Osram Bulb 01 Dimmer","tags":[],"groupNames":["gRemoteItems"]},"widgets":[]}]}]} -""".data(using: .utf8)! +""".utf8) -let jsonSitemap2 = """ +let jsonSitemap2 = Data(""" { "id": "grafana", "title": "grafana", @@ -481,7 +481,7 @@ let jsonSitemap2 = """ } ] } -""".data(using: .utf8)! +""".utf8) let jsonSitemap3 = """ [{"name":"myHome","label":"myHome","link":"https://192.168.2.63:8444/rest/sitemaps/myHome","homepage":{"link":"https://192.168.2.63:8444/rest/sitemaps/myHome/myHome","leaf":false,"timeout":false,"widgets":[]}},{"name":"grafana","label":"grafana","link":"https://192.168.2.63:8444/rest/sitemaps/grafana","homepage":{"link":"https://192.168.2.63:8444/rest/sitemaps/grafana/grafana","leaf":false,"timeout":false,"widgets":[]}},{"name":"_default","label":"Home","link":"https://192.168.2.63:8444/rest/sitemaps/_default","homepage":{"link":"https://192.168.2.63:8444/rest/sitemaps/_default/_default","leaf":false,"timeout":false,"widgets":[]}}] diff --git a/OpenHABCore/Tests/OpenHABCoreTests/JSONParserTests.swift b/OpenHABCore/Tests/OpenHABCoreTests/JSONParserTests.swift index 6ea080e5e..0849999e6 100644 --- a/OpenHABCore/Tests/OpenHABCoreTests/JSONParserTests.swift +++ b/OpenHABCore/Tests/OpenHABCoreTests/JSONParserTests.swift @@ -55,7 +55,7 @@ final class JSONParserTests: XCTestCase { } func testJSONItem() { - let json = """ + let json = Data(""" { "link": "https://192.168.2.63:8444/rest/items/lcnLightSwitch5_1", "state": "OFF", @@ -76,7 +76,7 @@ final class JSONParserTests: XCTestCase { "gLcn" ] } - """.data(using: .utf8)! + """.utf8) do { let codingData = try decoder.decode(OpenHABItem.CodingData.self, from: json) @@ -100,7 +100,7 @@ final class JSONParserTests: XCTestCase { } func testJSONWidget() { - let json = """ + let json = Data(""" { "widgetId": "0000", "type": "Switch", @@ -129,7 +129,7 @@ final class JSONParserTests: XCTestCase { }, "widgets": [] } - """.data(using: .utf8)! + """.utf8) do { let codingData = try decoder.decode(OpenHABWidget.CodingData.self, from: json) @@ -168,7 +168,7 @@ final class JSONParserTests: XCTestCase { } func testJSONLinkedPage() { - let json = """ + let json = Data(""" { "id": "1304", "title": "EG West", "icon": "rollershutter", @@ -253,7 +253,7 @@ final class JSONParserTests: XCTestCase { } ] } - """.data(using: .utf8)! + """.utf8) do { let sitemapPageCodingData = try decoder.decode(OpenHABPage.CodingData.self, from: json) let sitemapPage = sitemapPageCodingData.openHABSitemapPage @@ -264,7 +264,7 @@ final class JSONParserTests: XCTestCase { } func testJSONWidgetMapping() { - let json = """ + let json = Data(""" [ { "command": "0", @@ -279,7 +279,7 @@ final class JSONParserTests: XCTestCase { "label": "Automatik" } ] - """.data(using: .utf8)! + """.utf8) do { let codingData = try decoder.decode([OpenHABWidgetMapping].self, from: json) XCTAssertEqual(codingData[0].label, "Overwrite", "WidgetMapping properly parsed") @@ -289,7 +289,7 @@ final class JSONParserTests: XCTestCase { } func testJSONWidget2() { - let json = """ + let json = Data(""" { "widgetId": "01", "type": "Frame", @@ -345,7 +345,7 @@ final class JSONParserTests: XCTestCase { } ] } - """.data(using: .utf8)! + """.utf8) do { let codingData = try decoder.decode(OpenHABWidget.CodingData.self, from: json) XCTAssertEqual(codingData.widgetId, "01", "Widget properly parsed") @@ -380,9 +380,9 @@ final class JSONParserTests: XCTestCase { // swiftlint:disable line_length func testWatchSitemap() { - let json = """ + let json = Data(""" {"name":"watch","label":"watch","link":"https://192.168.2.15:8444/rest/sitemaps/watch","homepage":{"id":"watch","title":"watch","link":"https://192.168.2.15:8444/rest/sitemaps/watch/watch","leaf":false,"timeout":false,"widgets":[{"widgetId":"00","type":"Frame","label":"Ground floor","icon":"frame","mappings":[],"widgets":[{"widgetId":"0000","type":"Switch","label":"Licht Oberlicht","icon":"switch","mappings":[],"item":{"link":"https://192.168.2.15:8444/rest/items/lcnLightSwitch14_1","state":"OFF","editable":false,"type":"Switch","name":"lcnLightSwitch14_1","label":"Licht Oberlicht","tags":["Lighting"],"groupNames":["G_PresenceSimulation","gLcn"]},"widgets":[]},{"widgetId":"0001","type":"Switch","label":"Licht Keller WC Decke","icon":"colorpicker","mappings":[],"item":{"link":"https://192.168.2.15:8444/rest/items/lcnLightSwitch6_1","state":"OFF","editable":false,"type":"Switch","name":"lcnLightSwitch6_1","label":"Licht Keller WC Decke","category":"colorpicker","tags":["Lighting"],"groupNames":["gKellerLicht","gLcn"]},"widgets":[]}]}]}} - """.data(using: .utf8)! + """.utf8) do { let codingData = try decoder.decode(Components.Schemas.SitemapDTO.self, from: json) XCTAssertEqual(codingData.homepage?.link, "https://192.168.2.15:8444/rest/sitemaps/watch/watch", "OpenHABSitemapPage properly parsed") diff --git a/OpenHABCore/Tests/OpenHABCoreTests/OpenHABJSONParserTests.swift b/OpenHABCore/Tests/OpenHABCoreTests/OpenHABJSONParserTests.swift index a54dc1d4a..20438a4ef 100644 --- a/OpenHABCore/Tests/OpenHABCoreTests/OpenHABJSONParserTests.swift +++ b/OpenHABCore/Tests/OpenHABCoreTests/OpenHABJSONParserTests.swift @@ -32,9 +32,9 @@ class OpenHABJSONParserTests: XCTestCase { // [{"_id":"5c4229b41cd87d98382869da","message":"Light Küche was changed","__v":0,"created":"2019-01-18T19:32:04.648Z"}, // swiftlint:disable line_length - let json = """ + let json = Data(""" [{"_id":"5c82e14d2b48ecbf7a7223e0","message":"Light Küche was changed","__v":0,"created":"2019-03-08T21:40:29.412Z"},{"_id":"5c82c9c22b48ecbf7a70f44e","message":"Light Küche was changed","__v":0,"created":"2019-03-08T20:00:02.368Z"},{"_id":"5c82c9c02b48ecbf7a70f42c","message":"Light Küche was changed","__v":0,"created":"2019-03-08T20:00:00.982Z"},{"_id":"5c7fff782b48ecbf7a4dd8e4","message":"Light Küche was changed","__v":0,"created":"2019-03-06T17:12:24.093Z"},{"_id":"5c7ff5c12b48ecbf7a4d56fb","message":"Light Küche was changed","__v":0,"created":"2019-03-06T16:30:57.101Z"},{"_id":"5c7ed0852b48ecbf7a3d2151","message":"Light Küche was changed","__v":0,"created":"2019-03-05T19:39:49.373Z"},{"_id":"5c7d50ba2b48ecbf7a26948f","message":"Light Küche was changed","__v":0,"created":"2019-03-04T16:22:18.473Z"},{"_id":"5c7d50b62b48ecbf7a269455","message":"Light Küche was changed","__v":0,"created":"2019-03-04T16:22:14.321Z"},{"_id":"5c7d50b42b48ecbf7a269442","message":"Light Küche was changed","__v":0,"created":"2019-03-04T16:22:12.468Z"},{"_id":"5c7d507d2b48ecbf7a26916c","message":"Light Küche was changed","__v":0,"created":"2019-03-04T16:21:17.006Z"}] - """.data(using: .utf8)! + """.utf8) // swiftlint:enable line_length do { diff --git a/openHABTestsSwift/OpenHABGeneralTests.swift b/openHABTestsSwift/OpenHABGeneralTests.swift index 38badff9e..9138551cf 100644 --- a/openHABTestsSwift/OpenHABGeneralTests.swift +++ b/openHABTestsSwift/OpenHABGeneralTests.swift @@ -30,7 +30,7 @@ class OpenHABGeneralTests: XCTestCase { } func testHexString() { - let iPhoneData: Data = "Tim iPhone".data(using: .utf8)! + let iPhoneData = Data("Tim iPhone".utf8) let hexWithReduce = iPhoneData.reduce("") { $0 + String(format: "%02X", $1) } XCTAssertEqual(hexWithReduce, "54696D206950686F6E65", "hex properly calculated with reduce") } diff --git a/openHABWatch/Model/ObservableOpenHABSitemapPage.swift b/openHABWatch/Model/ObservableOpenHABSitemapPage.swift index e92bf8f98..edfb6ca36 100644 --- a/openHABWatch/Model/ObservableOpenHABSitemapPage.swift +++ b/openHABWatch/Model/ObservableOpenHABSitemapPage.swift @@ -61,12 +61,6 @@ class ObservableOpenHABSitemapPage: NSObject { extension ObservableOpenHABSitemapPage { struct CodingData: Decodable { - let pageId: String? - let title: String? - let link: String? - let leaf: Bool? - let widgets: [ObservableOpenHABWidget.CodingData]? - private enum CodingKeys: String, CodingKey { case pageId = "id" case title @@ -74,6 +68,12 @@ extension ObservableOpenHABSitemapPage { case leaf case widgets } + + let pageId: String? + let title: String? + let link: String? + let leaf: Bool? + let widgets: [ObservableOpenHABWidget.CodingData]? } } diff --git a/openHABWatch/Views/Utils/PreviewConstants.swift b/openHABWatch/Views/Utils/PreviewConstants.swift index 16e265027..114d99e61 100644 --- a/openHABWatch/Views/Utils/PreviewConstants.swift +++ b/openHABWatch/Views/Utils/PreviewConstants.swift @@ -14,7 +14,7 @@ import Foundation enum PreviewConstants { static let remoteURLString = "http://192.168.2.10:8080" - static let sitemapJson = """ + static let sitemapJson = Data(""" { "id": "watch", "title": "watch", @@ -287,5 +287,5 @@ enum PreviewConstants { } ] } - """.data(using: .utf8)! + """.utf8) } From 2b503d620b1e9ef71704308d6e9bc6a43c476989 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Fri, 30 May 2025 17:30:38 +0200 Subject: [PATCH 198/476] swiftlint call modified Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- openHAB.xcodeproj/project.pbxproj | 24 ------------------------ 1 file changed, 24 deletions(-) diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index b845c2d76..9ba31b175 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -1466,7 +1466,6 @@ runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "cd BuildTools\nSDKROOT=(xcrun --sdk macosx --show-sdk-path)\n\nswift package plugin --allow-writing-to-package-directory --allow-writing-to-directory \"$SRCROOT\" swiftformat \"$SRCROOT\" --config ./.swiftformat --cache /private/tmp/\nswift package plugin --allow-writing-to-package-directory --allow-writing-to-directory ../ swiftlint --cache-path /private/tmp/\n"; - shellScript = "cd BuildTools\nSDKROOT=(xcrun --sdk macosx --show-sdk-path)\n\nswift package plugin --allow-writing-to-package-directory --allow-writing-to-directory \"$SRCROOT\" swiftformat \"$SRCROOT\" --config ./.swiftformat --cache /private/tmp/\nswift package plugin --allow-writing-to-package-directory --allow-writing-to-directory ../ swiftlint --cache-path /private/tmp/ --force-exclude\n\n"; }; /* End PBXShellScriptBuildPhase section */ @@ -1745,7 +1744,6 @@ CODE_SIGN_IDENTITY = "Apple Distribution"; CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 33; - CURRENT_PROJECT_VERSION = 48; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = PBAPXHRAM9; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -1790,7 +1788,6 @@ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "Apple Distribution"; CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 33; - CURRENT_PROJECT_VERSION = 48; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = PBAPXHRAM9; @@ -1833,7 +1830,6 @@ CODE_SIGN_IDENTITY = "Apple Distribution"; CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 33; - CURRENT_PROJECT_VERSION = 48; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = PBAPXHRAM9; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -1883,7 +1879,6 @@ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "Apple Distribution"; CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 33; - CURRENT_PROJECT_VERSION = 48; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = PBAPXHRAM9; @@ -1930,7 +1925,6 @@ CODE_SIGN_IDENTITY = "Apple Distribution"; CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 33; - CURRENT_PROJECT_VERSION = 48; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = PBAPXHRAM9; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -1977,7 +1971,6 @@ CODE_SIGN_STYLE = Manual; COPY_PHASE_STRIP = NO; CURRENT_PROJECT_VERSION = 33; - CURRENT_PROJECT_VERSION = 48; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; @@ -2024,10 +2017,6 @@ CURRENT_PROJECT_VERSION = 33; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = PBAPXHRAM9; - CURRENT_PROJECT_VERSION = 48; - DEBUG_INFORMATION_FORMAT = dwarf; - DEVELOPMENT_TEAM = ""; - "DEVELOPMENT_TEAM[sdk=watchos*]" = PBAPXHRAM9; GCC_C_LANGUAGE_STANDARD = "compiler-default"; GCC_NO_COMMON_BLOCKS = YES; GCC_OPTIMIZATION_LEVEL = 0; @@ -2080,7 +2069,6 @@ CODE_SIGN_STYLE = Manual; COPY_PHASE_STRIP = NO; CURRENT_PROJECT_VERSION = 33; - CURRENT_PROJECT_VERSION = 48; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=watchos*]" = PBAPXHRAM9; @@ -2131,7 +2119,6 @@ CODE_SIGN_IDENTITY = "Apple Distribution"; CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 33; - CURRENT_PROJECT_VERSION = 48; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = PBAPXHRAM9; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -2175,7 +2162,6 @@ CODE_SIGN_STYLE = Manual; COPY_PHASE_STRIP = NO; CURRENT_PROJECT_VERSION = 33; - CURRENT_PROJECT_VERSION = 48; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; @@ -2216,7 +2202,6 @@ CODE_SIGN_IDENTITY = "Apple Distribution"; CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 33; - CURRENT_PROJECT_VERSION = 48; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = PBAPXHRAM9; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -2258,7 +2243,6 @@ "CODE_SIGN_IDENTITY[sdk=watchos*]" = "Apple Distribution"; CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 33; - CURRENT_PROJECT_VERSION = 48; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = D6A95UZXVC; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -2294,7 +2278,6 @@ CODE_SIGN_IDENTITY = "Apple Distribution"; CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 33; - CURRENT_PROJECT_VERSION = 48; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = D6A95UZXVC; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -2333,7 +2316,6 @@ CODE_SIGN_IDENTITY = "Apple Distribution"; CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 33; - CURRENT_PROJECT_VERSION = 48; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = D6A95UZXVC; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -2412,7 +2394,6 @@ GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 16.0; MARKETING_VERSION = 3.0.9; - MARKETING_VERSION = 3.0.10; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; SWIFT_STRICT_CONCURRENCY = complete; @@ -2486,7 +2467,6 @@ GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 16.0; MARKETING_VERSION = 3.0.9; - MARKETING_VERSION = 3.0.10; ONLY_ACTIVE_ARCH = NO; SDKROOT = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; @@ -2525,9 +2505,6 @@ CURRENT_PROJECT_VERSION = 33; DEVELOPMENT_TEAM = PBAPXHRAM9; ENABLE_DEBUG_DYLIB = YES; - CURRENT_PROJECT_VERSION = 48; - DEVELOPMENT_TEAM = ""; - "DEVELOPMENT_TEAM[sdk=iphoneos*]" = PBAPXHRAM9; GCC_PRECOMPILE_PREFIX_HEADER = YES; GCC_PREFIX_HEADER = "openHAB/openHAB-Prefix.pch"; INFOPLIST_FILE = "openHAB/openHAB-Info.plist"; @@ -2590,7 +2567,6 @@ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "Apple Distribution"; CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 33; - CURRENT_PROJECT_VERSION = 48; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = PBAPXHRAM9; ENABLE_DEBUG_DYLIB = YES; From 04370710a5d0d55c409b3cb1dc6fde845a48a01c Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Fri, 30 May 2025 17:58:16 +0200 Subject: [PATCH 199/476] Silence swiftlint warning : disabling opening_brace was superfluous, replace class with static in final classes Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../OpenHABCoreTests/MockURLProtocol.swift | 6 +++--- .../NetworkTrackerTests.swift | 19 +++++++++++++------ openHABTestsSwift/LocalizationTests.swift | 1 - .../OpenHABWatchLaunchTests.swift | 2 +- 4 files changed, 17 insertions(+), 11 deletions(-) diff --git a/OpenHABCore/Tests/OpenHABCoreTests/MockURLProtocol.swift b/OpenHABCore/Tests/OpenHABCoreTests/MockURLProtocol.swift index 6400eb484..0b3046154 100644 --- a/OpenHABCore/Tests/OpenHABCoreTests/MockURLProtocol.swift +++ b/OpenHABCore/Tests/OpenHABCoreTests/MockURLProtocol.swift @@ -35,15 +35,15 @@ final class MockURLProtocol: URLProtocol { return URLSession(configuration: configuration, delegate: self, delegateQueue: nil) }() - override class func canInit(with request: URLRequest) -> Bool { + override static func canInit(with request: URLRequest) -> Bool { true } - override class func canonicalRequest(for request: URLRequest) -> URLRequest { + override static func canonicalRequest(for request: URLRequest) -> URLRequest { request } - override class func requestIsCacheEquivalent(_ a: URLRequest, to b: URLRequest) -> Bool { + override static func requestIsCacheEquivalent(_ a: URLRequest, to b: URLRequest) -> Bool { false } diff --git a/OpenHABCore/Tests/OpenHABCoreTests/NetworkTrackerTests.swift b/OpenHABCore/Tests/OpenHABCoreTests/NetworkTrackerTests.swift index f7bab8681..c679184ae 100644 --- a/OpenHABCore/Tests/OpenHABCoreTests/NetworkTrackerTests.swift +++ b/OpenHABCore/Tests/OpenHABCoreTests/NetworkTrackerTests.swift @@ -162,17 +162,24 @@ final class NetworkTrackerTests: XCTestCase { var cancellables = Set() - // swiftlint:disable:next trailing_closure tracker.$status - .handleEvents(receiveSubscription: { _ in - statusSinkAttached.fulfill() - }) + // swiftlint:disable trailing_closure + .handleEvents( + receiveSubscription: { _ in + statusSinkAttached.fulfill() + }, + receiveOutput: nil, + receiveCompletion: nil, + receiveCancel: nil, + receiveRequest: { _ in } + ) + // swiftlint:enable trailing_closure .dropFirst() - .sink(receiveValue: { status in + .sink { status in if status == .notConnected { becameNotConnected.fulfill() } - }) + } .store(in: &cancellables) // Start tracking first to initialize properly diff --git a/openHABTestsSwift/LocalizationTests.swift b/openHABTestsSwift/LocalizationTests.swift index cb834479b..c3d6e245c 100644 --- a/openHABTestsSwift/LocalizationTests.swift +++ b/openHABTestsSwift/LocalizationTests.swift @@ -42,7 +42,6 @@ class LocalizationTests: XCTestCase { continue } XCTAssertNotEqual(translation, "__MISSING__", "Missing translation for key '\(tuple.key)' in language '\(language)'.") - // swiftlint:disable:next opening_brace let regex = /%(?:\d+\$)?[+-]?(?:[lh]{0,2})(?:[qLztj])?(?:[ 0]|'.{1})?\d*(?:\\.\d?)?[@dDiuUxXoOfeEgGcCsSpaAFn]/ let numberOfMatches = translation.matches(of: regex).count XCTAssertEqual(numberOfMatches, tuple.arguments.count, "Invalid number of format specifiers for key '\(tuple.key)' in language '\(language)'.") diff --git a/openHABWatchSwiftUI Watch AppUITests/OpenHABWatchLaunchTests.swift b/openHABWatchSwiftUI Watch AppUITests/OpenHABWatchLaunchTests.swift index b047737e3..f7000c6b9 100644 --- a/openHABWatchSwiftUI Watch AppUITests/OpenHABWatchLaunchTests.swift +++ b/openHABWatchSwiftUI Watch AppUITests/OpenHABWatchLaunchTests.swift @@ -23,7 +23,7 @@ import XCTest final class OpenHABWatchLaunchTests: XCTestCase { - override class var runsForEachTargetApplicationUIConfiguration: Bool { + override static var runsForEachTargetApplicationUIConfiguration: Bool { true } From dba0a2cce5f0d65faac50bd442260360755fd5af Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Mon, 2 Jun 2025 14:11:55 +0200 Subject: [PATCH 200/476] extract NetworkObserver and NetworkTrackerViewModel into separate files Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- NetworkTrackerViewModel.swift | 72 +++++ .../OpenHABCore/Util/NetworkObserver.swift | 206 ++++++++++++++ .../OpenHABCore/Util/NetworkTracker.swift | 256 +----------------- .../Util/NetworkTrackerViewModel.swift | 74 +++++ openHAB.xcworkspace/contents.xcworkspacedata | 3 + 5 files changed, 356 insertions(+), 255 deletions(-) create mode 100644 NetworkTrackerViewModel.swift create mode 100644 OpenHABCore/Sources/OpenHABCore/Util/NetworkObserver.swift create mode 100644 OpenHABCore/Sources/OpenHABCore/Util/NetworkTrackerViewModel.swift diff --git a/NetworkTrackerViewModel.swift b/NetworkTrackerViewModel.swift new file mode 100644 index 000000000..fdde92f00 --- /dev/null +++ b/NetworkTrackerViewModel.swift @@ -0,0 +1,72 @@ +// Copyright (c) 2010-2025 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 + +@MainActor +public final class NetworkTrackerViewModel: ObservableObject { + @Published public private(set) var activeConnection: ConnectionInfo? + @Published public private(set) var status: NetworkStatus = .connecting + + private let observer: NetworkObserver + + public init(observer: NetworkObserver = .shared) async { + self.observer = observer + await observer.bind(to: self) // ✅ Now allowed + } + + public func startTracking(with configurations: [ConnectionConfiguration]) async { + await observer.startTracking(connectionConfigurations: configurations) + } + + public func send(to item: OpenHABItem, command: String) async throws { + try await observer.send(to: item.name, command: command) + } + + public func updateState(for item: OpenHABItem, state: String) async throws { + try await observer.updateState(for: item.name, state: state) + } + + public func getItems() async throws -> [OpenHABItem] { + try await observer.getItems() + } + + public func getItemByName(id: String) async throws -> OpenHABItem? { + try await observer.getItemByName(id: id) + } + + public func pollDataForPage(sitemapname: String, pageId: String = "", longPolling: Bool = false) async throws -> OpenHABPage? { + try await observer.pollDataForPage(sitemapname: sitemapname, pageId: pageId, longPolling: longPolling) + } + + public func runNow(ruleUID: String, payload: [String: String]) async throws { + try await observer.runNow(ruleUID: ruleUID, payload: payload) + } + + public func resetFailures() async { + await observer.resetFailures() + } + + // Internal API for observer updates + func updateStatus(_ status: NetworkStatus, connection: ConnectionInfo?) { + Task { @MainActor in + self.status = status + self.activeConnection = connection + } + } + + func activeConnectionStream() -> AsyncStream { + AsyncStream { continuation in + let cancellable = self.$activeConnection + .sink { continuation.yield($0) } + + continuation.onTermination = { [cancellable] _ in cancellable.cancel() } + } + } +} diff --git a/OpenHABCore/Sources/OpenHABCore/Util/NetworkObserver.swift b/OpenHABCore/Sources/OpenHABCore/Util/NetworkObserver.swift new file mode 100644 index 000000000..dbdd57376 --- /dev/null +++ b/OpenHABCore/Sources/OpenHABCore/Util/NetworkObserver.swift @@ -0,0 +1,206 @@ +// Copyright (c) 2010-2025 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 Foundation +import Kingfisher +import os.log + +public actor NetworkObserver { + public static let shared = NetworkObserver() + + private var viewModel: NetworkTrackerViewModel? + + private var pathMonitor: any NWPathMonitoring = RealPathMonitor() + private var connectionPool = ConnectionPool() + private var failureTracker = ConnectionFailureTracker() + private var connectionConfigurations: [ConnectionConfiguration] = [] + private var retryTask: Task? + + private let logger = Logger(subsystem: "org.openhab.core", category: "NetworkObserver") + + private let allowUIEffects: Bool + + init(allowUIEffects: Bool = true) { + self.allowUIEffects = allowUIEffects + } + + private static func makeNetworkHandler(for observer: NetworkObserver?) -> @Sendable (Bool) -> Void { + { isConnected in + Task.detached(priority: .utility) { + guard let observer else { return } + await observer.handleNetworkChange(isConnected: isConnected) + } + } + } + + public func bind(to viewModel: NetworkTrackerViewModel) { + self.viewModel = viewModel + } + + public func startTracking(connectionConfigurations: [ConnectionConfiguration]) { + self.connectionConfigurations = connectionConfigurations + + Task { [weak self] in + guard let self else { return } + let handler = Self.makeNetworkHandler(for: self) + await pathMonitor.startMonitoring(handler: handler) + } + + Task { + await self.attemptConnection() + } + } + + private func attemptConnection() async { + guard !connectionConfigurations.isEmpty else { + await updateUI(status: .notConnected, connection: nil) + return + } + + logger.debug("Checking available connections...") + + let sortedConfigs = connectionConfigurations.sorted { $0.priority < $1.priority } + var bestConnection: ConnectionInfo? + var connectedCount = 0 + + await withTaskGroup(of: ConnectionInfo?.self) { group in + for config in sortedConfigs { + group.addTask { + await self.testConnection(configuration: config) + } + } + + for await connectionInfo in group { + guard let connectionInfo else { continue } + connectedCount += 1 + + if connectionInfo.configuration.priority == 0 { + bestConnection = connectionInfo + group.cancelAll() + break + } + + if bestConnection == nil || connectionInfo.configuration.priority < bestConnection!.configuration.priority { + bestConnection = connectionInfo + } + } + } + + let newStatus: NetworkStatus = switch connectedCount { + case 0: .notConnected + case 1: .someConnected + default: .allConnected + } + + await updateUI(status: newStatus, connection: bestConnection) + + if allowUIEffects, let best = bestConnection { + KingfisherManager.shared.defaultOptions = [ + .requestModifier(OpenHABAccessTokenAdapter(connectionConfiguration: best.configuration)) + ] + } else { + await startRetryTask() + } + } + + private func updateUI(status: NetworkStatus, connection: ConnectionInfo?) async { + let viewModel = viewModel + await MainActor.run { + viewModel?.updateStatus(status, connection: connection) + } + } + + private func startRetryTask() async { + retryTask?.cancel() + + let backoffMultiplier = await failureTracker.maxFailureCount() + let safeBackoff = min(backoffMultiplier, 10) + let delay: UInt64 = min(30 * (1 << safeBackoff), 300) + + retryTask = Task.detached { + self.logger.info("Retrying in \(delay) seconds") + try? await Task.sleep(nanoseconds: delay * 1_000_000_000) + if !Task.isCancelled { + await self.attemptConnection() + } + } + } + + private func handleNetworkChange(isConnected: Bool) async { + if isConnected { + await attemptConnection() + } else { + await updateUI(status: .notConnected, connection: nil) + await startRetryTask() + } + } + + private func testConnection(configuration: ConnectionConfiguration) async -> ConnectionInfo? { + guard await failureTracker.shouldAttempt(configuration) else { + logger.info("Skipping \(configuration.url) due to failures") + return nil + } + + do { + logger.info("Testing connection for \(configuration.url)") + let service = try await connectionPool.getOrCreateService(for: configuration) + let version = try await service.getRootVersion() + let info = ConnectionInfo(configuration: configuration, version: version) + + await failureTracker.reset(configuration) + return info + } catch { + await failureTracker.recordFailure(configuration) + logger.info("Connection failed: \(configuration.url): \(error.localizedDescription)") + return nil + } + } + + public func resetFailures() { + Task { await failureTracker.resetAll() } + } + + public func send(to item: String, command: String) async throws { + guard let connection = await viewModel?.activeConnection else { return } + let service = try await connectionPool.getOrCreateService(for: connection.configuration) + try await service.sendItemCommand(itemname: item, command: command) + } + + public func updateState(for item: String, state: String) async throws { + guard let connection = await viewModel?.activeConnection else { return } + let service = try await connectionPool.getOrCreateService(for: connection.configuration) + try await service.updateItemState(itemname: item, with: state) + } + + public func getItems() async throws -> [OpenHABItem] { + guard let connection = await viewModel?.activeConnection else { return [] } + let service = try await connectionPool.getOrCreateService(for: connection.configuration) + return try await service.getItems() + } + + public func getItemByName(id: String) async throws -> OpenHABItem? { + guard let connection = await viewModel?.activeConnection else { return nil } + let service = try await connectionPool.getOrCreateService(for: connection.configuration) + return try await service.getItemByName(id: id) + } + + public func pollDataForPage(sitemapname: String, pageId: String = "", longPolling: Bool = false) async throws -> OpenHABPage? { + guard let connection = await viewModel?.activeConnection else { return nil } + let service = try await connectionPool.getOrCreateService(for: connection.configuration) + return try await service.pollDataForPage(sitemapname: sitemapname, pageId: pageId, longPolling: longPolling) + } + + public func runNow(ruleUID: String, payload: [String: String]) async throws { + guard let connection = await viewModel?.activeConnection else { throw NetworkTrackerError.noActiveConnection } + let service = try await connectionPool.getOrCreateService(for: connection.configuration) + try await service.runNow(ruleUID: ruleUID, payload: payload) + } +} diff --git a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift index 8667f06a9..79f73c9b9 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift @@ -105,261 +105,7 @@ public protocol NetworkTracking: ObservableObject { var activeConnection: ConnectionInfo? { get } } -@MainActor -public final class NetworkTrackerViewModel: ObservableObject { - @Published public private(set) var activeConnection: ConnectionInfo? - @Published public private(set) var status: NetworkStatus = .connecting - - private let observer: NetworkObserver - - public init(observer: NetworkObserver = .shared) async { - self.observer = observer - await observer.bind(to: self) // ✅ Now allowed - } - - public func startTracking(with configurations: [ConnectionConfiguration]) async { - await observer.startTracking(connectionConfigurations: configurations) - } - - public func send(to item: OpenHABItem, command: String) async throws { - try await observer.send(to: item.name, command: command) - } - - public func updateState(for item: OpenHABItem, state: String) async throws { - try await observer.updateState(for: item.name, state: state) - } - - public func getItems() async throws -> [OpenHABItem] { - try await observer.getItems() - } - - public func getItemByName(id: String) async throws -> OpenHABItem? { - try await observer.getItemByName(id: id) - } - - public func pollDataForPage(sitemapname: String, pageId: String = "", longPolling: Bool = false) async throws -> OpenHABPage? { - try await observer.pollDataForPage(sitemapname: sitemapname, pageId: pageId, longPolling: longPolling) - } - - public func runNow(ruleUID: String, payload: [String: String]) async throws { - try await observer.runNow(ruleUID: ruleUID, payload: payload) - } - - public func resetFailures() async { - await observer.resetFailures() - } - - // Internal API for observer updates - func updateStatus(_ status: NetworkStatus, connection: ConnectionInfo?) { - Task { @MainActor in - self.status = status - self.activeConnection = connection - } - } - - func activeConnectionStream() -> AsyncStream { - AsyncStream { continuation in - let cancellable = self.$activeConnection - .sink { continuation.yield($0) } - - continuation.onTermination = { [cancellable] _ in cancellable.cancel() } - } - } -} - -public actor NetworkObserver { - public static let shared = NetworkObserver() - - private var viewModel: NetworkTrackerViewModel? - - private var pathMonitor: any NWPathMonitoring = RealPathMonitor() - private var connectionPool = ConnectionPool() - private var failureTracker = ConnectionFailureTracker() - private var connectionConfigurations: [ConnectionConfiguration] = [] - private var retryTask: Task? - - private let logger = Logger(subsystem: "org.openhab.core", category: "NetworkObserver") - - private let allowUIEffects: Bool - - init(allowUIEffects: Bool = true) { - self.allowUIEffects = allowUIEffects - } - - private static func makeNetworkHandler(for observer: NetworkObserver?) -> @Sendable (Bool) -> Void { - { isConnected in - Task.detached(priority: .utility) { - guard let observer else { return } - await observer.handleNetworkChange(isConnected: isConnected) - } - } - } - - public func bind(to viewModel: NetworkTrackerViewModel) { - self.viewModel = viewModel - } - - public func startTracking(connectionConfigurations: [ConnectionConfiguration]) { - self.connectionConfigurations = connectionConfigurations - - Task { [weak self] in - guard let self else { return } - let handler = Self.makeNetworkHandler(for: self) - await pathMonitor.startMonitoring(handler: handler) - } - - Task { - await self.attemptConnection() - } - } - - private func attemptConnection() async { - guard !connectionConfigurations.isEmpty else { - await updateUI(status: .notConnected, connection: nil) - return - } - - logger.debug("Checking available connections...") - - let sortedConfigs = connectionConfigurations.sorted { $0.priority < $1.priority } - var bestConnection: ConnectionInfo? - var connectedCount = 0 - - await withTaskGroup(of: ConnectionInfo?.self) { group in - for config in sortedConfigs { - group.addTask { - await self.testConnection(configuration: config) - } - } - - for await connectionInfo in group { - guard let connectionInfo else { continue } - connectedCount += 1 - - if connectionInfo.configuration.priority == 0 { - bestConnection = connectionInfo - group.cancelAll() - break - } - - if bestConnection == nil || connectionInfo.configuration.priority < bestConnection!.configuration.priority { - bestConnection = connectionInfo - } - } - } - - let newStatus: NetworkStatus = switch connectedCount { - case 0: .notConnected - case 1: .someConnected - default: .allConnected - } - - await updateUI(status: newStatus, connection: bestConnection) - - if allowUIEffects, let best = bestConnection { - KingfisherManager.shared.defaultOptions = [ - .requestModifier(OpenHABAccessTokenAdapter(connectionConfiguration: best.configuration)) - ] - } else { - await startRetryTask() - } - } - - private func updateUI(status: NetworkStatus, connection: ConnectionInfo?) async { - let viewModel = viewModel - await MainActor.run { - viewModel?.updateStatus(status, connection: connection) - } - } - - private func startRetryTask() async { - retryTask?.cancel() - - let backoffMultiplier = await failureTracker.maxFailureCount() - let safeBackoff = min(backoffMultiplier, 10) - let delay: UInt64 = min(30 * (1 << safeBackoff), 300) - - retryTask = Task.detached { - self.logger.info("Retrying in \(delay) seconds") - try? await Task.sleep(nanoseconds: delay * 1_000_000_000) - if !Task.isCancelled { - await self.attemptConnection() - } - } - } - - private func handleNetworkChange(isConnected: Bool) async { - if isConnected { - await attemptConnection() - } else { - await updateUI(status: .notConnected, connection: nil) - await startRetryTask() - } - } - - private func testConnection(configuration: ConnectionConfiguration) async -> ConnectionInfo? { - guard await failureTracker.shouldAttempt(configuration) else { - logger.info("Skipping \(configuration.url) due to failures") - return nil - } - - do { - logger.info("Testing connection for \(configuration.url)") - let service = try await connectionPool.getOrCreateService(for: configuration) - let version = try await service.getRootVersion() - let info = ConnectionInfo(configuration: configuration, version: version) - - await failureTracker.reset(configuration) - return info - } catch { - await failureTracker.recordFailure(configuration) - logger.info("Connection failed: \(configuration.url): \(error.localizedDescription)") - return nil - } - } - - public func resetFailures() { - Task { await failureTracker.resetAll() } - } - - public func send(to item: String, command: String) async throws { - guard let connection = await viewModel?.activeConnection else { return } - let service = try await connectionPool.getOrCreateService(for: connection.configuration) - try await service.sendItemCommand(itemname: item, command: command) - } - - public func updateState(for item: String, state: String) async throws { - guard let connection = await viewModel?.activeConnection else { return } - let service = try await connectionPool.getOrCreateService(for: connection.configuration) - try await service.updateItemState(itemname: item, with: state) - } - - public func getItems() async throws -> [OpenHABItem] { - guard let connection = await viewModel?.activeConnection else { return [] } - let service = try await connectionPool.getOrCreateService(for: connection.configuration) - return try await service.getItems() - } - - public func getItemByName(id: String) async throws -> OpenHABItem? { - guard let connection = await viewModel?.activeConnection else { return nil } - let service = try await connectionPool.getOrCreateService(for: connection.configuration) - return try await service.getItemByName(id: id) - } - - public func pollDataForPage(sitemapname: String, pageId: String = "", longPolling: Bool = false) async throws -> OpenHABPage? { - guard let connection = await viewModel?.activeConnection else { return nil } - let service = try await connectionPool.getOrCreateService(for: connection.configuration) - return try await service.pollDataForPage(sitemapname: sitemapname, pageId: pageId, longPolling: longPolling) - } - - public func runNow(ruleUID: String, payload: [String: String]) async throws { - guard let connection = await viewModel?.activeConnection else { throw NetworkTrackerError.noActiveConnection } - let service = try await connectionPool.getOrCreateService(for: connection.configuration) - try await service.runNow(ruleUID: ruleUID, payload: payload) - } -} - -// @available(*, deprecated) +@available(*, deprecated) public final class NetworkTracker: ObservableObject { public static let shared = NetworkTracker() diff --git a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTrackerViewModel.swift b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTrackerViewModel.swift new file mode 100644 index 000000000..714886665 --- /dev/null +++ b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTrackerViewModel.swift @@ -0,0 +1,74 @@ +// Copyright (c) 2010-2025 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 + +@preconcurrency import Combine + +@MainActor +public final class NetworkTrackerViewModel: ObservableObject { + @Published public private(set) var activeConnection: ConnectionInfo? + @Published public private(set) var status: NetworkStatus = .connecting + + private let observer: NetworkObserver + + public init(observer: NetworkObserver = .shared) async { + self.observer = observer + await observer.bind(to: self) // ✅ Now allowed + } + + public func startTracking(with configurations: [ConnectionConfiguration]) async { + await observer.startTracking(connectionConfigurations: configurations) + } + + public func send(to item: OpenHABItem, command: String) async throws { + try await observer.send(to: item.name, command: command) + } + + public func updateState(for item: OpenHABItem, state: String) async throws { + try await observer.updateState(for: item.name, state: state) + } + + public func getItems() async throws -> [OpenHABItem] { + try await observer.getItems() + } + + public func getItemByName(id: String) async throws -> OpenHABItem? { + try await observer.getItemByName(id: id) + } + + public func pollDataForPage(sitemapname: String, pageId: String = "", longPolling: Bool = false) async throws -> OpenHABPage? { + try await observer.pollDataForPage(sitemapname: sitemapname, pageId: pageId, longPolling: longPolling) + } + + public func runNow(ruleUID: String, payload: [String: String]) async throws { + try await observer.runNow(ruleUID: ruleUID, payload: payload) + } + + public func resetFailures() async { + await observer.resetFailures() + } + + // Internal API for observer updates + func updateStatus(_ status: NetworkStatus, connection: ConnectionInfo?) { + Task { @MainActor in + self.status = status + self.activeConnection = connection + } + } + + func activeConnectionStream() -> AsyncStream { + AsyncStream { continuation in + let cancellable = self.$activeConnection + .sink { continuation.yield($0) } + + continuation.onTermination = { [cancellable] _ in cancellable.cancel() } + } + } +} diff --git a/openHAB.xcworkspace/contents.xcworkspacedata b/openHAB.xcworkspace/contents.xcworkspacedata index da2e8a2db..42475e0a4 100644 --- a/openHAB.xcworkspace/contents.xcworkspacedata +++ b/openHAB.xcworkspace/contents.xcworkspacedata @@ -7,4 +7,7 @@ + + From 33cf3f5b7426af94c743270af0308e9573c3fa11 Mon Sep 17 00:00:00 2001 From: Tassilo Karge Date: Fri, 30 May 2025 23:28:16 +0200 Subject: [PATCH 201/476] enable storing multiple sets of preferences Signed-off-by: Tassilo Karge --- .../OpenHABCore/Util/Preferences.swift | 76 ++++++++++++++++++- 1 file changed, 75 insertions(+), 1 deletion(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift index 355330082..a523d3d76 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift @@ -17,6 +17,7 @@ import UIKit public struct UserDefault { private let key: String private let defaultValue: T + private let store: Bool private let subject: CurrentValueSubject public var wrappedValue: T { @@ -25,6 +26,11 @@ public struct UserDefault { } set { Preferences.sharedDefaults.set(newValue, forKey: key) + Preferences.change(storedPreference: key, to: newValue) + } + if store { + Preferences.change(storedPreference: key, to: newValue) + } DispatchQueue.main.async { [subject] in subject.send(newValue) } @@ -35,9 +41,10 @@ public struct UserDefault { subject.eraseToAnyPublisher() } - public init(_ key: String, defaultValue: T) { + public init(_ key: String, defaultValue: T, store: Bool = true) { self.key = key self.defaultValue = defaultValue + self.store = store let currentValue = Preferences.sharedDefaults.object(forKey: key) as? T ?? defaultValue subject = CurrentValueSubject(currentValue) } @@ -100,6 +107,7 @@ public struct UserDefaultURL { } set { Preferences.sharedDefaults.set(newValue, forKey: key) + Preferences.change(storedPreference: key, to: newValue) let defaultValue = defaultValue // Trim and validate the new URL let trimmedUri = newValue.removeTrailingSlashes().trimmingCharacters(in: .whitespacesAndNewlines) @@ -152,13 +160,79 @@ public enum Preferences { @UserDefaultObject("remoteConnectionConfig", defaultValue: ConnectionConfiguration.remoteDefault) public static var remoteConnectionConfig: ConnectionConfiguration @UserDefault("sitemapForWatchLabel", defaultValue: "watch") public static var sitemapForWatchLabel: String + /// settings for different homes TODO come up with better name + @UserDefault("storedPreferences", defaultValue: [:], store: false) public static var storedPreferences: [String: [String: Any]] + // MARK: - Private + /// the currently applied settings set from storedPreferences + @UserDefault("currentlyUsedSettings", defaultValue: "", store: false) private static var currentlyUsedSettings: String + @UserDefault("didMigrateToSharedDefaults", defaultValue: false) private static var didMigrateToSharedDefaults: Bool @UserDefault("didMigrateToConnectionConfig", defaultValue: false) private static var didMigrateToConnectionConfig: Bool @UserDefault("currentWebViewPath", defaultValue: "") public static var currentWebViewPath: String } +public extension Preferences { + static func switchCurrentlyUsedSettings(to name: String) { + guard !storedPreferences.isEmpty, let stored = storedPreferences[name] else { + // we have not stored our settings in that list yet + return + } + + Preferences.currentlyUsedSettings = name + + // TODO: not pretty to repeat everything here + Preferences.defaultView = stored["defaultView"] as! String + Preferences.localUrl = stored["localUrl"] as! String + Preferences.remoteUrl = stored["remoteUrl"] as! String + Preferences.username = stored["username"] as! String + Preferences.password = stored["password"] as! String + Preferences.alwaysSendCreds = stored["alwaysSendCreds"] as! Bool + Preferences.ignoreSSL = stored["ignoreSSL"] as! Bool + Preferences.demomode = stored["demomode"] as! Bool + Preferences.idleOff = stored["idleOff"] as! Bool + Preferences.realTimeSliders = stored["realTimeSliders"] as! Bool + Preferences.iconType = stored["iconType"] as! Int + Preferences.defaultSitemap = stored["defaultSitemap"] as! String + Preferences.sendCrashReports = stored["sendCrashReports"] as! Bool + Preferences.sortSitemapsby = stored["sortSitemapsby"] as! Int + Preferences.defaultMainUIPath = stored["defaultMainUIPath"] as! String + Preferences.alwaysAllowWebRTC = stored["alwaysAllowWebRTC"] as! Bool + Preferences.sitemapForWatch = stored["sitemapForWatch"] as! String + } + + fileprivate static func change(storedPreference: String, to newValue: Any) { + guard var stored = storedPreferences[currentlyUsedSettings] else { + storeCurrentPreferences() + return + } + stored[storedPreference] = newValue + } + + private static func storeCurrentPreferences() { + storedPreferences[currentlyUsedSettings] = [ + "defaultView": Preferences.defaultView, + "localUrl": Preferences.localUrl, + "remoteUrl": Preferences.remoteUrl, + "username": Preferences.username, + "password": Preferences.password, + "alwaysSendCreds": Preferences.alwaysSendCreds, + "ignoreSSL": Preferences.ignoreSSL, + "demomode": Preferences.demomode, + "idleOff": Preferences.idleOff, + "realTimeSliders": Preferences.realTimeSliders, + "iconType": Preferences.iconType, + "defaultSitemap": Preferences.defaultSitemap, + "sendCrashReports": Preferences.sendCrashReports, + "sortSitemapsby": Preferences.sortSitemapsby, + "defaultMainUIPath": Preferences.defaultMainUIPath, + "alwaysAllowWebRTC": Preferences.alwaysAllowWebRTC, + "sitemapForWatch": Preferences.sitemapForWatch + ] + } +} + public extension Preferences { static func migrateUserDefaultsIfRequired() { guard !didMigrateToSharedDefaults else { return } From e8de812d7b8cdd41685d404a6698660850390f5e Mon Sep 17 00:00:00 2001 From: Tassilo Karge Date: Sat, 31 May 2025 09:36:05 +0200 Subject: [PATCH 202/476] add option for home selection to drawer menu Signed-off-by: Tassilo Karge --- openHAB/DrawerView.swift | 80 ++++++++++++------------- openHAB/OpenHABRootViewController.swift | 8 +++ 2 files changed, 45 insertions(+), 43 deletions(-) diff --git a/openHAB/DrawerView.swift b/openHAB/DrawerView.swift index 592a5a414..2d6ce5518 100644 --- a/openHAB/DrawerView.swift +++ b/openHAB/DrawerView.swift @@ -81,24 +81,16 @@ struct ConnectionView: View { } struct DrawerView: View { - struct MainSectionView: View { - var openHABIconwidth: CGFloat - var onDismiss: (TargetController) -> Void - var dismiss: DismissAction + struct MainSectionView: View { + var menuEntry: (Image, Text, TargetController) -> MenuEntry var body: some View { Section(header: Text("Main")) { - HStack { - Image("openHABIcon") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: openHABIconwidth) - Text("Home") - } - .onTapGesture { - dismiss() - onDismiss(.webview) - } + menuEntry( + Image("openHABIcon"), + Text("Home"), + .webview + ) } } } @@ -200,40 +192,28 @@ struct DrawerView: View { } } - struct SystemSectionView: View { - var openHABIconwidth: CGFloat - var onDismiss: (TargetController) -> Void - var dismiss: DismissAction + struct SystemSectionView: View { + var menuEntry: (Image, Text, TargetController) -> MenuEntry var body: some View { Section(header: Text("System")) { - HStack { - Image(systemSymbol: .gear) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: openHABIconwidth) - Text(LocalizedStringKey("settings")) - } - .onTapGesture { - dismiss() - onDismiss(.settings) - } + settingsMenuEntry(image: .gear, text: "settings", goTo: .settings) if Preferences.getLowestPriorityOpenHABConnection() != nil, !Preferences.demomode { - HStack { - Image(systemSymbol: .bell) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: openHABIconwidth) - Text(LocalizedStringKey("notifications")) - } - .onTapGesture { - dismiss() - onDismiss(.notifications) - } + settingsMenuEntry(image: .bell, text: "notifications", goTo: .notifications) } + + settingsMenuEntry(image: .house, text: "homeSelection", goTo: .homeSelection) } } + + private func settingsMenuEntry(image: SFSymbol, text: String, goTo target: TargetController) -> MenuEntry { + menuEntry( + Image(systemSymbol: image), + Text(LocalizedStringKey(text)), + target + ) + } } @State private var sitemaps: [OpenHABSitemap] = [] @@ -255,13 +235,13 @@ struct DrawerView: View { var body: some View { VStack { List { - MainSectionView(openHABIconwidth: openHABIconwidth, onDismiss: onDismiss, dismiss: dismiss) + MainSectionView(menuEntry: menuEntry) TilesSectionView(uiTiles: uiTiles, tilesIconwidth: tilesIconwidth, onDismiss: onDismiss, dismiss: dismiss) SitemapsSectionView(sitemaps: sitemaps, sitemapIconwidth: sitemapIconwidth, sitemapForWatch: $sitemapForWatch, onDismiss: onDismiss, dismiss: dismiss) - SystemSectionView(openHABIconwidth: openHABIconwidth, onDismiss: onDismiss, dismiss: dismiss) + SystemSectionView(menuEntry: menuEntry) } .listStyle(.inset) @@ -282,6 +262,20 @@ struct DrawerView: View { } } + private func menuEntry(image: Image, text: Text, goTo target: TargetController) -> some View { + HStack { + image + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: openHABIconwidth) + text + } + .onTapGesture { + dismiss() + onDismiss(target) + } + } + private func updateSitemapsAndUITiles(activeConnection: ConnectionInfo?) async { guard let activeConnection else { return } diff --git a/openHAB/OpenHABRootViewController.swift b/openHAB/OpenHABRootViewController.swift index 10585fe6e..9cedb627e 100644 --- a/openHAB/OpenHABRootViewController.swift +++ b/openHAB/OpenHABRootViewController.swift @@ -27,6 +27,7 @@ enum TargetController { case notifications case browser(String) case tile(String) + case homeSelection } protocol ModalHandler: AnyObject { @@ -312,6 +313,10 @@ class OpenHABRootViewController: UIViewController { SideMenuManager.default.rightMenuNavigationController?.dismiss(animated: true) { self.modalDismissed(to: .tile(urlString)) } + case .homeSelection: + SideMenuManager.default.rightMenuNavigationController?.dismiss(animated: true) { + self.modalDismissed(to: .settings) + } } } @@ -661,6 +666,9 @@ extension OpenHABRootViewController: ModalHandler { break case let .tile(urlString): openTileURL(urlString) + case .homeSelection: + let hostingController = UIHostingController(rootView: SettingsView()) + navigationController?.pushViewController(hostingController, animated: true) } } } From a90dfc258ac19a5f3ebdb0b0b889f1aa092a75a7 Mon Sep 17 00:00:00 2001 From: Tassilo Karge Date: Sun, 1 Jun 2025 01:43:05 +0200 Subject: [PATCH 203/476] view to switch between homes started Signed-off-by: Tassilo Karge --- .../OpenHABCore/Util/Preferences.swift | 80 ++++++++++++------- openHAB.xcodeproj/project.pbxproj | 9 +++ openHAB/HomeSelectionView.swift | 69 ++++++++++++++++ openHAB/OpenHABRootViewController.swift | 5 +- .../Resources/de.lproj/Localizable.strings | 1 + .../Resources/en.lproj/Localizable.strings | 1 + 6 files changed, 132 insertions(+), 33 deletions(-) create mode 100644 openHAB/HomeSelectionView.swift diff --git a/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift index a523d3d76..7eeb51c76 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift @@ -29,7 +29,7 @@ public struct UserDefault { Preferences.change(storedPreference: key, to: newValue) } if store { - Preferences.change(storedPreference: key, to: newValue) + Preferences.storeCurrentPreferences() } DispatchQueue.main.async { [subject] in subject.send(newValue) @@ -107,7 +107,7 @@ public struct UserDefaultURL { } set { Preferences.sharedDefaults.set(newValue, forKey: key) - Preferences.change(storedPreference: key, to: newValue) + Preferences.storeCurrentPreferences() let defaultValue = defaultValue // Trim and validate the new URL let trimmedUri = newValue.removeTrailingSlashes().trimmingCharacters(in: .whitespacesAndNewlines) @@ -159,14 +159,15 @@ public enum Preferences { @UserDefaultObject("localConnectionConfig", defaultValue: ConnectionConfiguration.localDefault) public static var localConnectionConfig: ConnectionConfiguration @UserDefaultObject("remoteConnectionConfig", defaultValue: ConnectionConfiguration.remoteDefault) public static var remoteConnectionConfig: ConnectionConfiguration @UserDefault("sitemapForWatchLabel", defaultValue: "watch") public static var sitemapForWatchLabel: String + @UserDefault("homeName", defaultValue: "Home") public static var homeName: String /// settings for different homes TODO come up with better name - @UserDefault("storedPreferences", defaultValue: [:], store: false) public static var storedPreferences: [String: [String: Any]] + @UserDefault("storedPreferences", defaultValue: [:], store: false) public static var storedPreferences: [String: NSDictionary] // MARK: - Private /// the currently applied settings set from storedPreferences - @UserDefault("currentlyUsedSettings", defaultValue: "", store: false) private static var currentlyUsedSettings: String + @UserDefault("currentlyUsedSettings", defaultValue: UUID().uuidString, store: false) private static var currentlyUsedSettings: String @UserDefault("didMigrateToSharedDefaults", defaultValue: false) private static var didMigrateToSharedDefaults: Bool @UserDefault("didMigrateToConnectionConfig", defaultValue: false) private static var didMigrateToConnectionConfig: Bool @@ -174,44 +175,59 @@ public enum Preferences { } public extension Preferences { - static func switchCurrentlyUsedSettings(to name: String) { - guard !storedPreferences.isEmpty, let stored = storedPreferences[name] else { + static func listStoredPreferences() -> [UUID] { + initializeStoredPreferences() + let preferenceIds = storedPreferences + .sorted { e1, e2 in + (e1.value["homeName"] as? String ?? "") <= (e2.value["homeName"] as? String ?? "") + } + .map(\.key) + return preferenceIds.compactMap { UUID(uuidString: $0) } + } + + static func switchCurrentlyUsedSettings(to settingsId: UUID) { + initializeStoredPreferences() + + let settingsIdString = settingsId.uuidString + + guard let stored = storedPreferences[settingsIdString] else { // we have not stored our settings in that list yet return } - Preferences.currentlyUsedSettings = name + Preferences.currentlyUsedSettings = settingsIdString // TODO: not pretty to repeat everything here - Preferences.defaultView = stored["defaultView"] as! String - Preferences.localUrl = stored["localUrl"] as! String - Preferences.remoteUrl = stored["remoteUrl"] as! String - Preferences.username = stored["username"] as! String - Preferences.password = stored["password"] as! String - Preferences.alwaysSendCreds = stored["alwaysSendCreds"] as! Bool - Preferences.ignoreSSL = stored["ignoreSSL"] as! Bool - Preferences.demomode = stored["demomode"] as! Bool - Preferences.idleOff = stored["idleOff"] as! Bool - Preferences.realTimeSliders = stored["realTimeSliders"] as! Bool - Preferences.iconType = stored["iconType"] as! Int - Preferences.defaultSitemap = stored["defaultSitemap"] as! String - Preferences.sendCrashReports = stored["sendCrashReports"] as! Bool - Preferences.sortSitemapsby = stored["sortSitemapsby"] as! Int - Preferences.defaultMainUIPath = stored["defaultMainUIPath"] as! String - Preferences.alwaysAllowWebRTC = stored["alwaysAllowWebRTC"] as! Bool - Preferences.sitemapForWatch = stored["sitemapForWatch"] as! String + Preferences.defaultView = stored["defaultView"] as? String ?? "web" + Preferences.localUrl = stored["localUrl"] as? String ?? "" + Preferences.remoteUrl = stored["remoteUrl"] as? String ?? "https://myopenhab.org" + Preferences.username = stored["username"] as? String ?? "test" + Preferences.password = stored["password"] as? String ?? "test" + Preferences.alwaysSendCreds = stored["alwaysSendCreds"] as? Bool ?? false + Preferences.ignoreSSL = stored["ignoreSSL"] as? Bool ?? false + Preferences.demomode = stored["demomode"] as? Bool ?? true + Preferences.idleOff = stored["idleOff"] as? Bool ?? false + Preferences.realTimeSliders = stored["realTimeSliders"] as? Bool ?? false + Preferences.iconType = stored["iconType"] as? Int ?? 0 + Preferences.defaultSitemap = stored["defaultSitemap"] as? String ?? "demo" + Preferences.sendCrashReports = stored["sendCrashReports"] as? Bool ?? false + Preferences.sortSitemapsby = stored["sortSitemapsby"] as? Int ?? 0 + Preferences.defaultMainUIPath = stored["defaultMainUIPath"] as? String ?? "" + Preferences.alwaysAllowWebRTC = stored["alwaysAllowWebRTC"] as? Bool ?? false + Preferences.sitemapForWatch = stored["sitemapForWatch"] as? String ?? "watch" + Preferences.homeName = stored["homeName"] as? String ?? "Home" } - fileprivate static func change(storedPreference: String, to newValue: Any) { - guard var stored = storedPreferences[currentlyUsedSettings] else { + private static func initializeStoredPreferences() { + if storedPreferences.isEmpty { storeCurrentPreferences() - return } - stored[storedPreference] = newValue } - private static func storeCurrentPreferences() { - storedPreferences[currentlyUsedSettings] = [ + static func storeCurrentPreferences() { + // TODO: not pretty to repeat everything here + var stored = storedPreferences + stored[currentlyUsedSettings] = [ "defaultView": Preferences.defaultView, "localUrl": Preferences.localUrl, "remoteUrl": Preferences.remoteUrl, @@ -228,8 +244,10 @@ public extension Preferences { "sortSitemapsby": Preferences.sortSitemapsby, "defaultMainUIPath": Preferences.defaultMainUIPath, "alwaysAllowWebRTC": Preferences.alwaysAllowWebRTC, - "sitemapForWatch": Preferences.sitemapForWatch + "sitemapForWatch": Preferences.sitemapForWatch, + "homeName": Preferences.homeName ] + storedPreferences = stored } } diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index 9ba31b175..1b9dfdff8 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -9,6 +9,7 @@ /* Begin PBXBuildFile section */ 1224F78F228A89FD00750965 /* WatchMessageService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1224F78D228A89FC00750965 /* WatchMessageService.swift */; }; 2F6412EE2CE494A80039FB28 /* DatePickerUITableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F6412ED2CE494A80039FB28 /* DatePickerUITableViewCell.swift */; }; + 2FBCF58C2DEB0B7700CD5D83 /* HomeSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FBCF58B2DEB0B7700CD5D83 /* HomeSelectionView.swift */; }; 2FEFD8F62BE7C5BE00E387B9 /* TextInputUITableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FEFD8F52BE7C5BE00E387B9 /* TextInputUITableViewCell.swift */; }; 4D6470DA2561F935007B03FC /* openHABIntents.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 4D6470D32561F935007B03FC /* openHABIntents.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 653B54C0285C0AC700298ECD /* OpenHABRootViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 653B54BF285C0AC700298ECD /* OpenHABRootViewController.swift */; }; @@ -276,6 +277,7 @@ /* Begin PBXFileReference section */ 1224F78D228A89FC00750965 /* WatchMessageService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WatchMessageService.swift; sourceTree = ""; }; 2F6412ED2CE494A80039FB28 /* DatePickerUITableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatePickerUITableViewCell.swift; sourceTree = ""; }; + 2FBCF58B2DEB0B7700CD5D83 /* HomeSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeSelectionView.swift; sourceTree = ""; }; 2FEFD8F52BE7C5BE00E387B9 /* TextInputUITableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextInputUITableViewCell.swift; sourceTree = ""; }; 4D38D951256897490039DA6E /* SetNumberValueIntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetNumberValueIntentHandler.swift; sourceTree = ""; }; 4D38D959256897770039DA6E /* SetStringValueIntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetStringValueIntentHandler.swift; sourceTree = ""; }; @@ -929,6 +931,12 @@ DA2AEB6F2D92CF3E00897D80 /* UITableViewCellExtension.swift */, DA48001F2D837CD8009CF127 /* SettingsView */, 1224F78B228A89E300750965 /* Watch */, + 2FBCF58B2DEB0B7700CD5D83 /* HomeSelectionView.swift */, + DA242C612C83588600AFB10D /* SettingsView.swift */, + DA5ED9BF2C8509C2004875E0 /* ClientCertificatesView.swift */, + DA9F81862C85020F00B47B72 /* RTFTextView.swift */, + DA6B2EF02C87B59000DF77CF /* NotificationsView.swift */, + DA6B2EEE2C861BC900DF77CF /* DrawerView.swift */, DF4B84101886DA9900F34902 /* Widgets */, DFFD8FCE18EDBD30003B502A /* Util */, ); @@ -1586,6 +1594,7 @@ DF06F1FC18FEC2020011E7B9 /* ColorPickerViewController.swift in Sources */, DA4642322D7EE6CA006C3908 /* LoggerView.swift in Sources */, 1224F78F228A89FD00750965 /* WatchMessageService.swift in Sources */, + 2FBCF58C2DEB0B7700CD5D83 /* HomeSelectionView.swift in Sources */, DAA42BAC21DC984A00244B2A /* WebUITableViewCell.swift in Sources */, DF4B84131886DAC400F34902 /* FrameUITableViewCell.swift in Sources */, DF4B84161886EACA00F34902 /* GenericUITableViewCell.swift in Sources */, diff --git a/openHAB/HomeSelectionView.swift b/openHAB/HomeSelectionView.swift new file mode 100644 index 000000000..033abeead --- /dev/null +++ b/openHAB/HomeSelectionView.swift @@ -0,0 +1,69 @@ +// Copyright (c) 2010-2025 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 FirebaseCrashlytics +import Kingfisher +import OpenHABCore +import os +import SafariServices +import SFSafeSymbols +import SwiftUI +import WebKit + +struct HomeSelectionView: View { + @State private var showingCacheAlert = false + @State private var showCrashReportingAlert = false + @State private var showUselastPathAlert = false + + @State private var hasBeenLoaded = false + + @State private var homes: [UUID] = [] + + @Environment(\.dismiss) private var dismiss + + var appData: OpenHABDataObject? { + AppDelegate.appDelegate.appData + } + + private let logger = Logger(subsystem: "org.openhab.app", category: "SettingsView") + + var body: some View { + Form { + List(homes, id: \.self) { + Text(Preferences.storedPreferences[$0.uuidString]?["homeName"] as? String ?? "") + // TODO: selection of name in list changes settings + // TODO: options like remove, rename (or should we rename in settings?) + } + } + .onAppear { + homes = Preferences.listStoredPreferences() + } + .formStyle(.grouped) + .navigationBarTitle("homeSelection") + .toolbar { + ToolbarItemGroup(placement: .primaryAction) { + Button(action: { + addHome() + }, label: { + Image(systemSymbol: .plus) + }) + } + } + } + + private func addHome() { + // TODO: alert to insert name for home, store and dismiss + } +} + +#Preview { + HomeSelectionView() +} diff --git a/openHAB/OpenHABRootViewController.swift b/openHAB/OpenHABRootViewController.swift index 9cedb627e..24d1d1b2e 100644 --- a/openHAB/OpenHABRootViewController.swift +++ b/openHAB/OpenHABRootViewController.swift @@ -314,8 +314,9 @@ class OpenHABRootViewController: UIViewController { self.modalDismissed(to: .tile(urlString)) } case .homeSelection: + print("Dismissed to Home Selection") SideMenuManager.default.rightMenuNavigationController?.dismiss(animated: true) { - self.modalDismissed(to: .settings) + self.modalDismissed(to: .homeSelection) } } } @@ -667,7 +668,7 @@ extension OpenHABRootViewController: ModalHandler { case let .tile(urlString): openTileURL(urlString) case .homeSelection: - let hostingController = UIHostingController(rootView: SettingsView()) + let hostingController = UIHostingController(rootView: HomeSelectionView()) navigationController?.pushViewController(hostingController, animated: true) } } diff --git a/openHAB/Resources/de.lproj/Localizable.strings b/openHAB/Resources/de.lproj/Localizable.strings index 430832131..1d29d403d 100644 --- a/openHAB/Resources/de.lproj/Localizable.strings +++ b/openHAB/Resources/de.lproj/Localizable.strings @@ -61,6 +61,7 @@ "oh_secret" = "openHAB-Secret"; "notifications" = "Benachrichtigungen"; "settings" = "Einstellungen"; +"homeSelection" = "Auswahl des Zuhauses"; "privacy_policy" = "Datenschutzerklärung"; "crash_reporting_info" = "Durch die Aktivierung der Absturzberichterstattung stimmen Sie zu, dass Informationen zu Ihrem Gerät und der Nutzung der App erfasst und mit Crashlytics (einem Unternehmen von Google) geteilt werden. Weitere Informationen finden Sie in unserer Datenschutzerklärung."; "activate" = "Aktivieren"; diff --git a/openHAB/Resources/en.lproj/Localizable.strings b/openHAB/Resources/en.lproj/Localizable.strings index 70690af13..a4030f97f 100644 --- a/openHAB/Resources/en.lproj/Localizable.strings +++ b/openHAB/Resources/en.lproj/Localizable.strings @@ -61,6 +61,7 @@ "oh_secret" = "openHAB Secret"; "notifications" = "Notifications"; "settings" = "Settings"; +"homeSelection" = "Home Selection"; "privacy_policy" = "Privacy Policy"; "crash_reporting_info" = "By activating crash reporting you agree that device and usage information will be collected and shared with Crashlytics (a Google company). For further information view our privacy policy."; "activate" = "Activate"; From 03f0be7af11bfd0c78162910bb6b8692b2e2e396 Mon Sep 17 00:00:00 2001 From: Tassilo Karge Date: Sun, 1 Jun 2025 12:42:28 +0200 Subject: [PATCH 204/476] log preference values and value changes, store preferences correctly Signed-off-by: Tassilo Karge --- .../OpenHABCore/Util/Preferences.swift | 91 ++++++++++++------- 1 file changed, 59 insertions(+), 32 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift index 7eeb51c76..9151a5d61 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift @@ -22,14 +22,46 @@ public struct UserDefault { public var wrappedValue: T { get { - Preferences.sharedDefaults.object(forKey: key) as? T ?? defaultValue + let preferenceValue = Preferences.sharedDefaults.object(forKey: key) + if let preferenceAsT = preferenceValue as? T { + os_log( + "Preference value %{PUBLIC}@ is %{PUBLIC}@", + log: .default, + type: .debug, + key, + "\(preferenceAsT)" + ) + return preferenceAsT + } else { + if let preferenceValue { + os_log( + "Preference value %{PUBLIC}@ was %{PUBLIC}@ but did not conform to %{PUBLIC}@. Replace with default value.", + log: .default, + type: .fault, + key, + "\(preferenceValue)", + "\(T.self)" + ) + } else { + os_log( + "Preference value %{PUBLIC}@ was set for the first time. Using default value.", + log: .default, + type: .info, + key + ) + } + let fallback = defaultValue + Preferences.sharedDefaults.set(fallback, forKey: key) + return fallback + } } set { + os_log("Preference %{PUBLIC}@ will be changed to value %{PUBLIC}@", log: .default, type: .debug, key, "\(newValue)") Preferences.sharedDefaults.set(newValue, forKey: key) Preferences.change(storedPreference: key, to: newValue) } if store { - Preferences.storeCurrentPreferences() + Preferences.storeCurrentPreferences(updatedKey: key, updatedValue: newValue) } DispatchQueue.main.async { [subject] in subject.send(newValue) @@ -107,7 +139,7 @@ public struct UserDefaultURL { } set { Preferences.sharedDefaults.set(newValue, forKey: key) - Preferences.storeCurrentPreferences() + Preferences.storeCurrentPreferences(updatedKey: key, updatedValue: newValue) let defaultValue = defaultValue // Trim and validate the new URL let trimmedUri = newValue.removeTrailingSlashes().trimmingCharacters(in: .whitespacesAndNewlines) @@ -162,7 +194,7 @@ public enum Preferences { @UserDefault("homeName", defaultValue: "Home") public static var homeName: String /// settings for different homes TODO come up with better name - @UserDefault("storedPreferences", defaultValue: [:], store: false) public static var storedPreferences: [String: NSDictionary] + @UserDefault("storedPreferences", defaultValue: [:], store: false) public static var storedPreferences: [String: [String: Any]] // MARK: - Private @@ -176,7 +208,10 @@ public enum Preferences { public extension Preferences { static func listStoredPreferences() -> [UUID] { - initializeStoredPreferences() + if storedPreferences.isEmpty { + //first time the multi-home view is entered, there might be no stored preferences, if no preference was changed since the update + storeCurrentPreferences() + } let preferenceIds = storedPreferences .sorted { e1, e2 in (e1.value["homeName"] as? String ?? "") <= (e2.value["homeName"] as? String ?? "") @@ -186,8 +221,6 @@ public extension Preferences { } static func switchCurrentlyUsedSettings(to settingsId: UUID) { - initializeStoredPreferences() - let settingsIdString = settingsId.uuidString guard let stored = storedPreferences[settingsIdString] else { @@ -218,34 +251,28 @@ public extension Preferences { Preferences.homeName = stored["homeName"] as? String ?? "Home" } - private static func initializeStoredPreferences() { - if storedPreferences.isEmpty { - storeCurrentPreferences() - } - } - - static func storeCurrentPreferences() { + static func storeCurrentPreferences(updatedKey: String = "", updatedValue: Any = "") { // TODO: not pretty to repeat everything here var stored = storedPreferences stored[currentlyUsedSettings] = [ - "defaultView": Preferences.defaultView, - "localUrl": Preferences.localUrl, - "remoteUrl": Preferences.remoteUrl, - "username": Preferences.username, - "password": Preferences.password, - "alwaysSendCreds": Preferences.alwaysSendCreds, - "ignoreSSL": Preferences.ignoreSSL, - "demomode": Preferences.demomode, - "idleOff": Preferences.idleOff, - "realTimeSliders": Preferences.realTimeSliders, - "iconType": Preferences.iconType, - "defaultSitemap": Preferences.defaultSitemap, - "sendCrashReports": Preferences.sendCrashReports, - "sortSitemapsby": Preferences.sortSitemapsby, - "defaultMainUIPath": Preferences.defaultMainUIPath, - "alwaysAllowWebRTC": Preferences.alwaysAllowWebRTC, - "sitemapForWatch": Preferences.sitemapForWatch, - "homeName": Preferences.homeName + "defaultView": updatedKey == "defaultView" ? updatedValue : Preferences.defaultView, + "localUrl": updatedKey == "localUrl" ? updatedValue : Preferences.localUrl, + "remoteUrl": updatedKey == "remoteUrl" ? updatedValue : Preferences.remoteUrl, + "username": updatedKey == "username" ? updatedValue : Preferences.username, + "password": updatedKey == "password" ? updatedValue : Preferences.password, + "alwaysSendCreds": updatedKey == "alwaysSendCreds" ? updatedValue : Preferences.alwaysSendCreds, + "ignoreSSL": updatedKey == "ignoreSSL" ? updatedValue : Preferences.ignoreSSL, + "demomode": updatedKey == "demomode" ? updatedValue : Preferences.demomode, + "idleOff": updatedKey == "idleOff" ? updatedValue : Preferences.idleOff, + "realTimeSliders": updatedKey == "realTimeSliders" ? updatedValue : Preferences.realTimeSliders, + "iconType": updatedKey == "iconType" ? updatedValue : Preferences.iconType, + "defaultSitemap": updatedKey == "defaultSitemap" ? updatedValue : Preferences.defaultSitemap, + "sendCrashReports": updatedKey == "sendCrashReports" ? updatedValue : Preferences.sendCrashReports, + "sortSitemapsby": updatedKey == "sortSitemapsby" ? updatedValue : Preferences.sortSitemapsby, + "defaultMainUIPath": updatedKey == "defaultMainUIPath" ? updatedValue : Preferences.defaultMainUIPath, + "alwaysAllowWebRTC": updatedKey == "alwaysAllowWebRTC" ? updatedValue : Preferences.alwaysAllowWebRTC, + "sitemapForWatch": updatedKey == "sitemapForWatch" ? updatedValue : Preferences.sitemapForWatch, + "homeName": updatedKey == "homeName" ? updatedValue : Preferences.homeName ] storedPreferences = stored } From 9cfcb897734dab2f3ada23abe869d722bd1513a0 Mon Sep 17 00:00:00 2001 From: Tassilo Karge Date: Sun, 1 Jun 2025 13:21:45 +0200 Subject: [PATCH 205/476] prevent concurrent access by read and write of settings Signed-off-by: Tassilo Karge --- .../OpenHABCore/Util/Preferences.swift | 28 ++++++++++++++---- openHAB/DrawerView.swift | 2 +- openHAB/HomeSelectionView.swift | 29 +++++++++++++++---- 3 files changed, 47 insertions(+), 12 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift index 9151a5d61..b1521a435 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift @@ -184,7 +184,7 @@ public enum Preferences { @UserDefault("iconType", defaultValue: 0) public static var iconType: Int @UserDefault("defaultSitemap", defaultValue: "demo") public static var defaultSitemap: String @UserDefault("sendCrashReports", defaultValue: false) public static var sendCrashReports: Bool - @UserDefault("sortSitemapsBy", defaultValue: 0) public static var sortSitemapsby: Int + @UserDefault("sortSitemapsBy", defaultValue: 0) public static var sortSitemapsBy: Int @UserDefault("defaultMainUIPath", defaultValue: "") public static var defaultMainUIPath: String @UserDefault("alwaysAllowWebRTC", defaultValue: false) public static var alwaysAllowWebRTC: Bool @UserDefault("sitemapForWatch", defaultValue: "watch") public static var sitemapForWatch: String @@ -199,17 +199,19 @@ public enum Preferences { // MARK: - Private /// the currently applied settings set from storedPreferences - @UserDefault("currentlyUsedSettings", defaultValue: UUID().uuidString, store: false) private static var currentlyUsedSettings: String + @UserDefault("currentlyUsedSettings", defaultValue: UUID().uuidString, store: false) public private(set) static var currentlyUsedSettings: String @UserDefault("didMigrateToSharedDefaults", defaultValue: false) private static var didMigrateToSharedDefaults: Bool @UserDefault("didMigrateToConnectionConfig", defaultValue: false) private static var didMigrateToConnectionConfig: Bool @UserDefault("currentWebViewPath", defaultValue: "") public static var currentWebViewPath: String + + private static var loadingStoredPreferences = false } public extension Preferences { static func listStoredPreferences() -> [UUID] { if storedPreferences.isEmpty { - //first time the multi-home view is entered, there might be no stored preferences, if no preference was changed since the update + // first time the multi-home view is entered, there might be no stored preferences, if no preference was changed since the update storeCurrentPreferences() } let preferenceIds = storedPreferences @@ -220,6 +222,11 @@ public extension Preferences { return preferenceIds.compactMap { UUID(uuidString: $0) } } + static func createAndLoadNewStoredSettings(homeName: String) { + currentlyUsedSettings = UUID().uuidString + loadSettings(stored: ["homeName": homeName]) + } + static func switchCurrentlyUsedSettings(to settingsId: UUID) { let settingsIdString = settingsId.uuidString @@ -230,6 +237,11 @@ public extension Preferences { Preferences.currentlyUsedSettings = settingsIdString + loadSettings(stored: stored) + } + + private static func loadSettings(stored: [String: Any]) { + loadingStoredPreferences = true // TODO: not pretty to repeat everything here Preferences.defaultView = stored["defaultView"] as? String ?? "web" Preferences.localUrl = stored["localUrl"] as? String ?? "" @@ -244,14 +256,20 @@ public extension Preferences { Preferences.iconType = stored["iconType"] as? Int ?? 0 Preferences.defaultSitemap = stored["defaultSitemap"] as? String ?? "demo" Preferences.sendCrashReports = stored["sendCrashReports"] as? Bool ?? false - Preferences.sortSitemapsby = stored["sortSitemapsby"] as? Int ?? 0 + Preferences.sortSitemapsBy = stored["sortSitemapsBy"] as? Int ?? 0 Preferences.defaultMainUIPath = stored["defaultMainUIPath"] as? String ?? "" Preferences.alwaysAllowWebRTC = stored["alwaysAllowWebRTC"] as? Bool ?? false Preferences.sitemapForWatch = stored["sitemapForWatch"] as? String ?? "watch" Preferences.homeName = stored["homeName"] as? String ?? "Home" + loadingStoredPreferences = false + storeCurrentPreferences() } static func storeCurrentPreferences(updatedKey: String = "", updatedValue: Any = "") { + guard !loadingStoredPreferences else { + // concurrent access for writing and reading is prohibited + return + } // TODO: not pretty to repeat everything here var stored = storedPreferences stored[currentlyUsedSettings] = [ @@ -268,7 +286,7 @@ public extension Preferences { "iconType": updatedKey == "iconType" ? updatedValue : Preferences.iconType, "defaultSitemap": updatedKey == "defaultSitemap" ? updatedValue : Preferences.defaultSitemap, "sendCrashReports": updatedKey == "sendCrashReports" ? updatedValue : Preferences.sendCrashReports, - "sortSitemapsby": updatedKey == "sortSitemapsby" ? updatedValue : Preferences.sortSitemapsby, + "sortSitemapsBy": updatedKey == "sortSitemapsBy" ? updatedValue : Preferences.sortSitemapsBy, "defaultMainUIPath": updatedKey == "defaultMainUIPath" ? updatedValue : Preferences.defaultMainUIPath, "alwaysAllowWebRTC": updatedKey == "alwaysAllowWebRTC" ? updatedValue : Preferences.alwaysAllowWebRTC, "sitemapForWatch": updatedKey == "sitemapForWatch" ? updatedValue : Preferences.sitemapForWatch, diff --git a/openHAB/DrawerView.swift b/openHAB/DrawerView.swift index 2d6ce5518..957aaa912 100644 --- a/openHAB/DrawerView.swift +++ b/openHAB/DrawerView.swift @@ -288,7 +288,7 @@ struct DrawerView: View { sitemaps = Array(sitemaps.dropLast()) } - switch SortSitemapsOrder(rawValue: Preferences.sortSitemapsby) ?? .label { + switch SortSitemapsOrder(rawValue: Preferences.sortSitemapsBy) ?? .label { case .label: sitemaps.sort { $0.label < $1.label } case .name: diff --git a/openHAB/HomeSelectionView.swift b/openHAB/HomeSelectionView.swift index 033abeead..2672400f6 100644 --- a/openHAB/HomeSelectionView.swift +++ b/openHAB/HomeSelectionView.swift @@ -27,6 +27,9 @@ struct HomeSelectionView: View { @State private var homes: [UUID] = [] + @State private var showingNewHomeAlert = false + @State private var newHomeName = "" + @Environment(\.dismiss) private var dismiss var appData: OpenHABDataObject? { @@ -36,31 +39,45 @@ struct HomeSelectionView: View { private let logger = Logger(subsystem: "org.openhab.app", category: "SettingsView") var body: some View { - Form { - List(homes, id: \.self) { - Text(Preferences.storedPreferences[$0.uuidString]?["homeName"] as? String ?? "") + List(homes, id: \.self) { home in + HStack { + Text(Preferences.storedPreferences[home.uuidString]?["homeName"] as? String ?? "") // TODO: selection of name in list changes settings // TODO: options like remove, rename (or should we rename in settings?) + Spacer() + if Preferences.currentlyUsedSettings == home.uuidString { + Image(systemSymbol: .checkmark) + .foregroundColor(.blue) + } + } + .contentShape(.interaction, Rectangle()) // Ensures entire row is tappable + .onTapGesture { + Preferences.switchCurrentlyUsedSettings(to: home) + dismiss() } } .onAppear { homes = Preferences.listStoredPreferences() } - .formStyle(.grouped) .navigationBarTitle("homeSelection") .toolbar { ToolbarItemGroup(placement: .primaryAction) { Button(action: { - addHome() + showingNewHomeAlert.toggle() }, label: { Image(systemSymbol: .plus) }) + .alert("Enter name for new home", isPresented: $showingNewHomeAlert) { + TextField("Name for new home", text: $newHomeName) + Button("OK", action: addHome) + } } } } private func addHome() { - // TODO: alert to insert name for home, store and dismiss + Preferences.createAndLoadNewStoredSettings(homeName: newHomeName) + dismiss() } } From 1e1902d51faa1426d0c8b808dcf62b9fe0118fc3 Mon Sep 17 00:00:00 2001 From: Tassilo Karge Date: Sun, 1 Jun 2025 17:58:00 +0200 Subject: [PATCH 206/476] controls for editing homes list, renaming and deleting entries Signed-off-by: Tassilo Karge --- openHAB/HomeSelectionView.swift | 101 ++++++++++++++++++++++++-------- 1 file changed, 78 insertions(+), 23 deletions(-) diff --git a/openHAB/HomeSelectionView.swift b/openHAB/HomeSelectionView.swift index 2672400f6..e6e5ac5a5 100644 --- a/openHAB/HomeSelectionView.swift +++ b/openHAB/HomeSelectionView.swift @@ -23,13 +23,15 @@ struct HomeSelectionView: View { @State private var showCrashReportingAlert = false @State private var showUselastPathAlert = false - @State private var hasBeenLoaded = false - @State private var homes: [UUID] = [] @State private var showingNewHomeAlert = false @State private var newHomeName = "" + @State private var showEditOptions = false + + @State private var showingRenameHomeAlert = false + @Environment(\.dismiss) private var dismiss var appData: OpenHABDataObject? { @@ -41,19 +43,46 @@ struct HomeSelectionView: View { var body: some View { List(homes, id: \.self) { home in HStack { - Text(Preferences.storedPreferences[home.uuidString]?["homeName"] as? String ?? "") - // TODO: selection of name in list changes settings - // TODO: options like remove, rename (or should we rename in settings?) - Spacer() - if Preferences.currentlyUsedSettings == home.uuidString { - Image(systemSymbol: .checkmark) - .foregroundColor(.blue) + HStack { + Text(Preferences.storedPreferences[home.uuidString]?["homeName"] as? String ?? "") + // TODO: selection of name in list changes settings + // TODO: options like remove, rename (or should we rename in settings?) + Spacer() + if Preferences.currentlyUsedSettings == home.uuidString { + Image(systemSymbol: .checkmark) + .foregroundColor(.blue) + } + } + .contentShape(.interaction, Rectangle()) // Ensures entire row is tappable + .onTapGesture { + if !showEditOptions { + Preferences.switchCurrentlyUsedSettings(to: home) + dismiss() + } + } + HStack { + if showEditOptions { + if Preferences.currentlyUsedSettings != home.uuidString { + Button(action: { + delete(home: home) + }, label: { + Image(systemSymbol: .trash) + }) + } + Button(action: { + showingRenameHomeAlert.toggle() + }, label: { + Image(systemSymbol: .pencil) + }) + .alert("Enter new name", isPresented: $showingRenameHomeAlert) { + TextField("Name for new home", text: $newHomeName) + Button("OK") { + rename(home: home) + showingRenameHomeAlert.toggle() + } + } + } } - } - .contentShape(.interaction, Rectangle()) // Ensures entire row is tappable - .onTapGesture { - Preferences.switchCurrentlyUsedSettings(to: home) - dismiss() } } .onAppear { @@ -61,20 +90,46 @@ struct HomeSelectionView: View { } .navigationBarTitle("homeSelection") .toolbar { - ToolbarItemGroup(placement: .primaryAction) { - Button(action: { - showingNewHomeAlert.toggle() - }, label: { - Image(systemSymbol: .plus) - }) - .alert("Enter name for new home", isPresented: $showingNewHomeAlert) { - TextField("Name for new home", text: $newHomeName) - Button("OK", action: addHome) + if showEditOptions { + ToolbarItemGroup(placement: .primaryAction) { + Button(action: { + showEditOptions.toggle() + }, label: { + Image(systemSymbol: .checkmark) + }) + Button(action: { + showingNewHomeAlert.toggle() + }, label: { + Image(systemSymbol: .plus) + }) + .alert("Enter name for new home", isPresented: $showingNewHomeAlert) { + TextField("Name for new home", text: $newHomeName) + Button("OK", action: addHome) + } + } + } else { + ToolbarItemGroup(placement: .primaryAction) { + Button(action: { + showEditOptions.toggle() + }, label: { + Image(systemSymbol: .pencil) + }) } } } } + private func delete(home toDelete: UUID) { + os_log("delete home settings for %@", toDelete.uuidString) + // TODO: preferences remove stored home settings, reload view + } + + private func rename(home toRename: UUID) { + // TODO: rename home in settings, reload view + let newName = newHomeName + os_log("rename home %@ to %@", toRename.uuidString, newName) + } + private func addHome() { Preferences.createAndLoadNewStoredSettings(homeName: newHomeName) dismiss() From 9330afd8a3524eabfe9baa41a785b5e6d18ba00d Mon Sep 17 00:00:00 2001 From: Tassilo Karge Date: Sun, 1 Jun 2025 22:41:17 +0200 Subject: [PATCH 207/476] implement home removal and renaming Signed-off-by: Tassilo Karge --- .../OpenHABCore/Util/Preferences.swift | 36 ++++++- openHAB/HomeSelectionView.swift | 95 +++++++++++++------ 2 files changed, 98 insertions(+), 33 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift index b1521a435..7ef5f1962 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift @@ -210,10 +210,7 @@ public enum Preferences { public extension Preferences { static func listStoredPreferences() -> [UUID] { - if storedPreferences.isEmpty { - // first time the multi-home view is entered, there might be no stored preferences, if no preference was changed since the update - storeCurrentPreferences() - } + initializeStoredPreferences() let preferenceIds = storedPreferences .sorted { e1, e2 in (e1.value["homeName"] as? String ?? "") <= (e2.value["homeName"] as? String ?? "") @@ -222,11 +219,42 @@ public extension Preferences { return preferenceIds.compactMap { UUID(uuidString: $0) } } + static func getCurrentlyUsedSettings() -> UUID { + initializeStoredPreferences() + guard let currentPreferenceUUID = UUID(uuidString: currentlyUsedSettings) else { + fatalError("currentlyUsedSettings must be a UUID, but was \(currentlyUsedSettings)") + } + return currentPreferenceUUID + } + + private static func initializeStoredPreferences() { + if storedPreferences.isEmpty { + // first there might be no stored preferences, if no preference was changed since the update + storeCurrentPreferences() + } + } + static func createAndLoadNewStoredSettings(homeName: String) { currentlyUsedSettings = UUID().uuidString loadSettings(stored: ["homeName": homeName]) } + static func renameHome(_ settingsId: UUID, newHomeName: String) { + var stored = storedPreferences + stored[settingsId.uuidString]?["homeName"] = newHomeName + storedPreferences = stored + } + + static func deleteStoredSettings(_ settingsId: UUID) { + guard settingsId != getCurrentlyUsedSettings() else { + // cannot remove current home + return + } + var stored = storedPreferences + stored.removeValue(forKey: settingsId.uuidString) + storedPreferences = stored + } + static func switchCurrentlyUsedSettings(to settingsId: UUID) { let settingsIdString = settingsId.uuidString diff --git a/openHAB/HomeSelectionView.swift b/openHAB/HomeSelectionView.swift index e6e5ac5a5..e45c76b4d 100644 --- a/openHAB/HomeSelectionView.swift +++ b/openHAB/HomeSelectionView.swift @@ -19,10 +19,6 @@ import SwiftUI import WebKit struct HomeSelectionView: View { - @State private var showingCacheAlert = false - @State private var showCrashReportingAlert = false - @State private var showUselastPathAlert = false - @State private var homes: [UUID] = [] @State private var showingNewHomeAlert = false @@ -32,6 +28,8 @@ struct HomeSelectionView: View { @State private var showingRenameHomeAlert = false + @State private var showingDeleteHomeAlert = false + @Environment(\.dismiss) private var dismiss var appData: OpenHABDataObject? { @@ -42,52 +40,72 @@ struct HomeSelectionView: View { var body: some View { List(homes, id: \.self) { home in + let homeName = Preferences.storedPreferences[home.uuidString]?["homeName"] as? String ?? "" HStack { HStack { - Text(Preferences.storedPreferences[home.uuidString]?["homeName"] as? String ?? "") + if showEditOptions { + Image(systemSymbol: .pencil) + } + Text(homeName) // TODO: selection of name in list changes settings // TODO: options like remove, rename (or should we rename in settings?) - Spacer() - if Preferences.currentlyUsedSettings == home.uuidString { + if Preferences.currentlyUsedSettings == home.uuidString, !showEditOptions { + Spacer() Image(systemSymbol: .checkmark) .foregroundColor(.blue) } } - .contentShape(.interaction, Rectangle()) // Ensures entire row is tappable + .contentShape(.interaction, Rectangle()) .onTapGesture { if !showEditOptions { Preferences.switchCurrentlyUsedSettings(to: home) dismiss() + } else { + newHomeName = homeName + showingRenameHomeAlert.toggle() } } - HStack { - if showEditOptions { + .alert("Enter new name", isPresented: $showingRenameHomeAlert) { + TextField("New name for home", text: $newHomeName) + HStack { + Button("Abort") { + showingRenameHomeAlert.toggle() + } + Button("OK") { + rename(home: home) + showingRenameHomeAlert.toggle() + } + } + } + if showEditOptions { + HStack { + Spacer() if Preferences.currentlyUsedSettings != home.uuidString { Button(action: { - delete(home: home) + showingDeleteHomeAlert.toggle() }, label: { Image(systemSymbol: .trash) }) - } - Button(action: { - showingRenameHomeAlert.toggle() - }, label: { - Image(systemSymbol: .pencil) - }) - .alert("Enter new name", isPresented: $showingRenameHomeAlert) { - TextField("Name for new home", text: $newHomeName) - Button("OK") { - rename(home: home) - showingRenameHomeAlert.toggle() + .alert("Delete home \(homeName)?", isPresented: $showingDeleteHomeAlert) { + HStack { + Button("Abort") { + showingDeleteHomeAlert.toggle() + } + Button("OK") { + delete(home: home) + showingDeleteHomeAlert.toggle() + } + } } + } else { + Image(systemSymbol: .checkmark) + .foregroundColor(.blue) } } } } } - .onAppear { - homes = Preferences.listStoredPreferences() - } + .onAppear(perform: loadHomesList) .navigationBarTitle("homeSelection") .toolbar { if showEditOptions { @@ -98,13 +116,22 @@ struct HomeSelectionView: View { Image(systemSymbol: .checkmark) }) Button(action: { + newHomeName = "" showingNewHomeAlert.toggle() }, label: { Image(systemSymbol: .plus) }) .alert("Enter name for new home", isPresented: $showingNewHomeAlert) { TextField("Name for new home", text: $newHomeName) - Button("OK", action: addHome) + HStack { + Button("Abort") { + showingNewHomeAlert.toggle() + } + Button("OK") { + addHome() + showingNewHomeAlert.toggle() + } + } } } } else { @@ -119,20 +146,30 @@ struct HomeSelectionView: View { } } + private func loadHomesList() { + homes = Preferences.listStoredPreferences() + } + private func delete(home toDelete: UUID) { os_log("delete home settings for %@", toDelete.uuidString) - // TODO: preferences remove stored home settings, reload view + Preferences.deleteStoredSettings(toDelete) + loadHomesList() } private func rename(home toRename: UUID) { - // TODO: rename home in settings, reload view let newName = newHomeName os_log("rename home %@ to %@", toRename.uuidString, newName) + if toRename == Preferences.getCurrentlyUsedSettings() { + Preferences.homeName = newName + } else { + Preferences.renameHome(toRename, newHomeName: newName) + } + loadHomesList() } private func addHome() { Preferences.createAndLoadNewStoredSettings(homeName: newHomeName) - dismiss() + loadHomesList() } } From b8dfa28c567079dea16f04eb33e81a84a06a2ec9 Mon Sep 17 00:00:00 2001 From: Tassilo Karge Date: Sun, 1 Jun 2025 23:46:59 +0200 Subject: [PATCH 208/476] improve homeselection home management Signed-off-by: Tassilo Karge --- openHAB/HomeSelectionView.swift | 86 +++++++++++++++++++++------------ 1 file changed, 56 insertions(+), 30 deletions(-) diff --git a/openHAB/HomeSelectionView.swift b/openHAB/HomeSelectionView.swift index e45c76b4d..83a75b616 100644 --- a/openHAB/HomeSelectionView.swift +++ b/openHAB/HomeSelectionView.swift @@ -26,6 +26,9 @@ struct HomeSelectionView: View { @State private var showEditOptions = false + @State private var homeForAlert: UUID + @State private var homeNameForAlert: String + @State private var showingRenameHomeAlert = false @State private var showingDeleteHomeAlert = false @@ -45,6 +48,7 @@ struct HomeSelectionView: View { HStack { if showEditOptions { Image(systemSymbol: .pencil) + .foregroundStyle(.blue) } Text(homeName) // TODO: selection of name in list changes settings @@ -61,60 +65,77 @@ struct HomeSelectionView: View { Preferences.switchCurrentlyUsedSettings(to: home) dismiss() } else { + homeNameForAlert = homeName + homeForAlert = home newHomeName = homeName showingRenameHomeAlert.toggle() } } - .alert("Enter new name", isPresented: $showingRenameHomeAlert) { - TextField("New name for home", text: $newHomeName) - HStack { - Button("Abort") { - showingRenameHomeAlert.toggle() - } - Button("OK") { - rename(home: home) - showingRenameHomeAlert.toggle() - } - } - } if showEditOptions { HStack { Spacer() if Preferences.currentlyUsedSettings != home.uuidString { Button(action: { + homeNameForAlert = homeName + homeForAlert = home showingDeleteHomeAlert.toggle() }, label: { Image(systemSymbol: .trash) }) - .alert("Delete home \(homeName)?", isPresented: $showingDeleteHomeAlert) { - HStack { - Button("Abort") { - showingDeleteHomeAlert.toggle() - } - Button("OK") { - delete(home: home) - showingDeleteHomeAlert.toggle() - } - } - } } else { Image(systemSymbol: .checkmark) - .foregroundColor(.blue) + .foregroundStyle(.white) } } } } + .alert("Enter new name", isPresented: $showingRenameHomeAlert) { + TextField("New name for home \(homeNameForAlert)", text: $newHomeName) + HStack { + Button("Abort", role: .cancel) { + showingRenameHomeAlert.toggle() + } + Button("OK") { + rename(home: homeForAlert) + showingRenameHomeAlert.toggle() + } + } + } + .alert("Delete home \(homeNameForAlert)?", isPresented: $showingDeleteHomeAlert) { + HStack { + Button("Abort", role: .cancel) { + showingDeleteHomeAlert.toggle() + } + Button("Delete", role: .destructive) { + delete(home: homeForAlert) + showingDeleteHomeAlert.toggle() + } + } + } + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + Button(role: .destructive) { + delete(home: home) + } label: { + HStack { + Text("Delete") + Image(systemSymbol: .trashFill) + } + } + .tint(.red) + } + .swipeActions(edge: .leading) { + Button { + homeNameForAlert = homeName + homeForAlert = home + showingRenameHomeAlert.toggle() + } + } } .onAppear(perform: loadHomesList) .navigationBarTitle("homeSelection") .toolbar { if showEditOptions { ToolbarItemGroup(placement: .primaryAction) { - Button(action: { - showEditOptions.toggle() - }, label: { - Image(systemSymbol: .checkmark) - }) Button(action: { newHomeName = "" showingNewHomeAlert.toggle() @@ -124,7 +145,7 @@ struct HomeSelectionView: View { .alert("Enter name for new home", isPresented: $showingNewHomeAlert) { TextField("Name for new home", text: $newHomeName) HStack { - Button("Abort") { + Button("Abort", role: .cancel) { showingNewHomeAlert.toggle() } Button("OK") { @@ -133,6 +154,11 @@ struct HomeSelectionView: View { } } } + Button(action: { + showEditOptions.toggle() + }, label: { + Image(systemSymbol: .checkmark) + }) } } else { ToolbarItemGroup(placement: .primaryAction) { From b80426cd9a8da3d4a7a890202c5429eb8b3c0d7e Mon Sep 17 00:00:00 2001 From: Tassilo Karge Date: Sun, 1 Jun 2025 23:51:03 +0200 Subject: [PATCH 209/476] init state for home selection view correctly Signed-off-by: Tassilo Karge --- openHAB/HomeSelectionView.swift | 34 ++++++++++++++++++++++----------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/openHAB/HomeSelectionView.swift b/openHAB/HomeSelectionView.swift index 83a75b616..761df71e6 100644 --- a/openHAB/HomeSelectionView.swift +++ b/openHAB/HomeSelectionView.swift @@ -26,8 +26,8 @@ struct HomeSelectionView: View { @State private var showEditOptions = false - @State private var homeForAlert: UUID - @State private var homeNameForAlert: String + @State private var homeForAlert = UUID() // just a random uuid to init + @State private var homeNameForAlert = "" @State private var showingRenameHomeAlert = false @@ -89,8 +89,8 @@ struct HomeSelectionView: View { } } } - .alert("Enter new name", isPresented: $showingRenameHomeAlert) { - TextField("New name for home \(homeNameForAlert)", text: $newHomeName) + .alert("Enter new name for home \(homeNameForAlert)", isPresented: $showingRenameHomeAlert) { + TextField("New name", text: $newHomeName) HStack { Button("Abort", role: .cancel) { showingRenameHomeAlert.toggle() @@ -113,22 +113,28 @@ struct HomeSelectionView: View { } } .swipeActions(edge: .trailing, allowsFullSwipe: true) { - Button(role: .destructive) { + Button(role: .destructive, action: { delete(home: home) - } label: { + }, label: { HStack { Text("Delete") Image(systemSymbol: .trashFill) } - } + }) .tint(.red) } .swipeActions(edge: .leading) { - Button { + Button(action: { homeNameForAlert = homeName homeForAlert = home showingRenameHomeAlert.toggle() - } + }, label: { + HStack { + Image(systemSymbol: .pencil) + Text("Rename") + } + }) + .tint(.blue) } } .onAppear(perform: loadHomesList) @@ -176,13 +182,19 @@ struct HomeSelectionView: View { homes = Preferences.listStoredPreferences() } - private func delete(home toDelete: UUID) { + private func delete(home toDelete: UUID?) { + guard let toDelete else { + return + } os_log("delete home settings for %@", toDelete.uuidString) Preferences.deleteStoredSettings(toDelete) loadHomesList() } - private func rename(home toRename: UUID) { + private func rename(home toRename: UUID?) { + guard let toRename else { + return + } let newName = newHomeName os_log("rename home %@ to %@", toRename.uuidString, newName) if toRename == Preferences.getCurrentlyUsedSettings() { From 2271f26948a042634dedbdf29ab97f43ad2ea1cc Mon Sep 17 00:00:00 2001 From: Tassilo Karge Date: Mon, 2 Jun 2025 01:03:48 +0200 Subject: [PATCH 210/476] make whole cell selectable in home selection Signed-off-by: Tassilo Karge --- openHAB/HomeSelectionView.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openHAB/HomeSelectionView.swift b/openHAB/HomeSelectionView.swift index 761df71e6..8a384ebaf 100644 --- a/openHAB/HomeSelectionView.swift +++ b/openHAB/HomeSelectionView.swift @@ -57,6 +57,8 @@ struct HomeSelectionView: View { Spacer() Image(systemSymbol: .checkmark) .foregroundColor(.blue) + } else if !showEditOptions { + Spacer() // make more of the cell clickable } } .contentShape(.interaction, Rectangle()) From b7a3523509075592e10c8ef79ad94084382ee8ba Mon Sep 17 00:00:00 2001 From: Tassilo Karge Date: Mon, 2 Jun 2025 21:58:36 +0200 Subject: [PATCH 211/476] remove localizations to satisfy localization tests this sounds paradoxical, but the localization test does not accept a localization that does not exist in _all_ languages at once. Signed-off-by: Tassilo Karge --- openHAB/Resources/de.lproj/Localizable.strings | 1 - openHAB/Resources/en.lproj/Localizable.strings | 1 - 2 files changed, 2 deletions(-) diff --git a/openHAB/Resources/de.lproj/Localizable.strings b/openHAB/Resources/de.lproj/Localizable.strings index 1d29d403d..430832131 100644 --- a/openHAB/Resources/de.lproj/Localizable.strings +++ b/openHAB/Resources/de.lproj/Localizable.strings @@ -61,7 +61,6 @@ "oh_secret" = "openHAB-Secret"; "notifications" = "Benachrichtigungen"; "settings" = "Einstellungen"; -"homeSelection" = "Auswahl des Zuhauses"; "privacy_policy" = "Datenschutzerklärung"; "crash_reporting_info" = "Durch die Aktivierung der Absturzberichterstattung stimmen Sie zu, dass Informationen zu Ihrem Gerät und der Nutzung der App erfasst und mit Crashlytics (einem Unternehmen von Google) geteilt werden. Weitere Informationen finden Sie in unserer Datenschutzerklärung."; "activate" = "Aktivieren"; diff --git a/openHAB/Resources/en.lproj/Localizable.strings b/openHAB/Resources/en.lproj/Localizable.strings index a4030f97f..70690af13 100644 --- a/openHAB/Resources/en.lproj/Localizable.strings +++ b/openHAB/Resources/en.lproj/Localizable.strings @@ -61,7 +61,6 @@ "oh_secret" = "openHAB Secret"; "notifications" = "Notifications"; "settings" = "Settings"; -"homeSelection" = "Home Selection"; "privacy_policy" = "Privacy Policy"; "crash_reporting_info" = "By activating crash reporting you agree that device and usage information will be collected and shared with Crashlytics (a Google company). For further information view our privacy policy."; "activate" = "Activate"; From f3e2d8481b4f1011efc6dbe7243c08983f8e07e9 Mon Sep 17 00:00:00 2001 From: Tassilo Karge Date: Mon, 2 Jun 2025 22:50:13 +0200 Subject: [PATCH 212/476] solve errors introduced by rebase Signed-off-by: Tassilo Karge --- .../OpenHABCore/Util/Preferences.swift | 6 ++---- openHAB.xcodeproj/project.pbxproj | 21 +++++++------------ openHAB/HomeSelectionView.swift | 4 ---- openHAB/OpenHABRootViewController.swift | 6 +++--- openHAB/SettingsView/SettingsView.swift | 6 +++--- 5 files changed, 16 insertions(+), 27 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift index 7ef5f1962..6d6ca53f6 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift @@ -58,8 +58,6 @@ public struct UserDefault { set { os_log("Preference %{PUBLIC}@ will be changed to value %{PUBLIC}@", log: .default, type: .debug, key, "\(newValue)") Preferences.sharedDefaults.set(newValue, forKey: key) - Preferences.change(storedPreference: key, to: newValue) - } if store { Preferences.storeCurrentPreferences(updatedKey: key, updatedValue: newValue) } @@ -194,7 +192,7 @@ public enum Preferences { @UserDefault("homeName", defaultValue: "Home") public static var homeName: String /// settings for different homes TODO come up with better name - @UserDefault("storedPreferences", defaultValue: [:], store: false) public static var storedPreferences: [String: [String: Any]] + @UserDefault("storedPreferences", defaultValue: [:], store: false) public static var storedPreferences: [String: [String: any Sendable]] // MARK: - Private @@ -293,7 +291,7 @@ public extension Preferences { storeCurrentPreferences() } - static func storeCurrentPreferences(updatedKey: String = "", updatedValue: Any = "") { + static func storeCurrentPreferences(updatedKey: String = "", updatedValue: any Sendable = "") { guard !loadingStoredPreferences else { // concurrent access for writing and reading is prohibited return diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index 1b9dfdff8..5f751c075 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -8,6 +8,8 @@ /* Begin PBXBuildFile section */ 1224F78F228A89FD00750965 /* WatchMessageService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1224F78D228A89FC00750965 /* WatchMessageService.swift */; }; + 2F55E7BB2DEE447700EC8350 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F55E7BA2DEE447700EC8350 /* SettingsView.swift */; }; + 2F55E7BD2DEE44A800EC8350 /* ClientCertificatesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F55E7BC2DEE44A800EC8350 /* ClientCertificatesView.swift */; }; 2F6412EE2CE494A80039FB28 /* DatePickerUITableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F6412ED2CE494A80039FB28 /* DatePickerUITableViewCell.swift */; }; 2FBCF58C2DEB0B7700CD5D83 /* HomeSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FBCF58B2DEB0B7700CD5D83 /* HomeSelectionView.swift */; }; 2FEFD8F62BE7C5BE00E387B9 /* TextInputUITableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FEFD8F52BE7C5BE00E387B9 /* TextInputUITableViewCell.swift */; }; @@ -78,7 +80,6 @@ DA162BEC2CD3B53E0040DAE5 /* LogsViewer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA162BEB2CD3B53E0040DAE5 /* LogsViewer.swift */; }; DA19E25B22FD801D002F8F2F /* OpenHABGeneralTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA19E25A22FD801D002F8F2F /* OpenHABGeneralTests.swift */; }; DA21EAE22339621C001AB415 /* Throttler.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA21EAE12339621C001AB415 /* Throttler.swift */; }; - DA242C622C83588600AFB10D /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA242C612C83588600AFB10D /* SettingsView.swift */; }; DA28C362225241DE00AB409C /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DA28C361225241DE00AB409C /* WebKit.framework */; settings = {ATTRIBUTES = (Required, ); }; }; DA2AEB6E2D92BAD800897D80 /* PageLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2AEB6D2D92BAD800897D80 /* PageLoader.swift */; }; DA2AEB702D92CF3E00897D80 /* UITableViewCellExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2AEB6F2D92CF3E00897D80 /* UITableViewCellExtension.swift */; }; @@ -100,7 +101,6 @@ DA50C7BD2B0A51BD0009F716 /* SliderWithSwitchSupportRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA50C7BC2B0A51BD0009F716 /* SliderWithSwitchSupportRow.swift */; }; DA50C7BF2B0A65300009F716 /* SliderWithSwitchSupportUITableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA50C7BE2B0A652F0009F716 /* SliderWithSwitchSupportUITableViewCell.swift */; }; DA5ED9BE2C850955004875E0 /* ClientCertificatesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5ED9BD2C850955004875E0 /* ClientCertificatesViewModel.swift */; }; - DA5ED9C02C8509C2004875E0 /* ClientCertificatesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5ED9BF2C8509C2004875E0 /* ClientCertificatesView.swift */; }; DA65871F236F83CE007E2E7F /* UserDefaultsExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA65871E236F83CD007E2E7F /* UserDefaultsExtension.swift */; }; DA6B2EEF2C861BC900DF77CF /* DrawerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA6B2EEE2C861BC900DF77CF /* DrawerView.swift */; }; DA6B2EF12C87B59000DF77CF /* NotificationsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA6B2EF02C87B59000DF77CF /* NotificationsView.swift */; }; @@ -276,6 +276,8 @@ /* Begin PBXFileReference section */ 1224F78D228A89FC00750965 /* WatchMessageService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WatchMessageService.swift; sourceTree = ""; }; + 2F55E7BA2DEE447700EC8350 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; + 2F55E7BC2DEE44A800EC8350 /* ClientCertificatesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientCertificatesView.swift; sourceTree = ""; }; 2F6412ED2CE494A80039FB28 /* DatePickerUITableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatePickerUITableViewCell.swift; sourceTree = ""; }; 2FBCF58B2DEB0B7700CD5D83 /* HomeSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeSelectionView.swift; sourceTree = ""; }; 2FEFD8F52BE7C5BE00E387B9 /* TextInputUITableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextInputUITableViewCell.swift; sourceTree = ""; }; @@ -391,7 +393,6 @@ DA1C2E6D230DC28F00FACFB0 /* primary_category.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = primary_category.txt; sourceTree = ""; }; DA1C2E6E230DC28F00FACFB0 /* Snapfile */ = {isa = PBXFileReference; lastKnownFileType = text; path = Snapfile; sourceTree = ""; }; DA21EAE12339621C001AB415 /* Throttler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Throttler.swift; sourceTree = ""; }; - DA242C612C83588600AFB10D /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; DA28C361225241DE00AB409C /* WebKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WebKit.framework; path = System/Library/Frameworks/WebKit.framework; sourceTree = SDKROOT; }; DA2AEB6D2D92BAD800897D80 /* PageLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageLoader.swift; sourceTree = ""; }; DA2AEB6F2D92CF3E00897D80 /* UITableViewCellExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITableViewCellExtension.swift; sourceTree = ""; }; @@ -415,7 +416,6 @@ DA50C7BC2B0A51BD0009F716 /* SliderWithSwitchSupportRow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SliderWithSwitchSupportRow.swift; sourceTree = ""; }; DA50C7BE2B0A652F0009F716 /* SliderWithSwitchSupportUITableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SliderWithSwitchSupportUITableViewCell.swift; sourceTree = ""; }; DA5ED9BD2C850955004875E0 /* ClientCertificatesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientCertificatesViewModel.swift; sourceTree = ""; }; - DA5ED9BF2C8509C2004875E0 /* ClientCertificatesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientCertificatesView.swift; sourceTree = ""; }; DA65871E236F83CD007E2E7F /* UserDefaultsExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaultsExtension.swift; sourceTree = ""; }; DA6B2EEE2C861BC900DF77CF /* DrawerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DrawerView.swift; sourceTree = ""; }; DA6B2EF02C87B59000DF77CF /* NotificationsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsView.swift; sourceTree = ""; }; @@ -793,14 +793,14 @@ children = ( DA4800172D837221009CF127 /* AboutSettingsView.swift */, DA48001D2D837905009CF127 /* ApplicationSettingsView.swift */, - DA5ED9BF2C8509C2004875E0 /* ClientCertificatesView.swift */, + 2F55E7BC2DEE44A800EC8350 /* ClientCertificatesView.swift */, DA4800132D836892009CF127 /* ConnectionSettingsView.swift */, DA77E19A2D886D9B007CFF0F /* SingleConnectionSettingsView.swift */, DA4800202D839D39009CF127 /* AnimatedSecureTextField.swift */, DA4800192D83742A009CF127 /* DebugSettingsView.swift */, DA4800152D836EF0009CF127 /* MainUISettingsView.swift */, - DA242C612C83588600AFB10D /* SettingsView.swift */, DA48001B2D837556009CF127 /* SitemapSettingsView.swift */, + 2F55E7BA2DEE447700EC8350 /* SettingsView.swift */, ); path = SettingsView; sourceTree = ""; @@ -917,23 +917,18 @@ isa = PBXGroup; children = ( DA2AEB752D92D32000897D80 /* Cells */, - DA6B2EEE2C861BC900DF77CF /* DrawerView.swift */, DA4642312D7EE6CA006C3908 /* LoggerView.swift */, - DA6B2EF02C87B59000DF77CF /* NotificationsView.swift */, 653B54C1285E714900298ECD /* OpenHABViewController.swift */, 653B54BF285C0AC700298ECD /* OpenHABRootViewController.swift */, 65570A7C2476D16A00D524EA /* OpenHABWebViewController.swift */, DFB2624318830A3600D3244D /* OpenHABSitemapViewController.swift */, DA2AEB6D2D92BAD800897D80 /* PageLoader.swift */, - DA9F81862C85020F00B47B72 /* RTFTextView.swift */, DAC65FC6236EDF3900F4501E /* SpinnerViewController.swift */, DA6B2EF62C8B92E800DF77CF /* SelectionView.swift */, DA2AEB6F2D92CF3E00897D80 /* UITableViewCellExtension.swift */, DA48001F2D837CD8009CF127 /* SettingsView */, 1224F78B228A89E300750965 /* Watch */, 2FBCF58B2DEB0B7700CD5D83 /* HomeSelectionView.swift */, - DA242C612C83588600AFB10D /* SettingsView.swift */, - DA5ED9BF2C8509C2004875E0 /* ClientCertificatesView.swift */, DA9F81862C85020F00B47B72 /* RTFTextView.swift */, DA6B2EF02C87B59000DF77CF /* NotificationsView.swift */, DA6B2EEE2C861BC900DF77CF /* DrawerView.swift */, @@ -1586,7 +1581,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - DA242C622C83588600AFB10D /* SettingsView.swift in Sources */, DA7E1E4B2233986E002AEFD8 /* PlayerView.swift in Sources */, 65570A7D2476D16A00D524EA /* OpenHABWebViewController.swift in Sources */, DA48001E2D837905009CF127 /* ApplicationSettingsView.swift in Sources */, @@ -1606,6 +1600,7 @@ 2F6412EE2CE494A80039FB28 /* DatePickerUITableViewCell.swift in Sources */, DAA42BAA21DC983B00244B2A /* VideoUITableViewCell.swift in Sources */, DFB2623B18830A3600D3244D /* AppDelegate.swift in Sources */, + 2F55E7BD2DEE44A800EC8350 /* ClientCertificatesView.swift in Sources */, DA6B2EF72C8B92E800DF77CF /* SelectionView.swift in Sources */, DA4800212D839D3A009CF127 /* AnimatedSecureTextField.swift in Sources */, DAC131112DA3213100075AE2 /* SetSwitchStateIntentHandlerTests.swift in Sources */, @@ -1627,6 +1622,7 @@ DA2AEB702D92CF3E00897D80 /* UITableViewCellExtension.swift in Sources */, DA4800162D836EF0009CF127 /* MainUISettingsView.swift in Sources */, DAEAA89F21E6B16600267EA3 /* UITableView.swift in Sources */, + 2F55E7BB2DEE447700EC8350 /* SettingsView.swift in Sources */, DFB2624418830A3600D3244D /* OpenHABSitemapViewController.swift in Sources */, DA4800182D837221009CF127 /* AboutSettingsView.swift in Sources */, 653B54C2285E714900298ECD /* OpenHABViewController.swift in Sources */, @@ -1640,7 +1636,6 @@ DFFD8FD118EDBD4F003B502A /* UICircleButton.swift in Sources */, DA48001C2D837556009CF127 /* SitemapSettingsView.swift in Sources */, 938BF9C624EFCC0700E6B52F /* UILabel+Localization.swift in Sources */, - DA5ED9C02C8509C2004875E0 /* ClientCertificatesView.swift in Sources */, 653B54C0285C0AC700298ECD /* OpenHABRootViewController.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/openHAB/HomeSelectionView.swift b/openHAB/HomeSelectionView.swift index 8a384ebaf..a9ba0edde 100644 --- a/openHAB/HomeSelectionView.swift +++ b/openHAB/HomeSelectionView.swift @@ -35,10 +35,6 @@ struct HomeSelectionView: View { @Environment(\.dismiss) private var dismiss - var appData: OpenHABDataObject? { - AppDelegate.appDelegate.appData - } - private let logger = Logger(subsystem: "org.openhab.app", category: "SettingsView") var body: some View { diff --git a/openHAB/OpenHABRootViewController.swift b/openHAB/OpenHABRootViewController.swift index 24d1d1b2e..152a1d4d5 100644 --- a/openHAB/OpenHABRootViewController.swift +++ b/openHAB/OpenHABRootViewController.swift @@ -667,9 +667,9 @@ extension OpenHABRootViewController: ModalHandler { break case let .tile(urlString): openTileURL(urlString) - case .homeSelection: - let hostingController = UIHostingController(rootView: HomeSelectionView()) - navigationController?.pushViewController(hostingController, animated: true) + case .homeSelection: + let hostingController = UIHostingController(rootView: HomeSelectionView()) + navigationController?.pushViewController(hostingController, animated: true) } } } diff --git a/openHAB/SettingsView/SettingsView.swift b/openHAB/SettingsView/SettingsView.swift index 5108757a1..602b0a277 100644 --- a/openHAB/SettingsView/SettingsView.swift +++ b/openHAB/SettingsView/SettingsView.swift @@ -104,7 +104,7 @@ struct SettingsView: View { } // Sort the sitemaps according to Settings selection. - switch SortSitemapsOrder(rawValue: Preferences.sortSitemapsby) ?? .label { + switch SortSitemapsOrder(rawValue: Preferences.sortSitemapsBy) ?? .label { case .label: sitemaps.sort { $0.label < $1.label } case .name: sitemaps.sort { $0.name < $1.name } } @@ -129,7 +129,7 @@ struct SettingsView: View { settingsRealTimeSliders = Preferences.realTimeSliders settingsSendCrashReports = Preferences.sendCrashReports settingsIconType = IconType(rawValue: Preferences.iconType) ?? .png - settingsSortSitemapsBy = SortSitemapsOrder(rawValue: Preferences.sortSitemapsby) ?? .label + settingsSortSitemapsBy = SortSitemapsOrder(rawValue: Preferences.sortSitemapsBy) ?? .label settingsDefaultMainUIPath = Preferences.defaultMainUIPath settingsAlwaysAllowWebRTC = Preferences.alwaysAllowWebRTC settingsSitemapForWatch = Preferences.sitemapForWatch @@ -149,7 +149,7 @@ struct SettingsView: View { Preferences.realTimeSliders = settingsRealTimeSliders Preferences.iconType = settingsIconType.rawValue Preferences.sendCrashReports = settingsSendCrashReports - Preferences.sortSitemapsby = settingsSortSitemapsBy.rawValue + Preferences.sortSitemapsBy = settingsSortSitemapsBy.rawValue Preferences.defaultMainUIPath = settingsDefaultMainUIPath Preferences.alwaysAllowWebRTC = settingsAlwaysAllowWebRTC Preferences.sitemapForWatch = settingsSitemapForWatch From 67863492f752ba7f61b9581972c7dc090f13252d Mon Sep 17 00:00:00 2001 From: Tassilo Karge Date: Tue, 3 Jun 2025 23:19:28 +0200 Subject: [PATCH 213/476] add new preferences to stored set, remove deprecated and app-related ones, create set of stored properties on app start Signed-off-by: Tassilo Karge --- .../OpenHABCore/Util/Preferences.swift | 71 ++++++++++--------- openHAB/AppDelegate.swift | 1 + 2 files changed, 38 insertions(+), 34 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift index 6d6ca53f6..2c83978af 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift @@ -84,6 +84,7 @@ public struct UserDefault { public struct UserDefaultObject { private let key: String private let defaultValue: T + private let store: Bool private let subject: CurrentValueSubject public var wrappedValue: T { @@ -97,6 +98,9 @@ public struct UserDefaultObject { set { if let encoded = try? JSONEncoder().encode(newValue) { Preferences.sharedDefaults.set(encoded, forKey: key) + if store { + Preferences.storeCurrentPreferences(updatedKey: key, updatedValue: encoded) + } // Relevant for Combine publication DispatchQueue.main.async { [subject] in subject.send(newValue) @@ -109,9 +113,10 @@ public struct UserDefaultObject { subject.eraseToAnyPublisher() } - init(_ key: String, defaultValue: T) { + init(_ key: String, defaultValue: T, store: Bool = true) { self.key = key self.defaultValue = defaultValue + self.store = store // Combine publication if let data = Preferences.sharedDefaults.data(forKey: key), @@ -127,6 +132,7 @@ public struct UserDefaultObject { public struct UserDefaultURL { private let key: String private let defaultValue: String + private let store: Bool private let subject: CurrentValueSubject public var wrappedValue: String { @@ -137,7 +143,9 @@ public struct UserDefaultURL { } set { Preferences.sharedDefaults.set(newValue, forKey: key) - Preferences.storeCurrentPreferences(updatedKey: key, updatedValue: newValue) + if store { + Preferences.storeCurrentPreferences(updatedKey: key, updatedValue: newValue) + } let defaultValue = defaultValue // Trim and validate the new URL let trimmedUri = newValue.removeTrailingSlashes().trimmingCharacters(in: .whitespacesAndNewlines) @@ -155,9 +163,10 @@ public struct UserDefaultURL { subject.eraseToAnyPublisher() } - public init(_ key: String, defaultValue: String) { + public init(_ key: String, defaultValue: String, store: Bool = true) { self.key = key self.defaultValue = defaultValue + self.store = store let currentValue = Preferences.sharedDefaults.string(forKey: key) ?? defaultValue subject = CurrentValueSubject(currentValue) } @@ -167,21 +176,22 @@ public struct UserDefaultURL { public enum Preferences { static let sharedDefaults = UserDefaults(suiteName: "group.org.openhab.app")! - // MARK: - Public + // MARK: - Public Deprecated + + @UserDefaultURL("localUrl", defaultValue: "", store: false) public static var localUrl: String + @UserDefaultURL("remoteUrl", defaultValue: "https://myopenhab.org", store: false) public static var remoteUrl: String + @UserDefault("username", defaultValue: "test", store: false) public static var username: String + @UserDefault("password", defaultValue: "test", store: false) public static var password: String + @UserDefault("alwaysSendCreds", defaultValue: false, store: false) public static var alwaysSendCreds: Bool + @UserDefault("ignoreSSL", defaultValue: false, store: false) public static var ignoreSSL: Bool + + // MARK: - Public Home related @UserDefaultURL("defaultView", defaultValue: "web") public static var defaultView: String - @UserDefaultURL("localUrl", defaultValue: "") public static var localUrl: String - @UserDefaultURL("remoteUrl", defaultValue: "https://myopenhab.org") public static var remoteUrl: String - @UserDefault("username", defaultValue: "test") public static var username: String - @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("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 @UserDefault("iconType", defaultValue: 0) public static var iconType: Int @UserDefault("defaultSitemap", defaultValue: "demo") public static var defaultSitemap: String - @UserDefault("sendCrashReports", defaultValue: false) public static var sendCrashReports: Bool @UserDefault("sortSitemapsBy", defaultValue: 0) public static var sortSitemapsBy: Int @UserDefault("defaultMainUIPath", defaultValue: "") public static var defaultMainUIPath: String @UserDefault("alwaysAllowWebRTC", defaultValue: false) public static var alwaysAllowWebRTC: Bool @@ -191,6 +201,11 @@ public enum Preferences { @UserDefault("sitemapForWatchLabel", defaultValue: "watch") public static var sitemapForWatchLabel: String @UserDefault("homeName", defaultValue: "Home") public static var homeName: String + // MARK: - Public App related + + @UserDefault("sendCrashReports", defaultValue: false, store: false) public static var sendCrashReports: Bool + @UserDefault("idleOff", defaultValue: false, store: false) public static var idleOff: Bool + /// settings for different homes TODO come up with better name @UserDefault("storedPreferences", defaultValue: [:], store: false) public static var storedPreferences: [String: [String: any Sendable]] @@ -199,16 +214,15 @@ public enum Preferences { /// the currently applied settings set from storedPreferences @UserDefault("currentlyUsedSettings", defaultValue: UUID().uuidString, store: false) public private(set) static var currentlyUsedSettings: String - @UserDefault("didMigrateToSharedDefaults", defaultValue: false) private static var didMigrateToSharedDefaults: Bool - @UserDefault("didMigrateToConnectionConfig", defaultValue: false) private static var didMigrateToConnectionConfig: Bool - @UserDefault("currentWebViewPath", defaultValue: "") public static var currentWebViewPath: String + @UserDefault("didMigrateToSharedDefaults", defaultValue: false, store: false) private static var didMigrateToSharedDefaults: Bool + @UserDefault("didMigrateToConnectionConfig", defaultValue: false, store: false) private static var didMigrateToConnectionConfig: Bool + @UserDefault("currentWebViewPath", defaultValue: "", store: false) public static var currentWebViewPath: String private static var loadingStoredPreferences = false } public extension Preferences { static func listStoredPreferences() -> [UUID] { - initializeStoredPreferences() let preferenceIds = storedPreferences .sorted { e1, e2 in (e1.value["homeName"] as? String ?? "") <= (e2.value["homeName"] as? String ?? "") @@ -218,14 +232,13 @@ public extension Preferences { } static func getCurrentlyUsedSettings() -> UUID { - initializeStoredPreferences() guard let currentPreferenceUUID = UUID(uuidString: currentlyUsedSettings) else { fatalError("currentlyUsedSettings must be a UUID, but was \(currentlyUsedSettings)") } return currentPreferenceUUID } - private static func initializeStoredPreferences() { + static func initializeStoredPreferences() { if storedPreferences.isEmpty { // first there might be no stored preferences, if no preference was changed since the update storeCurrentPreferences() @@ -270,22 +283,17 @@ public extension Preferences { loadingStoredPreferences = true // TODO: not pretty to repeat everything here Preferences.defaultView = stored["defaultView"] as? String ?? "web" - Preferences.localUrl = stored["localUrl"] as? String ?? "" - Preferences.remoteUrl = stored["remoteUrl"] as? String ?? "https://myopenhab.org" - Preferences.username = stored["username"] as? String ?? "test" - Preferences.password = stored["password"] as? String ?? "test" - Preferences.alwaysSendCreds = stored["alwaysSendCreds"] as? Bool ?? false - Preferences.ignoreSSL = stored["ignoreSSL"] as? Bool ?? false Preferences.demomode = stored["demomode"] as? Bool ?? true - Preferences.idleOff = stored["idleOff"] as? Bool ?? false Preferences.realTimeSliders = stored["realTimeSliders"] as? Bool ?? false Preferences.iconType = stored["iconType"] as? Int ?? 0 Preferences.defaultSitemap = stored["defaultSitemap"] as? String ?? "demo" - Preferences.sendCrashReports = stored["sendCrashReports"] as? Bool ?? false Preferences.sortSitemapsBy = stored["sortSitemapsBy"] as? Int ?? 0 Preferences.defaultMainUIPath = stored["defaultMainUIPath"] as? String ?? "" Preferences.alwaysAllowWebRTC = stored["alwaysAllowWebRTC"] as? Bool ?? false Preferences.sitemapForWatch = stored["sitemapForWatch"] as? String ?? "watch" + Preferences.localConnectionConfig = stored["localConnectionConfig"] as? ConnectionConfiguration ?? ConnectionConfiguration.localDefault + Preferences.remoteConnectionConfig = stored["remoteConnectionConfig"] as? ConnectionConfiguration ?? ConnectionConfiguration.remoteDefault + Preferences.sitemapForWatchLabel = stored["sitemapForWatchLabel"] as? String ?? "watch" Preferences.homeName = stored["homeName"] as? String ?? "Home" loadingStoredPreferences = false storeCurrentPreferences() @@ -300,22 +308,17 @@ public extension Preferences { var stored = storedPreferences stored[currentlyUsedSettings] = [ "defaultView": updatedKey == "defaultView" ? updatedValue : Preferences.defaultView, - "localUrl": updatedKey == "localUrl" ? updatedValue : Preferences.localUrl, - "remoteUrl": updatedKey == "remoteUrl" ? updatedValue : Preferences.remoteUrl, - "username": updatedKey == "username" ? updatedValue : Preferences.username, - "password": updatedKey == "password" ? updatedValue : Preferences.password, - "alwaysSendCreds": updatedKey == "alwaysSendCreds" ? updatedValue : Preferences.alwaysSendCreds, - "ignoreSSL": updatedKey == "ignoreSSL" ? updatedValue : Preferences.ignoreSSL, "demomode": updatedKey == "demomode" ? updatedValue : Preferences.demomode, - "idleOff": updatedKey == "idleOff" ? updatedValue : Preferences.idleOff, "realTimeSliders": updatedKey == "realTimeSliders" ? updatedValue : Preferences.realTimeSliders, "iconType": updatedKey == "iconType" ? updatedValue : Preferences.iconType, "defaultSitemap": updatedKey == "defaultSitemap" ? updatedValue : Preferences.defaultSitemap, - "sendCrashReports": updatedKey == "sendCrashReports" ? updatedValue : Preferences.sendCrashReports, "sortSitemapsBy": updatedKey == "sortSitemapsBy" ? updatedValue : Preferences.sortSitemapsBy, "defaultMainUIPath": updatedKey == "defaultMainUIPath" ? updatedValue : Preferences.defaultMainUIPath, "alwaysAllowWebRTC": updatedKey == "alwaysAllowWebRTC" ? updatedValue : Preferences.alwaysAllowWebRTC, "sitemapForWatch": updatedKey == "sitemapForWatch" ? updatedValue : Preferences.sitemapForWatch, + "localConnectionConfig": updatedKey == "localConnectionConfig" ? updatedValue : Preferences.localConnectionConfig, + "remoteConnectionConfig": updatedKey == "remoteConnectionConfig" ? updatedValue : Preferences.remoteConnectionConfig, + "sitemapForWatchLabel": updatedKey == "sitemapForWatchLabel" ? updatedValue : Preferences.sitemapForWatchLabel, "homeName": updatedKey == "homeName" ? updatedValue : Preferences.homeName ] storedPreferences = stored diff --git a/openHAB/AppDelegate.swift b/openHAB/AppDelegate.swift index 96de7003f..a17ef10e2 100644 --- a/openHAB/AppDelegate.swift +++ b/openHAB/AppDelegate.swift @@ -79,6 +79,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { let appDefaults = ["CacheDataAgressively": NSNumber(value: true)] UserDefaults.standard.register(defaults: appDefaults) + Preferences.initializeStoredPreferences() Preferences.migrateUserDefaultsIfRequired() Preferences.migrateUserDefaultsToConnectionIfRequired() From 204d08236945b23d8b06b29ac1e311fe743e7a2f Mon Sep 17 00:00:00 2001 From: Tassilo Karge Date: Tue, 3 Jun 2025 23:51:15 +0200 Subject: [PATCH 214/476] JsonEncode UserDefaultObject before writing it to stored preferences Signed-off-by: Tassilo Karge --- OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift index 2c83978af..e74aa988c 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift @@ -316,8 +316,8 @@ public extension Preferences { "defaultMainUIPath": updatedKey == "defaultMainUIPath" ? updatedValue : Preferences.defaultMainUIPath, "alwaysAllowWebRTC": updatedKey == "alwaysAllowWebRTC" ? updatedValue : Preferences.alwaysAllowWebRTC, "sitemapForWatch": updatedKey == "sitemapForWatch" ? updatedValue : Preferences.sitemapForWatch, - "localConnectionConfig": updatedKey == "localConnectionConfig" ? updatedValue : Preferences.localConnectionConfig, - "remoteConnectionConfig": updatedKey == "remoteConnectionConfig" ? updatedValue : Preferences.remoteConnectionConfig, + "localConnectionConfig": updatedKey == "localConnectionConfig" ? updatedValue : try? JSONEncoder().encode(Preferences.localConnectionConfig), + "remoteConnectionConfig": updatedKey == "remoteConnectionConfig" ? updatedValue : try? JSONEncoder().encode(Preferences.remoteConnectionConfig), "sitemapForWatchLabel": updatedKey == "sitemapForWatchLabel" ? updatedValue : Preferences.sitemapForWatchLabel, "homeName": updatedKey == "homeName" ? updatedValue : Preferences.homeName ] From 5f5678880756e963db09e12df15c963d5689b197 Mon Sep 17 00:00:00 2001 From: Tassilo Karge Date: Wed, 4 Jun 2025 00:42:10 +0200 Subject: [PATCH 215/476] only load preferences on first appearance Signed-off-by: Tassilo Karge --- openHAB/SettingsView/SettingsView.swift | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/openHAB/SettingsView/SettingsView.swift b/openHAB/SettingsView/SettingsView.swift index 602b0a277..da67021dc 100644 --- a/openHAB/SettingsView/SettingsView.swift +++ b/openHAB/SettingsView/SettingsView.swift @@ -35,6 +35,8 @@ struct SettingsView: View { @State var settingsLocalConnectionConfiguration = ConnectionConfiguration(url: "", username: "", password: "") @State var settingsRemoteConnectionConfiguration = ConnectionConfiguration(url: "", username: "", password: "") + @State var viewAppearedOnce: Bool = false + @Environment(\.dismiss) private var dismiss private let logger = Logger(subsystem: "org.openhab.app", category: "SettingsView") @@ -88,9 +90,12 @@ struct SettingsView: View { } } .task { - loadSettings() - let activeConfiguration = settingsLocalConnectionConfiguration - await updateSitemaps(activeConfiguration: activeConfiguration) + if !viewAppearedOnce { + viewAppearedOnce = true + loadSettings() + let activeConfiguration = settingsLocalConnectionConfiguration + await updateSitemaps(activeConfiguration: activeConfiguration) + } } } From 2c921d58e1605cf4ce04679affe3b7739b10ed63 Mon Sep 17 00:00:00 2001 From: Tassilo Karge Date: Wed, 4 Jun 2025 20:57:17 +0200 Subject: [PATCH 216/476] Multiple homes branch on openapi (#871) * enable storing multiple sets of preferences Signed-off-by: Tassilo Karge * add option for home selection to drawer menu Signed-off-by: Tassilo Karge * view to switch between homes started Signed-off-by: Tassilo Karge * log preference values and value changes, store preferences correctly Signed-off-by: Tassilo Karge * prevent concurrent access by read and write of settings Signed-off-by: Tassilo Karge * controls for editing homes list, renaming and deleting entries Signed-off-by: Tassilo Karge * implement home removal and renaming Signed-off-by: Tassilo Karge * improve homeselection home management Signed-off-by: Tassilo Karge * init state for home selection view correctly Signed-off-by: Tassilo Karge * make whole cell selectable in home selection Signed-off-by: Tassilo Karge * remove localizations to satisfy localization tests this sounds paradoxical, but the localization test does not accept a localization that does not exist in _all_ languages at once. Signed-off-by: Tassilo Karge * solve errors introduced by rebase Signed-off-by: Tassilo Karge * add new preferences to stored set, remove deprecated and app-related ones, create set of stored properties on app start Signed-off-by: Tassilo Karge * JsonEncode UserDefaultObject before writing it to stored preferences Signed-off-by: Tassilo Karge * only load preferences on first appearance Signed-off-by: Tassilo Karge --------- Signed-off-by: Tassilo Karge From d1a717f17ceaa335626e4c5fe88de85241eb8318 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Thu, 5 Jun 2025 10:51:43 +0200 Subject: [PATCH 217/476] Set SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY to YES and adapted for compiler Set CODE_SIGN_IDENTITY for Development to Automatic for local compilation Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- openHAB.xcodeproj/project.pbxproj | 26 ++++++++++--------- .../Model/OpenHABWidgetExtension.swift | 1 + openHABWatch/OpenHABWatch.swift | 1 + openHABWatch/Views/ContentView.swift | 15 +++++------ openHABWatch/Views/Rows/ColorPickerRow.swift | 1 + .../Views/Rows/RollershutterRow.swift | 1 + openHABWatch/Views/Rows/SegmentRow.swift | 2 +- openHABWatch/Views/Rows/SetpointRow.swift | 1 + openHABWatch/Views/Rows/SliderRow.swift | 2 +- .../Rows/SliderWithSwitchSupportRow.swift | 2 +- .../Views/Utils/Color+Extension.swift | 1 + openHABWatch/Views/Utils/ColorSelection.swift | 1 + openHABWatch/Views/Utils/IconView.swift | 1 + 13 files changed, 32 insertions(+), 23 deletions(-) diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index 5f751c075..f2cf3fe61 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -1745,8 +1745,8 @@ CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_ENTITLEMENTS = openHABIntents/openHABIntents.entitlements; - CODE_SIGN_IDENTITY = "Apple Distribution"; - CODE_SIGN_STYLE = Manual; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 33; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = PBAPXHRAM9; @@ -1766,7 +1766,7 @@ MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = org.openhab.app.openHABIntents; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = "match AppStore org.openhab.app.openHABIntents"; + PROVISIONING_PROFILE_SPECIFIER = ""; SKIP_INSTALL = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; @@ -1831,8 +1831,8 @@ CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements; - CODE_SIGN_IDENTITY = "Apple Distribution"; - CODE_SIGN_STYLE = Manual; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 33; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = PBAPXHRAM9; @@ -1857,7 +1857,7 @@ MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = org.openhab.app.NotificationService; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = "match AppStore org.openhab.app.NotificationService"; + PROVISIONING_PROFILE_SPECIFIER = ""; SKIP_INSTALL = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -2016,8 +2016,8 @@ CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_ENTITLEMENTS = "openHABWatch Extension/openHABWatch Extension.entitlements"; - CODE_SIGN_IDENTITY = "Apple Distribution"; - CODE_SIGN_STYLE = Manual; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 33; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = PBAPXHRAM9; @@ -2042,7 +2042,7 @@ MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = org.openhab.app.watchkitapp; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = "match AppStore org.openhab.app.watchkitapp"; + PROVISIONING_PROFILE_SPECIFIER = ""; SDKROOT = watchos; SKIP_INSTALL = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; @@ -2413,6 +2413,7 @@ SWIFT_UPCOMING_FEATURE_IMPORT_OBJC_FORWARD_DECLS = YES; SWIFT_UPCOMING_FEATURE_INFER_SENDABLE_FROM_CAPTURES = YES; SWIFT_UPCOMING_FEATURE_ISOLATED_DEFAULT_VALUES = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_UPCOMING_FEATURE_NONFROZEN_ENUM_EXHAUSTIVITY = YES; SWIFT_UPCOMING_FEATURE_REGION_BASED_ISOLATION = NO; SWIFT_VERSION = ""; @@ -2487,6 +2488,7 @@ SWIFT_UPCOMING_FEATURE_IMPORT_OBJC_FORWARD_DECLS = YES; SWIFT_UPCOMING_FEATURE_INFER_SENDABLE_FROM_CAPTURES = YES; SWIFT_UPCOMING_FEATURE_ISOLATED_DEFAULT_VALUES = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_UPCOMING_FEATURE_NONFROZEN_ENUM_EXHAUSTIVITY = YES; SWIFT_UPCOMING_FEATURE_REGION_BASED_ISOLATION = NO; SWIFT_VERSION = ""; @@ -2504,8 +2506,8 @@ CLANG_CXX_LANGUAGE_STANDARD = "$(inherited)"; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = openHAB/openHAB.entitlements; - CODE_SIGN_IDENTITY = "Apple Distribution"; - CODE_SIGN_STYLE = Manual; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 33; DEVELOPMENT_TEAM = PBAPXHRAM9; ENABLE_DEBUG_DYLIB = YES; @@ -2530,7 +2532,7 @@ PRODUCT_BUNDLE_IDENTIFIER = org.openhab.app; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE = ""; - PROVISIONING_PROFILE_SPECIFIER = "match AppStore org.openhab.app"; + PROVISIONING_PROFILE_SPECIFIER = ""; SUPPORTS_MACCATALYST = NO; SWIFT_INSTALL_OBJC_HEADER = NO; SWIFT_OBJC_BRIDGING_HEADER = ""; diff --git a/openHABWatch/Model/OpenHABWidgetExtension.swift b/openHABWatch/Model/OpenHABWidgetExtension.swift index 92cf7feaa..5cbfc9504 100644 --- a/openHABWatch/Model/OpenHABWidgetExtension.swift +++ b/openHABWatch/Model/OpenHABWidgetExtension.swift @@ -12,6 +12,7 @@ import Foundation import OpenHABCore import os.log +import SFSafeSymbols import SwiftUI extension OpenHABWidget { diff --git a/openHABWatch/OpenHABWatch.swift b/openHABWatch/OpenHABWatch.swift index 51287366b..26881c304 100644 --- a/openHABWatch/OpenHABWatch.swift +++ b/openHABWatch/OpenHABWatch.swift @@ -10,6 +10,7 @@ // SPDX-License-Identifier: EPL-2.0 import OpenHABCore +import SFSafeSymbols import SwiftUI import UserNotifications diff --git a/openHABWatch/Views/ContentView.swift b/openHABWatch/Views/ContentView.swift index be8e5fab3..26fff7321 100644 --- a/openHABWatch/Views/ContentView.swift +++ b/openHABWatch/Views/ContentView.swift @@ -121,15 +121,14 @@ struct ContentView: View { } #Preview { - Group { - ContentView(viewModel: UserData()) + let userData = UserData() + let appSettings = AppSettings() - .environmentObject({ () -> UserData in - let envObj = UserData() - return envObj - }()) + return Group { + ContentView(viewModel: userData) + .environmentObject(userData) - ContentView(viewModel: UserData()) + ContentView(viewModel: userData) } - .environmentObject(AppSettings()) + .environmentObject(appSettings) } diff --git a/openHABWatch/Views/Rows/ColorPickerRow.swift b/openHABWatch/Views/Rows/ColorPickerRow.swift index 8ad5c0e13..74416aefa 100644 --- a/openHABWatch/Views/Rows/ColorPickerRow.swift +++ b/openHABWatch/Views/Rows/ColorPickerRow.swift @@ -11,6 +11,7 @@ import OpenHABCore import os.log +import SFSafeSymbols import SwiftUI struct ColorPickerRow: View { diff --git a/openHABWatch/Views/Rows/RollershutterRow.swift b/openHABWatch/Views/Rows/RollershutterRow.swift index 80d0a1d75..f1763a481 100644 --- a/openHABWatch/Views/Rows/RollershutterRow.swift +++ b/openHABWatch/Views/Rows/RollershutterRow.swift @@ -10,6 +10,7 @@ // SPDX-License-Identifier: EPL-2.0 import OpenHABCore +import SFSafeSymbols import SwiftUI struct RollershutterRow: View { diff --git a/openHABWatch/Views/Rows/SegmentRow.swift b/openHABWatch/Views/Rows/SegmentRow.swift index 7c82c7ce2..270eb66d4 100644 --- a/openHABWatch/Views/Rows/SegmentRow.swift +++ b/openHABWatch/Views/Rows/SegmentRow.swift @@ -63,7 +63,7 @@ struct SegmentRow: View { #Preview { let widget = UserData(preview: true).widgets[4] - Group { + return Group { SegmentRow(widget: widget) SegmentRow(widget: widget) } diff --git a/openHABWatch/Views/Rows/SetpointRow.swift b/openHABWatch/Views/Rows/SetpointRow.swift index 8f952f420..fde09ba0e 100644 --- a/openHABWatch/Views/Rows/SetpointRow.swift +++ b/openHABWatch/Views/Rows/SetpointRow.swift @@ -11,6 +11,7 @@ import OpenHABCore import os.log +import SFSafeSymbols import SwiftUI struct SetpointRow: View { diff --git a/openHABWatch/Views/Rows/SliderRow.swift b/openHABWatch/Views/Rows/SliderRow.swift index 993394826..a88c78355 100644 --- a/openHABWatch/Views/Rows/SliderRow.swift +++ b/openHABWatch/Views/Rows/SliderRow.swift @@ -61,7 +61,7 @@ struct SliderRow: View { #Preview { let widget = UserData(preview: true).widgets[3] - Group { + return Group { SliderRow(widget: widget) SliderRow(widget: widget) } diff --git a/openHABWatch/Views/Rows/SliderWithSwitchSupportRow.swift b/openHABWatch/Views/Rows/SliderWithSwitchSupportRow.swift index fa5d97622..ffefca687 100644 --- a/openHABWatch/Views/Rows/SliderWithSwitchSupportRow.swift +++ b/openHABWatch/Views/Rows/SliderWithSwitchSupportRow.swift @@ -86,7 +86,7 @@ struct SliderWithSwitchSupportRow: View { #Preview { let widget = UserData(preview: true).widgets[3] - Group { + return Group { SliderRow(widget: widget) SliderRow(widget: widget) } diff --git a/openHABWatch/Views/Utils/Color+Extension.swift b/openHABWatch/Views/Utils/Color+Extension.swift index a893483e9..0f1661cf4 100644 --- a/openHABWatch/Views/Utils/Color+Extension.swift +++ b/openHABWatch/Views/Utils/Color+Extension.swift @@ -9,6 +9,7 @@ // // SPDX-License-Identifier: EPL-2.0 +import OpenHABCore import SwiftUI extension Color { diff --git a/openHABWatch/Views/Utils/ColorSelection.swift b/openHABWatch/Views/Utils/ColorSelection.swift index ef83dfd2f..c17a320ce 100644 --- a/openHABWatch/Views/Utils/ColorSelection.swift +++ b/openHABWatch/Views/Utils/ColorSelection.swift @@ -9,6 +9,7 @@ // // SPDX-License-Identifier: EPL-2.0 +import OpenHABCore import os.log import SwiftUI diff --git a/openHABWatch/Views/Utils/IconView.swift b/openHABWatch/Views/Utils/IconView.swift index 05aeeb434..10948b102 100644 --- a/openHABWatch/Views/Utils/IconView.swift +++ b/openHABWatch/Views/Utils/IconView.swift @@ -12,6 +12,7 @@ import Kingfisher import OpenHABCore import os.log +import SFSafeSymbols import SwiftUI struct IconView: View { From c03e04a4f529ea56efea5c141c7d26dbd82f71c9 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Thu, 5 Jun 2025 13:18:27 +0200 Subject: [PATCH 218/476] Import OpenHABCore in OpenHABGeneralTests Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- openHABTestsSwift/OpenHABGeneralTests.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/openHABTestsSwift/OpenHABGeneralTests.swift b/openHABTestsSwift/OpenHABGeneralTests.swift index 9138551cf..a7d5ad7d2 100644 --- a/openHABTestsSwift/OpenHABGeneralTests.swift +++ b/openHABTestsSwift/OpenHABGeneralTests.swift @@ -10,6 +10,7 @@ // SPDX-License-Identifier: EPL-2.0 @testable import openHAB +import OpenHABCore import XCTest class OpenHABGeneralTests: XCTestCase { From 812ee5c487aa8464f5593b0c878e61ec2043cad6 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Fri, 6 Jun 2025 17:14:46 +0200 Subject: [PATCH 219/476] Making use of Swift 6 / Still throws warnings but it compiles in Swift 6 mode. Allows to migrate openHABSitemapWidgetEvents to return an opaque AsyncSequence Trying to make testTrackerGoesOfflineOnNetworkLoss less flaky Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- OpenHABCore/Package.swift | 2 +- .../OpenHABCore/Util/OpenAPIService.swift | 5 +- .../NetworkTrackerTests.swift | 49 +++++++------------ openHAB.xcodeproj/project.pbxproj | 4 +- openHAB/OpenHABSitemapViewController.swift | 1 - 5 files changed, 23 insertions(+), 38 deletions(-) diff --git a/OpenHABCore/Package.swift b/OpenHABCore/Package.swift index 2711948b0..1ff376ae2 100644 --- a/OpenHABCore/Package.swift +++ b/OpenHABCore/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.10 +// swift-tools-version:6.1 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift index ec821c849..ab16f1dd7 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift @@ -211,13 +211,14 @@ public extension OpenAPIService { return URL(string: urlString)?.lastPathComponent } - // Will need swift 6.0 SE-0421 to return an opaque sequence - func openHABSitemapWidgetEvents(subscriptionid: String, sitemap: String) async throws -> AsyncCompactMapSequence>, ServerSentEventWithJSONData>, OpenHABSitemapWidgetEvent> { + func openHABSitemapWidgetEvents(subscriptionid: String, sitemap: String) async throws -> some AsyncSequence { let path = Operations.getSitemapEvents_1.Input.Path(subscriptionid: subscriptionid) let query = Operations.getSitemapEvents_1.Input.Query(sitemap: sitemap, pageid: sitemap) + let decodedSequence = try await client.getSitemapEvents_1(path: path, query: query) .ok.body.text_event_hyphen_stream .asDecodedServerSentEventsWithJSONData(of: Components.Schemas.SitemapWidgetEvent.self) + return decodedSequence.compactMap { OpenHABSitemapWidgetEvent($0.data) } } } diff --git a/OpenHABCore/Tests/OpenHABCoreTests/NetworkTrackerTests.swift b/OpenHABCore/Tests/OpenHABCoreTests/NetworkTrackerTests.swift index c679184ae..4c5553eb8 100644 --- a/OpenHABCore/Tests/OpenHABCoreTests/NetworkTrackerTests.swift +++ b/OpenHABCore/Tests/OpenHABCoreTests/NetworkTrackerTests.swift @@ -83,23 +83,26 @@ final actor MockOpenAPIService: OpenAPIServiceProtocol { } final class MockPathMonitor: NWPathMonitoring { - private var handler: ((Bool) async -> Void)? + func cancel() { + // no-op + } - init() {} + private var handler: ((Bool) async -> Void)? + private var simulateConnectionContinuation: CheckedContinuation? func startMonitoring(handler: @escaping (Bool) async -> Void) async { self.handler = handler } - func cancel() { - // no-op + func simulateConnection(isConnected: Bool) async { + guard let handler else { return } + await handler(isConnected) + simulateConnectionContinuation?.resume() } - /// Call this in your tests to simulate a connection status change - func simulateConnection(isConnected: Bool) { - guard let handler else { return } - Task { - await handler(isConnected) + func waitUntilSimulatedConnectionHandled() async { + await withCheckedContinuation { continuation in + simulateConnectionContinuation = continuation } } } @@ -141,19 +144,16 @@ final class NetworkTrackerTests: XCTestCase { await tracker.startTracking(connectionConfigurations: [config]) // Simulate the network becoming available - mockMonitor.simulateConnection(isConnected: true) + await mockMonitor.simulateConnection(isConnected: true) await fulfillment(of: [expectation], timeout: 2.0) } @MainActor func testTrackerGoesOfflineOnNetworkLoss() async { - let statusSinkAttached = XCTestExpectation(description: "Combine sink attached") let becameNotConnected = XCTestExpectation(description: "Status becomes .notConnected") - let expectation = XCTestExpectation(description: "Status becomes .notConnected") - - let mockMonitor = MockPathMonitor() // ⬅️ Hold on to this + let mockMonitor = MockPathMonitor() let tracker = NetworkTracker( monitor: mockMonitor, connectionPool: ConnectionPool { _ in MockOpenAPIService() }, @@ -163,17 +163,6 @@ final class NetworkTrackerTests: XCTestCase { var cancellables = Set() tracker.$status - // swiftlint:disable trailing_closure - .handleEvents( - receiveSubscription: { _ in - statusSinkAttached.fulfill() - }, - receiveOutput: nil, - receiveCompletion: nil, - receiveCancel: nil, - receiveRequest: { _ in } - ) - // swiftlint:enable trailing_closure .dropFirst() .sink { status in if status == .notConnected { @@ -182,17 +171,13 @@ final class NetworkTrackerTests: XCTestCase { } .store(in: &cancellables) - // Start tracking first to initialize properly await tracker.startTracking(connectionConfigurations: [ ConnectionConfiguration(url: "http://mock", username: "", password: "", priority: 0) ]) - // 🚦 Wait until Combine is ready before triggering anything - await fulfillment(of: [statusSinkAttached], timeout: 2.0) - - // Simulate loss of network - mockMonitor.simulateConnection(isConnected: false) // ✅ use directly + await mockMonitor.simulateConnection(isConnected: false) + await mockMonitor.waitUntilSimulatedConnectionHandled() - await fulfillment(of: [becameNotConnected], timeout: 4.0) + await fulfillment(of: [becameNotConnected], timeout: 2.0) } } diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index f2cf3fe61..a2b75671a 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -2416,7 +2416,7 @@ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_UPCOMING_FEATURE_NONFROZEN_ENUM_EXHAUSTIVITY = YES; SWIFT_UPCOMING_FEATURE_REGION_BASED_ISOLATION = NO; - SWIFT_VERSION = ""; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; VERSIONING_SYSTEM = "apple-generic"; WATCHOS_DEPLOYMENT_TARGET = 9.0; @@ -2491,7 +2491,7 @@ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_UPCOMING_FEATURE_NONFROZEN_ENUM_EXHAUSTIVITY = YES; SWIFT_UPCOMING_FEATURE_REGION_BASED_ISOLATION = NO; - SWIFT_VERSION = ""; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; VALIDATE_PRODUCT = YES; VERSIONING_SYSTEM = "apple-generic"; diff --git a/openHAB/OpenHABSitemapViewController.swift b/openHAB/OpenHABSitemapViewController.swift index 98f745236..3bcb1a411 100644 --- a/openHAB/OpenHABSitemapViewController.swift +++ b/openHAB/OpenHABSitemapViewController.swift @@ -15,7 +15,6 @@ import Combine import Foundation import Kingfisher import OpenAPIRuntime -import OpenAPIURLSession import OpenHABCore import os.log import SafariServices From 77c67183ddbf18e38b2a76e9a8bd268f09f4a71f Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Fri, 6 Jun 2025 18:06:53 +0200 Subject: [PATCH 220/476] Revert "Making use of Swift 6 / Still throws warnings but it compiles in Swift 6 mode." This reverts commit 812ee5c487aa8464f5593b0c878e61ec2043cad6. --- OpenHABCore/Package.swift | 2 +- .../OpenHABCore/Util/OpenAPIService.swift | 5 +- .../NetworkTrackerTests.swift | 49 ++++++++++++------- openHAB.xcodeproj/project.pbxproj | 4 +- openHAB/OpenHABSitemapViewController.swift | 1 + 5 files changed, 38 insertions(+), 23 deletions(-) diff --git a/OpenHABCore/Package.swift b/OpenHABCore/Package.swift index 1ff376ae2..2711948b0 100644 --- a/OpenHABCore/Package.swift +++ b/OpenHABCore/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:6.1 +// swift-tools-version:5.10 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift index ab16f1dd7..ec821c849 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift @@ -211,14 +211,13 @@ public extension OpenAPIService { return URL(string: urlString)?.lastPathComponent } - func openHABSitemapWidgetEvents(subscriptionid: String, sitemap: String) async throws -> some AsyncSequence { + // Will need swift 6.0 SE-0421 to return an opaque sequence + func openHABSitemapWidgetEvents(subscriptionid: String, sitemap: String) async throws -> AsyncCompactMapSequence>, ServerSentEventWithJSONData>, OpenHABSitemapWidgetEvent> { let path = Operations.getSitemapEvents_1.Input.Path(subscriptionid: subscriptionid) let query = Operations.getSitemapEvents_1.Input.Query(sitemap: sitemap, pageid: sitemap) - let decodedSequence = try await client.getSitemapEvents_1(path: path, query: query) .ok.body.text_event_hyphen_stream .asDecodedServerSentEventsWithJSONData(of: Components.Schemas.SitemapWidgetEvent.self) - return decodedSequence.compactMap { OpenHABSitemapWidgetEvent($0.data) } } } diff --git a/OpenHABCore/Tests/OpenHABCoreTests/NetworkTrackerTests.swift b/OpenHABCore/Tests/OpenHABCoreTests/NetworkTrackerTests.swift index 4c5553eb8..c679184ae 100644 --- a/OpenHABCore/Tests/OpenHABCoreTests/NetworkTrackerTests.swift +++ b/OpenHABCore/Tests/OpenHABCoreTests/NetworkTrackerTests.swift @@ -83,26 +83,23 @@ final actor MockOpenAPIService: OpenAPIServiceProtocol { } final class MockPathMonitor: NWPathMonitoring { - func cancel() { - // no-op - } - private var handler: ((Bool) async -> Void)? - private var simulateConnectionContinuation: CheckedContinuation? + + init() {} func startMonitoring(handler: @escaping (Bool) async -> Void) async { self.handler = handler } - func simulateConnection(isConnected: Bool) async { - guard let handler else { return } - await handler(isConnected) - simulateConnectionContinuation?.resume() + func cancel() { + // no-op } - func waitUntilSimulatedConnectionHandled() async { - await withCheckedContinuation { continuation in - simulateConnectionContinuation = continuation + /// Call this in your tests to simulate a connection status change + func simulateConnection(isConnected: Bool) { + guard let handler else { return } + Task { + await handler(isConnected) } } } @@ -144,16 +141,19 @@ final class NetworkTrackerTests: XCTestCase { await tracker.startTracking(connectionConfigurations: [config]) // Simulate the network becoming available - await mockMonitor.simulateConnection(isConnected: true) + mockMonitor.simulateConnection(isConnected: true) await fulfillment(of: [expectation], timeout: 2.0) } @MainActor func testTrackerGoesOfflineOnNetworkLoss() async { + let statusSinkAttached = XCTestExpectation(description: "Combine sink attached") let becameNotConnected = XCTestExpectation(description: "Status becomes .notConnected") - let mockMonitor = MockPathMonitor() + let expectation = XCTestExpectation(description: "Status becomes .notConnected") + + let mockMonitor = MockPathMonitor() // ⬅️ Hold on to this let tracker = NetworkTracker( monitor: mockMonitor, connectionPool: ConnectionPool { _ in MockOpenAPIService() }, @@ -163,6 +163,17 @@ final class NetworkTrackerTests: XCTestCase { var cancellables = Set() tracker.$status + // swiftlint:disable trailing_closure + .handleEvents( + receiveSubscription: { _ in + statusSinkAttached.fulfill() + }, + receiveOutput: nil, + receiveCompletion: nil, + receiveCancel: nil, + receiveRequest: { _ in } + ) + // swiftlint:enable trailing_closure .dropFirst() .sink { status in if status == .notConnected { @@ -171,13 +182,17 @@ final class NetworkTrackerTests: XCTestCase { } .store(in: &cancellables) + // Start tracking first to initialize properly await tracker.startTracking(connectionConfigurations: [ ConnectionConfiguration(url: "http://mock", username: "", password: "", priority: 0) ]) - await mockMonitor.simulateConnection(isConnected: false) - await mockMonitor.waitUntilSimulatedConnectionHandled() + // 🚦 Wait until Combine is ready before triggering anything + await fulfillment(of: [statusSinkAttached], timeout: 2.0) + + // Simulate loss of network + mockMonitor.simulateConnection(isConnected: false) // ✅ use directly - await fulfillment(of: [becameNotConnected], timeout: 2.0) + await fulfillment(of: [becameNotConnected], timeout: 4.0) } } diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index a2b75671a..f2cf3fe61 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -2416,7 +2416,7 @@ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_UPCOMING_FEATURE_NONFROZEN_ENUM_EXHAUSTIVITY = YES; SWIFT_UPCOMING_FEATURE_REGION_BASED_ISOLATION = NO; - SWIFT_VERSION = 6.0; + SWIFT_VERSION = ""; TARGETED_DEVICE_FAMILY = "1,2"; VERSIONING_SYSTEM = "apple-generic"; WATCHOS_DEPLOYMENT_TARGET = 9.0; @@ -2491,7 +2491,7 @@ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_UPCOMING_FEATURE_NONFROZEN_ENUM_EXHAUSTIVITY = YES; SWIFT_UPCOMING_FEATURE_REGION_BASED_ISOLATION = NO; - SWIFT_VERSION = 6.0; + SWIFT_VERSION = ""; TARGETED_DEVICE_FAMILY = "1,2"; VALIDATE_PRODUCT = YES; VERSIONING_SYSTEM = "apple-generic"; diff --git a/openHAB/OpenHABSitemapViewController.swift b/openHAB/OpenHABSitemapViewController.swift index 3bcb1a411..98f745236 100644 --- a/openHAB/OpenHABSitemapViewController.swift +++ b/openHAB/OpenHABSitemapViewController.swift @@ -15,6 +15,7 @@ import Combine import Foundation import Kingfisher import OpenAPIRuntime +import OpenAPIURLSession import OpenHABCore import os.log import SafariServices From e4479ec81c36ff6155fa74cabeb8e9ccd5d3ed04 Mon Sep 17 00:00:00 2001 From: Tassilo Karge Date: Sun, 8 Jun 2025 01:11:58 +0200 Subject: [PATCH 221/476] decode connection settings when loading stored preferences (#875) Signed-off-by: Tassilo Karge --- .../Sources/OpenHABCore/Util/Preferences.swift | 4 ++-- openHAB/HomeSelectionView.swift | 16 ++++++++-------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift index e74aa988c..543ce63fb 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift @@ -291,8 +291,8 @@ public extension Preferences { Preferences.defaultMainUIPath = stored["defaultMainUIPath"] as? String ?? "" Preferences.alwaysAllowWebRTC = stored["alwaysAllowWebRTC"] as? Bool ?? false Preferences.sitemapForWatch = stored["sitemapForWatch"] as? String ?? "watch" - Preferences.localConnectionConfig = stored["localConnectionConfig"] as? ConnectionConfiguration ?? ConnectionConfiguration.localDefault - Preferences.remoteConnectionConfig = stored["remoteConnectionConfig"] as? ConnectionConfiguration ?? ConnectionConfiguration.remoteDefault + Preferences.localConnectionConfig = (try? JSONDecoder().decode(ConnectionConfiguration.self, from: stored["localConnectionConfig"] as? Data ?? Data())) ?? ConnectionConfiguration.localDefault + Preferences.remoteConnectionConfig = (try? JSONDecoder().decode(ConnectionConfiguration.self, from: stored["remoteConnectionConfig"] as? Data ?? Data())) ?? ConnectionConfiguration.remoteDefault Preferences.sitemapForWatchLabel = stored["sitemapForWatchLabel"] as? String ?? "watch" Preferences.homeName = stored["homeName"] as? String ?? "Home" loadingStoredPreferences = false diff --git a/openHAB/HomeSelectionView.swift b/openHAB/HomeSelectionView.swift index a9ba0edde..839178ca2 100644 --- a/openHAB/HomeSelectionView.swift +++ b/openHAB/HomeSelectionView.swift @@ -47,8 +47,6 @@ struct HomeSelectionView: View { .foregroundStyle(.blue) } Text(homeName) - // TODO: selection of name in list changes settings - // TODO: options like remove, rename (or should we rename in settings?) if Preferences.currentlyUsedSettings == home.uuidString, !showEditOptions { Spacer() Image(systemSymbol: .checkmark) @@ -59,13 +57,12 @@ struct HomeSelectionView: View { } .contentShape(.interaction, Rectangle()) .onTapGesture { + homeNameForAlert = homeName + homeForAlert = home + newHomeName = homeName if !showEditOptions { - Preferences.switchCurrentlyUsedSettings(to: home) - dismiss() + select(home: home) } else { - homeNameForAlert = homeName - homeForAlert = home - newHomeName = homeName showingRenameHomeAlert.toggle() } } @@ -176,6 +173,10 @@ struct HomeSelectionView: View { } } + private func select(home: UUID) { + Preferences.switchCurrentlyUsedSettings(to: home) + } + private func loadHomesList() { homes = Preferences.listStoredPreferences() } @@ -200,7 +201,6 @@ struct HomeSelectionView: View { } else { Preferences.renameHome(toRename, newHomeName: newName) } - loadHomesList() } private func addHome() { From 21a43e16e9f5be109be05e1e1598a53ab09af1a0 Mon Sep 17 00:00:00 2001 From: Dan Cunningham Date: Sun, 8 Jun 2025 13:59:19 -0700 Subject: [PATCH 222/476] Enable custom certs again (#873) * Enable custom certs again Signed-off-by: Dan Cunningham * fix tests Signed-off-by: Dan Cunningham --------- Signed-off-by: Dan Cunningham --- .../Util/ClientCertificateManager.swift | 18 +++++++++--------- .../Util/ServerCertificateManager.swift | 2 +- .../ClientCertificateManagerTests.swift | 6 +++--- openHAB/AppDelegate.swift | 10 ++++++++++ openHAB/OpenHABViewController.swift | 17 +++++++++++++---- .../Extension/OpenHABWatchAppDelegate.swift | 6 +++--- 6 files changed, 39 insertions(+), 20 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/ClientCertificateManager.swift b/OpenHABCore/Sources/OpenHABCore/Util/ClientCertificateManager.swift index fb1f3b3a8..f5acacfa8 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/ClientCertificateManager.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/ClientCertificateManager.swift @@ -16,11 +16,11 @@ import Security @MainActor public protocol ClientCertificateManagerDelegate: AnyObject { // delegate should ask user for a decision on whether to import the client certificate into the keychain - func askForClientCertificateImport() async -> Bool + func askForClientCertificateImport(_ clientCertificateManager: ClientCertificateManager?) async -> Bool // delegate should ask user for a decision on whether to import the client certificate into the keychain - func askForCertificatePassword() async -> String? + func askForCertificatePassword(_ clientCertificateManager: ClientCertificateManager?) async -> String? // delegate should alert the user that an error occured importing the certificate - func alertClientCertificateError(errMsg: String) async + func alertClientCertificateError(_ clientCertificateManager: ClientCertificateManager?, errMsg: String) async } public class ClientCertificateManager { @@ -29,7 +29,7 @@ public class ClientCertificateManager { private var importingCertChain: [SecCertificate]? public var importingPassword: String? - weak var delegate: (any ClientCertificateManagerDelegate)? + public weak var delegate: (any ClientCertificateManagerDelegate)? public var clientIdentities: [SecIdentity] = [] @@ -159,7 +159,7 @@ public class ClientCertificateManager { guard let delegate else { return false } - let shouldImport = await delegate.askForClientCertificateImport() + let shouldImport = await delegate.askForClientCertificateImport(self) return shouldImport } catch { logger.error("Failed to read certificate from URL: \(error.localizedDescription)") @@ -168,7 +168,7 @@ public class ClientCertificateManager { } @MainActor - public func clientCertificateAccepted(password: String) async { + public func clientCertificateAccepted(password: String?) async { importingPassword = password let status = decodePKCS12() @@ -177,7 +177,7 @@ public class ClientCertificateManager { await addClientCertificateToKeychain() case errSecAuthFailed: - guard let retryPassword = await delegate?.askForCertificatePassword() else { + guard let retryPassword = await delegate?.askForCertificatePassword(self) else { logger.warning("Password prompt cancelled after auth failure") return } @@ -185,7 +185,7 @@ public class ClientCertificateManager { default: let errMsg = String(format: NSLocalizedString("unable_to_decode_certificate", comment: ""), "\(status)") - await delegate?.alertClientCertificateError(errMsg: errMsg) + await delegate?.alertClientCertificateError(self, errMsg: errMsg) } } @@ -267,7 +267,7 @@ public class ClientCertificateManager { errorMessage = NSLocalizedString("certficate_exists", comment: "") } - await delegate?.alertClientCertificateError(errMsg: errorMessage) + await delegate?.alertClientCertificateError(self, errMsg: errorMessage) } } diff --git a/OpenHABCore/Sources/OpenHABCore/Util/ServerCertificateManager.swift b/OpenHABCore/Sources/OpenHABCore/Util/ServerCertificateManager.swift index 5010851d3..db8849fb1 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/ServerCertificateManager.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/ServerCertificateManager.swift @@ -35,7 +35,7 @@ public class ServerCertificateManager { // ServerTrustManager, ServerTrustEvalua case permitAlways } - weak var delegate: (any ServerCertificateManagerDelegate)? + public weak var delegate: (any ServerCertificateManagerDelegate)? // ignoreSSL is a synonym for allowInvalidCertificates, ignoreCertificates public var ignoreSSL = false public var trustedCertificates: [String: Data] = [:] diff --git a/OpenHABCore/Tests/OpenHABCoreTests/ClientCertificateManagerTests.swift b/OpenHABCore/Tests/OpenHABCoreTests/ClientCertificateManagerTests.swift index 40d6e137c..fa8f08dc9 100644 --- a/OpenHABCore/Tests/OpenHABCoreTests/ClientCertificateManagerTests.swift +++ b/OpenHABCore/Tests/OpenHABCoreTests/ClientCertificateManagerTests.swift @@ -20,15 +20,15 @@ final class MockClientCertDelegate: ClientCertificateManagerDelegate { var receivedErrorMessage: String? var receivedErrorCode: OSStatus? - func askForClientCertificateImport() async -> Bool { + func askForClientCertificateImport(_ clientCertificateManager: ClientCertificateManager?) async -> Bool { shouldImport } - func askForCertificatePassword() async -> String? { + func askForCertificatePassword(_ clientCertificateManager: ClientCertificateManager?) async -> String? { password } - func alertClientCertificateError(errMsg: String) async { + func alertClientCertificateError(_ clientCertificateManager: ClientCertificateManager?, errMsg: String) async { receivedErrorMessage = errMsg if let code = Int(errMsg.components(separatedBy: CharacterSet.decimalDigits.inverted).joined()) { receivedErrorCode = OSStatus(code) diff --git a/openHAB/AppDelegate.swift b/openHAB/AppDelegate.swift index a17ef10e2..2a0764142 100644 --- a/openHAB/AppDelegate.swift +++ b/openHAB/AppDelegate.swift @@ -151,9 +151,19 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // Probably need to do this in a way compatible to Android app's URL os_log("Calling Application Bundle ID: %{PUBLIC}@", log: .notifications, type: .info, options[UIApplication.OpenURLOptionsKey.sourceApplication] as? String ?? "") + os_log("URL: %{PUBLIC}@", log: .notifications, type: .info, url.absoluteString) os_log("URL scheme: %{PUBLIC}@", log: .notifications, type: .info, url.scheme ?? "") os_log("URL query: %{PUBLIC}@", log: .notifications, type: .info, url.query ?? "") + if url.isFileURL { + os_log("Loading Certificate", log: .notifications, type: .info) + let clientCertificateManager = NetworkTracker.shared.clientCertificateManager + Task { @MainActor in + await clientCertificateManager.startImportClientCertificate(url: url) + } + return true + } + // remove the 'openhab' from the url let action = url.absoluteString.split(separator: ":").dropFirst().joined(separator: ":") notifyNotificationListeners(action: action) diff --git a/openHAB/OpenHABViewController.swift b/openHAB/OpenHABViewController.swift index c7f43991b..d5a457000 100644 --- a/openHAB/OpenHABViewController.swift +++ b/openHAB/OpenHABViewController.swift @@ -24,6 +24,8 @@ class OpenHABViewController: UIViewController { super.viewDidLoad() NotificationCenter.default.addObserver(self, selector: #selector(OpenHABViewController.didEnterBackground(_:)), name: UIApplication.didEnterBackgroundNotification, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(OpenHABViewController.didBecomeActive(_:)), name: UIApplication.didBecomeActiveNotification, object: nil) + NetworkTracker.shared.clientCertificateManager.delegate = self + NetworkTracker.shared.serverCertificateManager.delegate = self } func showPopupMessage(seconds: Double, title: String, message: String, theme: Theme) { @@ -133,8 +135,8 @@ extension OpenHABViewController: ServerCertificateManagerDelegate { @MainActor extension OpenHABViewController: ClientCertificateManagerDelegate { // Ask user whether to import the certificate - func askForClientCertificateImport() async -> Bool { - await withCheckedContinuation { continuation in + func askForClientCertificateImport(_ clientCertificateManager: ClientCertificateManager?) async -> Bool { + let shouldImport = await withCheckedContinuation { continuation in let alertController = UIAlertController( title: NSLocalizedString("certificate_import_title", comment: ""), message: NSLocalizedString("certificate_import_text", comment: ""), @@ -154,10 +156,17 @@ extension OpenHABViewController: ClientCertificateManagerDelegate { self.present(alertController, animated: true) } + if shouldImport { + await clientCertificateManager!.clientCertificateAccepted(password: nil) + return true + } else { + clientCertificateManager!.clientCertificateRejected() + return false + } } // Ask user for password to decode PKCS#12 - func askForCertificatePassword() async -> String? { + func askForCertificatePassword(_ clientCertificateManager: ClientCertificateManager?) async -> String? { await withCheckedContinuation { continuation in let alertController = UIAlertController( title: NSLocalizedString("certificate_import_title", comment: ""), @@ -187,7 +196,7 @@ extension OpenHABViewController: ClientCertificateManagerDelegate { } // Show alert if certificate import failed - func alertClientCertificateError(errMsg: String) async { + func alertClientCertificateError(_ clientCertificateManager: ClientCertificateManager?, errMsg: String) async { let alertController = UIAlertController( title: NSLocalizedString("certificate_import_title", comment: ""), message: errMsg, diff --git a/openHABWatch/Extension/OpenHABWatchAppDelegate.swift b/openHABWatch/Extension/OpenHABWatchAppDelegate.swift index e475da0a6..33ca235de 100644 --- a/openHABWatch/Extension/OpenHABWatchAppDelegate.swift +++ b/openHABWatch/Extension/OpenHABWatchAppDelegate.swift @@ -64,15 +64,15 @@ extension OpenHABWatchAppDelegate: WKApplicationDelegate { extension OpenHABWatchAppDelegate: ClientCertificateManagerDelegate { // delegate should ask user for a decision on whether to import the client certificate into the keychain - func askForClientCertificateImport() async -> Bool { + func askForClientCertificateImport(_ clientCertificateManager: ClientCertificateManager?) async -> Bool { true } // delegate should ask user for the export password used to decode the PKCS#12 - func askForCertificatePassword() async -> String? { + func askForCertificatePassword(_ clientCertificateManager: ClientCertificateManager?) async -> String? { nil } // delegate should ask user for the export password used to decode the PKCS#12 - func alertClientCertificateError(errMsg: String) {} + func alertClientCertificateError(_ clientCertificateManager: ClientCertificateManager?, errMsg: String) {} } From fda44250ff9a0de743e12978a3d7a5a13cfc0702 Mon Sep 17 00:00:00 2001 From: Tassilo Karge Date: Mon, 9 Jun 2025 20:40:03 +0200 Subject: [PATCH 223/476] Remove redundancy in preferences, update watch when necessary (#878) * change watch preferences when loading or creating home Signed-off-by: Tassilo Karge * refactor all preferences types to have unified logging, storing and defaulting logic Signed-off-by: Tassilo Karge * use Combine framework to update preferences on watch when main app preferences change Signed-off-by: Tassilo Karge * use Combine framework to update crashlytics report sending Signed-off-by: Tassilo Karge * do not rely on deprecated settings for watch Signed-off-by: Tassilo Karge --------- Signed-off-by: Tassilo Karge --- .../OpenHABCore/Util/Preferences.swift | 189 ++++++++++-------- openHAB/AppDelegate.swift | 13 +- openHAB/DrawerView.swift | 1 - openHAB/OpenHABRootViewController.swift | 1 - openHAB/SettingsView/SettingsView.swift | 3 - openHAB/WatchMessageService.swift | 47 ++++- 6 files changed, 156 insertions(+), 98 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift index 543ce63fb..5623e4145 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift @@ -22,48 +22,10 @@ public struct UserDefault { public var wrappedValue: T { get { - let preferenceValue = Preferences.sharedDefaults.object(forKey: key) - if let preferenceAsT = preferenceValue as? T { - os_log( - "Preference value %{PUBLIC}@ is %{PUBLIC}@", - log: .default, - type: .debug, - key, - "\(preferenceAsT)" - ) - return preferenceAsT - } else { - if let preferenceValue { - os_log( - "Preference value %{PUBLIC}@ was %{PUBLIC}@ but did not conform to %{PUBLIC}@. Replace with default value.", - log: .default, - type: .fault, - key, - "\(preferenceValue)", - "\(T.self)" - ) - } else { - os_log( - "Preference value %{PUBLIC}@ was set for the first time. Using default value.", - log: .default, - type: .info, - key - ) - } - let fallback = defaultValue - Preferences.sharedDefaults.set(fallback, forKey: key) - return fallback - } + Preferences.getPreference(key: key, defaultValue: defaultValue, encoder: { $0 }, decoder: { $0 as? T }) } set { - os_log("Preference %{PUBLIC}@ will be changed to value %{PUBLIC}@", log: .default, type: .debug, key, "\(newValue)") - Preferences.sharedDefaults.set(newValue, forKey: key) - if store { - Preferences.storeCurrentPreferences(updatedKey: key, updatedValue: newValue) - } - DispatchQueue.main.async { [subject] in - subject.send(newValue) - } + Preferences.preferenceChanged(newValue: newValue, key: key, store: store, subject: subject) { $0 } } } @@ -75,7 +37,7 @@ public struct UserDefault { self.key = key self.defaultValue = defaultValue self.store = store - let currentValue = Preferences.sharedDefaults.object(forKey: key) as? T ?? defaultValue + let currentValue = Preferences.getPreference(key: key, defaultValue: defaultValue, encoder: { $0 }, decoder: { $0 as? T }) subject = CurrentValueSubject(currentValue) } } @@ -87,25 +49,21 @@ public struct UserDefaultObject { private let store: Bool private let subject: CurrentValueSubject + private let objectDecoder: (Any) -> (T?) = { + guard let data = $0 as? Data else { + return nil + } + return try? JSONDecoder().decode(T.self, from: data) + } + + private let objectEncoder: (T) -> (any Sendable)? = { try? JSONEncoder().encode($0) } + public var wrappedValue: T { get { - guard let data = Preferences.sharedDefaults.data(forKey: key), - let object = try? JSONDecoder().decode(T.self, from: data) else { - return defaultValue - } - return object + Preferences.getPreference(key: key, defaultValue: defaultValue, encoder: objectEncoder, decoder: objectDecoder) } set { - if let encoded = try? JSONEncoder().encode(newValue) { - Preferences.sharedDefaults.set(encoded, forKey: key) - if store { - Preferences.storeCurrentPreferences(updatedKey: key, updatedValue: encoded) - } - // Relevant for Combine publication - DispatchQueue.main.async { [subject] in - subject.send(newValue) - } - } + Preferences.preferenceChanged(newValue: newValue, key: key, store: store, subject: subject, converter: objectEncoder) } } @@ -119,17 +77,29 @@ public struct UserDefaultObject { self.store = store // Combine publication - if let data = Preferences.sharedDefaults.data(forKey: key), - let object = try? JSONDecoder().decode(T.self, from: data) { - subject = CurrentValueSubject(object) - } else { - subject = CurrentValueSubject(defaultValue) - } + let currentValue = Preferences.getPreference(key: key, defaultValue: defaultValue, encoder: objectEncoder, decoder: objectDecoder) + subject = CurrentValueSubject(currentValue) } } @propertyWrapper @MainActor public struct UserDefaultURL { + private static let urlSanitizer: (String) -> (String?) = { + // Trim and validate the new URL + let trimmedUri = $0.removeTrailingSlashes().trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmedUri.isValidURL || trimmedUri.isEmpty else { // empty is the default for localUrl + return nil + } + return trimmedUri + } + + private static let urlConverter: (Any) -> (String?) = { + guard let preferenceString = $0 as? String else { + return nil + } + return urlSanitizer(preferenceString) + } + private let key: String private let defaultValue: String private let store: Bool @@ -137,25 +107,10 @@ public struct UserDefaultURL { public var wrappedValue: String { get { - let storedValue = Preferences.sharedDefaults.string(forKey: key) ?? defaultValue - let trimmedUri = storedValue.removeTrailingSlashes().trimmingCharacters(in: .whitespacesAndNewlines) - return trimmedUri.isValidURL ? trimmedUri : defaultValue + Preferences.getPreference(key: key, defaultValue: defaultValue, encoder: UserDefaultURL.urlSanitizer, decoder: UserDefaultURL.urlConverter) } set { - Preferences.sharedDefaults.set(newValue, forKey: key) - if store { - Preferences.storeCurrentPreferences(updatedKey: key, updatedValue: newValue) - } - let defaultValue = defaultValue - // Trim and validate the new URL - let trimmedUri = newValue.removeTrailingSlashes().trimmingCharacters(in: .whitespacesAndNewlines) - DispatchQueue.main.async { [subject] in - if trimmedUri.isValidURL { - subject.send(trimmedUri) - } else { - subject.send(defaultValue) - } - } + Preferences.preferenceChanged(newValue: newValue, key: key, store: true, subject: subject, sanitize: UserDefaultURL.urlSanitizer) { $0 } } } @@ -167,7 +122,7 @@ public struct UserDefaultURL { self.key = key self.defaultValue = defaultValue self.store = store - let currentValue = Preferences.sharedDefaults.string(forKey: key) ?? defaultValue + let currentValue = Preferences.getPreference(key: key, defaultValue: defaultValue, encoder: { $0 }, decoder: UserDefaultURL.urlConverter) subject = CurrentValueSubject(currentValue) } } @@ -176,7 +131,7 @@ public struct UserDefaultURL { public enum Preferences { static let sharedDefaults = UserDefaults(suiteName: "group.org.openhab.app")! - // MARK: - Public Deprecated + // MARK: - Public Deprecated preferences @UserDefaultURL("localUrl", defaultValue: "", store: false) public static var localUrl: String @UserDefaultURL("remoteUrl", defaultValue: "https://myopenhab.org", store: false) public static var remoteUrl: String @@ -185,7 +140,7 @@ public enum Preferences { @UserDefault("alwaysSendCreds", defaultValue: false, store: false) public static var alwaysSendCreds: Bool @UserDefault("ignoreSSL", defaultValue: false, store: false) public static var ignoreSSL: Bool - // MARK: - Public Home related + // MARK: - Public Home related preferences @UserDefaultURL("defaultView", defaultValue: "web") public static var defaultView: String @UserDefault("demomode", defaultValue: true) public static var demomode: Bool @@ -201,15 +156,16 @@ public enum Preferences { @UserDefault("sitemapForWatchLabel", defaultValue: "watch") public static var sitemapForWatchLabel: String @UserDefault("homeName", defaultValue: "Home") public static var homeName: String - // MARK: - Public App related + // MARK: - Public App related preferences @UserDefault("sendCrashReports", defaultValue: false, store: false) public static var sendCrashReports: Bool + @UserDefault("idleOff", defaultValue: false, store: false) public static var idleOff: Bool /// settings for different homes TODO come up with better name @UserDefault("storedPreferences", defaultValue: [:], store: false) public static var storedPreferences: [String: [String: any Sendable]] - // MARK: - Private + // MARK: - Private preferences /// the currently applied settings set from storedPreferences @UserDefault("currentlyUsedSettings", defaultValue: UUID().uuidString, store: false) public private(set) static var currentlyUsedSettings: String @@ -221,6 +177,67 @@ public enum Preferences { private static var loadingStoredPreferences = false } +// MARK: Retrieving preference from user defaults, reacting to preference change + +private extension Preferences { + static func getPreference(key: String, defaultValue: T, encoder: (T) -> (some Sendable)?, decoder: (Any?) -> T?) -> T { + let preferenceValue = Preferences.sharedDefaults.object(forKey: key) + if let preferenceConverted = decoder(preferenceValue) { + os_log( + "Preference value %{PUBLIC}@ is %{PUBLIC}@", + log: .default, + type: .debug, + key, + "\(preferenceConverted)" + ) + return preferenceConverted + } else { + if let preferenceValue { + os_log( + "Preference value %{PUBLIC}@ was \"%{PUBLIC}@\" but did not conform to %{PUBLIC}@. Replace with default value.", + log: .default, + type: .fault, + key, + "\(preferenceValue)", + "\(T.self)" + ) + } else { + os_log( + "Preference value %{PUBLIC}@ was set for the first time. Using default value.", + log: .default, + type: .info, + key + ) + } + let fallback = defaultValue + Preferences.sharedDefaults.set(encoder(fallback), forKey: key) + return fallback + } + } + + static func preferenceChanged(newValue: T, key: String, store: Bool, subject: CurrentValueSubject, sanitize: (T) -> (T?) = { $0 }, converter: (T) -> (some Sendable)?) { + guard let sanitized = sanitize(newValue) else { + os_log("Preference %{PUBLIC}@ new value \"%{PUBLIC}@\" could not be sanitized, will be ignored", log: .default, type: .debug, key, "\(newValue)") + return + } + let convertedValue = converter(sanitized) + guard convertedValue != nil else { + os_log("Preference %{PUBLIC}@ conversion of new value %{PUBLIC}@ failed, do not store.", log: .default, type: .debug, key, "\(sanitized)") + return + } + os_log("Preference %{PUBLIC}@ will be changed to value %{PUBLIC}@", log: .default, type: .debug, key, "\(newValue)") + Preferences.sharedDefaults.set(convertedValue, forKey: key) + if store { + Preferences.storeCurrentPreferences(updatedKey: key, updatedValue: convertedValue) + } + DispatchQueue.main.async { [subject] in + subject.send(sanitized) + } + } +} + +// MARK: Multiple homes + public extension Preferences { static func listStoredPreferences() -> [UUID] { let preferenceIds = storedPreferences @@ -325,6 +342,8 @@ public extension Preferences { } } +// MARK: Migration + public extension Preferences { static func migrateUserDefaultsIfRequired() { guard !didMigrateToSharedDefaults else { return } @@ -380,6 +399,8 @@ public extension Preferences { } } +// MARK: All connections + public extension Preferences { static func getLowestPriorityOpenHABConnection() -> ConnectionConfiguration? { let allConnections = [localConnectionConfig, remoteConnectionConfig] diff --git a/openHAB/AppDelegate.swift b/openHAB/AppDelegate.swift index 2a0764142..215cba8b6 100644 --- a/openHAB/AppDelegate.swift +++ b/openHAB/AppDelegate.swift @@ -10,6 +10,7 @@ // SPDX-License-Identifier: EPL-2.0 import AVFoundation +import Combine import Firebase import FirebaseMessaging import Kingfisher @@ -55,13 +56,17 @@ class AppDelegate: UIResponder, UIApplicationDelegate { let audioPlayer = AudioPlayerActor() var window: UIWindow? + private var crashlyticsSubscriber: AnyCancellable? + // Delegate Requests from the Watch to the WatchMessageService var session: WCSession? { didSet { if let session { - session.delegate = WatchMessageService.singleton + let watchMessageService = WatchMessageService.singleton + session.delegate = watchMessageService session.activate() os_log("Paired watch %{PUBLIC}@, watch app installed %{PUBLIC}@", log: .watch, type: .info, "\(session.isPaired)", "\(session.isWatchAppInstalled)") + watchMessageService.subscribeToPreferences() } } } @@ -109,7 +114,11 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // init Firebase crash reporting FirebaseApp.configure() FirebaseApp.app()?.isDataCollectionDefaultEnabled = false - Crashlytics.crashlytics().setCrashlyticsCollectionEnabled(Preferences.sendCrashReports) + crashlyticsSubscriber = Preferences.$sendCrashReports.sink { [weak self] in + // TODO: is this called once we setup this subscriber? Otherwise we need to manually invoke it for setting up + Crashlytics.crashlytics().setCrashlyticsCollectionEnabled($0) + self?.logger.debug("setCrashlyticsCollectionEnabled to \($0)") + } Messaging.messaging().delegate = self } diff --git a/openHAB/DrawerView.swift b/openHAB/DrawerView.swift index 957aaa912..6e0885e19 100644 --- a/openHAB/DrawerView.swift +++ b/openHAB/DrawerView.swift @@ -147,7 +147,6 @@ struct DrawerView: View { Preferences.sitemapForWatch = sitemap.name Preferences.sitemapForWatchLabel = sitemap.label } - WatchMessageService.singleton.syncPreferencesToWatch() } } } diff --git a/openHAB/OpenHABRootViewController.swift b/openHAB/OpenHABRootViewController.swift index 152a1d4d5..49f2a855d 100644 --- a/openHAB/OpenHABRootViewController.swift +++ b/openHAB/OpenHABRootViewController.swift @@ -68,7 +68,6 @@ class OpenHABRootViewController: UIViewController { alertController.addAction( UIAlertAction(title: NSLocalizedString("activate", comment: ""), style: .default) { _ in Preferences.sendCrashReports = true - Crashlytics.crashlytics().setCrashlyticsCollectionEnabled(true) Crashlytics.crashlytics().sendUnsentReports() } ) diff --git a/openHAB/SettingsView/SettingsView.swift b/openHAB/SettingsView/SettingsView.swift index da67021dc..a944d9c3e 100644 --- a/openHAB/SettingsView/SettingsView.swift +++ b/openHAB/SettingsView/SettingsView.swift @@ -161,9 +161,6 @@ struct SettingsView: View { Preferences.sitemapForWatchLabel = sitemaps.first { $0.name == settingsSitemapForWatch }?.label ?? "unknown" Preferences.localConnectionConfig = settingsLocalConnectionConfiguration Preferences.remoteConnectionConfig = settingsRemoteConnectionConfiguration - WatchMessageService.singleton.syncPreferencesToWatch() - Crashlytics.crashlytics().setCrashlyticsCollectionEnabled(settingsSendCrashReports) - logger.debug("setCrashlyticsCollectionEnabled to \(settingsSendCrashReports)") } } diff --git a/openHAB/WatchMessageService.swift b/openHAB/WatchMessageService.swift index b4eb6dc03..706258850 100644 --- a/openHAB/WatchMessageService.swift +++ b/openHAB/WatchMessageService.swift @@ -9,6 +9,7 @@ // // SPDX-License-Identifier: EPL-2.0 +import Combine import Foundation import OpenHABCore import os.log @@ -22,9 +23,11 @@ class WatchMessageService: NSObject, WCSessionDelegate { private lazy var logger = Logger(subsystem: "org.openhab.app", category: "WatchMessageService") - private var cachedWatchPreferences: [String: Any] = [:] + private var cachedWatchPreferences: [String: Data] = [:] private let lock = NSLock() + private var preferencesSubscription: AnyCancellable? + // This method gets called when the watch requests the data // ⚠️ This is called off the main thread. Do NOT touch @MainActor stuff. func session(_ session: WCSession, didReceiveMessage message: [String: Any], replyHandler: @escaping ([String: Any]) -> Void) { @@ -55,6 +58,31 @@ class WatchMessageService: NSObject, WCSessionDelegate { // MARK: - Sync Preferences + @MainActor + public func subscribeToPreferences() { + let currentlyUsedSettings: AnyPublisher = Preferences.$currentlyUsedSettings + .map { $0 as any Sendable } + .eraseToAnyPublisher() + let watchRelatedSettings: AnyPublisher = currentlyUsedSettings + .merge( + with: + Preferences.$defaultSitemap.map { $0 as any Sendable }.eraseToAnyPublisher(), + Preferences.$sitemapForWatch.map { $0 as any Sendable }.eraseToAnyPublisher(), + Preferences.$sitemapForWatchLabel.map { $0 as any Sendable }.eraseToAnyPublisher(), + Preferences.$iconType.map { $0 as any Sendable }.eraseToAnyPublisher(), + Preferences.$demomode.map { $0 as any Sendable }.eraseToAnyPublisher(), + Preferences.$localConnectionConfig.map { $0 as any Sendable }.eraseToAnyPublisher(), + Preferences.$localConnectionConfig.map { $0 as any Sendable }.eraseToAnyPublisher() + ) + .eraseToAnyPublisher() + + preferencesSubscription = watchRelatedSettings + .debounce(for: .seconds(1), scheduler: RunLoop.main) + .sink { _ in } receiveValue: { _ in + self.syncPreferencesToWatch() + } + } + @MainActor public func syncPreferencesToWatch() { guard WCSession.default.activationState == .activated else { @@ -65,6 +93,11 @@ class WatchMessageService: NSObject, WCSessionDelegate { let prefs = WatchPreferences(fromPreferences: Preferences.self) let context = prefs.encodedWatchPreferences() + guard cachedWatchPreferences != context else { + // avoid update of update unchanged preferences + return + } + lock.lock() cachedWatchPreferences = context lock.unlock() @@ -82,13 +115,13 @@ class WatchMessageService: NSObject, WCSessionDelegate { extension WatchPreferences { init(fromPreferences preferences: Preferences.Type) { self.init( - localUrl: preferences.localUrl, - remoteUrl: preferences.remoteUrl, - username: preferences.username, - password: preferences.password, - alwaysSendCreds: preferences.alwaysSendCreds, + localUrl: preferences.localConnectionConfig.url, + remoteUrl: preferences.remoteConnectionConfig.url, + username: preferences.remoteConnectionConfig.username, + password: preferences.remoteConnectionConfig.password, + alwaysSendCreds: preferences.remoteConnectionConfig.alwaysSendBasicAuth, defaultSitemap: preferences.defaultSitemap, - ignoreSSL: preferences.ignoreSSL, + ignoreSSL: preferences.remoteConnectionConfig.ignoreSSL, sitemapForWatch: preferences.sitemapForWatch, sitemapForWatchLabel: preferences.sitemapForWatchLabel, iconType: preferences.iconType, From 96972ad607a4567b530d1dfe0751eb11a98cafae Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Tue, 10 Jun 2025 13:59:55 +0200 Subject: [PATCH 224/476] Update .swift-version to 5.10 Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .swift-version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.swift-version b/.swift-version index 95ee81a41..f9ce5a96e 100644 --- a/.swift-version +++ b/.swift-version @@ -1 +1 @@ -5.9 +5.10 From 0c53aa813552d68753e8bd6f1b5ddc2d3395f6c4 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Tue, 10 Jun 2025 14:18:13 +0200 Subject: [PATCH 225/476] Improve NetworkTracker offline test (#880) --- .../OpenHABCoreTests/NetworkTrackerTests.swift | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/OpenHABCore/Tests/OpenHABCoreTests/NetworkTrackerTests.swift b/OpenHABCore/Tests/OpenHABCoreTests/NetworkTrackerTests.swift index c679184ae..084277f26 100644 --- a/OpenHABCore/Tests/OpenHABCoreTests/NetworkTrackerTests.swift +++ b/OpenHABCore/Tests/OpenHABCoreTests/NetworkTrackerTests.swift @@ -84,11 +84,15 @@ final actor MockOpenAPIService: OpenAPIServiceProtocol { final class MockPathMonitor: NWPathMonitoring { private var handler: ((Bool) async -> Void)? + var onStartMonitoring: (() -> Void)? - init() {} + init(onStartMonitoring: (() -> Void)? = nil) { + self.onStartMonitoring = onStartMonitoring + } func startMonitoring(handler: @escaping (Bool) async -> Void) async { self.handler = handler + onStartMonitoring?() } func cancel() { @@ -150,10 +154,9 @@ final class NetworkTrackerTests: XCTestCase { func testTrackerGoesOfflineOnNetworkLoss() async { let statusSinkAttached = XCTestExpectation(description: "Combine sink attached") let becameNotConnected = XCTestExpectation(description: "Status becomes .notConnected") + let monitorStarted = XCTestExpectation(description: "Path monitor started") - let expectation = XCTestExpectation(description: "Status becomes .notConnected") - - let mockMonitor = MockPathMonitor() // ⬅️ Hold on to this + let mockMonitor = MockPathMonitor { monitorStarted.fulfill() } // ⬅️ Hold on to this let tracker = NetworkTracker( monitor: mockMonitor, connectionPool: ConnectionPool { _ in MockOpenAPIService() }, @@ -187,8 +190,8 @@ final class NetworkTrackerTests: XCTestCase { ConnectionConfiguration(url: "http://mock", username: "", password: "", priority: 0) ]) - // 🚦 Wait until Combine is ready before triggering anything - await fulfillment(of: [statusSinkAttached], timeout: 2.0) + // 🚦 Wait until Combine and monitoring are ready before triggering anything + await fulfillment(of: [statusSinkAttached, monitorStarted], timeout: 2.0) // Simulate loss of network mockMonitor.simulateConnection(isConnected: false) // ✅ use directly From a0667fcfbceac8aa4e15ddec98b6e7e1d994cb38 Mon Sep 17 00:00:00 2001 From: Tassilo Karge Date: Sun, 15 Jun 2025 17:53:23 +0200 Subject: [PATCH 226/476] push notification registration for all homes (#879) Signed-off-by: Tassilo Karge --- .../OpenHABCore/Util/Preferences.swift | 17 +++- openHAB/AppDelegate.swift | 1 - openHAB/OpenHABRootViewController.swift | 80 ++++++++++++++++--- 3 files changed, 81 insertions(+), 17 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift index 5623e4145..3fbd998e5 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift @@ -402,14 +402,25 @@ public extension Preferences { // MARK: All connections public extension Preferences { - static func getLowestPriorityOpenHABConnection() -> ConnectionConfiguration? { - let allConnections = [localConnectionConfig, remoteConnectionConfig] + static func getLowestPriorityOpenHABConnection(of stored: [String: Any]) -> ConnectionConfiguration? { + let localConfig = stored["localConnectionConfig"] as? Data ?? Data() + let localConnection = try? JSONDecoder().decode(ConnectionConfiguration.self, from: localConfig) + let remoteConfig = stored["remoteConnectionConfig"] as? Data ?? Data() + let remoteConnection = try? JSONDecoder().decode(ConnectionConfiguration.self, from: remoteConfig) + return Preferences.getLowestPriorityOpenHABConnection(of: [localConnection, remoteConnection]) + } - return allConnections + static func getLowestPriorityOpenHABConnection(of connections: [ConnectionConfiguration?]) -> ConnectionConfiguration? { + connections + .compactMap { $0 } .filter { $0.url.contains("openhab.org") } .sorted { $0.priority < $1.priority } .first } + + static func getLowestPriorityOpenHABConnection() -> ConnectionConfiguration? { + getLowestPriorityOpenHABConnection(of: [localConnectionConfig, remoteConnectionConfig]) + } } // MARK: - Sample Codable Model diff --git a/openHAB/AppDelegate.swift b/openHAB/AppDelegate.swift index 215cba8b6..e1a2c6b01 100644 --- a/openHAB/AppDelegate.swift +++ b/openHAB/AppDelegate.swift @@ -115,7 +115,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate { FirebaseApp.configure() FirebaseApp.app()?.isDataCollectionDefaultEnabled = false crashlyticsSubscriber = Preferences.$sendCrashReports.sink { [weak self] in - // TODO: is this called once we setup this subscriber? Otherwise we need to manually invoke it for setting up Crashlytics.crashlytics().setCrashlyticsCollectionEnabled($0) self?.logger.debug("setCrashlyticsCollectionEnabled to \($0)") } diff --git a/openHAB/OpenHABRootViewController.swift b/openHAB/OpenHABRootViewController.swift index 49f2a855d..c55e1b8a6 100644 --- a/openHAB/OpenHABRootViewController.swift +++ b/openHAB/OpenHABRootViewController.swift @@ -42,6 +42,8 @@ class OpenHABRootViewController: UIViewController { var isDemoMode = false var cancellables = Set() + private var apsRegistrationData: [AnyHashable: Any]? + private lazy var webViewController: OpenHABWebViewController = { let storyboard = UIStoryboard(name: "Main", bundle: Bundle.main) var viewController = storyboard.instantiateViewController(withIdentifier: "OpenHABWebViewController") as! OpenHABWebViewController @@ -328,19 +330,71 @@ class OpenHABRootViewController: UIViewController { @objc func handleApsRegistration(_ note: Notification?) { logger.info("handleApsRegistration") - let theData = note?.userInfo - if theData != nil { - guard let config = Preferences.getLowestPriorityOpenHABConnection() else { return } - guard let deviceId = theData?["deviceId"] as? String, let deviceToken = theData?["deviceToken"] as? String, let deviceName = theData?["deviceName"] as? String else { return } - logger.info("Registering notifications with \(config.url)") - Task { - do { - let client = HTTPClient(configuration: config) - try await client.register(prefsURL: config.url, deviceToken: deviceToken, deviceId: deviceId, deviceName: deviceName) - logger.info("my.openHAB registration succeeded") - } catch { - logger.error("my.openHAB registration failed \(error.localizedDescription)") - } + apsRegistrationData = note?.userInfo + subscribeToOpenhabConnectionChanges() + } + + private func subscribeToOpenhabConnectionChanges() { + struct UuidWithConnection: Hashable, Equatable { + let uuid: String + let connection: ConnectionConfiguration // not only URL, because auth and certs might be relevant for establishing the connection + } + + let storedOpenHabConnections = Preferences.$storedPreferences + .debounce(for: .seconds(1), scheduler: RunLoop.main) // avoid overexcited registrations / deregistrations in batch updates + .map { storedPrefsUpdate in // we want to recognize changes in the OpenHab URLs for any of the homes + Set(storedPrefsUpdate.compactMap { storedWithUuid in + let (uuid, homeConfig) = storedWithUuid + guard let connection = Preferences.getLowestPriorityOpenHABConnection(of: homeConfig) else { return nil } + return UuidWithConnection(uuid: uuid, connection: connection) + }) + } + + // create a tuple that lets us inspect the previous value + let connectionsWithPreviousValues = storedOpenHabConnections + .scan((previous: Set(), current: Set())) { previous, current in + (previous: previous.current, current: current) + } + + let differences = connectionsWithPreviousValues.map { (previous, current) in // diff set of previous and current OpenHab URLs + (newValues: current.subtracting(previous), deletedValues: previous.subtracting(current)) + } + + let openhabConnectionSubscription = differences.sink { [weak self] diff in + for newHome in diff.newValues { + self?.registerHome(uuid: newHome.uuid, connection: newHome.connection) + } + for deletedHome in diff.deletedValues { + // TODO: implement deregistration + logger.warning("APNS Deregistration is missing (wanted to deregister \(deletedHome.connection.url))") + } + } + + cancellables.insert(openhabConnectionSubscription) + } + + private func registerHome(uuid: String, connection: ConnectionConfiguration) { + guard let apsRegistrationData else { + logger.fault("Cannot register homes for push notifications, no notification registration data available") + return + } + guard let deviceId = apsRegistrationData["deviceId"] as? String, + let deviceToken = apsRegistrationData["deviceToken"] as? String, + let deviceName = apsRegistrationData["deviceName"] as? String else { + return + } + logger.info("Registering notifications with \(connection.url)") + _ = registerHome(connection, deviceToken, deviceId, deviceName) + } + + private func registerHome(_ config: ConnectionConfiguration, _ deviceToken: String, _ deviceId: String, _ deviceName: String) -> Task { + Task { + do { + let client = HTTPClient(configuration: config) + try await client.register(prefsURL: config.url, deviceToken: deviceToken, deviceId: deviceId, deviceName: deviceName) + logger.info("my.openHAB registration succeeded") + } catch { + logger.error("my.openHAB registration failed \(error.localizedDescription)") } } } From ee6d77c4eb9df738e3d814218a23712c1f4e86d7 Mon Sep 17 00:00:00 2001 From: Dan Cunningham Date: Sun, 22 Jun 2025 10:08:07 -0700 Subject: [PATCH 227/476] [WIP] openapigen multiple cloud support (#881) * Supports notifications for multiple homes/cloud instances Signed-off-by: Dan Cunningham * Clean up Signed-off-by: Dan Cunningham --------- Signed-off-by: Dan Cunningham --- NotificationService/NotificationService.swift | 33 +- .../Util/ConnectionConfiguration.swift | 53 ++- .../Sources/OpenHABCore/Util/HTTPClient.swift | 5 +- .../OpenHABCore/Util/NetworkTracker.swift | 4 + .../OpenHABCore/Util/Preferences.swift | 333 ++++++++++++------ .../xcschemes/NotificationService.xcscheme | 98 ++++++ openHAB/AppDelegate.swift | 20 +- openHAB/DrawerView.swift | 4 +- openHAB/HomeSelectionView.swift | 2 +- openHAB/NewImageUITableViewCell.swift | 4 +- openHAB/NotificationsView.swift | 2 +- openHAB/OpenHABRootViewController.swift | 41 ++- .../SettingsView/ConnectionSettingsView.swift | 8 +- openHAB/SettingsView/SettingsView.swift | 5 +- .../SingleConnectionSettingsView.swift | 39 +- openHAB/VideoUITableViewCell.swift | 2 +- 16 files changed, 490 insertions(+), 163 deletions(-) create mode 100644 openHAB.xcodeproj/xcshareddata/xcschemes/NotificationService.xcscheme diff --git a/NotificationService/NotificationService.swift b/NotificationService/NotificationService.swift index 5975b9e09..ba61b1eef 100644 --- a/NotificationService/NotificationService.swift +++ b/NotificationService/NotificationService.swift @@ -46,7 +46,8 @@ class NotificationService: UNNotificationServiceExtension { var contentHandler: ((UNNotificationContent) -> Void)? var bestAttemptContent: UNMutableNotificationContent? var cancellables = Set() - + var networkTracker: NetworkTracker? + var cloudUserId: String? let logger = Logger(subsystem: "org.openhab.network", category: "NotificationService") override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { @@ -66,6 +67,8 @@ class NotificationService: UNNotificationServiceExtension { bestAttemptContent.body = message } + cloudUserId = userInfo["userId"] as? String + // Check if the user has defined custom actions in the payload if let actionsArray = parseActions(userInfo), let category = parseCategory(userInfo) { for actionDict in actionsArray { @@ -186,11 +189,9 @@ class NotificationService: UNNotificationServiceExtension { } private func downloadMedia(url: String) async throws -> (URL?, String?) { - await NetworkTracker.shared.startTracking(connectionConfigurations: [Preferences.localConnectionConfig, Preferences.remoteConnectionConfig]) - guard let fullURL = await resolveFullURL(from: url) else { return (nil, nil) } - guard let activeConfig = await NetworkTracker.shared.waitForActiveConnection()?.configuration else { return (nil, nil) } + guard let activeConfig = await networkTracker().waitForActiveConnection()?.configuration else { return (nil, nil) } let client = HTTPClient(configuration: activeConfig) @@ -201,7 +202,7 @@ class NotificationService: UNNotificationServiceExtension { // 🔹 Extracted helper function to determine full URL private func resolveFullURL(from url: String) async -> URL? { if url.starts(with: "/") { - guard let activeConfig = await NetworkTracker.shared.waitForActiveConnection()?.configuration else { return nil } + guard let activeConfig = await networkTracker().waitForActiveConnection()?.configuration else { return nil } return URL(string: activeConfig.url)?.appendingPathComponent(url) } else { return URL(string: url) @@ -221,7 +222,7 @@ class NotificationService: UNNotificationServiceExtension { let itemName = String(itemURL.absoluteString.dropFirst(scheme.count + 1)) - let item = try await NetworkTracker.shared.getItemByName(id: itemName) + let item = try await networkTracker().getItemByName(id: itemName) guard let state = item?.state else { return (nil, nil) } // Extract MIME type and base64 string @@ -270,4 +271,24 @@ class NotificationService: UNNotificationServiceExtension { } return nil } + + func networkTracker() async -> NetworkTracker { + if let cached = networkTracker { + return cached + } + let tracker = NetworkTracker.shared + let connections: [ConnectionConfiguration] + if let cloudUserId, + let uuid = await Preferences.storedSettingsId(forCloudUserId: cloudUserId), + let instance = await Preferences.preferenceInstance(for: uuid.uuidString) { + logger.info("setting up network tracking for \(cloudUserId)") + connections = [instance.localConnectionConfig, instance.remoteConnectionConfig] + } else { + logger.info("Using default connection configurations") + connections = await [Preferences.localConnectionConfig, Preferences.remoteConnectionConfig] + } + await tracker.startTracking(connectionConfigurations: connections) + networkTracker = tracker + return tracker + } } diff --git a/OpenHABCore/Sources/OpenHABCore/Util/ConnectionConfiguration.swift b/OpenHABCore/Sources/OpenHABCore/Util/ConnectionConfiguration.swift index 15cf8c16b..60089b7e9 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/ConnectionConfiguration.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/ConnectionConfiguration.swift @@ -17,10 +17,8 @@ public struct ConnectionPayload: Codable { } public struct ConnectionConfiguration: Hashable, Sendable, Codable { - // 🔹 Coding keys for manual encoding/decoding - private enum CodingKeys: String, CodingKey { - case url, username, password, alwaysSendBasicAuth, ignoreSSL, priority - } + public static let defaultLocalURL = "https://openhab.local:8443" + public static let defaultRemoteURL = "https://myopenhab.org" public var url: String public var username: String @@ -28,14 +26,17 @@ public struct ConnectionConfiguration: Hashable, Sendable, Codable { public var alwaysSendBasicAuth: Bool public var ignoreSSL: Bool public var priority: Int // Lower is higher priority, 0 is primary + public var supportsNotifications: Bool = false + public var cloudUserId: String? - public init(url: String, username: String, password: String, alwaysSendBasicAuth: Bool = false, ignoreSSL: Bool = false, priority: Int = 10) { + public init(url: String, username: String, password: String, alwaysSendBasicAuth: Bool = false, ignoreSSL: Bool = false, supportsNotifications: Bool = false, priority: Int = 10) { self.url = ConnectionConfiguration.normalizeURL(url) self.username = username self.password = password self.alwaysSendBasicAuth = alwaysSendBasicAuth self.ignoreSSL = ignoreSSL self.priority = priority + self.supportsNotifications = supportsNotifications } // 🔹 Ensure normalization on decoding @@ -47,7 +48,39 @@ public struct ConnectionConfiguration: Hashable, Sendable, Codable { password = try container.decode(String.self, forKey: .password) alwaysSendBasicAuth = try container.decode(Bool.self, forKey: .alwaysSendBasicAuth) ignoreSSL = try container.decode(Bool.self, forKey: .ignoreSSL) + supportsNotifications = try container.decode(Bool.self, forKey: .supportsNotifications) priority = try container.decode(Int.self, forKey: .priority) + cloudUserId = try container.decodeIfPresent(String.self, forKey: .cloudUserId) + } + + // MARK: - Static helpers with default values that can be overidden if needed + + public static func makeDefaultLocal(customize: ((inout ConnectionConfiguration) -> Void)? = nil) -> ConnectionConfiguration { + var cfg = ConnectionConfiguration( + url: defaultLocalURL, + username: "", + password: "", + alwaysSendBasicAuth: false, + ignoreSSL: false, + supportsNotifications: false, + priority: 0 + ) + customize?(&cfg) + return cfg + } + + public static func makeDefaultRemote(customize: ((inout ConnectionConfiguration) -> Void)? = nil) -> ConnectionConfiguration { + var cfg = ConnectionConfiguration( + url: defaultRemoteURL, + username: "", + password: "", + alwaysSendBasicAuth: false, + ignoreSSL: false, + supportsNotifications: true, + priority: 1 + ) + customize?(&cfg) + return cfg } // 🔹 Normalize a URL (removes trailing slashes, trims spaces, redirects openHAB cloud) @@ -75,6 +108,16 @@ public struct ConnectionConfiguration: Hashable, Sendable, Codable { try container.encode(password, forKey: .password) try container.encode(alwaysSendBasicAuth, forKey: .alwaysSendBasicAuth) try container.encode(ignoreSSL, forKey: .ignoreSSL) + try container.encode(supportsNotifications, forKey: .supportsNotifications) try container.encode(priority, forKey: .priority) + if let cloudUserId { + try container.encode(cloudUserId, forKey: .cloudUserId) + } + } +} + +extension ConnectionConfiguration { + private enum CodingKeys: String, CodingKey { + case url, username, password, alwaysSendBasicAuth, ignoreSSL, supportsNotifications, priority, cloudUserId } } diff --git a/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift b/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift index 413252409..7982916a1 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift @@ -159,10 +159,11 @@ public final class HTTPClient: NSObject { public func register(prefsURL: String, deviceToken: String, deviceId: String, - deviceName: String) async throws -> Data? { + deviceName: String) async throws -> String? { if let url = Endpoint.appleRegistration(prefsURL: prefsURL, deviceToken: deviceToken, deviceId: deviceId, deviceName: deviceName).url { let (data, _): (Data, URLResponse) = try await doRequest(baseURL: url, type: .data) - return data + struct CloudUserResponse: Decodable { let userId: String } + return try? JSONDecoder().decode(CloudUserResponse.self, from: data).userId } else { throw HTTPClientError.couldNotRegister } diff --git a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift index 79f73c9b9..95134e902 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift @@ -162,6 +162,10 @@ public final class NetworkTracker: ObservableObject { await attemptConnection() } + public func stopTracking() async { + await setActiveConnection(nil) + } + public func waitForActiveConnection(timeout: TimeInterval = 10) async -> ConnectionInfo? { logger.info("NetworkConnection: waitForActiveConnection") // Utilize for await to listen for changes in $activeConnection diff --git a/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift index 3fbd998e5..7de8c6835 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift @@ -10,6 +10,7 @@ // SPDX-License-Identifier: EPL-2.0 @preconcurrency import Combine +import Foundation import os.log import UIKit @@ -127,52 +128,122 @@ public struct UserDefaultURL { } } +public struct PreferenceInstance: Sendable { + public let id: UUID + public let defaultView: String + public let demomode: Bool + public let realTimeSliders: Bool + public let iconType: Int + public let defaultSitemap: String + public let sortSitemapsBy: Int + public let defaultMainUIPath: String + public let alwaysAllowWebRTC: Bool + public let sitemapForWatch: String + public let localConnectionConfig: ConnectionConfiguration + public let remoteConnectionConfig: ConnectionConfiguration + public let sitemapForWatchLabel: String + public let homeName: String + + fileprivate init?(id: UUID, dict: [String: any Sendable]) { + guard + let localData = dict[Preferences.Key.localConnectionConfig.rawValue] as? Data, + let remoteData = dict[Preferences.Key.remoteConnectionConfig.rawValue] as? Data, + let localCfg = try? JSONDecoder().decode(ConnectionConfiguration.self, from: localData), + let remoteCfg = try? JSONDecoder().decode(ConnectionConfiguration.self, from: remoteData) + else { return nil } + + self.id = id + defaultView = dict[Preferences.Key.defaultView.rawValue] as? String ?? "web" + demomode = dict[Preferences.Key.demomode.rawValue] as? Bool ?? true + realTimeSliders = dict[Preferences.Key.realTimeSliders.rawValue] as? Bool ?? false + iconType = dict[Preferences.Key.iconType.rawValue] as? Int ?? 0 + defaultSitemap = dict[Preferences.Key.defaultSitemap.rawValue] as? String ?? "demo" + sortSitemapsBy = dict[Preferences.Key.sortSitemapsBy.rawValue] as? Int ?? 0 + defaultMainUIPath = dict[Preferences.Key.defaultMainUIPath.rawValue] as? String ?? "" + alwaysAllowWebRTC = dict[Preferences.Key.alwaysAllowWebRTC.rawValue] as? Bool ?? false + sitemapForWatch = dict[Preferences.Key.sitemapForWatch.rawValue] as? String ?? "watch" + localConnectionConfig = localCfg + remoteConnectionConfig = remoteCfg + sitemapForWatchLabel = dict[Preferences.Key.sitemapForWatchLabel.rawValue] as? String ?? "watch" + homeName = dict[Preferences.Key.homeName.rawValue] as? String ?? "Home" + } +} + @MainActor public enum Preferences { + public enum Key: String { + case localUrl + case remoteUrl + case username + case password + case alwaysSendCreds + case ignoreSSL + case defaultView + case demomode + case realTimeSliders + case iconType + case defaultSitemap + case sortSitemapsBy + case defaultMainUIPath + case alwaysAllowWebRTC + case sitemapForWatch + case localConnectionConfig + case remoteConnectionConfig + case sitemapForWatchLabel + case homeName + case sendCrashReports + case idleOff + case storedPreferences + case currentlyUsedSettings + case didMigrateToSharedDefaults + case didMigrateToConnectionConfig + case currentWebViewPath + } + static let sharedDefaults = UserDefaults(suiteName: "group.org.openhab.app")! // MARK: - Public Deprecated preferences - @UserDefaultURL("localUrl", defaultValue: "", store: false) public static var localUrl: String - @UserDefaultURL("remoteUrl", defaultValue: "https://myopenhab.org", store: false) public static var remoteUrl: String - @UserDefault("username", defaultValue: "test", store: false) public static var username: String - @UserDefault("password", defaultValue: "test", store: false) public static var password: String - @UserDefault("alwaysSendCreds", defaultValue: false, store: false) public static var alwaysSendCreds: Bool - @UserDefault("ignoreSSL", defaultValue: false, store: false) public static var ignoreSSL: Bool + @UserDefaultURL(Key.localUrl.rawValue, defaultValue: "", store: false) public static var localUrl: String + @UserDefaultURL(Key.remoteUrl.rawValue, defaultValue: "https://myopenhab.org", store: false) public static var remoteUrl: String + @UserDefault(Key.username.rawValue, defaultValue: "test", store: false) public static var username: String + @UserDefault(Key.password.rawValue, defaultValue: "test", store: false) public static var password: String + @UserDefault(Key.alwaysSendCreds.rawValue, defaultValue: false, store: false) public static var alwaysSendCreds: Bool + @UserDefault(Key.ignoreSSL.rawValue, defaultValue: false, store: false) public static var ignoreSSL: Bool // MARK: - Public Home related preferences - @UserDefaultURL("defaultView", defaultValue: "web") public static var defaultView: String - @UserDefault("demomode", defaultValue: true) public static var demomode: Bool - @UserDefault("realTimeSliders", defaultValue: false) public static var realTimeSliders: Bool - @UserDefault("iconType", defaultValue: 0) public static var iconType: Int - @UserDefault("defaultSitemap", defaultValue: "demo") public static var defaultSitemap: String - @UserDefault("sortSitemapsBy", defaultValue: 0) public static var sortSitemapsBy: Int - @UserDefault("defaultMainUIPath", defaultValue: "") public static var defaultMainUIPath: String - @UserDefault("alwaysAllowWebRTC", defaultValue: false) public static var alwaysAllowWebRTC: Bool - @UserDefault("sitemapForWatch", defaultValue: "watch") public static var sitemapForWatch: String - @UserDefaultObject("localConnectionConfig", defaultValue: ConnectionConfiguration.localDefault) public static var localConnectionConfig: ConnectionConfiguration - @UserDefaultObject("remoteConnectionConfig", defaultValue: ConnectionConfiguration.remoteDefault) public static var remoteConnectionConfig: ConnectionConfiguration - @UserDefault("sitemapForWatchLabel", defaultValue: "watch") public static var sitemapForWatchLabel: String - @UserDefault("homeName", defaultValue: "Home") public static var homeName: String + @UserDefaultURL(Key.defaultView.rawValue, defaultValue: "web") public static var defaultView: String + @UserDefault(Key.demomode.rawValue, defaultValue: true) public static var demomode: Bool + @UserDefault(Key.realTimeSliders.rawValue, defaultValue: false) public static var realTimeSliders: Bool + @UserDefault(Key.iconType.rawValue, defaultValue: 0) public static var iconType: Int + @UserDefault(Key.defaultSitemap.rawValue, defaultValue: "demo") public static var defaultSitemap: String + @UserDefault(Key.sortSitemapsBy.rawValue, defaultValue: 0) public static var sortSitemapsBy: Int + @UserDefault(Key.defaultMainUIPath.rawValue, defaultValue: "") public static var defaultMainUIPath: String + @UserDefault(Key.alwaysAllowWebRTC.rawValue, defaultValue: false) public static var alwaysAllowWebRTC: Bool + @UserDefault(Key.sitemapForWatch.rawValue, defaultValue: "watch") public static var sitemapForWatch: String + @UserDefaultObject(Key.localConnectionConfig.rawValue, defaultValue: ConnectionConfiguration.localDefault) public static var localConnectionConfig: ConnectionConfiguration + @UserDefaultObject(Key.remoteConnectionConfig.rawValue, defaultValue: ConnectionConfiguration.remoteDefault) public static var remoteConnectionConfig: ConnectionConfiguration + @UserDefault(Key.sitemapForWatchLabel.rawValue, defaultValue: "watch") public static var sitemapForWatchLabel: String + @UserDefault(Key.homeName.rawValue, defaultValue: "Home") public static var homeName: String // MARK: - Public App related preferences - @UserDefault("sendCrashReports", defaultValue: false, store: false) public static var sendCrashReports: Bool + @UserDefault(Key.sendCrashReports.rawValue, defaultValue: false, store: false) public static var sendCrashReports: Bool - @UserDefault("idleOff", defaultValue: false, store: false) public static var idleOff: Bool + @UserDefault(Key.idleOff.rawValue, defaultValue: false, store: false) public static var idleOff: Bool /// settings for different homes TODO come up with better name - @UserDefault("storedPreferences", defaultValue: [:], store: false) public static var storedPreferences: [String: [String: any Sendable]] + @UserDefault(Key.storedPreferences.rawValue, defaultValue: [:], store: false) public static var storedPreferences: [String: [String: any Sendable]] // MARK: - Private preferences /// the currently applied settings set from storedPreferences - @UserDefault("currentlyUsedSettings", defaultValue: UUID().uuidString, store: false) public private(set) static var currentlyUsedSettings: String + @UserDefault(Key.currentlyUsedSettings.rawValue, defaultValue: UUID().uuidString, store: false) public private(set) static var currentlyUsedSettings: String - @UserDefault("didMigrateToSharedDefaults", defaultValue: false, store: false) private static var didMigrateToSharedDefaults: Bool - @UserDefault("didMigrateToConnectionConfig", defaultValue: false, store: false) private static var didMigrateToConnectionConfig: Bool - @UserDefault("currentWebViewPath", defaultValue: "", store: false) public static var currentWebViewPath: String + @UserDefault(Key.didMigrateToSharedDefaults.rawValue, defaultValue: false, store: false) private static var didMigrateToSharedDefaults: Bool + @UserDefault(Key.didMigrateToConnectionConfig.rawValue, defaultValue: false, store: false) private static var didMigrateToConnectionConfig: Bool + @UserDefault(Key.currentWebViewPath.rawValue, defaultValue: "", store: false) public static var currentWebViewPath: String private static var loadingStoredPreferences = false } @@ -238,6 +309,7 @@ private extension Preferences { // MARK: Multiple homes +@MainActor public extension Preferences { static func listStoredPreferences() -> [UUID] { let preferenceIds = storedPreferences @@ -299,46 +371,105 @@ public extension Preferences { private static func loadSettings(stored: [String: Any]) { loadingStoredPreferences = true // TODO: not pretty to repeat everything here - Preferences.defaultView = stored["defaultView"] as? String ?? "web" - Preferences.demomode = stored["demomode"] as? Bool ?? true - Preferences.realTimeSliders = stored["realTimeSliders"] as? Bool ?? false - Preferences.iconType = stored["iconType"] as? Int ?? 0 - Preferences.defaultSitemap = stored["defaultSitemap"] as? String ?? "demo" - Preferences.sortSitemapsBy = stored["sortSitemapsBy"] as? Int ?? 0 - Preferences.defaultMainUIPath = stored["defaultMainUIPath"] as? String ?? "" - Preferences.alwaysAllowWebRTC = stored["alwaysAllowWebRTC"] as? Bool ?? false - Preferences.sitemapForWatch = stored["sitemapForWatch"] as? String ?? "watch" - Preferences.localConnectionConfig = (try? JSONDecoder().decode(ConnectionConfiguration.self, from: stored["localConnectionConfig"] as? Data ?? Data())) ?? ConnectionConfiguration.localDefault - Preferences.remoteConnectionConfig = (try? JSONDecoder().decode(ConnectionConfiguration.self, from: stored["remoteConnectionConfig"] as? Data ?? Data())) ?? ConnectionConfiguration.remoteDefault - Preferences.sitemapForWatchLabel = stored["sitemapForWatchLabel"] as? String ?? "watch" - Preferences.homeName = stored["homeName"] as? String ?? "Home" + Preferences.defaultView = stored[Key.defaultView.rawValue] as? String ?? "web" + Preferences.demomode = stored[Key.demomode.rawValue] as? Bool ?? true + Preferences.realTimeSliders = stored[Key.realTimeSliders.rawValue] as? Bool ?? false + Preferences.iconType = stored[Key.iconType.rawValue] as? Int ?? 0 + Preferences.defaultSitemap = stored[Key.defaultSitemap.rawValue] as? String ?? "demo" + Preferences.sortSitemapsBy = stored[Key.sortSitemapsBy.rawValue] as? Int ?? 0 + Preferences.defaultMainUIPath = stored[Key.defaultMainUIPath.rawValue] as? String ?? "" + Preferences.alwaysAllowWebRTC = stored[Key.alwaysAllowWebRTC.rawValue] as? Bool ?? false + Preferences.sitemapForWatch = stored[Key.sitemapForWatch.rawValue] as? String ?? "watch" + Preferences.localConnectionConfig = (try? JSONDecoder().decode(ConnectionConfiguration.self, from: stored[Key.localConnectionConfig.rawValue] as? Data ?? Data())) ?? ConnectionConfiguration.localDefault + Preferences.remoteConnectionConfig = (try? JSONDecoder().decode(ConnectionConfiguration.self, from: stored[Key.remoteConnectionConfig.rawValue] as? Data ?? Data())) ?? ConnectionConfiguration.remoteDefault + Preferences.sitemapForWatchLabel = stored[Key.sitemapForWatchLabel.rawValue] as? String ?? "watch" + Preferences.homeName = stored[Key.homeName.rawValue] as? String ?? "Home" loadingStoredPreferences = false storeCurrentPreferences() } +} + +@MainActor +public extension Preferences { + private static func currentPreferencesDict(updatedKey: String = "", updatedValue: any Sendable = "") -> [String: any Sendable] { + [ + Key.defaultView.rawValue: updatedKey == Key.defaultView.rawValue ? updatedValue : defaultView, + Key.demomode.rawValue: updatedKey == Key.demomode.rawValue ? updatedValue : demomode, + Key.realTimeSliders.rawValue: updatedKey == Key.realTimeSliders.rawValue ? updatedValue : realTimeSliders, + Key.iconType.rawValue: updatedKey == Key.iconType.rawValue ? updatedValue : iconType, + Key.defaultSitemap.rawValue: updatedKey == Key.defaultSitemap.rawValue ? updatedValue : defaultSitemap, + Key.sortSitemapsBy.rawValue: updatedKey == Key.sortSitemapsBy.rawValue ? updatedValue : sortSitemapsBy, + Key.defaultMainUIPath.rawValue: updatedKey == Key.defaultMainUIPath.rawValue ? updatedValue : defaultMainUIPath, + Key.alwaysAllowWebRTC.rawValue: updatedKey == Key.alwaysAllowWebRTC.rawValue ? updatedValue : alwaysAllowWebRTC, + Key.sitemapForWatch.rawValue: updatedKey == Key.sitemapForWatch.rawValue ? updatedValue : sitemapForWatch, + Key.localConnectionConfig.rawValue: updatedKey == Key.localConnectionConfig.rawValue ? updatedValue : try? JSONEncoder().encode(localConnectionConfig), + Key.remoteConnectionConfig.rawValue: updatedKey == Key.remoteConnectionConfig.rawValue ? updatedValue : try? JSONEncoder().encode(remoteConnectionConfig), + Key.sitemapForWatchLabel.rawValue: updatedKey == Key.sitemapForWatchLabel.rawValue ? updatedValue : sitemapForWatchLabel, + Key.homeName.rawValue: updatedKey == Key.homeName.rawValue ? updatedValue : homeName + ] + } + static func storePreferences(for settingsId: String, updatedKey: String = "", updatedValue: any Sendable = "") { + guard !loadingStoredPreferences else { return } + var all = storedPreferences + if updatedKey.isEmpty { + // store the current set preferences for the settingsId + all[settingsId] = currentPreferencesDict() + } else { + // assign the current settings for this home + var record = all[settingsId] ?? [:] + // update just the single value + record[updatedKey] = updatedValue + // set the updated record back + all[settingsId] = record + } + storedPreferences = all + os_log("Stored preferences for home %{public}@", log: .default, type: .debug, settingsId) + } + + // omitting the updatedKey will result in all settings being saved static func storeCurrentPreferences(updatedKey: String = "", updatedValue: any Sendable = "") { - guard !loadingStoredPreferences else { - // concurrent access for writing and reading is prohibited - return + storePreferences(for: currentlyUsedSettings, updatedKey: updatedKey, updatedValue: updatedValue) + } + + // helper function for when we update the remote connection cloudUserId for notifications + static func updateRemoteConnectionConfig(_ connection: ConnectionConfiguration, for settingsId: String) { + guard let encoded = try? JSONEncoder().encode(connection) else { return } + // Update local instance if this is the active home + if settingsId == currentlyUsedSettings { + remoteConnectionConfig = connection } - // TODO: not pretty to repeat everything here - var stored = storedPreferences - stored[currentlyUsedSettings] = [ - "defaultView": updatedKey == "defaultView" ? updatedValue : Preferences.defaultView, - "demomode": updatedKey == "demomode" ? updatedValue : Preferences.demomode, - "realTimeSliders": updatedKey == "realTimeSliders" ? updatedValue : Preferences.realTimeSliders, - "iconType": updatedKey == "iconType" ? updatedValue : Preferences.iconType, - "defaultSitemap": updatedKey == "defaultSitemap" ? updatedValue : Preferences.defaultSitemap, - "sortSitemapsBy": updatedKey == "sortSitemapsBy" ? updatedValue : Preferences.sortSitemapsBy, - "defaultMainUIPath": updatedKey == "defaultMainUIPath" ? updatedValue : Preferences.defaultMainUIPath, - "alwaysAllowWebRTC": updatedKey == "alwaysAllowWebRTC" ? updatedValue : Preferences.alwaysAllowWebRTC, - "sitemapForWatch": updatedKey == "sitemapForWatch" ? updatedValue : Preferences.sitemapForWatch, - "localConnectionConfig": updatedKey == "localConnectionConfig" ? updatedValue : try? JSONEncoder().encode(Preferences.localConnectionConfig), - "remoteConnectionConfig": updatedKey == "remoteConnectionConfig" ? updatedValue : try? JSONEncoder().encode(Preferences.remoteConnectionConfig), - "sitemapForWatchLabel": updatedKey == "sitemapForWatchLabel" ? updatedValue : Preferences.sitemapForWatchLabel, - "homeName": updatedKey == "homeName" ? updatedValue : Preferences.homeName - ] - storedPreferences = stored + storePreferences(for: settingsId, updatedKey: Key.remoteConnectionConfig.rawValue, updatedValue: encoded) + } +} + +@MainActor +public extension Preferences { + static func firstStoredSettings(where key: String, matches predicate: (Any) -> Bool) -> (id: UUID, record: [String: any Sendable])? { + for (uuidString, record) in storedPreferences { + guard let raw = record[key], predicate(raw), + let uuid = UUID(uuidString: uuidString) else { continue } + return (uuid, record) + } + return nil + } + + static func storedSettingsId(forCloudUserId id: String) -> UUID? { + firstStoredSettings(where: Key.remoteConnectionConfig.rawValue) { raw in + guard + let data = raw as? Data, + let cfg = try? JSONDecoder().decode(ConnectionConfiguration.self, from: data) + else { return false } + return cfg.cloudUserId == id + }?.id + } +} + +@MainActor +public extension Preferences { + static func preferenceInstance(for settingsId: String) -> PreferenceInstance? { + guard let dict = storedPreferences[settingsId], let uuid = UUID(uuidString: settingsId) else { return nil } + return PreferenceInstance(id: uuid, dict: dict) } } @@ -349,29 +480,29 @@ public extension Preferences { guard !didMigrateToSharedDefaults else { return } didMigrateToSharedDefaults = true - Preferences.localUrl = UserDefaults.standard.string(forKey: "localUrl") ?? Preferences.localUrl - Preferences.remoteUrl = UserDefaults.standard.string(forKey: "remoteUrl") ?? Preferences.remoteUrl - Preferences.username = UserDefaults.standard.string(forKey: "username") ?? Preferences.username - Preferences.password = UserDefaults.standard.string(forKey: "password") ?? Preferences.password - Preferences.alwaysSendCreds = UserDefaults.standard.object(forKey: "alwaysSendCreds") as? Bool ?? Preferences.alwaysSendCreds - Preferences.ignoreSSL = UserDefaults.standard.object(forKey: "ignoreSSL") as? Bool ?? Preferences.ignoreSSL - Preferences.demomode = UserDefaults.standard.object(forKey: "demomode") as? Bool ?? Preferences.demomode - Preferences.idleOff = UserDefaults.standard.object(forKey: "idleOff") as? Bool ?? Preferences.idleOff - Preferences.realTimeSliders = UserDefaults.standard.object(forKey: "realTimeSliders") as? Bool ?? Preferences.realTimeSliders - Preferences.iconType = UserDefaults.standard.object(forKey: "iconType") as? Int ?? Preferences.iconType - Preferences.defaultSitemap = UserDefaults.standard.string(forKey: "defaultSitemap") ?? Preferences.defaultSitemap - Preferences.sendCrashReports = UserDefaults.standard.object(forKey: "sendCrashReports") as? Bool ?? Preferences.sendCrashReports + Preferences.localUrl = UserDefaults.standard.string(forKey: Key.localUrl.rawValue) ?? Preferences.localUrl + Preferences.remoteUrl = UserDefaults.standard.string(forKey: Key.remoteUrl.rawValue) ?? Preferences.remoteUrl + Preferences.username = UserDefaults.standard.string(forKey: Key.username.rawValue) ?? Preferences.username + Preferences.password = UserDefaults.standard.string(forKey: Key.password.rawValue) ?? Preferences.password + Preferences.alwaysSendCreds = UserDefaults.standard.object(forKey: Key.alwaysSendCreds.rawValue) as? Bool ?? Preferences.alwaysSendCreds + Preferences.ignoreSSL = UserDefaults.standard.object(forKey: Key.ignoreSSL.rawValue) as? Bool ?? Preferences.ignoreSSL + Preferences.demomode = UserDefaults.standard.object(forKey: Key.demomode.rawValue) as? Bool ?? Preferences.demomode + Preferences.idleOff = UserDefaults.standard.object(forKey: Key.idleOff.rawValue) as? Bool ?? Preferences.idleOff + Preferences.realTimeSliders = UserDefaults.standard.object(forKey: Key.realTimeSliders.rawValue) as? Bool ?? Preferences.realTimeSliders + Preferences.iconType = UserDefaults.standard.object(forKey: Key.iconType.rawValue) as? Int ?? Preferences.iconType + Preferences.defaultSitemap = UserDefaults.standard.string(forKey: Key.defaultSitemap.rawValue) ?? Preferences.defaultSitemap + Preferences.sendCrashReports = UserDefaults.standard.object(forKey: Key.sendCrashReports.rawValue) as? Bool ?? Preferences.sendCrashReports } static func migrateUserDefaultsToConnectionIfRequired() { guard !didMigrateToConnectionConfig else { return } - let oldLocalUrl = UserDefaults.standard.string(forKey: "localUrl") ?? Preferences.localUrl - let oldRemoteUrl = UserDefaults.standard.string(forKey: "remoteUrl") ?? Preferences.remoteUrl - let oldUsername = UserDefaults.standard.string(forKey: "username") ?? Preferences.username - let oldPassword = UserDefaults.standard.string(forKey: "password") ?? Preferences.password - let oldAlwaysSendCreds = UserDefaults.standard.object(forKey: "alwaysSendCreds") as? Bool ?? Preferences.alwaysSendCreds - let oldIgnoreSSL = UserDefaults.standard.object(forKey: "ignoreSSL") as? Bool ?? Preferences.ignoreSSL + let oldLocalUrl = UserDefaults.standard.string(forKey: Key.localUrl.rawValue) ?? Preferences.localUrl + let oldRemoteUrl = UserDefaults.standard.string(forKey: Key.remoteUrl.rawValue) ?? Preferences.remoteUrl + let oldUsername = UserDefaults.standard.string(forKey: Key.username.rawValue) ?? Preferences.username + let oldPassword = UserDefaults.standard.string(forKey: Key.password.rawValue) ?? Preferences.password + let oldAlwaysSendCreds = UserDefaults.standard.object(forKey: Key.alwaysSendCreds.rawValue) as? Bool ?? Preferences.alwaysSendCreds + let oldIgnoreSSL = UserDefaults.standard.object(forKey: Key.ignoreSSL.rawValue) as? Bool ?? Preferences.ignoreSSL // Create new configuration let newLocalConfiguration = ConnectionConfiguration( @@ -380,6 +511,7 @@ public extension Preferences { password: "", alwaysSendBasicAuth: oldAlwaysSendCreds, ignoreSSL: oldIgnoreSSL, + supportsNotifications: false, priority: 0 ) @@ -389,6 +521,7 @@ public extension Preferences { password: oldPassword, alwaysSendBasicAuth: oldAlwaysSendCreds, ignoreSSL: oldIgnoreSSL, + supportsNotifications: true, priority: 1 ) @@ -402,45 +535,29 @@ public extension Preferences { // MARK: All connections public extension Preferences { - static func getLowestPriorityOpenHABConnection(of stored: [String: Any]) -> ConnectionConfiguration? { - let localConfig = stored["localConnectionConfig"] as? Data ?? Data() - let localConnection = try? JSONDecoder().decode(ConnectionConfiguration.self, from: localConfig) - let remoteConfig = stored["remoteConnectionConfig"] as? Data ?? Data() + static func getNotificationConnection(of stored: [String: Any]) -> ConnectionConfiguration? { + let remoteConfig = stored[Key.remoteConnectionConfig.rawValue] as? Data ?? Data() let remoteConnection = try? JSONDecoder().decode(ConnectionConfiguration.self, from: remoteConfig) - return Preferences.getLowestPriorityOpenHABConnection(of: [localConnection, remoteConnection]) + return Preferences.getNotificationConnection(of: [remoteConnection]) } - static func getLowestPriorityOpenHABConnection(of connections: [ConnectionConfiguration?]) -> ConnectionConfiguration? { - connections - .compactMap { $0 } - .filter { $0.url.contains("openhab.org") } - .sorted { $0.priority < $1.priority } - .first + static func getNotificationConnection(of connections: [ConnectionConfiguration?]) -> ConnectionConfiguration? { + // These used to be chained calls, but the swift compiler was compaining about complexity + let validConnections = connections.compactMap { $0 } + let notificationCapable = validConnections.filter(\.supportsNotifications) + // lower value means higher priority, 0 is primary + let sorted = notificationCapable.sorted { $0.priority < $1.priority } + return sorted.first } - static func getLowestPriorityOpenHABConnection() -> ConnectionConfiguration? { - getLowestPriorityOpenHABConnection(of: [localConnectionConfig, remoteConnectionConfig]) + static func getNotificationConnection() -> ConnectionConfiguration? { + getNotificationConnection(of: [remoteConnectionConfig]) } } // MARK: - Sample Codable Model public extension ConnectionConfiguration { - static let localDefault = ConnectionConfiguration( - url: "http://192.168.1.1:8080", - username: "", - password: "", - alwaysSendBasicAuth: false, - ignoreSSL: false, - priority: 0 - ) - - static let remoteDefault = ConnectionConfiguration( - url: "https://myopenhab.org", - username: "", - password: "", - alwaysSendBasicAuth: false, - ignoreSSL: false, - priority: 1 - ) + static let localDefault = ConnectionConfiguration.makeDefaultLocal() + static let remoteDefault = ConnectionConfiguration.makeDefaultRemote() } diff --git a/openHAB.xcodeproj/xcshareddata/xcschemes/NotificationService.xcscheme b/openHAB.xcodeproj/xcshareddata/xcschemes/NotificationService.xcscheme new file mode 100644 index 000000000..1adbba228 --- /dev/null +++ b/openHAB.xcodeproj/xcshareddata/xcschemes/NotificationService.xcscheme @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/openHAB/AppDelegate.swift b/openHAB/AppDelegate.swift index e1a2c6b01..ee0e3bb8a 100644 --- a/openHAB/AppDelegate.swift +++ b/openHAB/AppDelegate.swift @@ -233,7 +233,8 @@ extension AppDelegate: UNUserNotificationCenterDelegate { let message = userInfo["message"] as? String ?? NSLocalizedString("message_not_decoded", comment: "") let action = userInfo["actionIdentifier"] as? String ?? userInfo["on-click"] as? String - await displayNotification(message: message, action: action) + let cloudUserId = userInfo["userId"] as? String + await displayNotification(message: message, action: action, cloudUserId: cloudUserId) return [] // Modify this if you want to show banners, alerts, etc. } @@ -242,6 +243,7 @@ extension AppDelegate: UNUserNotificationCenterDelegate { func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async { var userInfo = response.notification.request.content.userInfo let actionIdentifier = response.actionIdentifier + logger.info("Notification clicked: action \(actionIdentifier) userInfo \(userInfo)") if actionIdentifier != UNNotificationDismissActionIdentifier { @@ -249,11 +251,13 @@ extension AppDelegate: UNUserNotificationCenterDelegate { userInfo["actionIdentifier"] = actionIdentifier } let action = userInfo["actionIdentifier"] as? String ?? userInfo["on-click"] as? String - notifyNotificationListeners(action: action) + let cloudUserId = userInfo["userId"] as? String + + notifyNotificationListeners(action: action, cloudUserId: cloudUserId) } } - private func displayNotification(message: String, action: String?) async { + private func displayNotification(message: String, action: String?, cloudUserId: String?) async { logger.info("displayNotification \(message)") Task { @@ -289,7 +293,7 @@ extension AppDelegate: UNUserNotificationCenterDelegate { // Use closure-based tap gesture insteae of #selector let tapGesture = MessageTapGestureRecognizer { Task { - self.messageViewTapped(action: action) + self.messageViewTapped(action: action, cloudUserId: cloudUserId) } } view.addGestureRecognizer(tapGesture) @@ -300,17 +304,17 @@ extension AppDelegate: UNUserNotificationCenterDelegate { } // Action to be performed when the notification message view is tapped - func messageViewTapped(action: String?) { - notifyNotificationListeners(action: action) + func messageViewTapped(action: String?, cloudUserId: String? = nil) { + notifyNotificationListeners(action: action, cloudUserId: cloudUserId) SwiftMessages.hideAll() } // ✅ Ensure this runs on the MainActor @MainActor - private func notifyNotificationListeners(action: String?) { + private func notifyNotificationListeners(action: String?, cloudUserId: String? = nil) { if let navigationController = window?.rootViewController as? UINavigationController, let rootViewController = navigationController.viewControllers.first as? OpenHABRootViewController { - rootViewController.handleNotification(action: action) + rootViewController.handleNotification(action: action, cloudUserId: cloudUserId) } } } diff --git a/openHAB/DrawerView.swift b/openHAB/DrawerView.swift index 6e0885e19..33097cb09 100644 --- a/openHAB/DrawerView.swift +++ b/openHAB/DrawerView.swift @@ -198,11 +198,11 @@ struct DrawerView: View { Section(header: Text("System")) { settingsMenuEntry(image: .gear, text: "settings", goTo: .settings) - if Preferences.getLowestPriorityOpenHABConnection() != nil, !Preferences.demomode { + if Preferences.getNotificationConnection() != nil, !Preferences.demomode { settingsMenuEntry(image: .bell, text: "notifications", goTo: .notifications) } - settingsMenuEntry(image: .house, text: "homeSelection", goTo: .homeSelection) + settingsMenuEntry(image: .house, text: "Manage Homes", goTo: .homeSelection) } } diff --git a/openHAB/HomeSelectionView.swift b/openHAB/HomeSelectionView.swift index 839178ca2..a46e7d223 100644 --- a/openHAB/HomeSelectionView.swift +++ b/openHAB/HomeSelectionView.swift @@ -133,7 +133,7 @@ struct HomeSelectionView: View { } } .onAppear(perform: loadHomesList) - .navigationBarTitle("homeSelection") + .navigationBarTitle("Manage Homes") .toolbar { if showEditOptions { ToolbarItemGroup(placement: .primaryAction) { diff --git a/openHAB/NewImageUITableViewCell.swift b/openHAB/NewImageUITableViewCell.swift index f87a1b3b7..1411354b9 100644 --- a/openHAB/NewImageUITableViewCell.swift +++ b/openHAB/NewImageUITableViewCell.swift @@ -162,8 +162,8 @@ class NewImageUITableViewCell: GenericUITableViewCell, NoIconDisplayableCell { activeTask = Task { do { - guard let config = Preferences.getLowestPriorityOpenHABConnection() else { - logger.warning("No openHAB configuration found.") + guard let config = NetworkTracker.shared.activeConnection?.configuration else { + logger.warning("No openHAB connection found.") throw HTTPClientError.noConfiguration } let client = HTTPClient(configuration: config) diff --git a/openHAB/NotificationsView.swift b/openHAB/NotificationsView.swift index 2821d08c3..6026faf3b 100644 --- a/openHAB/NotificationsView.swift +++ b/openHAB/NotificationsView.swift @@ -133,7 +133,7 @@ extension NotificationsView where Tracker == NetworkTracker { let logger = Logger(subsystem: "org.openhab.app", category: "NotificationView") do { - guard let config = Preferences.getLowestPriorityOpenHABConnection() else { + guard let config = Preferences.getNotificationConnection() else { logger.warning("No openHAB configuration found.") return [] } diff --git a/openHAB/OpenHABRootViewController.swift b/openHAB/OpenHABRootViewController.swift index c55e1b8a6..d349f9e7f 100644 --- a/openHAB/OpenHABRootViewController.swift +++ b/openHAB/OpenHABRootViewController.swift @@ -345,7 +345,7 @@ class OpenHABRootViewController: UIViewController { .map { storedPrefsUpdate in // we want to recognize changes in the OpenHab URLs for any of the homes Set(storedPrefsUpdate.compactMap { storedWithUuid in let (uuid, homeConfig) = storedWithUuid - guard let connection = Preferences.getLowestPriorityOpenHABConnection(of: homeConfig) else { return nil } + guard let connection = Preferences.getNotificationConnection(of: homeConfig) else { return nil } return UuidWithConnection(uuid: uuid, connection: connection) }) } @@ -361,7 +361,9 @@ class OpenHABRootViewController: UIViewController { } let openhabConnectionSubscription = differences.sink { [weak self] diff in + logger.info("openhabConnectionSubscription updated") for newHome in diff.newValues { + logger.info("openhabConnectionSubscription uuid \(newHome.uuid) registering for push notifications ") self?.registerHome(uuid: newHome.uuid, connection: newHome.connection) } for deletedHome in diff.deletedValues { @@ -384,22 +386,49 @@ class OpenHABRootViewController: UIViewController { return } logger.info("Registering notifications with \(connection.url)") - _ = registerHome(connection, deviceToken, deviceId, deviceName) + _ = registerHome(uuid, connection, deviceToken, deviceId, deviceName) } - private func registerHome(_ config: ConnectionConfiguration, _ deviceToken: String, _ deviceId: String, _ deviceName: String) -> Task { + private func registerHome(_ uuid: String, _ config: ConnectionConfiguration, _ deviceToken: String, _ deviceId: String, _ deviceName: String) -> Task { Task { do { let client = HTTPClient(configuration: config) - try await client.register(prefsURL: config.url, deviceToken: deviceToken, deviceId: deviceId, deviceName: deviceName) - logger.info("my.openHAB registration succeeded") + if let cloudUserId = try await client.register(prefsURL: config.url, deviceToken: deviceToken, deviceId: deviceId, deviceName: deviceName) { + var cc = config + cc.cloudUserId = cloudUserId + Preferences.updateRemoteConnectionConfig(cc, for: uuid) + logger.info("my.openHAB registration succeeded with cloudUserId \(cloudUserId)") + } + logger.info("my.openHAB registration succeeded without cloudUserId") } catch { logger.error("my.openHAB registration failed \(error.localizedDescription)") } } } - func handleNotification(action: String?) { + func handleNotification(action: String?, cloudUserId: String?) { + guard let action else { return } + + logger.info("handleNotification cloudUserId: \(cloudUserId ?? "")") + if let cloudUserId, let targetHome = Preferences.storedSettingsId(forCloudUserId: cloudUserId) { + if Preferences.remoteConnectionConfig.cloudUserId != cloudUserId { + // if we need to switch homes, disconnnect the tracking fist,and wait for the tracker to start again with the updated preferences + Task { + await NetworkTracker.shared.stopTracking() + logger.info("Switching to home \(targetHome)") + Preferences.switchCurrentlyUsedSettings(to: targetHome) + await NetworkTracker.shared.waitForActiveConnection() + handleNotificationInternal(action) + } + return + } + } + handleNotificationInternal(action) + } + + private func handleNotificationInternal(_ action: String?) { + logger.info("handleNotificationInternal: \(action ?? "")") + guard let action else { return } let cmd = action.split(separator: ":").dropFirst().joined(separator: ":") diff --git a/openHAB/SettingsView/ConnectionSettingsView.swift b/openHAB/SettingsView/ConnectionSettingsView.swift index dbe92651f..f6096e5e3 100644 --- a/openHAB/SettingsView/ConnectionSettingsView.swift +++ b/openHAB/SettingsView/ConnectionSettingsView.swift @@ -22,8 +22,8 @@ struct ConnectionSettingsView: View { Toggle("Demo Mode", isOn: $settingsDemomode) if !settingsDemomode { - SingleConnectionSettingsView(headerText: "Local server", connectionConfig: $localConnectionConfiguration) - SingleConnectionSettingsView(headerText: "Remote server", connectionConfig: $remoteConnectionConfiguration) + SingleConnectionSettingsView(headerText: "Local server", connectionConfig: $localConnectionConfiguration, showNotificationToggle: false) + SingleConnectionSettingsView(headerText: "Remote server", connectionConfig: $remoteConnectionConfiguration, showNotificationToggle: true) } } } @@ -32,14 +32,14 @@ struct ConnectionSettingsView: View { #Preview { struct PreviewWrapper: View { @State var demoMode = false - @State var localUrl = "http://192.168.1.100" + @State var localUrl = "https://openhab.local:8443" @State var remoteUrl = "https://myopenhab.org" @State var username = "user" @State var password = "password123" @State var alwaysSendCreds = true @State var connectionConfig1 = ConnectionConfiguration( - url: "http://192.168.2.1", + url: "https://openhab.local:8443", username: "user", password: "password123" ) diff --git a/openHAB/SettingsView/SettingsView.swift b/openHAB/SettingsView/SettingsView.swift index a944d9c3e..a0586f444 100644 --- a/openHAB/SettingsView/SettingsView.swift +++ b/openHAB/SettingsView/SettingsView.swift @@ -34,7 +34,7 @@ struct SettingsView: View { @State var sitemaps: [OpenHABSitemap] = [] @State var settingsLocalConnectionConfiguration = ConnectionConfiguration(url: "", username: "", password: "") @State var settingsRemoteConnectionConfiguration = ConnectionConfiguration(url: "", username: "", password: "") - + @State var settingsHomeName = "" @State var viewAppearedOnce: Bool = false @Environment(\.dismiss) private var dismiss @@ -74,7 +74,7 @@ struct SettingsView: View { } .formStyle(.grouped) .navigationBarBackButtonHidden(true) - .navigationBarTitle("Settings") + .navigationBarTitle("\(settingsHomeName) Settings") .toolbar { ToolbarItemGroup(placement: .primaryAction) { Button("Save") { @@ -140,6 +140,7 @@ struct SettingsView: View { settingsSitemapForWatch = Preferences.sitemapForWatch settingsLocalConnectionConfiguration = Preferences.localConnectionConfig settingsRemoteConnectionConfiguration = Preferences.remoteConnectionConfig + settingsHomeName = Preferences.homeName } func saveSettings() { diff --git a/openHAB/SettingsView/SingleConnectionSettingsView.swift b/openHAB/SettingsView/SingleConnectionSettingsView.swift index 97cbb8c0d..6723e3a5b 100644 --- a/openHAB/SettingsView/SingleConnectionSettingsView.swift +++ b/openHAB/SettingsView/SingleConnectionSettingsView.swift @@ -34,10 +34,11 @@ struct SpinningSymbol: View { struct SingleConnectionSettingsView: View { var headerText: String @Binding var connectionConfig: ConnectionConfiguration - + var showNotificationToggle: Bool @State private var isTestingConnection = false @State private var connectionTestMessage: String? @State private var connectionTestSuccess: Bool? + @State private var showAdvanced = false var body: some View { Section(header: Text(headerText)) { @@ -120,14 +121,23 @@ struct SingleConnectionSettingsView: View { Text("Enter password on server") } } - - Toggle("Always send credentials", isOn: $connectionConfig.alwaysSendBasicAuth) - .font(.caption) - .opacity(0.8) - - Toggle("Ignore SSL certificates", isOn: $connectionConfig.ignoreSSL) - .font(.caption) - .opacity(0.8) + DisclosureGroup("Advanced", isExpanded: $showAdvanced) { + Toggle("Always send credentials", isOn: $connectionConfig.alwaysSendBasicAuth) + .font(.caption) + .opacity(0.8) + + Toggle("Ignore SSL certificates", isOn: $connectionConfig.ignoreSSL) + .font(.caption) + .opacity(0.8) + + if showNotificationToggle { + Toggle("openHAB Cloud Service", isOn: $connectionConfig.supportsNotifications) + .font(.caption) + .opacity(0.8) + } + } + .font(.subheadline) + .animation(.default, value: showAdvanced) } } @@ -199,16 +209,15 @@ struct SingleConnectionSettingsView: View { // **TODO Migrate to @Previewable on iOS 17 #Preview { struct PreviewWrapper: View { - @State var connectionConfig = ConnectionConfiguration( - url: "http://192.168.2.1:8080", - username: "user", - password: "password123" - ) + @State var connectionConfig = ConnectionConfiguration.makeDefaultRemote { cfg in + cfg.username = "user" + cfg.password = "password123" + } var body: some View { NavigationView { Form { - SingleConnectionSettingsView(headerText: "Connection Settings for local server", connectionConfig: $connectionConfig) + SingleConnectionSettingsView(headerText: "Connection Settings for local server", connectionConfig: $connectionConfig, showNotificationToggle: false) } } } diff --git a/openHAB/VideoUITableViewCell.swift b/openHAB/VideoUITableViewCell.swift index 6810d29d5..e1f734627 100644 --- a/openHAB/VideoUITableViewCell.swift +++ b/openHAB/VideoUITableViewCell.swift @@ -170,7 +170,7 @@ class VideoUITableViewCell: GenericUITableViewCell, NoIconDisplayableCell { activeTask = Task { do { - guard let config = Preferences.getLowestPriorityOpenHABConnection() else { + guard let config = NetworkTracker.shared.activeConnection?.configuration else { logger.warning("No openHAB configuration found.") throw HTTPClientError.noConfiguration } From 4b4749f3333a292acccee0666587167f1cde3176 Mon Sep 17 00:00:00 2001 From: Dan Cunningham Date: Sun, 22 Jun 2025 10:10:28 -0700 Subject: [PATCH 228/476] Revert "[WIP] openapigen multiple cloud support (#881)" (#883) This reverts commit ee6d77c4eb9df738e3d814218a23712c1f4e86d7. --- NotificationService/NotificationService.swift | 33 +- .../Util/ConnectionConfiguration.swift | 53 +-- .../Sources/OpenHABCore/Util/HTTPClient.swift | 5 +- .../OpenHABCore/Util/NetworkTracker.swift | 4 - .../OpenHABCore/Util/Preferences.swift | 333 ++++++------------ .../xcschemes/NotificationService.xcscheme | 98 ------ openHAB/AppDelegate.swift | 20 +- openHAB/DrawerView.swift | 4 +- openHAB/HomeSelectionView.swift | 2 +- openHAB/NewImageUITableViewCell.swift | 4 +- openHAB/NotificationsView.swift | 2 +- openHAB/OpenHABRootViewController.swift | 41 +-- .../SettingsView/ConnectionSettingsView.swift | 8 +- openHAB/SettingsView/SettingsView.swift | 5 +- .../SingleConnectionSettingsView.swift | 39 +- openHAB/VideoUITableViewCell.swift | 2 +- 16 files changed, 163 insertions(+), 490 deletions(-) delete mode 100644 openHAB.xcodeproj/xcshareddata/xcschemes/NotificationService.xcscheme diff --git a/NotificationService/NotificationService.swift b/NotificationService/NotificationService.swift index ba61b1eef..5975b9e09 100644 --- a/NotificationService/NotificationService.swift +++ b/NotificationService/NotificationService.swift @@ -46,8 +46,7 @@ class NotificationService: UNNotificationServiceExtension { var contentHandler: ((UNNotificationContent) -> Void)? var bestAttemptContent: UNMutableNotificationContent? var cancellables = Set() - var networkTracker: NetworkTracker? - var cloudUserId: String? + let logger = Logger(subsystem: "org.openhab.network", category: "NotificationService") override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { @@ -67,8 +66,6 @@ class NotificationService: UNNotificationServiceExtension { bestAttemptContent.body = message } - cloudUserId = userInfo["userId"] as? String - // Check if the user has defined custom actions in the payload if let actionsArray = parseActions(userInfo), let category = parseCategory(userInfo) { for actionDict in actionsArray { @@ -189,9 +186,11 @@ class NotificationService: UNNotificationServiceExtension { } private func downloadMedia(url: String) async throws -> (URL?, String?) { + await NetworkTracker.shared.startTracking(connectionConfigurations: [Preferences.localConnectionConfig, Preferences.remoteConnectionConfig]) + guard let fullURL = await resolveFullURL(from: url) else { return (nil, nil) } - guard let activeConfig = await networkTracker().waitForActiveConnection()?.configuration else { return (nil, nil) } + guard let activeConfig = await NetworkTracker.shared.waitForActiveConnection()?.configuration else { return (nil, nil) } let client = HTTPClient(configuration: activeConfig) @@ -202,7 +201,7 @@ class NotificationService: UNNotificationServiceExtension { // 🔹 Extracted helper function to determine full URL private func resolveFullURL(from url: String) async -> URL? { if url.starts(with: "/") { - guard let activeConfig = await networkTracker().waitForActiveConnection()?.configuration else { return nil } + guard let activeConfig = await NetworkTracker.shared.waitForActiveConnection()?.configuration else { return nil } return URL(string: activeConfig.url)?.appendingPathComponent(url) } else { return URL(string: url) @@ -222,7 +221,7 @@ class NotificationService: UNNotificationServiceExtension { let itemName = String(itemURL.absoluteString.dropFirst(scheme.count + 1)) - let item = try await networkTracker().getItemByName(id: itemName) + let item = try await NetworkTracker.shared.getItemByName(id: itemName) guard let state = item?.state else { return (nil, nil) } // Extract MIME type and base64 string @@ -271,24 +270,4 @@ class NotificationService: UNNotificationServiceExtension { } return nil } - - func networkTracker() async -> NetworkTracker { - if let cached = networkTracker { - return cached - } - let tracker = NetworkTracker.shared - let connections: [ConnectionConfiguration] - if let cloudUserId, - let uuid = await Preferences.storedSettingsId(forCloudUserId: cloudUserId), - let instance = await Preferences.preferenceInstance(for: uuid.uuidString) { - logger.info("setting up network tracking for \(cloudUserId)") - connections = [instance.localConnectionConfig, instance.remoteConnectionConfig] - } else { - logger.info("Using default connection configurations") - connections = await [Preferences.localConnectionConfig, Preferences.remoteConnectionConfig] - } - await tracker.startTracking(connectionConfigurations: connections) - networkTracker = tracker - return tracker - } } diff --git a/OpenHABCore/Sources/OpenHABCore/Util/ConnectionConfiguration.swift b/OpenHABCore/Sources/OpenHABCore/Util/ConnectionConfiguration.swift index 60089b7e9..15cf8c16b 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/ConnectionConfiguration.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/ConnectionConfiguration.swift @@ -17,8 +17,10 @@ public struct ConnectionPayload: Codable { } public struct ConnectionConfiguration: Hashable, Sendable, Codable { - public static let defaultLocalURL = "https://openhab.local:8443" - public static let defaultRemoteURL = "https://myopenhab.org" + // 🔹 Coding keys for manual encoding/decoding + private enum CodingKeys: String, CodingKey { + case url, username, password, alwaysSendBasicAuth, ignoreSSL, priority + } public var url: String public var username: String @@ -26,17 +28,14 @@ public struct ConnectionConfiguration: Hashable, Sendable, Codable { public var alwaysSendBasicAuth: Bool public var ignoreSSL: Bool public var priority: Int // Lower is higher priority, 0 is primary - public var supportsNotifications: Bool = false - public var cloudUserId: String? - public init(url: String, username: String, password: String, alwaysSendBasicAuth: Bool = false, ignoreSSL: Bool = false, supportsNotifications: Bool = false, priority: Int = 10) { + public init(url: String, username: String, password: String, alwaysSendBasicAuth: Bool = false, ignoreSSL: Bool = false, priority: Int = 10) { self.url = ConnectionConfiguration.normalizeURL(url) self.username = username self.password = password self.alwaysSendBasicAuth = alwaysSendBasicAuth self.ignoreSSL = ignoreSSL self.priority = priority - self.supportsNotifications = supportsNotifications } // 🔹 Ensure normalization on decoding @@ -48,39 +47,7 @@ public struct ConnectionConfiguration: Hashable, Sendable, Codable { password = try container.decode(String.self, forKey: .password) alwaysSendBasicAuth = try container.decode(Bool.self, forKey: .alwaysSendBasicAuth) ignoreSSL = try container.decode(Bool.self, forKey: .ignoreSSL) - supportsNotifications = try container.decode(Bool.self, forKey: .supportsNotifications) priority = try container.decode(Int.self, forKey: .priority) - cloudUserId = try container.decodeIfPresent(String.self, forKey: .cloudUserId) - } - - // MARK: - Static helpers with default values that can be overidden if needed - - public static func makeDefaultLocal(customize: ((inout ConnectionConfiguration) -> Void)? = nil) -> ConnectionConfiguration { - var cfg = ConnectionConfiguration( - url: defaultLocalURL, - username: "", - password: "", - alwaysSendBasicAuth: false, - ignoreSSL: false, - supportsNotifications: false, - priority: 0 - ) - customize?(&cfg) - return cfg - } - - public static func makeDefaultRemote(customize: ((inout ConnectionConfiguration) -> Void)? = nil) -> ConnectionConfiguration { - var cfg = ConnectionConfiguration( - url: defaultRemoteURL, - username: "", - password: "", - alwaysSendBasicAuth: false, - ignoreSSL: false, - supportsNotifications: true, - priority: 1 - ) - customize?(&cfg) - return cfg } // 🔹 Normalize a URL (removes trailing slashes, trims spaces, redirects openHAB cloud) @@ -108,16 +75,6 @@ public struct ConnectionConfiguration: Hashable, Sendable, Codable { try container.encode(password, forKey: .password) try container.encode(alwaysSendBasicAuth, forKey: .alwaysSendBasicAuth) try container.encode(ignoreSSL, forKey: .ignoreSSL) - try container.encode(supportsNotifications, forKey: .supportsNotifications) try container.encode(priority, forKey: .priority) - if let cloudUserId { - try container.encode(cloudUserId, forKey: .cloudUserId) - } - } -} - -extension ConnectionConfiguration { - private enum CodingKeys: String, CodingKey { - case url, username, password, alwaysSendBasicAuth, ignoreSSL, supportsNotifications, priority, cloudUserId } } diff --git a/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift b/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift index 7982916a1..413252409 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift @@ -159,11 +159,10 @@ public final class HTTPClient: NSObject { public func register(prefsURL: String, deviceToken: String, deviceId: String, - deviceName: String) async throws -> String? { + deviceName: String) async throws -> Data? { if let url = Endpoint.appleRegistration(prefsURL: prefsURL, deviceToken: deviceToken, deviceId: deviceId, deviceName: deviceName).url { let (data, _): (Data, URLResponse) = try await doRequest(baseURL: url, type: .data) - struct CloudUserResponse: Decodable { let userId: String } - return try? JSONDecoder().decode(CloudUserResponse.self, from: data).userId + return data } else { throw HTTPClientError.couldNotRegister } diff --git a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift index 95134e902..79f73c9b9 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift @@ -162,10 +162,6 @@ public final class NetworkTracker: ObservableObject { await attemptConnection() } - public func stopTracking() async { - await setActiveConnection(nil) - } - public func waitForActiveConnection(timeout: TimeInterval = 10) async -> ConnectionInfo? { logger.info("NetworkConnection: waitForActiveConnection") // Utilize for await to listen for changes in $activeConnection diff --git a/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift index 7de8c6835..3fbd998e5 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift @@ -10,7 +10,6 @@ // SPDX-License-Identifier: EPL-2.0 @preconcurrency import Combine -import Foundation import os.log import UIKit @@ -128,122 +127,52 @@ public struct UserDefaultURL { } } -public struct PreferenceInstance: Sendable { - public let id: UUID - public let defaultView: String - public let demomode: Bool - public let realTimeSliders: Bool - public let iconType: Int - public let defaultSitemap: String - public let sortSitemapsBy: Int - public let defaultMainUIPath: String - public let alwaysAllowWebRTC: Bool - public let sitemapForWatch: String - public let localConnectionConfig: ConnectionConfiguration - public let remoteConnectionConfig: ConnectionConfiguration - public let sitemapForWatchLabel: String - public let homeName: String - - fileprivate init?(id: UUID, dict: [String: any Sendable]) { - guard - let localData = dict[Preferences.Key.localConnectionConfig.rawValue] as? Data, - let remoteData = dict[Preferences.Key.remoteConnectionConfig.rawValue] as? Data, - let localCfg = try? JSONDecoder().decode(ConnectionConfiguration.self, from: localData), - let remoteCfg = try? JSONDecoder().decode(ConnectionConfiguration.self, from: remoteData) - else { return nil } - - self.id = id - defaultView = dict[Preferences.Key.defaultView.rawValue] as? String ?? "web" - demomode = dict[Preferences.Key.demomode.rawValue] as? Bool ?? true - realTimeSliders = dict[Preferences.Key.realTimeSliders.rawValue] as? Bool ?? false - iconType = dict[Preferences.Key.iconType.rawValue] as? Int ?? 0 - defaultSitemap = dict[Preferences.Key.defaultSitemap.rawValue] as? String ?? "demo" - sortSitemapsBy = dict[Preferences.Key.sortSitemapsBy.rawValue] as? Int ?? 0 - defaultMainUIPath = dict[Preferences.Key.defaultMainUIPath.rawValue] as? String ?? "" - alwaysAllowWebRTC = dict[Preferences.Key.alwaysAllowWebRTC.rawValue] as? Bool ?? false - sitemapForWatch = dict[Preferences.Key.sitemapForWatch.rawValue] as? String ?? "watch" - localConnectionConfig = localCfg - remoteConnectionConfig = remoteCfg - sitemapForWatchLabel = dict[Preferences.Key.sitemapForWatchLabel.rawValue] as? String ?? "watch" - homeName = dict[Preferences.Key.homeName.rawValue] as? String ?? "Home" - } -} - @MainActor public enum Preferences { - public enum Key: String { - case localUrl - case remoteUrl - case username - case password - case alwaysSendCreds - case ignoreSSL - case defaultView - case demomode - case realTimeSliders - case iconType - case defaultSitemap - case sortSitemapsBy - case defaultMainUIPath - case alwaysAllowWebRTC - case sitemapForWatch - case localConnectionConfig - case remoteConnectionConfig - case sitemapForWatchLabel - case homeName - case sendCrashReports - case idleOff - case storedPreferences - case currentlyUsedSettings - case didMigrateToSharedDefaults - case didMigrateToConnectionConfig - case currentWebViewPath - } - static let sharedDefaults = UserDefaults(suiteName: "group.org.openhab.app")! // MARK: - Public Deprecated preferences - @UserDefaultURL(Key.localUrl.rawValue, defaultValue: "", store: false) public static var localUrl: String - @UserDefaultURL(Key.remoteUrl.rawValue, defaultValue: "https://myopenhab.org", store: false) public static var remoteUrl: String - @UserDefault(Key.username.rawValue, defaultValue: "test", store: false) public static var username: String - @UserDefault(Key.password.rawValue, defaultValue: "test", store: false) public static var password: String - @UserDefault(Key.alwaysSendCreds.rawValue, defaultValue: false, store: false) public static var alwaysSendCreds: Bool - @UserDefault(Key.ignoreSSL.rawValue, defaultValue: false, store: false) public static var ignoreSSL: Bool + @UserDefaultURL("localUrl", defaultValue: "", store: false) public static var localUrl: String + @UserDefaultURL("remoteUrl", defaultValue: "https://myopenhab.org", store: false) public static var remoteUrl: String + @UserDefault("username", defaultValue: "test", store: false) public static var username: String + @UserDefault("password", defaultValue: "test", store: false) public static var password: String + @UserDefault("alwaysSendCreds", defaultValue: false, store: false) public static var alwaysSendCreds: Bool + @UserDefault("ignoreSSL", defaultValue: false, store: false) public static var ignoreSSL: Bool // MARK: - Public Home related preferences - @UserDefaultURL(Key.defaultView.rawValue, defaultValue: "web") public static var defaultView: String - @UserDefault(Key.demomode.rawValue, defaultValue: true) public static var demomode: Bool - @UserDefault(Key.realTimeSliders.rawValue, defaultValue: false) public static var realTimeSliders: Bool - @UserDefault(Key.iconType.rawValue, defaultValue: 0) public static var iconType: Int - @UserDefault(Key.defaultSitemap.rawValue, defaultValue: "demo") public static var defaultSitemap: String - @UserDefault(Key.sortSitemapsBy.rawValue, defaultValue: 0) public static var sortSitemapsBy: Int - @UserDefault(Key.defaultMainUIPath.rawValue, defaultValue: "") public static var defaultMainUIPath: String - @UserDefault(Key.alwaysAllowWebRTC.rawValue, defaultValue: false) public static var alwaysAllowWebRTC: Bool - @UserDefault(Key.sitemapForWatch.rawValue, defaultValue: "watch") public static var sitemapForWatch: String - @UserDefaultObject(Key.localConnectionConfig.rawValue, defaultValue: ConnectionConfiguration.localDefault) public static var localConnectionConfig: ConnectionConfiguration - @UserDefaultObject(Key.remoteConnectionConfig.rawValue, defaultValue: ConnectionConfiguration.remoteDefault) public static var remoteConnectionConfig: ConnectionConfiguration - @UserDefault(Key.sitemapForWatchLabel.rawValue, defaultValue: "watch") public static var sitemapForWatchLabel: String - @UserDefault(Key.homeName.rawValue, defaultValue: "Home") public static var homeName: String + @UserDefaultURL("defaultView", defaultValue: "web") public static var defaultView: String + @UserDefault("demomode", defaultValue: true) public static var demomode: Bool + @UserDefault("realTimeSliders", defaultValue: false) public static var realTimeSliders: Bool + @UserDefault("iconType", defaultValue: 0) public static var iconType: Int + @UserDefault("defaultSitemap", defaultValue: "demo") public static var defaultSitemap: String + @UserDefault("sortSitemapsBy", defaultValue: 0) public static var sortSitemapsBy: Int + @UserDefault("defaultMainUIPath", defaultValue: "") public static var defaultMainUIPath: String + @UserDefault("alwaysAllowWebRTC", defaultValue: false) public static var alwaysAllowWebRTC: Bool + @UserDefault("sitemapForWatch", defaultValue: "watch") public static var sitemapForWatch: String + @UserDefaultObject("localConnectionConfig", defaultValue: ConnectionConfiguration.localDefault) public static var localConnectionConfig: ConnectionConfiguration + @UserDefaultObject("remoteConnectionConfig", defaultValue: ConnectionConfiguration.remoteDefault) public static var remoteConnectionConfig: ConnectionConfiguration + @UserDefault("sitemapForWatchLabel", defaultValue: "watch") public static var sitemapForWatchLabel: String + @UserDefault("homeName", defaultValue: "Home") public static var homeName: String // MARK: - Public App related preferences - @UserDefault(Key.sendCrashReports.rawValue, defaultValue: false, store: false) public static var sendCrashReports: Bool + @UserDefault("sendCrashReports", defaultValue: false, store: false) public static var sendCrashReports: Bool - @UserDefault(Key.idleOff.rawValue, defaultValue: false, store: false) public static var idleOff: Bool + @UserDefault("idleOff", defaultValue: false, store: false) public static var idleOff: Bool /// settings for different homes TODO come up with better name - @UserDefault(Key.storedPreferences.rawValue, defaultValue: [:], store: false) public static var storedPreferences: [String: [String: any Sendable]] + @UserDefault("storedPreferences", defaultValue: [:], store: false) public static var storedPreferences: [String: [String: any Sendable]] // MARK: - Private preferences /// the currently applied settings set from storedPreferences - @UserDefault(Key.currentlyUsedSettings.rawValue, defaultValue: UUID().uuidString, store: false) public private(set) static var currentlyUsedSettings: String + @UserDefault("currentlyUsedSettings", defaultValue: UUID().uuidString, store: false) public private(set) static var currentlyUsedSettings: String - @UserDefault(Key.didMigrateToSharedDefaults.rawValue, defaultValue: false, store: false) private static var didMigrateToSharedDefaults: Bool - @UserDefault(Key.didMigrateToConnectionConfig.rawValue, defaultValue: false, store: false) private static var didMigrateToConnectionConfig: Bool - @UserDefault(Key.currentWebViewPath.rawValue, defaultValue: "", store: false) public static var currentWebViewPath: String + @UserDefault("didMigrateToSharedDefaults", defaultValue: false, store: false) private static var didMigrateToSharedDefaults: Bool + @UserDefault("didMigrateToConnectionConfig", defaultValue: false, store: false) private static var didMigrateToConnectionConfig: Bool + @UserDefault("currentWebViewPath", defaultValue: "", store: false) public static var currentWebViewPath: String private static var loadingStoredPreferences = false } @@ -309,7 +238,6 @@ private extension Preferences { // MARK: Multiple homes -@MainActor public extension Preferences { static func listStoredPreferences() -> [UUID] { let preferenceIds = storedPreferences @@ -371,105 +299,46 @@ public extension Preferences { private static func loadSettings(stored: [String: Any]) { loadingStoredPreferences = true // TODO: not pretty to repeat everything here - Preferences.defaultView = stored[Key.defaultView.rawValue] as? String ?? "web" - Preferences.demomode = stored[Key.demomode.rawValue] as? Bool ?? true - Preferences.realTimeSliders = stored[Key.realTimeSliders.rawValue] as? Bool ?? false - Preferences.iconType = stored[Key.iconType.rawValue] as? Int ?? 0 - Preferences.defaultSitemap = stored[Key.defaultSitemap.rawValue] as? String ?? "demo" - Preferences.sortSitemapsBy = stored[Key.sortSitemapsBy.rawValue] as? Int ?? 0 - Preferences.defaultMainUIPath = stored[Key.defaultMainUIPath.rawValue] as? String ?? "" - Preferences.alwaysAllowWebRTC = stored[Key.alwaysAllowWebRTC.rawValue] as? Bool ?? false - Preferences.sitemapForWatch = stored[Key.sitemapForWatch.rawValue] as? String ?? "watch" - Preferences.localConnectionConfig = (try? JSONDecoder().decode(ConnectionConfiguration.self, from: stored[Key.localConnectionConfig.rawValue] as? Data ?? Data())) ?? ConnectionConfiguration.localDefault - Preferences.remoteConnectionConfig = (try? JSONDecoder().decode(ConnectionConfiguration.self, from: stored[Key.remoteConnectionConfig.rawValue] as? Data ?? Data())) ?? ConnectionConfiguration.remoteDefault - Preferences.sitemapForWatchLabel = stored[Key.sitemapForWatchLabel.rawValue] as? String ?? "watch" - Preferences.homeName = stored[Key.homeName.rawValue] as? String ?? "Home" + Preferences.defaultView = stored["defaultView"] as? String ?? "web" + Preferences.demomode = stored["demomode"] as? Bool ?? true + Preferences.realTimeSliders = stored["realTimeSliders"] as? Bool ?? false + Preferences.iconType = stored["iconType"] as? Int ?? 0 + Preferences.defaultSitemap = stored["defaultSitemap"] as? String ?? "demo" + Preferences.sortSitemapsBy = stored["sortSitemapsBy"] as? Int ?? 0 + Preferences.defaultMainUIPath = stored["defaultMainUIPath"] as? String ?? "" + Preferences.alwaysAllowWebRTC = stored["alwaysAllowWebRTC"] as? Bool ?? false + Preferences.sitemapForWatch = stored["sitemapForWatch"] as? String ?? "watch" + Preferences.localConnectionConfig = (try? JSONDecoder().decode(ConnectionConfiguration.self, from: stored["localConnectionConfig"] as? Data ?? Data())) ?? ConnectionConfiguration.localDefault + Preferences.remoteConnectionConfig = (try? JSONDecoder().decode(ConnectionConfiguration.self, from: stored["remoteConnectionConfig"] as? Data ?? Data())) ?? ConnectionConfiguration.remoteDefault + Preferences.sitemapForWatchLabel = stored["sitemapForWatchLabel"] as? String ?? "watch" + Preferences.homeName = stored["homeName"] as? String ?? "Home" loadingStoredPreferences = false storeCurrentPreferences() } -} - -@MainActor -public extension Preferences { - private static func currentPreferencesDict(updatedKey: String = "", updatedValue: any Sendable = "") -> [String: any Sendable] { - [ - Key.defaultView.rawValue: updatedKey == Key.defaultView.rawValue ? updatedValue : defaultView, - Key.demomode.rawValue: updatedKey == Key.demomode.rawValue ? updatedValue : demomode, - Key.realTimeSliders.rawValue: updatedKey == Key.realTimeSliders.rawValue ? updatedValue : realTimeSliders, - Key.iconType.rawValue: updatedKey == Key.iconType.rawValue ? updatedValue : iconType, - Key.defaultSitemap.rawValue: updatedKey == Key.defaultSitemap.rawValue ? updatedValue : defaultSitemap, - Key.sortSitemapsBy.rawValue: updatedKey == Key.sortSitemapsBy.rawValue ? updatedValue : sortSitemapsBy, - Key.defaultMainUIPath.rawValue: updatedKey == Key.defaultMainUIPath.rawValue ? updatedValue : defaultMainUIPath, - Key.alwaysAllowWebRTC.rawValue: updatedKey == Key.alwaysAllowWebRTC.rawValue ? updatedValue : alwaysAllowWebRTC, - Key.sitemapForWatch.rawValue: updatedKey == Key.sitemapForWatch.rawValue ? updatedValue : sitemapForWatch, - Key.localConnectionConfig.rawValue: updatedKey == Key.localConnectionConfig.rawValue ? updatedValue : try? JSONEncoder().encode(localConnectionConfig), - Key.remoteConnectionConfig.rawValue: updatedKey == Key.remoteConnectionConfig.rawValue ? updatedValue : try? JSONEncoder().encode(remoteConnectionConfig), - Key.sitemapForWatchLabel.rawValue: updatedKey == Key.sitemapForWatchLabel.rawValue ? updatedValue : sitemapForWatchLabel, - Key.homeName.rawValue: updatedKey == Key.homeName.rawValue ? updatedValue : homeName - ] - } - static func storePreferences(for settingsId: String, updatedKey: String = "", updatedValue: any Sendable = "") { - guard !loadingStoredPreferences else { return } - var all = storedPreferences - if updatedKey.isEmpty { - // store the current set preferences for the settingsId - all[settingsId] = currentPreferencesDict() - } else { - // assign the current settings for this home - var record = all[settingsId] ?? [:] - // update just the single value - record[updatedKey] = updatedValue - // set the updated record back - all[settingsId] = record - } - storedPreferences = all - os_log("Stored preferences for home %{public}@", log: .default, type: .debug, settingsId) - } - - // omitting the updatedKey will result in all settings being saved static func storeCurrentPreferences(updatedKey: String = "", updatedValue: any Sendable = "") { - storePreferences(for: currentlyUsedSettings, updatedKey: updatedKey, updatedValue: updatedValue) - } - - // helper function for when we update the remote connection cloudUserId for notifications - static func updateRemoteConnectionConfig(_ connection: ConnectionConfiguration, for settingsId: String) { - guard let encoded = try? JSONEncoder().encode(connection) else { return } - // Update local instance if this is the active home - if settingsId == currentlyUsedSettings { - remoteConnectionConfig = connection - } - storePreferences(for: settingsId, updatedKey: Key.remoteConnectionConfig.rawValue, updatedValue: encoded) - } -} - -@MainActor -public extension Preferences { - static func firstStoredSettings(where key: String, matches predicate: (Any) -> Bool) -> (id: UUID, record: [String: any Sendable])? { - for (uuidString, record) in storedPreferences { - guard let raw = record[key], predicate(raw), - let uuid = UUID(uuidString: uuidString) else { continue } - return (uuid, record) + guard !loadingStoredPreferences else { + // concurrent access for writing and reading is prohibited + return } - return nil - } - - static func storedSettingsId(forCloudUserId id: String) -> UUID? { - firstStoredSettings(where: Key.remoteConnectionConfig.rawValue) { raw in - guard - let data = raw as? Data, - let cfg = try? JSONDecoder().decode(ConnectionConfiguration.self, from: data) - else { return false } - return cfg.cloudUserId == id - }?.id - } -} - -@MainActor -public extension Preferences { - static func preferenceInstance(for settingsId: String) -> PreferenceInstance? { - guard let dict = storedPreferences[settingsId], let uuid = UUID(uuidString: settingsId) else { return nil } - return PreferenceInstance(id: uuid, dict: dict) + // TODO: not pretty to repeat everything here + var stored = storedPreferences + stored[currentlyUsedSettings] = [ + "defaultView": updatedKey == "defaultView" ? updatedValue : Preferences.defaultView, + "demomode": updatedKey == "demomode" ? updatedValue : Preferences.demomode, + "realTimeSliders": updatedKey == "realTimeSliders" ? updatedValue : Preferences.realTimeSliders, + "iconType": updatedKey == "iconType" ? updatedValue : Preferences.iconType, + "defaultSitemap": updatedKey == "defaultSitemap" ? updatedValue : Preferences.defaultSitemap, + "sortSitemapsBy": updatedKey == "sortSitemapsBy" ? updatedValue : Preferences.sortSitemapsBy, + "defaultMainUIPath": updatedKey == "defaultMainUIPath" ? updatedValue : Preferences.defaultMainUIPath, + "alwaysAllowWebRTC": updatedKey == "alwaysAllowWebRTC" ? updatedValue : Preferences.alwaysAllowWebRTC, + "sitemapForWatch": updatedKey == "sitemapForWatch" ? updatedValue : Preferences.sitemapForWatch, + "localConnectionConfig": updatedKey == "localConnectionConfig" ? updatedValue : try? JSONEncoder().encode(Preferences.localConnectionConfig), + "remoteConnectionConfig": updatedKey == "remoteConnectionConfig" ? updatedValue : try? JSONEncoder().encode(Preferences.remoteConnectionConfig), + "sitemapForWatchLabel": updatedKey == "sitemapForWatchLabel" ? updatedValue : Preferences.sitemapForWatchLabel, + "homeName": updatedKey == "homeName" ? updatedValue : Preferences.homeName + ] + storedPreferences = stored } } @@ -480,29 +349,29 @@ public extension Preferences { guard !didMigrateToSharedDefaults else { return } didMigrateToSharedDefaults = true - Preferences.localUrl = UserDefaults.standard.string(forKey: Key.localUrl.rawValue) ?? Preferences.localUrl - Preferences.remoteUrl = UserDefaults.standard.string(forKey: Key.remoteUrl.rawValue) ?? Preferences.remoteUrl - Preferences.username = UserDefaults.standard.string(forKey: Key.username.rawValue) ?? Preferences.username - Preferences.password = UserDefaults.standard.string(forKey: Key.password.rawValue) ?? Preferences.password - Preferences.alwaysSendCreds = UserDefaults.standard.object(forKey: Key.alwaysSendCreds.rawValue) as? Bool ?? Preferences.alwaysSendCreds - Preferences.ignoreSSL = UserDefaults.standard.object(forKey: Key.ignoreSSL.rawValue) as? Bool ?? Preferences.ignoreSSL - Preferences.demomode = UserDefaults.standard.object(forKey: Key.demomode.rawValue) as? Bool ?? Preferences.demomode - Preferences.idleOff = UserDefaults.standard.object(forKey: Key.idleOff.rawValue) as? Bool ?? Preferences.idleOff - Preferences.realTimeSliders = UserDefaults.standard.object(forKey: Key.realTimeSliders.rawValue) as? Bool ?? Preferences.realTimeSliders - Preferences.iconType = UserDefaults.standard.object(forKey: Key.iconType.rawValue) as? Int ?? Preferences.iconType - Preferences.defaultSitemap = UserDefaults.standard.string(forKey: Key.defaultSitemap.rawValue) ?? Preferences.defaultSitemap - Preferences.sendCrashReports = UserDefaults.standard.object(forKey: Key.sendCrashReports.rawValue) as? Bool ?? Preferences.sendCrashReports + Preferences.localUrl = UserDefaults.standard.string(forKey: "localUrl") ?? Preferences.localUrl + Preferences.remoteUrl = UserDefaults.standard.string(forKey: "remoteUrl") ?? Preferences.remoteUrl + Preferences.username = UserDefaults.standard.string(forKey: "username") ?? Preferences.username + Preferences.password = UserDefaults.standard.string(forKey: "password") ?? Preferences.password + Preferences.alwaysSendCreds = UserDefaults.standard.object(forKey: "alwaysSendCreds") as? Bool ?? Preferences.alwaysSendCreds + Preferences.ignoreSSL = UserDefaults.standard.object(forKey: "ignoreSSL") as? Bool ?? Preferences.ignoreSSL + Preferences.demomode = UserDefaults.standard.object(forKey: "demomode") as? Bool ?? Preferences.demomode + Preferences.idleOff = UserDefaults.standard.object(forKey: "idleOff") as? Bool ?? Preferences.idleOff + Preferences.realTimeSliders = UserDefaults.standard.object(forKey: "realTimeSliders") as? Bool ?? Preferences.realTimeSliders + Preferences.iconType = UserDefaults.standard.object(forKey: "iconType") as? Int ?? Preferences.iconType + Preferences.defaultSitemap = UserDefaults.standard.string(forKey: "defaultSitemap") ?? Preferences.defaultSitemap + Preferences.sendCrashReports = UserDefaults.standard.object(forKey: "sendCrashReports") as? Bool ?? Preferences.sendCrashReports } static func migrateUserDefaultsToConnectionIfRequired() { guard !didMigrateToConnectionConfig else { return } - let oldLocalUrl = UserDefaults.standard.string(forKey: Key.localUrl.rawValue) ?? Preferences.localUrl - let oldRemoteUrl = UserDefaults.standard.string(forKey: Key.remoteUrl.rawValue) ?? Preferences.remoteUrl - let oldUsername = UserDefaults.standard.string(forKey: Key.username.rawValue) ?? Preferences.username - let oldPassword = UserDefaults.standard.string(forKey: Key.password.rawValue) ?? Preferences.password - let oldAlwaysSendCreds = UserDefaults.standard.object(forKey: Key.alwaysSendCreds.rawValue) as? Bool ?? Preferences.alwaysSendCreds - let oldIgnoreSSL = UserDefaults.standard.object(forKey: Key.ignoreSSL.rawValue) as? Bool ?? Preferences.ignoreSSL + let oldLocalUrl = UserDefaults.standard.string(forKey: "localUrl") ?? Preferences.localUrl + let oldRemoteUrl = UserDefaults.standard.string(forKey: "remoteUrl") ?? Preferences.remoteUrl + let oldUsername = UserDefaults.standard.string(forKey: "username") ?? Preferences.username + let oldPassword = UserDefaults.standard.string(forKey: "password") ?? Preferences.password + let oldAlwaysSendCreds = UserDefaults.standard.object(forKey: "alwaysSendCreds") as? Bool ?? Preferences.alwaysSendCreds + let oldIgnoreSSL = UserDefaults.standard.object(forKey: "ignoreSSL") as? Bool ?? Preferences.ignoreSSL // Create new configuration let newLocalConfiguration = ConnectionConfiguration( @@ -511,7 +380,6 @@ public extension Preferences { password: "", alwaysSendBasicAuth: oldAlwaysSendCreds, ignoreSSL: oldIgnoreSSL, - supportsNotifications: false, priority: 0 ) @@ -521,7 +389,6 @@ public extension Preferences { password: oldPassword, alwaysSendBasicAuth: oldAlwaysSendCreds, ignoreSSL: oldIgnoreSSL, - supportsNotifications: true, priority: 1 ) @@ -535,29 +402,45 @@ public extension Preferences { // MARK: All connections public extension Preferences { - static func getNotificationConnection(of stored: [String: Any]) -> ConnectionConfiguration? { - let remoteConfig = stored[Key.remoteConnectionConfig.rawValue] as? Data ?? Data() + static func getLowestPriorityOpenHABConnection(of stored: [String: Any]) -> ConnectionConfiguration? { + let localConfig = stored["localConnectionConfig"] as? Data ?? Data() + let localConnection = try? JSONDecoder().decode(ConnectionConfiguration.self, from: localConfig) + let remoteConfig = stored["remoteConnectionConfig"] as? Data ?? Data() let remoteConnection = try? JSONDecoder().decode(ConnectionConfiguration.self, from: remoteConfig) - return Preferences.getNotificationConnection(of: [remoteConnection]) + return Preferences.getLowestPriorityOpenHABConnection(of: [localConnection, remoteConnection]) } - static func getNotificationConnection(of connections: [ConnectionConfiguration?]) -> ConnectionConfiguration? { - // These used to be chained calls, but the swift compiler was compaining about complexity - let validConnections = connections.compactMap { $0 } - let notificationCapable = validConnections.filter(\.supportsNotifications) - // lower value means higher priority, 0 is primary - let sorted = notificationCapable.sorted { $0.priority < $1.priority } - return sorted.first + static func getLowestPriorityOpenHABConnection(of connections: [ConnectionConfiguration?]) -> ConnectionConfiguration? { + connections + .compactMap { $0 } + .filter { $0.url.contains("openhab.org") } + .sorted { $0.priority < $1.priority } + .first } - static func getNotificationConnection() -> ConnectionConfiguration? { - getNotificationConnection(of: [remoteConnectionConfig]) + static func getLowestPriorityOpenHABConnection() -> ConnectionConfiguration? { + getLowestPriorityOpenHABConnection(of: [localConnectionConfig, remoteConnectionConfig]) } } // MARK: - Sample Codable Model public extension ConnectionConfiguration { - static let localDefault = ConnectionConfiguration.makeDefaultLocal() - static let remoteDefault = ConnectionConfiguration.makeDefaultRemote() + static let localDefault = ConnectionConfiguration( + url: "http://192.168.1.1:8080", + username: "", + password: "", + alwaysSendBasicAuth: false, + ignoreSSL: false, + priority: 0 + ) + + static let remoteDefault = ConnectionConfiguration( + url: "https://myopenhab.org", + username: "", + password: "", + alwaysSendBasicAuth: false, + ignoreSSL: false, + priority: 1 + ) } diff --git a/openHAB.xcodeproj/xcshareddata/xcschemes/NotificationService.xcscheme b/openHAB.xcodeproj/xcshareddata/xcschemes/NotificationService.xcscheme deleted file mode 100644 index 1adbba228..000000000 --- a/openHAB.xcodeproj/xcshareddata/xcschemes/NotificationService.xcscheme +++ /dev/null @@ -1,98 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/openHAB/AppDelegate.swift b/openHAB/AppDelegate.swift index ee0e3bb8a..e1a2c6b01 100644 --- a/openHAB/AppDelegate.swift +++ b/openHAB/AppDelegate.swift @@ -233,8 +233,7 @@ extension AppDelegate: UNUserNotificationCenterDelegate { let message = userInfo["message"] as? String ?? NSLocalizedString("message_not_decoded", comment: "") let action = userInfo["actionIdentifier"] as? String ?? userInfo["on-click"] as? String - let cloudUserId = userInfo["userId"] as? String - await displayNotification(message: message, action: action, cloudUserId: cloudUserId) + await displayNotification(message: message, action: action) return [] // Modify this if you want to show banners, alerts, etc. } @@ -243,7 +242,6 @@ extension AppDelegate: UNUserNotificationCenterDelegate { func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async { var userInfo = response.notification.request.content.userInfo let actionIdentifier = response.actionIdentifier - logger.info("Notification clicked: action \(actionIdentifier) userInfo \(userInfo)") if actionIdentifier != UNNotificationDismissActionIdentifier { @@ -251,13 +249,11 @@ extension AppDelegate: UNUserNotificationCenterDelegate { userInfo["actionIdentifier"] = actionIdentifier } let action = userInfo["actionIdentifier"] as? String ?? userInfo["on-click"] as? String - let cloudUserId = userInfo["userId"] as? String - - notifyNotificationListeners(action: action, cloudUserId: cloudUserId) + notifyNotificationListeners(action: action) } } - private func displayNotification(message: String, action: String?, cloudUserId: String?) async { + private func displayNotification(message: String, action: String?) async { logger.info("displayNotification \(message)") Task { @@ -293,7 +289,7 @@ extension AppDelegate: UNUserNotificationCenterDelegate { // Use closure-based tap gesture insteae of #selector let tapGesture = MessageTapGestureRecognizer { Task { - self.messageViewTapped(action: action, cloudUserId: cloudUserId) + self.messageViewTapped(action: action) } } view.addGestureRecognizer(tapGesture) @@ -304,17 +300,17 @@ extension AppDelegate: UNUserNotificationCenterDelegate { } // Action to be performed when the notification message view is tapped - func messageViewTapped(action: String?, cloudUserId: String? = nil) { - notifyNotificationListeners(action: action, cloudUserId: cloudUserId) + func messageViewTapped(action: String?) { + notifyNotificationListeners(action: action) SwiftMessages.hideAll() } // ✅ Ensure this runs on the MainActor @MainActor - private func notifyNotificationListeners(action: String?, cloudUserId: String? = nil) { + private func notifyNotificationListeners(action: String?) { if let navigationController = window?.rootViewController as? UINavigationController, let rootViewController = navigationController.viewControllers.first as? OpenHABRootViewController { - rootViewController.handleNotification(action: action, cloudUserId: cloudUserId) + rootViewController.handleNotification(action: action) } } } diff --git a/openHAB/DrawerView.swift b/openHAB/DrawerView.swift index 33097cb09..6e0885e19 100644 --- a/openHAB/DrawerView.swift +++ b/openHAB/DrawerView.swift @@ -198,11 +198,11 @@ struct DrawerView: View { Section(header: Text("System")) { settingsMenuEntry(image: .gear, text: "settings", goTo: .settings) - if Preferences.getNotificationConnection() != nil, !Preferences.demomode { + if Preferences.getLowestPriorityOpenHABConnection() != nil, !Preferences.demomode { settingsMenuEntry(image: .bell, text: "notifications", goTo: .notifications) } - settingsMenuEntry(image: .house, text: "Manage Homes", goTo: .homeSelection) + settingsMenuEntry(image: .house, text: "homeSelection", goTo: .homeSelection) } } diff --git a/openHAB/HomeSelectionView.swift b/openHAB/HomeSelectionView.swift index a46e7d223..839178ca2 100644 --- a/openHAB/HomeSelectionView.swift +++ b/openHAB/HomeSelectionView.swift @@ -133,7 +133,7 @@ struct HomeSelectionView: View { } } .onAppear(perform: loadHomesList) - .navigationBarTitle("Manage Homes") + .navigationBarTitle("homeSelection") .toolbar { if showEditOptions { ToolbarItemGroup(placement: .primaryAction) { diff --git a/openHAB/NewImageUITableViewCell.swift b/openHAB/NewImageUITableViewCell.swift index 1411354b9..f87a1b3b7 100644 --- a/openHAB/NewImageUITableViewCell.swift +++ b/openHAB/NewImageUITableViewCell.swift @@ -162,8 +162,8 @@ class NewImageUITableViewCell: GenericUITableViewCell, NoIconDisplayableCell { activeTask = Task { do { - guard let config = NetworkTracker.shared.activeConnection?.configuration else { - logger.warning("No openHAB connection found.") + guard let config = Preferences.getLowestPriorityOpenHABConnection() else { + logger.warning("No openHAB configuration found.") throw HTTPClientError.noConfiguration } let client = HTTPClient(configuration: config) diff --git a/openHAB/NotificationsView.swift b/openHAB/NotificationsView.swift index 6026faf3b..2821d08c3 100644 --- a/openHAB/NotificationsView.swift +++ b/openHAB/NotificationsView.swift @@ -133,7 +133,7 @@ extension NotificationsView where Tracker == NetworkTracker { let logger = Logger(subsystem: "org.openhab.app", category: "NotificationView") do { - guard let config = Preferences.getNotificationConnection() else { + guard let config = Preferences.getLowestPriorityOpenHABConnection() else { logger.warning("No openHAB configuration found.") return [] } diff --git a/openHAB/OpenHABRootViewController.swift b/openHAB/OpenHABRootViewController.swift index d349f9e7f..c55e1b8a6 100644 --- a/openHAB/OpenHABRootViewController.swift +++ b/openHAB/OpenHABRootViewController.swift @@ -345,7 +345,7 @@ class OpenHABRootViewController: UIViewController { .map { storedPrefsUpdate in // we want to recognize changes in the OpenHab URLs for any of the homes Set(storedPrefsUpdate.compactMap { storedWithUuid in let (uuid, homeConfig) = storedWithUuid - guard let connection = Preferences.getNotificationConnection(of: homeConfig) else { return nil } + guard let connection = Preferences.getLowestPriorityOpenHABConnection(of: homeConfig) else { return nil } return UuidWithConnection(uuid: uuid, connection: connection) }) } @@ -361,9 +361,7 @@ class OpenHABRootViewController: UIViewController { } let openhabConnectionSubscription = differences.sink { [weak self] diff in - logger.info("openhabConnectionSubscription updated") for newHome in diff.newValues { - logger.info("openhabConnectionSubscription uuid \(newHome.uuid) registering for push notifications ") self?.registerHome(uuid: newHome.uuid, connection: newHome.connection) } for deletedHome in diff.deletedValues { @@ -386,49 +384,22 @@ class OpenHABRootViewController: UIViewController { return } logger.info("Registering notifications with \(connection.url)") - _ = registerHome(uuid, connection, deviceToken, deviceId, deviceName) + _ = registerHome(connection, deviceToken, deviceId, deviceName) } - private func registerHome(_ uuid: String, _ config: ConnectionConfiguration, _ deviceToken: String, _ deviceId: String, _ deviceName: String) -> Task { + private func registerHome(_ config: ConnectionConfiguration, _ deviceToken: String, _ deviceId: String, _ deviceName: String) -> Task { Task { do { let client = HTTPClient(configuration: config) - if let cloudUserId = try await client.register(prefsURL: config.url, deviceToken: deviceToken, deviceId: deviceId, deviceName: deviceName) { - var cc = config - cc.cloudUserId = cloudUserId - Preferences.updateRemoteConnectionConfig(cc, for: uuid) - logger.info("my.openHAB registration succeeded with cloudUserId \(cloudUserId)") - } - logger.info("my.openHAB registration succeeded without cloudUserId") + try await client.register(prefsURL: config.url, deviceToken: deviceToken, deviceId: deviceId, deviceName: deviceName) + logger.info("my.openHAB registration succeeded") } catch { logger.error("my.openHAB registration failed \(error.localizedDescription)") } } } - func handleNotification(action: String?, cloudUserId: String?) { - guard let action else { return } - - logger.info("handleNotification cloudUserId: \(cloudUserId ?? "")") - if let cloudUserId, let targetHome = Preferences.storedSettingsId(forCloudUserId: cloudUserId) { - if Preferences.remoteConnectionConfig.cloudUserId != cloudUserId { - // if we need to switch homes, disconnnect the tracking fist,and wait for the tracker to start again with the updated preferences - Task { - await NetworkTracker.shared.stopTracking() - logger.info("Switching to home \(targetHome)") - Preferences.switchCurrentlyUsedSettings(to: targetHome) - await NetworkTracker.shared.waitForActiveConnection() - handleNotificationInternal(action) - } - return - } - } - handleNotificationInternal(action) - } - - private func handleNotificationInternal(_ action: String?) { - logger.info("handleNotificationInternal: \(action ?? "")") - + func handleNotification(action: String?) { guard let action else { return } let cmd = action.split(separator: ":").dropFirst().joined(separator: ":") diff --git a/openHAB/SettingsView/ConnectionSettingsView.swift b/openHAB/SettingsView/ConnectionSettingsView.swift index f6096e5e3..dbe92651f 100644 --- a/openHAB/SettingsView/ConnectionSettingsView.swift +++ b/openHAB/SettingsView/ConnectionSettingsView.swift @@ -22,8 +22,8 @@ struct ConnectionSettingsView: View { Toggle("Demo Mode", isOn: $settingsDemomode) if !settingsDemomode { - SingleConnectionSettingsView(headerText: "Local server", connectionConfig: $localConnectionConfiguration, showNotificationToggle: false) - SingleConnectionSettingsView(headerText: "Remote server", connectionConfig: $remoteConnectionConfiguration, showNotificationToggle: true) + SingleConnectionSettingsView(headerText: "Local server", connectionConfig: $localConnectionConfiguration) + SingleConnectionSettingsView(headerText: "Remote server", connectionConfig: $remoteConnectionConfiguration) } } } @@ -32,14 +32,14 @@ struct ConnectionSettingsView: View { #Preview { struct PreviewWrapper: View { @State var demoMode = false - @State var localUrl = "https://openhab.local:8443" + @State var localUrl = "http://192.168.1.100" @State var remoteUrl = "https://myopenhab.org" @State var username = "user" @State var password = "password123" @State var alwaysSendCreds = true @State var connectionConfig1 = ConnectionConfiguration( - url: "https://openhab.local:8443", + url: "http://192.168.2.1", username: "user", password: "password123" ) diff --git a/openHAB/SettingsView/SettingsView.swift b/openHAB/SettingsView/SettingsView.swift index a0586f444..a944d9c3e 100644 --- a/openHAB/SettingsView/SettingsView.swift +++ b/openHAB/SettingsView/SettingsView.swift @@ -34,7 +34,7 @@ struct SettingsView: View { @State var sitemaps: [OpenHABSitemap] = [] @State var settingsLocalConnectionConfiguration = ConnectionConfiguration(url: "", username: "", password: "") @State var settingsRemoteConnectionConfiguration = ConnectionConfiguration(url: "", username: "", password: "") - @State var settingsHomeName = "" + @State var viewAppearedOnce: Bool = false @Environment(\.dismiss) private var dismiss @@ -74,7 +74,7 @@ struct SettingsView: View { } .formStyle(.grouped) .navigationBarBackButtonHidden(true) - .navigationBarTitle("\(settingsHomeName) Settings") + .navigationBarTitle("Settings") .toolbar { ToolbarItemGroup(placement: .primaryAction) { Button("Save") { @@ -140,7 +140,6 @@ struct SettingsView: View { settingsSitemapForWatch = Preferences.sitemapForWatch settingsLocalConnectionConfiguration = Preferences.localConnectionConfig settingsRemoteConnectionConfiguration = Preferences.remoteConnectionConfig - settingsHomeName = Preferences.homeName } func saveSettings() { diff --git a/openHAB/SettingsView/SingleConnectionSettingsView.swift b/openHAB/SettingsView/SingleConnectionSettingsView.swift index 6723e3a5b..97cbb8c0d 100644 --- a/openHAB/SettingsView/SingleConnectionSettingsView.swift +++ b/openHAB/SettingsView/SingleConnectionSettingsView.swift @@ -34,11 +34,10 @@ struct SpinningSymbol: View { struct SingleConnectionSettingsView: View { var headerText: String @Binding var connectionConfig: ConnectionConfiguration - var showNotificationToggle: Bool + @State private var isTestingConnection = false @State private var connectionTestMessage: String? @State private var connectionTestSuccess: Bool? - @State private var showAdvanced = false var body: some View { Section(header: Text(headerText)) { @@ -121,23 +120,14 @@ struct SingleConnectionSettingsView: View { Text("Enter password on server") } } - DisclosureGroup("Advanced", isExpanded: $showAdvanced) { - Toggle("Always send credentials", isOn: $connectionConfig.alwaysSendBasicAuth) - .font(.caption) - .opacity(0.8) - - Toggle("Ignore SSL certificates", isOn: $connectionConfig.ignoreSSL) - .font(.caption) - .opacity(0.8) - - if showNotificationToggle { - Toggle("openHAB Cloud Service", isOn: $connectionConfig.supportsNotifications) - .font(.caption) - .opacity(0.8) - } - } - .font(.subheadline) - .animation(.default, value: showAdvanced) + + Toggle("Always send credentials", isOn: $connectionConfig.alwaysSendBasicAuth) + .font(.caption) + .opacity(0.8) + + Toggle("Ignore SSL certificates", isOn: $connectionConfig.ignoreSSL) + .font(.caption) + .opacity(0.8) } } @@ -209,15 +199,16 @@ struct SingleConnectionSettingsView: View { // **TODO Migrate to @Previewable on iOS 17 #Preview { struct PreviewWrapper: View { - @State var connectionConfig = ConnectionConfiguration.makeDefaultRemote { cfg in - cfg.username = "user" - cfg.password = "password123" - } + @State var connectionConfig = ConnectionConfiguration( + url: "http://192.168.2.1:8080", + username: "user", + password: "password123" + ) var body: some View { NavigationView { Form { - SingleConnectionSettingsView(headerText: "Connection Settings for local server", connectionConfig: $connectionConfig, showNotificationToggle: false) + SingleConnectionSettingsView(headerText: "Connection Settings for local server", connectionConfig: $connectionConfig) } } } diff --git a/openHAB/VideoUITableViewCell.swift b/openHAB/VideoUITableViewCell.swift index e1f734627..6810d29d5 100644 --- a/openHAB/VideoUITableViewCell.swift +++ b/openHAB/VideoUITableViewCell.swift @@ -170,7 +170,7 @@ class VideoUITableViewCell: GenericUITableViewCell, NoIconDisplayableCell { activeTask = Task { do { - guard let config = NetworkTracker.shared.activeConnection?.configuration else { + guard let config = Preferences.getLowestPriorityOpenHABConnection() else { logger.warning("No openHAB configuration found.") throw HTTPClientError.noConfiguration } From 642ed7931b06070a9ba2980e09836204ff34693e Mon Sep 17 00:00:00 2001 From: Tassilo Karge Date: Sun, 22 Jun 2025 19:10:57 +0200 Subject: [PATCH 229/476] Refactor preferences thoroughly for multiple homes support (#882) * Supports notifications for multiple homes/cloud instances Signed-off-by: Dan Cunningham * Address spelling error -> supportsNotifications Avoid force unwrap on networkTracker! Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> * encapsulate home settings Signed-off-by: Tassilo Karge * use the same uuid for initial home preferences and initial currently used preferences Signed-off-by: Tassilo Karge * naming improvements Signed-off-by: Tassilo Karge * reenable migration from old defaults Signed-off-by: Tassilo Karge * adapt UserDefaultsTest to refactored preferences Signed-off-by: Tassilo Karge --------- Signed-off-by: Dan Cunningham Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> Signed-off-by: Tassilo Karge Co-authored-by: Dan Cunningham Co-authored-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- NotificationService/NotificationService.swift | 38 +- .../Util/ConnectionConfiguration.swift | 15 +- .../Sources/OpenHABCore/Util/HTTPClient.swift | 5 +- .../OpenHABCore/Util/NetworkTracker.swift | 4 + .../OpenHABCore/Util/OpenHABItemCache.swift | 4 +- .../OpenHABCore/Util/Preferences.swift | 435 +++++++++--------- .../OpenHABCoreTests/UserDefaultsTests.swift | 55 ++- .../xcschemes/NotificationService.xcscheme | 98 ++++ openHAB/AppDelegate.swift | 24 +- openHAB/DrawerView.swift | 26 +- openHAB/HomeSelectionView.swift | 20 +- openHAB/NewImageUITableViewCell.swift | 4 +- openHAB/NotificationsView.swift | 2 +- openHAB/OpenHABRootViewController.swift | 89 ++-- openHAB/OpenHABSitemapViewController.swift | 6 +- openHAB/OpenHABWebViewController.swift | 6 +- .../SettingsView/ConnectionSettingsView.swift | 8 +- openHAB/SettingsView/SettingsView.swift | 76 +-- .../SingleConnectionSettingsView.swift | 11 +- openHAB/VideoUITableViewCell.swift | 2 +- openHAB/WatchMessageService.swift | 28 +- openHAB/WebUITableViewCell.swift | 2 +- 22 files changed, 545 insertions(+), 413 deletions(-) create mode 100644 openHAB.xcodeproj/xcshareddata/xcschemes/NotificationService.xcscheme diff --git a/NotificationService/NotificationService.swift b/NotificationService/NotificationService.swift index 5975b9e09..435091148 100644 --- a/NotificationService/NotificationService.swift +++ b/NotificationService/NotificationService.swift @@ -46,7 +46,8 @@ class NotificationService: UNNotificationServiceExtension { var contentHandler: ((UNNotificationContent) -> Void)? var bestAttemptContent: UNMutableNotificationContent? var cancellables = Set() - + var networkTracker: NetworkTracker? + var cloudUserId: String? let logger = Logger(subsystem: "org.openhab.network", category: "NotificationService") override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { @@ -66,6 +67,8 @@ class NotificationService: UNNotificationServiceExtension { bestAttemptContent.body = message } + cloudUserId = userInfo["userId"] as? String + // Check if the user has defined custom actions in the payload if let actionsArray = parseActions(userInfo), let category = parseCategory(userInfo) { for actionDict in actionsArray { @@ -186,11 +189,9 @@ class NotificationService: UNNotificationServiceExtension { } private func downloadMedia(url: String) async throws -> (URL?, String?) { - await NetworkTracker.shared.startTracking(connectionConfigurations: [Preferences.localConnectionConfig, Preferences.remoteConnectionConfig]) - guard let fullURL = await resolveFullURL(from: url) else { return (nil, nil) } - guard let activeConfig = await NetworkTracker.shared.waitForActiveConnection()?.configuration else { return (nil, nil) } + guard let activeConfig = await networkTracker().waitForActiveConnection()?.configuration else { return (nil, nil) } let client = HTTPClient(configuration: activeConfig) @@ -201,7 +202,7 @@ class NotificationService: UNNotificationServiceExtension { // 🔹 Extracted helper function to determine full URL private func resolveFullURL(from url: String) async -> URL? { if url.starts(with: "/") { - guard let activeConfig = await NetworkTracker.shared.waitForActiveConnection()?.configuration else { return nil } + guard let activeConfig = await networkTracker().waitForActiveConnection()?.configuration else { return nil } return URL(string: activeConfig.url)?.appendingPathComponent(url) } else { return URL(string: url) @@ -221,7 +222,7 @@ class NotificationService: UNNotificationServiceExtension { let itemName = String(itemURL.absoluteString.dropFirst(scheme.count + 1)) - let item = try await NetworkTracker.shared.getItemByName(id: itemName) + let item = try await networkTracker().getItemByName(id: itemName) guard let state = item?.state else { return (nil, nil) } // Extract MIME type and base64 string @@ -270,4 +271,29 @@ class NotificationService: UNNotificationServiceExtension { } return nil } + + func networkTracker() async -> NetworkTracker { + if let tracker = networkTracker { + return tracker + } + + let tracker = NetworkTracker.shared + let connections: [ConnectionConfiguration] + + if let cloudUserId, + let instance = await Preferences.storedHome(forCloudUserId: cloudUserId) { + logger.info("Setting up network tracking for \(cloudUserId)") + connections = [instance.localConnectionConfig, instance.remoteConnectionConfig] + } else { + logger.info("Using default connection configurations") + connections = await [ + Preferences.currentHomePreferences.localConnectionConfig, + Preferences.currentHomePreferences.remoteConnectionConfig + ] + } + + await tracker.startTracking(connectionConfigurations: connections) + networkTracker = tracker + return tracker + } } diff --git a/OpenHABCore/Sources/OpenHABCore/Util/ConnectionConfiguration.swift b/OpenHABCore/Sources/OpenHABCore/Util/ConnectionConfiguration.swift index 15cf8c16b..9bdff40dc 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/ConnectionConfiguration.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/ConnectionConfiguration.swift @@ -16,10 +16,10 @@ public struct ConnectionPayload: Codable { public var remote: ConnectionConfiguration } -public struct ConnectionConfiguration: Hashable, Sendable, Codable { +public struct ConnectionConfiguration: Hashable, Sendable, Codable, Equatable { // 🔹 Coding keys for manual encoding/decoding private enum CodingKeys: String, CodingKey { - case url, username, password, alwaysSendBasicAuth, ignoreSSL, priority + case url, username, password, alwaysSendBasicAuth, ignoreSSL, supportsNotifications, priority, cloudUserId } public var url: String @@ -28,14 +28,17 @@ public struct ConnectionConfiguration: Hashable, Sendable, Codable { public var alwaysSendBasicAuth: Bool public var ignoreSSL: Bool public var priority: Int // Lower is higher priority, 0 is primary + public var supportsNotifications = false + public var cloudUserId: String? - public init(url: String, username: String, password: String, alwaysSendBasicAuth: Bool = false, ignoreSSL: Bool = false, priority: Int = 10) { + public init(url: String, username: String, password: String, alwaysSendBasicAuth: Bool = false, ignoreSSL: Bool = false, supportsNotifications: Bool = false, priority: Int = 10) { self.url = ConnectionConfiguration.normalizeURL(url) self.username = username self.password = password self.alwaysSendBasicAuth = alwaysSendBasicAuth self.ignoreSSL = ignoreSSL self.priority = priority + self.supportsNotifications = supportsNotifications } // 🔹 Ensure normalization on decoding @@ -47,7 +50,9 @@ public struct ConnectionConfiguration: Hashable, Sendable, Codable { password = try container.decode(String.self, forKey: .password) alwaysSendBasicAuth = try container.decode(Bool.self, forKey: .alwaysSendBasicAuth) ignoreSSL = try container.decode(Bool.self, forKey: .ignoreSSL) + supportsNotifications = try container.decode(Bool.self, forKey: .supportsNotifications) priority = try container.decode(Int.self, forKey: .priority) + cloudUserId = try container.decodeIfPresent(String.self, forKey: .cloudUserId) } // 🔹 Normalize a URL (removes trailing slashes, trims spaces, redirects openHAB cloud) @@ -75,6 +80,10 @@ public struct ConnectionConfiguration: Hashable, Sendable, Codable { try container.encode(password, forKey: .password) try container.encode(alwaysSendBasicAuth, forKey: .alwaysSendBasicAuth) try container.encode(ignoreSSL, forKey: .ignoreSSL) + try container.encode(supportsNotifications, forKey: .supportsNotifications) try container.encode(priority, forKey: .priority) + if let cloudUserId { + try container.encode(cloudUserId, forKey: .cloudUserId) + } } } diff --git a/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift b/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift index 413252409..7982916a1 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift @@ -159,10 +159,11 @@ public final class HTTPClient: NSObject { public func register(prefsURL: String, deviceToken: String, deviceId: String, - deviceName: String) async throws -> Data? { + deviceName: String) async throws -> String? { if let url = Endpoint.appleRegistration(prefsURL: prefsURL, deviceToken: deviceToken, deviceId: deviceId, deviceName: deviceName).url { let (data, _): (Data, URLResponse) = try await doRequest(baseURL: url, type: .data) - return data + struct CloudUserResponse: Decodable { let userId: String } + return try? JSONDecoder().decode(CloudUserResponse.self, from: data).userId } else { throw HTTPClientError.couldNotRegister } diff --git a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift index 79f73c9b9..95134e902 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift @@ -162,6 +162,10 @@ public final class NetworkTracker: ObservableObject { await attemptConnection() } + public func stopTracking() async { + await setActiveConnection(nil) + } + public func waitForActiveConnection(timeout: TimeInterval = 10) async -> ConnectionInfo? { logger.info("NetworkConnection: waitForActiveConnection") // Utilize for await to listen for changes in $activeConnection diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift index 16f3460d1..5fe4d1599 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift @@ -40,8 +40,8 @@ public actor OpenHABItemCache { } public func setup() async { - let connection1: ConnectionConfiguration = await Preferences.localConnectionConfig - let connection2: ConnectionConfiguration = await Preferences.remoteConnectionConfig + let connection1: ConnectionConfiguration = await Preferences.currentHomePreferences.localConnectionConfig + let connection2: ConnectionConfiguration = await Preferences.currentHomePreferences.remoteConnectionConfig logger.info("Local: \(connection1.url), Remote: \(connection2.url)") await NetworkTracker.shared.startTracking(connectionConfigurations: [connection1, connection2]) } diff --git a/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift index 3fbd998e5..432cee500 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift @@ -17,7 +17,7 @@ import UIKit public struct UserDefault { private let key: String private let defaultValue: T - private let store: Bool + private let isHomeProperty: Bool private let subject: CurrentValueSubject public var wrappedValue: T { @@ -25,7 +25,7 @@ public struct UserDefault { Preferences.getPreference(key: key, defaultValue: defaultValue, encoder: { $0 }, decoder: { $0 as? T }) } set { - Preferences.preferenceChanged(newValue: newValue, key: key, store: store, subject: subject) { $0 } + Preferences.preferenceChanged(newValue: newValue, key: key, isHomeProperty: isHomeProperty, subject: subject) { $0 } } } @@ -33,10 +33,10 @@ public struct UserDefault { subject.eraseToAnyPublisher() } - public init(_ key: String, defaultValue: T, store: Bool = true) { + public init(_ key: String, defaultValue: T, isHomeProperty: Bool = false) { self.key = key self.defaultValue = defaultValue - self.store = store + self.isHomeProperty = isHomeProperty let currentValue = Preferences.getPreference(key: key, defaultValue: defaultValue, encoder: { $0 }, decoder: { $0 as? T }) subject = CurrentValueSubject(currentValue) } @@ -46,7 +46,7 @@ public struct UserDefault { public struct UserDefaultObject { private let key: String private let defaultValue: T - private let store: Bool + private let isHomeProperty: Bool private let subject: CurrentValueSubject private let objectDecoder: (Any) -> (T?) = { @@ -63,7 +63,7 @@ public struct UserDefaultObject { Preferences.getPreference(key: key, defaultValue: defaultValue, encoder: objectEncoder, decoder: objectDecoder) } set { - Preferences.preferenceChanged(newValue: newValue, key: key, store: store, subject: subject, converter: objectEncoder) + Preferences.preferenceChanged(newValue: newValue, key: key, isHomeProperty: isHomeProperty, subject: subject, converter: objectEncoder) } } @@ -71,10 +71,10 @@ public struct UserDefaultObject { subject.eraseToAnyPublisher() } - init(_ key: String, defaultValue: T, store: Bool = true) { + init(_ key: String, defaultValue: T, isHomeProperty: Bool = false) { self.key = key self.defaultValue = defaultValue - self.store = store + self.isHomeProperty = isHomeProperty // Combine publication let currentValue = Preferences.getPreference(key: key, defaultValue: defaultValue, encoder: objectEncoder, decoder: objectDecoder) @@ -82,106 +82,66 @@ public struct UserDefaultObject { } } -@propertyWrapper @MainActor -public struct UserDefaultURL { - private static let urlSanitizer: (String) -> (String?) = { - // Trim and validate the new URL - let trimmedUri = $0.removeTrailingSlashes().trimmingCharacters(in: .whitespacesAndNewlines) - guard trimmedUri.isValidURL || trimmedUri.isEmpty else { // empty is the default for localUrl - return nil - } - return trimmedUri - } - - private static let urlConverter: (Any) -> (String?) = { - guard let preferenceString = $0 as? String else { - return nil - } - return urlSanitizer(preferenceString) - } - - private let key: String - private let defaultValue: String - private let store: Bool - private let subject: CurrentValueSubject - - public var wrappedValue: String { - get { - Preferences.getPreference(key: key, defaultValue: defaultValue, encoder: UserDefaultURL.urlSanitizer, decoder: UserDefaultURL.urlConverter) - } - set { - Preferences.preferenceChanged(newValue: newValue, key: key, store: true, subject: subject, sanitize: UserDefaultURL.urlSanitizer) { $0 } - } - } - - public var projectedValue: AnyPublisher { - subject.eraseToAnyPublisher() - } - - public init(_ key: String, defaultValue: String, store: Bool = true) { - self.key = key - self.defaultValue = defaultValue - self.store = store - let currentValue = Preferences.getPreference(key: key, defaultValue: defaultValue, encoder: { $0 }, decoder: UserDefaultURL.urlConverter) - subject = CurrentValueSubject(currentValue) +public struct HomePreferences: Codable, Sendable, Equatable { + public let id: UUID + public var defaultView: String = "web" + public var demomode: Bool = true + public var realTimeSliders: Bool = false + public var iconType: Int = 0 + public var defaultSitemap: String = "demo" + public var sortSitemapsBy: Int = 0 + public var defaultMainUIPath: String = "" + public var alwaysAllowWebRTC: Bool = false + public var sitemapForWatch: String = "watch" + public var localConnectionConfig: ConnectionConfiguration = .localDefault + public var remoteConnectionConfig: ConnectionConfiguration = .remoteDefault + public var sitemapForWatchLabel: String = "watch" + public var homeName: String = "Home" + + fileprivate init(id: UUID) { + self.id = id } } @MainActor public enum Preferences { - static let sharedDefaults = UserDefaults(suiteName: "group.org.openhab.app")! - - // MARK: - Public Deprecated preferences - - @UserDefaultURL("localUrl", defaultValue: "", store: false) public static var localUrl: String - @UserDefaultURL("remoteUrl", defaultValue: "https://myopenhab.org", store: false) public static var remoteUrl: String - @UserDefault("username", defaultValue: "test", store: false) public static var username: String - @UserDefault("password", defaultValue: "test", store: false) public static var password: String - @UserDefault("alwaysSendCreds", defaultValue: false, store: false) public static var alwaysSendCreds: Bool - @UserDefault("ignoreSSL", defaultValue: false, store: false) public static var ignoreSSL: Bool - - // MARK: - Public Home related preferences - - @UserDefaultURL("defaultView", defaultValue: "web") public static var defaultView: String - @UserDefault("demomode", defaultValue: true) public static var demomode: Bool - @UserDefault("realTimeSliders", defaultValue: false) public static var realTimeSliders: Bool - @UserDefault("iconType", defaultValue: 0) public static var iconType: Int - @UserDefault("defaultSitemap", defaultValue: "demo") public static var defaultSitemap: String - @UserDefault("sortSitemapsBy", defaultValue: 0) public static var sortSitemapsBy: Int - @UserDefault("defaultMainUIPath", defaultValue: "") public static var defaultMainUIPath: String - @UserDefault("alwaysAllowWebRTC", defaultValue: false) public static var alwaysAllowWebRTC: Bool - @UserDefault("sitemapForWatch", defaultValue: "watch") public static var sitemapForWatch: String - @UserDefaultObject("localConnectionConfig", defaultValue: ConnectionConfiguration.localDefault) public static var localConnectionConfig: ConnectionConfiguration - @UserDefaultObject("remoteConnectionConfig", defaultValue: ConnectionConfiguration.remoteDefault) public static var remoteConnectionConfig: ConnectionConfiguration - @UserDefault("sitemapForWatchLabel", defaultValue: "watch") public static var sitemapForWatchLabel: String - @UserDefault("homeName", defaultValue: "Home") public static var homeName: String + /// the currently applied settings set from storedHomes + @UserDefaultObject("currentHomePreferences", defaultValue: HomePreferences(id: Preferences.activeHomeId)) + public private(set) static var currentHomePreferences: HomePreferences - // MARK: - Public App related preferences + @UserDefault("sendCrashReports", defaultValue: false) + public static var sendCrashReports: Bool - @UserDefault("sendCrashReports", defaultValue: false, store: false) public static var sendCrashReports: Bool + @UserDefault("idleOff", defaultValue: false) + public static var idleOff: Bool - @UserDefault("idleOff", defaultValue: false, store: false) public static var idleOff: Bool + @UserDefault("currentWebViewPath", defaultValue: "") + public static var currentWebViewPath: String - /// settings for different homes TODO come up with better name - @UserDefault("storedPreferences", defaultValue: [:], store: false) public static var storedPreferences: [String: [String: any Sendable]] + /// settings for different homes + @UserDefaultObject("storedHomes", defaultValue: [:]) + public private(set) static var storedHomes: [UUID: HomePreferences] - // MARK: - Private preferences + /// the currently applied settings set from storedHomes + @UserDefaultObject("activeHomeId", defaultValue: UUID()) + private static var activeHomeId: UUID - /// the currently applied settings set from storedPreferences - @UserDefault("currentlyUsedSettings", defaultValue: UUID().uuidString, store: false) public private(set) static var currentlyUsedSettings: String + @UserDefault("didMigrateToSharedDefaults", defaultValue: false) + private static var didMigrateToSharedDefaults: Bool - @UserDefault("didMigrateToSharedDefaults", defaultValue: false, store: false) private static var didMigrateToSharedDefaults: Bool - @UserDefault("didMigrateToConnectionConfig", defaultValue: false, store: false) private static var didMigrateToConnectionConfig: Bool - @UserDefault("currentWebViewPath", defaultValue: "", store: false) public static var currentWebViewPath: String + @UserDefault("didMigrateToMultipleHomes", defaultValue: false) + private static var didMigrateToMultipleHomes: Bool - private static var loadingStoredPreferences = false + private static var loadingStoredHome = false } // MARK: Retrieving preference from user defaults, reacting to preference change -private extension Preferences { - static func getPreference(key: String, defaultValue: T, encoder: (T) -> (some Sendable)?, decoder: (Any?) -> T?) -> T { - let preferenceValue = Preferences.sharedDefaults.object(forKey: key) +extension Preferences { + static let sharedDefaults = UserDefaults(suiteName: "group.org.openhab.app")! + + fileprivate static func getPreference(key: String, defaultValue: T, encoder: (T) -> (some Sendable)?, decoder: (Any?) -> T?) -> T { + let preferenceValue = sharedDefaults.object(forKey: key) if let preferenceConverted = decoder(preferenceValue) { os_log( "Preference value %{PUBLIC}@ is %{PUBLIC}@", @@ -210,12 +170,12 @@ private extension Preferences { ) } let fallback = defaultValue - Preferences.sharedDefaults.set(encoder(fallback), forKey: key) + sharedDefaults.set(encoder(fallback), forKey: key) return fallback } } - static func preferenceChanged(newValue: T, key: String, store: Bool, subject: CurrentValueSubject, sanitize: (T) -> (T?) = { $0 }, converter: (T) -> (some Sendable)?) { + fileprivate static func preferenceChanged(newValue: T, key: String, isHomeProperty: Bool, subject: CurrentValueSubject, sanitize: (T) -> (T?) = { $0 }, converter: (T) -> (some Sendable)?) { guard let sanitized = sanitize(newValue) else { os_log("Preference %{PUBLIC}@ new value \"%{PUBLIC}@\" could not be sanitized, will be ignored", log: .default, type: .debug, key, "\(newValue)") return @@ -226,10 +186,8 @@ private extension Preferences { return } os_log("Preference %{PUBLIC}@ will be changed to value %{PUBLIC}@", log: .default, type: .debug, key, "\(newValue)") - Preferences.sharedDefaults.set(convertedValue, forKey: key) - if store { - Preferences.storeCurrentPreferences(updatedKey: key, updatedValue: convertedValue) - } + sharedDefaults.set(convertedValue, forKey: key) + DispatchQueue.main.async { [subject] in subject.send(sanitized) } @@ -239,199 +197,227 @@ private extension Preferences { // MARK: Multiple homes public extension Preferences { - static func listStoredPreferences() -> [UUID] { - let preferenceIds = storedPreferences + static func listStoredHomes() -> [UUID] { + let preferenceIds = storedHomes .sorted { e1, e2 in - (e1.value["homeName"] as? String ?? "") <= (e2.value["homeName"] as? String ?? "") + e1.value.homeName <= e2.value.homeName } .map(\.key) - return preferenceIds.compactMap { UUID(uuidString: $0) } + return preferenceIds } - static func getCurrentlyUsedSettings() -> UUID { - guard let currentPreferenceUUID = UUID(uuidString: currentlyUsedSettings) else { - fatalError("currentlyUsedSettings must be a UUID, but was \(currentlyUsedSettings)") - } - return currentPreferenceUUID + static func createAndLoadNewStoredSettings(homeName: String) { + activeHomeId = UUID() + var newHome = HomePreferences(id: activeHomeId) + newHome.homeName = homeName + loadHomePreferences(newHome) } - static func initializeStoredPreferences() { - if storedPreferences.isEmpty { - // first there might be no stored preferences, if no preference was changed since the update - storeCurrentPreferences() + static func renameHome(_ homeId: UUID, newHomeName: String) { + if homeId == activeHomeId { + modifyActiveHome { + $0.homeName = newHomeName + } + } else { + var stored = storedHomes + stored[homeId]?.homeName = newHomeName + storedHomes = stored } } - static func createAndLoadNewStoredSettings(homeName: String) { - currentlyUsedSettings = UUID().uuidString - loadSettings(stored: ["homeName": homeName]) - } - - static func renameHome(_ settingsId: UUID, newHomeName: String) { - var stored = storedPreferences - stored[settingsId.uuidString]?["homeName"] = newHomeName - storedPreferences = stored + /// helper function for when we update the remote connection cloudUserId for notifications + static func setCloudUserId(_ cloudUserId: String?, for homeId: UUID) { + if homeId == activeHomeId { + modifyActiveHome { homePreferences in + homePreferences.remoteConnectionConfig.cloudUserId = cloudUserId + } + } else { + var stored = storedHomes + var home = stored[homeId] + home?.remoteConnectionConfig.cloudUserId = cloudUserId + stored[homeId] = home + storedHomes = stored + } } - static func deleteStoredSettings(_ settingsId: UUID) { - guard settingsId != getCurrentlyUsedSettings() else { + static func deleteStoredHome(_ homeId: UUID) { + guard homeId != activeHomeId else { // cannot remove current home return } - var stored = storedPreferences - stored.removeValue(forKey: settingsId.uuidString) - storedPreferences = stored + var stored = storedHomes + stored.removeValue(forKey: homeId) + storedHomes = stored } - static func switchCurrentlyUsedSettings(to settingsId: UUID) { - let settingsIdString = settingsId.uuidString - - guard let stored = storedPreferences[settingsIdString] else { + static func switchActiveHome(to homeId: UUID) { + guard let storedHome = storedHomes[homeId] else { // we have not stored our settings in that list yet return } - Preferences.currentlyUsedSettings = settingsIdString + activeHomeId = homeId + + loadHomePreferences(storedHome) + } + + private static func initializeStoredHomes() { + if storedHomes.isEmpty { + // first there might be no stored preferences, if no preference was changed since the update + storeActiveHome() + } + } + + private static func loadHomePreferences(_ preferences: HomePreferences) { + loadingStoredHome = true + Preferences.currentHomePreferences = preferences + loadingStoredHome = false + storeActiveHome() // store home settings in case they were not yet there + } - loadSettings(stored: stored) + private static func storeActiveHome() { + var all = storedHomes + let homeId = Preferences.activeHomeId + all[homeId] = Preferences.currentHomePreferences + storedHomes = all + os_log("Stored preferences for current home %{public}@", log: .default, type: .debug, homeId.uuidString) } - private static func loadSettings(stored: [String: Any]) { - loadingStoredPreferences = true - // TODO: not pretty to repeat everything here - Preferences.defaultView = stored["defaultView"] as? String ?? "web" - Preferences.demomode = stored["demomode"] as? Bool ?? true - Preferences.realTimeSliders = stored["realTimeSliders"] as? Bool ?? false - Preferences.iconType = stored["iconType"] as? Int ?? 0 - Preferences.defaultSitemap = stored["defaultSitemap"] as? String ?? "demo" - Preferences.sortSitemapsBy = stored["sortSitemapsBy"] as? Int ?? 0 - Preferences.defaultMainUIPath = stored["defaultMainUIPath"] as? String ?? "" - Preferences.alwaysAllowWebRTC = stored["alwaysAllowWebRTC"] as? Bool ?? false - Preferences.sitemapForWatch = stored["sitemapForWatch"] as? String ?? "watch" - Preferences.localConnectionConfig = (try? JSONDecoder().decode(ConnectionConfiguration.self, from: stored["localConnectionConfig"] as? Data ?? Data())) ?? ConnectionConfiguration.localDefault - Preferences.remoteConnectionConfig = (try? JSONDecoder().decode(ConnectionConfiguration.self, from: stored["remoteConnectionConfig"] as? Data ?? Data())) ?? ConnectionConfiguration.remoteDefault - Preferences.sitemapForWatchLabel = stored["sitemapForWatchLabel"] as? String ?? "watch" - Preferences.homeName = stored["homeName"] as? String ?? "Home" - loadingStoredPreferences = false - storeCurrentPreferences() + static func modifyActiveHome(modificationFunction: (inout HomePreferences) -> Void) { + var homePreferences = currentHomePreferences + modificationFunction(&homePreferences) + currentHomePreferences = homePreferences + storeActiveHome() } +} - static func storeCurrentPreferences(updatedKey: String = "", updatedValue: any Sendable = "") { - guard !loadingStoredPreferences else { - // concurrent access for writing and reading is prohibited - return +public extension Preferences { + static func firstStoredHome(where predicate: (HomePreferences) -> Bool) -> (id: UUID, record: HomePreferences)? { + for (uuid, record) in storedHomes { + guard predicate(record) else { continue } + return (uuid, record) } - // TODO: not pretty to repeat everything here - var stored = storedPreferences - stored[currentlyUsedSettings] = [ - "defaultView": updatedKey == "defaultView" ? updatedValue : Preferences.defaultView, - "demomode": updatedKey == "demomode" ? updatedValue : Preferences.demomode, - "realTimeSliders": updatedKey == "realTimeSliders" ? updatedValue : Preferences.realTimeSliders, - "iconType": updatedKey == "iconType" ? updatedValue : Preferences.iconType, - "defaultSitemap": updatedKey == "defaultSitemap" ? updatedValue : Preferences.defaultSitemap, - "sortSitemapsBy": updatedKey == "sortSitemapsBy" ? updatedValue : Preferences.sortSitemapsBy, - "defaultMainUIPath": updatedKey == "defaultMainUIPath" ? updatedValue : Preferences.defaultMainUIPath, - "alwaysAllowWebRTC": updatedKey == "alwaysAllowWebRTC" ? updatedValue : Preferences.alwaysAllowWebRTC, - "sitemapForWatch": updatedKey == "sitemapForWatch" ? updatedValue : Preferences.sitemapForWatch, - "localConnectionConfig": updatedKey == "localConnectionConfig" ? updatedValue : try? JSONEncoder().encode(Preferences.localConnectionConfig), - "remoteConnectionConfig": updatedKey == "remoteConnectionConfig" ? updatedValue : try? JSONEncoder().encode(Preferences.remoteConnectionConfig), - "sitemapForWatchLabel": updatedKey == "sitemapForWatchLabel" ? updatedValue : Preferences.sitemapForWatchLabel, - "homeName": updatedKey == "homeName" ? updatedValue : Preferences.homeName - ] - storedPreferences = stored + return nil + } + + static func storedHome(forCloudUserId id: String) -> HomePreferences? { + firstStoredHome { homePreferences in + homePreferences.remoteConnectionConfig.cloudUserId == id + }?.record } } // MARK: Migration public extension Preferences { - static func migrateUserDefaultsIfRequired() { + static func migratePreferences() { + initializeStoredHomes() + migrateToSharedDefaultsIfRequired() + migrateToMultipleHomesIfRequired() + } + + private static func migrateToSharedDefaultsIfRequired() { guard !didMigrateToSharedDefaults else { return } - didMigrateToSharedDefaults = true - Preferences.localUrl = UserDefaults.standard.string(forKey: "localUrl") ?? Preferences.localUrl - Preferences.remoteUrl = UserDefaults.standard.string(forKey: "remoteUrl") ?? Preferences.remoteUrl - Preferences.username = UserDefaults.standard.string(forKey: "username") ?? Preferences.username - Preferences.password = UserDefaults.standard.string(forKey: "password") ?? Preferences.password - Preferences.alwaysSendCreds = UserDefaults.standard.object(forKey: "alwaysSendCreds") as? Bool ?? Preferences.alwaysSendCreds - Preferences.ignoreSSL = UserDefaults.standard.object(forKey: "ignoreSSL") as? Bool ?? Preferences.ignoreSSL - Preferences.demomode = UserDefaults.standard.object(forKey: "demomode") as? Bool ?? Preferences.demomode + modifyActiveHome { currentHomePreferences in + currentHomePreferences.localConnectionConfig.url = UserDefaults.standard.string(forKey: "localUrl") ?? currentHomePreferences.localConnectionConfig.url + currentHomePreferences.localConnectionConfig.alwaysSendBasicAuth = UserDefaults.standard.object(forKey: "alwaysSendCreds") as? Bool ?? currentHomePreferences.localConnectionConfig.alwaysSendBasicAuth + currentHomePreferences.localConnectionConfig.ignoreSSL = UserDefaults.standard.object(forKey: "ignoreSSL") as? Bool ?? currentHomePreferences.localConnectionConfig.ignoreSSL + currentHomePreferences.remoteConnectionConfig.url = UserDefaults.standard.string(forKey: "remoteUrl") ?? currentHomePreferences.remoteConnectionConfig.url + currentHomePreferences.remoteConnectionConfig.username = UserDefaults.standard.string(forKey: "username") ?? currentHomePreferences.remoteConnectionConfig.username + currentHomePreferences.remoteConnectionConfig.password = UserDefaults.standard.string(forKey: "password") ?? currentHomePreferences.remoteConnectionConfig.password + currentHomePreferences.remoteConnectionConfig.alwaysSendBasicAuth = UserDefaults.standard.object(forKey: "alwaysSendCreds") as? Bool ?? currentHomePreferences.remoteConnectionConfig.alwaysSendBasicAuth + currentHomePreferences.remoteConnectionConfig.ignoreSSL = UserDefaults.standard.object(forKey: "ignoreSSL") as? Bool ?? currentHomePreferences.remoteConnectionConfig.ignoreSSL + currentHomePreferences.demomode = UserDefaults.standard.object(forKey: "demomode") as? Bool ?? currentHomePreferences.demomode + currentHomePreferences.realTimeSliders = UserDefaults.standard.object(forKey: "realTimeSliders") as? Bool ?? currentHomePreferences.realTimeSliders + currentHomePreferences.iconType = UserDefaults.standard.object(forKey: "iconType") as? Int ?? currentHomePreferences.iconType + currentHomePreferences.defaultSitemap = UserDefaults.standard.string(forKey: "defaultSitemap") ?? currentHomePreferences.defaultSitemap + } + Preferences.idleOff = UserDefaults.standard.object(forKey: "idleOff") as? Bool ?? Preferences.idleOff - Preferences.realTimeSliders = UserDefaults.standard.object(forKey: "realTimeSliders") as? Bool ?? Preferences.realTimeSliders - Preferences.iconType = UserDefaults.standard.object(forKey: "iconType") as? Int ?? Preferences.iconType - Preferences.defaultSitemap = UserDefaults.standard.string(forKey: "defaultSitemap") ?? Preferences.defaultSitemap Preferences.sendCrashReports = UserDefaults.standard.object(forKey: "sendCrashReports") as? Bool ?? Preferences.sendCrashReports + + didMigrateToSharedDefaults = true + // this was done implicitly + didMigrateToMultipleHomes = true } - static func migrateUserDefaultsToConnectionIfRequired() { - guard !didMigrateToConnectionConfig else { return } + private static func migrateToMultipleHomesIfRequired() { + guard !didMigrateToMultipleHomes else { return } + + migrateToSharedDefaultsIfRequired() - let oldLocalUrl = UserDefaults.standard.string(forKey: "localUrl") ?? Preferences.localUrl - let oldRemoteUrl = UserDefaults.standard.string(forKey: "remoteUrl") ?? Preferences.remoteUrl - let oldUsername = UserDefaults.standard.string(forKey: "username") ?? Preferences.username - let oldPassword = UserDefaults.standard.string(forKey: "password") ?? Preferences.password - let oldAlwaysSendCreds = UserDefaults.standard.object(forKey: "alwaysSendCreds") as? Bool ?? Preferences.alwaysSendCreds - let oldIgnoreSSL = UserDefaults.standard.object(forKey: "ignoreSSL") as? Bool ?? Preferences.ignoreSSL + let oldLocalUrl = Preferences.sharedDefaults.string(forKey: "localUrl") + let oldRemoteUrl = Preferences.sharedDefaults.string(forKey: "remoteUrl") + let oldUsername = Preferences.sharedDefaults.string(forKey: "username") + let oldPassword = Preferences.sharedDefaults.string(forKey: "password") + let oldAlwaysSendCreds = Preferences.sharedDefaults.object(forKey: "alwaysSendCreds") as? Bool + let oldIgnoreSSL = Preferences.sharedDefaults.object(forKey: "ignoreSSL") as? Bool // Create new configuration - let newLocalConfiguration = ConnectionConfiguration( - url: oldLocalUrl, - username: "", - password: "", - alwaysSendBasicAuth: oldAlwaysSendCreds, - ignoreSSL: oldIgnoreSSL, - priority: 0 - ) - - let newRemoteConfiguration = ConnectionConfiguration( - url: oldRemoteUrl, - username: oldUsername, - password: oldPassword, - alwaysSendBasicAuth: oldAlwaysSendCreds, - ignoreSSL: oldIgnoreSSL, - priority: 1 - ) + var newLocalConfiguration = Preferences.currentHomePreferences.localConnectionConfig + newLocalConfiguration.url = oldLocalUrl ?? newLocalConfiguration.url + newLocalConfiguration.alwaysSendBasicAuth = oldAlwaysSendCreds ?? newLocalConfiguration.alwaysSendBasicAuth + newLocalConfiguration.ignoreSSL = oldIgnoreSSL ?? newLocalConfiguration.ignoreSSL + + var newRemoteConfiguration = Preferences.currentHomePreferences.remoteConnectionConfig + newRemoteConfiguration.url = oldRemoteUrl ?? newRemoteConfiguration.url + newRemoteConfiguration.username = oldUsername ?? newRemoteConfiguration.username + newRemoteConfiguration.password = oldPassword ?? newRemoteConfiguration.password + newRemoteConfiguration.alwaysSendBasicAuth = oldAlwaysSendCreds ?? newRemoteConfiguration.alwaysSendBasicAuth + newRemoteConfiguration.ignoreSSL = oldIgnoreSSL ?? newRemoteConfiguration.ignoreSSL // Save to Preferences - Preferences.localConnectionConfig = newLocalConfiguration - Preferences.remoteConnectionConfig = newRemoteConfiguration - didMigrateToConnectionConfig = true + modifyActiveHome { currentHomePreferences in + currentHomePreferences.defaultView = Preferences.sharedDefaults.string(forKey: "defaultView") ?? currentHomePreferences.defaultView + currentHomePreferences.demomode = Preferences.sharedDefaults.object(forKey: "demomode") as? Bool ?? currentHomePreferences.demomode + currentHomePreferences.realTimeSliders = Preferences.sharedDefaults.object(forKey: "realTimeSliders") as? Bool ?? currentHomePreferences.realTimeSliders + currentHomePreferences.iconType = Preferences.sharedDefaults.object(forKey: "iconType") as? Int ?? currentHomePreferences.iconType + currentHomePreferences.defaultSitemap = Preferences.sharedDefaults.string(forKey: "defaultSitemap") ?? currentHomePreferences.defaultSitemap + currentHomePreferences.sortSitemapsBy = Preferences.sharedDefaults.object(forKey: "sortSitemapsBy") as? Int ?? currentHomePreferences.sortSitemapsBy + currentHomePreferences.defaultMainUIPath = Preferences.sharedDefaults.string(forKey: "defaultMainUIPath") ?? currentHomePreferences.defaultMainUIPath + currentHomePreferences.alwaysAllowWebRTC = Preferences.sharedDefaults.object(forKey: "alwaysAllowWebRTC") as? Bool ?? currentHomePreferences.alwaysAllowWebRTC + currentHomePreferences.sitemapForWatch = Preferences.sharedDefaults.string(forKey: "sitemapForWatch") ?? currentHomePreferences.sitemapForWatch + currentHomePreferences.localConnectionConfig = newLocalConfiguration + currentHomePreferences.remoteConnectionConfig = newRemoteConfiguration + currentHomePreferences.sitemapForWatchLabel = Preferences.sharedDefaults.string(forKey: "sitemapForWatchLabel") ?? currentHomePreferences.sitemapForWatchLabel + } + + didMigrateToMultipleHomes = true } } // MARK: All connections public extension Preferences { - static func getLowestPriorityOpenHABConnection(of stored: [String: Any]) -> ConnectionConfiguration? { - let localConfig = stored["localConnectionConfig"] as? Data ?? Data() - let localConnection = try? JSONDecoder().decode(ConnectionConfiguration.self, from: localConfig) - let remoteConfig = stored["remoteConnectionConfig"] as? Data ?? Data() - let remoteConnection = try? JSONDecoder().decode(ConnectionConfiguration.self, from: remoteConfig) - return Preferences.getLowestPriorityOpenHABConnection(of: [localConnection, remoteConnection]) + static func getNotificationConnection() -> ConnectionConfiguration? { + getNotificationConnection(of: [Preferences.currentHomePreferences.remoteConnectionConfig]) } - static func getLowestPriorityOpenHABConnection(of connections: [ConnectionConfiguration?]) -> ConnectionConfiguration? { + static func getNotificationConnection(of homeConfig: HomePreferences) -> ConnectionConfiguration? { + getNotificationConnection(of: [homeConfig.remoteConnectionConfig]) + } + + // this will support mutliple connection configs, right now we just pass in the remote config + static func getNotificationConnection(of connections: [ConnectionConfiguration?]) -> ConnectionConfiguration? { connections .compactMap { $0 } - .filter { $0.url.contains("openhab.org") } - .sorted { $0.priority < $1.priority } + .filter { $0.supportsNotifications == true } + .sorted { $0.priority > $1.priority } .first } - - static func getLowestPriorityOpenHABConnection() -> ConnectionConfiguration? { - getLowestPriorityOpenHABConnection(of: [localConnectionConfig, remoteConnectionConfig]) - } } // MARK: - Sample Codable Model public extension ConnectionConfiguration { static let localDefault = ConnectionConfiguration( - url: "http://192.168.1.1:8080", + url: "https://openhab.local:8443", username: "", password: "", alwaysSendBasicAuth: false, ignoreSSL: false, + supportsNotifications: false, priority: 0 ) @@ -441,6 +427,7 @@ public extension ConnectionConfiguration { password: "", alwaysSendBasicAuth: false, ignoreSSL: false, + supportsNotifications: true, priority: 1 ) } diff --git a/OpenHABCore/Tests/OpenHABCoreTests/UserDefaultsTests.swift b/OpenHABCore/Tests/OpenHABCoreTests/UserDefaultsTests.swift index ec858a12c..2eba8f775 100644 --- a/OpenHABCore/Tests/OpenHABCoreTests/UserDefaultsTests.swift +++ b/OpenHABCore/Tests/OpenHABCoreTests/UserDefaultsTests.swift @@ -28,26 +28,43 @@ final class UserDefaultsTests: XCTestCase { } data.removePersistentDomain(forName: defaultsName) - Preferences.username = "testuser" - Preferences.localUrl = "http://local.test" - Preferences.remoteUrl = "http://remote.test" - Preferences.password = "secret" - Preferences.ignoreSSL = true - Preferences.demomode = true + let random: String = UUID().uuidString + + var home = Preferences.currentHomePreferences + home.remoteConnectionConfig.username = "testuser\(random)" + home.localConnectionConfig.url = "http://local\(random).test" + home.remoteConnectionConfig.url = "http://remote\(random).test" + home.remoteConnectionConfig.password = "secret\(random)" + home.remoteConnectionConfig.ignoreSSL = true + home.demomode = true + home.iconType = 2 + home.defaultSitemap = "default\(random)" + home.sitemapForWatch = "watchmap\(random)" + + Preferences.modifyActiveHome { preferences in + preferences.remoteConnectionConfig.username = "testuser\(random)" + preferences.localConnectionConfig.url = "http://local\(random).test" + preferences.remoteConnectionConfig.url = "http://remote\(random).test" + preferences.remoteConnectionConfig.password = "secret\(random)" + preferences.remoteConnectionConfig.ignoreSSL = true + preferences.demomode = true + preferences.iconType = 2 + preferences.defaultSitemap = "default\(random)" + preferences.sitemapForWatch = "watchmap\(random)" + } + Preferences.idleOff = false - Preferences.iconType = 2 - Preferences.defaultSitemap = "default" - Preferences.sitemapForWatch = "watchmap" - - XCTAssertEqual(Preferences.username, data.string(forKey: "username")) - XCTAssertEqual(Preferences.localUrl, data.string(forKey: "localUrl")) - XCTAssertEqual(Preferences.remoteUrl, data.string(forKey: "remoteUrl")) - XCTAssertEqual(Preferences.password, data.string(forKey: "password")) - XCTAssertEqual(Preferences.ignoreSSL, data.bool(forKey: "ignoreSSL")) - XCTAssertEqual(Preferences.demomode, data.bool(forKey: "demomode")) + + XCTAssertEqual(Preferences.currentHomePreferences.remoteConnectionConfig.username, home.remoteConnectionConfig.username) + XCTAssertEqual(Preferences.currentHomePreferences.localConnectionConfig.url, home.localConnectionConfig.url) + XCTAssertEqual(Preferences.currentHomePreferences.remoteConnectionConfig.url, home.remoteConnectionConfig.url) + XCTAssertEqual(Preferences.currentHomePreferences.remoteConnectionConfig.password, home.remoteConnectionConfig.password) + XCTAssertEqual(Preferences.currentHomePreferences.remoteConnectionConfig.ignoreSSL, home.remoteConnectionConfig.ignoreSSL) + XCTAssertEqual(Preferences.currentHomePreferences.demomode, home.demomode) XCTAssertEqual(Preferences.idleOff, data.bool(forKey: "idleOff")) - XCTAssertEqual(Preferences.iconType, data.integer(forKey: "iconType")) - XCTAssertEqual(Preferences.defaultSitemap, data.string(forKey: "defaultSitemap")) - XCTAssertEqual(Preferences.sitemapForWatch, data.string(forKey: "sitemapForWatch")) + XCTAssertEqual(Preferences.currentHomePreferences.iconType, home.iconType) + XCTAssertEqual(Preferences.currentHomePreferences.defaultSitemap, home.defaultSitemap) + XCTAssertEqual(Preferences.currentHomePreferences.sitemapForWatch, home.sitemapForWatch) + XCTAssertEqual(home, try? JSONDecoder().decode(HomePreferences.self, from: data.data(forKey: "currentHomePreferences")!)) } } diff --git a/openHAB.xcodeproj/xcshareddata/xcschemes/NotificationService.xcscheme b/openHAB.xcodeproj/xcshareddata/xcschemes/NotificationService.xcscheme new file mode 100644 index 000000000..1adbba228 --- /dev/null +++ b/openHAB.xcodeproj/xcshareddata/xcschemes/NotificationService.xcscheme @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/openHAB/AppDelegate.swift b/openHAB/AppDelegate.swift index e1a2c6b01..3989e1282 100644 --- a/openHAB/AppDelegate.swift +++ b/openHAB/AppDelegate.swift @@ -84,9 +84,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { let appDefaults = ["CacheDataAgressively": NSNumber(value: true)] UserDefaults.standard.register(defaults: appDefaults) - Preferences.initializeStoredPreferences() - Preferences.migrateUserDefaultsIfRequired() - Preferences.migrateUserDefaultsToConnectionIfRequired() + Preferences.migratePreferences() registerForPushNotifications() @@ -233,7 +231,8 @@ extension AppDelegate: UNUserNotificationCenterDelegate { let message = userInfo["message"] as? String ?? NSLocalizedString("message_not_decoded", comment: "") let action = userInfo["actionIdentifier"] as? String ?? userInfo["on-click"] as? String - await displayNotification(message: message, action: action) + let cloudUserId = userInfo["userId"] as? String + await displayNotification(message: message, action: action, cloudUserId: cloudUserId) return [] // Modify this if you want to show banners, alerts, etc. } @@ -242,6 +241,7 @@ extension AppDelegate: UNUserNotificationCenterDelegate { func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async { var userInfo = response.notification.request.content.userInfo let actionIdentifier = response.actionIdentifier + logger.info("Notification clicked: action \(actionIdentifier) userInfo \(userInfo)") if actionIdentifier != UNNotificationDismissActionIdentifier { @@ -249,11 +249,13 @@ extension AppDelegate: UNUserNotificationCenterDelegate { userInfo["actionIdentifier"] = actionIdentifier } let action = userInfo["actionIdentifier"] as? String ?? userInfo["on-click"] as? String - notifyNotificationListeners(action: action) + let cloudUserId = userInfo["userId"] as? String + + notifyNotificationListeners(action: action, cloudUserId: cloudUserId) } } - private func displayNotification(message: String, action: String?) async { + private func displayNotification(message: String, action: String?, cloudUserId: String?) async { logger.info("displayNotification \(message)") Task { @@ -289,7 +291,7 @@ extension AppDelegate: UNUserNotificationCenterDelegate { // Use closure-based tap gesture insteae of #selector let tapGesture = MessageTapGestureRecognizer { Task { - self.messageViewTapped(action: action) + self.messageViewTapped(action: action, cloudUserId: cloudUserId) } } view.addGestureRecognizer(tapGesture) @@ -300,17 +302,17 @@ extension AppDelegate: UNUserNotificationCenterDelegate { } // Action to be performed when the notification message view is tapped - func messageViewTapped(action: String?) { - notifyNotificationListeners(action: action) + func messageViewTapped(action: String?, cloudUserId: String? = nil) { + notifyNotificationListeners(action: action, cloudUserId: cloudUserId) SwiftMessages.hideAll() } // ✅ Ensure this runs on the MainActor @MainActor - private func notifyNotificationListeners(action: String?) { + private func notifyNotificationListeners(action: String?, cloudUserId: String? = nil) { if let navigationController = window?.rootViewController as? UINavigationController, let rootViewController = navigationController.viewControllers.first as? OpenHABRootViewController { - rootViewController.handleNotification(action: action) + rootViewController.handleNotification(action: action, cloudUserId: cloudUserId) } } } diff --git a/openHAB/DrawerView.swift b/openHAB/DrawerView.swift index 6e0885e19..b528e0764 100644 --- a/openHAB/DrawerView.swift +++ b/openHAB/DrawerView.swift @@ -138,14 +138,16 @@ struct DrawerView: View { dismiss: dismiss ) .onTapGesture(count: 2) { - if sitemap.name == sitemapForWatch { - sitemapForWatch = nil - Preferences.sitemapForWatch = "" - Preferences.sitemapForWatchLabel = "" - } else { - sitemapForWatch = sitemap.name - Preferences.sitemapForWatch = sitemap.name - Preferences.sitemapForWatchLabel = sitemap.label + Preferences.modifyActiveHome { homePreferences in + if sitemap.name == sitemapForWatch { + sitemapForWatch = nil + homePreferences.sitemapForWatch = "" + homePreferences.sitemapForWatchLabel = "" + } else { + sitemapForWatch = sitemap.name + homePreferences.sitemapForWatch = sitemap.name + homePreferences.sitemapForWatchLabel = sitemap.label + } } } } @@ -198,11 +200,11 @@ struct DrawerView: View { Section(header: Text("System")) { settingsMenuEntry(image: .gear, text: "settings", goTo: .settings) - if Preferences.getLowestPriorityOpenHABConnection() != nil, !Preferences.demomode { + if Preferences.getNotificationConnection() != nil, !Preferences.currentHomePreferences.demomode { settingsMenuEntry(image: .bell, text: "notifications", goTo: .notifications) } - settingsMenuEntry(image: .house, text: "homeSelection", goTo: .homeSelection) + settingsMenuEntry(image: .house, text: "Manage Homes", goTo: .homeSelection) } } @@ -252,7 +254,7 @@ struct DrawerView: View { .task { let activeConnection = networkTracker.activeConnection await updateSitemapsAndUITiles(activeConnection: activeConnection) - sitemapForWatch = Preferences.sitemapForWatch + sitemapForWatch = Preferences.currentHomePreferences.sitemapForWatch } .onReceive(networkTracker.$activeConnection) { activeConnection in Task { @@ -287,7 +289,7 @@ struct DrawerView: View { sitemaps = Array(sitemaps.dropLast()) } - switch SortSitemapsOrder(rawValue: Preferences.sortSitemapsBy) ?? .label { + switch SortSitemapsOrder(rawValue: Preferences.currentHomePreferences.sortSitemapsBy) ?? .label { case .label: sitemaps.sort { $0.label < $1.label } case .name: diff --git a/openHAB/HomeSelectionView.swift b/openHAB/HomeSelectionView.swift index 839178ca2..092b68730 100644 --- a/openHAB/HomeSelectionView.swift +++ b/openHAB/HomeSelectionView.swift @@ -39,7 +39,7 @@ struct HomeSelectionView: View { var body: some View { List(homes, id: \.self) { home in - let homeName = Preferences.storedPreferences[home.uuidString]?["homeName"] as? String ?? "" + let homeName = Preferences.storedHomes[home]?.homeName ?? "" HStack { HStack { if showEditOptions { @@ -47,7 +47,7 @@ struct HomeSelectionView: View { .foregroundStyle(.blue) } Text(homeName) - if Preferences.currentlyUsedSettings == home.uuidString, !showEditOptions { + if Preferences.currentHomePreferences.id == home, !showEditOptions { Spacer() Image(systemSymbol: .checkmark) .foregroundColor(.blue) @@ -69,7 +69,7 @@ struct HomeSelectionView: View { if showEditOptions { HStack { Spacer() - if Preferences.currentlyUsedSettings != home.uuidString { + if Preferences.currentHomePreferences.id != home { Button(action: { homeNameForAlert = homeName homeForAlert = home @@ -133,7 +133,7 @@ struct HomeSelectionView: View { } } .onAppear(perform: loadHomesList) - .navigationBarTitle("homeSelection") + .navigationBarTitle("Manage Homes") .toolbar { if showEditOptions { ToolbarItemGroup(placement: .primaryAction) { @@ -174,11 +174,11 @@ struct HomeSelectionView: View { } private func select(home: UUID) { - Preferences.switchCurrentlyUsedSettings(to: home) + Preferences.switchActiveHome(to: home) } private func loadHomesList() { - homes = Preferences.listStoredPreferences() + homes = Preferences.listStoredHomes() } private func delete(home toDelete: UUID?) { @@ -186,7 +186,7 @@ struct HomeSelectionView: View { return } os_log("delete home settings for %@", toDelete.uuidString) - Preferences.deleteStoredSettings(toDelete) + Preferences.deleteStoredHome(toDelete) loadHomesList() } @@ -196,11 +196,7 @@ struct HomeSelectionView: View { } let newName = newHomeName os_log("rename home %@ to %@", toRename.uuidString, newName) - if toRename == Preferences.getCurrentlyUsedSettings() { - Preferences.homeName = newName - } else { - Preferences.renameHome(toRename, newHomeName: newName) - } + Preferences.renameHome(toRename, newHomeName: newName) } private func addHome() { diff --git a/openHAB/NewImageUITableViewCell.swift b/openHAB/NewImageUITableViewCell.swift index f87a1b3b7..1411354b9 100644 --- a/openHAB/NewImageUITableViewCell.swift +++ b/openHAB/NewImageUITableViewCell.swift @@ -162,8 +162,8 @@ class NewImageUITableViewCell: GenericUITableViewCell, NoIconDisplayableCell { activeTask = Task { do { - guard let config = Preferences.getLowestPriorityOpenHABConnection() else { - logger.warning("No openHAB configuration found.") + guard let config = NetworkTracker.shared.activeConnection?.configuration else { + logger.warning("No openHAB connection found.") throw HTTPClientError.noConfiguration } let client = HTTPClient(configuration: config) diff --git a/openHAB/NotificationsView.swift b/openHAB/NotificationsView.swift index 2821d08c3..6026faf3b 100644 --- a/openHAB/NotificationsView.swift +++ b/openHAB/NotificationsView.swift @@ -133,7 +133,7 @@ extension NotificationsView where Tracker == NetworkTracker { let logger = Logger(subsystem: "org.openhab.app", category: "NotificationView") do { - guard let config = Preferences.getLowestPriorityOpenHABConnection() else { + guard let config = Preferences.getNotificationConnection() else { logger.warning("No openHAB configuration found.") return [] } diff --git a/openHAB/OpenHABRootViewController.swift b/openHAB/OpenHABRootViewController.swift index c55e1b8a6..cd60fa472 100644 --- a/openHAB/OpenHABRootViewController.swift +++ b/openHAB/OpenHABRootViewController.swift @@ -91,13 +91,15 @@ class OpenHABRootViewController: UIViewController { #if DEBUG if ProcessInfo.processInfo.environment["UITest"] != nil { // this is here to continue to make existing tests work, need to look at this later - Preferences.demomode = true + Preferences.modifyActiveHome(modificationFunction: { homePreferences in + homePreferences.demomode = true + }) } // setup accessibilityIdentifiers for UITest navigationItem.rightBarButtonItem?.accessibilityIdentifier = "HamburgerButton" #endif // save this so we know if its changed later - isDemoMode = Preferences.demomode + isDemoMode = Preferences.currentHomePreferences.demomode switchToSavedView() setupTracker() } @@ -107,18 +109,14 @@ class OpenHABRootViewController: UIViewController { super.viewWillAppear(animated) navigationController?.navigationBar.prefersLargeTitles = true // if we have turned demo mode off/on, reset view - if isDemoMode != Preferences.demomode { + if isDemoMode != Preferences.currentHomePreferences.demomode { switchToSavedView() - isDemoMode = Preferences.demomode + isDemoMode = Preferences.currentHomePreferences.demomode } } fileprivate func setupTracker() { - let serverInfo = Publishers.CombineLatest( - Preferences.$localConnectionConfig, - Preferences.$remoteConnectionConfig - ) - .eraseToAnyPublisher() + let serverInfo = Preferences.$currentHomePreferences // Register for certificate trust notifications NotificationCenter.default.addObserver( @@ -183,11 +181,11 @@ class OpenHABRootViewController: UIViewController { } } - Publishers.CombineLatest(serverInfo, Preferences.$demomode) - .debounce(for: .milliseconds(500), scheduler: RunLoop.main) // ensures if multiple values are saved, we get called once - .sink { (serverInfoTuple, miscTuple) in - let (localConnectionConfig, remoteConnectionConfig) = serverInfoTuple - let (demomode) = miscTuple + serverInfo.debounce(for: .milliseconds(500), scheduler: RunLoop.main) // ensures if multiple values are saved, we get called once + .sink { homeSettings in + let localConnectionConfig = homeSettings.localConnectionConfig + let remoteConnectionConfig = homeSettings.remoteConnectionConfig + let demomode = homeSettings.demomode Task { if demomode { @@ -298,7 +296,9 @@ class OpenHABRootViewController: UIViewController { self.modalDismissed(to: .settings) } case let .sitemap(sitemap): - Preferences.defaultSitemap = sitemap + Preferences.modifyActiveHome { homePreferences in + homePreferences.defaultSitemap = sitemap + } SideMenuManager.default.rightMenuNavigationController?.dismiss(animated: true) { self.modalDismissed(to: .sitemap(sitemap)) } @@ -336,16 +336,16 @@ class OpenHABRootViewController: UIViewController { private func subscribeToOpenhabConnectionChanges() { struct UuidWithConnection: Hashable, Equatable { - let uuid: String + let uuid: UUID let connection: ConnectionConfiguration // not only URL, because auth and certs might be relevant for establishing the connection } - let storedOpenHabConnections = Preferences.$storedPreferences + let storedOpenHabConnections = Preferences.$storedHomes .debounce(for: .seconds(1), scheduler: RunLoop.main) // avoid overexcited registrations / deregistrations in batch updates - .map { storedPrefsUpdate in // we want to recognize changes in the OpenHab URLs for any of the homes - Set(storedPrefsUpdate.compactMap { storedWithUuid in + .map { updatedPreferences in // we want to recognize changes in the OpenHab URLs for any of the homes + Set(updatedPreferences.compactMap { storedWithUuid in let (uuid, homeConfig) = storedWithUuid - guard let connection = Preferences.getLowestPriorityOpenHABConnection(of: homeConfig) else { return nil } + guard let connection = Preferences.getNotificationConnection(of: homeConfig) else { return nil } return UuidWithConnection(uuid: uuid, connection: connection) }) } @@ -361,7 +361,9 @@ class OpenHABRootViewController: UIViewController { } let openhabConnectionSubscription = differences.sink { [weak self] diff in + logger.info("openhabConnectionSubscription updated") for newHome in diff.newValues { + logger.info("openhabConnectionSubscription uuid \(newHome.uuid) registering for push notifications ") self?.registerHome(uuid: newHome.uuid, connection: newHome.connection) } for deletedHome in diff.deletedValues { @@ -373,7 +375,7 @@ class OpenHABRootViewController: UIViewController { cancellables.insert(openhabConnectionSubscription) } - private func registerHome(uuid: String, connection: ConnectionConfiguration) { + private func registerHome(uuid: UUID, connection: ConnectionConfiguration) { guard let apsRegistrationData else { logger.fault("Cannot register homes for push notifications, no notification registration data available") return @@ -384,22 +386,45 @@ class OpenHABRootViewController: UIViewController { return } logger.info("Registering notifications with \(connection.url)") - _ = registerHome(connection, deviceToken, deviceId, deviceName) + _ = registerHome(uuid, connection, deviceToken, deviceId, deviceName) } - private func registerHome(_ config: ConnectionConfiguration, _ deviceToken: String, _ deviceId: String, _ deviceName: String) -> Task { + private func registerHome(_ uuid: UUID, _ config: ConnectionConfiguration, _ deviceToken: String, _ deviceId: String, _ deviceName: String) -> Task { Task { do { let client = HTTPClient(configuration: config) - try await client.register(prefsURL: config.url, deviceToken: deviceToken, deviceId: deviceId, deviceName: deviceName) - logger.info("my.openHAB registration succeeded") + if let cloudUserId = try await client.register(prefsURL: config.url, deviceToken: deviceToken, deviceId: deviceId, deviceName: deviceName) { + Preferences.setCloudUserId(cloudUserId, for: uuid) + logger.info("my.openHAB registration succeeded with cloudUserId \(cloudUserId)") + } + logger.info("my.openHAB registration succeeded without cloudUserId") } catch { logger.error("my.openHAB registration failed \(error.localizedDescription)") } } } - func handleNotification(action: String?) { + func handleNotification(action: String?, cloudUserId: String?) { + guard let action else { return } + + logger.info("handleNotification cloudUserId: \(cloudUserId ?? "")") + if let cloudUserId, let targetHome = Preferences.storedHome(forCloudUserId: cloudUserId), Preferences.currentHomePreferences.remoteConnectionConfig.cloudUserId != cloudUserId { + // if we need to switch homes, disconnnect the tracking fist,and wait for the tracker to start again with the updated preferences + Task { + await NetworkTracker.shared.stopTracking() + logger.info("Switching to home \(targetHome.id)") + Preferences.switchActiveHome(to: targetHome.id) + await NetworkTracker.shared.waitForActiveConnection() + handleNotificationInternal(action) + } + return + } + handleNotificationInternal(action) + } + + private func handleNotificationInternal(_ action: String?) { + logger.info("handleNotificationInternal: \(action ?? "")") + guard let action else { return } let cmd = action.split(separator: ":").dropFirst().joined(separator: ":") @@ -610,8 +635,10 @@ class OpenHABRootViewController: UIViewController { currentView = targetView // Don't save our view in demo mode - if !Preferences.demomode { - Preferences.defaultView = currentView.viewName() + if !Preferences.currentHomePreferences.demomode { + Preferences.modifyActiveHome { + $0.defaultView = currentView.viewName() + } } } else { // if we hit the menu item again while on the view, trigger a reload @@ -623,11 +650,11 @@ class OpenHABRootViewController: UIViewController { } private func switchToSavedView() { - if Preferences.demomode { + if Preferences.currentHomePreferences.demomode { switchView(target: .sitemap("")) } else { - os_log("OpenHABRootViewController switchToSavedView %@", log: .viewCycle, type: .info, Preferences.defaultView == "sitemap" ? "sitemap" : "web") - switchView(target: Preferences.defaultView == "sitemap" ? .sitemap("") : .webview) + os_log("OpenHABRootViewController switchToSavedView %@", log: .viewCycle, type: .info, Preferences.currentHomePreferences.defaultView == "sitemap" ? "sitemap" : "web") + switchView(target: Preferences.currentHomePreferences.defaultView == "sitemap" ? .sitemap("") : .webview) } } diff --git a/openHAB/OpenHABSitemapViewController.swift b/openHAB/OpenHABSitemapViewController.swift index 98f745236..ee49fd988 100644 --- a/openHAB/OpenHABSitemapViewController.swift +++ b/openHAB/OpenHABSitemapViewController.swift @@ -268,7 +268,7 @@ class OpenHABSitemapViewController: OpenHABViewController, UISearchControllerDel } override func reloadView() { - defaultSitemap = Preferences.defaultSitemap + defaultSitemap = Preferences.currentHomePreferences.defaultSitemap logger.debug("Reload view") selectSitemap() } @@ -555,9 +555,9 @@ extension OpenHABSitemapViewController { // load settings into local properties func loadSettings() { - defaultSitemap = Preferences.defaultSitemap + defaultSitemap = Preferences.currentHomePreferences.defaultSitemap idleOff = Preferences.idleOff - iconType = IconType(rawValue: Preferences.iconType) ?? .png + iconType = IconType(rawValue: Preferences.currentHomePreferences.iconType) ?? .png #if DEBUG // always use demo sitemap for UITest if ProcessInfo.processInfo.environment["UITest"] != nil { diff --git a/openHAB/OpenHABWebViewController.swift b/openHAB/OpenHABWebViewController.swift index 55d69606f..9b759ce8f 100644 --- a/openHAB/OpenHABWebViewController.swift +++ b/openHAB/OpenHABWebViewController.swift @@ -146,8 +146,8 @@ class OpenHABWebViewController: OpenHABViewController { if let path { url = appendPathToURL(baseURL: url, path: path) ?? url - } else if !Preferences.defaultMainUIPath.isEmpty { - url = appendPathToURL(baseURL: url, path: Preferences.defaultMainUIPath) ?? url + } else if !Preferences.currentHomePreferences.defaultMainUIPath.isEmpty { + url = appendPathToURL(baseURL: url, path: Preferences.currentHomePreferences.defaultMainUIPath) ?? url } return url } @@ -407,6 +407,6 @@ extension OpenHABWebViewController: WKUIDelegate { decideMediaCapturePermissionsFor origin: WKSecurityOrigin, initiatedBy frame: WKFrameInfo, type: WKMediaCaptureType) async -> WKPermissionDecision { - Preferences.alwaysAllowWebRTC ? .grant : .prompt + Preferences.currentHomePreferences.alwaysAllowWebRTC ? .grant : .prompt } } diff --git a/openHAB/SettingsView/ConnectionSettingsView.swift b/openHAB/SettingsView/ConnectionSettingsView.swift index dbe92651f..f6096e5e3 100644 --- a/openHAB/SettingsView/ConnectionSettingsView.swift +++ b/openHAB/SettingsView/ConnectionSettingsView.swift @@ -22,8 +22,8 @@ struct ConnectionSettingsView: View { Toggle("Demo Mode", isOn: $settingsDemomode) if !settingsDemomode { - SingleConnectionSettingsView(headerText: "Local server", connectionConfig: $localConnectionConfiguration) - SingleConnectionSettingsView(headerText: "Remote server", connectionConfig: $remoteConnectionConfiguration) + SingleConnectionSettingsView(headerText: "Local server", connectionConfig: $localConnectionConfiguration, showNotificationToggle: false) + SingleConnectionSettingsView(headerText: "Remote server", connectionConfig: $remoteConnectionConfiguration, showNotificationToggle: true) } } } @@ -32,14 +32,14 @@ struct ConnectionSettingsView: View { #Preview { struct PreviewWrapper: View { @State var demoMode = false - @State var localUrl = "http://192.168.1.100" + @State var localUrl = "https://openhab.local:8443" @State var remoteUrl = "https://myopenhab.org" @State var username = "user" @State var password = "password123" @State var alwaysSendCreds = true @State var connectionConfig1 = ConnectionConfiguration( - url: "http://192.168.2.1", + url: "https://openhab.local:8443", username: "user", password: "password123" ) diff --git a/openHAB/SettingsView/SettingsView.swift b/openHAB/SettingsView/SettingsView.swift index a944d9c3e..71a9170fc 100644 --- a/openHAB/SettingsView/SettingsView.swift +++ b/openHAB/SettingsView/SettingsView.swift @@ -16,13 +16,7 @@ import SwiftUI struct SettingsView: View { @State var settingsDemomode = false - @State var settingsLocalUrl = "" - @State var settingsRemoteUrl = "" - @State var settingsUsername = "" - @State var settingsPassword = "" - @State var settingsAlwaysSendCreds = true @State var settingsIdleOff = true - @State var settingsIgnoreSSL = true @State var settingsRealTimeSliders = true @State var settingsSendCrashReports = false @State var settingsIconType: IconType = .svg @@ -34,7 +28,7 @@ struct SettingsView: View { @State var sitemaps: [OpenHABSitemap] = [] @State var settingsLocalConnectionConfiguration = ConnectionConfiguration(url: "", username: "", password: "") @State var settingsRemoteConnectionConfiguration = ConnectionConfiguration(url: "", username: "", password: "") - + @State var settingsHomeName = "" @State var viewAppearedOnce: Bool = false @Environment(\.dismiss) private var dismiss @@ -74,7 +68,7 @@ struct SettingsView: View { } .formStyle(.grouped) .navigationBarBackButtonHidden(true) - .navigationBarTitle("Settings") + .navigationBarTitle("\(settingsHomeName) Settings") .toolbar { ToolbarItemGroup(placement: .primaryAction) { Button("Save") { @@ -109,7 +103,7 @@ struct SettingsView: View { } // Sort the sitemaps according to Settings selection. - switch SortSitemapsOrder(rawValue: Preferences.sortSitemapsBy) ?? .label { + switch SortSitemapsOrder(rawValue: Preferences.currentHomePreferences.sortSitemapsBy) ?? .label { case .label: sitemaps.sort { $0.label < $1.label } case .name: sitemaps.sort { $0.name < $1.name } } @@ -123,44 +117,34 @@ struct SettingsView: View { #if !DEBUG logger.debug("Loading Settings") #endif - settingsLocalUrl = Preferences.localUrl - settingsRemoteUrl = Preferences.remoteUrl - settingsUsername = Preferences.username - settingsPassword = Preferences.password - settingsAlwaysSendCreds = Preferences.alwaysSendCreds - settingsIgnoreSSL = Preferences.ignoreSSL - settingsDemomode = Preferences.demomode + settingsDemomode = Preferences.currentHomePreferences.demomode settingsIdleOff = Preferences.idleOff - settingsRealTimeSliders = Preferences.realTimeSliders + settingsRealTimeSliders = Preferences.currentHomePreferences.realTimeSliders settingsSendCrashReports = Preferences.sendCrashReports - settingsIconType = IconType(rawValue: Preferences.iconType) ?? .png - settingsSortSitemapsBy = SortSitemapsOrder(rawValue: Preferences.sortSitemapsBy) ?? .label - settingsDefaultMainUIPath = Preferences.defaultMainUIPath - settingsAlwaysAllowWebRTC = Preferences.alwaysAllowWebRTC - settingsSitemapForWatch = Preferences.sitemapForWatch - settingsLocalConnectionConfiguration = Preferences.localConnectionConfig - settingsRemoteConnectionConfiguration = Preferences.remoteConnectionConfig + settingsIconType = IconType(rawValue: Preferences.currentHomePreferences.iconType) ?? .png + settingsSortSitemapsBy = SortSitemapsOrder(rawValue: Preferences.currentHomePreferences.sortSitemapsBy) ?? .label + settingsDefaultMainUIPath = Preferences.currentHomePreferences.defaultMainUIPath + settingsAlwaysAllowWebRTC = Preferences.currentHomePreferences.alwaysAllowWebRTC + settingsSitemapForWatch = Preferences.currentHomePreferences.sitemapForWatch + settingsLocalConnectionConfiguration = Preferences.currentHomePreferences.localConnectionConfig + settingsRemoteConnectionConfiguration = Preferences.currentHomePreferences.remoteConnectionConfig + settingsHomeName = Preferences.currentHomePreferences.homeName } func saveSettings() { - Preferences.localUrl = settingsLocalUrl - Preferences.remoteUrl = settingsRemoteUrl - Preferences.username = settingsUsername - Preferences.password = settingsPassword - Preferences.alwaysSendCreds = settingsAlwaysSendCreds - Preferences.ignoreSSL = settingsIgnoreSSL - Preferences.demomode = settingsDemomode + Preferences.modifyActiveHome { homePreferences in + homePreferences.demomode = settingsDemomode + homePreferences.realTimeSliders = settingsRealTimeSliders + homePreferences.iconType = settingsIconType.rawValue + homePreferences.sortSitemapsBy = settingsSortSitemapsBy.rawValue + homePreferences.alwaysAllowWebRTC = settingsAlwaysAllowWebRTC + homePreferences.sitemapForWatch = settingsSitemapForWatch + homePreferences.sitemapForWatchLabel = sitemaps.first { $0.name == settingsSitemapForWatch }?.label ?? "unknown" + homePreferences.localConnectionConfig = settingsLocalConnectionConfiguration + homePreferences.remoteConnectionConfig = settingsRemoteConnectionConfiguration + } Preferences.idleOff = settingsIdleOff - Preferences.realTimeSliders = settingsRealTimeSliders - Preferences.iconType = settingsIconType.rawValue Preferences.sendCrashReports = settingsSendCrashReports - Preferences.sortSitemapsBy = settingsSortSitemapsBy.rawValue - Preferences.defaultMainUIPath = settingsDefaultMainUIPath - Preferences.alwaysAllowWebRTC = settingsAlwaysAllowWebRTC - Preferences.sitemapForWatch = settingsSitemapForWatch - Preferences.sitemapForWatchLabel = sitemaps.first { $0.name == settingsSitemapForWatch }?.label ?? "unknown" - Preferences.localConnectionConfig = settingsLocalConnectionConfiguration - Preferences.remoteConnectionConfig = settingsRemoteConnectionConfiguration } } @@ -176,13 +160,7 @@ extension UIApplication { #Preview { struct PreviewWrapper: View { @State var settingsDemomode = false - @State var settingsLocalUrl = "http://192.168.1.100" - @State var settingsRemoteUrl = "https://myopenhab.org" - @State var settingsUsername = "user" - @State var settingsPassword = "password123" - @State var settingsAlwaysSendCreds = true @State var settingsIdleOff = true - @State var settingsIgnoreSSL = true @State var settingsRealTimeSliders = true @State var settingsSendCrashReports = false @State var settingsIconType: IconType = .png @@ -221,13 +199,7 @@ extension UIApplication { NavigationView { SettingsView( settingsDemomode: settingsDemomode, - settingsLocalUrl: settingsLocalUrl, - settingsRemoteUrl: settingsRemoteUrl, - settingsUsername: settingsUsername, - settingsPassword: settingsPassword, - settingsAlwaysSendCreds: settingsAlwaysSendCreds, settingsIdleOff: settingsIdleOff, - settingsIgnoreSSL: settingsIgnoreSSL, settingsRealTimeSliders: settingsRealTimeSliders, settingsSendCrashReports: settingsSendCrashReports, settingsIconType: settingsIconType, diff --git a/openHAB/SettingsView/SingleConnectionSettingsView.swift b/openHAB/SettingsView/SingleConnectionSettingsView.swift index 97cbb8c0d..abb708a4c 100644 --- a/openHAB/SettingsView/SingleConnectionSettingsView.swift +++ b/openHAB/SettingsView/SingleConnectionSettingsView.swift @@ -34,6 +34,7 @@ struct SpinningSymbol: View { struct SingleConnectionSettingsView: View { var headerText: String @Binding var connectionConfig: ConnectionConfiguration + var showNotificationToggle: Bool @State private var isTestingConnection = false @State private var connectionTestMessage: String? @@ -128,6 +129,12 @@ struct SingleConnectionSettingsView: View { Toggle("Ignore SSL certificates", isOn: $connectionConfig.ignoreSSL) .font(.caption) .opacity(0.8) + + if showNotificationToggle { + Toggle("openHAB Cloud Service", isOn: $connectionConfig.supportsNotifications) + .font(.caption) + .opacity(0.8) + } } } @@ -200,7 +207,7 @@ struct SingleConnectionSettingsView: View { #Preview { struct PreviewWrapper: View { @State var connectionConfig = ConnectionConfiguration( - url: "http://192.168.2.1:8080", + url: "https://openhab.local:8443", username: "user", password: "password123" ) @@ -208,7 +215,7 @@ struct SingleConnectionSettingsView: View { var body: some View { NavigationView { Form { - SingleConnectionSettingsView(headerText: "Connection Settings for local server", connectionConfig: $connectionConfig) + SingleConnectionSettingsView(headerText: "Connection Settings for local server", connectionConfig: $connectionConfig, showNotificationToggle: false) } } } diff --git a/openHAB/VideoUITableViewCell.swift b/openHAB/VideoUITableViewCell.swift index 6810d29d5..e1f734627 100644 --- a/openHAB/VideoUITableViewCell.swift +++ b/openHAB/VideoUITableViewCell.swift @@ -170,7 +170,7 @@ class VideoUITableViewCell: GenericUITableViewCell, NoIconDisplayableCell { activeTask = Task { do { - guard let config = Preferences.getLowestPriorityOpenHABConnection() else { + guard let config = NetworkTracker.shared.activeConnection?.configuration else { logger.warning("No openHAB configuration found.") throw HTTPClientError.noConfiguration } diff --git a/openHAB/WatchMessageService.swift b/openHAB/WatchMessageService.swift index 706258850..c07d3043d 100644 --- a/openHAB/WatchMessageService.swift +++ b/openHAB/WatchMessageService.swift @@ -60,37 +60,21 @@ class WatchMessageService: NSObject, WCSessionDelegate { @MainActor public func subscribeToPreferences() { - let currentlyUsedSettings: AnyPublisher = Preferences.$currentlyUsedSettings - .map { $0 as any Sendable } - .eraseToAnyPublisher() - let watchRelatedSettings: AnyPublisher = currentlyUsedSettings - .merge( - with: - Preferences.$defaultSitemap.map { $0 as any Sendable }.eraseToAnyPublisher(), - Preferences.$sitemapForWatch.map { $0 as any Sendable }.eraseToAnyPublisher(), - Preferences.$sitemapForWatchLabel.map { $0 as any Sendable }.eraseToAnyPublisher(), - Preferences.$iconType.map { $0 as any Sendable }.eraseToAnyPublisher(), - Preferences.$demomode.map { $0 as any Sendable }.eraseToAnyPublisher(), - Preferences.$localConnectionConfig.map { $0 as any Sendable }.eraseToAnyPublisher(), - Preferences.$localConnectionConfig.map { $0 as any Sendable }.eraseToAnyPublisher() - ) - .eraseToAnyPublisher() - - preferencesSubscription = watchRelatedSettings + preferencesSubscription = Preferences.$currentHomePreferences .debounce(for: .seconds(1), scheduler: RunLoop.main) - .sink { _ in } receiveValue: { _ in - self.syncPreferencesToWatch() + .sink { _ in } receiveValue: { homeSettings in + self.syncPreferencesToWatch(homeSettings) } } @MainActor - public func syncPreferencesToWatch() { + public func syncPreferencesToWatch(_ homeSettings: HomePreferences = Preferences.currentHomePreferences) { guard WCSession.default.activationState == .activated else { logger.warning("WCSession not activated; skipping sync.") return } - let prefs = WatchPreferences(fromPreferences: Preferences.self) + let prefs = WatchPreferences(fromPreferences: homeSettings) let context = prefs.encodedWatchPreferences() guard cachedWatchPreferences != context else { @@ -113,7 +97,7 @@ class WatchMessageService: NSObject, WCSessionDelegate { @MainActor extension WatchPreferences { - init(fromPreferences preferences: Preferences.Type) { + init(fromPreferences preferences: HomePreferences) { self.init( localUrl: preferences.localConnectionConfig.url, remoteUrl: preferences.remoteConnectionConfig.url, diff --git a/openHAB/WebUITableViewCell.swift b/openHAB/WebUITableViewCell.swift index 373e59a84..781be6b93 100644 --- a/openHAB/WebUITableViewCell.swift +++ b/openHAB/WebUITableViewCell.swift @@ -50,7 +50,7 @@ class WebUITableViewCell: GenericUITableViewCell, NoIconDisplayableCell { override func displayWidget() { os_log("webview loading url %{PUBLIC}@", log: .default, type: .info, widget.url) - let urlString = widget.url.lowercased().hasPrefix("http") ? widget.url : Preferences.localUrl + widget.url + let urlString = widget.url.lowercased().hasPrefix("http") ? widget.url : Preferences.currentHomePreferences.localConnectionConfig.url + widget.url guard url?.absoluteString != urlString else { os_log("webview URL has not changed, abort loading", log: .viewCycle, type: .info) return From 9b5cee5df9f286c17ffc98ac60f03eb89441b553 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Fri, 27 Jun 2025 21:21:49 +0200 Subject: [PATCH 230/476] Cleansing for redundant types (#884) Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- BuildTools/.swiftlint.yml | 3 +++ .../OpenHABCore/Model/OpenHABWidget.swift | 2 +- .../OpenHABCore/Util/Preferences.swift | 22 +++++++++---------- .../NetworkTrackerTests.swift | 5 ++--- .../ServerCertificateManagerTests.swift | 2 +- openHAB/ColorPickerView.swift | 6 ++--- openHAB/DrawerView.swift | 2 +- openHAB/OpenHABRootViewController.swift | 4 ++-- .../AnimatedSecureTextField.swift | 2 +- openHAB/SettingsView/SettingsView.swift | 2 +- .../SingleConnectionSettingsView.swift | 2 +- openHABWatch/Model/AppSettings.swift | 2 +- .../Model/ObservableOpenHABWidget.swift | 2 +- openHABWatch/Views/Utils/ColorSelection.swift | 2 +- 14 files changed, 30 insertions(+), 28 deletions(-) diff --git a/BuildTools/.swiftlint.yml b/BuildTools/.swiftlint.yml index fe563c1c3..4ae79e895 100644 --- a/BuildTools/.swiftlint.yml +++ b/BuildTools/.swiftlint.yml @@ -108,3 +108,6 @@ file_name: opening_brace: ignore_multiline_function_signatures: true + +redundant_type_annotation: + consider_default_literal_types_redundant: true diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift index eb7c9d641..d5531ef65 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift @@ -60,7 +60,7 @@ public class OpenHABWidget: NSObject, MKAnnotation, Identifiable, ObservableObje case text, number, date, time, datetime, unknown } - public var id: String = "" + public var id = "" public var sendCommand: ((_ item: OpenHABItem, _ command: String?) -> Void)? public var widgetId = "" diff --git a/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift index 432cee500..b8b78849b 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift @@ -84,19 +84,19 @@ public struct UserDefaultObject { public struct HomePreferences: Codable, Sendable, Equatable { public let id: UUID - public var defaultView: String = "web" - public var demomode: Bool = true - public var realTimeSliders: Bool = false - public var iconType: Int = 0 - public var defaultSitemap: String = "demo" - public var sortSitemapsBy: Int = 0 - public var defaultMainUIPath: String = "" - public var alwaysAllowWebRTC: Bool = false - public var sitemapForWatch: String = "watch" + public var defaultView = "web" + public var demomode = true + public var realTimeSliders = false + public var iconType = 0 + public var defaultSitemap = "demo" + public var sortSitemapsBy = 0 + public var defaultMainUIPath = "" + public var alwaysAllowWebRTC = false + public var sitemapForWatch = "watch" public var localConnectionConfig: ConnectionConfiguration = .localDefault public var remoteConnectionConfig: ConnectionConfiguration = .remoteDefault - public var sitemapForWatchLabel: String = "watch" - public var homeName: String = "Home" + public var sitemapForWatchLabel = "watch" + public var homeName = "Home" fileprivate init(id: UUID) { self.id = id diff --git a/OpenHABCore/Tests/OpenHABCoreTests/NetworkTrackerTests.swift b/OpenHABCore/Tests/OpenHABCoreTests/NetworkTrackerTests.swift index 084277f26..7e44d1011 100644 --- a/OpenHABCore/Tests/OpenHABCoreTests/NetworkTrackerTests.swift +++ b/OpenHABCore/Tests/OpenHABCoreTests/NetworkTrackerTests.swift @@ -173,9 +173,8 @@ final class NetworkTrackerTests: XCTestCase { }, receiveOutput: nil, receiveCompletion: nil, - receiveCancel: nil, - receiveRequest: { _ in } - ) + receiveCancel: nil + ) { _ in } // swiftlint:enable trailing_closure .dropFirst() .sink { status in diff --git a/OpenHABCore/Tests/OpenHABCoreTests/ServerCertificateManagerTests.swift b/OpenHABCore/Tests/OpenHABCoreTests/ServerCertificateManagerTests.swift index 278f8c1fe..dfb9b381e 100644 --- a/OpenHABCore/Tests/OpenHABCoreTests/ServerCertificateManagerTests.swift +++ b/OpenHABCore/Tests/OpenHABCoreTests/ServerCertificateManagerTests.swift @@ -28,7 +28,7 @@ func XCTAssertThrowsErrorAsync( } final class MockServerCertificateDelegate: ServerCertificateManagerDelegate { - var lastCall: String = "" + var lastCall = "" var expectedResult: ServerCertificateManager.EvaluateResult = .permitOnce var acceptedChangedCalled = false diff --git a/openHAB/ColorPickerView.swift b/openHAB/ColorPickerView.swift index 9aa47f063..8aae3034e 100644 --- a/openHAB/ColorPickerView.swift +++ b/openHAB/ColorPickerView.swift @@ -16,9 +16,9 @@ import SwiftUI struct ColorPickerView: View { @State private var selectedColor: Color = .white - @State private var hue: Double = 0.0 - @State private var saturation: Double = 0.0 - @State private var brightness: Double = 0.0 + @State private var hue = 0.0 + @State private var saturation = 0.0 + @State private var brightness = 0.0 @ObservedObject var throttler = Throttler(maxInterval: 0.3) diff --git a/openHAB/DrawerView.swift b/openHAB/DrawerView.swift index b528e0764..efd80500b 100644 --- a/openHAB/DrawerView.swift +++ b/openHAB/DrawerView.swift @@ -220,7 +220,7 @@ struct DrawerView: View { @State private var sitemaps: [OpenHABSitemap] = [] @State private var uiTiles: [OpenHABUiTile] = [] @State private var selectedSection: Int? - @State private var connectedUrl: String = "Not connected" // Default label text + @State private var connectedUrl = "Not connected" // Default label text @EnvironmentObject private var networkTracker: NetworkTracker diff --git a/openHAB/OpenHABRootViewController.swift b/openHAB/OpenHABRootViewController.swift index cd60fa472..14f852182 100644 --- a/openHAB/OpenHABRootViewController.swift +++ b/openHAB/OpenHABRootViewController.swift @@ -91,9 +91,9 @@ class OpenHABRootViewController: UIViewController { #if DEBUG if ProcessInfo.processInfo.environment["UITest"] != nil { // this is here to continue to make existing tests work, need to look at this later - Preferences.modifyActiveHome(modificationFunction: { homePreferences in + Preferences.modifyActiveHome { homePreferences in homePreferences.demomode = true - }) + } } // setup accessibilityIdentifiers for UITest navigationItem.rightBarButtonItem?.accessibilityIdentifier = "HamburgerButton" diff --git a/openHAB/SettingsView/AnimatedSecureTextField.swift b/openHAB/SettingsView/AnimatedSecureTextField.swift index f8d4e7869..f12ecc0c3 100644 --- a/openHAB/SettingsView/AnimatedSecureTextField.swift +++ b/openHAB/SettingsView/AnimatedSecureTextField.swift @@ -47,7 +47,7 @@ struct AnimatedSecureTextField: View { #Preview { struct PreviewWrapper: View { - @State private var password: String = "password12" + @State private var password = "password12" @State var isSecure = true var body: some View { diff --git a/openHAB/SettingsView/SettingsView.swift b/openHAB/SettingsView/SettingsView.swift index 71a9170fc..08b7a792c 100644 --- a/openHAB/SettingsView/SettingsView.swift +++ b/openHAB/SettingsView/SettingsView.swift @@ -29,7 +29,7 @@ struct SettingsView: View { @State var settingsLocalConnectionConfiguration = ConnectionConfiguration(url: "", username: "", password: "") @State var settingsRemoteConnectionConfiguration = ConnectionConfiguration(url: "", username: "", password: "") @State var settingsHomeName = "" - @State var viewAppearedOnce: Bool = false + @State var viewAppearedOnce = false @Environment(\.dismiss) private var dismiss diff --git a/openHAB/SettingsView/SingleConnectionSettingsView.swift b/openHAB/SettingsView/SingleConnectionSettingsView.swift index abb708a4c..e800089bc 100644 --- a/openHAB/SettingsView/SingleConnectionSettingsView.swift +++ b/openHAB/SettingsView/SingleConnectionSettingsView.swift @@ -176,7 +176,7 @@ struct SingleConnectionSettingsView: View { isTestingConnection = false } - private func testConnection() async throws { + func testConnection() async throws { try connectionConfig.url.testAsValidOpenHABURL() let connection = try OpenAPIService(connectionConfiguration: connectionConfig, serviceConfiguration: .shortTerm) diff --git a/openHABWatch/Model/AppSettings.swift b/openHABWatch/Model/AppSettings.swift index b8d2a38e1..3e1068560 100644 --- a/openHABWatch/Model/AppSettings.swift +++ b/openHABWatch/Model/AppSettings.swift @@ -16,7 +16,7 @@ import SwiftUI final class AppSettings: ObservableObject { static let shared = AppSettings() - var openHABVersion: Int = 2 + var openHABVersion = 2 var cancellables = Set() @Published var localConnectionConfig: ConnectionConfiguration? diff --git a/openHABWatch/Model/ObservableOpenHABWidget.swift b/openHABWatch/Model/ObservableOpenHABWidget.swift index 4d94dff24..ac7260203 100644 --- a/openHABWatch/Model/ObservableOpenHABWidget.swift +++ b/openHABWatch/Model/ObservableOpenHABWidget.swift @@ -46,7 +46,7 @@ enum InputHint: String, Decodable, CaseIterable { @available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *) class ObservableOpenHABWidget: NSObject, MKAnnotation, Identifiable, ObservableObject { - var id: String = "" + var id = "" var sendCommand: ((_ item: OpenHABItem, _ command: String?) -> Void)? var widgetId = "" diff --git a/openHABWatch/Views/Utils/ColorSelection.swift b/openHABWatch/Views/Utils/ColorSelection.swift index c17a320ce..df0719414 100644 --- a/openHABWatch/Views/Utils/ColorSelection.swift +++ b/openHABWatch/Views/Utils/ColorSelection.swift @@ -52,7 +52,7 @@ enum DragState { struct ColorSelection: View { @GestureState var thumb: DragState = .inactive - @State var hue: Double = 0.5 + @State var hue = 0.5 @State var xpos: Double = 100 @State var ypos: Double = 100 From c60757821409684d2fe3674ebbab563f7b362099 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Sat, 28 Jun 2025 14:29:50 +0200 Subject: [PATCH 231/476] Migrating from os_log to logger with tool and some handwork (#885) * Full migration from os_log to logger * With tool OSLogRewriter and some manual work * Migrating os_signpost to OSSignPoster API Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .gitignore | 3 + BuildTools/.swiftlint.yml | 1 + NotificationService/NotificationService.swift | 21 +- .../OpenHABCore/Model/OpenHABItem.swift | 4 +- .../OpenHABCore/Model/OpenHABPage.swift | 5 +- .../OpenHABCore/Model/OpenHABWidget.swift | 8 +- .../Util/ClientCertificateManager.swift | 12 +- .../Sources/OpenHABCore/Util/HTTPClient.swift | 20 +- .../OpenHABCore/Util/NetworkTracker.swift | 2 +- .../Util/OpenHABImageProcessor.swift | 3 +- .../OpenHABCore/Util/Preferences.swift | 34 +- .../OpenHABCore/Util/StringExtension.swift | 4 +- .../OpenHABCoreTests/JSONParserTests.swift | 77 +- OsLogRewriter/AGENT.md | 26 + OsLogRewriter/HTTPClient.swift | 276 ++++++ OsLogRewriter/OpenHABRootViewController.swift | 756 ++++++++++++++ .../OpenHABSitemapViewController.swift | 927 ++++++++++++++++++ OsLogRewriter/Package.resolved | 15 + OsLogRewriter/Package.swift | 33 + OsLogRewriter/Preferences.swift | 415 ++++++++ OsLogRewriter/Readme.md | 22 + .../Sources/OsLogRewriter/main.swift | 23 + .../OsLogRewriterLib/OsLogRewriter.swift | 304 ++++++ .../OsLogRewriterTests.swift | 266 +++++ openHAB.xcodeproj/project.pbxproj | 28 +- .../xcschemes/openHABTestsSwift.xcscheme | 9 - .../xcshareddata/swiftpm/Package.resolved | 30 +- openHAB/AppDelegate.swift | 33 +- openHAB/ColorPickerCell.swift | 8 +- openHAB/ColorPickerViewController.swift | 8 +- openHAB/HomeSelectionView.swift | 8 +- openHAB/NewImageUITableViewCell.swift | 15 +- openHAB/NotificationTableViewCell.swift | 4 +- openHAB/OpenHABRootViewController.swift | 20 +- openHAB/OpenHABSitemapViewController.swift | 28 +- openHAB/OpenHABWebViewController.swift | 22 +- openHAB/RollershutterCell.swift | 11 +- openHAB/SegmentedUITableViewCell.swift | 4 +- openHAB/SettingsView/SettingsView.swift | 2 +- openHAB/SliderUITableViewCell.swift | 4 +- ...iderWithSwitchSupportUITableViewCell.swift | 8 +- openHAB/SwitchUITableViewCell.swift | 6 +- openHAB/VideoUITableViewCell.swift | 12 +- openHAB/WebUITableViewCell.swift | 22 +- .../Model/OpenHABWidgetExtension.swift | 1 - openHABWatch/Views/Rows/ColorPickerRow.swift | 5 +- openHABWatch/Views/Rows/SliderRow.swift | 4 +- .../Rows/SliderWithSwitchSupportRow.swift | 8 +- openHABWatch/Views/Rows/SwitchRow.swift | 6 +- openHABWatch/Views/Utils/ColorSelection.swift | 5 +- 50 files changed, 3327 insertions(+), 241 deletions(-) create mode 100644 OsLogRewriter/AGENT.md create mode 100644 OsLogRewriter/HTTPClient.swift create mode 100644 OsLogRewriter/OpenHABRootViewController.swift create mode 100644 OsLogRewriter/OpenHABSitemapViewController.swift create mode 100644 OsLogRewriter/Package.resolved create mode 100644 OsLogRewriter/Package.swift create mode 100644 OsLogRewriter/Preferences.swift create mode 100644 OsLogRewriter/Readme.md create mode 100644 OsLogRewriter/Sources/OsLogRewriter/main.swift create mode 100644 OsLogRewriter/Sources/OsLogRewriterLib/OsLogRewriter.swift create mode 100644 OsLogRewriter/Tests/OsLogRewriterTests/OsLogRewriterTests.swift diff --git a/.gitignore b/.gitignore index d054d1e0e..67161d5aa 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,6 @@ OpenHABCore/Package.resolved swift-openapi-generator/ OpenHABCore/swift-openapi-generator/ vendor/ + +OsLogRewriter/.build +OsLogRewriter/.swiftpm diff --git a/BuildTools/.swiftlint.yml b/BuildTools/.swiftlint.yml index 4ae79e895..b1de4d19c 100644 --- a/BuildTools/.swiftlint.yml +++ b/BuildTools/.swiftlint.yml @@ -36,6 +36,7 @@ excluded: - ../DerivedData - ../openHAB/Intents/Generated - ../openHABWatch/Intents/Generated + - ../OsLogRewriter/.build nesting: type_level: 2 diff --git a/NotificationService/NotificationService.swift b/NotificationService/NotificationService.swift index 435091148..be4d9fa04 100644 --- a/NotificationService/NotificationService.swift +++ b/NotificationService/NotificationService.swift @@ -88,7 +88,7 @@ class NotificationService: UNNotificationServiceExtension { } } if !notificationActions.isEmpty { - os_log("didReceive registering %{PUBLIC}@ for category %{PUBLIC}@", log: .default, type: .info, notificationActions, category) + logger.info("didReceive registering \(notificationActions) for category \(category)") let notificationCategory = UNNotificationCategory( identifier: category, @@ -98,7 +98,7 @@ class NotificationService: UNNotificationServiceExtension { ) UNUserNotificationCenter.current().getNotificationCategories { existingCategories in var updatedCategories = existingCategories - os_log("handleNotification adding category %{PUBLIC}@", log: .default, type: .info, category) + self.logger.info("handleNotification adding category \(category)") updatedCategories.insert(notificationCategory) UNUserNotificationCenter.current().setNotificationCategories(updatedCategories) } @@ -123,7 +123,7 @@ class NotificationService: UNNotificationServiceExtension { throw NotificationServiceError.handleNotificationCouldNotAttach } } catch { - os_log("Error fetching data: %{PUBLIC}@", log: .default, type: .error, error.localizedDescription) + logger.error("Error fetching data: \(error.localizedDescription)") } contentHandler(bestAttemptContent) } @@ -134,9 +134,7 @@ class NotificationService: UNNotificationServiceExtension { } override func serviceExtensionTimeWillExpire() { - // Called just before the extension will be terminated by the system. - // Use this as an opportunity to deliver your "best attempt" at modified content, otherwise the original push payload will be used. - os_log("serviceExtensionTimeWillExpire", log: .default, type: .info) + logger.info("serviceExtensionTimeWillExpire") if let contentHandler, let bestAttemptContent { contentHandler(bestAttemptContent) } @@ -150,7 +148,7 @@ class NotificationService: UNNotificationServiceExtension { return actionsArray } } catch { - os_log("Error parsing actions: %{PUBLIC}@", log: .default, type: .info, error.localizedDescription) + logger.info("Error parsing actions: \(error.localizedDescription)") } } return nil @@ -176,7 +174,7 @@ class NotificationService: UNNotificationServiceExtension { } } catch { - os_log("Error fetching data: %{PUBLIC}@", log: .default, type: .error, error.localizedDescription) + logger.error("Error fetching data: \(error.localizedDescription)") } } return returnValues @@ -241,8 +239,7 @@ class NotificationService: UNNotificationServiceExtension { let tempDirectory = FileManager.default.temporaryDirectory let tempFileURL = tempDirectory.appendingPathComponent(UUID().uuidString) try imageData.write(to: tempFileURL) - - os_log("Image saved to temporary file: %{PUBLIC}@", log: .default, type: .info, tempFileURL.absoluteString) + logger.info("Image saved to temporary file: \(tempFileURL.absoluteString)") return (tempFileURL, mimeType) } @@ -262,12 +259,12 @@ class NotificationService: UNNotificationServiceExtension { try fileManager.moveItem(at: tempFile, to: newTempFile) attachment = try UNNotificationAttachment(identifier: UUID().uuidString, url: newTempFile, options: nil) } else { - os_log("Unrecognized MIME type or file extension", log: .default, type: .error) + logger.error("Unrecognized MIME type or file extension") attachment = nil } return attachment } catch { - os_log("Failed to create UNNotificationAttachment: %{PUBLIC}@", log: .default, type: .error, error.localizedDescription) + logger.error("Failed to create UNNotificationAttachment: \(error.localizedDescription)") } return nil } diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABItem.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABItem.swift index 774011112..24025d15c 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABItem.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABItem.swift @@ -30,6 +30,8 @@ public struct OpenHABItem: Sendable { case switchItem = "Switch" case undetermined = "" // Relevant only for SitemapWidgetEvent } + + private let logger = Logger(subsystem: "org.openhab", category: "OpenHABItem") public var type: ItemType? public var groupType: ItemType? @@ -95,7 +97,7 @@ public extension OpenHABItem { let hue = CGFloat(state: values[0], divisor: 360) let saturation = CGFloat(state: values[1], divisor: 100) let brightness = CGFloat(state: values[2], divisor: 100) - os_log("hue saturation brightness: %g %g %g", log: .default, type: .info, hue, saturation, brightness) + logger.info("hue saturation brightness: \(hue) \(saturation) \(brightness)") return UIColor(hue: hue, saturation: saturation, brightness: brightness, alpha: 1.0) } else { return .black diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABPage.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABPage.swift index a12810123..39ac4b0c4 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABPage.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABPage.swift @@ -13,6 +13,8 @@ import Foundation import os.log public class OpenHABPage: NSObject, @unchecked Sendable { + private let logger = Logger(subsystem: "org.openhab", category: "OpenHABItem") + public var sendCommand: ((_ item: OpenHABItem, _ command: String?) -> Void)? public var widgets: [OpenHABWidget] = [] public var pageId = "" @@ -40,8 +42,7 @@ public class OpenHABPage: NSObject, @unchecked Sendable { private func sendCommand(_ item: OpenHABItem?, commandToSend command: String?) { guard let item else { return } - - os_log("SitemapPage sending command %{PUBLIC}@ to %{PUBLIC}@", log: OSLog.remoteAccess, type: .info, command.orEmpty, item.name) + logger.info("SitemapPage sending command \(command.orEmpty) to \(item.name)") sendCommand?(item, command) } } diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift index d5531ef65..c2ff12aa0 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift @@ -60,6 +60,8 @@ public class OpenHABWidget: NSObject, MKAnnotation, Identifiable, ObservableObje case text, number, date, time, datetime, unknown } + private let logger = Logger(subsystem: "org.openhab", category: "OpenHABWidget") + public var id = "" public var sendCommand: ((_ item: OpenHABItem, _ command: String?) -> Void)? @@ -188,7 +190,7 @@ public class OpenHABWidget: NSObject, MKAnnotation, Identifiable, ObservableObje public func sendItemUpdate(state: NumberState?) { guard let item, let state else { - os_log("ItemUpdate for Item or State = nil", log: .default, type: .info) + logger.info("ItemUpdate for Item or State = nil") return } if item.isOfTypeOrGroupType(.numberWithDimension) { @@ -206,11 +208,11 @@ public class OpenHABWidget: NSObject, MKAnnotation, Identifiable, ObservableObje public func sendCommand(_ command: String?) { guard let item else { - os_log("Command for Item = nil", log: .default, type: .info) + logger.info("Command for Item = nil") return } guard let sendCommand else { - os_log("sendCommand closure not set", log: .default, type: .info) + logger.info("sendCommand closure not set") return } sendCommand(item, command) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/ClientCertificateManager.swift b/OpenHABCore/Sources/OpenHABCore/Util/ClientCertificateManager.swift index f5acacfa8..792c3cbd0 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/ClientCertificateManager.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/ClientCertificateManager.swift @@ -101,14 +101,14 @@ public class ClientCertificateManager { kSecValueRef as String: cert! ] var status = SecItemDelete(deleteCertQuery as NSDictionary) - os_log("SecItemDelete(cert) result=%{PUBLIC}d", log: .default, type: .info, status) + logger.info("SecItemDelete(cert) result = \(status) ") if status == noErr { let deleteKeyQuery: [String: Any] = [ kSecClass as String: kSecClassKey, kSecValueRef as String: key! ] status = SecItemDelete(deleteKeyQuery as NSDictionary) - os_log("SecItemDelete(key) result=%{PUBLIC}d", log: .default, type: .info, status) + logger.info("SecItemDelete(key) result= \(status)") } // Figure out which certs in the certificate chain also need to be removed. @@ -127,7 +127,7 @@ public class ClientCertificateManager { ] let status = SecItemDelete(deleteCertQuery as NSDictionary) let summary = SecCertificateCopySubjectSummary(ct) as String? ?? "" - os_log("SecItemDelete(certChain) %s result=%{PUBLIC}d", log: .default, type: .info, summary, status) + logger.info("SecItemDelete(certChain) \(summary) result = \(status)") } } } @@ -286,7 +286,7 @@ public class ClientCertificateManager { importingIdentity = identityDictionaries[0][kSecImportItemIdentity as String] as! SecIdentity? importingCertChain = identityDictionaries[0][kSecImportItemCertChain as String] as! [SecCertificate]? } else { - os_log("SecPKCS12Import failed; result=%{PUBLIC}d", log: .default, type: .info, status) + logger.info("SecPKCS12Import failed; result = \(status)") } return status } @@ -333,12 +333,12 @@ public class ClientCertificateManager { let certificates = SecTrustCopyCertificateChain(trust) as? [SecCertificate] { let rootCA = certificates[chainSize - 1] let anchors = [rootCA] - os_log("Setting anchor for trust evaluation to %s", log: .default, type: .info, SecCertificateCopySubjectSummary(rootCA)! as String) + logger.info("Setting anchor for trust evaluation to \(SecCertificateCopySubjectSummary(rootCA)! as String)") SecTrustSetAnchorCertificates(trust, anchors as CFArray) trustResult = SecTrustResultType.proceed var trustError: CFError? if SecTrustEvaluateWithError(trust, &trustError) != true { - os_log("Trust evaluation failed building client certificate chain after anchor has been set: %s", log: .default, type: .info, trustError.debugDescription) + logger.info("Trust evaluation failed building client certificate chain after anchor has been set: \(trustError.debugDescription)") SecTrustGetTrustResult(trust, &trustResult) } } diff --git a/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift b/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift index 7982916a1..ca449e190 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift @@ -12,6 +12,8 @@ @preconcurrency import Foundation import os +private let logger = Logger(subsystem: "org.openhab", category: "HTTPClient") + public enum HTTPClientError: Error { case serverTrustEvaluationFailed(reason: String) case noDataforItem @@ -71,7 +73,7 @@ actor CertificateStore { let data = try PropertyListEncoder().encode(trustedCertificates) try data.write(to: getPersistencePath()) } catch { - os_log("Could not save trusted certificates", log: .default) + logger.info("Could not save trusted certificates") } } @@ -91,20 +93,20 @@ actor CertificateStore { saveTrustedCertificates() // Ensure that data is written in new format } } catch { - os_log("Could not load trusted certificates", log: .default) + logger.info("Could not load trusted certificates") } } } private func initializeCertificatesStore() { - os_log("Initializing cert store", log: .default, type: .info) + logger.info("Initializing cert store") loadTrustedCertificates() if trustedCertificates.isEmpty { - os_log("No cert store, creating", log: .default, type: .info) + logger.info("No cert store, creating") trustedCertificates = [:] saveTrustedCertificates() } else { - os_log("Loaded existing cert store", log: .default, type: .info) + logger.info("Loaded existing cert store") } } @@ -150,7 +152,7 @@ public final class HTTPClient: NSObject { do { return try await doRequest(baseURL: url, type: .bytes) } catch { - os_log("Failed to fetch MJPEG stream: %@", log: .default, type: .error, error.localizedDescription) + logger.error("Failed to fetch MJPEG stream: \(error.localizedDescription)") throw HTTPClientError.failedtoFetchMJPEG } } @@ -208,7 +210,7 @@ public final class HTTPClient: NSObject { type: SessionType, cacheingPolicy: URLRequest.CachePolicy = .useProtocolCachePolicy) async throws -> (T, URLResponse) { guard var url = baseURL ?? self.baseURL else { - os_log("doRequest ERROR: Base URL is nil", log: .networking, type: .info) + logger.info("doRequest ERROR: Base URL is nil") throw HTTPClientError.baseURLIsNil } @@ -237,10 +239,10 @@ public final class HTTPClient: NSObject { let (result, response): (T, URLResponse) = try await performRequest(request: request, type: type) if let response = response as? HTTPURLResponse { if (400 ... 599).contains(response.statusCode) { - os_log("HTTP error from URL %{public}@ : %{public}d", log: .networking, type: .error, url.absoluteString, response.statusCode) + logger.error("HTTP error from URL \(url.absoluteString) : \(response.statusCode)") throw HTTPClientError.httpError(response.statusCode) } else { - os_log("Response from URL %{public}@ : %{public}d", log: .networking, type: .info, url.absoluteString, response.statusCode) + logger.info("Response from URL \(url.absoluteString) : \(response.statusCode)") return (result, response) } } diff --git a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift index 95134e902..c8a31ef61 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift @@ -187,7 +187,7 @@ public final class NetworkTracker: ObservableObject { private func checkActiveConnection() async { guard let activeConnection else { // No active connection, proceed with the normal connection attempt - os_log("No active connection, attempting to reconnect...", log: OSLog.default, type: .info) + logger.info("No active connection, attempting to reconnect...") await attemptConnection() return } diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OpenHABImageProcessor.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABImageProcessor.swift index dabd491a3..35ee34b57 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/OpenHABImageProcessor.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABImageProcessor.swift @@ -19,6 +19,7 @@ public struct OpenHABImageProcessor: ImageProcessor { // `identifier` should be the same for processors with the same properties/functionality // It will be used when storing and retrieving the image to/from cache. public let identifier = "org.openhab.svgprocessor" + private let logger = Logger(subsystem: "org.openhab", category: "OpenHABImageProcessor") public init() {} @@ -26,7 +27,7 @@ public struct OpenHABImageProcessor: ImageProcessor { public func process(item: ImageProcessItem, options: KingfisherParsedOptionsInfo) -> KFCrossPlatformImage? { switch item { case let .image(image): - os_log("already an image", log: .default, type: .info) + logger.info("already an image") return image case let .data(data): guard !data.isEmpty else { return nil } diff --git a/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift index b8b78849b..da2c92846 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift @@ -13,6 +13,8 @@ import os.log import UIKit +private let logger = Logger(subsystem: "org.openhab", category: "Preferences") + @propertyWrapper @MainActor public struct UserDefault { private let key: String @@ -143,31 +145,13 @@ extension Preferences { fileprivate static func getPreference(key: String, defaultValue: T, encoder: (T) -> (some Sendable)?, decoder: (Any?) -> T?) -> T { let preferenceValue = sharedDefaults.object(forKey: key) if let preferenceConverted = decoder(preferenceValue) { - os_log( - "Preference value %{PUBLIC}@ is %{PUBLIC}@", - log: .default, - type: .debug, - key, - "\(preferenceConverted)" - ) + logger.debug("Preference value \(key) is \(String(describing: preferenceConverted))") return preferenceConverted } else { if let preferenceValue { - os_log( - "Preference value %{PUBLIC}@ was \"%{PUBLIC}@\" but did not conform to %{PUBLIC}@. Replace with default value.", - log: .default, - type: .fault, - key, - "\(preferenceValue)", - "\(T.self)" - ) + logger.error("Preference value \(key) was \(String(describing: preferenceValue)) but did not conform to \(T.self). Replace with default value.") } else { - os_log( - "Preference value %{PUBLIC}@ was set for the first time. Using default value.", - log: .default, - type: .info, - key - ) + logger.info("Preference value \(key) was set for the first time. Using default value.") } let fallback = defaultValue sharedDefaults.set(encoder(fallback), forKey: key) @@ -177,15 +161,15 @@ extension Preferences { fileprivate static func preferenceChanged(newValue: T, key: String, isHomeProperty: Bool, subject: CurrentValueSubject, sanitize: (T) -> (T?) = { $0 }, converter: (T) -> (some Sendable)?) { guard let sanitized = sanitize(newValue) else { - os_log("Preference %{PUBLIC}@ new value \"%{PUBLIC}@\" could not be sanitized, will be ignored", log: .default, type: .debug, key, "\(newValue)") + logger.debug("Preference \(key) new value \(String(describing: newValue)) could not be sanitized, will be ignored") return } let convertedValue = converter(sanitized) guard convertedValue != nil else { - os_log("Preference %{PUBLIC}@ conversion of new value %{PUBLIC}@ failed, do not store.", log: .default, type: .debug, key, "\(sanitized)") + logger.debug("Preference \(key) conversion of new value \(String(describing: sanitized)) failed, do not store.") return } - os_log("Preference %{PUBLIC}@ will be changed to value %{PUBLIC}@", log: .default, type: .debug, key, "\(newValue)") + logger.debug("Preference \(key) will be changed to value \(String(describing: newValue))") sharedDefaults.set(convertedValue, forKey: key) DispatchQueue.main.async { [subject] in @@ -280,7 +264,7 @@ public extension Preferences { let homeId = Preferences.activeHomeId all[homeId] = Preferences.currentHomePreferences storedHomes = all - os_log("Stored preferences for current home %{public}@", log: .default, type: .debug, homeId.uuidString) + logger.debug("Stored preferences for current home \(homeId.uuidString)") } static func modifyActiveHome(modificationFunction: (inout HomePreferences) -> Void) { diff --git a/OpenHABCore/Sources/OpenHABCore/Util/StringExtension.swift b/OpenHABCore/Sources/OpenHABCore/Util/StringExtension.swift index c6ca386d5..f28abf52f 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/StringExtension.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/StringExtension.swift @@ -14,6 +14,8 @@ import MapKit import os.log import UIKit +private let logger = Logger(subsystem: "org.openhab", category: "String") + public extension String { internal var doubleValue: Double { let formatter = NumberFormatter() @@ -112,7 +114,7 @@ public extension String { let hue = CGFloat(state: values[0], divisor: 360) let saturation = CGFloat(state: values[1], divisor: 100) let brightness = CGFloat(state: values[2], divisor: 100) - os_log("hue saturation brightness: %g %g %g", log: .default, type: .info, hue, saturation, brightness) + logger.info("hue saturation brightness: \(hue) \(saturation) \(brightness)") return UIColor(hue: hue, saturation: saturation, brightness: brightness, alpha: 1.0) } diff --git a/OpenHABCore/Tests/OpenHABCoreTests/JSONParserTests.swift b/OpenHABCore/Tests/OpenHABCoreTests/JSONParserTests.swift index 0849999e6..ba3a088b8 100644 --- a/OpenHABCore/Tests/OpenHABCoreTests/JSONParserTests.swift +++ b/OpenHABCore/Tests/OpenHABCoreTests/JSONParserTests.swift @@ -444,60 +444,29 @@ final class JSONParserTests: XCTestCase { } func testJSONLargeSitemapParseSwift() throws { - let log = OSLog( - subsystem: "org.openhab.app", - category: "RecordDecoding" - ) - - if #available(iOS 12, *) { - let signpostID = OSSignpostID(log: log) - - let jsonFile = "LargeSitemap" - os_signpost( - .begin, - log: log, - name: "Read File", - signpostID: signpostID, - "%{public}s", - jsonFile - ) - - let testBundle = Bundle.module - let url = testBundle.url(forResource: jsonFile, withExtension: "json") - let contents = try Data(contentsOf: url!) - os_signpost( - .end, - log: log, - name: "Read File", - signpostID: signpostID, - "%{public}s", - jsonFile - ) - - os_signpost( - .begin, - log: log, - name: "Decode JSON", - signpostID: signpostID, - "Begin" - ) - let codingData = try decoder.decode(Components.Schemas.SitemapDTO.self, from: contents) - os_signpost( - .end, - log: log, - name: "Decode JSON", - signpostID: signpostID, - "End" - ) - - let widgets = try XCTUnwrap(codingData.homepage?.widgets) - let widget = widgets[0] - XCTAssertEqual(widget.label, "Flat Scenes") - XCTAssertEqual(widget.widgets?[0].label, "Scenes") - XCTAssertEqual(codingData.homepage?.link, "https://192.168.0.9:8443/rest/sitemaps/default/default") - let widget2 = widgets[10] - XCTAssertEqual(widget2.widgets?[0].label, "Admin Items") - } + let logger = Logger(subsystem: "org.openhab.app", category: "RecordDecoding") + + let jsonFile = "LargeSitemap" + let testBundle = Bundle.module + let url = try XCTUnwrap(testBundle.url(forResource: jsonFile, withExtension: "json")) + + let signposter = OSSignposter(subsystem: "org.openhab.app", category: "RecordDecoding") + + let state = signposter.beginInterval("Read File") + let contents = try Data(contentsOf: url) + signposter.endInterval("Read File", state) + + let state2 = signposter.beginInterval("Decode JSON") + let codingData = try decoder.decode(Components.Schemas.SitemapDTO.self, from: contents) + signposter.endInterval("Decode JSON", state2) + + let widgets = try XCTUnwrap(codingData.homepage?.widgets) + let widget = widgets[0] + XCTAssertEqual(widget.label, "Flat Scenes") + XCTAssertEqual(widget.widgets?[0].label, "Scenes") + XCTAssertEqual(codingData.homepage?.link, "https://192.168.0.9:8443/rest/sitemaps/default/default") + let widget2 = widgets[10] + XCTAssertEqual(widget2.widgets?[0].label, "Admin Items") } func testItemWithDescription() { diff --git a/OsLogRewriter/AGENT.md b/OsLogRewriter/AGENT.md new file mode 100644 index 000000000..33d7cb382 --- /dev/null +++ b/OsLogRewriter/AGENT.md @@ -0,0 +1,26 @@ +# OsLogRewriter - openHAB iOS Build Tool + +## Commands +- **Build**: `swift build` +- **Run**: `swift run OsLogRewriter ` +- **Clean**: `swift package clean` +- **Test**: `swift test` (runs comprehensive test suite) +- **Test Verbose**: `swift test --verbose` (shows detailed test output) + +## Architecture +- **Purpose**: Swift syntax rewriter tool that transforms `os_log()` calls to `logger.debug/info/error()` calls +- **Single executable target**: OsLogRewriter in Sources/OsLogRewriter.swift +- **Library target**: OsLogRewriterLib in Sources/OsLogRewriterLib/ (for testing and reuse) +- **Dependencies**: SwiftSyntax (509.0.0+) for AST parsing/rewriting +- **Platform**: macOS 13+, Swift 6.1+ +- **Context**: Part of openHAB iOS app build pipeline for log statement migration + +## Code Style +- Uses SwiftSyntax AST manipulation patterns with SyntaxRewriter subclass +- Functional programming approach with syntax tree transformations +- String interpolation format: "\(variable)" in logger calls +- Error handling with stderr output and exit codes +- XCTest framework for comprehensive unit testing +- Swift 6 strict concurrency (@preconcurrency, @MainActor, Sendable) +- Property wrappers for UserDefaults (@UserDefault, @UserDefaultObject) +- Eclipse Public License 2.0 headers required on source files diff --git a/OsLogRewriter/HTTPClient.swift b/OsLogRewriter/HTTPClient.swift new file mode 100644 index 000000000..388618a55 --- /dev/null +++ b/OsLogRewriter/HTTPClient.swift @@ -0,0 +1,276 @@ +// Copyright (c) 2010-2025 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 + +@preconcurrency import Foundation +import os + +public enum HTTPClientError: Error { + case serverTrustEvaluationFailed(reason: String) + case noDataforItem + case noDataForProperties + case baseURLIsNil + case httpError(Int) + case couldNotRegister + case couldNotLoadNotification + case failedtoFetchMJPEG + case noConfiguration + + var debugDescription: String { + switch self { + case .noDataforItem: + "No data for item" + case let .serverTrustEvaluationFailed(reason): + "server trust evaluation failed: \(reason)" + case .noDataForProperties: + "No data for properties" + case .baseURLIsNil: + "Base URL is nil" + case let .httpError(statusCode): + "HTTP error \(statusCode)" + case .couldNotRegister: + "Could not register" + case .couldNotLoadNotification: + "Could not load notification" + case .failedtoFetchMJPEG: + "Failed to fetch MJPEG" + case .noConfiguration: + "No configuration" + } + } +} + +public enum CertificateEvaluateResult: Sendable { + case undecided + case deny + case permitOnce + case permitAlways +} + +actor CertificateStore { + private var trustedCertificates: [String: Data] = [:] + + private func getPersistencePath() -> URL { + #if os(watchOS) + let documentsDirectory = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true)[0] + return URL(fileURLWithPath: documentsDirectory).appendingPathComponent("trustedCertificates") + #else + FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.org.openhab.app")!.appendingPathComponent("trustedCertificates") + #endif + } + + private func saveTrustedCertificates() { + do { + let data = try PropertyListEncoder().encode(trustedCertificates) + try data.write(to: getPersistencePath()) + } catch { + logger.info("Could not save trusted certificates") + } + } + + private func loadTrustedCertificates() { + var decodableTrustedCertificates: [String: Data] = [:] + do { + let rawdata = try Data(contentsOf: getPersistencePath()) + let decoder = PropertyListDecoder() + decodableTrustedCertificates = try decoder.decode([String: Data].self, from: rawdata) + trustedCertificates = decodableTrustedCertificates + } catch { + // if Decodable fails, fall back to NSKeyedArchiver + do { + let rawdata = try Data(contentsOf: getPersistencePath()) + if let unarchivedTrustedCertificates = try? NSKeyedUnarchiver.unarchivedObject(ofClasses: [NSDictionary.self, NSString.self, NSData.self], from: rawdata) as? [String: Data] { + trustedCertificates = unarchivedTrustedCertificates + saveTrustedCertificates() // Ensure that data is written in new format + } + } catch { + logger.info("Could not load trusted certificates") + } + } + } + + private func initializeCertificatesStore() { + logger.info("Initializing cert store") + loadTrustedCertificates() + if trustedCertificates.isEmpty { + logger.info("No cert store, creating") + trustedCertificates = [:] + saveTrustedCertificates() + } else { + logger.info("Loaded existing cert store") + } + } + + public func storeCertificateData(_ certificate: Data?, forDomain domain: String) { + trustedCertificates[domain] = certificate + saveTrustedCertificates() + } + + public func certificateData(forDomain domain: String) -> Data? { + guard let data = trustedCertificates[domain] else { return nil } + return data + } +} + +public final class HTTPClient: NSObject { + // MARK: - Properties + + public enum SessionType { + case download + case data + case bytes + } + + // this can be changed if we detect another server + public let baseURL: URL? + + private let logger = Logger(subsystem: "org.openhab.core", category: "HTTPClient") + + private let configuration: ConnectionConfiguration + public let session: URLSession + public let delegate: HTTPClientDelegate + + public init(baseURL: URL? = nil, configuration: ConnectionConfiguration) { + self.configuration = configuration + self.baseURL = baseURL + delegate = HTTPClientDelegate(with: configuration) + let config = URLSessionConfiguration.default + session = URLSession(configuration: config, delegate: delegate, delegateQueue: nil) + super.init() + } + + public func processStream(url: URL) async throws -> (URLSession.AsyncBytes, URLResponse) { + do { + return try await doRequest(baseURL: url, type: .bytes) + } catch { + logger.error("Failed to fetch MJPEG stream: \(error.localizedDescription)") + throw HTTPClientError.failedtoFetchMJPEG + } + } + + @discardableResult + public func register(prefsURL: String, + deviceToken: String, + deviceId: String, + deviceName: String) async throws -> String? { + if let url = Endpoint.appleRegistration(prefsURL: prefsURL, deviceToken: deviceToken, deviceId: deviceId, deviceName: deviceName).url { + let (data, _): (Data, URLResponse) = try await doRequest(baseURL: url, type: .data) + struct CloudUserResponse: Decodable { let userId: String } + return try? JSONDecoder().decode(CloudUserResponse.self, from: data).userId + } else { + throw HTTPClientError.couldNotRegister + } + } + + public func notification(url: URL) async throws -> Data { + let (data, _): (Data, URLResponse) = try await doRequest(baseURL: url, type: .data) + return data + } + + public func notification(urlString: String) async throws -> [OpenHABNotification] { + guard let url = Endpoint.notification(prefsURL: urlString).url else { throw HTTPClientError.couldNotLoadNotification } + let data = try await notification(url: url) + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .formatted(DateFormatter.iso8601Full) + let codingDatas = try data.decoded(as: [OpenHABNotification.CodingData].self, using: decoder) + return codingDatas.map(\.openHABNotification) + } + + /** + Initiates a download request to a specified base URL for a specified path and returns the file URL via a completion handler. + + - Parameters: + - url + - Returns: + - response: The URL response object providing response metadata, such as HTTP headers and status code. + - error: An error object that indicates why the request failed, or `nil` if the request was successful. + */ + + public func downloadFile(url: URL) async throws -> (URL, URLResponse) { + let (fileURL, response): (URL, URLResponse) = try await doRequest(baseURL: url, path: nil, type: .download) + + return (fileURL, response) + } + + public func doRequest(baseURL: URL?, + path: String? = nil, + headers: [String: String]? = nil, + timeout: TimeInterval = 60.0, + body: String? = nil, + type: SessionType, + cacheingPolicy: URLRequest.CachePolicy = .useProtocolCachePolicy) async throws -> (T, URLResponse) { + guard var url = baseURL ?? self.baseURL else { + logger.info("doRequest ERROR: Base URL is nil") + throw HTTPClientError.baseURLIsNil + } + + if let path { + url.appendPathComponent(path) + } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.timeoutInterval = timeout + + if let headers { + for (key, value) in headers { + request.setValue(value, forHTTPHeaderField: key) + } + } + if let body { + request.httpBody = body.data(using: .utf8) + request.setValue("text/plain", forHTTPHeaderField: "Content-Type") + } + + if cacheingPolicy != .useProtocolCachePolicy { + request.cachePolicy = cacheingPolicy + } + + let (result, response): (T, URLResponse) = try await performRequest(request: request, type: type) + if let response = response as? HTTPURLResponse { + if (400 ... 599).contains(response.statusCode) { + logger.error("HTTP error from URL \(url.absoluteString) : %{public}d") + throw HTTPClientError.httpError(response.statusCode) + } else { + logger.info("Response from URL \(url.absoluteString) : %{public}d") + return (result, response) + } + } + fatalError() + } + + private func performRequest(request: URLRequest, type: SessionType = .data) async throws -> (T, URLResponse) { + var request = request + + let username = configuration.username + let password = configuration.password + let alwaysSendBasicAuth = configuration.alwaysSendBasicAuth + + if request.url?.host?.hasSuffix("myopenhab.org") == true || alwaysSendBasicAuth, !username.isEmpty, !password.isEmpty { + request.setValue(basicAuthHeader(username: username, password: password), forHTTPHeaderField: "Authorization") + } + + switch type { + case .download: + return try await session.download(for: request) as! (T, URLResponse) + case .data: + return try await session.data(for: request) as! (T, URLResponse) + case .bytes: + return try await session.bytes(for: request) as! (T, URLResponse) + } + } +} + +public extension Notification.Name { + static let evaluateServerTrust = Notification.Name("evaluateServerTrust") + static let evaluateCertificateMismatch = Notification.Name("evaluateCertificateMismatch") + static let acceptedServerCertificatesChanged = Notification.Name("acceptedServerCertificatesChanged") +} diff --git a/OsLogRewriter/OpenHABRootViewController.swift b/OsLogRewriter/OpenHABRootViewController.swift new file mode 100644 index 000000000..f2afd37df --- /dev/null +++ b/OsLogRewriter/OpenHABRootViewController.swift @@ -0,0 +1,756 @@ +// Copyright (c) 2010-2025 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 Combine +import FirebaseCrashlytics +import Foundation +import OpenHABCore +import os.log +import SafariServices +import SFSafeSymbols +import SideMenu +import SwiftUI +import UIKit + +enum TargetController { + case webview + case settings + case sitemap(String) + case notifications + case browser(String) + case tile(String) + case homeSelection +} + +protocol ModalHandler: AnyObject { + func modalDismissed(to: TargetController) +} + +private let logger = Logger(subsystem: "org.openhab.UI", category: "OpenHABRootViewController") + +// swiftlint:disable type_body_length +class OpenHABRootViewController: UIViewController { + var currentView: OpenHABViewController! + var isDemoMode = false + var cancellables = Set() + + private var apsRegistrationData: [AnyHashable: Any]? + + private lazy var webViewController: OpenHABWebViewController = { + let storyboard = UIStoryboard(name: "Main", bundle: Bundle.main) + var viewController = storyboard.instantiateViewController(withIdentifier: "OpenHABWebViewController") as! OpenHABWebViewController + return viewController + }() + + private lazy var sitemapViewController: OpenHABSitemapViewController = { + let storyboard = UIStoryboard(name: "Main", bundle: Bundle.main) + var viewController = storyboard.instantiateViewController(withIdentifier: "OpenHABPageViewController") as! OpenHABSitemapViewController + return viewController + }() + + private var activeConnection: ConnectionInfo? + + override func viewDidLoad() { + super.viewDidLoad() + logger.info("OpenHABRootViewController viewDidLoad") + setupSideMenu() + + NotificationCenter.default.addObserver(self, selector: #selector(OpenHABRootViewController.handleApsRegistration(_:)), name: NSNotification.Name("apsRegistered"), object: nil) + + if Crashlytics.crashlytics().didCrashDuringPreviousExecution(), !Preferences.sendCrashReports { + let alertController = UIAlertController(title: NSLocalizedString("crash_detected", comment: "").capitalized, message: NSLocalizedString("crash_reporting_info", comment: ""), preferredStyle: .alert) + alertController.addAction( + UIAlertAction(title: NSLocalizedString("activate", comment: ""), style: .default) { _ in + Preferences.sendCrashReports = true + Crashlytics.crashlytics().sendUnsentReports() + } + ) + alertController.addAction( + UIAlertAction(title: NSLocalizedString("privacy_policy", comment: ""), style: .default) { [weak self] _ in + let webViewController = SFSafariViewController(url: URL.privacyPolicy) + webViewController.configuration.barCollapsingEnabled = true + self?.present(webViewController, animated: true) + } + ) + alertController.addAction( + UIAlertAction(title: NSLocalizedString("cancel", comment: ""), style: .default) { _ in + Crashlytics.crashlytics().deleteUnsentReports() + } + ) + present(alertController, animated: true) + } + + #if DEBUG + if ProcessInfo.processInfo.environment["UITest"] != nil { + // this is here to continue to make existing tests work, need to look at this later + Preferences.modifyActiveHome { homePreferences in + homePreferences.demomode = true + } + } + // setup accessibilityIdentifiers for UITest + navigationItem.rightBarButtonItem?.accessibilityIdentifier = "HamburgerButton" + #endif + // save this so we know if its changed later + isDemoMode = Preferences.currentHomePreferences.demomode + switchToSavedView() + setupTracker() + } + + override func viewWillAppear(_ animated: Bool) { + logger.info("OpenHABRootController viewWillAppear") + super.viewWillAppear(animated) + navigationController?.navigationBar.prefersLargeTitles = true + // if we have turned demo mode off/on, reset view + if isDemoMode != Preferences.currentHomePreferences.demomode { + switchToSavedView() + isDemoMode = Preferences.currentHomePreferences.demomode + } + } + + fileprivate func setupTracker() { + let serverInfo = Preferences.$currentHomePreferences + + // Register for certificate trust notifications + NotificationCenter.default.addObserver( + forName: .evaluateServerTrust, + object: nil, + queue: nil + ) { [weak self] notification in + guard + let summary = notification.userInfo?["summary"] as? String, + let domain = notification.userInfo?["domain"] as? String, + let client = notification.object as? HTTPClient + else { + return + } + + let delegate = client.delegate + + Task { @MainActor in + self?.handleCertificateTrust( + summary: summary, + domain: domain, + delegate: delegate, + messageTemplateKey: "ssl_certificate_invalid" + ) + } + } + + NotificationCenter.default.addObserver( + forName: .evaluateCertificateMismatch, + object: nil, + queue: nil + ) { [weak self] notification in + guard + let summary = notification.userInfo?["summary"] as? String, + let domain = notification.userInfo?["domain"] as? String, + let client = notification.object as? HTTPClient + + else { + return + } + + let delegate = client.delegate + + Task { @MainActor in + self?.handleCertificateTrust( + summary: summary, + domain: domain, + delegate: delegate, + messageTemplateKey: "ssl_certificate_no_match" + ) + } + } + + NotificationCenter.default.addObserver( + forName: .acceptedServerCertificatesChanged, + object: nil, + queue: nil + ) { _ in + Task { @MainActor in + WatchMessageService.singleton.syncPreferencesToWatch() + NetworkTracker.shared.restartTracking() + } + } + + serverInfo.debounce(for: .milliseconds(500), scheduler: RunLoop.main) // ensures if multiple values are saved, we get called once + .sink { homeSettings in + let localConnectionConfig = homeSettings.localConnectionConfig + let remoteConnectionConfig = homeSettings.remoteConnectionConfig + let demomode = homeSettings.demomode + + Task { + if demomode { + await NetworkTracker.shared.startTracking(connectionConfigurations: [ + ConnectionConfiguration( + url: "https://demo.openhab.org", + username: "", + password: "", + priority: 0 + ) + ]) + } else { + await NetworkTracker.shared.startTracking(connectionConfigurations: [ + localConnectionConfig, + remoteConnectionConfig + ]) + } + } + } + .store(in: &cancellables) + + NetworkTracker.shared.$activeConnection + .receive(on: DispatchQueue.main) + .sink { [weak self] activeConnection in + if let activeConnection { + self?.activeConnection = activeConnection + } + } + .store(in: &cancellables) + } + + fileprivate func setupSideMenu() { + let hamburgerButtonItem: UIBarButtonItem + let imageConfig = UIImage.SymbolConfiguration(textStyle: .largeTitle) + let buttonImage = UIImage(systemSymbol: .line3Horizontal, withConfiguration: imageConfig) + let button = UIButton(type: .custom) + button.setImage(buttonImage, for: .normal) + button.addTarget(self, action: #selector(OpenHABRootViewController.rightDrawerButtonPress(_:)), for: .touchUpInside) + hamburgerButtonItem = UIBarButtonItem(customView: button) + hamburgerButtonItem.customView?.heightAnchor.constraint(equalToConstant: 30).isActive = true + navigationItem.setRightBarButton(hamburgerButtonItem, animated: true) + + // Define the menus + + let presentationStyle: SideMenuPresentationStyle = .viewSlideOutMenuIn + presentationStyle.presentingEndAlpha = 1 + presentationStyle.onTopShadowOpacity = 0.5 + var settings = SideMenuSettings() + settings.presentationStyle = presentationStyle + settings.statusBarEndAlpha = 0 + + SideMenuManager.default.rightMenuNavigationController?.settings = settings + + let networkTracker = NetworkTracker.shared + let drawerView = DrawerView { mode in + self.handleDismiss(mode: mode) + } + .environmentObject(networkTracker) + let hostingController = UIHostingController(rootView: drawerView) + let menu = SideMenuNavigationController(rootViewController: hostingController) + + SideMenuManager.default.rightMenuNavigationController = menu + + // Enable gestures. The left and/or right menus must be set up above for these to work. + // Note that these continue to work on the Navigation Controller independent of the View Controller it displays! + SideMenuManager.default.addPanGestureToPresent(toView: navigationController!.navigationBar) + SideMenuManager.default.addScreenEdgePanGesturesToPresent(toView: navigationController!.view, forMenu: .right) + } + + private func openTileURL(_ urlString: String) { + // Use SFSafariViewController in SwiftUI with UIViewControllerRepresentable + // Dependent on $OPENHAB_CONF/services/runtime.cfg + // Can either be an absolute URL, a path (sometimes malformed) + guard !urlString.isEmpty else { return } + + let url: URL? + if urlString.hasPrefix("http") || urlString.hasPrefix("https") { + url = URL(string: urlString) + } else { + guard let rootUrl = activeConnection?.configuration.url else { + logger.error("openTileURL failed: no active connection URL") + return + } + url = Endpoint.resource(openHABRootUrl: rootUrl, path: urlString.prepare()).url + } + openURL(url: url) + } + + private func openURL(url: URL?) { + if let url { + let config = SFSafariViewController.Configuration() + config.entersReaderIfAvailable = true + let vc = SFSafariViewController(url: url, configuration: config) + present(vc, animated: true) + } + } + + private func handleDismiss(mode: TargetController) { + switch mode { + case .webview: + // Handle webview navigation or state update + print("Dismissed to WebView") + SideMenuManager.default.rightMenuNavigationController?.dismiss(animated: true) + switchView(target: .webview) + case .settings: + print("Dismissed to Settings") + SideMenuManager.default.rightMenuNavigationController?.dismiss(animated: true) { + self.modalDismissed(to: .settings) + } + case let .sitemap(sitemap): + Preferences.modifyActiveHome { homePreferences in + homePreferences.defaultSitemap = sitemap + } + SideMenuManager.default.rightMenuNavigationController?.dismiss(animated: true) { + self.modalDismissed(to: .sitemap(sitemap)) + } + case .notifications: + SideMenuManager.default.rightMenuNavigationController?.dismiss(animated: true) { + self.modalDismissed(to: .notifications) + } + case let .browser(urlString): + SideMenuManager.default.rightMenuNavigationController?.dismiss(animated: true) { + self.modalDismissed(to: .browser(urlString)) + } + case let .tile(urlString): + SideMenuManager.default.rightMenuNavigationController?.dismiss(animated: true) { + self.modalDismissed(to: .tile(urlString)) + } + case .homeSelection: + print("Dismissed to Home Selection") + SideMenuManager.default.rightMenuNavigationController?.dismiss(animated: true) { + self.modalDismissed(to: .homeSelection) + } + } + } + + @objc + func rightDrawerButtonPress(_ sender: Any?) { + showSideMenu() + } + + @objc + func handleApsRegistration(_ note: Notification?) { + logger.info("handleApsRegistration") + apsRegistrationData = note?.userInfo + subscribeToOpenhabConnectionChanges() + } + + private func subscribeToOpenhabConnectionChanges() { + struct UuidWithConnection: Hashable, Equatable { + let uuid: UUID + let connection: ConnectionConfiguration // not only URL, because auth and certs might be relevant for establishing the connection + } + + let storedOpenHabConnections = Preferences.$storedHomes + .debounce(for: .seconds(1), scheduler: RunLoop.main) // avoid overexcited registrations / deregistrations in batch updates + .map { updatedPreferences in // we want to recognize changes in the OpenHab URLs for any of the homes + Set(updatedPreferences.compactMap { storedWithUuid in + let (uuid, homeConfig) = storedWithUuid + guard let connection = Preferences.getNotificationConnection(of: homeConfig) else { return nil } + return UuidWithConnection(uuid: uuid, connection: connection) + }) + } + + // create a tuple that lets us inspect the previous value + let connectionsWithPreviousValues = storedOpenHabConnections + .scan((previous: Set(), current: Set())) { previous, current in + (previous: previous.current, current: current) + } + + let differences = connectionsWithPreviousValues.map { (previous, current) in // diff set of previous and current OpenHab URLs + (newValues: current.subtracting(previous), deletedValues: previous.subtracting(current)) + } + + let openhabConnectionSubscription = differences.sink { [weak self] diff in + logger.info("openhabConnectionSubscription updated") + for newHome in diff.newValues { + logger.info("openhabConnectionSubscription uuid \(newHome.uuid) registering for push notifications ") + self?.registerHome(uuid: newHome.uuid, connection: newHome.connection) + } + for deletedHome in diff.deletedValues { + // TODO: implement deregistration + logger.warning("APNS Deregistration is missing (wanted to deregister \(deletedHome.connection.url))") + } + } + + cancellables.insert(openhabConnectionSubscription) + } + + private func registerHome(uuid: UUID, connection: ConnectionConfiguration) { + guard let apsRegistrationData else { + logger.fault("Cannot register homes for push notifications, no notification registration data available") + return + } + guard let deviceId = apsRegistrationData["deviceId"] as? String, + let deviceToken = apsRegistrationData["deviceToken"] as? String, + let deviceName = apsRegistrationData["deviceName"] as? String else { + return + } + logger.info("Registering notifications with \(connection.url)") + _ = registerHome(uuid, connection, deviceToken, deviceId, deviceName) + } + + private func registerHome(_ uuid: UUID, _ config: ConnectionConfiguration, _ deviceToken: String, _ deviceId: String, _ deviceName: String) -> Task { + Task { + do { + let client = HTTPClient(configuration: config) + if let cloudUserId = try await client.register(prefsURL: config.url, deviceToken: deviceToken, deviceId: deviceId, deviceName: deviceName) { + Preferences.setCloudUserId(cloudUserId, for: uuid) + logger.info("my.openHAB registration succeeded with cloudUserId \(cloudUserId)") + } + logger.info("my.openHAB registration succeeded without cloudUserId") + } catch { + logger.error("my.openHAB registration failed \(error.localizedDescription)") + } + } + } + + func handleNotification(action: String?, cloudUserId: String?) { + guard let action else { return } + + logger.info("handleNotification cloudUserId: \(cloudUserId ?? "")") + if let cloudUserId, let targetHome = Preferences.storedHome(forCloudUserId: cloudUserId), Preferences.currentHomePreferences.remoteConnectionConfig.cloudUserId != cloudUserId { + // if we need to switch homes, disconnnect the tracking fist,and wait for the tracker to start again with the updated preferences + Task { + await NetworkTracker.shared.stopTracking() + logger.info("Switching to home \(targetHome.id)") + Preferences.switchActiveHome(to: targetHome.id) + await NetworkTracker.shared.waitForActiveConnection() + handleNotificationInternal(action) + } + return + } + handleNotificationInternal(action) + } + + private func handleNotificationInternal(_ action: String?) { + logger.info("handleNotificationInternal: \(action ?? "")") + + guard let action else { return } + + let cmd = action.split(separator: ":").dropFirst().joined(separator: ":") + + switch true { + case action.hasPrefix("ui"): + uiCommandAction(cmd) + case action.hasPrefix("command"): + sendCommandAction(cmd) + case action.hasPrefix("http"): + httpCommandAction(action) + case action.hasPrefix("app"): + appCommandAction(action) + case action.hasPrefix("rule"): + ruleCommandAction(action) + default: + return + } + } + + // Helper function to safely call the completion handler on the main thread + private func callCompletionHandler(_ completionHandler: (() -> Void)?) { + if let completionHandler { + DispatchQueue.main.async { + completionHandler() + } + } + } + + private func uiCommandAction(_ command: String) { + logger.info("navigateCommandAction: \(command)") + let regexPattern = /^(\/basicui\/app\\?.*|\/.*|.*)$/ + if let firstMatch = command.firstMatch(of: regexPattern) { + let path = String(firstMatch.1) + logger.info("navigateCommandAction path: \(path)") + if path.starts(with: "/basicui/app?") { + if currentView != sitemapViewController { + switchView(target: .sitemap("")) + } + if let urlComponents = URLComponents(string: path) { + let queryItems = urlComponents.queryItems + let sitemap = queryItems?.first { $0.name == "sitemap" }?.value + let subview = queryItems?.first { $0.name == "w" }?.value + if let sitemap { + Task { + await sitemapViewController.pushSitemap(name: sitemap, path: subview) + } + } + } + } else { + if currentView != webViewController { + switchView(target: .webview) + } + if path.starts(with: "/") { + // have the webview load this path itself + webViewController.loadWebView(force: true, path: path) + } else { + // have the mainUI handle the navigation + webViewController.navigateCommand(path) + } + } + } else { + logger.error("Invalid regex: \(command)") + } + } + + private func sendCommandAction(_ action: String) { + let components = action.split(separator: ":") + guard components.count == 2 else { + return + } + + let itemName = String(components[0]) + let itemCommand = String(components[1]) + Task { + do { + logger.info("Sending command") + try await NetworkTracker.shared.send(to: itemName, command: itemCommand) + } catch NetworkTrackerError.noActiveConnection { + displayErrorNotification("Could not find server") + } catch { + displayErrorNotification("Failed to establish a connection: \(error.localizedDescription)") + logger.error("Could not send data \(error.localizedDescription)") + } + } + } + + private func displayErrorNotification(_ message: String, completionHandler: (() -> Void)? = nil) { + let content = UNMutableNotificationContent() + content.title = "Could not send command" + content.body = message + content.sound = UNNotificationSound.default + + // Create the request + let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil) + + // Schedule the request with the notification center + UNUserNotificationCenter.current().add(request) { error in + if let error { + print("Error scheduling notification: \(error.localizedDescription)") + } + } + } + + private func httpCommandAction(_ command: String) { + if let url = URL(string: command) { + let vc = SFSafariViewController(url: url) + present(vc, animated: true) + } + } + + private func appCommandAction(_ command: String) { + let content = command.dropFirst(4) // Remove "app:" + let pairs = content.split(separator: ",") + for pair in pairs { + let keyValue = pair.split(separator: "=", maxSplits: 1) + guard keyValue.count == 2 else { continue } + if keyValue[0] == "ios" { + if let url = URL(string: String(keyValue[1])) { + logger.error("appCommandAction opening \(String(keyValue[0])) \(String(keyValue[1]))") + UIApplication.shared.open(url) + return + } + } + } + } + + private func ruleCommandAction(_ command: String) { + let components = command.split(separator: ":", maxSplits: 2) + + guard components.count == 3, components[0] == "rule" else { return } + + let uuid = String(components[1]) + let propertiesString = String(components[2]) + + let propertyPairs = propertiesString.split(separator: ",") + var properties: [String: String] = [:] + + for pair in propertyPairs { + let keyValue = pair.split(separator: "=", maxSplits: 1) + if keyValue.count == 2 { + let key = String(keyValue[0]) + let value = String(keyValue[1]) + properties[key] = value + } + } + Task { + do { + logger.error("Sending command") + try await NetworkTracker.shared.runNow(ruleUID: uuid, payload: properties) + logger.info("Request succeeded") + } catch let error as NetworkTrackerError { + displayErrorNotification("\(error.localizedDescription)") + } catch { + logger.error("Could not send data \(error.localizedDescription)") + displayErrorNotification("Request to server failed: \(error.localizedDescription)") + } + } + } + + func showSideMenu() { + logger.info("OpenHABRootViewController showSideMenu") + if let menu = SideMenuManager.default.rightMenuNavigationController { + // don't try and push an already visible menu less you crash the app + dismiss(animated: false) { + var topMostViewController: UIViewController? = + UIApplication.shared.connectedScenes.flatMap { ($0 as? UIWindowScene)?.windows ?? [] }.last { $0.isKeyWindow }?.rootViewController + + while let presentedViewController = topMostViewController?.presentedViewController { + topMostViewController = presentedViewController + } + topMostViewController?.present(menu, animated: true) + } + } + } + + private func addView(viewController: UIViewController) { + addChild(viewController) + view.addSubview(viewController.view) + viewController.view.frame = view.bounds + viewController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] + viewController.didMove(toParent: self) + } + + private func removeView(viewController: UIViewController) { + viewController.willMove(toParent: nil) + viewController.view.removeFromSuperview() + viewController.removeFromParent() + } + + private func switchView(target: TargetController) { + let targetView: OpenHABViewController + + switch target { + case .sitemap: + targetView = sitemapViewController + case .webview: + targetView = webViewController + default: + return + } + + if currentView != targetView { + if let currentView { + removeView(viewController: currentView) + } + addView(viewController: targetView) + currentView = targetView + + // Don't save our view in demo mode + if !Preferences.currentHomePreferences.demomode { + Preferences.modifyActiveHome { + $0.defaultView = currentView.viewName() + } + } + } else { + // if we hit the menu item again while on the view, trigger a reload + currentView.reloadView() + } + + // Make sure we reset any views that may be pushed + navigationController?.popToRootViewController(animated: true) + } + + private func switchToSavedView() { + if Preferences.currentHomePreferences.demomode { + switchView(target: .sitemap("")) + } else { + logger.info("OpenHABRootViewController switchToSavedView \(Preferences.currentHomePreferences.defaultView == "sitemap"?"sitemap": "web")") + switchView(target: Preferences.currentHomePreferences.defaultView == "sitemap" ? .sitemap("") : .webview) + } + } + + @MainActor + @objc func handleCertificateTrust(_ notification: Notification, message: String) { + guard let summary = notification.userInfo?["summary"] as? String, + let domain = notification.userInfo?["domain"] as? String, + let client = notification.object as? HTTPClient else { return } + let title = NSLocalizedString("ssl_certificate_warning", comment: "") + let message = String(format: NSLocalizedString(message, comment: ""), summary, domain) + DispatchQueue.main.async { + // Show alert to user + let alert = UIAlertController( + title: title, + message: message, + preferredStyle: .alert + ) + + alert.addAction(UIAlertAction(title: "Always", style: .default) { _ in + client.delegate.completeEvaluation(.permitAlways) + }) + + alert.addAction(UIAlertAction(title: "Once", style: .default) { _ in + client.delegate.completeEvaluation(.permitOnce) + }) + + alert.addAction(UIAlertAction(title: "Deny", style: .cancel) { _ in + client.delegate.completeEvaluation(.deny) + }) + + self.present(alert, animated: true) + } + } + + @MainActor + @objc + func handleCertificateTrust(summary: String, domain: String, delegate: HTTPClientDelegate, messageTemplateKey: String) { + let title = NSLocalizedString("ssl_certificate_warning", comment: "") + let message = String(format: NSLocalizedString(messageTemplateKey, comment: ""), summary, domain) + + let alert = UIAlertController( + title: title, + message: message, + preferredStyle: .alert + ) + + alert.addAction(UIAlertAction(title: "Always", style: .default) { _ in + delegate.completeEvaluation(.permitAlways) + }) + + alert.addAction(UIAlertAction(title: "Once", style: .default) { _ in + delegate.completeEvaluation(.permitOnce) + }) + + alert.addAction(UIAlertAction(title: "Deny", style: .cancel) { _ in + delegate.completeEvaluation(.deny) + }) + + present(alert, animated: true) + } +} + +// swiftlint:enable type_body_length + +// MARK: - UISideMenuNavigationControllerDelegate + +extension OpenHABRootViewController: SideMenuNavigationControllerDelegate { + nonisolated func sideMenuWillAppear(menu: SideMenuNavigationController, animated: Bool) { + logger.info("OpenHABRootViewController sideMenuWillAppear") + } +} + +// MARK: - ModalHandler + +extension OpenHABRootViewController: ModalHandler { + nonisolated func modalDismissed(to: TargetController) { + Task { @MainActor in + switch to { + case .sitemap: + switchView(target: to) + case .settings: + let hostingController = UIHostingController(rootView: SettingsView()) + navigationController?.pushViewController(hostingController, animated: true) + case .notifications: + let hostingController = UIHostingController(rootView: NotificationsView()) + navigationController?.pushViewController(hostingController, animated: true) + case .webview: + switchView(target: to) + case .browser: + break + case let .tile(urlString): + openTileURL(urlString) + case .homeSelection: + let hostingController = UIHostingController(rootView: HomeSelectionView()) + navigationController?.pushViewController(hostingController, animated: true) + } + } + } +} diff --git a/OsLogRewriter/OpenHABSitemapViewController.swift b/OsLogRewriter/OpenHABSitemapViewController.swift new file mode 100644 index 000000000..c272117a7 --- /dev/null +++ b/OsLogRewriter/OpenHABSitemapViewController.swift @@ -0,0 +1,927 @@ +// Copyright (c) 2010-2025 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 AVFoundation +import AVKit +import Combine +import Foundation +import Kingfisher +import OpenAPIRuntime +import OpenAPIURLSession +import OpenHABCore +import os.log +import SafariServices +import SFSafeSymbols +import SwiftMessages +import SwiftUI +import UIKit + +class OpenHABSitemapViewController: OpenHABViewController, UISearchControllerDelegate { + var pageUrl = "" + private var iconType: IconType = .png + private var openHABRootUrl = "" + + private var activeConnectionInfo: ConnectionInfo? + + private var defaultSitemap = "" + private var pageId = "" + private var idleOff = false + private var sitemaps: [OpenHABSitemap] = [] + private var currentPage: OpenHABPage? + private var pageNetworkStatus: NetworkStatus? + private var pageNetworkStatusAvailable = false + private var refreshControl: UIRefreshControl? + private var filteredPage: OpenHABPage? + private let searchController = UISearchController(searchResultsController: nil) + private var isUserInteracting = false + private var isWaitingToReload = false + // Properties in your view controller: + + private var pageHandlingTask: Task? + + private var pageLoader: PageLoader? + + private let logger = Logger(subsystem: "org.openhab.app", category: "OpenHABSitemapViewController") + + var relevantPage: OpenHABPage? { + if isFiltering { + filteredPage + } else { + currentPage + } + } + + var sitemapViewController: OpenHABSitemapViewController? + + // MARK: - Private instance methods + + var searchBarIsEmpty: Bool { + // Returns true if the text is empty or nil + searchController.searchBar.text?.isEmpty ?? true + } + + var isFiltering: Bool { + searchController.isActive && !searchBarIsEmpty + } + + private var openAPIService: OpenAPIService? + + @IBOutlet private var widgetTableView: UITableView! + + override func viewDidLoad() { + super.viewDidLoad() + logger.info("OpenHABSitemapViewController viewDidLoad") + + registerTableViewCells() + configureTableView() + widgetTableView.tableFooterView = UIView() + + refreshControl = UIRefreshControl() + refreshControl?.addTarget(self, action: #selector(handleRefresh(_:)), for: .valueChanged) + widgetTableView.refreshControl = refreshControl + + // Setup search controller + searchController.searchResultsUpdater = self + searchController.obscuresBackgroundDuringPresentation = false + searchController.searchBar.autocapitalizationType = .none + searchController.searchBar.delegate = self + searchController.delegate = self + searchController.searchBar.placeholder = NSLocalizedString("search_items", comment: "") + definesPresentationContext = true + + // Assign to navigation item (must be in navigation stack) + navigationItem.searchController = searchController + navigationItem.hidesSearchBarWhenScrolling = false + + // Setup active connection + guard let config = activeConnectionInfo?.configuration else { return } + do { + openAPIService = try OpenAPIService(connectionConfiguration: config) + } catch { + logger.error("Failed to create OpenAPIService: \(error.localizedDescription)") + } + + if let service = openAPIService { + pageLoader = PageLoader(service: service, pageId: "", defaultSitemap: "") + } + + #if DEBUG + widgetTableView.accessibilityIdentifier = "OpenHABSitemapViewControllerWidgetTableView" + #endif + } + + override func viewDidAppear(_ animated: Bool) { + logger.info("OpenHABSitemapViewController viewDidAppear") + super.viewDidAppear(animated) + + if parent?.navigationItem.searchController !== searchController { + parent?.navigationItem.searchController = searchController + parent?.navigationItem.hidesSearchBarWhenScrolling = true + } + } + + override func viewWillAppear(_ animated: Bool) { + logger.info("OpenHABSitemapViewController viewWillAppear") + super.viewWillAppear(animated) + + navigationController?.navigationBar.prefersLargeTitles = true + + // Load settings into local properties + loadSettings() + // Disable idle timeout if configured in settings + if idleOff { + UIApplication.shared.isIdleTimerDisabled = true + } + + // if pageUrl is empty, it means we are the first opened OpenHABSitemapViewController + if pageUrl.isEmpty { + sitemapViewController = self +// if navigationController?.viewControllers.first == self { + // This is the first sitemap opened + if currentPage != nil { + currentPage?.widgets = [] + widgetTableView.reloadData() + } + logger.info("OpenHABSitemapViewController pageUrl is empty, this is first launch") + } else { + if !pageNetworkStatusChanged() || !pageId.isEmpty { + // swiftformat:disable:next redundantSelf + logger.info("OpenHABSitemapViewController pageUrl \(self.pageUrl)") + startPageHandling() + } else { + logger.info("OpenHABSitemapViewController network status changed while it was not appearing") + restart() + } + } + + startTrackNetworkStatus() + startWatchingActiveServer() + + ImageDownloader.default.authenticationChallengeResponder = self + } + + override func viewWillDisappear(_ animated: Bool) { + logger.info("OpenHABSitemapViewController viewWillDisappear") + + trackerCancellables.removeAll() + stopAllTasks() + + super.viewWillDisappear(animated) + + if #unavailable(iOS 13.0) { + if animated, !searchController.isActive, !searchController.isEditing, navigationController.map({ $0.viewControllers.last != self }) ?? false, + let searchBarSuperview = searchController.searchBar.superview, + let searchBarHeightConstraint = searchBarSuperview.constraints.first(where: { + $0.firstAttribute == .height + && $0.secondItem == nil + && $0.secondAttribute == .notAnAttribute + && $0.constant > 0 + }) { + UIView.performWithoutAnimation { + searchBarHeightConstraint.constant = 0 + searchBarSuperview.superview?.layoutIfNeeded() + } + } + } + parent?.navigationItem.searchController = nil + } + + @objc + override func didEnterBackground(_ notification: Notification?) { + super.didEnterBackground(notification) + logger.info("OpenHABSitemapViewController didEnterBackground") + } + + @objc + override func didBecomeActive(_ notification: Notification?) { + super.didBecomeActive(notification) + logger.info("OpenHABSitemapViewController didBecomeActive") + if isViewLoaded, view.window != nil, !pageUrl.isEmpty { + if !pageNetworkStatusChanged() { + logger.info("OpenHABSitemapViewController isViewLoaded, restarting network activity") + startPageHandling() + } else { + logger.info("OpenHABSitemapViewController network status changed while it was inactive") + restart() + } + } + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + widgetTableView.reloadData() + } + + private func startTrackNetworkStatus() { + let task = Task { + for await status in NetworkTracker.shared.$status.values { + logger.info("OpenHABViewController tracker status \(status.rawValue)") + await MainActor.run { + switch status { + case .connecting: + self.showPopupMessage(seconds: 1.5, title: NSLocalizedString("connecting", comment: ""), message: "", theme: .info) + case .notConnected: + logger.info("Tracking error") +// self.showPopupMessage(seconds: 60, title: NSLocalizedString("error", comment: ""), message: NSLocalizedString("network_not_available", comment: ""), theme: .error) + case .connected, .allConnected, .someConnected: + self.hidePopupMessages() + } + } + } + } + activeTasks.insert(task) + } + + func startWatchingActiveServer() { + let task = Task { + for await activeConnection in NetworkTracker.shared.$activeConnection.values { + if let activeConnection { + await MainActor.run { + logger.info("OpenHABSitemapViewController tracker URL \(activeConnection.configuration.url)") + self.openHABRootUrl = activeConnection.configuration.url + self.activeConnectionInfo = activeConnection + self.selectSitemap() + } + break + } + } + } + activeTasks.insert(task) // Store the task for cancellation + } + + func stopAllTasks() { + for task in activeTasks { + task.cancel() + } + activeTasks.removeAll() + pageHandlingTask?.cancel() + pageHandlingTask = nil + } + + override func reloadView() { + defaultSitemap = Preferences.currentHomePreferences.defaultSitemap + logger.debug("Reload view") + selectSitemap() + } + + override func viewName() -> String { + "sitemap" + } +} + +extension OpenHABSitemapViewController: GenericUITableViewCellTouchEventDelegate { + func touchDown() { + isUserInteracting = true + } + + func touchUp() { + isUserInteracting = false + if isWaitingToReload { + widgetTableView.reloadData() + refreshControl?.endRefreshing() + } + isWaitingToReload = false + } +} + +extension OpenHABSitemapViewController { + func configureTableView() { + widgetTableView.dataSource = self + widgetTableView.delegate = self + } + + func registerTableViewCells() { + widgetTableView.register(cellType: MapViewTableViewCell.self) + widgetTableView.register(cellType: NewImageUITableViewCell.self) + widgetTableView.register(cellType: VideoUITableViewCell.self) + } + + @objc + func handleRefresh(_ refreshControl: UIRefreshControl?) { + startPageHandling() + widgetTableView.reloadData() + widgetTableView.layoutIfNeeded() + } + + func restart() { + if sitemapViewController == self { + logger.info("I am a rootViewController!") + + } else { + sitemapViewController?.pageUrl = "" + navigationController?.popToRootViewController(animated: true) + } + } + + func relevantWidget(indexPath: IndexPath) -> OpenHABWidget? { + relevantPage?.widgets[safe: indexPath.row] + } + + public func updateWidgetTableView() { + UIView.performWithoutAnimation { + widgetTableView.beginUpdates() + widgetTableView.endUpdates() + } + } + + func updateUI(with page: OpenHABPage) { + currentPage = page + + if isFiltering { + filterContentForSearchText(searchController.searchBar.text) + } + + currentPage?.sendCommand = { [weak self] item, command in + self?.sendCommand(item, commandToSend: command) + } + + // isUserInteracting fixes https://github.com/openhab/openhab-ios/issues/646 where reloading while the user is interacting can have unintended consequences + if !isUserInteracting { + widgetTableView.reloadData() + refreshControl?.endRefreshing() + } else { + isWaitingToReload = true + } + // on initial load ??? refreshControl?.endRefreshing() + + widgetTableView.reloadData() + parent?.navigationItem.title = currentPage?.title.components(separatedBy: "[")[0] + } + + // Select sitemap + func selectSitemap() { + Task { + do { + guard let activeConnection = NetworkTracker.shared.activeConnection else { + throw OpenHABSitemapError.noActiveConnection + } + logger.debug("Running selectSitemap for URL: \(activeConnection.configuration.url)") + + openAPIService = try OpenAPIService(connectionConfiguration: activeConnection.configuration) + sitemaps = try await openAPIService?.openHABSitemaps() ?? [] + + guard let openAPIService else { + logger.error("Failed to load openAPIService") + return + } + await pageLoader?.updateAPIService(newService: openAPIService) + + switch sitemaps.count { + case 2...: + if !self.defaultSitemap.isEmpty { + if let sitemapToOpen = sitemap(byName: self.defaultSitemap) { + if self.currentPage?.pageId != sitemapToOpen.name { + self.currentPage?.widgets.removeAll() // NOTE: remove all widgets to ensure cells get invalidated + } + pageUrl = sitemapToOpen.homepageLink + startPageHandling() + } else { + showSideMenu() + } + } else { + showSideMenu() + } + case 1: + pageUrl = sitemaps[0].homepageLink + startPageHandling() + case ...0: + showPopupMessage(seconds: 5, title: NSLocalizedString("warning", comment: ""), message: NSLocalizedString("empty_sitemap", comment: ""), theme: .warning) + showSideMenu() + default: break + } + widgetTableView.reloadData() + } catch _ as OpenAPIServiceError { + logger.debug("OpenAPIService Error on OpenHABSitemapViewController") + } catch let error as OpenHABSitemapError { + logger.error("OpenHABSitemap Error: \(error.localizedDescription)") + DispatchQueue.main.async { + self.showPopupMessage( + seconds: 5, + title: NSLocalizedString("error", comment: ""), + message: error.localizedDescription, + theme: .error + ) + } + } catch { + logger.error("\(error.localizedDescription)") + DispatchQueue.main.async { + if let urlError = error as? URLError, urlError.code == .clientCertificateRejected { + self.showPopupMessage( + seconds: 5, + title: NSLocalizedString("error", comment: ""), + message: NSLocalizedString("ssl_certificate_error", comment: ""), + theme: .error + ) + } else { + self.showPopupMessage( + seconds: 5, + title: NSLocalizedString("error", comment: ""), + message: error.localizedDescription, + theme: .error + ) + } + } + } + } + } + + // This is mainly used for navigating to a specific sitemap and path from notifications + func pushSitemap(name: String, path: String?) async { + guard let activeConnection = await NetworkTracker.shared.waitForActiveConnection() else { + logger.error("pushSitemap: No active connection available") + return + } + + logger.info("pushSitemap: pushing page") + + guard let baseUrl = URL(string: activeConnection.configuration.url) else { + logger.error("pushSitemap: Invalid base URL") + return + } + + var url = baseUrl.appendingPathComponent("rest") + .appendingPathComponent("sitemaps") + .appendingPathComponent(name) + + if let subpath = path { + url.appendPathComponent(subpath) + } + + guard let newViewController = storyboard?.instantiateViewController(withIdentifier: "OpenHABPageViewController") as? OpenHABSitemapViewController else { + logger.error("pushSitemap: Failed to instantiate OpenHABSitemapViewController") + return + } + + newViewController.pageUrl = url.absoluteString + newViewController.openHABRootUrl = activeConnection.configuration.url + navigationController?.pushViewController(newViewController, animated: true) + } + + func startPageHandling() { + pageHandlingTask?.cancel() + + guard !pageUrl.isEmpty else { + logger.error("startPageHandling: Cannot run with empty pageUrl") + return + } + + logger.info("🚀 Starting page load and long polling flow...") + + pageHandlingTask = Task { + do { + // Initial page load + + guard let configuration = NetworkTracker.shared.activeConnection?.configuration else { + throw NetworkTrackerError.noActiveConnection + } + + if openAPIService == nil { + openAPIService = try OpenAPIService(connectionConfiguration: configuration) + } + + let initialPage = try await openAPIService?.pollDataForPage( + sitemapname: defaultSitemap, + pageId: pageId, + longPolling: false + ) + + // Alternative 2 to be tested. + // await pageLoader?.updatePageConfig(newPageId: pageId, newSitemap: defaultSitemap) + // guard let page = try await pageLoader?.fetchPage(longPolling: true) else { return } + // + try Task.checkCancellation() + if let page = initialPage { + await MainActor.run { + self.updateUI(with: page) + } + } + + // Start long polling loop + while !Task.isCancelled { + let page = try await openAPIService?.pollDataForPage( + sitemapname: defaultSitemap, + pageId: pageId, + longPolling: true + ) + try Task.checkCancellation() + + if let page { + await MainActor.run { + self.updateUI(with: page) + } + } + } + } catch is CancellationError { + logger.info("🔁 pageHandlingTask was cancelled") + } catch let error as DecodingError { + logger.error("DecodingError \(error.localizedDescription)") + } catch let error as ClientError { + if let urlError = error.underlyingError as? URLError { + switch urlError.code { + case .cancelled: + logger.info("Task was cancelled - URLError code: .cancelled") + case .timedOut: + logger.info("Task timed out - URLError code: .timedOut") + default: + logger.info("URLError: \(urlError.localizedDescription)") + } + } else { + logger.error("\(error.localizedDescription)") + } + } catch let openAPIError as OpenAPIServiceError { + logger.info("On pageHandling \(openAPIError)") + } catch { + logger.error("❌ pageHandlingTask error: \(error.localizedDescription)") + await MainActor.run { + self.showPopupMessage( + seconds: 5, + title: NSLocalizedString("error", comment: ""), + message: error.localizedDescription, + theme: .error + ) + } + } + } + } + + // load settings into local properties + func loadSettings() { + defaultSitemap = Preferences.currentHomePreferences.defaultSitemap + idleOff = Preferences.idleOff + iconType = IconType(rawValue: Preferences.currentHomePreferences.iconType) ?? .png + #if DEBUG + // always use demo sitemap for UITest + if ProcessInfo.processInfo.environment["UITest"] != nil { + defaultSitemap = "demo" + iconType = .png + } + #endif + } + + // Find and return sitemap by it's name if any + func sitemap(byName sitemapName: String?) -> OpenHABSitemap? { + for sitemap in sitemaps where sitemap.name == sitemapName { + return sitemap + } + return nil + } + + @discardableResult + func pageNetworkStatusChanged() -> Bool { + logger.info("OpenHABSitemapViewController pageNetworkStatusChange") + + guard !pageUrl.isEmpty else { return false } + + let currentStatus = NetworkTracker.shared.status + + // First run + if !pageNetworkStatusAvailable { + pageNetworkStatus = currentStatus + pageNetworkStatusAvailable = true + return false + } + + if pageNetworkStatus == currentStatus { + return false + } else { + pageNetworkStatus = currentStatus + return true + } + } + + func filterContentForSearchText(_ searchText: String?, scope: String = "All") { + guard let searchText else { return } + + filteredPage = currentPage?.filter { + $0.label.lowercased().contains(searchText.lowercased()) && $0.type != .frame + } + filteredPage?.sendCommand = { [weak self] item, command in + self?.sendCommand(item, commandToSend: command) + } + widgetTableView.reloadData() + } + + func sendCommand(_ item: OpenHABItem?, commandToSend command: String?) { + if let item, let command { + sendCommand(itemname: item.name, command: command) + } + } + + func sendCommand(itemname: String, command: String) { + Task { try await openAPIService?.sendItemCommand(itemname: itemname, command: command) } + } +} + +// MARK: - UISearchResultsUpdating + +extension OpenHABSitemapViewController: UISearchResultsUpdating { + func updateSearchResults(for searchController: UISearchController) { + logger.info("Search updated: \(searchController.searchBar.text ?? "")") + filterContentForSearchText(searchController.searchBar.text) + } +} + +// MARK: - UISearchBarDelegate + +extension OpenHABSitemapViewController: UISearchBarDelegate { + func searchBarSearchButtonClicked(_ searchBar: UISearchBar) { + searchBar.resignFirstResponder() + } +} + +// MARK: - ColorPickerCellDelegate + +extension OpenHABSitemapViewController: @preconcurrency ColorPickerCellDelegate { + func didPressColorButton(_ cell: ColorPickerCell?) { + let colorPickerViewController = storyboard?.instantiateViewController(withIdentifier: "ColorPickerViewController") as? ColorPickerViewController + if let cell { + let widget = relevantPage?.widgets[widgetTableView.indexPath(for: cell)?.row ?? 0] + colorPickerViewController?.title = widget?.labelText + colorPickerViewController?.widget = widget + } + if let colorPickerViewController { + navigationController?.pushViewController(colorPickerViewController, animated: true) + } + } +} + +// MARK: - UITableViewDelegate, UITableViewDataSource + +extension OpenHABSitemapViewController: UITableViewDelegate, UITableViewDataSource { + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + relevantPage?.widgets.count ?? 0 + } + + func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { + 44.0 + } + + func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + let widget: OpenHABWidget? = relevantPage?.widgets[indexPath.row] + switch widget?.type { + case .frame: + return widget?.label.count ?? 0 > 0 ? 35.0 : 0 + case .image, .chart, .video: + return UITableView.automaticDimension + case .webview, .mapview: + if let height = widget?.height { + // calculate webview/mapview height and return it. Limited to UIScreen.main.bounds.height + let heightValue = height * 44 + logger.info("Webview/Mapview height would be %g") + return min(UIScreen.main.bounds.height, CGFloat(heightValue)) + } else { + // return default height for webview/mapview as 8 rows + return 44.0 * 8 + } + default: return 44.0 + } + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + guard let widget: OpenHABWidget = relevantWidget(indexPath: indexPath) else { + // this should never be the case + let cell = tableView.dequeueReusableCell(for: indexPath) as GenericUITableViewCell + cell.displayWidget() + cell.touchEventDelegate = self + cell.separatorInset = UIEdgeInsets(top: 0, left: 60, bottom: 0, right: 0) + return cell + } + + let provider = WidgetCellFactory.provider(for: widget) + let cell = provider.dequeue(from: tableView, at: indexPath) + provider.configure(cell: cell, for: widget, controller: self) + + var iconColor = widget.iconColor + if iconColor.isEmpty, traitCollection.userInterfaceStyle == .dark { + iconColor = "white" + } + // No icon will be displazed for cells that conform to NoIconDisplayableCell protocol + if !(cell is any NoIconDisplayableCell) { + if !widget.icon.isEmpty { + if let urlc = Endpoint.icon( + rootUrl: openHABRootUrl, + version: NetworkTracker.shared.activeConnection?.version ?? 2, + icon: widget.icon, + state: widget.iconState(), + iconType: iconType, + iconColor: iconColor + ).url { + var imageRequest = URLRequest(url: urlc) + imageRequest.timeoutInterval = 10.0 + cell.imageView?.kf.setImage( + with: KF.ImageResource(downloadURL: urlc, cacheKey: urlc.path + (urlc.query ?? "")), + placeholder: nil, + options: [.processor(OpenHABImageProcessor())] + ) { result in + switch result { + case .success: + DispatchQueue.main.async { + cell.setNeedsLayout() + } + case let .failure(error): + self.logger.error("Image loading failed for widget \(widget.label) : \(error.localizedDescription)") + } + } + } + } + } + + if cell is FrameUITableViewCell { + cell.backgroundColor = .ohSystemGroupedBackground + } else { + cell.backgroundColor = .ohSecondarySystemGroupedBackground + } + + if let cell = cell as? GenericUITableViewCell { + cell.widget = widget + cell.displayWidget() + cell.touchEventDelegate = self + } + + // Check if this is not the last row in the widgets list + if indexPath.row < (relevantPage?.widgets.count ?? 1) - 1 { + let nextWidget: OpenHABWidget? = relevantPage?.widgets[indexPath.row + 1] + if let type = nextWidget?.type, type.isAny(of: .frame, .image, .video, .webview, .chart) { + cell.separatorInset = UIEdgeInsets.zero + } else if !(widget.type == .frame) { + cell.separatorInset = UIEdgeInsets(top: 0, left: 60, bottom: 0, right: 0) + } + } + + return cell + } + + func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { + // Prevent the cell from inheriting the Table View's margin settings + cell.preservesSuperviewLayoutMargins = false + + // Explictly set your cell's layout margins + cell.layoutMargins = .zero + + (cell as? VideoUITableViewCell)?.play() + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + if let index = widgetTableView.indexPathForSelectedRow { + widgetTableView.deselectRow(at: index, animated: false) + } + + guard let widget: OpenHABWidget = relevantWidget(indexPath: indexPath) else { return } + + if let linkedPage = widget.linkedPage { + logger.info("Selected linked page: \(linkedPage.link)") + stopAllTasks() + let newViewController = (storyboard?.instantiateViewController(withIdentifier: "OpenHABPageViewController") as? OpenHABSitemapViewController)! + newViewController.title = linkedPage.title.components(separatedBy: "[")[0] + newViewController.pageId = linkedPage.pageId + newViewController.pageUrl = linkedPage.link + newViewController.openHABRootUrl = openHABRootUrl + navigationController?.pushViewController(newViewController, animated: true) + } else if widget.type == .selection { + let selectionItemState = widget.item?.state + logger.info("Selected selection widget in status: \(selectionItemState ?? "unknown")") + let hostingController = UIHostingController( + rootView: SelectionView( + mappings: widget.mappingsOrItemOptions, + selectionItemState: selectionItemState + ) { selectedMappingIndex in + let selectedMapping: OpenHABWidgetMapping = widget.mappingsOrItemOptions[selectedMappingIndex] + self.sendCommand(widget.item, commandToSend: selectedMapping.command) + } + ) + hostingController.title = widget.labelText + navigationController?.pushViewController(hostingController, animated: true) + } else if widget.type == .input { + let hint = widget.inputHint + let textExtractor: ((UIAlertController) -> String?)? + let textFieldAdder: ((UITextField) -> Void)? + + switch hint { + case .date, .time, .datetime: + // value setting is handeled by the cell itself + textExtractor = nil + textFieldAdder = nil + case .number: + textFieldAdder = { textField in + textField.text = widget.state + textField.clearButtonMode = .always + textField.delegate = self + textField.keyboardType = .numbersAndPunctuation + } + // replace expected decimal separator + textExtractor = { $0.textFields?[0].text?.replacingOccurrences(of: NSLocale.current.decimalSeparator ?? "", with: ".") } + case .text: + textFieldAdder = { textField in + textField.text = widget.state + textField.clearButtonMode = .always + textField.keyboardType = .default + } + textExtractor = { $0.textFields?[0].text } + case .unknown: + textExtractor = nil + textFieldAdder = nil + } + guard let textExtractor, let textFieldAdder else { + return + } + + // TODO: proper texts instead of hardcoded values + let alert = UIAlertController( + title: "Enter new value", + message: "Current value for \(widget.label) is \(widget.state)", + preferredStyle: .alert + ) + alert.addTextField(configurationHandler: textFieldAdder) + let sendAction = UIAlertAction(title: "Set value", style: .destructive) { [weak self] _ in + self?.sendCommand(widget.item, commandToSend: textExtractor(alert)) + } + alert.addAction(sendAction) + alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) + alert.preferredAction = sendAction + present(alert, animated: true) + } + } + + func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { + if let cell = cell as? any GenericCellCacheProtocol { + // invalidate cache only if the cell is not visible or the datasource is empty (eg. sitemap change) + if tableView.indexPathsForVisibleRows == nil || !tableView.indexPathsForVisibleRows!.contains(indexPath) || currentPage == nil || currentPage!.widgets.isEmpty { + cell.invalidateCache() + } + } + } + + func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { + if let cell = tableView.cellForRow(at: indexPath) as? GenericUITableViewCell, cell.widget.type == .text, let text = cell.widget?.labelValue ?? cell.widget?.labelText, !text.isEmpty { + return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { _ in + let copy = UIAction(title: NSLocalizedString("copy_label", comment: ""), image: UIImage(systemSymbol: .squareAndArrowUp)) { _ in + UIPasteboard.general.string = text + } + + return UIMenu(title: "", children: [copy]) + } + } + + return nil + } +} + +extension OpenHABSitemapViewController: UITextFieldDelegate { + func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { + let decimalSeparator = NSLocale.current.decimalSeparator ?? "" + let oldString = (textField.text ?? "") + let wholeNumberRegex = /^-?[0-9]*$/ + + // check for deletion + return string.isEmpty + // check for new negative sign + || ( + !string.starts(with: "-") // new string does not add negative sign + || range.location == 0 // new string adds negative sign to beginning + && ( + !oldString.starts(with: "-") // old string does not contain negative sign + || range.length > 0 + ) + ) // new string replaces negative sign in old string + // check for old negative sign + && ( + oldString.isEmpty + || !oldString.starts(with: "-") // old string does not start with negative sign + || range.location > 0 // new string starts after negative sign in old string + || range.length > 0 + ) // new string replaces negative sign in old string + // check for decimal signs + && ( + string.firstRange(of: wholeNumberRegex) != nil // new string is whole number + || ( + string.replacing(decimalSeparator, with: "", maxReplacements: 1) + .firstRange(of: wholeNumberRegex) != nil // new string is valid decimal number + && !(oldString as NSString).replacingCharacters(in: range, with: "").contains(decimalSeparator) + ) + ) // old string without replaced range not yet contains decimal separator + } +} + +// MARK: Kingfisher authentication with NSURLCredential + +extension OpenHABSitemapViewController: AuthenticationChallengeResponsible { + func downloader(_ downloader: ImageDownloader, + didReceive challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { + await onReceiveSessionChallenge(with: challenge) + } + + func downloader(_ downloader: ImageDownloader, + task: URLSessionTask, + didReceive challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { + onReceiveSessionTaskChallenge(with: challenge) + } +} diff --git a/OsLogRewriter/Package.resolved b/OsLogRewriter/Package.resolved new file mode 100644 index 000000000..a885294c3 --- /dev/null +++ b/OsLogRewriter/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "380ae23f2bef266c2da40f8c25852f85a24515b623df3ec53d26d2e1e888d325", + "pins" : [ + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-syntax.git", + "state" : { + "revision" : "64889f0c732f210a935a0ad7cda38f77f876262d", + "version" : "509.1.1" + } + } + ], + "version" : 3 +} diff --git a/OsLogRewriter/Package.swift b/OsLogRewriter/Package.swift new file mode 100644 index 000000000..6d43f4635 --- /dev/null +++ b/OsLogRewriter/Package.swift @@ -0,0 +1,33 @@ +// swift-tools-version:6.1 +import PackageDescription + +let package = Package( + name: "OsLogRewriter", + platforms: [.macOS(.v15)], + dependencies: [ + .package(url: "https://github.com/apple/swift-syntax.git", from: "509.0.0") + ], + targets: [ + .executableTarget( + name: "OsLogRewriter", + dependencies: [ + "OsLogRewriterLib" + ] + ), + .target( + name: "OsLogRewriterLib", + dependencies: [ + .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), + .product(name: "SwiftSyntax", package: "swift-syntax") + ] + ), + .testTarget( + name: "OsLogRewriterTests", + dependencies: [ + "OsLogRewriterLib", + .product(name: "SwiftSyntax", package: "swift-syntax"), + .product(name: "SwiftParser", package: "swift-syntax") + ] + ) + ] +) diff --git a/OsLogRewriter/Preferences.swift b/OsLogRewriter/Preferences.swift new file mode 100644 index 000000000..80af3f999 --- /dev/null +++ b/OsLogRewriter/Preferences.swift @@ -0,0 +1,415 @@ +// Copyright (c) 2010-2025 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 + +@preconcurrency import Combine +import os.log +import UIKit + +@propertyWrapper @MainActor +public struct UserDefault { + private let key: String + private let defaultValue: T + private let isHomeProperty: Bool + private let subject: CurrentValueSubject + + public var wrappedValue: T { + get { + Preferences.getPreference(key: key, defaultValue: defaultValue, encoder: { $0 }, decoder: { $0 as? T }) + } + set { + Preferences.preferenceChanged(newValue: newValue, key: key, isHomeProperty: isHomeProperty, subject: subject) { $0 } + } + } + + public var projectedValue: AnyPublisher { + subject.eraseToAnyPublisher() + } + + public init(_ key: String, defaultValue: T, isHomeProperty: Bool = false) { + self.key = key + self.defaultValue = defaultValue + self.isHomeProperty = isHomeProperty + let currentValue = Preferences.getPreference(key: key, defaultValue: defaultValue, encoder: { $0 }, decoder: { $0 as? T }) + subject = CurrentValueSubject(currentValue) + } +} + +@propertyWrapper @MainActor +public struct UserDefaultObject { + private let key: String + private let defaultValue: T + private let isHomeProperty: Bool + private let subject: CurrentValueSubject + + private let objectDecoder: (Any) -> (T?) = { + guard let data = $0 as? Data else { + return nil + } + return try? JSONDecoder().decode(T.self, from: data) + } + + private let objectEncoder: (T) -> (any Sendable)? = { try? JSONEncoder().encode($0) } + + public var wrappedValue: T { + get { + Preferences.getPreference(key: key, defaultValue: defaultValue, encoder: objectEncoder, decoder: objectDecoder) + } + set { + Preferences.preferenceChanged(newValue: newValue, key: key, isHomeProperty: isHomeProperty, subject: subject, converter: objectEncoder) + } + } + + public var projectedValue: AnyPublisher { + subject.eraseToAnyPublisher() + } + + init(_ key: String, defaultValue: T, isHomeProperty: Bool = false) { + self.key = key + self.defaultValue = defaultValue + self.isHomeProperty = isHomeProperty + + // Combine publication + let currentValue = Preferences.getPreference(key: key, defaultValue: defaultValue, encoder: objectEncoder, decoder: objectDecoder) + subject = CurrentValueSubject(currentValue) + } +} + +public struct HomePreferences: Codable, Sendable, Equatable { + public let id: UUID + public var defaultView = "web" + public var demomode = true + public var realTimeSliders = false + public var iconType = 0 + public var defaultSitemap = "demo" + public var sortSitemapsBy = 0 + public var defaultMainUIPath = "" + public var alwaysAllowWebRTC = false + public var sitemapForWatch = "watch" + public var localConnectionConfig: ConnectionConfiguration = .localDefault + public var remoteConnectionConfig: ConnectionConfiguration = .remoteDefault + public var sitemapForWatchLabel = "watch" + public var homeName = "Home" + + fileprivate init(id: UUID) { + self.id = id + } +} + +@MainActor +public enum Preferences { + /// the currently applied settings set from storedHomes + @UserDefaultObject("currentHomePreferences", defaultValue: HomePreferences(id: Preferences.activeHomeId)) + public private(set) static var currentHomePreferences: HomePreferences + + @UserDefault("sendCrashReports", defaultValue: false) + public static var sendCrashReports: Bool + + @UserDefault("idleOff", defaultValue: false) + public static var idleOff: Bool + + @UserDefault("currentWebViewPath", defaultValue: "") + public static var currentWebViewPath: String + + /// settings for different homes + @UserDefaultObject("storedHomes", defaultValue: [:]) + public private(set) static var storedHomes: [UUID: HomePreferences] + + /// the currently applied settings set from storedHomes + @UserDefaultObject("activeHomeId", defaultValue: UUID()) + private static var activeHomeId: UUID + + @UserDefault("didMigrateToSharedDefaults", defaultValue: false) + private static var didMigrateToSharedDefaults: Bool + + @UserDefault("didMigrateToMultipleHomes", defaultValue: false) + private static var didMigrateToMultipleHomes: Bool + + private static var loadingStoredHome = false +} + +// MARK: Retrieving preference from user defaults, reacting to preference change + +extension Preferences { + static let sharedDefaults = UserDefaults(suiteName: "group.org.openhab.app")! + + fileprivate static func getPreference(key: String, defaultValue: T, encoder: (T) -> (some Sendable)?, decoder: (Any?) -> T?) -> T { + let preferenceValue = sharedDefaults.object(forKey: key) + if let preferenceConverted = decoder(preferenceValue) { + logger.debug("Preference value \(key) is \(preferenceConverted)") + return preferenceConverted + } else { + if let preferenceValue { + logger.error("Preference value \(key) was \"\(preferenceValue)\" but did not conform to \(T.self). Replace with default value.") + } else { + logger.info("Preference value \(key) was set for the first time. Using default value.") + } + let fallback = defaultValue + sharedDefaults.set(encoder(fallback), forKey: key) + return fallback + } + } + + fileprivate static func preferenceChanged(newValue: T, key: String, isHomeProperty: Bool, subject: CurrentValueSubject, sanitize: (T) -> (T?) = { $0 }, converter: (T) -> (some Sendable)?) { + guard let sanitized = sanitize(newValue) else { + logger.debug("Preference \(key) new value \"\(newValue)\" could not be sanitized, will be ignored") + return + } + let convertedValue = converter(sanitized) + guard convertedValue != nil else { + logger.debug("Preference \(key) conversion of new value \(sanitized) failed, do not store.") + return + } + logger.debug("Preference \(key) will be changed to value \(newValue)") + sharedDefaults.set(convertedValue, forKey: key) + + DispatchQueue.main.async { [subject] in + subject.send(sanitized) + } + } +} + +// MARK: Multiple homes + +public extension Preferences { + static func listStoredHomes() -> [UUID] { + let preferenceIds = storedHomes + .sorted { e1, e2 in + e1.value.homeName <= e2.value.homeName + } + .map(\.key) + return preferenceIds + } + + static func createAndLoadNewStoredSettings(homeName: String) { + activeHomeId = UUID() + var newHome = HomePreferences(id: activeHomeId) + newHome.homeName = homeName + loadHomePreferences(newHome) + } + + static func renameHome(_ homeId: UUID, newHomeName: String) { + if homeId == activeHomeId { + modifyActiveHome { + $0.homeName = newHomeName + } + } else { + var stored = storedHomes + stored[homeId]?.homeName = newHomeName + storedHomes = stored + } + } + + /// helper function for when we update the remote connection cloudUserId for notifications + static func setCloudUserId(_ cloudUserId: String?, for homeId: UUID) { + if homeId == activeHomeId { + modifyActiveHome { homePreferences in + homePreferences.remoteConnectionConfig.cloudUserId = cloudUserId + } + } else { + var stored = storedHomes + var home = stored[homeId] + home?.remoteConnectionConfig.cloudUserId = cloudUserId + stored[homeId] = home + storedHomes = stored + } + } + + static func deleteStoredHome(_ homeId: UUID) { + guard homeId != activeHomeId else { + // cannot remove current home + return + } + var stored = storedHomes + stored.removeValue(forKey: homeId) + storedHomes = stored + } + + static func switchActiveHome(to homeId: UUID) { + guard let storedHome = storedHomes[homeId] else { + // we have not stored our settings in that list yet + return + } + + activeHomeId = homeId + + loadHomePreferences(storedHome) + } + + private static func initializeStoredHomes() { + if storedHomes.isEmpty { + // first there might be no stored preferences, if no preference was changed since the update + storeActiveHome() + } + } + + private static func loadHomePreferences(_ preferences: HomePreferences) { + loadingStoredHome = true + Preferences.currentHomePreferences = preferences + loadingStoredHome = false + storeActiveHome() // store home settings in case they were not yet there + } + + private static func storeActiveHome() { + var all = storedHomes + let homeId = Preferences.activeHomeId + all[homeId] = Preferences.currentHomePreferences + storedHomes = all + logger.debug("Stored preferences for current home \(homeId.uuidString)") + } + + static func modifyActiveHome(modificationFunction: (inout HomePreferences) -> Void) { + var homePreferences = currentHomePreferences + modificationFunction(&homePreferences) + currentHomePreferences = homePreferences + storeActiveHome() + } +} + +public extension Preferences { + static func firstStoredHome(where predicate: (HomePreferences) -> Bool) -> (id: UUID, record: HomePreferences)? { + for (uuid, record) in storedHomes { + guard predicate(record) else { continue } + return (uuid, record) + } + return nil + } + + static func storedHome(forCloudUserId id: String) -> HomePreferences? { + firstStoredHome { homePreferences in + homePreferences.remoteConnectionConfig.cloudUserId == id + }?.record + } +} + +// MARK: Migration + +public extension Preferences { + static func migratePreferences() { + initializeStoredHomes() + migrateToSharedDefaultsIfRequired() + migrateToMultipleHomesIfRequired() + } + + private static func migrateToSharedDefaultsIfRequired() { + guard !didMigrateToSharedDefaults else { return } + + modifyActiveHome { currentHomePreferences in + currentHomePreferences.localConnectionConfig.url = UserDefaults.standard.string(forKey: "localUrl") ?? currentHomePreferences.localConnectionConfig.url + currentHomePreferences.localConnectionConfig.alwaysSendBasicAuth = UserDefaults.standard.object(forKey: "alwaysSendCreds") as? Bool ?? currentHomePreferences.localConnectionConfig.alwaysSendBasicAuth + currentHomePreferences.localConnectionConfig.ignoreSSL = UserDefaults.standard.object(forKey: "ignoreSSL") as? Bool ?? currentHomePreferences.localConnectionConfig.ignoreSSL + currentHomePreferences.remoteConnectionConfig.url = UserDefaults.standard.string(forKey: "remoteUrl") ?? currentHomePreferences.remoteConnectionConfig.url + currentHomePreferences.remoteConnectionConfig.username = UserDefaults.standard.string(forKey: "username") ?? currentHomePreferences.remoteConnectionConfig.username + currentHomePreferences.remoteConnectionConfig.password = UserDefaults.standard.string(forKey: "password") ?? currentHomePreferences.remoteConnectionConfig.password + currentHomePreferences.remoteConnectionConfig.alwaysSendBasicAuth = UserDefaults.standard.object(forKey: "alwaysSendCreds") as? Bool ?? currentHomePreferences.remoteConnectionConfig.alwaysSendBasicAuth + currentHomePreferences.remoteConnectionConfig.ignoreSSL = UserDefaults.standard.object(forKey: "ignoreSSL") as? Bool ?? currentHomePreferences.remoteConnectionConfig.ignoreSSL + currentHomePreferences.demomode = UserDefaults.standard.object(forKey: "demomode") as? Bool ?? currentHomePreferences.demomode + currentHomePreferences.realTimeSliders = UserDefaults.standard.object(forKey: "realTimeSliders") as? Bool ?? currentHomePreferences.realTimeSliders + currentHomePreferences.iconType = UserDefaults.standard.object(forKey: "iconType") as? Int ?? currentHomePreferences.iconType + currentHomePreferences.defaultSitemap = UserDefaults.standard.string(forKey: "defaultSitemap") ?? currentHomePreferences.defaultSitemap + } + + Preferences.idleOff = UserDefaults.standard.object(forKey: "idleOff") as? Bool ?? Preferences.idleOff + Preferences.sendCrashReports = UserDefaults.standard.object(forKey: "sendCrashReports") as? Bool ?? Preferences.sendCrashReports + + didMigrateToSharedDefaults = true + // this was done implicitly + didMigrateToMultipleHomes = true + } + + private static func migrateToMultipleHomesIfRequired() { + guard !didMigrateToMultipleHomes else { return } + + migrateToSharedDefaultsIfRequired() + + let oldLocalUrl = Preferences.sharedDefaults.string(forKey: "localUrl") + let oldRemoteUrl = Preferences.sharedDefaults.string(forKey: "remoteUrl") + let oldUsername = Preferences.sharedDefaults.string(forKey: "username") + let oldPassword = Preferences.sharedDefaults.string(forKey: "password") + let oldAlwaysSendCreds = Preferences.sharedDefaults.object(forKey: "alwaysSendCreds") as? Bool + let oldIgnoreSSL = Preferences.sharedDefaults.object(forKey: "ignoreSSL") as? Bool + + // Create new configuration + var newLocalConfiguration = Preferences.currentHomePreferences.localConnectionConfig + newLocalConfiguration.url = oldLocalUrl ?? newLocalConfiguration.url + newLocalConfiguration.alwaysSendBasicAuth = oldAlwaysSendCreds ?? newLocalConfiguration.alwaysSendBasicAuth + newLocalConfiguration.ignoreSSL = oldIgnoreSSL ?? newLocalConfiguration.ignoreSSL + + var newRemoteConfiguration = Preferences.currentHomePreferences.remoteConnectionConfig + newRemoteConfiguration.url = oldRemoteUrl ?? newRemoteConfiguration.url + newRemoteConfiguration.username = oldUsername ?? newRemoteConfiguration.username + newRemoteConfiguration.password = oldPassword ?? newRemoteConfiguration.password + newRemoteConfiguration.alwaysSendBasicAuth = oldAlwaysSendCreds ?? newRemoteConfiguration.alwaysSendBasicAuth + newRemoteConfiguration.ignoreSSL = oldIgnoreSSL ?? newRemoteConfiguration.ignoreSSL + + // Save to Preferences + modifyActiveHome { currentHomePreferences in + currentHomePreferences.defaultView = Preferences.sharedDefaults.string(forKey: "defaultView") ?? currentHomePreferences.defaultView + currentHomePreferences.demomode = Preferences.sharedDefaults.object(forKey: "demomode") as? Bool ?? currentHomePreferences.demomode + currentHomePreferences.realTimeSliders = Preferences.sharedDefaults.object(forKey: "realTimeSliders") as? Bool ?? currentHomePreferences.realTimeSliders + currentHomePreferences.iconType = Preferences.sharedDefaults.object(forKey: "iconType") as? Int ?? currentHomePreferences.iconType + currentHomePreferences.defaultSitemap = Preferences.sharedDefaults.string(forKey: "defaultSitemap") ?? currentHomePreferences.defaultSitemap + currentHomePreferences.sortSitemapsBy = Preferences.sharedDefaults.object(forKey: "sortSitemapsBy") as? Int ?? currentHomePreferences.sortSitemapsBy + currentHomePreferences.defaultMainUIPath = Preferences.sharedDefaults.string(forKey: "defaultMainUIPath") ?? currentHomePreferences.defaultMainUIPath + currentHomePreferences.alwaysAllowWebRTC = Preferences.sharedDefaults.object(forKey: "alwaysAllowWebRTC") as? Bool ?? currentHomePreferences.alwaysAllowWebRTC + currentHomePreferences.sitemapForWatch = Preferences.sharedDefaults.string(forKey: "sitemapForWatch") ?? currentHomePreferences.sitemapForWatch + currentHomePreferences.localConnectionConfig = newLocalConfiguration + currentHomePreferences.remoteConnectionConfig = newRemoteConfiguration + currentHomePreferences.sitemapForWatchLabel = Preferences.sharedDefaults.string(forKey: "sitemapForWatchLabel") ?? currentHomePreferences.sitemapForWatchLabel + } + + didMigrateToMultipleHomes = true + } +} + +// MARK: All connections + +public extension Preferences { + static func getNotificationConnection() -> ConnectionConfiguration? { + getNotificationConnection(of: [Preferences.currentHomePreferences.remoteConnectionConfig]) + } + + static func getNotificationConnection(of homeConfig: HomePreferences) -> ConnectionConfiguration? { + getNotificationConnection(of: [homeConfig.remoteConnectionConfig]) + } + + // this will support mutliple connection configs, right now we just pass in the remote config + static func getNotificationConnection(of connections: [ConnectionConfiguration?]) -> ConnectionConfiguration? { + connections + .compactMap { $0 } + .filter { $0.supportsNotifications == true } + .sorted { $0.priority > $1.priority } + .first + } +} + +// MARK: - Sample Codable Model + +public extension ConnectionConfiguration { + static let localDefault = ConnectionConfiguration( + url: "https://openhab.local:8443", + username: "", + password: "", + alwaysSendBasicAuth: false, + ignoreSSL: false, + supportsNotifications: false, + priority: 0 + ) + + static let remoteDefault = ConnectionConfiguration( + url: "https://myopenhab.org", + username: "", + password: "", + alwaysSendBasicAuth: false, + ignoreSSL: false, + supportsNotifications: true, + priority: 1 + ) +} diff --git a/OsLogRewriter/Readme.md b/OsLogRewriter/Readme.md new file mode 100644 index 000000000..fcb6238fc --- /dev/null +++ b/OsLogRewriter/Readme.md @@ -0,0 +1,22 @@ +# Tool to transforms all os_log patterns from the openHAB iOS codebase to the new logger.debug/info/error format with clean Swift string interpolation syntax + +## 📋 Build and run the executable + +'swift run OsLogRewriter > ' + +'find ./Sources -name "*.swift" -exec swift run OsLogRewriter {} \; > temp.swift && mv temp.swift {}' + +## 📋 How to Run Tests: + + ### Run all tests (simple) + swift test + +# Features + + 1. Parameter handling - parses all arguments after the format string + 2. Format specifier support - Handles multiple format patterns: %{public}@, %{PUBLIC}@, %@, %d, %ld, %s + 3. Severity mapping - Correctly maps type: .debug/.info/.error/.fault to logger methods + 4. String interpolation - Properly converts format strings with arguments to Swift string interpolation + 5. Multi-line format preservation - Complex multi-line os_log calls handled correctly + 6. Trivia Cleaning + 7. Smart indentation diff --git a/OsLogRewriter/Sources/OsLogRewriter/main.swift b/OsLogRewriter/Sources/OsLogRewriter/main.swift new file mode 100644 index 000000000..9b792bdc9 --- /dev/null +++ b/OsLogRewriter/Sources/OsLogRewriter/main.swift @@ -0,0 +1,23 @@ +// Copyright (c) 2010-2025 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 Foundation +import OsLogRewriterLib + +let path = CommandLine.arguments.dropFirst().first! + +do { + let result = try rewriteOsLogCallsInFile(at: path) + print(result) +} catch { + fputs("❌ Error: \(error)\n", stderr) + exit(1) +} diff --git a/OsLogRewriter/Sources/OsLogRewriterLib/OsLogRewriter.swift b/OsLogRewriter/Sources/OsLogRewriterLib/OsLogRewriter.swift new file mode 100644 index 000000000..c6c766698 --- /dev/null +++ b/OsLogRewriter/Sources/OsLogRewriterLib/OsLogRewriter.swift @@ -0,0 +1,304 @@ +// Copyright (c) 2010-2025 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 Foundation +import SwiftParser +import SwiftSyntax +import SwiftSyntaxBuilder + +// Helper class to recursively remove trivia from all syntax nodes +final class TriviaRemover: SyntaxRewriter { + override func visit(_ token: TokenSyntax) -> TokenSyntax { + token.with(\.leadingTrivia, []).with(\.trailingTrivia, []) + } +} + +final class OsLogRewriter: SyntaxRewriter { + func buildLoggerCall(format: String, args: [ExprSyntax], severity: String, indentation: Trivia = []) -> ExprSyntax { + var segments: [StringLiteralSegmentListSyntax.Element] = [] + + // Handle simple case with no format arguments + if args.isEmpty { + segments.append(.stringSegment(StringSegmentSyntax(content: .stringSegment(format)))) + } else { + // Split format string by common format specifiers and interleave with arguments + let formatPatterns = ["%{public}@", "%{PUBLIC}@", "%@", "%d", "%ld", "%s"] + + var argIndex = 0 + + // Find and replace format specifiers in order + var remainingFormat = format + + for pattern in formatPatterns { + while let range = remainingFormat.range(of: pattern), argIndex < args.count { + // Add text before the pattern + let beforeText = String(remainingFormat[.. ExprSyntax { + let triviaRemover = TriviaRemover() + return ExprSyntax(triviaRemover.visit(expr)) + } + + // Helper function to extract indentation from trivia + func extractIndentation(from trivia: Trivia) -> Trivia { + // Look for the last newline and extract spaces/tabs after it + var indentationPieces: [TriviaPiece] = [] + var foundNewline = false + + for piece in trivia.reversed() { + switch piece { + case .newlines, .carriageReturns, .carriageReturnLineFeeds: + foundNewline = true + case let .spaces(count): + if foundNewline { + indentationPieces.append(.spaces(count)) + } else if !foundNewline { + // If we haven't found a newline yet, this might be leading spaces + indentationPieces.append(.spaces(count)) + } + case let .tabs(count): + if foundNewline { + indentationPieces.append(.tabs(count)) + } else if !foundNewline { + indentationPieces.append(.tabs(count)) + } + default: + if foundNewline { + break + } + } + } + + return Trivia(pieces: indentationPieces.reversed()) + } + + // Helper function to detect indentation from the context of the os_log call + func detectIndentationLevel(_ node: FunctionCallExprSyntax) -> Trivia { + // First try to extract from the node's own leading trivia + let directIndentation = extractIndentation(from: node.leadingTrivia) + if !directIndentation.isEmpty { + return directIndentation + } + + // If that doesn't work, try to find indentation by looking at the parent context + // We'll walk up the syntax tree to find a node with meaningful indentation + return findContextualIndentation(node) ?? Trivia.spaces(8) // fallback + } + + // Helper function to find indentation from parent context + func findContextualIndentation(_ node: some SyntaxProtocol) -> Trivia? { + var current: (any SyntaxProtocol)? = node + + while let currentNode = current { + // Check if this node has meaningful leading trivia with indentation + if let indentation = extractMeaningfulIndentation(from: currentNode.leadingTrivia) { + return indentation + } + + // Move to parent + current = currentNode.parent + } + + return nil + } + + // Helper function to extract meaningful indentation (non-empty) + func extractMeaningfulIndentation(from trivia: Trivia) -> Trivia? { + let extracted = extractIndentation(from: trivia) + return extracted.isEmpty ? nil : extracted + } + + // Helper function to simplify string interpolations like "\(value)" to just value + func simplifyStringInterpolation(_ expr: ExprSyntax) -> ExprSyntax { + // Check if this is a string literal + guard let stringLiteral = expr.as(StringLiteralExprSyntax.self) else { + return expr + } + + // Handle the case where the string literal is just a single interpolation + // This could be 1 segment (just interpolation) or 3 segments (empty + interpolation + empty) + + if stringLiteral.segments.count == 1 { + // Single segment - check if it's an interpolation + if case let .expressionSegment(expressionSegment) = stringLiteral.segments.first, + expressionSegment.expressions.count == 1, + let innerExpr = expressionSegment.expressions.first?.expression { + return innerExpr + } + } else if stringLiteral.segments.count == 3 { + // Three segments - check if it's empty + interpolation + empty + let segments = Array(stringLiteral.segments) + + // Check if first and last are empty string segments + if case let .stringSegment(firstSegment) = segments[0], + case let .expressionSegment(middleSegment) = segments[1], + case let .stringSegment(lastSegment) = segments[2], + firstSegment.content.text.isEmpty, + lastSegment.content.text.isEmpty, + middleSegment.expressions.count == 1, + let innerExpr = middleSegment.expressions.first?.expression { + return innerExpr + } + } + + // Otherwise return the original expression + return expr + } + + override func visit(_ node: FunctionCallExprSyntax) -> ExprSyntax { + guard + let calledExpression = node.calledExpression.as(DeclReferenceExprSyntax.self), + calledExpression.baseName.text == "os_log" + else { + return super.visit(node) + } + + // Detect the proper indentation level from context + let originalIndentation = detectIndentationLevel(node) + + var messageArg: ExprSyntax? + var formatArgs: [ExprSyntax] = [] + var severity = "info" // Default fallback + + // Parse arguments in order + for arg in node.arguments { + switch arg.label?.text { + case nil: + // Unlabeled arguments - first is message, rest are format args + if messageArg == nil { + messageArg = arg.expression + } else { + formatArgs.append(arg.expression) + } + + case "log": + // Skip the log parameter (e.g., log: .default) + continue + + case "type": + if let severityArg = arg.expression.as(MemberAccessExprSyntax.self) { + switch severityArg.declName.baseName.text { + case "debug": + severity = "debug" + case "info": + severity = "info" + case "error", "fault": + severity = "error" + default: + severity = "info" + } + } + + default: + // Any other labeled arguments are treated as format args + formatArgs.append(arg.expression) + } + } + + guard let msg = messageArg?.as(StringLiteralExprSyntax.self) else { + // If we can't parse the message as a string literal, skip transformation + return super.visit(node) + } + + let format = msg.segments.compactMap { segment -> String in + if let text = segment.as(StringSegmentSyntax.self)?.content.text { + return text + } else { + return "" + } + } + .joined() + + // Clean up the format string - remove extra whitespace and normalize line breaks + let cleanedFormat = format.replacingOccurrences(of: "\n", with: " ") + .replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression) + .trimmingCharacters(in: .whitespacesAndNewlines) + + return buildLoggerCall(format: cleanedFormat, args: formatArgs, severity: severity, indentation: originalIndentation) + } +} + +// Public API for the library +public func rewriteOsLogCalls(in sourceCode: String) throws -> String { + let syntaxTree = Parser.parse(source: sourceCode) + let rewriter = OsLogRewriter() + let result = rewriter.visit(syntaxTree) + return result.description +} + +public func rewriteOsLogCallsInFile(at path: String) throws -> String { + let sourceText = try String(contentsOfFile: path) + return try rewriteOsLogCalls(in: sourceText) +} diff --git a/OsLogRewriter/Tests/OsLogRewriterTests/OsLogRewriterTests.swift b/OsLogRewriter/Tests/OsLogRewriterTests/OsLogRewriterTests.swift new file mode 100644 index 000000000..be21145c7 --- /dev/null +++ b/OsLogRewriter/Tests/OsLogRewriterTests/OsLogRewriterTests.swift @@ -0,0 +1,266 @@ +// Copyright (c) 2010-2025 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 + +@testable import OsLogRewriterLib +import XCTest + +final class OsLogRewriterTests: XCTestCase { + func testSimpleOsLogMessage() throws { + let input = """ + import os.log + + func test() { + os_log("Simple message") + } + """ + + let expected = """ + import os.log + + func test() { + logger.info("Simple message") + } + """ + + let result = try rewriteOsLogCalls(in: input) + XCTAssertEqual( + result.trimmingCharacters(in: .whitespacesAndNewlines), + expected.trimmingCharacters(in: .whitespacesAndNewlines) + ) + } + + func testOsLogWithSeverity() throws { + let input = """ + import os.log + + func test() { + os_log("Debug message", type: .debug) + os_log("Info message", type: .info) + os_log("Error message", type: .error) + os_log("Fault message", type: .fault) + } + """ + + let expected = """ + import os.log + + func test() { + logger.debug("Debug message") + logger.info("Info message") + logger.error("Error message") + logger.error("Fault message") + } + """ + + let result = try rewriteOsLogCalls(in: input) + XCTAssertEqual( + result.trimmingCharacters(in: .whitespacesAndNewlines), + expected.trimmingCharacters(in: .whitespacesAndNewlines) + ) + } + + func testOsLogWithFormatParameters() throws { + let input = """ + import os.log + + func test() { + let key = "testKey" + let value = "testValue" + os_log("Preference %{public}@ is %{public}@", key, value) + } + """ + + let expected = """ + import os.log + + func test() { + let key = "testKey" + let value = "testValue" + logger.info("Preference \\(key) is \\(value)") + } + """ + + let result = try rewriteOsLogCalls(in: input) + XCTAssertEqual( + result.trimmingCharacters(in: .whitespacesAndNewlines), + expected.trimmingCharacters(in: .whitespacesAndNewlines) + ) + } + + func testOsLogWithLogParameter() throws { + let input = """ + import os.log + + func test() { + os_log("Message with log param", log: .default, type: .debug) + } + """ + + let expected = """ + import os.log + + func test() { + logger.debug("Message with log param") + } + """ + + let result = try rewriteOsLogCalls(in: input) + XCTAssertEqual( + result.trimmingCharacters(in: .whitespacesAndNewlines), + expected.trimmingCharacters(in: .whitespacesAndNewlines) + ) + } + + func testOsLogWithStringInterpolationArguments() throws { + let input = """ + import os.log + + func test() { + let value = 42 + os_log("Value is %{public}@", "\\(value)") + } + """ + + let expected = """ + import os.log + + func test() { + let value = 42 + logger.info("Value is \\(value)") + } + """ + + let result = try rewriteOsLogCalls(in: input) + XCTAssertEqual( + result.trimmingCharacters(in: .whitespacesAndNewlines), + expected.trimmingCharacters(in: .whitespacesAndNewlines) + ) + } + + func testMultipleFormatSpecifiers() throws { + let input = """ + import os.log + + func test() { + os_log("User %{public}@ logged in from %{public}@ at %{public}@", username, ipAddress, timestamp) + } + """ + + let expected = """ + import os.log + + func test() { + logger.info("User \\(username) logged in from \\(ipAddress) at \\(timestamp)") + } + """ + + let result = try rewriteOsLogCalls(in: input) + XCTAssertEqual( + result.trimmingCharacters(in: .whitespacesAndNewlines), + expected.trimmingCharacters(in: .whitespacesAndNewlines) + ) + } + + func testDifferentFormatSpecifiers() throws { + let input = """ + import os.log + + func test() { + os_log("String: %{PUBLIC}@, Number: %d, Long: %ld", stringValue, intValue, longValue) + } + """ + + let expected = """ + import os.log + + func test() { + logger.info("String: \\(stringValue), Number: \\(intValue), Long: \\(longValue)") + } + """ + + let result = try rewriteOsLogCalls(in: input) + XCTAssertEqual( + result.trimmingCharacters(in: .whitespacesAndNewlines), + expected.trimmingCharacters(in: .whitespacesAndNewlines) + ) + } + + func testOpenHABStylePatterns() throws { + let input = """ + import os.log + + func test() { + os_log("Preference value %{PUBLIC}@ was \\"%{PUBLIC}@\\" but did not conform to %{PUBLIC}@. Replace with default value.", + log: .default, type: .fault, key, "\\(preferenceValue)", "\\(T.self)") + } + """ + + let expected = """ + import os.log + + func test() { + logger.error("Preference value \\(key) was \\"\\(preferenceValue)\\" but did not conform to \\(T.self). Replace with default value.") + } + """ + + let result = try rewriteOsLogCalls(in: input) + XCTAssertEqual( + result.trimmingCharacters(in: .whitespacesAndNewlines), + expected.trimmingCharacters(in: .whitespacesAndNewlines) + ) + } + + func testNonOsLogCallsUnchanged() throws { + let input = """ + import os.log + + func test() { + print("This should not change") + NSLog("This should not change") + someOtherFunction("parameter") + } + """ + + let result = try rewriteOsLogCalls(in: input) + XCTAssertEqual( + result.trimmingCharacters(in: .whitespacesAndNewlines), + input.trimmingCharacters(in: .whitespacesAndNewlines) + ) + } + + func testMixedOsLogAndOtherCalls() throws { + let input = """ + import os.log + + func test() { + print("Regular print") + os_log("This gets transformed", type: .debug) + NSLog("This stays the same") + } + """ + + let expected = """ + import os.log + + func test() { + print("Regular print") + logger.debug("This gets transformed") + NSLog("This stays the same") + } + """ + + let result = try rewriteOsLogCalls(in: input) + XCTAssertEqual( + result.trimmingCharacters(in: .whitespacesAndNewlines), + expected.trimmingCharacters(in: .whitespacesAndNewlines) + ) + } +} diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index f2cf3fe61..d27e6a592 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -14,7 +14,6 @@ 2FBCF58C2DEB0B7700CD5D83 /* HomeSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FBCF58B2DEB0B7700CD5D83 /* HomeSelectionView.swift */; }; 2FEFD8F62BE7C5BE00E387B9 /* TextInputUITableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FEFD8F52BE7C5BE00E387B9 /* TextInputUITableViewCell.swift */; }; 4D6470DA2561F935007B03FC /* openHABIntents.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 4D6470D32561F935007B03FC /* openHABIntents.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; - 653B54C0285C0AC700298ECD /* OpenHABRootViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 653B54BF285C0AC700298ECD /* OpenHABRootViewController.swift */; }; 653B54C2285E714900298ECD /* OpenHABViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 653B54C1285E714900298ECD /* OpenHABViewController.swift */; }; 65570A7D2476D16A00D524EA /* OpenHABWebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65570A7C2476D16A00D524EA /* OpenHABWebViewController.swift */; }; 6557AF8F2C0241C10094D0C8 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 6557AF8E2C0241C10094D0C8 /* PrivacyInfo.xcprivacy */; }; @@ -113,6 +112,8 @@ 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 */; }; + DA95F3332E0F2B1700FE4474 /* OpenHABRootViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA95F3322E0F2B1700FE4474 /* OpenHABRootViewController.swift */; }; + DA95F3352E0F2C1600FE4474 /* OpenHABSitemapViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA95F3342E0F2C1600FE4474 /* OpenHABSitemapViewController.swift */; }; DA9721C324E29A8F0092CCFD /* UserDefaultsBacked.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA9721C224E29A8F0092CCFD /* UserDefaultsBacked.swift */; }; DA9A7EFD2D668D5900824156 /* SFSafeSymbols in Frameworks */ = {isa = PBXBuildFile; productRef = DA9A7EFC2D668D5900824156 /* SFSafeSymbols */; }; DA9A7EFF2D66915900824156 /* SFSafeSymbols in Frameworks */ = {isa = PBXBuildFile; productRef = DA9A7EFE2D66915900824156 /* SFSafeSymbols */; }; @@ -168,7 +169,6 @@ DFB2622D18830A3600D3244D /* CoreGraphics.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DFB2622C18830A3600D3244D /* CoreGraphics.framework */; }; DFB2622F18830A3600D3244D /* UIKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DFB2622E18830A3600D3244D /* UIKit.framework */; }; DFB2623B18830A3600D3244D /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFB2623A18830A3600D3244D /* AppDelegate.swift */; }; - DFB2624418830A3600D3244D /* OpenHABSitemapViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFB2624318830A3600D3244D /* OpenHABSitemapViewController.swift */; }; DFB2624618830A3600D3244D /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DFB2624518830A3600D3244D /* Images.xcassets */; }; DFDA3CEA193CADB200888039 /* ping.wav in Resources */ = {isa = PBXBuildFile; fileRef = DFDA3CE9193CADB200888039 /* ping.wav */; }; DFDF45311932042B00A6E581 /* legal.rtf in Resources */ = {isa = PBXBuildFile; fileRef = DFDF45301932042B00A6E581 /* legal.rtf */; }; @@ -292,7 +292,6 @@ 4D64720D256315D9007B03FC /* openHABIntents.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = openHABIntents.entitlements; sourceTree = ""; }; 4D647220256331B9007B03FC /* SetSwitchStateIntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetSwitchStateIntentHandler.swift; sourceTree = ""; }; 4D64724C256346BD007B03FC /* SetDimmerRollerValueIntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetDimmerRollerValueIntentHandler.swift; sourceTree = ""; }; - 653B54BF285C0AC700298ECD /* OpenHABRootViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenHABRootViewController.swift; sourceTree = ""; }; 653B54C1285E714900298ECD /* OpenHABViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenHABViewController.swift; sourceTree = ""; }; 653C09D41EAD691A00BA4C4A /* openHAB.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = openHAB.entitlements; sourceTree = ""; }; 65570A7C2476D16A00D524EA /* OpenHABWebViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenHABWebViewController.swift; sourceTree = ""; }; @@ -355,6 +354,7 @@ DA0775382346705F0086C685 /* PushNotificationPayload.apns */ = {isa = PBXFileReference; lastKnownFileType = text; path = PushNotificationPayload.apns; sourceTree = ""; }; DA077649234683BC0086C685 /* SwitchRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwitchRow.swift; sourceTree = ""; }; DA0776EF234788010086C685 /* UserData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserData.swift; sourceTree = ""; }; + DA0DA9E12E0C9B74000C5D0A /* BuildTools */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = BuildTools; sourceTree = ""; }; DA0F37CF23D4ACC7007EAB48 /* SliderRow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SliderRow.swift; sourceTree = ""; }; DA15BFBC23C6726400BD8ADA /* AppSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettings.swift; sourceTree = ""; }; DA162BEB2CD3B53E0040DAE5 /* LogsViewer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogsViewer.swift; sourceTree = ""; }; @@ -427,6 +427,8 @@ DA7E1E47222EB00B002AEFD8 /* PlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerView.swift; sourceTree = ""; }; DA817E79234BF39B00C91824 /* CHANGELOG.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = CHANGELOG.md; sourceTree = ""; }; DA88F8C522EC377100B408E5 /* ReleaseNotes.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = ReleaseNotes.md; sourceTree = ""; }; + DA95F3322E0F2B1700FE4474 /* OpenHABRootViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenHABRootViewController.swift; sourceTree = ""; }; + DA95F3342E0F2C1600FE4474 /* OpenHABSitemapViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenHABSitemapViewController.swift; sourceTree = ""; }; DA9721C224E29A8F0092CCFD /* UserDefaultsBacked.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaultsBacked.swift; sourceTree = ""; }; DA9F81862C85020F00B47B72 /* RTFTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RTFTextView.swift; sourceTree = ""; }; DAA42BA721DC97DF00244B2A /* NotificationTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NotificationTableViewCell.swift; sourceTree = ""; }; @@ -473,6 +475,7 @@ DAF4581D23DC60020018B495 /* ImageRawRow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageRawRow.swift; sourceTree = ""; }; DAF4F6BF222734D200C24876 /* NewImageUITableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewImageUITableViewCell.swift; sourceTree = ""; }; DAF6F4112C67E83B0083883E /* openapiCorrected.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = openapiCorrected.json; sourceTree = ""; }; + DAFD2FE62E0D96700059A1EB /* OsLogRewriter */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = OsLogRewriter; sourceTree = ""; }; DF05FF221896BD2D00FF2F9B /* SelectionUITableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SelectionUITableViewCell.swift; sourceTree = ""; }; DF06F1FB18FEC2020011E7B9 /* ColorPickerViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ColorPickerViewController.swift; sourceTree = ""; }; DF4B84121886DAC400F34902 /* FrameUITableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FrameUITableViewCell.swift; sourceTree = ""; }; @@ -486,7 +489,6 @@ DFB2622E18830A3600D3244D /* UIKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UIKit.framework; path = System/Library/Frameworks/UIKit.framework; sourceTree = SDKROOT; }; DFB2623218830A3600D3244D /* openHAB-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "openHAB-Info.plist"; sourceTree = ""; }; DFB2623A18830A3600D3244D /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - DFB2624318830A3600D3244D /* OpenHABSitemapViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenHABSitemapViewController.swift; sourceTree = ""; }; DFB2624518830A3600D3244D /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Images.xcassets; sourceTree = ""; }; 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 = ""; }; @@ -710,6 +712,13 @@ path = Extension; sourceTree = ""; }; + DA0DA9E02E0C9B51000C5D0A /* New Group */ = { + isa = PBXGroup; + children = ( + ); + path = "New Group"; + sourceTree = ""; + }; DA1C2E4A230DC28F00FACFB0 /* fastlane */ = { isa = PBXGroup; children = ( @@ -919,9 +928,9 @@ DA2AEB752D92D32000897D80 /* Cells */, DA4642312D7EE6CA006C3908 /* LoggerView.swift */, 653B54C1285E714900298ECD /* OpenHABViewController.swift */, - 653B54BF285C0AC700298ECD /* OpenHABRootViewController.swift */, + DA95F3322E0F2B1700FE4474 /* OpenHABRootViewController.swift */, 65570A7C2476D16A00D524EA /* OpenHABWebViewController.swift */, - DFB2624318830A3600D3244D /* OpenHABSitemapViewController.swift */, + DA95F3342E0F2C1600FE4474 /* OpenHABSitemapViewController.swift */, DA2AEB6D2D92BAD800897D80 /* PageLoader.swift */, DAC65FC6236EDF3900F4501E /* SpinnerViewController.swift */, DA6B2EF62C8B92E800DF77CF /* SelectionView.swift */, @@ -980,6 +989,7 @@ DFB2621E18830A3600D3244D = { isa = PBXGroup; children = ( + DA0DA9E02E0C9B51000C5D0A /* New Group */, 6557AF8E2C0241C10094D0C8 /* PrivacyInfo.xcprivacy */, DA4D4DB4233F9ACB00B37E37 /* README.md */, DA4D4E0E2340A00200B37E37 /* Changes.md */, @@ -997,6 +1007,8 @@ DFB2622818830A3600D3244D /* Products */, DFB2622918830A3600D3244D /* Frameworks */, DA1C2E4A230DC28F00FACFB0 /* fastlane */, + DAFD2FE62E0D96700059A1EB /* OsLogRewriter */, + DA0DA9E12E0C9B74000C5D0A /* BuildTools */, DAE238252806E5C800196467 /* Recovered References */, ); sourceTree = ""; @@ -1581,6 +1593,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + DA95F3332E0F2B1700FE4474 /* OpenHABRootViewController.swift in Sources */, DA7E1E4B2233986E002AEFD8 /* PlayerView.swift in Sources */, 65570A7D2476D16A00D524EA /* OpenHABWebViewController.swift in Sources */, DA48001E2D837905009CF127 /* ApplicationSettingsView.swift in Sources */, @@ -1598,6 +1611,7 @@ DA6B2EF52C89F8F200DF77CF /* ColorPickerView.swift in Sources */, DA4800142D836892009CF127 /* ConnectionSettingsView.swift in Sources */, 2F6412EE2CE494A80039FB28 /* DatePickerUITableViewCell.swift in Sources */, + DA95F3352E0F2C1600FE4474 /* OpenHABSitemapViewController.swift in Sources */, DAA42BAA21DC983B00244B2A /* VideoUITableViewCell.swift in Sources */, DFB2623B18830A3600D3244D /* AppDelegate.swift in Sources */, 2F55E7BD2DEE44A800EC8350 /* ClientCertificatesView.swift in Sources */, @@ -1623,7 +1637,6 @@ DA4800162D836EF0009CF127 /* MainUISettingsView.swift in Sources */, DAEAA89F21E6B16600267EA3 /* UITableView.swift in Sources */, 2F55E7BB2DEE447700EC8350 /* SettingsView.swift in Sources */, - DFB2624418830A3600D3244D /* OpenHABSitemapViewController.swift in Sources */, DA4800182D837221009CF127 /* AboutSettingsView.swift in Sources */, 653B54C2285E714900298ECD /* OpenHABViewController.swift in Sources */, DA2AEB6E2D92BAD800897D80 /* PageLoader.swift in Sources */, @@ -1636,7 +1649,6 @@ DFFD8FD118EDBD4F003B502A /* UICircleButton.swift in Sources */, DA48001C2D837556009CF127 /* SitemapSettingsView.swift in Sources */, 938BF9C624EFCC0700E6B52F /* UILabel+Localization.swift in Sources */, - 653B54C0285C0AC700298ECD /* OpenHABRootViewController.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/openHAB.xcodeproj/xcshareddata/xcschemes/openHABTestsSwift.xcscheme b/openHAB.xcodeproj/xcshareddata/xcschemes/openHABTestsSwift.xcscheme index 3acbef85e..2b520ec53 100644 --- a/openHAB.xcodeproj/xcshareddata/xcschemes/openHABTestsSwift.xcscheme +++ b/openHAB.xcodeproj/xcshareddata/xcschemes/openHABTestsSwift.xcscheme @@ -63,15 +63,6 @@ savedToolIdentifier = "" useCustomWorkingDirectory = "NO" debugDocumentVersioning = "YES"> - - - - diff --git a/openHAB.xcworkspace/xcshareddata/swiftpm/Package.resolved b/openHAB.xcworkspace/xcshareddata/swiftpm/Package.resolved index d98176df9..f8971ed73 100644 --- a/openHAB.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/openHAB.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,4 @@ { - "originHash" : "8bdfc8eeb7fcbc48ed390776694834a01e4fcdb093404ab31c58f64918915df4", "pins" : [ { "identity" : "abseil-cpp-binary", @@ -217,6 +216,33 @@ "version" : "1.28.2" } }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-syntax.git", + "state" : { + "revision" : "64889f0c732f210a935a0ad7cda38f77f876262d", + "version" : "509.1.1" + } + }, + { + "identity" : "swiftformatplugin", + "kind" : "remoteSourceControl", + "location" : "https://github.com/weakfl/SwiftFormatPlugin", + "state" : { + "revision" : "daf7c48b2264b11cc8535aa3649b2d8486bb3b08", + "version" : "0.56.1" + } + }, + { + "identity" : "swiftlintplugin", + "kind" : "remoteSourceControl", + "location" : "https://github.com/weakfl/SwiftLintPlugin.git", + "state" : { + "revision" : "3e10a982ff8f62ba6a19401380280a26e4c56bef", + "version" : "0.59.1" + } + }, { "identity" : "swiftmessages", "kind" : "remoteSourceControl", @@ -227,5 +253,5 @@ } } ], - "version" : 3 + "version" : 2 } diff --git a/openHAB/AppDelegate.swift b/openHAB/AppDelegate.swift index 3989e1282..1c770788f 100644 --- a/openHAB/AppDelegate.swift +++ b/openHAB/AppDelegate.swift @@ -65,7 +65,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { let watchMessageService = WatchMessageService.singleton session.delegate = watchMessageService session.activate() - os_log("Paired watch %{PUBLIC}@, watch app installed %{PUBLIC}@", log: .watch, type: .info, "\(session.isPaired)", "\(session.isWatchAppInstalled)") + logger.info("Paired watch \(session.isPaired), watch app installed \(session.isWatchAppInstalled)") watchMessageService.subscribeToPreferences() } } @@ -77,7 +77,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { - os_log("didFinishLaunchingWithOptions started", log: .viewCycle, type: .info) + logger.info("didFinishLaunchingWithOptions started") setupFirebase() @@ -87,18 +87,16 @@ class AppDelegate: UIResponder, UIApplicationDelegate { Preferences.migratePreferences() registerForPushNotifications() - - os_log("uniq id: %{PUBLIC}s", log: .notifications, type: .info, UIDevice.current.identifierForVendor?.uuidString ?? "") - os_log("device name: %{PUBLIC}s", log: .notifications, type: .info, UIDevice.current.name) + logger.info("uniq id: \(UIDevice.current.identifierForVendor?.uuidString ?? "")") + logger.info("device name: \(UIDevice.current.name)") let audioSession = AVAudioSession.sharedInstance() do { try audioSession.setCategory(.playback, mode: .default, options: []) } catch { - os_log("Setting category to AVAudioSessionCategoryPlayback failed.", log: .default, type: .info) + logger.info("Setting category to AVAudioSessionCategoryPlayback failed.") } - - os_log("didFinishLaunchingWithOptions ended", log: .viewCycle, type: .info) + logger.info("didFinishLaunchingWithOptions ended") activateWatchConnectivity() @@ -123,7 +121,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { if WCSession.isSupported() { session = WCSession.default } else { - os_log("WCSession is not supported - For instance on iPad", log: .watch, type: .debug) + logger.debug("WCSession is not supported - For instance on iPad") } } @@ -138,10 +136,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate { #endif UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, _ in - os_log("Permission granted: %{PUBLIC}@", log: .notifications, type: .info, granted ? "YES" : "NO") + self.logger.info("Permission granted: \(granted ? "YES" : "NO")") guard granted else { return } UNUserNotificationCenter.current().getNotificationSettings { settings in - os_log("Notification settings: %{PUBLIC}@", log: .notifications, type: .info, settings) + self.logger.info("Notification settings: \(settings)") guard settings.authorizationStatus == .authorized else { return } DispatchQueue.main.async { @@ -155,14 +153,13 @@ class AppDelegate: UIResponder, UIApplicationDelegate { func application(_ application: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any]) -> Bool { // TODO: Pass this parameters to openHABViewController somehow to open specified sitemap/page and send specified command // Probably need to do this in a way compatible to Android app's URL - - os_log("Calling Application Bundle ID: %{PUBLIC}@", log: .notifications, type: .info, options[UIApplication.OpenURLOptionsKey.sourceApplication] as? String ?? "") - os_log("URL: %{PUBLIC}@", log: .notifications, type: .info, url.absoluteString) - os_log("URL scheme: %{PUBLIC}@", log: .notifications, type: .info, url.scheme ?? "") - os_log("URL query: %{PUBLIC}@", log: .notifications, type: .info, url.query ?? "") + logger.info("Calling Application Bundle ID: \(options[UIApplication.OpenURLOptionsKey.sourceApplication] as? String ?? "")") + logger.info("URL: \(url.absoluteString)") + logger.info("URL scheme: \(url.scheme ?? "")") + logger.info("URL query: \(url.query ?? "")") if url.isFileURL { - os_log("Loading Certificate", log: .notifications, type: .info) + logger.info("Loading Certificate") let clientCertificateManager = NetworkTracker.shared.clientCertificateManager Task { @MainActor in await clientCertificateManager.startImportClientCertificate(url: url) @@ -182,7 +179,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: any Error) { - os_log("Failed to get token for notifications: %{PUBLIC}@", log: .notifications, type: .error, error.localizedDescription) + logger.error("Failed to get token for notifications: \(error.localizedDescription)") } @MainActor diff --git a/openHAB/ColorPickerCell.swift b/openHAB/ColorPickerCell.swift index cd19aaa74..ac1887ce3 100644 --- a/openHAB/ColorPickerCell.swift +++ b/openHAB/ColorPickerCell.swift @@ -18,6 +18,8 @@ protocol ColorPickerCellDelegate: NSObjectProtocol { } class ColorPickerCell: GenericUITableViewCell { + private let logger = Logger(subsystem: "org.openhab", category: "ColorPickerCell") + weak var delegate: (any ColorPickerCellDelegate)? @IBOutlet private var downButton: UIButton! @@ -25,7 +27,7 @@ class ColorPickerCell: GenericUITableViewCell { @IBOutlet private var colorButton: UICircleButton! required init?(coder: NSCoder) { - os_log("ColorPickerCell initWithCoder", log: OSLog.viewCycle, type: .info) + logger.info("ColorPickerCell initWithCoder") super.init(coder: coder) @@ -53,13 +55,13 @@ class ColorPickerCell: GenericUITableViewCell { @objc func upButtonPressed() { - os_log("ON button pressed", log: .viewCycle, type: .info) + logger.info("ON button pressed") widget.sendCommand("ON") } @objc func downButtonPressed() { - os_log("OFF button pressed", log: .viewCycle, type: .info) + logger.info("OFF button pressed") widget.sendCommand("OFF") } } diff --git a/openHAB/ColorPickerViewController.swift b/openHAB/ColorPickerViewController.swift index 697b0c96f..a2fa3fd46 100644 --- a/openHAB/ColorPickerViewController.swift +++ b/openHAB/ColorPickerViewController.swift @@ -15,6 +15,8 @@ import os.log import UIKit class ColorPickerViewController: DefaultColorPickerViewController { + private let logger = Logger(subsystem: "org.openhab", category: "ColorPickerViewController") + var widget: OpenHABWidget? /// Throttle engine @@ -32,12 +34,12 @@ class ColorPickerViewController: DefaultColorPickerViewController { } required init?(coder: NSCoder) { - os_log("ColorPickerViewController initWithCoder", log: .viewCycle, type: .info) + logger.info("ColorPickerViewController initWithCoder") super.init(coder: coder) } override func viewDidLoad() { - os_log("ColorPickerViewController viewDidLoad", log: .viewCycle, type: .info) + logger.info("ColorPickerViewController viewDidLoad") if let color = widget?.item?.stateAsUIColor() { selectedColor = color @@ -66,7 +68,7 @@ class ColorPickerViewController: DefaultColorPickerViewController { saturation *= 100 brightness *= 100 - os_log("Color changed to HSB(%g, %g, %g).", log: .default, type: .info, hue, saturation, brightness) + logger.info("Color changed to HSB(\(hue), \(saturation), \(brightness)).") widget?.sendCommand("\(hue),\(saturation),\(brightness)") } diff --git a/openHAB/HomeSelectionView.swift b/openHAB/HomeSelectionView.swift index 092b68730..7a350004e 100644 --- a/openHAB/HomeSelectionView.swift +++ b/openHAB/HomeSelectionView.swift @@ -19,6 +19,8 @@ import SwiftUI import WebKit struct HomeSelectionView: View { + private let logger = Logger(subsystem: "org.openhab", category: "HomeSelectionView") + @State private var homes: [UUID] = [] @State private var showingNewHomeAlert = false @@ -35,8 +37,6 @@ struct HomeSelectionView: View { @Environment(\.dismiss) private var dismiss - private let logger = Logger(subsystem: "org.openhab.app", category: "SettingsView") - var body: some View { List(homes, id: \.self) { home in let homeName = Preferences.storedHomes[home]?.homeName ?? "" @@ -185,7 +185,7 @@ struct HomeSelectionView: View { guard let toDelete else { return } - os_log("delete home settings for %@", toDelete.uuidString) + logger.info("delete home settings for \(toDelete.uuidString)") Preferences.deleteStoredHome(toDelete) loadHomesList() } @@ -195,7 +195,7 @@ struct HomeSelectionView: View { return } let newName = newHomeName - os_log("rename home %@ to %@", toRename.uuidString, newName) + logger.info("rename home \(toRename.uuidString) to \(newName)") Preferences.renameHome(toRename, newHomeName: newName) } diff --git a/openHAB/NewImageUITableViewCell.swift b/openHAB/NewImageUITableViewCell.swift index 1411354b9..a4c8e40a6 100644 --- a/openHAB/NewImageUITableViewCell.swift +++ b/openHAB/NewImageUITableViewCell.swift @@ -21,6 +21,8 @@ enum ImageType { } class NewImageUITableViewCell: GenericUITableViewCell, NoIconDisplayableCell { + private let logger = Logger(subsystem: "org.openhab", category: "NewImageUITableViewCell") + var didLoad: (() -> Void)? private var mainImageView: ScaleAspectFitImageView! @@ -30,8 +32,6 @@ class NewImageUITableViewCell: GenericUITableViewCell, NoIconDisplayableCell { var openHABRootUrl: String? - private let logger = Logger(subsystem: "org.openhab.app", category: "NewImageUITableViewCell") - private var shouldCache: Bool { widget?.refresh == 0 } @@ -42,7 +42,7 @@ class NewImageUITableViewCell: GenericUITableViewCell, NoIconDisplayableCell { switch widget.type { case .chart: guard let openHABRootUrl else { - os_log("Missing openHABRootUrl in NewImageUITableViewCell", log: .urlComposition, type: .error) + logger.error("Missing openHABRootUrl in NewImageUITableViewCell") return .empty } return .link(url: Endpoint.chart( @@ -111,7 +111,7 @@ class NewImageUITableViewCell: GenericUITableViewCell, NoIconDisplayableCell { refreshTimer = nil let refreshInterval = TimeInterval(Double(widget.refresh) / 1000) if refreshInterval > 0.09 { - os_log("Sheduling image refresh every %g seconds", log: .viewCycle, type: .info, refreshInterval) + logger.info("Scheduling image refresh every \(refreshInterval) seconds") refreshTimer = Timer.scheduledTimer( timeInterval: refreshInterval, target: self, @@ -133,14 +133,14 @@ class NewImageUITableViewCell: GenericUITableViewCell, NoIconDisplayableCell { guard let url else { return } loadRemoteImage(withURL: url) default: - os_log("Failed to determine widget payload.", log: .urlComposition, type: .debug) + logger.debug("Failed to determine widget payload.") } } private func widgetPayload(fromItem item: OpenHABItem) -> ImageType { switch item.type { case .image: - os_log("Image base64Encoded.", log: .urlComposition, type: .debug) + logger.debug("Image base64Encoded.") guard let data = item.state?.components(separatedBy: ",")[safe: 1], let decodedData = Data(base64Encoded: data, options: .ignoreUnknownCharacters) else { return .empty } @@ -181,7 +181,8 @@ class NewImageUITableViewCell: GenericUITableViewCell, NoIconDisplayableCell { @objc func refreshImage(_ timer: Timer?) { - os_log("Refreshing image on %g seconds schedule", log: .viewCycle, type: .info, Double(widget.refresh) / 1000) + // swiftformat:disable:next redundantSelf + logger.info("Refreshing image on \(Double(self.widget.refresh) / 1000) seconds schedule") loadImage() } diff --git a/openHAB/NotificationTableViewCell.swift b/openHAB/NotificationTableViewCell.swift index 4b924e497..2887cba96 100644 --- a/openHAB/NotificationTableViewCell.swift +++ b/openHAB/NotificationTableViewCell.swift @@ -14,11 +14,13 @@ import os.log import UIKit class NotificationTableViewCell: UITableViewCell { + private let logger = Logger(subsystem: "org.openhab", category: "NotificationTableViewCell") + @IBOutlet private(set) var customTextLabel: UILabel! @IBOutlet private(set) var customDetailTextLabel: UILabel! required init?(coder: NSCoder) { - os_log("DrawerUITableViewCell initWithCoder", log: .viewCycle, type: .info) + logger.info("NotificationTableViewCell initWithCoder") super.init(coder: coder) separatorInset = .zero } diff --git a/openHAB/OpenHABRootViewController.swift b/openHAB/OpenHABRootViewController.swift index 14f852182..1b0cb1244 100644 --- a/openHAB/OpenHABRootViewController.swift +++ b/openHAB/OpenHABRootViewController.swift @@ -60,7 +60,7 @@ class OpenHABRootViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() - os_log("OpenHABRootViewController viewDidLoad", log: .default, type: .info) + logger.info("OpenHABRootViewController viewDidLoad") setupSideMenu() NotificationCenter.default.addObserver(self, selector: #selector(OpenHABRootViewController.handleApsRegistration(_:)), name: NSNotification.Name("apsRegistered"), object: nil) @@ -105,7 +105,7 @@ class OpenHABRootViewController: UIViewController { } override func viewWillAppear(_ animated: Bool) { - os_log("OpenHABRootController viewWillAppear", log: .viewCycle, type: .info) + logger.info("OpenHABRootController viewWillAppear") super.viewWillAppear(animated) navigationController?.navigationBar.prefersLargeTitles = true // if we have turned demo mode off/on, reset view @@ -266,7 +266,7 @@ class OpenHABRootViewController: UIViewController { url = URL(string: urlString) } else { guard let rootUrl = activeConnection?.configuration.url else { - os_log("openTileURL failed: no active connection URL", log: .default, type: .error) + logger.error("openTileURL failed: no active connection URL") return } url = Endpoint.resource(openHABRootUrl: rootUrl, path: urlString.prepare()).url @@ -455,11 +455,11 @@ class OpenHABRootViewController: UIViewController { } private func uiCommandAction(_ command: String) { - os_log("navigateCommandAction: %{PUBLIC}@", log: .notifications, type: .info, command) + logger.info("navigateCommandAction: \(command)") let regexPattern = /^(\/basicui\/app\\?.*|\/.*|.*)$/ if let firstMatch = command.firstMatch(of: regexPattern) { let path = String(firstMatch.1) - os_log("navigateCommandAction path: %{PUBLIC}@", log: .notifications, type: .info, path) + logger.info("navigateCommandAction path: \(path)") if path.starts(with: "/basicui/app?") { if currentView != sitemapViewController { switchView(target: .sitemap("")) @@ -487,7 +487,7 @@ class OpenHABRootViewController: UIViewController { } } } else { - os_log("Invalid regex: %{PUBLIC}@", log: .notifications, type: .error, command) + logger.error("Invalid regex: \(command)") } } @@ -544,7 +544,7 @@ class OpenHABRootViewController: UIViewController { guard keyValue.count == 2 else { continue } if keyValue[0] == "ios" { if let url = URL(string: String(keyValue[1])) { - os_log("appCommandAction opening %{public}@ %{public}@", log: .default, type: .error, String(keyValue[0]), String(keyValue[1])) + logger.error("appCommandAction opening \(String(keyValue[0])) \(String(keyValue[1]))") UIApplication.shared.open(url) return } @@ -586,7 +586,7 @@ class OpenHABRootViewController: UIViewController { } func showSideMenu() { - os_log("OpenHABRootViewController showSideMenu", log: .viewCycle, type: .info) + logger.info("OpenHABRootViewController showSideMenu") if let menu = SideMenuManager.default.rightMenuNavigationController { // don't try and push an already visible menu less you crash the app dismiss(animated: false) { @@ -653,7 +653,7 @@ class OpenHABRootViewController: UIViewController { if Preferences.currentHomePreferences.demomode { switchView(target: .sitemap("")) } else { - os_log("OpenHABRootViewController switchToSavedView %@", log: .viewCycle, type: .info, Preferences.currentHomePreferences.defaultView == "sitemap" ? "sitemap" : "web") + logger.info("OpenHABRootViewController switchToSavedView \(Preferences.currentHomePreferences.defaultView == "sitemap" ? "sitemap" : "web")") switchView(target: Preferences.currentHomePreferences.defaultView == "sitemap" ? .sitemap("") : .webview) } } @@ -723,7 +723,7 @@ class OpenHABRootViewController: UIViewController { extension OpenHABRootViewController: SideMenuNavigationControllerDelegate { nonisolated func sideMenuWillAppear(menu: SideMenuNavigationController, animated: Bool) { - os_log("OpenHABRootViewController sideMenuWillAppear", log: .viewCycle, type: .info) + logger.info("OpenHABRootViewController sideMenuWillAppear") } } diff --git a/openHAB/OpenHABSitemapViewController.swift b/openHAB/OpenHABSitemapViewController.swift index ee49fd988..689904f7d 100644 --- a/openHAB/OpenHABSitemapViewController.swift +++ b/openHAB/OpenHABSitemapViewController.swift @@ -78,7 +78,7 @@ class OpenHABSitemapViewController: OpenHABViewController, UISearchControllerDel override func viewDidLoad() { super.viewDidLoad() - os_log("OpenHABSitemapViewController viewDidLoad", log: .default, type: .info) + logger.info("OpenHABSitemapViewController viewDidLoad") registerTableViewCells() configureTableView() @@ -119,7 +119,7 @@ class OpenHABSitemapViewController: OpenHABViewController, UISearchControllerDel } override func viewDidAppear(_ animated: Bool) { - os_log("OpenHABSitemapViewController viewDidAppear", log: .viewCycle, type: .info) + logger.info("OpenHABSitemapViewController viewDidAppear") super.viewDidAppear(animated) if parent?.navigationItem.searchController !== searchController { @@ -169,7 +169,7 @@ class OpenHABSitemapViewController: OpenHABViewController, UISearchControllerDel } override func viewWillDisappear(_ animated: Bool) { - os_log("OpenHABSitemapViewController viewWillDisappear", log: .viewCycle, type: .info) + logger.info("OpenHABSitemapViewController viewWillDisappear") trackerCancellables.removeAll() stopAllTasks() @@ -197,19 +197,19 @@ class OpenHABSitemapViewController: OpenHABViewController, UISearchControllerDel @objc override func didEnterBackground(_ notification: Notification?) { super.didEnterBackground(notification) - os_log("OpenHABSitemapViewController didEnterBackground", log: .viewCycle, type: .info) + logger.info("OpenHABSitemapViewController didEnterBackground") } @objc override func didBecomeActive(_ notification: Notification?) { super.didBecomeActive(notification) - os_log("OpenHABSitemapViewController didBecomeActive", log: .viewCycle, type: .info) + logger.info("OpenHABSitemapViewController didBecomeActive") if isViewLoaded, view.window != nil, !pageUrl.isEmpty { if !pageNetworkStatusChanged() { - os_log("OpenHABSitemapViewController isViewLoaded, restarting network activity", log: .viewCycle, type: .info) + logger.info("OpenHABSitemapViewController isViewLoaded, restarting network activity") startPageHandling() } else { - os_log("OpenHABSitemapViewController network status changed while it was inactive", log: .viewCycle, type: .info) + logger.info("OpenHABSitemapViewController network status changed while it was inactive") restart() } } @@ -224,13 +224,13 @@ class OpenHABSitemapViewController: OpenHABViewController, UISearchControllerDel private func startTrackNetworkStatus() { let task = Task { for await status in NetworkTracker.shared.$status.values { - os_log("OpenHABViewController tracker status %{PUBLIC}@", log: .viewCycle, type: .info, status.rawValue) + logger.info("OpenHABViewController tracker status \(status.rawValue)") await MainActor.run { switch status { case .connecting: self.showPopupMessage(seconds: 1.5, title: NSLocalizedString("connecting", comment: ""), message: "", theme: .info) case .notConnected: - os_log("Tracking error", log: .viewCycle, type: .info) + logger.info("Tracking error") // self.showPopupMessage(seconds: 60, title: NSLocalizedString("error", comment: ""), message: NSLocalizedString("network_not_available", comment: ""), theme: .error) case .connected, .allConnected, .someConnected: self.hidePopupMessages() @@ -246,7 +246,7 @@ class OpenHABSitemapViewController: OpenHABViewController, UISearchControllerDel for await activeConnection in NetworkTracker.shared.$activeConnection.values { if let activeConnection { await MainActor.run { - os_log("OpenHABSitemapViewController tracker URL %{PUBLIC}@", log: .viewCycle, type: .info, activeConnection.configuration.url) + logger.info("OpenHABSitemapViewController tracker URL \(activeConnection.configuration.url)") self.openHABRootUrl = activeConnection.configuration.url self.activeConnectionInfo = activeConnection self.selectSitemap() @@ -314,7 +314,7 @@ extension OpenHABSitemapViewController { func restart() { if sitemapViewController == self { - os_log("I am a rootViewController!", log: .viewCycle, type: .info) + logger.info("I am a rootViewController!") } else { sitemapViewController?.pageUrl = "" @@ -412,7 +412,7 @@ extension OpenHABSitemapViewController { ) } } catch { - os_log("%{PUBLIC}@", log: .default, type: .error, error.localizedDescription) + logger.error("\(error.localizedDescription)") DispatchQueue.main.async { if let urlError = error as? URLError, urlError.code == .clientCertificateRejected { self.showPopupMessage( @@ -577,7 +577,7 @@ extension OpenHABSitemapViewController { @discardableResult func pageNetworkStatusChanged() -> Bool { - os_log("OpenHABSitemapViewController pageNetworkStatusChange", log: .remoteAccess, type: .info) + logger.info("OpenHABSitemapViewController pageNetworkStatusChange") guard !pageUrl.isEmpty else { return false } @@ -676,7 +676,7 @@ extension OpenHABSitemapViewController: UITableViewDelegate, UITableViewDataSour if let height = widget?.height { // calculate webview/mapview height and return it. Limited to UIScreen.main.bounds.height let heightValue = height * 44 - os_log("Webview/Mapview height would be %g", log: .viewCycle, type: .info, heightValue) + logger.info("Webview/Mapview height would be \(heightValue)") return min(UIScreen.main.bounds.height, CGFloat(heightValue)) } else { // return default height for webview/mapview as 8 rows diff --git a/openHAB/OpenHABWebViewController.swift b/openHAB/OpenHABWebViewController.swift index 9b759ce8f..8e0386939 100644 --- a/openHAB/OpenHABWebViewController.swift +++ b/openHAB/OpenHABWebViewController.swift @@ -75,7 +75,7 @@ class OpenHABWebViewController: OpenHABViewController { .sink { activeConnection in if let activeConnection { let activeConfiguration = activeConnection.configuration - os_log("OpenHABWebViewController openHAB URL = %{PUBLIC}@", log: .remoteAccess, type: .info, "\(activeConfiguration.url)") + self.logger.info("OpenHABWebViewController openHAB URL = \(activeConfiguration.url)") self.openHABTrackedRootUrl = activeConfiguration.url self.activeConfig = activeConfiguration self.loadWebView(force: false) @@ -86,7 +86,7 @@ class OpenHABWebViewController: OpenHABViewController { NetworkTracker.shared.$status .receive(on: DispatchQueue.main) .sink { status in - os_log("OpenHABWebViewController tracker status %{PUBLIC}@", log: .viewCycle, type: .info, status.rawValue) + self.logger.info("OpenHABWebViewController tracker status \(status.rawValue)") switch status { case .connecting: self.showPopupMessage(seconds: 60, title: NSLocalizedString("connecting", comment: ""), message: "", theme: .info) @@ -187,8 +187,10 @@ class OpenHABWebViewController: OpenHABViewController { navigationController?.setNavigationBarHidden(hideNavBar, animated: true) } + // swiftformat:disable redundantSelf func clearExistingPage() { - os_log("clearExistingPage - webView.url %{PUBLIC}@", log: .wkwebview, type: .info, String(describing: webView.url?.description)) + logger.info("clearExistingPage - webView.url \(String(describing: self.webView.url?.description))") + setHideNavBar(shouldHide: false) // clear out existing page while we load. webView.stopLoading() @@ -196,11 +198,13 @@ class OpenHABWebViewController: OpenHABViewController { } func pageLoadError(message: String) { - os_log("pageLoadError - webView.url %{PUBLIC}@ %{PUBLIC}@", log: .wkwebview, type: .info, String(describing: webView.url?.description), message) + logger.info("pageLoadError - webView.url \(String(describing: self.webView.url?.description)) \(message)") showActivityIndicator(show: false) showPopupMessage(seconds: 60, title: NSLocalizedString("error", comment: ""), message: message, theme: .error) } + // swiftformat:enable redundantSelf + override func reloadView() { currentTarget = "" clearExistingPage() @@ -225,9 +229,9 @@ class OpenHABWebViewController: OpenHABViewController { let jsCode = "window.MainUI.handleCommand('\(command)')" webView.evaluateJavaScript(jsCode) { (_, error) in if let error { - os_log("navigateCommandInternal failed %{PUBLIC}@", log: .wkwebview, type: .error, error.localizedDescription) + self.logger.error("navigateCommandInternal failed \(error.localizedDescription)") } else { - os_log("navigateCommandInternal Success", log: .wkwebview, type: .info) + self.logger.info("navigateCommandInternal Success") } } } @@ -271,9 +275,9 @@ class OpenHABWebViewController: OpenHABViewController { extension OpenHABWebViewController: WKScriptMessageHandler { @MainActor func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { - os_log("WKScriptMessage %{PUBLIC}@", log: OSLog.remoteAccess, type: .info, message.name) + logger.info("WKScriptMessage \(message.name)") if let callbackName = message.body as? String { - os_log("WKScriptMessage %{PUBLIC}@", log: OSLog.remoteAccess, type: .info, callbackName) + logger.info("WKScriptMessage \(callbackName)") switch callbackName { case "exitToApp": showSideMenu() @@ -283,7 +287,7 @@ extension OpenHABWebViewController: WKScriptMessageHandler { setHideNavBar(shouldHide: true) } case "sseConnected-true": - os_log("WKScriptMessage sseConnected is true", log: OSLog.remoteAccess, type: .info) + logger.info("WKScriptMessage sseConnected is true") hidePopupMessages() sseTimer?.invalidate() acceptsCommands = true diff --git a/openHAB/RollershutterCell.swift b/openHAB/RollershutterCell.swift index 553b6192e..28ff03fd0 100644 --- a/openHAB/RollershutterCell.swift +++ b/openHAB/RollershutterCell.swift @@ -15,6 +15,7 @@ import UIKit class RollershutterCell: GenericUITableViewCell { private let feedbackGenerator = UIImpactFeedbackGenerator(style: .light) + private let logger = Logger(subsystem: "org.openhab", category: "RollershutterCell") @IBOutlet private var upButton: UIButton! @IBOutlet private var stopButton: UIButton! @@ -22,13 +23,13 @@ class RollershutterCell: GenericUITableViewCell { @IBOutlet private var customDetailText: UILabel! required init?(coder: NSCoder) { - os_log("RollershutterCell initWithCoder", log: .viewCycle, type: .info) + logger.info("RollershutterCell initWithCoder") super.init(coder: coder) initialize() } override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - os_log("RollershutterCell initWithStyle", log: .viewCycle, type: .info) + logger.info("RollershutterCell initWithStyle") super.init(style: style, reuseIdentifier: reuseIdentifier) initialize() } @@ -48,21 +49,21 @@ class RollershutterCell: GenericUITableViewCell { @objc func upButtonPressed() { - os_log("up button pressed", log: .viewCycle, type: .info) + logger.info("up button pressed") widget.sendCommand("UP") feedbackGenerator.impactOccurred() } @objc func stopButtonPressed() { - os_log("stop button pressed", log: .viewCycle, type: .info) + logger.info("stop button pressed") widget.sendCommand("STOP") feedbackGenerator.impactOccurred() } @objc func downButtonPressed() { - os_log("down button pressed", log: .viewCycle, type: .info) + logger.info("down button pressed") widget.sendCommand("DOWN") feedbackGenerator.impactOccurred() } diff --git a/openHAB/SegmentedUITableViewCell.swift b/openHAB/SegmentedUITableViewCell.swift index fa044b08d..f8ecec766 100644 --- a/openHAB/SegmentedUITableViewCell.swift +++ b/openHAB/SegmentedUITableViewCell.swift @@ -14,6 +14,8 @@ import os.log import UIKit class SegmentedUITableViewCell: GenericUITableViewCell { + let logger = Logger(subsystem: "org.openhab", category: "SegmentedUITableViewCell") + private let feedbackGenerator = UIImpactFeedbackGenerator(style: .light) // @IBOutlet private var customTextLabel: UILabel! @@ -56,7 +58,7 @@ class SegmentedUITableViewCell: GenericUITableViewCell { return } - os_log("Segment pressed %d", log: .default, type: .info, segmentedControl.selectedSegmentIndex) + logger.info("Segment pressed \(segmentedControl.selectedSegmentIndex)") widget.sendCommand(mapping.command) feedbackGenerator.impactOccurred() } diff --git a/openHAB/SettingsView/SettingsView.swift b/openHAB/SettingsView/SettingsView.swift index 08b7a792c..4f2145716 100644 --- a/openHAB/SettingsView/SettingsView.swift +++ b/openHAB/SettingsView/SettingsView.swift @@ -108,7 +108,7 @@ struct SettingsView: View { case .name: sitemaps.sort { $0.name < $1.name } } } catch { - os_log("%{PUBLIC}@", log: .default, type: .error, error.localizedDescription) + logger.error("\(error.localizedDescription)") sitemaps = [] } } diff --git a/openHAB/SliderUITableViewCell.swift b/openHAB/SliderUITableViewCell.swift index 2a0f4df39..214cd9fd7 100644 --- a/openHAB/SliderUITableViewCell.swift +++ b/openHAB/SliderUITableViewCell.swift @@ -14,6 +14,8 @@ import os.log import UIKit class SliderUITableViewCell: GenericUITableViewCell { + let logger = Logger(subsystem: "org.openhab", category: "SliderUITableViewCell") + private var step: Float = 1.0 private var widgetValue: Double { @@ -104,7 +106,7 @@ class SliderUITableViewCell: GenericUITableViewCell { } private func sliderDidChange(toValue value: Double) { - os_log("Slider new value = %g", log: .default, type: .info, value) + logger.info("Slider new value = \(value)") widget.sendCommand(value.valueText(step: Double(step))) } } diff --git a/openHAB/SliderWithSwitchSupportUITableViewCell.swift b/openHAB/SliderWithSwitchSupportUITableViewCell.swift index dfe401e50..e58d3a488 100644 --- a/openHAB/SliderWithSwitchSupportUITableViewCell.swift +++ b/openHAB/SliderWithSwitchSupportUITableViewCell.swift @@ -16,6 +16,8 @@ import OpenHABCore import os.log class SliderWithSwitchSupportUITableViewCell: GenericUITableViewCell { + private let logger = Logger(subsystem: "org.openhab", category: "SliderWithSwitchSupportUITableViewCell") + private var step: Float = 1.0 private var widgetValue: Double { @@ -116,17 +118,17 @@ class SliderWithSwitchSupportUITableViewCell: GenericUITableViewCell { } private func sliderDidChange(toValue value: Double) { - os_log("Slider new value = %g", log: .default, type: .info, value) + logger.info("Slider new value = \(value)") widget.sendCommand(value.valueText(step: Double(step))) } @objc func switchChange() { if (widgetSwitch?.isOn)! { - os_log("Switch to ON", log: .viewCycle, type: .info) + logger.info("Switch to ON") widget.sendCommand("ON") } else { - os_log("Switch to OFF", log: .viewCycle, type: .info) + logger.info("Switch to OFF") widget.sendCommand("OFF") } } diff --git a/openHAB/SwitchUITableViewCell.swift b/openHAB/SwitchUITableViewCell.swift index 2578cf55f..bfb97d9d7 100644 --- a/openHAB/SwitchUITableViewCell.swift +++ b/openHAB/SwitchUITableViewCell.swift @@ -14,6 +14,8 @@ import os.log import UIKit class SwitchUITableViewCell: GenericUITableViewCell { + let logger = Logger(subsystem: "org.openhab", category: "SwitchUITableViewCell") + @IBOutlet private var widgetSwitch: UISwitch! required init?(coder: NSCoder) { @@ -47,10 +49,10 @@ class SwitchUITableViewCell: GenericUITableViewCell { @objc func switchChange() { if (widgetSwitch?.isOn)! { - os_log("Switch to ON", log: .viewCycle, type: .info) + logger.info("Switch to ON") widget.sendCommand("ON") } else { - os_log("Switch to OFF", log: .viewCycle, type: .info) + logger.info("Switch to OFF") widget.sendCommand("OFF") } } diff --git a/openHAB/VideoUITableViewCell.swift b/openHAB/VideoUITableViewCell.swift index e1f734627..b9f0c68e7 100644 --- a/openHAB/VideoUITableViewCell.swift +++ b/openHAB/VideoUITableViewCell.swift @@ -19,6 +19,8 @@ enum VideoEncoding: String { } class VideoUITableViewCell: GenericUITableViewCell, NoIconDisplayableCell { + private let logger = Logger(subsystem: "org.openhab", category: "VideoUITableViewCell") + private var activityIndicator: UIActivityIndicatorView = if #available(iOS 13.0, *) { .init(style: .medium) } else { @@ -34,8 +36,6 @@ class VideoUITableViewCell: GenericUITableViewCell, NoIconDisplayableCell { } } - private let logger = Logger(subsystem: "org.openhab.app", category: "VideoUITableViewCell") - private var playerView: PlayerView! private var mainImageView: UIImageView! private var playerObserver: NSKeyValueObservation? @@ -130,12 +130,12 @@ class VideoUITableViewCell: GenericUITableViewCell, NoIconDisplayableCell { switch playerItem.status { case .failed: - os_log("Failed to load video with URL: %{PUBLIC}@", log: .urlComposition, type: .debug, url.absoluteString) + logger.debug("Failed to load video with URL: \(url.absoluteString)") Task { @MainActor in self.url = nil } case .readyToPlay: - os_log("Loaded video with URL: %{PUBLIC}@", log: .urlComposition, type: .debug, url.absoluteString) + logger.debug("Loaded video with URL: \(url.absoluteString)") default: return } Task { @MainActor in @@ -178,7 +178,7 @@ class VideoUITableViewCell: GenericUITableViewCell, NoIconDisplayableCell { let (byteStream, _) = try await client.processStream(url: url) await handleMJPEGStream(byteStream) } catch { - os_log("Failed to start MJPEG stream: %@", log: .decoding, type: .error, error.localizedDescription) + logger.error("Failed to start MJPEG stream: \(error.localizedDescription)") } } @@ -206,7 +206,7 @@ class VideoUITableViewCell: GenericUITableViewCell, NoIconDisplayableCell { } } } catch { - os_log("Failed to process MJPEG stream: %@", log: .decoding, type: .error, error.localizedDescription) + logger.error("Failed to process MJPEG stream: \(error.localizedDescription)") } } } diff --git a/openHAB/WebUITableViewCell.swift b/openHAB/WebUITableViewCell.swift index 781be6b93..56e8f3b5d 100644 --- a/openHAB/WebUITableViewCell.swift +++ b/openHAB/WebUITableViewCell.swift @@ -14,6 +14,8 @@ import os.log import WebKit class WebUITableViewCell: GenericUITableViewCell, NoIconDisplayableCell { + private let logger = Logger(subsystem: "org.openhab.core", category: "WebUITableViewCell") + private var url: URL? private var widgetWebView: WKWebView! @@ -48,11 +50,13 @@ class WebUITableViewCell: GenericUITableViewCell, NoIconDisplayableCell { } override func displayWidget() { - os_log("webview loading url %{PUBLIC}@", log: .default, type: .info, widget.url) + // swiftformat:disable redundantSelf + logger.info("webview loading url \(self.widget.url)") + // swiftformat:enable redundantSelf let urlString = widget.url.lowercased().hasPrefix("http") ? widget.url : Preferences.currentHomePreferences.localConnectionConfig.url + widget.url guard url?.absoluteString != urlString else { - os_log("webview URL has not changed, abort loading", log: .viewCycle, type: .info) + logger.info("webview URL has not changed, abort loading") return } @@ -66,7 +70,7 @@ class WebUITableViewCell: GenericUITableViewCell, NoIconDisplayableCell { } func setFrame(_ frame: CGRect) { - os_log("setFrame", log: .viewCycle, type: .info) + logger.info("setFrame") super.frame = frame widgetWebView?.reload() } @@ -81,28 +85,30 @@ extension WebUITableViewCell: GenericCellCacheProtocol { extension WebUITableViewCell: WKNavigationDelegate { func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) { - os_log("webview started loading with URL: %{PUBLIC}s", log: .viewCycle, type: .info, widget.url) + // swiftformat:disable:next redundantSelf + logger.info("webview started loading with URL: \(self.widget.url)") } func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { - os_log("webview finished load with URL: %{PUBLIC}s", log: .viewCycle, type: .info, widget.url) + // swiftformat:disable:next redundantSelf + logger.info("webview finished load with URL: \(self.widget.url)") } func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse) async -> WKNavigationResponsePolicy { if let response = navigationResponse.response as? HTTPURLResponse, response.statusCode >= 400 { - os_log("webview failed with status code: %{PUBLIC}i", log: .urlComposition, type: .debug, response.statusCode) + logger.debug("webview failed with status code: \(response.statusCode)") url = nil } return .allow } func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: any Error) { - os_log("webview failed with error: %{PUBLIC}s", log: .urlComposition, type: .debug, error.localizedDescription) + logger.debug("webview failed with error: \(error.localizedDescription)") url = nil } func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: any Error) { - os_log("webview failed with error: %{PUBLIC}s", log: .urlComposition, type: .debug, error.localizedDescription) + logger.debug("webview failed with error: \(error.localizedDescription)") url = nil } diff --git a/openHABWatch/Model/OpenHABWidgetExtension.swift b/openHABWatch/Model/OpenHABWidgetExtension.swift index 5cbfc9504..5b9a1d740 100644 --- a/openHABWatch/Model/OpenHABWidgetExtension.swift +++ b/openHABWatch/Model/OpenHABWidgetExtension.swift @@ -18,7 +18,6 @@ import SwiftUI extension OpenHABWidget { @ViewBuilder func makeView(settings: AppSettings) -> some View { if linkedPage != nil { - // os_log("Selected %{PUBLIC}@", log: .viewCycle, type: .info, pageUrl) NavigationLink(destination: LazyView( // TODO: diff --git a/openHABWatch/Views/Rows/ColorPickerRow.swift b/openHABWatch/Views/Rows/ColorPickerRow.swift index 74416aefa..0467242ac 100644 --- a/openHABWatch/Views/Rows/ColorPickerRow.swift +++ b/openHABWatch/Views/Rows/ColorPickerRow.swift @@ -15,6 +15,7 @@ import SFSafeSymbols import SwiftUI struct ColorPickerRow: View { + let logger = Logger(subsystem: "org.openhab", category: "ColorPickerRow") @ObservedObject var widget: OpenHABWidget @ObservedObject var settings = AppSettings.shared var body: some View { @@ -55,12 +56,12 @@ struct ColorPickerRow: View { } func upButtonPressed() { - os_log("ON button pressed", log: .command, type: .info) + logger.info("ON button pressed") widget.sendCommand("ON") } func downButtonPressed() { - os_log("OFF button pressed", log: .command, type: .info) + logger.info("OFF button pressed") widget.sendCommand("OFF") } } diff --git a/openHABWatch/Views/Rows/SliderRow.swift b/openHABWatch/Views/Rows/SliderRow.swift index a88c78355..cd6f8c0b3 100644 --- a/openHABWatch/Views/Rows/SliderRow.swift +++ b/openHABWatch/Views/Rows/SliderRow.swift @@ -14,6 +14,8 @@ import os.log import SwiftUI struct SliderRow: View { + let logger = Logger(subsystem: "org.openhab", category: "SliderRow") + @ObservedObject var widget: OpenHABWidget @EnvironmentObject var settings: AppSettings @State private var pendingValue: Double? @@ -23,7 +25,7 @@ struct SliderRow: View { pendingValue ?? widget.adjustedValue }, set: { newValue in - os_log("SliderRow new value = %g", log: .default, type: .info, newValue) + logger.info("SliderRow new value = \(newValue)") pendingValue = newValue DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { // 500ms delay if pendingValue == newValue { // Ensure no new updates came in diff --git a/openHABWatch/Views/Rows/SliderWithSwitchSupportRow.swift b/openHABWatch/Views/Rows/SliderWithSwitchSupportRow.swift index ffefca687..1223b752a 100644 --- a/openHABWatch/Views/Rows/SliderWithSwitchSupportRow.swift +++ b/openHABWatch/Views/Rows/SliderWithSwitchSupportRow.swift @@ -14,6 +14,8 @@ import os.log import SwiftUI struct SliderWithSwitchSupportRow: View { + private let logger = Logger(subsystem: "org.openhab", category: "SliderWithSwitchSupportRow") + @ObservedObject var widget: OpenHABWidget @EnvironmentObject var settings: AppSettings @State private var pendingValue: Double? @@ -24,7 +26,7 @@ struct SliderWithSwitchSupportRow: View { pendingValue ?? widget.adjustedValue }, set: { newValue in - os_log("SliderWithSwitchSupportRow new value = %g", log: .default, type: .info, newValue) + logger.info("SliderWithSwitchSupportRow new value = \(newValue)") pendingValue = newValue DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { // 500ms delay if pendingValue == newValue { // Ensure no new updates came in @@ -45,10 +47,10 @@ struct SliderWithSwitchSupportRow: View { }, set: { if $0 { - os_log("Switch to ON", log: .viewCycle, type: .info) + logger.info("Switch to ON") widget.sendCommand(widget.maxValue.valueText(step: widget.step)) } else { - os_log("Switch to OFF", log: .viewCycle, type: .info) + logger.info("Switch to OFF") widget.sendCommand(widget.minValue.valueText(step: widget.step)) } } diff --git a/openHABWatch/Views/Rows/SwitchRow.swift b/openHABWatch/Views/Rows/SwitchRow.swift index aea12ee89..54d874e48 100644 --- a/openHABWatch/Views/Rows/SwitchRow.swift +++ b/openHABWatch/Views/Rows/SwitchRow.swift @@ -14,6 +14,8 @@ import os.log import SwiftUI struct SwitchRow: View { + private let logger = Logger(subsystem: "org.openhab", category: "SwitchRow") + @ObservedObject var widget: OpenHABWidget @EnvironmentObject var settings: AppSettings @@ -23,10 +25,10 @@ struct SwitchRow: View { get: { widget.stateEnumBinding.boolState }, set: { if $0 { - os_log("Switch to ON", log: .viewCycle, type: .info) + logger.info("Switch to ON") widget.sendCommand("ON") } else { - os_log("Switch to OFF", log: .viewCycle, type: .info) + logger.info("Switch to OFF") widget.sendCommand("OFF") } widget.stateEnumBinding = .switcher($0) diff --git a/openHABWatch/Views/Utils/ColorSelection.swift b/openHABWatch/Views/Utils/ColorSelection.swift index df0719414..4dfaccf89 100644 --- a/openHABWatch/Views/Utils/ColorSelection.swift +++ b/openHABWatch/Views/Utils/ColorSelection.swift @@ -50,6 +50,8 @@ enum DragState { } struct ColorSelection: View { + private let logger = Logger(subsystem: "org.openhab", category: "ColorSelection") + @GestureState var thumb: DragState = .inactive @State var hue = 0.5 @@ -104,8 +106,7 @@ struct ColorSelection: View { } .onEnded { value in guard case .second(true, let drag?) = value else { return } - os_log("Translation x y = %g, %g", log: .default, type: .info, drag.translation.width, drag.translation.height) - + logger.info("Translation x y = \(drag.translation.width), \(drag.translation.height)") xpos += Double(drag.translation.width) ypos += Double(drag.translation.height) } From 32a8e9e7d08bb6ef0258bd83b93b2f29b7d72de9 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Mon, 30 Jun 2025 12:53:55 +0200 Subject: [PATCH 232/476] In order for OpenHABWidget to become Sendable, all its properties must be Sendable. image of type UImage is not. OpenHABWidget is used in only in NewImageUITableViewCell as a cachedImage. Transfer the functionality thereto. (#877) Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../Sources/OpenHABCore/Model/OpenHABWidget.swift | 1 - openHAB/NewImageUITableViewCell.swift | 12 +++++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift index c2ff12aa0..db47942b4 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift @@ -90,7 +90,6 @@ public class OpenHABWidget: NSObject, MKAnnotation, Identifiable, ObservableObje @Published public var item: OpenHABItem? public var linkedPage: OpenHABPage? public var mappings: [OpenHABWidgetMapping] = [] - public var image: UIImage? public var widgets: [OpenHABWidget] = [] public var visibility = true public var switchSupport = false diff --git a/openHAB/NewImageUITableViewCell.swift b/openHAB/NewImageUITableViewCell.swift index a4c8e40a6..046aef2fe 100644 --- a/openHAB/NewImageUITableViewCell.swift +++ b/openHAB/NewImageUITableViewCell.swift @@ -29,6 +29,7 @@ class NewImageUITableViewCell: GenericUITableViewCell, NoIconDisplayableCell { private var refreshTimer: Timer? private var chartStyle: ChartStyle = .light private var activeTask: Task? + private var cachedImage: UIImage? var openHABRootUrl: String? @@ -100,10 +101,10 @@ class NewImageUITableViewCell: GenericUITableViewCell, NoIconDisplayableCell { } override func displayWidget() { - if widget?.image == nil { + if cachedImage == nil { loadImage() } else { - mainImageView.image = widget?.image + mainImageView.image = cachedImage } // If widget have a refresh rate configured, i.e. different from zero, schedule an image update timer if widget.refresh != 0 { @@ -126,7 +127,7 @@ class NewImageUITableViewCell: GenericUITableViewCell, NoIconDisplayableCell { func loadImage() { switch widgetPayload { case let .embedded(image): - widget?.image = image + cachedImage = image mainImageView.image = image didLoad?() case let .link(url): @@ -169,8 +170,8 @@ class NewImageUITableViewCell: GenericUITableViewCell, NoIconDisplayableCell { let client = HTTPClient(configuration: config) let (data, _): (Data, URLResponse) = try await client.doRequest(baseURL: url, timeout: 10.0, type: .data, cacheingPolicy: !shouldCache ? .reloadIgnoringCacheData : .useProtocolCachePolicy) await MainActor.run { - self.mainImageView?.image = UIImage(data: data) - self.widget?.image = UIImage(data: data) + self.cachedImage = UIImage(data: data) + self.mainImageView?.image = self.cachedImage self.didLoad?() } } catch { @@ -199,5 +200,6 @@ class NewImageUITableViewCell: GenericUITableViewCell, NoIconDisplayableCell { extension NewImageUITableViewCell: GenericCellCacheProtocol { func invalidateCache() { refreshTimer?.invalidate() + cachedImage = nil } } From 9b391c7509e9b6999eea836968c74f3525d6f901 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Mon, 30 Jun 2025 19:27:45 +0200 Subject: [PATCH 233/476] Removal of temp edited files Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../OpenHABCore/Model/OpenHABItem.swift | 2 +- OsLogRewriter/HTTPClient.swift | 276 ------ OsLogRewriter/OpenHABRootViewController.swift | 756 -------------- .../OpenHABSitemapViewController.swift | 927 ------------------ OsLogRewriter/Preferences.swift | 415 -------- 5 files changed, 1 insertion(+), 2375 deletions(-) delete mode 100644 OsLogRewriter/HTTPClient.swift delete mode 100644 OsLogRewriter/OpenHABRootViewController.swift delete mode 100644 OsLogRewriter/OpenHABSitemapViewController.swift delete mode 100644 OsLogRewriter/Preferences.swift diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABItem.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABItem.swift index 24025d15c..45fd7d2af 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABItem.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABItem.swift @@ -30,7 +30,7 @@ public struct OpenHABItem: Sendable { case switchItem = "Switch" case undetermined = "" // Relevant only for SitemapWidgetEvent } - + private let logger = Logger(subsystem: "org.openhab", category: "OpenHABItem") public var type: ItemType? diff --git a/OsLogRewriter/HTTPClient.swift b/OsLogRewriter/HTTPClient.swift deleted file mode 100644 index 388618a55..000000000 --- a/OsLogRewriter/HTTPClient.swift +++ /dev/null @@ -1,276 +0,0 @@ -// Copyright (c) 2010-2025 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 - -@preconcurrency import Foundation -import os - -public enum HTTPClientError: Error { - case serverTrustEvaluationFailed(reason: String) - case noDataforItem - case noDataForProperties - case baseURLIsNil - case httpError(Int) - case couldNotRegister - case couldNotLoadNotification - case failedtoFetchMJPEG - case noConfiguration - - var debugDescription: String { - switch self { - case .noDataforItem: - "No data for item" - case let .serverTrustEvaluationFailed(reason): - "server trust evaluation failed: \(reason)" - case .noDataForProperties: - "No data for properties" - case .baseURLIsNil: - "Base URL is nil" - case let .httpError(statusCode): - "HTTP error \(statusCode)" - case .couldNotRegister: - "Could not register" - case .couldNotLoadNotification: - "Could not load notification" - case .failedtoFetchMJPEG: - "Failed to fetch MJPEG" - case .noConfiguration: - "No configuration" - } - } -} - -public enum CertificateEvaluateResult: Sendable { - case undecided - case deny - case permitOnce - case permitAlways -} - -actor CertificateStore { - private var trustedCertificates: [String: Data] = [:] - - private func getPersistencePath() -> URL { - #if os(watchOS) - let documentsDirectory = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true)[0] - return URL(fileURLWithPath: documentsDirectory).appendingPathComponent("trustedCertificates") - #else - FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.org.openhab.app")!.appendingPathComponent("trustedCertificates") - #endif - } - - private func saveTrustedCertificates() { - do { - let data = try PropertyListEncoder().encode(trustedCertificates) - try data.write(to: getPersistencePath()) - } catch { - logger.info("Could not save trusted certificates") - } - } - - private func loadTrustedCertificates() { - var decodableTrustedCertificates: [String: Data] = [:] - do { - let rawdata = try Data(contentsOf: getPersistencePath()) - let decoder = PropertyListDecoder() - decodableTrustedCertificates = try decoder.decode([String: Data].self, from: rawdata) - trustedCertificates = decodableTrustedCertificates - } catch { - // if Decodable fails, fall back to NSKeyedArchiver - do { - let rawdata = try Data(contentsOf: getPersistencePath()) - if let unarchivedTrustedCertificates = try? NSKeyedUnarchiver.unarchivedObject(ofClasses: [NSDictionary.self, NSString.self, NSData.self], from: rawdata) as? [String: Data] { - trustedCertificates = unarchivedTrustedCertificates - saveTrustedCertificates() // Ensure that data is written in new format - } - } catch { - logger.info("Could not load trusted certificates") - } - } - } - - private func initializeCertificatesStore() { - logger.info("Initializing cert store") - loadTrustedCertificates() - if trustedCertificates.isEmpty { - logger.info("No cert store, creating") - trustedCertificates = [:] - saveTrustedCertificates() - } else { - logger.info("Loaded existing cert store") - } - } - - public func storeCertificateData(_ certificate: Data?, forDomain domain: String) { - trustedCertificates[domain] = certificate - saveTrustedCertificates() - } - - public func certificateData(forDomain domain: String) -> Data? { - guard let data = trustedCertificates[domain] else { return nil } - return data - } -} - -public final class HTTPClient: NSObject { - // MARK: - Properties - - public enum SessionType { - case download - case data - case bytes - } - - // this can be changed if we detect another server - public let baseURL: URL? - - private let logger = Logger(subsystem: "org.openhab.core", category: "HTTPClient") - - private let configuration: ConnectionConfiguration - public let session: URLSession - public let delegate: HTTPClientDelegate - - public init(baseURL: URL? = nil, configuration: ConnectionConfiguration) { - self.configuration = configuration - self.baseURL = baseURL - delegate = HTTPClientDelegate(with: configuration) - let config = URLSessionConfiguration.default - session = URLSession(configuration: config, delegate: delegate, delegateQueue: nil) - super.init() - } - - public func processStream(url: URL) async throws -> (URLSession.AsyncBytes, URLResponse) { - do { - return try await doRequest(baseURL: url, type: .bytes) - } catch { - logger.error("Failed to fetch MJPEG stream: \(error.localizedDescription)") - throw HTTPClientError.failedtoFetchMJPEG - } - } - - @discardableResult - public func register(prefsURL: String, - deviceToken: String, - deviceId: String, - deviceName: String) async throws -> String? { - if let url = Endpoint.appleRegistration(prefsURL: prefsURL, deviceToken: deviceToken, deviceId: deviceId, deviceName: deviceName).url { - let (data, _): (Data, URLResponse) = try await doRequest(baseURL: url, type: .data) - struct CloudUserResponse: Decodable { let userId: String } - return try? JSONDecoder().decode(CloudUserResponse.self, from: data).userId - } else { - throw HTTPClientError.couldNotRegister - } - } - - public func notification(url: URL) async throws -> Data { - let (data, _): (Data, URLResponse) = try await doRequest(baseURL: url, type: .data) - return data - } - - public func notification(urlString: String) async throws -> [OpenHABNotification] { - guard let url = Endpoint.notification(prefsURL: urlString).url else { throw HTTPClientError.couldNotLoadNotification } - let data = try await notification(url: url) - - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .formatted(DateFormatter.iso8601Full) - let codingDatas = try data.decoded(as: [OpenHABNotification.CodingData].self, using: decoder) - return codingDatas.map(\.openHABNotification) - } - - /** - Initiates a download request to a specified base URL for a specified path and returns the file URL via a completion handler. - - - Parameters: - - url - - Returns: - - response: The URL response object providing response metadata, such as HTTP headers and status code. - - error: An error object that indicates why the request failed, or `nil` if the request was successful. - */ - - public func downloadFile(url: URL) async throws -> (URL, URLResponse) { - let (fileURL, response): (URL, URLResponse) = try await doRequest(baseURL: url, path: nil, type: .download) - - return (fileURL, response) - } - - public func doRequest(baseURL: URL?, - path: String? = nil, - headers: [String: String]? = nil, - timeout: TimeInterval = 60.0, - body: String? = nil, - type: SessionType, - cacheingPolicy: URLRequest.CachePolicy = .useProtocolCachePolicy) async throws -> (T, URLResponse) { - guard var url = baseURL ?? self.baseURL else { - logger.info("doRequest ERROR: Base URL is nil") - throw HTTPClientError.baseURLIsNil - } - - if let path { - url.appendPathComponent(path) - } - - var request = URLRequest(url: url) - request.httpMethod = "GET" - request.timeoutInterval = timeout - - if let headers { - for (key, value) in headers { - request.setValue(value, forHTTPHeaderField: key) - } - } - if let body { - request.httpBody = body.data(using: .utf8) - request.setValue("text/plain", forHTTPHeaderField: "Content-Type") - } - - if cacheingPolicy != .useProtocolCachePolicy { - request.cachePolicy = cacheingPolicy - } - - let (result, response): (T, URLResponse) = try await performRequest(request: request, type: type) - if let response = response as? HTTPURLResponse { - if (400 ... 599).contains(response.statusCode) { - logger.error("HTTP error from URL \(url.absoluteString) : %{public}d") - throw HTTPClientError.httpError(response.statusCode) - } else { - logger.info("Response from URL \(url.absoluteString) : %{public}d") - return (result, response) - } - } - fatalError() - } - - private func performRequest(request: URLRequest, type: SessionType = .data) async throws -> (T, URLResponse) { - var request = request - - let username = configuration.username - let password = configuration.password - let alwaysSendBasicAuth = configuration.alwaysSendBasicAuth - - if request.url?.host?.hasSuffix("myopenhab.org") == true || alwaysSendBasicAuth, !username.isEmpty, !password.isEmpty { - request.setValue(basicAuthHeader(username: username, password: password), forHTTPHeaderField: "Authorization") - } - - switch type { - case .download: - return try await session.download(for: request) as! (T, URLResponse) - case .data: - return try await session.data(for: request) as! (T, URLResponse) - case .bytes: - return try await session.bytes(for: request) as! (T, URLResponse) - } - } -} - -public extension Notification.Name { - static let evaluateServerTrust = Notification.Name("evaluateServerTrust") - static let evaluateCertificateMismatch = Notification.Name("evaluateCertificateMismatch") - static let acceptedServerCertificatesChanged = Notification.Name("acceptedServerCertificatesChanged") -} diff --git a/OsLogRewriter/OpenHABRootViewController.swift b/OsLogRewriter/OpenHABRootViewController.swift deleted file mode 100644 index f2afd37df..000000000 --- a/OsLogRewriter/OpenHABRootViewController.swift +++ /dev/null @@ -1,756 +0,0 @@ -// Copyright (c) 2010-2025 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 Combine -import FirebaseCrashlytics -import Foundation -import OpenHABCore -import os.log -import SafariServices -import SFSafeSymbols -import SideMenu -import SwiftUI -import UIKit - -enum TargetController { - case webview - case settings - case sitemap(String) - case notifications - case browser(String) - case tile(String) - case homeSelection -} - -protocol ModalHandler: AnyObject { - func modalDismissed(to: TargetController) -} - -private let logger = Logger(subsystem: "org.openhab.UI", category: "OpenHABRootViewController") - -// swiftlint:disable type_body_length -class OpenHABRootViewController: UIViewController { - var currentView: OpenHABViewController! - var isDemoMode = false - var cancellables = Set() - - private var apsRegistrationData: [AnyHashable: Any]? - - private lazy var webViewController: OpenHABWebViewController = { - let storyboard = UIStoryboard(name: "Main", bundle: Bundle.main) - var viewController = storyboard.instantiateViewController(withIdentifier: "OpenHABWebViewController") as! OpenHABWebViewController - return viewController - }() - - private lazy var sitemapViewController: OpenHABSitemapViewController = { - let storyboard = UIStoryboard(name: "Main", bundle: Bundle.main) - var viewController = storyboard.instantiateViewController(withIdentifier: "OpenHABPageViewController") as! OpenHABSitemapViewController - return viewController - }() - - private var activeConnection: ConnectionInfo? - - override func viewDidLoad() { - super.viewDidLoad() - logger.info("OpenHABRootViewController viewDidLoad") - setupSideMenu() - - NotificationCenter.default.addObserver(self, selector: #selector(OpenHABRootViewController.handleApsRegistration(_:)), name: NSNotification.Name("apsRegistered"), object: nil) - - if Crashlytics.crashlytics().didCrashDuringPreviousExecution(), !Preferences.sendCrashReports { - let alertController = UIAlertController(title: NSLocalizedString("crash_detected", comment: "").capitalized, message: NSLocalizedString("crash_reporting_info", comment: ""), preferredStyle: .alert) - alertController.addAction( - UIAlertAction(title: NSLocalizedString("activate", comment: ""), style: .default) { _ in - Preferences.sendCrashReports = true - Crashlytics.crashlytics().sendUnsentReports() - } - ) - alertController.addAction( - UIAlertAction(title: NSLocalizedString("privacy_policy", comment: ""), style: .default) { [weak self] _ in - let webViewController = SFSafariViewController(url: URL.privacyPolicy) - webViewController.configuration.barCollapsingEnabled = true - self?.present(webViewController, animated: true) - } - ) - alertController.addAction( - UIAlertAction(title: NSLocalizedString("cancel", comment: ""), style: .default) { _ in - Crashlytics.crashlytics().deleteUnsentReports() - } - ) - present(alertController, animated: true) - } - - #if DEBUG - if ProcessInfo.processInfo.environment["UITest"] != nil { - // this is here to continue to make existing tests work, need to look at this later - Preferences.modifyActiveHome { homePreferences in - homePreferences.demomode = true - } - } - // setup accessibilityIdentifiers for UITest - navigationItem.rightBarButtonItem?.accessibilityIdentifier = "HamburgerButton" - #endif - // save this so we know if its changed later - isDemoMode = Preferences.currentHomePreferences.demomode - switchToSavedView() - setupTracker() - } - - override func viewWillAppear(_ animated: Bool) { - logger.info("OpenHABRootController viewWillAppear") - super.viewWillAppear(animated) - navigationController?.navigationBar.prefersLargeTitles = true - // if we have turned demo mode off/on, reset view - if isDemoMode != Preferences.currentHomePreferences.demomode { - switchToSavedView() - isDemoMode = Preferences.currentHomePreferences.demomode - } - } - - fileprivate func setupTracker() { - let serverInfo = Preferences.$currentHomePreferences - - // Register for certificate trust notifications - NotificationCenter.default.addObserver( - forName: .evaluateServerTrust, - object: nil, - queue: nil - ) { [weak self] notification in - guard - let summary = notification.userInfo?["summary"] as? String, - let domain = notification.userInfo?["domain"] as? String, - let client = notification.object as? HTTPClient - else { - return - } - - let delegate = client.delegate - - Task { @MainActor in - self?.handleCertificateTrust( - summary: summary, - domain: domain, - delegate: delegate, - messageTemplateKey: "ssl_certificate_invalid" - ) - } - } - - NotificationCenter.default.addObserver( - forName: .evaluateCertificateMismatch, - object: nil, - queue: nil - ) { [weak self] notification in - guard - let summary = notification.userInfo?["summary"] as? String, - let domain = notification.userInfo?["domain"] as? String, - let client = notification.object as? HTTPClient - - else { - return - } - - let delegate = client.delegate - - Task { @MainActor in - self?.handleCertificateTrust( - summary: summary, - domain: domain, - delegate: delegate, - messageTemplateKey: "ssl_certificate_no_match" - ) - } - } - - NotificationCenter.default.addObserver( - forName: .acceptedServerCertificatesChanged, - object: nil, - queue: nil - ) { _ in - Task { @MainActor in - WatchMessageService.singleton.syncPreferencesToWatch() - NetworkTracker.shared.restartTracking() - } - } - - serverInfo.debounce(for: .milliseconds(500), scheduler: RunLoop.main) // ensures if multiple values are saved, we get called once - .sink { homeSettings in - let localConnectionConfig = homeSettings.localConnectionConfig - let remoteConnectionConfig = homeSettings.remoteConnectionConfig - let demomode = homeSettings.demomode - - Task { - if demomode { - await NetworkTracker.shared.startTracking(connectionConfigurations: [ - ConnectionConfiguration( - url: "https://demo.openhab.org", - username: "", - password: "", - priority: 0 - ) - ]) - } else { - await NetworkTracker.shared.startTracking(connectionConfigurations: [ - localConnectionConfig, - remoteConnectionConfig - ]) - } - } - } - .store(in: &cancellables) - - NetworkTracker.shared.$activeConnection - .receive(on: DispatchQueue.main) - .sink { [weak self] activeConnection in - if let activeConnection { - self?.activeConnection = activeConnection - } - } - .store(in: &cancellables) - } - - fileprivate func setupSideMenu() { - let hamburgerButtonItem: UIBarButtonItem - let imageConfig = UIImage.SymbolConfiguration(textStyle: .largeTitle) - let buttonImage = UIImage(systemSymbol: .line3Horizontal, withConfiguration: imageConfig) - let button = UIButton(type: .custom) - button.setImage(buttonImage, for: .normal) - button.addTarget(self, action: #selector(OpenHABRootViewController.rightDrawerButtonPress(_:)), for: .touchUpInside) - hamburgerButtonItem = UIBarButtonItem(customView: button) - hamburgerButtonItem.customView?.heightAnchor.constraint(equalToConstant: 30).isActive = true - navigationItem.setRightBarButton(hamburgerButtonItem, animated: true) - - // Define the menus - - let presentationStyle: SideMenuPresentationStyle = .viewSlideOutMenuIn - presentationStyle.presentingEndAlpha = 1 - presentationStyle.onTopShadowOpacity = 0.5 - var settings = SideMenuSettings() - settings.presentationStyle = presentationStyle - settings.statusBarEndAlpha = 0 - - SideMenuManager.default.rightMenuNavigationController?.settings = settings - - let networkTracker = NetworkTracker.shared - let drawerView = DrawerView { mode in - self.handleDismiss(mode: mode) - } - .environmentObject(networkTracker) - let hostingController = UIHostingController(rootView: drawerView) - let menu = SideMenuNavigationController(rootViewController: hostingController) - - SideMenuManager.default.rightMenuNavigationController = menu - - // Enable gestures. The left and/or right menus must be set up above for these to work. - // Note that these continue to work on the Navigation Controller independent of the View Controller it displays! - SideMenuManager.default.addPanGestureToPresent(toView: navigationController!.navigationBar) - SideMenuManager.default.addScreenEdgePanGesturesToPresent(toView: navigationController!.view, forMenu: .right) - } - - private func openTileURL(_ urlString: String) { - // Use SFSafariViewController in SwiftUI with UIViewControllerRepresentable - // Dependent on $OPENHAB_CONF/services/runtime.cfg - // Can either be an absolute URL, a path (sometimes malformed) - guard !urlString.isEmpty else { return } - - let url: URL? - if urlString.hasPrefix("http") || urlString.hasPrefix("https") { - url = URL(string: urlString) - } else { - guard let rootUrl = activeConnection?.configuration.url else { - logger.error("openTileURL failed: no active connection URL") - return - } - url = Endpoint.resource(openHABRootUrl: rootUrl, path: urlString.prepare()).url - } - openURL(url: url) - } - - private func openURL(url: URL?) { - if let url { - let config = SFSafariViewController.Configuration() - config.entersReaderIfAvailable = true - let vc = SFSafariViewController(url: url, configuration: config) - present(vc, animated: true) - } - } - - private func handleDismiss(mode: TargetController) { - switch mode { - case .webview: - // Handle webview navigation or state update - print("Dismissed to WebView") - SideMenuManager.default.rightMenuNavigationController?.dismiss(animated: true) - switchView(target: .webview) - case .settings: - print("Dismissed to Settings") - SideMenuManager.default.rightMenuNavigationController?.dismiss(animated: true) { - self.modalDismissed(to: .settings) - } - case let .sitemap(sitemap): - Preferences.modifyActiveHome { homePreferences in - homePreferences.defaultSitemap = sitemap - } - SideMenuManager.default.rightMenuNavigationController?.dismiss(animated: true) { - self.modalDismissed(to: .sitemap(sitemap)) - } - case .notifications: - SideMenuManager.default.rightMenuNavigationController?.dismiss(animated: true) { - self.modalDismissed(to: .notifications) - } - case let .browser(urlString): - SideMenuManager.default.rightMenuNavigationController?.dismiss(animated: true) { - self.modalDismissed(to: .browser(urlString)) - } - case let .tile(urlString): - SideMenuManager.default.rightMenuNavigationController?.dismiss(animated: true) { - self.modalDismissed(to: .tile(urlString)) - } - case .homeSelection: - print("Dismissed to Home Selection") - SideMenuManager.default.rightMenuNavigationController?.dismiss(animated: true) { - self.modalDismissed(to: .homeSelection) - } - } - } - - @objc - func rightDrawerButtonPress(_ sender: Any?) { - showSideMenu() - } - - @objc - func handleApsRegistration(_ note: Notification?) { - logger.info("handleApsRegistration") - apsRegistrationData = note?.userInfo - subscribeToOpenhabConnectionChanges() - } - - private func subscribeToOpenhabConnectionChanges() { - struct UuidWithConnection: Hashable, Equatable { - let uuid: UUID - let connection: ConnectionConfiguration // not only URL, because auth and certs might be relevant for establishing the connection - } - - let storedOpenHabConnections = Preferences.$storedHomes - .debounce(for: .seconds(1), scheduler: RunLoop.main) // avoid overexcited registrations / deregistrations in batch updates - .map { updatedPreferences in // we want to recognize changes in the OpenHab URLs for any of the homes - Set(updatedPreferences.compactMap { storedWithUuid in - let (uuid, homeConfig) = storedWithUuid - guard let connection = Preferences.getNotificationConnection(of: homeConfig) else { return nil } - return UuidWithConnection(uuid: uuid, connection: connection) - }) - } - - // create a tuple that lets us inspect the previous value - let connectionsWithPreviousValues = storedOpenHabConnections - .scan((previous: Set(), current: Set())) { previous, current in - (previous: previous.current, current: current) - } - - let differences = connectionsWithPreviousValues.map { (previous, current) in // diff set of previous and current OpenHab URLs - (newValues: current.subtracting(previous), deletedValues: previous.subtracting(current)) - } - - let openhabConnectionSubscription = differences.sink { [weak self] diff in - logger.info("openhabConnectionSubscription updated") - for newHome in diff.newValues { - logger.info("openhabConnectionSubscription uuid \(newHome.uuid) registering for push notifications ") - self?.registerHome(uuid: newHome.uuid, connection: newHome.connection) - } - for deletedHome in diff.deletedValues { - // TODO: implement deregistration - logger.warning("APNS Deregistration is missing (wanted to deregister \(deletedHome.connection.url))") - } - } - - cancellables.insert(openhabConnectionSubscription) - } - - private func registerHome(uuid: UUID, connection: ConnectionConfiguration) { - guard let apsRegistrationData else { - logger.fault("Cannot register homes for push notifications, no notification registration data available") - return - } - guard let deviceId = apsRegistrationData["deviceId"] as? String, - let deviceToken = apsRegistrationData["deviceToken"] as? String, - let deviceName = apsRegistrationData["deviceName"] as? String else { - return - } - logger.info("Registering notifications with \(connection.url)") - _ = registerHome(uuid, connection, deviceToken, deviceId, deviceName) - } - - private func registerHome(_ uuid: UUID, _ config: ConnectionConfiguration, _ deviceToken: String, _ deviceId: String, _ deviceName: String) -> Task { - Task { - do { - let client = HTTPClient(configuration: config) - if let cloudUserId = try await client.register(prefsURL: config.url, deviceToken: deviceToken, deviceId: deviceId, deviceName: deviceName) { - Preferences.setCloudUserId(cloudUserId, for: uuid) - logger.info("my.openHAB registration succeeded with cloudUserId \(cloudUserId)") - } - logger.info("my.openHAB registration succeeded without cloudUserId") - } catch { - logger.error("my.openHAB registration failed \(error.localizedDescription)") - } - } - } - - func handleNotification(action: String?, cloudUserId: String?) { - guard let action else { return } - - logger.info("handleNotification cloudUserId: \(cloudUserId ?? "")") - if let cloudUserId, let targetHome = Preferences.storedHome(forCloudUserId: cloudUserId), Preferences.currentHomePreferences.remoteConnectionConfig.cloudUserId != cloudUserId { - // if we need to switch homes, disconnnect the tracking fist,and wait for the tracker to start again with the updated preferences - Task { - await NetworkTracker.shared.stopTracking() - logger.info("Switching to home \(targetHome.id)") - Preferences.switchActiveHome(to: targetHome.id) - await NetworkTracker.shared.waitForActiveConnection() - handleNotificationInternal(action) - } - return - } - handleNotificationInternal(action) - } - - private func handleNotificationInternal(_ action: String?) { - logger.info("handleNotificationInternal: \(action ?? "")") - - guard let action else { return } - - let cmd = action.split(separator: ":").dropFirst().joined(separator: ":") - - switch true { - case action.hasPrefix("ui"): - uiCommandAction(cmd) - case action.hasPrefix("command"): - sendCommandAction(cmd) - case action.hasPrefix("http"): - httpCommandAction(action) - case action.hasPrefix("app"): - appCommandAction(action) - case action.hasPrefix("rule"): - ruleCommandAction(action) - default: - return - } - } - - // Helper function to safely call the completion handler on the main thread - private func callCompletionHandler(_ completionHandler: (() -> Void)?) { - if let completionHandler { - DispatchQueue.main.async { - completionHandler() - } - } - } - - private func uiCommandAction(_ command: String) { - logger.info("navigateCommandAction: \(command)") - let regexPattern = /^(\/basicui\/app\\?.*|\/.*|.*)$/ - if let firstMatch = command.firstMatch(of: regexPattern) { - let path = String(firstMatch.1) - logger.info("navigateCommandAction path: \(path)") - if path.starts(with: "/basicui/app?") { - if currentView != sitemapViewController { - switchView(target: .sitemap("")) - } - if let urlComponents = URLComponents(string: path) { - let queryItems = urlComponents.queryItems - let sitemap = queryItems?.first { $0.name == "sitemap" }?.value - let subview = queryItems?.first { $0.name == "w" }?.value - if let sitemap { - Task { - await sitemapViewController.pushSitemap(name: sitemap, path: subview) - } - } - } - } else { - if currentView != webViewController { - switchView(target: .webview) - } - if path.starts(with: "/") { - // have the webview load this path itself - webViewController.loadWebView(force: true, path: path) - } else { - // have the mainUI handle the navigation - webViewController.navigateCommand(path) - } - } - } else { - logger.error("Invalid regex: \(command)") - } - } - - private func sendCommandAction(_ action: String) { - let components = action.split(separator: ":") - guard components.count == 2 else { - return - } - - let itemName = String(components[0]) - let itemCommand = String(components[1]) - Task { - do { - logger.info("Sending command") - try await NetworkTracker.shared.send(to: itemName, command: itemCommand) - } catch NetworkTrackerError.noActiveConnection { - displayErrorNotification("Could not find server") - } catch { - displayErrorNotification("Failed to establish a connection: \(error.localizedDescription)") - logger.error("Could not send data \(error.localizedDescription)") - } - } - } - - private func displayErrorNotification(_ message: String, completionHandler: (() -> Void)? = nil) { - let content = UNMutableNotificationContent() - content.title = "Could not send command" - content.body = message - content.sound = UNNotificationSound.default - - // Create the request - let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil) - - // Schedule the request with the notification center - UNUserNotificationCenter.current().add(request) { error in - if let error { - print("Error scheduling notification: \(error.localizedDescription)") - } - } - } - - private func httpCommandAction(_ command: String) { - if let url = URL(string: command) { - let vc = SFSafariViewController(url: url) - present(vc, animated: true) - } - } - - private func appCommandAction(_ command: String) { - let content = command.dropFirst(4) // Remove "app:" - let pairs = content.split(separator: ",") - for pair in pairs { - let keyValue = pair.split(separator: "=", maxSplits: 1) - guard keyValue.count == 2 else { continue } - if keyValue[0] == "ios" { - if let url = URL(string: String(keyValue[1])) { - logger.error("appCommandAction opening \(String(keyValue[0])) \(String(keyValue[1]))") - UIApplication.shared.open(url) - return - } - } - } - } - - private func ruleCommandAction(_ command: String) { - let components = command.split(separator: ":", maxSplits: 2) - - guard components.count == 3, components[0] == "rule" else { return } - - let uuid = String(components[1]) - let propertiesString = String(components[2]) - - let propertyPairs = propertiesString.split(separator: ",") - var properties: [String: String] = [:] - - for pair in propertyPairs { - let keyValue = pair.split(separator: "=", maxSplits: 1) - if keyValue.count == 2 { - let key = String(keyValue[0]) - let value = String(keyValue[1]) - properties[key] = value - } - } - Task { - do { - logger.error("Sending command") - try await NetworkTracker.shared.runNow(ruleUID: uuid, payload: properties) - logger.info("Request succeeded") - } catch let error as NetworkTrackerError { - displayErrorNotification("\(error.localizedDescription)") - } catch { - logger.error("Could not send data \(error.localizedDescription)") - displayErrorNotification("Request to server failed: \(error.localizedDescription)") - } - } - } - - func showSideMenu() { - logger.info("OpenHABRootViewController showSideMenu") - if let menu = SideMenuManager.default.rightMenuNavigationController { - // don't try and push an already visible menu less you crash the app - dismiss(animated: false) { - var topMostViewController: UIViewController? = - UIApplication.shared.connectedScenes.flatMap { ($0 as? UIWindowScene)?.windows ?? [] }.last { $0.isKeyWindow }?.rootViewController - - while let presentedViewController = topMostViewController?.presentedViewController { - topMostViewController = presentedViewController - } - topMostViewController?.present(menu, animated: true) - } - } - } - - private func addView(viewController: UIViewController) { - addChild(viewController) - view.addSubview(viewController.view) - viewController.view.frame = view.bounds - viewController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] - viewController.didMove(toParent: self) - } - - private func removeView(viewController: UIViewController) { - viewController.willMove(toParent: nil) - viewController.view.removeFromSuperview() - viewController.removeFromParent() - } - - private func switchView(target: TargetController) { - let targetView: OpenHABViewController - - switch target { - case .sitemap: - targetView = sitemapViewController - case .webview: - targetView = webViewController - default: - return - } - - if currentView != targetView { - if let currentView { - removeView(viewController: currentView) - } - addView(viewController: targetView) - currentView = targetView - - // Don't save our view in demo mode - if !Preferences.currentHomePreferences.demomode { - Preferences.modifyActiveHome { - $0.defaultView = currentView.viewName() - } - } - } else { - // if we hit the menu item again while on the view, trigger a reload - currentView.reloadView() - } - - // Make sure we reset any views that may be pushed - navigationController?.popToRootViewController(animated: true) - } - - private func switchToSavedView() { - if Preferences.currentHomePreferences.demomode { - switchView(target: .sitemap("")) - } else { - logger.info("OpenHABRootViewController switchToSavedView \(Preferences.currentHomePreferences.defaultView == "sitemap"?"sitemap": "web")") - switchView(target: Preferences.currentHomePreferences.defaultView == "sitemap" ? .sitemap("") : .webview) - } - } - - @MainActor - @objc func handleCertificateTrust(_ notification: Notification, message: String) { - guard let summary = notification.userInfo?["summary"] as? String, - let domain = notification.userInfo?["domain"] as? String, - let client = notification.object as? HTTPClient else { return } - let title = NSLocalizedString("ssl_certificate_warning", comment: "") - let message = String(format: NSLocalizedString(message, comment: ""), summary, domain) - DispatchQueue.main.async { - // Show alert to user - let alert = UIAlertController( - title: title, - message: message, - preferredStyle: .alert - ) - - alert.addAction(UIAlertAction(title: "Always", style: .default) { _ in - client.delegate.completeEvaluation(.permitAlways) - }) - - alert.addAction(UIAlertAction(title: "Once", style: .default) { _ in - client.delegate.completeEvaluation(.permitOnce) - }) - - alert.addAction(UIAlertAction(title: "Deny", style: .cancel) { _ in - client.delegate.completeEvaluation(.deny) - }) - - self.present(alert, animated: true) - } - } - - @MainActor - @objc - func handleCertificateTrust(summary: String, domain: String, delegate: HTTPClientDelegate, messageTemplateKey: String) { - let title = NSLocalizedString("ssl_certificate_warning", comment: "") - let message = String(format: NSLocalizedString(messageTemplateKey, comment: ""), summary, domain) - - let alert = UIAlertController( - title: title, - message: message, - preferredStyle: .alert - ) - - alert.addAction(UIAlertAction(title: "Always", style: .default) { _ in - delegate.completeEvaluation(.permitAlways) - }) - - alert.addAction(UIAlertAction(title: "Once", style: .default) { _ in - delegate.completeEvaluation(.permitOnce) - }) - - alert.addAction(UIAlertAction(title: "Deny", style: .cancel) { _ in - delegate.completeEvaluation(.deny) - }) - - present(alert, animated: true) - } -} - -// swiftlint:enable type_body_length - -// MARK: - UISideMenuNavigationControllerDelegate - -extension OpenHABRootViewController: SideMenuNavigationControllerDelegate { - nonisolated func sideMenuWillAppear(menu: SideMenuNavigationController, animated: Bool) { - logger.info("OpenHABRootViewController sideMenuWillAppear") - } -} - -// MARK: - ModalHandler - -extension OpenHABRootViewController: ModalHandler { - nonisolated func modalDismissed(to: TargetController) { - Task { @MainActor in - switch to { - case .sitemap: - switchView(target: to) - case .settings: - let hostingController = UIHostingController(rootView: SettingsView()) - navigationController?.pushViewController(hostingController, animated: true) - case .notifications: - let hostingController = UIHostingController(rootView: NotificationsView()) - navigationController?.pushViewController(hostingController, animated: true) - case .webview: - switchView(target: to) - case .browser: - break - case let .tile(urlString): - openTileURL(urlString) - case .homeSelection: - let hostingController = UIHostingController(rootView: HomeSelectionView()) - navigationController?.pushViewController(hostingController, animated: true) - } - } - } -} diff --git a/OsLogRewriter/OpenHABSitemapViewController.swift b/OsLogRewriter/OpenHABSitemapViewController.swift deleted file mode 100644 index c272117a7..000000000 --- a/OsLogRewriter/OpenHABSitemapViewController.swift +++ /dev/null @@ -1,927 +0,0 @@ -// Copyright (c) 2010-2025 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 AVFoundation -import AVKit -import Combine -import Foundation -import Kingfisher -import OpenAPIRuntime -import OpenAPIURLSession -import OpenHABCore -import os.log -import SafariServices -import SFSafeSymbols -import SwiftMessages -import SwiftUI -import UIKit - -class OpenHABSitemapViewController: OpenHABViewController, UISearchControllerDelegate { - var pageUrl = "" - private var iconType: IconType = .png - private var openHABRootUrl = "" - - private var activeConnectionInfo: ConnectionInfo? - - private var defaultSitemap = "" - private var pageId = "" - private var idleOff = false - private var sitemaps: [OpenHABSitemap] = [] - private var currentPage: OpenHABPage? - private var pageNetworkStatus: NetworkStatus? - private var pageNetworkStatusAvailable = false - private var refreshControl: UIRefreshControl? - private var filteredPage: OpenHABPage? - private let searchController = UISearchController(searchResultsController: nil) - private var isUserInteracting = false - private var isWaitingToReload = false - // Properties in your view controller: - - private var pageHandlingTask: Task? - - private var pageLoader: PageLoader? - - private let logger = Logger(subsystem: "org.openhab.app", category: "OpenHABSitemapViewController") - - var relevantPage: OpenHABPage? { - if isFiltering { - filteredPage - } else { - currentPage - } - } - - var sitemapViewController: OpenHABSitemapViewController? - - // MARK: - Private instance methods - - var searchBarIsEmpty: Bool { - // Returns true if the text is empty or nil - searchController.searchBar.text?.isEmpty ?? true - } - - var isFiltering: Bool { - searchController.isActive && !searchBarIsEmpty - } - - private var openAPIService: OpenAPIService? - - @IBOutlet private var widgetTableView: UITableView! - - override func viewDidLoad() { - super.viewDidLoad() - logger.info("OpenHABSitemapViewController viewDidLoad") - - registerTableViewCells() - configureTableView() - widgetTableView.tableFooterView = UIView() - - refreshControl = UIRefreshControl() - refreshControl?.addTarget(self, action: #selector(handleRefresh(_:)), for: .valueChanged) - widgetTableView.refreshControl = refreshControl - - // Setup search controller - searchController.searchResultsUpdater = self - searchController.obscuresBackgroundDuringPresentation = false - searchController.searchBar.autocapitalizationType = .none - searchController.searchBar.delegate = self - searchController.delegate = self - searchController.searchBar.placeholder = NSLocalizedString("search_items", comment: "") - definesPresentationContext = true - - // Assign to navigation item (must be in navigation stack) - navigationItem.searchController = searchController - navigationItem.hidesSearchBarWhenScrolling = false - - // Setup active connection - guard let config = activeConnectionInfo?.configuration else { return } - do { - openAPIService = try OpenAPIService(connectionConfiguration: config) - } catch { - logger.error("Failed to create OpenAPIService: \(error.localizedDescription)") - } - - if let service = openAPIService { - pageLoader = PageLoader(service: service, pageId: "", defaultSitemap: "") - } - - #if DEBUG - widgetTableView.accessibilityIdentifier = "OpenHABSitemapViewControllerWidgetTableView" - #endif - } - - override func viewDidAppear(_ animated: Bool) { - logger.info("OpenHABSitemapViewController viewDidAppear") - super.viewDidAppear(animated) - - if parent?.navigationItem.searchController !== searchController { - parent?.navigationItem.searchController = searchController - parent?.navigationItem.hidesSearchBarWhenScrolling = true - } - } - - override func viewWillAppear(_ animated: Bool) { - logger.info("OpenHABSitemapViewController viewWillAppear") - super.viewWillAppear(animated) - - navigationController?.navigationBar.prefersLargeTitles = true - - // Load settings into local properties - loadSettings() - // Disable idle timeout if configured in settings - if idleOff { - UIApplication.shared.isIdleTimerDisabled = true - } - - // if pageUrl is empty, it means we are the first opened OpenHABSitemapViewController - if pageUrl.isEmpty { - sitemapViewController = self -// if navigationController?.viewControllers.first == self { - // This is the first sitemap opened - if currentPage != nil { - currentPage?.widgets = [] - widgetTableView.reloadData() - } - logger.info("OpenHABSitemapViewController pageUrl is empty, this is first launch") - } else { - if !pageNetworkStatusChanged() || !pageId.isEmpty { - // swiftformat:disable:next redundantSelf - logger.info("OpenHABSitemapViewController pageUrl \(self.pageUrl)") - startPageHandling() - } else { - logger.info("OpenHABSitemapViewController network status changed while it was not appearing") - restart() - } - } - - startTrackNetworkStatus() - startWatchingActiveServer() - - ImageDownloader.default.authenticationChallengeResponder = self - } - - override func viewWillDisappear(_ animated: Bool) { - logger.info("OpenHABSitemapViewController viewWillDisappear") - - trackerCancellables.removeAll() - stopAllTasks() - - super.viewWillDisappear(animated) - - if #unavailable(iOS 13.0) { - if animated, !searchController.isActive, !searchController.isEditing, navigationController.map({ $0.viewControllers.last != self }) ?? false, - let searchBarSuperview = searchController.searchBar.superview, - let searchBarHeightConstraint = searchBarSuperview.constraints.first(where: { - $0.firstAttribute == .height - && $0.secondItem == nil - && $0.secondAttribute == .notAnAttribute - && $0.constant > 0 - }) { - UIView.performWithoutAnimation { - searchBarHeightConstraint.constant = 0 - searchBarSuperview.superview?.layoutIfNeeded() - } - } - } - parent?.navigationItem.searchController = nil - } - - @objc - override func didEnterBackground(_ notification: Notification?) { - super.didEnterBackground(notification) - logger.info("OpenHABSitemapViewController didEnterBackground") - } - - @objc - override func didBecomeActive(_ notification: Notification?) { - super.didBecomeActive(notification) - logger.info("OpenHABSitemapViewController didBecomeActive") - if isViewLoaded, view.window != nil, !pageUrl.isEmpty { - if !pageNetworkStatusChanged() { - logger.info("OpenHABSitemapViewController isViewLoaded, restarting network activity") - startPageHandling() - } else { - logger.info("OpenHABSitemapViewController network status changed while it was inactive") - restart() - } - } - } - - override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { - super.traitCollectionDidChange(previousTraitCollection) - - widgetTableView.reloadData() - } - - private func startTrackNetworkStatus() { - let task = Task { - for await status in NetworkTracker.shared.$status.values { - logger.info("OpenHABViewController tracker status \(status.rawValue)") - await MainActor.run { - switch status { - case .connecting: - self.showPopupMessage(seconds: 1.5, title: NSLocalizedString("connecting", comment: ""), message: "", theme: .info) - case .notConnected: - logger.info("Tracking error") -// self.showPopupMessage(seconds: 60, title: NSLocalizedString("error", comment: ""), message: NSLocalizedString("network_not_available", comment: ""), theme: .error) - case .connected, .allConnected, .someConnected: - self.hidePopupMessages() - } - } - } - } - activeTasks.insert(task) - } - - func startWatchingActiveServer() { - let task = Task { - for await activeConnection in NetworkTracker.shared.$activeConnection.values { - if let activeConnection { - await MainActor.run { - logger.info("OpenHABSitemapViewController tracker URL \(activeConnection.configuration.url)") - self.openHABRootUrl = activeConnection.configuration.url - self.activeConnectionInfo = activeConnection - self.selectSitemap() - } - break - } - } - } - activeTasks.insert(task) // Store the task for cancellation - } - - func stopAllTasks() { - for task in activeTasks { - task.cancel() - } - activeTasks.removeAll() - pageHandlingTask?.cancel() - pageHandlingTask = nil - } - - override func reloadView() { - defaultSitemap = Preferences.currentHomePreferences.defaultSitemap - logger.debug("Reload view") - selectSitemap() - } - - override func viewName() -> String { - "sitemap" - } -} - -extension OpenHABSitemapViewController: GenericUITableViewCellTouchEventDelegate { - func touchDown() { - isUserInteracting = true - } - - func touchUp() { - isUserInteracting = false - if isWaitingToReload { - widgetTableView.reloadData() - refreshControl?.endRefreshing() - } - isWaitingToReload = false - } -} - -extension OpenHABSitemapViewController { - func configureTableView() { - widgetTableView.dataSource = self - widgetTableView.delegate = self - } - - func registerTableViewCells() { - widgetTableView.register(cellType: MapViewTableViewCell.self) - widgetTableView.register(cellType: NewImageUITableViewCell.self) - widgetTableView.register(cellType: VideoUITableViewCell.self) - } - - @objc - func handleRefresh(_ refreshControl: UIRefreshControl?) { - startPageHandling() - widgetTableView.reloadData() - widgetTableView.layoutIfNeeded() - } - - func restart() { - if sitemapViewController == self { - logger.info("I am a rootViewController!") - - } else { - sitemapViewController?.pageUrl = "" - navigationController?.popToRootViewController(animated: true) - } - } - - func relevantWidget(indexPath: IndexPath) -> OpenHABWidget? { - relevantPage?.widgets[safe: indexPath.row] - } - - public func updateWidgetTableView() { - UIView.performWithoutAnimation { - widgetTableView.beginUpdates() - widgetTableView.endUpdates() - } - } - - func updateUI(with page: OpenHABPage) { - currentPage = page - - if isFiltering { - filterContentForSearchText(searchController.searchBar.text) - } - - currentPage?.sendCommand = { [weak self] item, command in - self?.sendCommand(item, commandToSend: command) - } - - // isUserInteracting fixes https://github.com/openhab/openhab-ios/issues/646 where reloading while the user is interacting can have unintended consequences - if !isUserInteracting { - widgetTableView.reloadData() - refreshControl?.endRefreshing() - } else { - isWaitingToReload = true - } - // on initial load ??? refreshControl?.endRefreshing() - - widgetTableView.reloadData() - parent?.navigationItem.title = currentPage?.title.components(separatedBy: "[")[0] - } - - // Select sitemap - func selectSitemap() { - Task { - do { - guard let activeConnection = NetworkTracker.shared.activeConnection else { - throw OpenHABSitemapError.noActiveConnection - } - logger.debug("Running selectSitemap for URL: \(activeConnection.configuration.url)") - - openAPIService = try OpenAPIService(connectionConfiguration: activeConnection.configuration) - sitemaps = try await openAPIService?.openHABSitemaps() ?? [] - - guard let openAPIService else { - logger.error("Failed to load openAPIService") - return - } - await pageLoader?.updateAPIService(newService: openAPIService) - - switch sitemaps.count { - case 2...: - if !self.defaultSitemap.isEmpty { - if let sitemapToOpen = sitemap(byName: self.defaultSitemap) { - if self.currentPage?.pageId != sitemapToOpen.name { - self.currentPage?.widgets.removeAll() // NOTE: remove all widgets to ensure cells get invalidated - } - pageUrl = sitemapToOpen.homepageLink - startPageHandling() - } else { - showSideMenu() - } - } else { - showSideMenu() - } - case 1: - pageUrl = sitemaps[0].homepageLink - startPageHandling() - case ...0: - showPopupMessage(seconds: 5, title: NSLocalizedString("warning", comment: ""), message: NSLocalizedString("empty_sitemap", comment: ""), theme: .warning) - showSideMenu() - default: break - } - widgetTableView.reloadData() - } catch _ as OpenAPIServiceError { - logger.debug("OpenAPIService Error on OpenHABSitemapViewController") - } catch let error as OpenHABSitemapError { - logger.error("OpenHABSitemap Error: \(error.localizedDescription)") - DispatchQueue.main.async { - self.showPopupMessage( - seconds: 5, - title: NSLocalizedString("error", comment: ""), - message: error.localizedDescription, - theme: .error - ) - } - } catch { - logger.error("\(error.localizedDescription)") - DispatchQueue.main.async { - if let urlError = error as? URLError, urlError.code == .clientCertificateRejected { - self.showPopupMessage( - seconds: 5, - title: NSLocalizedString("error", comment: ""), - message: NSLocalizedString("ssl_certificate_error", comment: ""), - theme: .error - ) - } else { - self.showPopupMessage( - seconds: 5, - title: NSLocalizedString("error", comment: ""), - message: error.localizedDescription, - theme: .error - ) - } - } - } - } - } - - // This is mainly used for navigating to a specific sitemap and path from notifications - func pushSitemap(name: String, path: String?) async { - guard let activeConnection = await NetworkTracker.shared.waitForActiveConnection() else { - logger.error("pushSitemap: No active connection available") - return - } - - logger.info("pushSitemap: pushing page") - - guard let baseUrl = URL(string: activeConnection.configuration.url) else { - logger.error("pushSitemap: Invalid base URL") - return - } - - var url = baseUrl.appendingPathComponent("rest") - .appendingPathComponent("sitemaps") - .appendingPathComponent(name) - - if let subpath = path { - url.appendPathComponent(subpath) - } - - guard let newViewController = storyboard?.instantiateViewController(withIdentifier: "OpenHABPageViewController") as? OpenHABSitemapViewController else { - logger.error("pushSitemap: Failed to instantiate OpenHABSitemapViewController") - return - } - - newViewController.pageUrl = url.absoluteString - newViewController.openHABRootUrl = activeConnection.configuration.url - navigationController?.pushViewController(newViewController, animated: true) - } - - func startPageHandling() { - pageHandlingTask?.cancel() - - guard !pageUrl.isEmpty else { - logger.error("startPageHandling: Cannot run with empty pageUrl") - return - } - - logger.info("🚀 Starting page load and long polling flow...") - - pageHandlingTask = Task { - do { - // Initial page load - - guard let configuration = NetworkTracker.shared.activeConnection?.configuration else { - throw NetworkTrackerError.noActiveConnection - } - - if openAPIService == nil { - openAPIService = try OpenAPIService(connectionConfiguration: configuration) - } - - let initialPage = try await openAPIService?.pollDataForPage( - sitemapname: defaultSitemap, - pageId: pageId, - longPolling: false - ) - - // Alternative 2 to be tested. - // await pageLoader?.updatePageConfig(newPageId: pageId, newSitemap: defaultSitemap) - // guard let page = try await pageLoader?.fetchPage(longPolling: true) else { return } - // - try Task.checkCancellation() - if let page = initialPage { - await MainActor.run { - self.updateUI(with: page) - } - } - - // Start long polling loop - while !Task.isCancelled { - let page = try await openAPIService?.pollDataForPage( - sitemapname: defaultSitemap, - pageId: pageId, - longPolling: true - ) - try Task.checkCancellation() - - if let page { - await MainActor.run { - self.updateUI(with: page) - } - } - } - } catch is CancellationError { - logger.info("🔁 pageHandlingTask was cancelled") - } catch let error as DecodingError { - logger.error("DecodingError \(error.localizedDescription)") - } catch let error as ClientError { - if let urlError = error.underlyingError as? URLError { - switch urlError.code { - case .cancelled: - logger.info("Task was cancelled - URLError code: .cancelled") - case .timedOut: - logger.info("Task timed out - URLError code: .timedOut") - default: - logger.info("URLError: \(urlError.localizedDescription)") - } - } else { - logger.error("\(error.localizedDescription)") - } - } catch let openAPIError as OpenAPIServiceError { - logger.info("On pageHandling \(openAPIError)") - } catch { - logger.error("❌ pageHandlingTask error: \(error.localizedDescription)") - await MainActor.run { - self.showPopupMessage( - seconds: 5, - title: NSLocalizedString("error", comment: ""), - message: error.localizedDescription, - theme: .error - ) - } - } - } - } - - // load settings into local properties - func loadSettings() { - defaultSitemap = Preferences.currentHomePreferences.defaultSitemap - idleOff = Preferences.idleOff - iconType = IconType(rawValue: Preferences.currentHomePreferences.iconType) ?? .png - #if DEBUG - // always use demo sitemap for UITest - if ProcessInfo.processInfo.environment["UITest"] != nil { - defaultSitemap = "demo" - iconType = .png - } - #endif - } - - // Find and return sitemap by it's name if any - func sitemap(byName sitemapName: String?) -> OpenHABSitemap? { - for sitemap in sitemaps where sitemap.name == sitemapName { - return sitemap - } - return nil - } - - @discardableResult - func pageNetworkStatusChanged() -> Bool { - logger.info("OpenHABSitemapViewController pageNetworkStatusChange") - - guard !pageUrl.isEmpty else { return false } - - let currentStatus = NetworkTracker.shared.status - - // First run - if !pageNetworkStatusAvailable { - pageNetworkStatus = currentStatus - pageNetworkStatusAvailable = true - return false - } - - if pageNetworkStatus == currentStatus { - return false - } else { - pageNetworkStatus = currentStatus - return true - } - } - - func filterContentForSearchText(_ searchText: String?, scope: String = "All") { - guard let searchText else { return } - - filteredPage = currentPage?.filter { - $0.label.lowercased().contains(searchText.lowercased()) && $0.type != .frame - } - filteredPage?.sendCommand = { [weak self] item, command in - self?.sendCommand(item, commandToSend: command) - } - widgetTableView.reloadData() - } - - func sendCommand(_ item: OpenHABItem?, commandToSend command: String?) { - if let item, let command { - sendCommand(itemname: item.name, command: command) - } - } - - func sendCommand(itemname: String, command: String) { - Task { try await openAPIService?.sendItemCommand(itemname: itemname, command: command) } - } -} - -// MARK: - UISearchResultsUpdating - -extension OpenHABSitemapViewController: UISearchResultsUpdating { - func updateSearchResults(for searchController: UISearchController) { - logger.info("Search updated: \(searchController.searchBar.text ?? "")") - filterContentForSearchText(searchController.searchBar.text) - } -} - -// MARK: - UISearchBarDelegate - -extension OpenHABSitemapViewController: UISearchBarDelegate { - func searchBarSearchButtonClicked(_ searchBar: UISearchBar) { - searchBar.resignFirstResponder() - } -} - -// MARK: - ColorPickerCellDelegate - -extension OpenHABSitemapViewController: @preconcurrency ColorPickerCellDelegate { - func didPressColorButton(_ cell: ColorPickerCell?) { - let colorPickerViewController = storyboard?.instantiateViewController(withIdentifier: "ColorPickerViewController") as? ColorPickerViewController - if let cell { - let widget = relevantPage?.widgets[widgetTableView.indexPath(for: cell)?.row ?? 0] - colorPickerViewController?.title = widget?.labelText - colorPickerViewController?.widget = widget - } - if let colorPickerViewController { - navigationController?.pushViewController(colorPickerViewController, animated: true) - } - } -} - -// MARK: - UITableViewDelegate, UITableViewDataSource - -extension OpenHABSitemapViewController: UITableViewDelegate, UITableViewDataSource { - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - relevantPage?.widgets.count ?? 0 - } - - func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { - 44.0 - } - - func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - let widget: OpenHABWidget? = relevantPage?.widgets[indexPath.row] - switch widget?.type { - case .frame: - return widget?.label.count ?? 0 > 0 ? 35.0 : 0 - case .image, .chart, .video: - return UITableView.automaticDimension - case .webview, .mapview: - if let height = widget?.height { - // calculate webview/mapview height and return it. Limited to UIScreen.main.bounds.height - let heightValue = height * 44 - logger.info("Webview/Mapview height would be %g") - return min(UIScreen.main.bounds.height, CGFloat(heightValue)) - } else { - // return default height for webview/mapview as 8 rows - return 44.0 * 8 - } - default: return 44.0 - } - } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - guard let widget: OpenHABWidget = relevantWidget(indexPath: indexPath) else { - // this should never be the case - let cell = tableView.dequeueReusableCell(for: indexPath) as GenericUITableViewCell - cell.displayWidget() - cell.touchEventDelegate = self - cell.separatorInset = UIEdgeInsets(top: 0, left: 60, bottom: 0, right: 0) - return cell - } - - let provider = WidgetCellFactory.provider(for: widget) - let cell = provider.dequeue(from: tableView, at: indexPath) - provider.configure(cell: cell, for: widget, controller: self) - - var iconColor = widget.iconColor - if iconColor.isEmpty, traitCollection.userInterfaceStyle == .dark { - iconColor = "white" - } - // No icon will be displazed for cells that conform to NoIconDisplayableCell protocol - if !(cell is any NoIconDisplayableCell) { - if !widget.icon.isEmpty { - if let urlc = Endpoint.icon( - rootUrl: openHABRootUrl, - version: NetworkTracker.shared.activeConnection?.version ?? 2, - icon: widget.icon, - state: widget.iconState(), - iconType: iconType, - iconColor: iconColor - ).url { - var imageRequest = URLRequest(url: urlc) - imageRequest.timeoutInterval = 10.0 - cell.imageView?.kf.setImage( - with: KF.ImageResource(downloadURL: urlc, cacheKey: urlc.path + (urlc.query ?? "")), - placeholder: nil, - options: [.processor(OpenHABImageProcessor())] - ) { result in - switch result { - case .success: - DispatchQueue.main.async { - cell.setNeedsLayout() - } - case let .failure(error): - self.logger.error("Image loading failed for widget \(widget.label) : \(error.localizedDescription)") - } - } - } - } - } - - if cell is FrameUITableViewCell { - cell.backgroundColor = .ohSystemGroupedBackground - } else { - cell.backgroundColor = .ohSecondarySystemGroupedBackground - } - - if let cell = cell as? GenericUITableViewCell { - cell.widget = widget - cell.displayWidget() - cell.touchEventDelegate = self - } - - // Check if this is not the last row in the widgets list - if indexPath.row < (relevantPage?.widgets.count ?? 1) - 1 { - let nextWidget: OpenHABWidget? = relevantPage?.widgets[indexPath.row + 1] - if let type = nextWidget?.type, type.isAny(of: .frame, .image, .video, .webview, .chart) { - cell.separatorInset = UIEdgeInsets.zero - } else if !(widget.type == .frame) { - cell.separatorInset = UIEdgeInsets(top: 0, left: 60, bottom: 0, right: 0) - } - } - - return cell - } - - func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { - // Prevent the cell from inheriting the Table View's margin settings - cell.preservesSuperviewLayoutMargins = false - - // Explictly set your cell's layout margins - cell.layoutMargins = .zero - - (cell as? VideoUITableViewCell)?.play() - } - - func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - if let index = widgetTableView.indexPathForSelectedRow { - widgetTableView.deselectRow(at: index, animated: false) - } - - guard let widget: OpenHABWidget = relevantWidget(indexPath: indexPath) else { return } - - if let linkedPage = widget.linkedPage { - logger.info("Selected linked page: \(linkedPage.link)") - stopAllTasks() - let newViewController = (storyboard?.instantiateViewController(withIdentifier: "OpenHABPageViewController") as? OpenHABSitemapViewController)! - newViewController.title = linkedPage.title.components(separatedBy: "[")[0] - newViewController.pageId = linkedPage.pageId - newViewController.pageUrl = linkedPage.link - newViewController.openHABRootUrl = openHABRootUrl - navigationController?.pushViewController(newViewController, animated: true) - } else if widget.type == .selection { - let selectionItemState = widget.item?.state - logger.info("Selected selection widget in status: \(selectionItemState ?? "unknown")") - let hostingController = UIHostingController( - rootView: SelectionView( - mappings: widget.mappingsOrItemOptions, - selectionItemState: selectionItemState - ) { selectedMappingIndex in - let selectedMapping: OpenHABWidgetMapping = widget.mappingsOrItemOptions[selectedMappingIndex] - self.sendCommand(widget.item, commandToSend: selectedMapping.command) - } - ) - hostingController.title = widget.labelText - navigationController?.pushViewController(hostingController, animated: true) - } else if widget.type == .input { - let hint = widget.inputHint - let textExtractor: ((UIAlertController) -> String?)? - let textFieldAdder: ((UITextField) -> Void)? - - switch hint { - case .date, .time, .datetime: - // value setting is handeled by the cell itself - textExtractor = nil - textFieldAdder = nil - case .number: - textFieldAdder = { textField in - textField.text = widget.state - textField.clearButtonMode = .always - textField.delegate = self - textField.keyboardType = .numbersAndPunctuation - } - // replace expected decimal separator - textExtractor = { $0.textFields?[0].text?.replacingOccurrences(of: NSLocale.current.decimalSeparator ?? "", with: ".") } - case .text: - textFieldAdder = { textField in - textField.text = widget.state - textField.clearButtonMode = .always - textField.keyboardType = .default - } - textExtractor = { $0.textFields?[0].text } - case .unknown: - textExtractor = nil - textFieldAdder = nil - } - guard let textExtractor, let textFieldAdder else { - return - } - - // TODO: proper texts instead of hardcoded values - let alert = UIAlertController( - title: "Enter new value", - message: "Current value for \(widget.label) is \(widget.state)", - preferredStyle: .alert - ) - alert.addTextField(configurationHandler: textFieldAdder) - let sendAction = UIAlertAction(title: "Set value", style: .destructive) { [weak self] _ in - self?.sendCommand(widget.item, commandToSend: textExtractor(alert)) - } - alert.addAction(sendAction) - alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) - alert.preferredAction = sendAction - present(alert, animated: true) - } - } - - func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { - if let cell = cell as? any GenericCellCacheProtocol { - // invalidate cache only if the cell is not visible or the datasource is empty (eg. sitemap change) - if tableView.indexPathsForVisibleRows == nil || !tableView.indexPathsForVisibleRows!.contains(indexPath) || currentPage == nil || currentPage!.widgets.isEmpty { - cell.invalidateCache() - } - } - } - - func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { - if let cell = tableView.cellForRow(at: indexPath) as? GenericUITableViewCell, cell.widget.type == .text, let text = cell.widget?.labelValue ?? cell.widget?.labelText, !text.isEmpty { - return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { _ in - let copy = UIAction(title: NSLocalizedString("copy_label", comment: ""), image: UIImage(systemSymbol: .squareAndArrowUp)) { _ in - UIPasteboard.general.string = text - } - - return UIMenu(title: "", children: [copy]) - } - } - - return nil - } -} - -extension OpenHABSitemapViewController: UITextFieldDelegate { - func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { - let decimalSeparator = NSLocale.current.decimalSeparator ?? "" - let oldString = (textField.text ?? "") - let wholeNumberRegex = /^-?[0-9]*$/ - - // check for deletion - return string.isEmpty - // check for new negative sign - || ( - !string.starts(with: "-") // new string does not add negative sign - || range.location == 0 // new string adds negative sign to beginning - && ( - !oldString.starts(with: "-") // old string does not contain negative sign - || range.length > 0 - ) - ) // new string replaces negative sign in old string - // check for old negative sign - && ( - oldString.isEmpty - || !oldString.starts(with: "-") // old string does not start with negative sign - || range.location > 0 // new string starts after negative sign in old string - || range.length > 0 - ) // new string replaces negative sign in old string - // check for decimal signs - && ( - string.firstRange(of: wholeNumberRegex) != nil // new string is whole number - || ( - string.replacing(decimalSeparator, with: "", maxReplacements: 1) - .firstRange(of: wholeNumberRegex) != nil // new string is valid decimal number - && !(oldString as NSString).replacingCharacters(in: range, with: "").contains(decimalSeparator) - ) - ) // old string without replaced range not yet contains decimal separator - } -} - -// MARK: Kingfisher authentication with NSURLCredential - -extension OpenHABSitemapViewController: AuthenticationChallengeResponsible { - func downloader(_ downloader: ImageDownloader, - didReceive challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { - await onReceiveSessionChallenge(with: challenge) - } - - func downloader(_ downloader: ImageDownloader, - task: URLSessionTask, - didReceive challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { - onReceiveSessionTaskChallenge(with: challenge) - } -} diff --git a/OsLogRewriter/Preferences.swift b/OsLogRewriter/Preferences.swift deleted file mode 100644 index 80af3f999..000000000 --- a/OsLogRewriter/Preferences.swift +++ /dev/null @@ -1,415 +0,0 @@ -// Copyright (c) 2010-2025 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 - -@preconcurrency import Combine -import os.log -import UIKit - -@propertyWrapper @MainActor -public struct UserDefault { - private let key: String - private let defaultValue: T - private let isHomeProperty: Bool - private let subject: CurrentValueSubject - - public var wrappedValue: T { - get { - Preferences.getPreference(key: key, defaultValue: defaultValue, encoder: { $0 }, decoder: { $0 as? T }) - } - set { - Preferences.preferenceChanged(newValue: newValue, key: key, isHomeProperty: isHomeProperty, subject: subject) { $0 } - } - } - - public var projectedValue: AnyPublisher { - subject.eraseToAnyPublisher() - } - - public init(_ key: String, defaultValue: T, isHomeProperty: Bool = false) { - self.key = key - self.defaultValue = defaultValue - self.isHomeProperty = isHomeProperty - let currentValue = Preferences.getPreference(key: key, defaultValue: defaultValue, encoder: { $0 }, decoder: { $0 as? T }) - subject = CurrentValueSubject(currentValue) - } -} - -@propertyWrapper @MainActor -public struct UserDefaultObject { - private let key: String - private let defaultValue: T - private let isHomeProperty: Bool - private let subject: CurrentValueSubject - - private let objectDecoder: (Any) -> (T?) = { - guard let data = $0 as? Data else { - return nil - } - return try? JSONDecoder().decode(T.self, from: data) - } - - private let objectEncoder: (T) -> (any Sendable)? = { try? JSONEncoder().encode($0) } - - public var wrappedValue: T { - get { - Preferences.getPreference(key: key, defaultValue: defaultValue, encoder: objectEncoder, decoder: objectDecoder) - } - set { - Preferences.preferenceChanged(newValue: newValue, key: key, isHomeProperty: isHomeProperty, subject: subject, converter: objectEncoder) - } - } - - public var projectedValue: AnyPublisher { - subject.eraseToAnyPublisher() - } - - init(_ key: String, defaultValue: T, isHomeProperty: Bool = false) { - self.key = key - self.defaultValue = defaultValue - self.isHomeProperty = isHomeProperty - - // Combine publication - let currentValue = Preferences.getPreference(key: key, defaultValue: defaultValue, encoder: objectEncoder, decoder: objectDecoder) - subject = CurrentValueSubject(currentValue) - } -} - -public struct HomePreferences: Codable, Sendable, Equatable { - public let id: UUID - public var defaultView = "web" - public var demomode = true - public var realTimeSliders = false - public var iconType = 0 - public var defaultSitemap = "demo" - public var sortSitemapsBy = 0 - public var defaultMainUIPath = "" - public var alwaysAllowWebRTC = false - public var sitemapForWatch = "watch" - public var localConnectionConfig: ConnectionConfiguration = .localDefault - public var remoteConnectionConfig: ConnectionConfiguration = .remoteDefault - public var sitemapForWatchLabel = "watch" - public var homeName = "Home" - - fileprivate init(id: UUID) { - self.id = id - } -} - -@MainActor -public enum Preferences { - /// the currently applied settings set from storedHomes - @UserDefaultObject("currentHomePreferences", defaultValue: HomePreferences(id: Preferences.activeHomeId)) - public private(set) static var currentHomePreferences: HomePreferences - - @UserDefault("sendCrashReports", defaultValue: false) - public static var sendCrashReports: Bool - - @UserDefault("idleOff", defaultValue: false) - public static var idleOff: Bool - - @UserDefault("currentWebViewPath", defaultValue: "") - public static var currentWebViewPath: String - - /// settings for different homes - @UserDefaultObject("storedHomes", defaultValue: [:]) - public private(set) static var storedHomes: [UUID: HomePreferences] - - /// the currently applied settings set from storedHomes - @UserDefaultObject("activeHomeId", defaultValue: UUID()) - private static var activeHomeId: UUID - - @UserDefault("didMigrateToSharedDefaults", defaultValue: false) - private static var didMigrateToSharedDefaults: Bool - - @UserDefault("didMigrateToMultipleHomes", defaultValue: false) - private static var didMigrateToMultipleHomes: Bool - - private static var loadingStoredHome = false -} - -// MARK: Retrieving preference from user defaults, reacting to preference change - -extension Preferences { - static let sharedDefaults = UserDefaults(suiteName: "group.org.openhab.app")! - - fileprivate static func getPreference(key: String, defaultValue: T, encoder: (T) -> (some Sendable)?, decoder: (Any?) -> T?) -> T { - let preferenceValue = sharedDefaults.object(forKey: key) - if let preferenceConverted = decoder(preferenceValue) { - logger.debug("Preference value \(key) is \(preferenceConverted)") - return preferenceConverted - } else { - if let preferenceValue { - logger.error("Preference value \(key) was \"\(preferenceValue)\" but did not conform to \(T.self). Replace with default value.") - } else { - logger.info("Preference value \(key) was set for the first time. Using default value.") - } - let fallback = defaultValue - sharedDefaults.set(encoder(fallback), forKey: key) - return fallback - } - } - - fileprivate static func preferenceChanged(newValue: T, key: String, isHomeProperty: Bool, subject: CurrentValueSubject, sanitize: (T) -> (T?) = { $0 }, converter: (T) -> (some Sendable)?) { - guard let sanitized = sanitize(newValue) else { - logger.debug("Preference \(key) new value \"\(newValue)\" could not be sanitized, will be ignored") - return - } - let convertedValue = converter(sanitized) - guard convertedValue != nil else { - logger.debug("Preference \(key) conversion of new value \(sanitized) failed, do not store.") - return - } - logger.debug("Preference \(key) will be changed to value \(newValue)") - sharedDefaults.set(convertedValue, forKey: key) - - DispatchQueue.main.async { [subject] in - subject.send(sanitized) - } - } -} - -// MARK: Multiple homes - -public extension Preferences { - static func listStoredHomes() -> [UUID] { - let preferenceIds = storedHomes - .sorted { e1, e2 in - e1.value.homeName <= e2.value.homeName - } - .map(\.key) - return preferenceIds - } - - static func createAndLoadNewStoredSettings(homeName: String) { - activeHomeId = UUID() - var newHome = HomePreferences(id: activeHomeId) - newHome.homeName = homeName - loadHomePreferences(newHome) - } - - static func renameHome(_ homeId: UUID, newHomeName: String) { - if homeId == activeHomeId { - modifyActiveHome { - $0.homeName = newHomeName - } - } else { - var stored = storedHomes - stored[homeId]?.homeName = newHomeName - storedHomes = stored - } - } - - /// helper function for when we update the remote connection cloudUserId for notifications - static func setCloudUserId(_ cloudUserId: String?, for homeId: UUID) { - if homeId == activeHomeId { - modifyActiveHome { homePreferences in - homePreferences.remoteConnectionConfig.cloudUserId = cloudUserId - } - } else { - var stored = storedHomes - var home = stored[homeId] - home?.remoteConnectionConfig.cloudUserId = cloudUserId - stored[homeId] = home - storedHomes = stored - } - } - - static func deleteStoredHome(_ homeId: UUID) { - guard homeId != activeHomeId else { - // cannot remove current home - return - } - var stored = storedHomes - stored.removeValue(forKey: homeId) - storedHomes = stored - } - - static func switchActiveHome(to homeId: UUID) { - guard let storedHome = storedHomes[homeId] else { - // we have not stored our settings in that list yet - return - } - - activeHomeId = homeId - - loadHomePreferences(storedHome) - } - - private static func initializeStoredHomes() { - if storedHomes.isEmpty { - // first there might be no stored preferences, if no preference was changed since the update - storeActiveHome() - } - } - - private static func loadHomePreferences(_ preferences: HomePreferences) { - loadingStoredHome = true - Preferences.currentHomePreferences = preferences - loadingStoredHome = false - storeActiveHome() // store home settings in case they were not yet there - } - - private static func storeActiveHome() { - var all = storedHomes - let homeId = Preferences.activeHomeId - all[homeId] = Preferences.currentHomePreferences - storedHomes = all - logger.debug("Stored preferences for current home \(homeId.uuidString)") - } - - static func modifyActiveHome(modificationFunction: (inout HomePreferences) -> Void) { - var homePreferences = currentHomePreferences - modificationFunction(&homePreferences) - currentHomePreferences = homePreferences - storeActiveHome() - } -} - -public extension Preferences { - static func firstStoredHome(where predicate: (HomePreferences) -> Bool) -> (id: UUID, record: HomePreferences)? { - for (uuid, record) in storedHomes { - guard predicate(record) else { continue } - return (uuid, record) - } - return nil - } - - static func storedHome(forCloudUserId id: String) -> HomePreferences? { - firstStoredHome { homePreferences in - homePreferences.remoteConnectionConfig.cloudUserId == id - }?.record - } -} - -// MARK: Migration - -public extension Preferences { - static func migratePreferences() { - initializeStoredHomes() - migrateToSharedDefaultsIfRequired() - migrateToMultipleHomesIfRequired() - } - - private static func migrateToSharedDefaultsIfRequired() { - guard !didMigrateToSharedDefaults else { return } - - modifyActiveHome { currentHomePreferences in - currentHomePreferences.localConnectionConfig.url = UserDefaults.standard.string(forKey: "localUrl") ?? currentHomePreferences.localConnectionConfig.url - currentHomePreferences.localConnectionConfig.alwaysSendBasicAuth = UserDefaults.standard.object(forKey: "alwaysSendCreds") as? Bool ?? currentHomePreferences.localConnectionConfig.alwaysSendBasicAuth - currentHomePreferences.localConnectionConfig.ignoreSSL = UserDefaults.standard.object(forKey: "ignoreSSL") as? Bool ?? currentHomePreferences.localConnectionConfig.ignoreSSL - currentHomePreferences.remoteConnectionConfig.url = UserDefaults.standard.string(forKey: "remoteUrl") ?? currentHomePreferences.remoteConnectionConfig.url - currentHomePreferences.remoteConnectionConfig.username = UserDefaults.standard.string(forKey: "username") ?? currentHomePreferences.remoteConnectionConfig.username - currentHomePreferences.remoteConnectionConfig.password = UserDefaults.standard.string(forKey: "password") ?? currentHomePreferences.remoteConnectionConfig.password - currentHomePreferences.remoteConnectionConfig.alwaysSendBasicAuth = UserDefaults.standard.object(forKey: "alwaysSendCreds") as? Bool ?? currentHomePreferences.remoteConnectionConfig.alwaysSendBasicAuth - currentHomePreferences.remoteConnectionConfig.ignoreSSL = UserDefaults.standard.object(forKey: "ignoreSSL") as? Bool ?? currentHomePreferences.remoteConnectionConfig.ignoreSSL - currentHomePreferences.demomode = UserDefaults.standard.object(forKey: "demomode") as? Bool ?? currentHomePreferences.demomode - currentHomePreferences.realTimeSliders = UserDefaults.standard.object(forKey: "realTimeSliders") as? Bool ?? currentHomePreferences.realTimeSliders - currentHomePreferences.iconType = UserDefaults.standard.object(forKey: "iconType") as? Int ?? currentHomePreferences.iconType - currentHomePreferences.defaultSitemap = UserDefaults.standard.string(forKey: "defaultSitemap") ?? currentHomePreferences.defaultSitemap - } - - Preferences.idleOff = UserDefaults.standard.object(forKey: "idleOff") as? Bool ?? Preferences.idleOff - Preferences.sendCrashReports = UserDefaults.standard.object(forKey: "sendCrashReports") as? Bool ?? Preferences.sendCrashReports - - didMigrateToSharedDefaults = true - // this was done implicitly - didMigrateToMultipleHomes = true - } - - private static func migrateToMultipleHomesIfRequired() { - guard !didMigrateToMultipleHomes else { return } - - migrateToSharedDefaultsIfRequired() - - let oldLocalUrl = Preferences.sharedDefaults.string(forKey: "localUrl") - let oldRemoteUrl = Preferences.sharedDefaults.string(forKey: "remoteUrl") - let oldUsername = Preferences.sharedDefaults.string(forKey: "username") - let oldPassword = Preferences.sharedDefaults.string(forKey: "password") - let oldAlwaysSendCreds = Preferences.sharedDefaults.object(forKey: "alwaysSendCreds") as? Bool - let oldIgnoreSSL = Preferences.sharedDefaults.object(forKey: "ignoreSSL") as? Bool - - // Create new configuration - var newLocalConfiguration = Preferences.currentHomePreferences.localConnectionConfig - newLocalConfiguration.url = oldLocalUrl ?? newLocalConfiguration.url - newLocalConfiguration.alwaysSendBasicAuth = oldAlwaysSendCreds ?? newLocalConfiguration.alwaysSendBasicAuth - newLocalConfiguration.ignoreSSL = oldIgnoreSSL ?? newLocalConfiguration.ignoreSSL - - var newRemoteConfiguration = Preferences.currentHomePreferences.remoteConnectionConfig - newRemoteConfiguration.url = oldRemoteUrl ?? newRemoteConfiguration.url - newRemoteConfiguration.username = oldUsername ?? newRemoteConfiguration.username - newRemoteConfiguration.password = oldPassword ?? newRemoteConfiguration.password - newRemoteConfiguration.alwaysSendBasicAuth = oldAlwaysSendCreds ?? newRemoteConfiguration.alwaysSendBasicAuth - newRemoteConfiguration.ignoreSSL = oldIgnoreSSL ?? newRemoteConfiguration.ignoreSSL - - // Save to Preferences - modifyActiveHome { currentHomePreferences in - currentHomePreferences.defaultView = Preferences.sharedDefaults.string(forKey: "defaultView") ?? currentHomePreferences.defaultView - currentHomePreferences.demomode = Preferences.sharedDefaults.object(forKey: "demomode") as? Bool ?? currentHomePreferences.demomode - currentHomePreferences.realTimeSliders = Preferences.sharedDefaults.object(forKey: "realTimeSliders") as? Bool ?? currentHomePreferences.realTimeSliders - currentHomePreferences.iconType = Preferences.sharedDefaults.object(forKey: "iconType") as? Int ?? currentHomePreferences.iconType - currentHomePreferences.defaultSitemap = Preferences.sharedDefaults.string(forKey: "defaultSitemap") ?? currentHomePreferences.defaultSitemap - currentHomePreferences.sortSitemapsBy = Preferences.sharedDefaults.object(forKey: "sortSitemapsBy") as? Int ?? currentHomePreferences.sortSitemapsBy - currentHomePreferences.defaultMainUIPath = Preferences.sharedDefaults.string(forKey: "defaultMainUIPath") ?? currentHomePreferences.defaultMainUIPath - currentHomePreferences.alwaysAllowWebRTC = Preferences.sharedDefaults.object(forKey: "alwaysAllowWebRTC") as? Bool ?? currentHomePreferences.alwaysAllowWebRTC - currentHomePreferences.sitemapForWatch = Preferences.sharedDefaults.string(forKey: "sitemapForWatch") ?? currentHomePreferences.sitemapForWatch - currentHomePreferences.localConnectionConfig = newLocalConfiguration - currentHomePreferences.remoteConnectionConfig = newRemoteConfiguration - currentHomePreferences.sitemapForWatchLabel = Preferences.sharedDefaults.string(forKey: "sitemapForWatchLabel") ?? currentHomePreferences.sitemapForWatchLabel - } - - didMigrateToMultipleHomes = true - } -} - -// MARK: All connections - -public extension Preferences { - static func getNotificationConnection() -> ConnectionConfiguration? { - getNotificationConnection(of: [Preferences.currentHomePreferences.remoteConnectionConfig]) - } - - static func getNotificationConnection(of homeConfig: HomePreferences) -> ConnectionConfiguration? { - getNotificationConnection(of: [homeConfig.remoteConnectionConfig]) - } - - // this will support mutliple connection configs, right now we just pass in the remote config - static func getNotificationConnection(of connections: [ConnectionConfiguration?]) -> ConnectionConfiguration? { - connections - .compactMap { $0 } - .filter { $0.supportsNotifications == true } - .sorted { $0.priority > $1.priority } - .first - } -} - -// MARK: - Sample Codable Model - -public extension ConnectionConfiguration { - static let localDefault = ConnectionConfiguration( - url: "https://openhab.local:8443", - username: "", - password: "", - alwaysSendBasicAuth: false, - ignoreSSL: false, - supportsNotifications: false, - priority: 0 - ) - - static let remoteDefault = ConnectionConfiguration( - url: "https://myopenhab.org", - username: "", - password: "", - alwaysSendBasicAuth: false, - ignoreSSL: false, - supportsNotifications: true, - priority: 1 - ) -} From e150e545f9fb0f9b9470ae5a3d5fcc1aa45f5bd7 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Wed, 9 Jul 2025 19:39:11 +0200 Subject: [PATCH 234/476] Make it runnable againg Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- openHAB/SitemapPageViewModel.swift | 2 +- openHAB/WidgetSetpointView.swift | 32 ++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 openHAB/WidgetSetpointView.swift diff --git a/openHAB/SitemapPageViewModel.swift b/openHAB/SitemapPageViewModel.swift index 8c638c601..18e981a3f 100644 --- a/openHAB/SitemapPageViewModel.swift +++ b/openHAB/SitemapPageViewModel.swift @@ -65,7 +65,7 @@ class SitemapPageViewModel: ObservableObject { } func loadSettings() { - defaultSitemap = Preferences.defaultSitemap + defaultSitemap = Preferences.currentHomePreferences.defaultSitemap } func startPageHandling() { diff --git a/openHAB/WidgetSetpointView.swift b/openHAB/WidgetSetpointView.swift new file mode 100644 index 000000000..1831a261c --- /dev/null +++ b/openHAB/WidgetSetpointView.swift @@ -0,0 +1,32 @@ +// Copyright (c) 2010-2025 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 OpenHABCore +import SwiftUI + +struct WidgetSwitchView: View { + @ObservedObject var widget: OpenHABWidget + + var body: some View { + Toggle(isOn: Binding( + get: { + widget.state.uppercased() == "ON" + }, + set: { newValue in + let newState = newValue ? "ON" : "OFF" + widget.state = newState // 1. Update local state immediately + widget.sendCommand(newState) // 2. Send to server + } + )) { + Text(widget.labelText ?? widget.label) + } + } +} From ef0e8969bd1d21998dee507c1e7e7ccef87efde6 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Wed, 9 Jul 2025 19:47:59 +0200 Subject: [PATCH 235/476] ported the functionality from SwitchUITableViewCell to WidgetSwitchView Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- openHAB.xcodeproj/project.pbxproj | 20 +++--------- openHAB/WidgetSetpointView.swift | 2 +- openHAB/WidgetSwitchView.swift | 53 +++++++++++++++++++++++++------ openHAB/WidgetViewFactory.swift | 2 ++ 4 files changed, 51 insertions(+), 26 deletions(-) diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index b630baba0..1e1a2df5d 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -88,6 +88,7 @@ DA2E0B0E23DCC153009B0A99 /* MapView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2E0B0D23DCC152009B0A99 /* MapView.swift */; }; DA2E0B1023DCC439009B0A99 /* MapViewRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2E0B0F23DCC439009B0A99 /* MapViewRow.swift */; }; DA32D1B42C8C98C40018D974 /* IconWithAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA32D1B32C8C98C40018D974 /* IconWithAction.swift */; }; + DA35E2B02E1EDB86003987BB /* WidgetSetpointView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA35E2AF2E1EDB86003987BB /* WidgetSetpointView.swift */; }; DA4642322D7EE6CA006C3908 /* LoggerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4642312D7EE6CA006C3908 /* LoggerView.swift */; }; DA4800142D836892009CF127 /* ConnectionSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4800132D836892009CF127 /* ConnectionSettingsView.swift */; }; DA4800162D836EF0009CF127 /* MainUISettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4800152D836EF0009CF127 /* MainUISettingsView.swift */; }; @@ -100,7 +101,6 @@ DA50C7BD2B0A51BD0009F716 /* SliderWithSwitchSupportRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA50C7BC2B0A51BD0009F716 /* SliderWithSwitchSupportRow.swift */; }; DA50C7BF2B0A65300009F716 /* SliderWithSwitchSupportUITableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA50C7BE2B0A652F0009F716 /* SliderWithSwitchSupportUITableViewCell.swift */; }; DA5ED9BE2C850955004875E0 /* ClientCertificatesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5ED9BD2C850955004875E0 /* ClientCertificatesViewModel.swift */; }; - DA5ED9C02C8509C2004875E0 /* ClientCertificatesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5ED9BF2C8509C2004875E0 /* ClientCertificatesView.swift */; }; DA64ACA62DBEAD5600294F60 /* SitemapPageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA64ACA52DBEAD5600294F60 /* SitemapPageViewModel.swift */; }; DA64ACA82DBEAD8300294F60 /* SitemapPageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA64ACA72DBEAD8300294F60 /* SitemapPageView.swift */; }; DA64ACAA2DBEADB000294F60 /* WidgetRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA64ACA92DBEADB000294F60 /* WidgetRow.swift */; }; @@ -412,6 +412,7 @@ DA2E0B0D23DCC152009B0A99 /* MapView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MapView.swift; sourceTree = ""; }; DA2E0B0F23DCC439009B0A99 /* MapViewRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapViewRow.swift; sourceTree = ""; }; DA32D1B32C8C98C40018D974 /* IconWithAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconWithAction.swift; sourceTree = ""; }; + DA35E2AF2E1EDB86003987BB /* WidgetSetpointView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetSetpointView.swift; sourceTree = ""; }; DA4642312D7EE6CA006C3908 /* LoggerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoggerView.swift; sourceTree = ""; }; DA4800132D836892009CF127 /* ConnectionSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionSettingsView.swift; sourceTree = ""; }; DA4800152D836EF0009CF127 /* MainUISettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainUISettingsView.swift; sourceTree = ""; }; @@ -425,7 +426,6 @@ DA50C7BC2B0A51BD0009F716 /* SliderWithSwitchSupportRow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SliderWithSwitchSupportRow.swift; sourceTree = ""; }; DA50C7BE2B0A652F0009F716 /* SliderWithSwitchSupportUITableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SliderWithSwitchSupportUITableViewCell.swift; sourceTree = ""; }; DA5ED9BD2C850955004875E0 /* ClientCertificatesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientCertificatesViewModel.swift; sourceTree = ""; }; - DA5ED9BF2C8509C2004875E0 /* ClientCertificatesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientCertificatesView.swift; sourceTree = ""; }; DA64ACA52DBEAD5600294F60 /* SitemapPageViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SitemapPageViewModel.swift; sourceTree = ""; }; DA64ACA72DBEAD8300294F60 /* SitemapPageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SitemapPageView.swift; sourceTree = ""; }; DA64ACA92DBEADB000294F60 /* WidgetRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetRow.swift; sourceTree = ""; }; @@ -1111,6 +1111,7 @@ 938BF9D224EFD0B700E6B52F /* UIViewController+Localization.swift */, 935B484525342B8E00E44CF0 /* URL+Static.swift */, DAEA21D92DBF477E00D54342 /* WidgetSwitchView.swift */, + DA35E2AF2E1EDB86003987BB /* WidgetSetpointView.swift */, DAEA21DB2DBF47DA00D54342 /* WidgetSliderView.swift */, DAEA21DD2DBF481300D54342 /* WidgetTextView.swift */, ); @@ -1637,6 +1638,7 @@ B7D5ECE121499E55001B0EC6 /* MapViewTableViewCell.swift in Sources */, DAEA21DC2DBF47DA00D54342 /* WidgetSliderView.swift in Sources */, DA77E19B2D886D9B007CFF0F /* SingleConnectionSettingsView.swift in Sources */, + DA35E2B02E1EDB86003987BB /* WidgetSetpointView.swift in Sources */, DA6B2EF52C89F8F200DF77CF /* ColorPickerView.swift in Sources */, DA4800142D836892009CF127 /* ConnectionSettingsView.swift in Sources */, 2F6412EE2CE494A80039FB28 /* DatePickerUITableViewCell.swift in Sources */, @@ -1793,7 +1795,6 @@ CODE_SIGN_ENTITLEMENTS = openHABIntents/openHABIntents.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 33; CURRENT_PROJECT_VERSION = 48; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = PBAPXHRAM9; @@ -1880,7 +1881,6 @@ CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 33; CURRENT_PROJECT_VERSION = 48; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = PBAPXHRAM9; @@ -1974,9 +1974,6 @@ CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CODE_SIGN_IDENTITY = "Apple Distribution"; - CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 33; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 48; @@ -2069,8 +2066,6 @@ CODE_SIGN_ENTITLEMENTS = "openHABWatch Extension/openHABWatch Extension.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 33; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; CURRENT_PROJECT_VERSION = 48; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = PBAPXHRAM9; @@ -2173,9 +2168,6 @@ CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CODE_SIGN_IDENTITY = "Apple Distribution"; - CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 33; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 48; @@ -2564,11 +2556,9 @@ CODE_SIGN_ENTITLEMENTS = openHAB/openHAB.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 33; - DEVELOPMENT_TEAM = PBAPXHRAM9; - ENABLE_DEBUG_DYLIB = YES; CURRENT_PROJECT_VERSION = 48; DEVELOPMENT_TEAM = PBAPXHRAM9; + ENABLE_DEBUG_DYLIB = YES; GCC_PRECOMPILE_PREFIX_HEADER = YES; GCC_PREFIX_HEADER = "openHAB/openHAB-Prefix.pch"; INFOPLIST_FILE = "openHAB/openHAB-Info.plist"; diff --git a/openHAB/WidgetSetpointView.swift b/openHAB/WidgetSetpointView.swift index 1831a261c..d9a1f3c00 100644 --- a/openHAB/WidgetSetpointView.swift +++ b/openHAB/WidgetSetpointView.swift @@ -12,7 +12,7 @@ import OpenHABCore import SwiftUI -struct WidgetSwitchView: View { +struct WidgetSetpointView: View { @ObservedObject var widget: OpenHABWidget var body: some View { diff --git a/openHAB/WidgetSwitchView.swift b/openHAB/WidgetSwitchView.swift index 1831a261c..2f3fbb144 100644 --- a/openHAB/WidgetSwitchView.swift +++ b/openHAB/WidgetSwitchView.swift @@ -10,23 +10,56 @@ // SPDX-License-Identifier: EPL-2.0 import OpenHABCore +import os.log import SwiftUI struct WidgetSwitchView: View { @ObservedObject var widget: OpenHABWidget + private let logger = Logger(subsystem: "org.openhab", category: "WidgetSwitchView") + + private var effectiveState: String { + var state = widget.state + // If state is nil or empty using the item state (OH 1.x compatibility) + if state.isEmpty { + state = widget.item?.state ?? "" + } + return state + } + + private var isOn: Bool { + effectiveState.parseAsBool() + } + var body: some View { - Toggle(isOn: Binding( - get: { - widget.state.uppercased() == "ON" - }, - set: { newValue in - let newState = newValue ? "ON" : "OFF" - widget.state = newState // 1. Update local state immediately - widget.sendCommand(newState) // 2. Send to server + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(widget.labelText ?? widget.label) + .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(UIColor(fromString: widget.labelcolor))) + + if let labelValue = widget.labelValue, !labelValue.isEmpty { + Text(labelValue) + .font(.caption) + .foregroundColor(widget.valuecolor.isEmpty ? .secondary : Color(UIColor(fromString: widget.valuecolor))) + } } - )) { - Text(widget.labelText ?? widget.label) + + Spacer() + + Toggle("", isOn: Binding( + get: { isOn }, + set: { newValue in + let newState = newValue ? "ON" : "OFF" + if newValue { + logger.info("Switch to ON") + } else { + logger.info("Switch to OFF") + } + widget.sendCommand(newState) + } + )) + .labelsHidden() } + .contentShape(Rectangle()) } } diff --git a/openHAB/WidgetViewFactory.swift b/openHAB/WidgetViewFactory.swift index 7e7666ae1..dd9295b4f 100644 --- a/openHAB/WidgetViewFactory.swift +++ b/openHAB/WidgetViewFactory.swift @@ -24,6 +24,8 @@ enum WidgetViewFactory { WidgetTextView(widget: widget) case .frame: EmptyView() // ignore frames + case .setpoint: + WidgetSetpointView(widget: widget) default: WidgetGenericView(widget: widget) } From 49174e225c5d0ae063fc671aede76c975f3c21b5 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Wed, 9 Jul 2025 20:51:52 +0200 Subject: [PATCH 236/476] Porting functionality to SwiftUI Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- openHAB.xcodeproj/project.pbxproj | 72 +++++++++-- openHAB/ColorExtension.swift | 23 ++++ openHAB/{ => SwiftUI}/SitemapPageView.swift | 0 openHAB/SwiftUI/WidgetColorPickerView.swift | 77 ++++++++++++ .../SwiftUI/WidgetDatePickerInputView.swift | 105 ++++++++++++++++ openHAB/{ => SwiftUI}/WidgetGenericView.swift | 0 openHAB/SwiftUI/WidgetImageView.swift | 64 ++++++++++ openHAB/SwiftUI/WidgetMapView.swift | 66 ++++++++++ openHAB/SwiftUI/WidgetRollershutterView.swift | 82 +++++++++++++ openHAB/{ => SwiftUI}/WidgetRow.swift | 0 openHAB/SwiftUI/WidgetSegmentedView.swift | 75 ++++++++++++ openHAB/SwiftUI/WidgetSelectionView.swift | 58 +++++++++ openHAB/SwiftUI/WidgetSetpointView.swift | 114 +++++++++++++++++ openHAB/{ => SwiftUI}/WidgetSliderView.swift | 0 .../SwiftUI/WidgetSliderWithSwitchView.swift | 115 ++++++++++++++++++ openHAB/{ => SwiftUI}/WidgetSwitchView.swift | 0 openHAB/SwiftUI/WidgetTextInputView.swift | 58 +++++++++ openHAB/{ => SwiftUI}/WidgetTextView.swift | 0 openHAB/SwiftUI/WidgetVideoView.swift | 60 +++++++++ openHAB/SwiftUI/WidgetViewFactory.swift | 65 ++++++++++ openHAB/SwiftUI/WidgetWebView.swift | 69 +++++++++++ openHAB/WidgetSetpointView.swift | 32 ----- openHAB/WidgetViewFactory.swift | 33 ----- 23 files changed, 1095 insertions(+), 73 deletions(-) create mode 100644 openHAB/ColorExtension.swift rename openHAB/{ => SwiftUI}/SitemapPageView.swift (100%) create mode 100644 openHAB/SwiftUI/WidgetColorPickerView.swift create mode 100644 openHAB/SwiftUI/WidgetDatePickerInputView.swift rename openHAB/{ => SwiftUI}/WidgetGenericView.swift (100%) create mode 100644 openHAB/SwiftUI/WidgetImageView.swift create mode 100644 openHAB/SwiftUI/WidgetMapView.swift create mode 100644 openHAB/SwiftUI/WidgetRollershutterView.swift rename openHAB/{ => SwiftUI}/WidgetRow.swift (100%) create mode 100644 openHAB/SwiftUI/WidgetSegmentedView.swift create mode 100644 openHAB/SwiftUI/WidgetSelectionView.swift create mode 100644 openHAB/SwiftUI/WidgetSetpointView.swift rename openHAB/{ => SwiftUI}/WidgetSliderView.swift (100%) create mode 100644 openHAB/SwiftUI/WidgetSliderWithSwitchView.swift rename openHAB/{ => SwiftUI}/WidgetSwitchView.swift (100%) create mode 100644 openHAB/SwiftUI/WidgetTextInputView.swift rename openHAB/{ => SwiftUI}/WidgetTextView.swift (100%) create mode 100644 openHAB/SwiftUI/WidgetVideoView.swift create mode 100644 openHAB/SwiftUI/WidgetViewFactory.swift create mode 100644 openHAB/SwiftUI/WidgetWebView.swift delete mode 100644 openHAB/WidgetSetpointView.swift delete mode 100644 openHAB/WidgetViewFactory.swift diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index 1e1a2df5d..dae5a534f 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -89,6 +89,18 @@ DA2E0B1023DCC439009B0A99 /* MapViewRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2E0B0F23DCC439009B0A99 /* MapViewRow.swift */; }; DA32D1B42C8C98C40018D974 /* IconWithAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA32D1B32C8C98C40018D974 /* IconWithAction.swift */; }; DA35E2B02E1EDB86003987BB /* WidgetSetpointView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA35E2AF2E1EDB86003987BB /* WidgetSetpointView.swift */; }; + DA35E2BD2E1EEA9D003987BB /* WidgetMapView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA35E2B52E1EEA9D003987BB /* WidgetMapView.swift */; }; + DA35E2BE2E1EEA9D003987BB /* WidgetDatePickerInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA35E2B32E1EEA9D003987BB /* WidgetDatePickerInputView.swift */; }; + DA35E2BF2E1EEA9D003987BB /* WidgetWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA35E2BC2E1EEA9D003987BB /* WidgetWebView.swift */; }; + DA35E2C02E1EEA9D003987BB /* WidgetColorPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA35E2B22E1EEA9D003987BB /* WidgetColorPickerView.swift */; }; + DA35E2C12E1EEA9D003987BB /* WidgetSliderWithSwitchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA35E2B92E1EEA9D003987BB /* WidgetSliderWithSwitchView.swift */; }; + DA35E2C22E1EEA9D003987BB /* WidgetTextInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA35E2BA2E1EEA9D003987BB /* WidgetTextInputView.swift */; }; + DA35E2C32E1EEA9D003987BB /* WidgetImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA35E2B42E1EEA9D003987BB /* WidgetImageView.swift */; }; + DA35E2C42E1EEA9D003987BB /* WidgetSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA35E2B82E1EEA9D003987BB /* WidgetSelectionView.swift */; }; + DA35E2C52E1EEA9D003987BB /* WidgetRollershutterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA35E2B62E1EEA9D003987BB /* WidgetRollershutterView.swift */; }; + DA35E2C62E1EEA9D003987BB /* WidgetSegmentedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA35E2B72E1EEA9D003987BB /* WidgetSegmentedView.swift */; }; + DA35E2C72E1EEA9D003987BB /* WidgetVideoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA35E2BB2E1EEA9D003987BB /* WidgetVideoView.swift */; }; + DA35E2C92E1EF15B003987BB /* ColorExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA35E2C82E1EF15B003987BB /* ColorExtension.swift */; }; DA4642322D7EE6CA006C3908 /* LoggerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4642312D7EE6CA006C3908 /* LoggerView.swift */; }; DA4800142D836892009CF127 /* ConnectionSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4800132D836892009CF127 /* ConnectionSettingsView.swift */; }; DA4800162D836EF0009CF127 /* MainUISettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4800152D836EF0009CF127 /* MainUISettingsView.swift */; }; @@ -413,6 +425,18 @@ DA2E0B0F23DCC439009B0A99 /* MapViewRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapViewRow.swift; sourceTree = ""; }; DA32D1B32C8C98C40018D974 /* IconWithAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconWithAction.swift; sourceTree = ""; }; DA35E2AF2E1EDB86003987BB /* WidgetSetpointView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetSetpointView.swift; sourceTree = ""; }; + DA35E2B22E1EEA9D003987BB /* WidgetColorPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetColorPickerView.swift; sourceTree = ""; }; + DA35E2B32E1EEA9D003987BB /* WidgetDatePickerInputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetDatePickerInputView.swift; sourceTree = ""; }; + DA35E2B42E1EEA9D003987BB /* WidgetImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetImageView.swift; sourceTree = ""; }; + DA35E2B52E1EEA9D003987BB /* WidgetMapView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetMapView.swift; sourceTree = ""; }; + DA35E2B62E1EEA9D003987BB /* WidgetRollershutterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetRollershutterView.swift; sourceTree = ""; }; + DA35E2B72E1EEA9D003987BB /* WidgetSegmentedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetSegmentedView.swift; sourceTree = ""; }; + DA35E2B82E1EEA9D003987BB /* WidgetSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetSelectionView.swift; sourceTree = ""; }; + DA35E2B92E1EEA9D003987BB /* WidgetSliderWithSwitchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetSliderWithSwitchView.swift; sourceTree = ""; }; + DA35E2BA2E1EEA9D003987BB /* WidgetTextInputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetTextInputView.swift; sourceTree = ""; }; + DA35E2BB2E1EEA9D003987BB /* WidgetVideoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetVideoView.swift; sourceTree = ""; }; + DA35E2BC2E1EEA9D003987BB /* WidgetWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetWebView.swift; sourceTree = ""; }; + DA35E2C82E1EF15B003987BB /* ColorExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorExtension.swift; sourceTree = ""; }; DA4642312D7EE6CA006C3908 /* LoggerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoggerView.swift; sourceTree = ""; }; DA4800132D836892009CF127 /* ConnectionSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionSettingsView.swift; sourceTree = ""; }; DA4800152D836EF0009CF127 /* MainUISettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainUISettingsView.swift; sourceTree = ""; }; @@ -815,6 +839,32 @@ path = openHABTestsSwift; sourceTree = ""; }; + DA35E2B12E1EEA58003987BB /* SwiftUI */ = { + isa = PBXGroup; + children = ( + DAEA21DF2DBF483E00D54342 /* WidgetGenericView.swift */, + DA64ACA72DBEAD8300294F60 /* SitemapPageView.swift */, + DAEA21D72DBF472D00D54342 /* WidgetViewFactory.swift */, + DA64ACA92DBEADB000294F60 /* WidgetRow.swift */, + DA35E2B22E1EEA9D003987BB /* WidgetColorPickerView.swift */, + DA35E2B32E1EEA9D003987BB /* WidgetDatePickerInputView.swift */, + DA35E2B42E1EEA9D003987BB /* WidgetImageView.swift */, + DA35E2B52E1EEA9D003987BB /* WidgetMapView.swift */, + DA35E2B62E1EEA9D003987BB /* WidgetRollershutterView.swift */, + DA35E2B72E1EEA9D003987BB /* WidgetSegmentedView.swift */, + DA35E2B82E1EEA9D003987BB /* WidgetSelectionView.swift */, + DA35E2B92E1EEA9D003987BB /* WidgetSliderWithSwitchView.swift */, + DA35E2BA2E1EEA9D003987BB /* WidgetTextInputView.swift */, + DA35E2BB2E1EEA9D003987BB /* WidgetVideoView.swift */, + DA35E2BC2E1EEA9D003987BB /* WidgetWebView.swift */, + DAEA21D92DBF477E00D54342 /* WidgetSwitchView.swift */, + DA35E2AF2E1EDB86003987BB /* WidgetSetpointView.swift */, + DAEA21DB2DBF47DA00D54342 /* WidgetSliderView.swift */, + DAEA21DD2DBF481300D54342 /* WidgetTextView.swift */, + ); + path = SwiftUI; + sourceTree = ""; + }; DA48001F2D837CD8009CF127 /* SettingsView */ = { isa = PBXGroup; children = ( @@ -943,6 +993,7 @@ DF4B83FD18857FA100F34902 /* UI */ = { isa = PBXGroup; children = ( + DA35E2C82E1EF15B003987BB /* ColorExtension.swift */, DA2AEB752D92D32000897D80 /* Cells */, DA4642312D7EE6CA006C3908 /* LoggerView.swift */, 653B54C1285E714900298ECD /* OpenHABViewController.swift */, @@ -961,9 +1012,7 @@ DA6B2EEE2C861BC900DF77CF /* DrawerView.swift */, DF4B84101886DA9900F34902 /* Widgets */, DFFD8FCE18EDBD30003B502A /* Util */, - DA64ACA72DBEAD8300294F60 /* SitemapPageView.swift */, - DAEA21D72DBF472D00D54342 /* WidgetViewFactory.swift */, - DA64ACA92DBEADB000294F60 /* WidgetRow.swift */, + DA35E2B12E1EEA58003987BB /* SwiftUI */, ); name = UI; sourceTree = ""; @@ -1105,15 +1154,10 @@ isa = PBXGroup; children = ( 938EDCE022C4FEB800661CA1 /* ScaleAspectFitImageView.swift */, - DAEA21DF2DBF483E00D54342 /* WidgetGenericView.swift */, DFFD8FD018EDBD4F003B502A /* UICircleButton.swift */, 938BF9C524EFCC0700E6B52F /* UILabel+Localization.swift */, 938BF9D224EFD0B700E6B52F /* UIViewController+Localization.swift */, 935B484525342B8E00E44CF0 /* URL+Static.swift */, - DAEA21D92DBF477E00D54342 /* WidgetSwitchView.swift */, - DA35E2AF2E1EDB86003987BB /* WidgetSetpointView.swift */, - DAEA21DB2DBF47DA00D54342 /* WidgetSliderView.swift */, - DAEA21DD2DBF481300D54342 /* WidgetTextView.swift */, ); name = Util; sourceTree = ""; @@ -1637,6 +1681,7 @@ DAEA21DA2DBF477E00D54342 /* WidgetSwitchView.swift in Sources */, B7D5ECE121499E55001B0EC6 /* MapViewTableViewCell.swift in Sources */, DAEA21DC2DBF47DA00D54342 /* WidgetSliderView.swift in Sources */, + DA35E2C92E1EF15B003987BB /* ColorExtension.swift in Sources */, DA77E19B2D886D9B007CFF0F /* SingleConnectionSettingsView.swift in Sources */, DA35E2B02E1EDB86003987BB /* WidgetSetpointView.swift in Sources */, DA6B2EF52C89F8F200DF77CF /* ColorPickerView.swift in Sources */, @@ -1650,6 +1695,17 @@ DA64ACA82DBEAD8300294F60 /* SitemapPageView.swift in Sources */, DA4800212D839D3A009CF127 /* AnimatedSecureTextField.swift in Sources */, DAC131112DA3213100075AE2 /* SetSwitchStateIntentHandlerTests.swift in Sources */, + DA35E2BD2E1EEA9D003987BB /* WidgetMapView.swift in Sources */, + DA35E2BE2E1EEA9D003987BB /* WidgetDatePickerInputView.swift in Sources */, + DA35E2BF2E1EEA9D003987BB /* WidgetWebView.swift in Sources */, + DA35E2C02E1EEA9D003987BB /* WidgetColorPickerView.swift in Sources */, + DA35E2C12E1EEA9D003987BB /* WidgetSliderWithSwitchView.swift in Sources */, + DA35E2C22E1EEA9D003987BB /* WidgetTextInputView.swift in Sources */, + DA35E2C32E1EEA9D003987BB /* WidgetImageView.swift in Sources */, + DA35E2C42E1EEA9D003987BB /* WidgetSelectionView.swift in Sources */, + DA35E2C52E1EEA9D003987BB /* WidgetRollershutterView.swift in Sources */, + DA35E2C62E1EEA9D003987BB /* WidgetSegmentedView.swift in Sources */, + DA35E2C72E1EEA9D003987BB /* WidgetVideoView.swift in Sources */, DAC65FC7236EDF3900F4501E /* SpinnerViewController.swift in Sources */, DA9F81872C85020F00B47B72 /* RTFTextView.swift in Sources */, DA6B2EF12C87B59000DF77CF /* NotificationsView.swift in Sources */, diff --git a/openHAB/ColorExtension.swift b/openHAB/ColorExtension.swift new file mode 100644 index 000000000..0f1661cf4 --- /dev/null +++ b/openHAB/ColorExtension.swift @@ -0,0 +1,23 @@ +// Copyright (c) 2010-2025 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 OpenHABCore +import SwiftUI + +extension Color { + init(fromString string: String) { + self.init(UIColor(fromString: string)) + } + + init(hex: String) { + self.init(UIColor(hex: hex)) + } +} diff --git a/openHAB/SitemapPageView.swift b/openHAB/SwiftUI/SitemapPageView.swift similarity index 100% rename from openHAB/SitemapPageView.swift rename to openHAB/SwiftUI/SitemapPageView.swift diff --git a/openHAB/SwiftUI/WidgetColorPickerView.swift b/openHAB/SwiftUI/WidgetColorPickerView.swift new file mode 100644 index 000000000..22b5e1921 --- /dev/null +++ b/openHAB/SwiftUI/WidgetColorPickerView.swift @@ -0,0 +1,77 @@ +// Copyright (c) 2010-2025 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 OpenHABCore +import os.log +import SwiftUI + +struct WidgetColorPickerView: View { + @ObservedObject var widget: OpenHABWidget + @State private var selectedColor: Color = .white + + private let logger = Logger(subsystem: "org.openhab", category: "WidgetColorPickerView") + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + if let labelText = widget.labelText, !labelText.isEmpty { + Text(labelText) + .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(UIColor(fromString: widget.labelcolor))) + } + + ColorPicker("Color", selection: $selectedColor, supportsOpacity: false) + .labelsHidden() + .onChange(of: selectedColor) { newColor in + sendColorCommand(newColor) + } + + if let labelValue = widget.labelValue, !labelValue.isEmpty { + Text(labelValue) + .font(.caption) + .foregroundColor(widget.valuecolor.isEmpty ? .secondary : Color(UIColor(fromString: widget.valuecolor))) + } + } + .onAppear { + if let state = widget.item?.state, !state.isEmpty { + selectedColor = parseColor(from: state) ?? .white + } + } + } + + private func sendColorCommand(_ color: Color) { + let uiColor = UIColor(color) + var hue: CGFloat = 0 + var saturation: CGFloat = 0 + var brightness: CGFloat = 0 + var alpha: CGFloat = 0 + + uiColor.getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: &alpha) + + let hueValue = Int(hue * 360) + let saturationValue = Int(saturation * 100) + let brightnessValue = Int(brightness * 100) + + let command = "\(hueValue),\(saturationValue),\(brightnessValue)" + logger.info("Sending color command: \(command)") + widget.sendCommand(command) + } + + private func parseColor(from state: String) -> Color? { + let components = state.split(separator: ",") + guard components.count >= 3, + let hue = Double(components[0]), + let saturation = Double(components[1]), + let brightness = Double(components[2]) else { + return nil + } + + return Color(hue: hue / 360.0, saturation: saturation / 100.0, brightness: brightness / 100.0) + } +} diff --git a/openHAB/SwiftUI/WidgetDatePickerInputView.swift b/openHAB/SwiftUI/WidgetDatePickerInputView.swift new file mode 100644 index 000000000..a2881e2c8 --- /dev/null +++ b/openHAB/SwiftUI/WidgetDatePickerInputView.swift @@ -0,0 +1,105 @@ +// Copyright (c) 2010-2025 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 OpenHABCore +import os.log +import SwiftUI + +struct WidgetDatePickerInputView: View { + @ObservedObject var widget: OpenHABWidget + @State private var selectedDate = Date() + + private let logger = Logger(subsystem: "org.openhab", category: "WidgetDatePickerInputView") + + private var datePickerComponents: DatePickerComponents { + switch widget.inputHint { + case .date: .date + case .time: .hourAndMinute + case .datetime: [.date, .hourAndMinute] + default: [.date, .hourAndMinute] + } + } + + private var useWheelStyle: Bool { + widget.inputHint == .time + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + if let labelText = widget.labelText, !labelText.isEmpty { + Text(labelText) + .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(UIColor(fromString: widget.labelcolor))) + } + + DatePicker( + selection: $selectedDate, + displayedComponents: datePickerComponents + ) { + EmptyView() + } +// if useWheelStyle { +// .datePickerStyle(.wheel) +// } else { +// .datePickerStyle(.compact) +// } + .onChange(of: selectedDate) { newDate in + sendDateCommand(newDate) + } + + if let labelValue = widget.labelValue, !labelValue.isEmpty { + Text(labelValue) + .font(.caption) + .foregroundColor(widget.valuecolor.isEmpty ? .secondary : Color(UIColor(fromString: widget.valuecolor))) + } + } + .onAppear { + if let state = widget.item?.state, !state.isEmpty { + selectedDate = parseDate(from: state) ?? Date() + } + } + } + + private func sendDateCommand(_ date: Date) { + let formatter = DateFormatter() + + switch widget.inputHint { + case .date: + formatter.dateFormat = "yyyy-MM-dd" + case .time: + formatter.dateFormat = "HH:mm" + case .datetime: + formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss" + default: + formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss" + } + + let command = formatter.string(from: date) + logger.info("Sending date command: \(command)") + widget.sendCommand(command) + } + + private func parseDate(from state: String) -> Date? { + let formatters = [ + "yyyy-MM-dd'T'HH:mm:ss", + "yyyy-MM-dd", + "HH:mm" + ] + + for format in formatters { + let formatter = DateFormatter() + formatter.dateFormat = format + if let date = formatter.date(from: state) { + return date + } + } + return nil + } +} diff --git a/openHAB/WidgetGenericView.swift b/openHAB/SwiftUI/WidgetGenericView.swift similarity index 100% rename from openHAB/WidgetGenericView.swift rename to openHAB/SwiftUI/WidgetGenericView.swift diff --git a/openHAB/SwiftUI/WidgetImageView.swift b/openHAB/SwiftUI/WidgetImageView.swift new file mode 100644 index 000000000..a170bcdab --- /dev/null +++ b/openHAB/SwiftUI/WidgetImageView.swift @@ -0,0 +1,64 @@ +// Copyright (c) 2010-2025 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 Kingfisher +import OpenHABCore +import SwiftUI + +struct WidgetImageView: View { + @ObservedObject var widget: OpenHABWidget + + private var imageURL: URL? { + guard !widget.url.isEmpty else { return nil } + return URL(string: widget.url) + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + if let labelText = widget.labelText, !labelText.isEmpty { + Text(labelText) + .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(UIColor(fromString: widget.labelcolor))) + } + + if let imageURL { + KFImage(imageURL) + .placeholder { + Rectangle() + .fill(Color.gray.opacity(0.3)) + .frame(height: 200) + .overlay( + ProgressView() + .progressViewStyle(CircularProgressViewStyle()) + ) + } + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxHeight: 300) + .cornerRadius(8) + } else { + Rectangle() + .fill(Color.gray.opacity(0.3)) + .frame(height: 200) + .overlay( + Text("No Image URL") + .foregroundColor(.secondary) + ) + .cornerRadius(8) + } + + if let labelValue = widget.labelValue, !labelValue.isEmpty { + Text(labelValue) + .font(.caption) + .foregroundColor(widget.valuecolor.isEmpty ? .secondary : Color(UIColor(fromString: widget.valuecolor))) + } + } + } +} diff --git a/openHAB/SwiftUI/WidgetMapView.swift b/openHAB/SwiftUI/WidgetMapView.swift new file mode 100644 index 000000000..663d075a1 --- /dev/null +++ b/openHAB/SwiftUI/WidgetMapView.swift @@ -0,0 +1,66 @@ +// Copyright (c) 2010-2025 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 CoreLocation +import MapKit +import OpenHABCore +import SwiftUI + +struct WidgetMapView: View { + @ObservedObject var widget: OpenHABWidget + @State private var region = MKCoordinateRegion( + center: CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194), + span: MKCoordinateSpan(latitudeDelta: 0.05, longitudeDelta: 0.05) + ) + + private var coordinates: CLLocationCoordinate2D? { + guard let state = widget.item?.state, !state.isEmpty else { return nil } + let components = state.split(separator: ",") + guard components.count >= 2, + let latitude = Double(components[0]), + let longitude = Double(components[1]) else { + return nil + } + return CLLocationCoordinate2D(latitude: latitude, longitude: longitude) + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + if let labelText = widget.labelText, !labelText.isEmpty { + Text(labelText) + .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(UIColor(fromString: widget.labelcolor))) + } + + Map(coordinateRegion: $region, annotationItems: coordinates.map { [$0] } ?? []) { coordinate in + MapPin(coordinate: coordinate, tint: .red) + } + .frame(height: 200) + .cornerRadius(8) + .onAppear { + if let coordinates { + region.center = coordinates + } + } + + if let labelValue = widget.labelValue, !labelValue.isEmpty { + Text(labelValue) + .font(.caption) + .foregroundColor(widget.valuecolor.isEmpty ? .secondary : Color(UIColor(fromString: widget.valuecolor))) + } + } + } +} + +extension CLLocationCoordinate2D: @retroactive Identifiable { + var id: String { + "\(latitude),\(longitude)" + } +} diff --git a/openHAB/SwiftUI/WidgetRollershutterView.swift b/openHAB/SwiftUI/WidgetRollershutterView.swift new file mode 100644 index 000000000..1997d8e86 --- /dev/null +++ b/openHAB/SwiftUI/WidgetRollershutterView.swift @@ -0,0 +1,82 @@ +// Copyright (c) 2010-2025 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 OpenHABCore +import os.log +import SwiftUI + +struct WidgetRollershutterView: View { + @ObservedObject var widget: OpenHABWidget + + private let logger = Logger(subsystem: "org.openhab", category: "WidgetRollershutterView") + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(widget.labelText ?? widget.label) + .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(UIColor(fromString: widget.labelcolor))) + + if let labelValue = widget.labelValue, !labelValue.isEmpty { + Text(labelValue) + .font(.caption) + .foregroundColor(widget.valuecolor.isEmpty ? .secondary : Color(UIColor(fromString: widget.valuecolor))) + } + } + + Spacer() + } + + HStack(spacing: 12) { + Button(action: { + logger.info("up button pressed") + widget.sendCommand("UP") + }) { + Image(systemName: "chevron.up") + .font(.title2) + .foregroundColor(.primary) + .frame(width: 44, height: 44) + .background(Color.secondary.opacity(0.2)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + .buttonStyle(.plain) + + Button(action: { + logger.info("stop button pressed") + widget.sendCommand("STOP") + }) { + Image(systemName: "stop.fill") + .font(.title2) + .foregroundColor(.primary) + .frame(width: 44, height: 44) + .background(Color.secondary.opacity(0.2)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + .buttonStyle(.plain) + + Button(action: { + logger.info("down button pressed") + widget.sendCommand("DOWN") + }) { + Image(systemName: "chevron.down") + .font(.title2) + .foregroundColor(.primary) + .frame(width: 44, height: 44) + .background(Color.secondary.opacity(0.2)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + .buttonStyle(.plain) + + Spacer() + } + } + } +} diff --git a/openHAB/WidgetRow.swift b/openHAB/SwiftUI/WidgetRow.swift similarity index 100% rename from openHAB/WidgetRow.swift rename to openHAB/SwiftUI/WidgetRow.swift diff --git a/openHAB/SwiftUI/WidgetSegmentedView.swift b/openHAB/SwiftUI/WidgetSegmentedView.swift new file mode 100644 index 000000000..89c1ea0f9 --- /dev/null +++ b/openHAB/SwiftUI/WidgetSegmentedView.swift @@ -0,0 +1,75 @@ +// Copyright (c) 2010-2025 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 OpenHABCore +import os.log +import SwiftUI + +struct WidgetSegmentedView: View { + @ObservedObject var widget: OpenHABWidget + + private let logger = Logger(subsystem: "org.openhab", category: "WidgetSegmentedView") + + private var mappings: [OpenHABWidgetMapping] { + widget.mappingsOrItemOptions + } + + private var selectedIndex: Int? { + guard !isMomentary else { return nil } + return Int(widget.mappingIndex(byCommand: widget.item?.state) ?? -1).clamped(to: -1 ... mappings.count - 1) == -1 ? nil : Int(widget.mappingIndex(byCommand: widget.item?.state) ?? -1) + } + + private var isMomentary: Bool { + mappings.count == 1 || widget.item?.state == "NULL" + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + if let labelText = widget.labelText, !labelText.isEmpty { + HStack { + Text(labelText) + .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(UIColor(fromString: widget.labelcolor))) + + Spacer() + + if let labelValue = widget.labelValue, !labelValue.isEmpty { + Text(labelValue) + .font(.caption) + .foregroundColor(widget.valuecolor.isEmpty ? .secondary : Color(UIColor(fromString: widget.valuecolor))) + } + } + } + + if !mappings.isEmpty { + Picker("", selection: Binding( + get: { selectedIndex }, + set: { newIndex in + guard let index = newIndex, let mapping = mappings[safe: index] else { return } + logger.info("Segment pressed \(index)") + widget.sendCommand(mapping.command) + } + )) { + ForEach(mappings.indices, id: \.self) { index in + Text(mappings[index].label) + .tag(index as Int?) + } + } + .pickerStyle(.segmented) + } + } + } +} + +extension Int { + func clamped(to range: ClosedRange) -> Int { + Swift.min(Swift.max(self, range.lowerBound), range.upperBound) + } +} diff --git a/openHAB/SwiftUI/WidgetSelectionView.swift b/openHAB/SwiftUI/WidgetSelectionView.swift new file mode 100644 index 000000000..de95b88ac --- /dev/null +++ b/openHAB/SwiftUI/WidgetSelectionView.swift @@ -0,0 +1,58 @@ +// Copyright (c) 2010-2025 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 OpenHABCore +import os.log +import SwiftUI + +struct WidgetSelectionView: View { + @ObservedObject var widget: OpenHABWidget + @State private var selectedIndex: Int = 0 + + private let logger = Logger(subsystem: "org.openhab", category: "WidgetSelectionView") + + private var mappings: [OpenHABWidgetMapping] { + widget.mappingsOrItemOptions + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + if let labelText = widget.labelText, !labelText.isEmpty { + Text(labelText) + .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(UIColor(fromString: widget.labelcolor))) + } + + if !mappings.isEmpty { + Picker("Selection", selection: $selectedIndex) { + ForEach(mappings.indices, id: \.self) { index in + Text(mappings[index].label) + .tag(index) + } + } + .pickerStyle(.menu) + .onChange(of: selectedIndex) { newIndex in + guard let mapping = mappings[safe: newIndex] else { return } + logger.info("Selection changed to: \(mapping.label)") + widget.sendCommand(mapping.command) + } + } + + if let labelValue = widget.labelValue, !labelValue.isEmpty { + Text(labelValue) + .font(.caption) + .foregroundColor(widget.valuecolor.isEmpty ? .secondary : Color(UIColor(fromString: widget.valuecolor))) + } + } + .onAppear { + selectedIndex = Int(widget.mappingIndex(byCommand: widget.item?.state) ?? 0) + } + } +} diff --git a/openHAB/SwiftUI/WidgetSetpointView.swift b/openHAB/SwiftUI/WidgetSetpointView.swift new file mode 100644 index 000000000..7b3602561 --- /dev/null +++ b/openHAB/SwiftUI/WidgetSetpointView.swift @@ -0,0 +1,114 @@ +// Copyright (c) 2010-2025 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 OpenHABCore +import os.log +import SwiftUI + +struct WidgetSetpointView: View { + @ObservedObject var widget: OpenHABWidget + + private let logger = Logger(subsystem: "org.openhab", category: "WidgetSetpointView") + private let setpointService = SetPointService() + + private var currentValue: Double { + widget.stateValueAsNumberState?.value ?? widget.minValue + } + + private var formattedValue: String { + if let labelValue = widget.labelValue, !labelValue.isEmpty { + return labelValue + } else { + let step = widget.step + if step.truncatingRemainder(dividingBy: 1) == 0 { + return String(format: "%.0f", currentValue) + } else { + return String(format: "%.1f", currentValue) + } + } + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(widget.labelText ?? widget.label) + .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(UIColor(fromString: widget.labelcolor))) + + Text(formattedValue) + .font(.caption) + .foregroundColor(widget.valuecolor.isEmpty ? .secondary : Color(UIColor(fromString: widget.valuecolor))) + } + + Spacer() + + HStack(spacing: 12) { + Button(action: decreaseValue) { + Image(systemName: "minus") + .font(.title2) + .foregroundColor(.primary) + .frame(width: 44, height: 44) + .background(Color.secondary.opacity(0.2)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + .buttonStyle(.plain) + .disabled(currentValue <= widget.minValue) + + Button(action: increaseValue) { + Image(systemName: "plus") + .font(.title2) + .foregroundColor(.primary) + .frame(width: 44, height: 44) + .background(Color.secondary.opacity(0.2)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + .buttonStyle(.plain) + .disabled(currentValue >= widget.maxValue) + } + } + } + } + + private func decreaseValue() { + handleUpDown(isDecreasing: true) + } + + private func increaseValue() { + handleUpDown(isDecreasing: false) + } + + private func handleUpDown(isDecreasing: Bool) { + var numberState = widget.stateValueAsNumberState + let currentValue = numberState?.value ?? widget.minValue + + let limitedNewValue = setpointService.calculateNewValue( + currentValue: currentValue, + step: widget.step, + minValue: widget.minValue, + maxValue: widget.maxValue, + isDecreasing: isDecreasing + ) + + guard limitedNewValue != currentValue else { + // nothing to update, skip sending value + return + } + + if numberState != nil { + numberState?.value = limitedNewValue + } else { + numberState = NumberState(value: limitedNewValue) + } + + logger.info("Setpoint \(isDecreasing ? "decreased" : "increased") to \(limitedNewValue)") + widget.sendItemUpdate(state: numberState) + } +} diff --git a/openHAB/WidgetSliderView.swift b/openHAB/SwiftUI/WidgetSliderView.swift similarity index 100% rename from openHAB/WidgetSliderView.swift rename to openHAB/SwiftUI/WidgetSliderView.swift diff --git a/openHAB/SwiftUI/WidgetSliderWithSwitchView.swift b/openHAB/SwiftUI/WidgetSliderWithSwitchView.swift new file mode 100644 index 000000000..6b3fd8101 --- /dev/null +++ b/openHAB/SwiftUI/WidgetSliderWithSwitchView.swift @@ -0,0 +1,115 @@ +// Copyright (c) 2010-2025 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 OpenHABCore +import os.log +import SwiftUI + +struct WidgetSliderWithSwitchView: View { + @ObservedObject var widget: OpenHABWidget + @State private var sliderValue: Double = 0 + + private let logger = Logger(subsystem: "org.openhab", category: "WidgetSliderWithSwitchView") + + private var step: Double { + widget.step + } + + private var effectiveState: String { + var state = widget.state + if state.isEmpty { + state = widget.item?.state ?? "" + } + return state + } + + private var isSwitchOn: Bool { + effectiveState.parseAsBool() + } + + private var adjustedValue: Double { + if let item = widget.item { + adj(item.stateAsDouble()) + } else { + widget.minValue + } + } + + private func adj(_ raw: Double) -> Double { + var valueAdjustedToStep = Double(floor(Float((raw - widget.minValue) / step)) * Float(step)) + valueAdjustedToStep += widget.minValue + return valueAdjustedToStep.clamped(to: widget.minValue ... widget.maxValue) + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(widget.labelText ?? widget.label) + .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(UIColor(fromString: widget.labelcolor))) + + if let labelValue = widget.labelValue, !labelValue.isEmpty { + Text(labelValue) + .font(.caption) + .foregroundColor(widget.valuecolor.isEmpty ? .secondary : Color(UIColor(fromString: widget.valuecolor))) + } else { + Text(sliderValue.valueText(step: step)) + .font(.caption) + .foregroundColor(.secondary) + } + } + + Spacer() + + Toggle("", isOn: Binding( + get: { isSwitchOn }, + set: { newValue in + let command = newValue ? "ON" : "OFF" + logger.info("Switch to \(command)") + widget.sendCommand(command) + } + )) + .labelsHidden() + } + + Slider( + value: $sliderValue, + in: widget.minValue ... widget.maxValue, + step: step + ) { editing in + if !editing { + logger.info("Slider new value = \(sliderValue)") + widget.sendCommand(sliderValue.valueText(step: step)) + } + } + } + .onAppear { + sliderValue = adjustedValue + } + .onChange(of: widget.item?.state) { _ in + sliderValue = adjustedValue + } + } +} + +extension Double { + func clamped(to range: ClosedRange) -> Double { + Swift.min(Swift.max(self, range.lowerBound), range.upperBound) + } + + func valueText(step: Double) -> String { + if step.truncatingRemainder(dividingBy: 1) == 0 { + String(format: "%.0f", self) + } else { + String(format: "%.1f", self) + } + } +} diff --git a/openHAB/WidgetSwitchView.swift b/openHAB/SwiftUI/WidgetSwitchView.swift similarity index 100% rename from openHAB/WidgetSwitchView.swift rename to openHAB/SwiftUI/WidgetSwitchView.swift diff --git a/openHAB/SwiftUI/WidgetTextInputView.swift b/openHAB/SwiftUI/WidgetTextInputView.swift new file mode 100644 index 000000000..26c08e8ff --- /dev/null +++ b/openHAB/SwiftUI/WidgetTextInputView.swift @@ -0,0 +1,58 @@ +// Copyright (c) 2010-2025 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 OpenHABCore +import os.log +import SwiftUI + +struct WidgetTextInputView: View { + @ObservedObject var widget: OpenHABWidget + @State private var inputText: String = "" + @FocusState private var isTextFieldFocused: Bool + + private let logger = Logger(subsystem: "org.openhab", category: "WidgetTextInputView") + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + if let labelText = widget.labelText, !labelText.isEmpty { + Text(labelText) + .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(UIColor(fromString: widget.labelcolor))) + } + + TextField("Enter text", text: $inputText) + .textFieldStyle(.roundedBorder) + .focused($isTextFieldFocused) + .onSubmit { + sendTextCommand() + } + + if let labelValue = widget.labelValue, !labelValue.isEmpty { + Text(labelValue) + .font(.caption) + .foregroundColor(widget.valuecolor.isEmpty ? .secondary : Color(UIColor(fromString: widget.valuecolor))) + } + } + .onAppear { + inputText = widget.item?.state ?? "" + } + .onChange(of: widget.item?.state) { newState in + if !isTextFieldFocused { + inputText = newState ?? "" + } + } + } + + private func sendTextCommand() { + logger.info("Sending text command: \(inputText)") + widget.sendCommand(inputText) + isTextFieldFocused = false + } +} diff --git a/openHAB/WidgetTextView.swift b/openHAB/SwiftUI/WidgetTextView.swift similarity index 100% rename from openHAB/WidgetTextView.swift rename to openHAB/SwiftUI/WidgetTextView.swift diff --git a/openHAB/SwiftUI/WidgetVideoView.swift b/openHAB/SwiftUI/WidgetVideoView.swift new file mode 100644 index 000000000..2f6d369df --- /dev/null +++ b/openHAB/SwiftUI/WidgetVideoView.swift @@ -0,0 +1,60 @@ +// Copyright (c) 2010-2025 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 AVKit +import OpenHABCore +import SwiftUI + +struct WidgetVideoView: View { + @ObservedObject var widget: OpenHABWidget + @State private var player: AVPlayer? + + private var videoURL: URL? { + guard !widget.url.isEmpty else { return nil } + return URL(string: widget.url) + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + if let labelText = widget.labelText, !labelText.isEmpty { + Text(labelText) + .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(UIColor(fromString: widget.labelcolor))) + } + + if let videoURL { + VideoPlayer(player: player) + .frame(height: 200) + .cornerRadius(8) + .onAppear { + player = AVPlayer(url: videoURL) + } + .onDisappear { + player?.pause() + } + } else { + Rectangle() + .fill(Color.gray.opacity(0.3)) + .frame(height: 200) + .overlay( + Text("No Video URL") + .foregroundColor(.secondary) + ) + .cornerRadius(8) + } + + if let labelValue = widget.labelValue, !labelValue.isEmpty { + Text(labelValue) + .font(.caption) + .foregroundColor(widget.valuecolor.isEmpty ? .secondary : Color(UIColor(fromString: widget.valuecolor))) + } + } + } +} diff --git a/openHAB/SwiftUI/WidgetViewFactory.swift b/openHAB/SwiftUI/WidgetViewFactory.swift new file mode 100644 index 000000000..6b157bea1 --- /dev/null +++ b/openHAB/SwiftUI/WidgetViewFactory.swift @@ -0,0 +1,65 @@ +// Copyright (c) 2010-2025 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 OpenHABCore +import SwiftUI + +enum WidgetViewFactory { + @ViewBuilder + static func view(for widget: OpenHABWidget) -> some View { + switch widget.type { + case .switchWidget: + if !widget.mappings.isEmpty { + WidgetSegmentedView(widget: widget) + } else if widget.item?.isOfTypeOrGroupType(.switchItem) ?? false { + WidgetSwitchView(widget: widget) + } else if widget.item?.isOfTypeOrGroupType(.rollershutter) ?? false { + WidgetRollershutterView(widget: widget) + } else if !widget.mappingsOrItemOptions.isEmpty { + WidgetSegmentedView(widget: widget) + } else { + WidgetSwitchView(widget: widget) + } + case .slider: + if widget.switchSupport { + WidgetSliderWithSwitchView(widget: widget) + } else { + WidgetSliderView(widget: widget) + } + case .input: + if [.date, .time, .datetime].contains(widget.inputHint) { + WidgetDatePickerInputView(widget: widget) + } else { + WidgetTextInputView(widget: widget) + } + case .text: + WidgetTextView(widget: widget) + case .frame: + EmptyView() // ignore frames + case .setpoint: + WidgetSetpointView(widget: widget) + case .selection: + WidgetSelectionView(widget: widget) + case .colorpicker: + WidgetColorPickerView(widget: widget) + case .image, .chart: + WidgetImageView(widget: widget) + case .video: + WidgetVideoView(widget: widget) + case .webview: + WidgetWebViewContainer(widget: widget) + case .mapview: + WidgetMapView(widget: widget) + case .group, .defaultWidget, .unknown: + WidgetGenericView(widget: widget) + } + } +} diff --git a/openHAB/SwiftUI/WidgetWebView.swift b/openHAB/SwiftUI/WidgetWebView.swift new file mode 100644 index 000000000..55bcb98b4 --- /dev/null +++ b/openHAB/SwiftUI/WidgetWebView.swift @@ -0,0 +1,69 @@ +// Copyright (c) 2010-2025 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 OpenHABCore +import SwiftUI +import WebKit + +struct WidgetWebView: UIViewRepresentable { + @ObservedObject var widget: OpenHABWidget + + private var webURL: URL? { + guard !widget.url.isEmpty else { return nil } + return URL(string: widget.url) + } + + func makeUIView(context: Context) -> WKWebView { + let webView = WKWebView() + webView.navigationDelegate = context.coordinator + return webView + } + + func updateUIView(_ webView: WKWebView, context: Context) { + if let webURL { + let request = URLRequest(url: webURL) + webView.load(request) + } + } + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + class Coordinator: NSObject, WKNavigationDelegate { + func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { + print("WebView failed to load: \(error.localizedDescription)") + } + } +} + +struct WidgetWebViewContainer: View { + @ObservedObject var widget: OpenHABWidget + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + if let labelText = widget.labelText, !labelText.isEmpty { + Text(labelText) + .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(UIColor(fromString: widget.labelcolor))) + } + + WidgetWebView(widget: widget) + .frame(height: 300) + .cornerRadius(8) + + if let labelValue = widget.labelValue, !labelValue.isEmpty { + Text(labelValue) + .font(.caption) + .foregroundColor(widget.valuecolor.isEmpty ? .secondary : Color(UIColor(fromString: widget.valuecolor))) + } + } + } +} diff --git a/openHAB/WidgetSetpointView.swift b/openHAB/WidgetSetpointView.swift deleted file mode 100644 index d9a1f3c00..000000000 --- a/openHAB/WidgetSetpointView.swift +++ /dev/null @@ -1,32 +0,0 @@ -// Copyright (c) 2010-2025 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 OpenHABCore -import SwiftUI - -struct WidgetSetpointView: View { - @ObservedObject var widget: OpenHABWidget - - var body: some View { - Toggle(isOn: Binding( - get: { - widget.state.uppercased() == "ON" - }, - set: { newValue in - let newState = newValue ? "ON" : "OFF" - widget.state = newState // 1. Update local state immediately - widget.sendCommand(newState) // 2. Send to server - } - )) { - Text(widget.labelText ?? widget.label) - } - } -} diff --git a/openHAB/WidgetViewFactory.swift b/openHAB/WidgetViewFactory.swift deleted file mode 100644 index dd9295b4f..000000000 --- a/openHAB/WidgetViewFactory.swift +++ /dev/null @@ -1,33 +0,0 @@ -// Copyright (c) 2010-2025 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 OpenHABCore -import SwiftUI - -enum WidgetViewFactory { - @ViewBuilder - static func view(for widget: OpenHABWidget) -> some View { - switch widget.type { - case .switchWidget: - WidgetSwitchView(widget: widget) - case .slider: - WidgetSliderView(widget: widget) - case .text: - WidgetTextView(widget: widget) - case .frame: - EmptyView() // ignore frames - case .setpoint: - WidgetSetpointView(widget: widget) - default: - WidgetGenericView(widget: widget) - } - } -} From 1ba19da681bd1452bb2eb492d2e6f52027a9df82 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Wed, 9 Jul 2025 21:18:15 +0200 Subject: [PATCH 237/476] address linter warnings Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- openHAB/SwiftUI/WidgetRollershutterView.swift | 19 +++--- openHAB/SwiftUI/WidgetSelectionView.swift | 2 +- openHAB/SwiftUI/WidgetSetpointView.swift | 5 +- .../SwiftUI/WidgetSliderWithSwitchView.swift | 12 ++-- openHAB/SwiftUI/WidgetTextInputView.swift | 2 +- openHAB/SwiftUI/WidgetWebView.swift | 58 +++++++++---------- 6 files changed, 50 insertions(+), 48 deletions(-) diff --git a/openHAB/SwiftUI/WidgetRollershutterView.swift b/openHAB/SwiftUI/WidgetRollershutterView.swift index 1997d8e86..e6add90ea 100644 --- a/openHAB/SwiftUI/WidgetRollershutterView.swift +++ b/openHAB/SwiftUI/WidgetRollershutterView.swift @@ -11,6 +11,7 @@ import OpenHABCore import os.log +import SFSafeSymbols import SwiftUI struct WidgetRollershutterView: View { @@ -36,11 +37,11 @@ struct WidgetRollershutterView: View { } HStack(spacing: 12) { - Button(action: { + Button { logger.info("up button pressed") widget.sendCommand("UP") - }) { - Image(systemName: "chevron.up") + } label: { + Image(systemSymbol: .chevronUp) .font(.title2) .foregroundColor(.primary) .frame(width: 44, height: 44) @@ -49,11 +50,11 @@ struct WidgetRollershutterView: View { } .buttonStyle(.plain) - Button(action: { + Button { logger.info("stop button pressed") widget.sendCommand("STOP") - }) { - Image(systemName: "stop.fill") + } label: { + Image(systemSymbol: .stopFill) .font(.title2) .foregroundColor(.primary) .frame(width: 44, height: 44) @@ -62,11 +63,11 @@ struct WidgetRollershutterView: View { } .buttonStyle(.plain) - Button(action: { + Button { logger.info("down button pressed") widget.sendCommand("DOWN") - }) { - Image(systemName: "chevron.down") + } label: { + Image(systemSymbol: .chevronDown) .font(.title2) .foregroundColor(.primary) .frame(width: 44, height: 44) diff --git a/openHAB/SwiftUI/WidgetSelectionView.swift b/openHAB/SwiftUI/WidgetSelectionView.swift index de95b88ac..05c844a48 100644 --- a/openHAB/SwiftUI/WidgetSelectionView.swift +++ b/openHAB/SwiftUI/WidgetSelectionView.swift @@ -15,7 +15,7 @@ import SwiftUI struct WidgetSelectionView: View { @ObservedObject var widget: OpenHABWidget - @State private var selectedIndex: Int = 0 + @State private var selectedIndex = 0 private let logger = Logger(subsystem: "org.openhab", category: "WidgetSelectionView") diff --git a/openHAB/SwiftUI/WidgetSetpointView.swift b/openHAB/SwiftUI/WidgetSetpointView.swift index 7b3602561..79c5e5383 100644 --- a/openHAB/SwiftUI/WidgetSetpointView.swift +++ b/openHAB/SwiftUI/WidgetSetpointView.swift @@ -11,6 +11,7 @@ import OpenHABCore import os.log +import SFSafeSymbols import SwiftUI struct WidgetSetpointView: View { @@ -52,7 +53,7 @@ struct WidgetSetpointView: View { HStack(spacing: 12) { Button(action: decreaseValue) { - Image(systemName: "minus") + Image(systemSymbol: .minus) .font(.title2) .foregroundColor(.primary) .frame(width: 44, height: 44) @@ -63,7 +64,7 @@ struct WidgetSetpointView: View { .disabled(currentValue <= widget.minValue) Button(action: increaseValue) { - Image(systemName: "plus") + Image(systemSymbol: .plus) .font(.title2) .foregroundColor(.primary) .frame(width: 44, height: 44) diff --git a/openHAB/SwiftUI/WidgetSliderWithSwitchView.swift b/openHAB/SwiftUI/WidgetSliderWithSwitchView.swift index 6b3fd8101..14c957900 100644 --- a/openHAB/SwiftUI/WidgetSliderWithSwitchView.swift +++ b/openHAB/SwiftUI/WidgetSliderWithSwitchView.swift @@ -43,12 +43,6 @@ struct WidgetSliderWithSwitchView: View { } } - private func adj(_ raw: Double) -> Double { - var valueAdjustedToStep = Double(floor(Float((raw - widget.minValue) / step)) * Float(step)) - valueAdjustedToStep += widget.minValue - return valueAdjustedToStep.clamped(to: widget.minValue ... widget.maxValue) - } - var body: some View { VStack(alignment: .leading, spacing: 8) { HStack { @@ -98,6 +92,12 @@ struct WidgetSliderWithSwitchView: View { sliderValue = adjustedValue } } + + private func adj(_ raw: Double) -> Double { + var valueAdjustedToStep = Double(floor(Float((raw - widget.minValue) / step)) * Float(step)) + valueAdjustedToStep += widget.minValue + return valueAdjustedToStep.clamped(to: widget.minValue ... widget.maxValue) + } } extension Double { diff --git a/openHAB/SwiftUI/WidgetTextInputView.swift b/openHAB/SwiftUI/WidgetTextInputView.swift index 26c08e8ff..e7e940a9d 100644 --- a/openHAB/SwiftUI/WidgetTextInputView.swift +++ b/openHAB/SwiftUI/WidgetTextInputView.swift @@ -15,7 +15,7 @@ import SwiftUI struct WidgetTextInputView: View { @ObservedObject var widget: OpenHABWidget - @State private var inputText: String = "" + @State private var inputText = "" @FocusState private var isTextFieldFocused: Bool private let logger = Logger(subsystem: "org.openhab", category: "WidgetTextInputView") diff --git a/openHAB/SwiftUI/WidgetWebView.swift b/openHAB/SwiftUI/WidgetWebView.swift index 55bcb98b4..d72eb2ba3 100644 --- a/openHAB/SwiftUI/WidgetWebView.swift +++ b/openHAB/SwiftUI/WidgetWebView.swift @@ -13,7 +13,36 @@ import OpenHABCore import SwiftUI import WebKit +struct WidgetWebViewContainer: View { + @ObservedObject var widget: OpenHABWidget + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + if let labelText = widget.labelText, !labelText.isEmpty { + Text(labelText) + .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(UIColor(fromString: widget.labelcolor))) + } + + WidgetWebView(widget: widget) + .frame(height: 300) + .cornerRadius(8) + + if let labelValue = widget.labelValue, !labelValue.isEmpty { + Text(labelValue) + .font(.caption) + .foregroundColor(widget.valuecolor.isEmpty ? .secondary : Color(UIColor(fromString: widget.valuecolor))) + } + } + } +} + struct WidgetWebView: UIViewRepresentable { + class Coordinator: NSObject, WKNavigationDelegate { + func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: any Error) { + print("WebView failed to load: \(error.localizedDescription)") + } + } + @ObservedObject var widget: OpenHABWidget private var webURL: URL? { @@ -37,33 +66,4 @@ struct WidgetWebView: UIViewRepresentable { func makeCoordinator() -> Coordinator { Coordinator() } - - class Coordinator: NSObject, WKNavigationDelegate { - func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { - print("WebView failed to load: \(error.localizedDescription)") - } - } -} - -struct WidgetWebViewContainer: View { - @ObservedObject var widget: OpenHABWidget - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - if let labelText = widget.labelText, !labelText.isEmpty { - Text(labelText) - .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(UIColor(fromString: widget.labelcolor))) - } - - WidgetWebView(widget: widget) - .frame(height: 300) - .cornerRadius(8) - - if let labelValue = widget.labelValue, !labelValue.isEmpty { - Text(labelValue) - .font(.caption) - .foregroundColor(widget.valuecolor.isEmpty ? .secondary : Color(UIColor(fromString: widget.valuecolor))) - } - } - } } From 5eb0a0bb85c86a2d265a6bdf2e489d4039c767c1 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Wed, 9 Jul 2025 21:38:24 +0200 Subject: [PATCH 238/476] Make it testable Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- openHAB/SwiftUI/WidgetGenericView.swift | 3 +-- openHAB/SwiftUI/WidgetSliderView.swift | 3 +-- openHAB/SwiftUI/WidgetSliderWithSwitchView.swift | 14 -------------- openHAB/SwiftUI/WidgetTextView.swift | 2 +- openHABTestsSwift/OpenHABGeneralTests.swift | 5 +++-- 5 files changed, 6 insertions(+), 21 deletions(-) diff --git a/openHAB/SwiftUI/WidgetGenericView.swift b/openHAB/SwiftUI/WidgetGenericView.swift index 4a5e3e175..34fbbfab2 100644 --- a/openHAB/SwiftUI/WidgetGenericView.swift +++ b/openHAB/SwiftUI/WidgetGenericView.swift @@ -13,8 +13,7 @@ import OpenHABCore import SwiftUI struct WidgetGenericView: View { - let widget: OpenHABWidget - + @ObservedObject var widget: OpenHABWidget var body: some View { HStack { // if let iconUrl = widget.iconUrl { diff --git a/openHAB/SwiftUI/WidgetSliderView.swift b/openHAB/SwiftUI/WidgetSliderView.swift index c5fe8e548..66083844a 100644 --- a/openHAB/SwiftUI/WidgetSliderView.swift +++ b/openHAB/SwiftUI/WidgetSliderView.swift @@ -13,8 +13,7 @@ import OpenHABCore import SwiftUI struct WidgetSliderView: View { - let widget: OpenHABWidget - + @ObservedObject var widget: OpenHABWidget // Example: assuming widget has a numeric value as text var currentValue: Double { Double(widget.labelValue ?? "") ?? 0.0 diff --git a/openHAB/SwiftUI/WidgetSliderWithSwitchView.swift b/openHAB/SwiftUI/WidgetSliderWithSwitchView.swift index 14c957900..097ab11ac 100644 --- a/openHAB/SwiftUI/WidgetSliderWithSwitchView.swift +++ b/openHAB/SwiftUI/WidgetSliderWithSwitchView.swift @@ -99,17 +99,3 @@ struct WidgetSliderWithSwitchView: View { return valueAdjustedToStep.clamped(to: widget.minValue ... widget.maxValue) } } - -extension Double { - func clamped(to range: ClosedRange) -> Double { - Swift.min(Swift.max(self, range.lowerBound), range.upperBound) - } - - func valueText(step: Double) -> String { - if step.truncatingRemainder(dividingBy: 1) == 0 { - String(format: "%.0f", self) - } else { - String(format: "%.1f", self) - } - } -} diff --git a/openHAB/SwiftUI/WidgetTextView.swift b/openHAB/SwiftUI/WidgetTextView.swift index bd2bf93a4..fd6840647 100644 --- a/openHAB/SwiftUI/WidgetTextView.swift +++ b/openHAB/SwiftUI/WidgetTextView.swift @@ -13,7 +13,7 @@ import OpenHABCore import SwiftUI struct WidgetTextView: View { - let widget: OpenHABWidget + @ObservedObject var widget: OpenHABWidget var body: some View { VStack(alignment: .leading) { diff --git a/openHABTestsSwift/OpenHABGeneralTests.swift b/openHABTestsSwift/OpenHABGeneralTests.swift index 17f0de2ac..2d052a82c 100644 --- a/openHABTestsSwift/OpenHABGeneralTests.swift +++ b/openHABTestsSwift/OpenHABGeneralTests.swift @@ -25,8 +25,9 @@ class OpenHABGeneralTests: XCTestCase { return String(format: "%.\(digits)f", widgetValue) } - XCTAssertEqual(1000.0.valueText(step: 0.01), "1000.00") - XCTAssertEqual(1000.0.valueText(step: 1), "1000") + let value = 1000.0 + XCTAssertEqual(value.valueText(step: 0.01), "1000.00") + XCTAssertEqual(value.valueText(step: 1), "1000") XCTAssertEqual(valueTextWithoutFormatter(1000.0, step: 5.23), "1000.00") } From 1f6bfb9f399d6873caa30172f56df5ab0c448174 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Thu, 10 Jul 2025 18:20:27 +0200 Subject: [PATCH 239/476] Add ImageView as quick fix Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- openHAB.xcodeproj/project.pbxproj | 8 ++ openHAB/DrawerView.swift | 27 ----- openHAB/SitemapPageViewModel.swift | 27 ++--- openHAB/SwiftUI/ImageView.swift | 45 +++++++++ openHAB/SwiftUI/SitemapPageView.swift | 4 + openHAB/SwiftUI/WidgetIconView.swift | 117 ++++++++++++++++++++++ openHAB/SwiftUI/WidgetSetpointView.swift | 14 ++- openHAB/SwiftUI/WidgetSwitchView.swift | 6 ++ openHAB/SwiftUI/WidgetTextInputView.swift | 35 ++++--- openHAB/SwiftUI/WidgetTextView.swift | 24 +++-- openHAB/SwiftUI/WidgetViewFactory.swift | 2 +- 11 files changed, 245 insertions(+), 64 deletions(-) create mode 100644 openHAB/SwiftUI/ImageView.swift create mode 100644 openHAB/SwiftUI/WidgetIconView.swift diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index dae5a534f..4b2a3c16e 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -101,6 +101,8 @@ DA35E2C62E1EEA9D003987BB /* WidgetSegmentedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA35E2B72E1EEA9D003987BB /* WidgetSegmentedView.swift */; }; DA35E2C72E1EEA9D003987BB /* WidgetVideoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA35E2BB2E1EEA9D003987BB /* WidgetVideoView.swift */; }; DA35E2C92E1EF15B003987BB /* ColorExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA35E2C82E1EF15B003987BB /* ColorExtension.swift */; }; + DA35E2CB2E1F93AD003987BB /* ImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA35E2CA2E1F93AD003987BB /* ImageView.swift */; }; + DA35E2CD2E1F96CA003987BB /* WidgetIconView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA35E2CC2E1F96CA003987BB /* WidgetIconView.swift */; }; DA4642322D7EE6CA006C3908 /* LoggerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4642312D7EE6CA006C3908 /* LoggerView.swift */; }; DA4800142D836892009CF127 /* ConnectionSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4800132D836892009CF127 /* ConnectionSettingsView.swift */; }; DA4800162D836EF0009CF127 /* MainUISettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4800152D836EF0009CF127 /* MainUISettingsView.swift */; }; @@ -437,6 +439,8 @@ DA35E2BB2E1EEA9D003987BB /* WidgetVideoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetVideoView.swift; sourceTree = ""; }; DA35E2BC2E1EEA9D003987BB /* WidgetWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetWebView.swift; sourceTree = ""; }; DA35E2C82E1EF15B003987BB /* ColorExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorExtension.swift; sourceTree = ""; }; + DA35E2CA2E1F93AD003987BB /* ImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageView.swift; sourceTree = ""; }; + DA35E2CC2E1F96CA003987BB /* WidgetIconView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetIconView.swift; sourceTree = ""; }; DA4642312D7EE6CA006C3908 /* LoggerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoggerView.swift; sourceTree = ""; }; DA4800132D836892009CF127 /* ConnectionSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionSettingsView.swift; sourceTree = ""; }; DA4800152D836EF0009CF127 /* MainUISettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainUISettingsView.swift; sourceTree = ""; }; @@ -843,6 +847,7 @@ isa = PBXGroup; children = ( DAEA21DF2DBF483E00D54342 /* WidgetGenericView.swift */, + DA35E2CA2E1F93AD003987BB /* ImageView.swift */, DA64ACA72DBEAD8300294F60 /* SitemapPageView.swift */, DAEA21D72DBF472D00D54342 /* WidgetViewFactory.swift */, DA64ACA92DBEADB000294F60 /* WidgetRow.swift */, @@ -861,6 +866,7 @@ DA35E2AF2E1EDB86003987BB /* WidgetSetpointView.swift */, DAEA21DB2DBF47DA00D54342 /* WidgetSliderView.swift */, DAEA21DD2DBF481300D54342 /* WidgetTextView.swift */, + DA35E2CC2E1F96CA003987BB /* WidgetIconView.swift */, ); path = SwiftUI; sourceTree = ""; @@ -1684,6 +1690,7 @@ DA35E2C92E1EF15B003987BB /* ColorExtension.swift in Sources */, DA77E19B2D886D9B007CFF0F /* SingleConnectionSettingsView.swift in Sources */, DA35E2B02E1EDB86003987BB /* WidgetSetpointView.swift in Sources */, + DA35E2CB2E1F93AD003987BB /* ImageView.swift in Sources */, DA6B2EF52C89F8F200DF77CF /* ColorPickerView.swift in Sources */, DA4800142D836892009CF127 /* ConnectionSettingsView.swift in Sources */, 2F6412EE2CE494A80039FB28 /* DatePickerUITableViewCell.swift in Sources */, @@ -1706,6 +1713,7 @@ DA35E2C52E1EEA9D003987BB /* WidgetRollershutterView.swift in Sources */, DA35E2C62E1EEA9D003987BB /* WidgetSegmentedView.swift in Sources */, DA35E2C72E1EEA9D003987BB /* WidgetVideoView.swift in Sources */, + DA35E2CD2E1F96CA003987BB /* WidgetIconView.swift in Sources */, DAC65FC7236EDF3900F4501E /* SpinnerViewController.swift in Sources */, DA9F81872C85020F00B47B72 /* RTFTextView.swift in Sources */, DA6B2EF12C87B59000DF77CF /* NotificationsView.swift in Sources */, diff --git a/openHAB/DrawerView.swift b/openHAB/DrawerView.swift index efd80500b..90ff996de 100644 --- a/openHAB/DrawerView.swift +++ b/openHAB/DrawerView.swift @@ -30,33 +30,6 @@ enum DrawerViewError: Error, CustomDebugStringConvertible { } } -struct ImageView: View { - let url: String - - @EnvironmentObject var networkTracker: NetworkTracker - - var body: some View { - if !url.isEmpty { - switch url { - case _ where url.hasPrefix("data:image"): - let provider = Base64ImageDataProvider(base64String: url.deletingPrefix("data:image/png;base64,"), cacheKey: UUID().uuidString) - return KFImage(source: .provider(provider)).resizable() - case _ where url.hasPrefix("http"): - return KFImage(URL(string: url)).resizable() - default: - let builtURL = Endpoint.resource( - openHABRootUrl: networkTracker.activeConnection?.configuration.url ?? "", - path: url.prepare() - ).url - return KFImage(builtURL).resizable() - } - } else { - // This will always fallback to placeholder - return KFImage(URL(string: "bundle://openHABIcon")).placeholder { Image("openHABIcon").resizable() } - } - } -} - // Display the connected URL struct ConnectionView: View { @StateObject private var networkTracker = NetworkTracker.shared diff --git a/openHAB/SitemapPageViewModel.swift b/openHAB/SitemapPageViewModel.swift index 18e981a3f..58df5126b 100644 --- a/openHAB/SitemapPageViewModel.swift +++ b/openHAB/SitemapPageViewModel.swift @@ -40,12 +40,12 @@ class SitemapPageViewModel: ObservableObject { @Published var isLoading = false @Published var openHABRootUrl: String? + @ObservedObject var networkTracker = NetworkTracker.shared private var openAPIService: OpenAPIService? private var activeConnectionInfo: ConnectionInfo? private var pageHandlingTask: Task? private var defaultSitemap = "" private var pageId = "" - private var trackerTask: Task? var relevantWidgets: [OpenHABWidget] { if searchText.isEmpty { @@ -61,7 +61,7 @@ class SitemapPageViewModel: ObservableObject { init() { loadSettings() - startWatchingActiveServer() + setupActiveConnectionObserver() } func loadSettings() { @@ -231,18 +231,21 @@ class SitemapPageViewModel: ObservableObject { } deinit { - trackerTask?.cancel() + pageHandlingTask?.cancel() } - func startWatchingActiveServer() { - trackerTask = Task { - for await activeConnection in NetworkTracker.shared.$activeConnection.stream() { - if let activeConnection { - logger.info("Tracker URL \(activeConnection.configuration.url)") - await handleActiveConnection(activeConnection) - break - } - } + private func setupActiveConnectionObserver() { + // The @ObservedObject will automatically trigger view updates + // We'll handle the connection changes in the view's onChange modifier + } + + func handleActiveConnectionChange(_ activeConnection: ConnectionInfo?) { + guard let activeConnection else { return } + + logger.info("SitemapPageViewModel tracker URL \(activeConnection.configuration.url)") + + Task { + await handleActiveConnection(activeConnection) } } diff --git a/openHAB/SwiftUI/ImageView.swift b/openHAB/SwiftUI/ImageView.swift new file mode 100644 index 000000000..9898d20e9 --- /dev/null +++ b/openHAB/SwiftUI/ImageView.swift @@ -0,0 +1,45 @@ +// Copyright (c) 2010-2025 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 Combine +import Kingfisher +import OpenHABCore +import os.log +import SafariServices +import SFSafeSymbols +import SwiftUI + +struct ImageView: View { + let url: String + + @EnvironmentObject var networkTracker: NetworkTracker + + var body: some View { + if !url.isEmpty { + switch url { + case _ where url.hasPrefix("data:image"): + let provider = Base64ImageDataProvider(base64String: url.deletingPrefix("data:image/png;base64,"), cacheKey: UUID().uuidString) + return KFImage(source: .provider(provider)).resizable() + case _ where url.hasPrefix("http"): + return KFImage(URL(string: url)).resizable() + default: + let builtURL = Endpoint.resource( + openHABRootUrl: networkTracker.activeConnection?.configuration.url ?? "", + path: url.prepare() + ).url + return KFImage(builtURL).resizable() + } + } else { + // This will always fallback to placeholder + return KFImage(URL(string: "bundle://openHABIcon")).placeholder { Image("openHABIcon").resizable() } + } + } +} diff --git a/openHAB/SwiftUI/SitemapPageView.swift b/openHAB/SwiftUI/SitemapPageView.swift index 9b8570f47..33f66a4cf 100644 --- a/openHAB/SwiftUI/SitemapPageView.swift +++ b/openHAB/SwiftUI/SitemapPageView.swift @@ -9,6 +9,7 @@ // // SPDX-License-Identifier: EPL-2.0 +import OpenHABCore import SwiftUI struct SitemapPageView: View { @@ -29,6 +30,9 @@ struct SitemapPageView: View { .task { viewModel.startPageHandling() } + .onChange(of: viewModel.networkTracker.activeConnection) { activeConnection in + viewModel.handleActiveConnectionChange(activeConnection) + } .alert("Error", isPresented: .constant(viewModel.error != nil), actions: { Button("OK", role: .cancel) {} }, message: { diff --git a/openHAB/SwiftUI/WidgetIconView.swift b/openHAB/SwiftUI/WidgetIconView.swift new file mode 100644 index 000000000..f5d8920be --- /dev/null +++ b/openHAB/SwiftUI/WidgetIconView.swift @@ -0,0 +1,117 @@ +// Copyright (c) 2010-2025 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 Kingfisher +import OpenHABCore +import os.log +import SwiftUI + +/// A SwiftUI view that displays widget icons with openHAB-specific styling and caching +struct WidgetIconView: View { + @ObservedObject var widget: OpenHABWidget + let size: CGSize + let iconType: IconType + let iconColor: String + + @State private var imageLoadingFailed = false + + private let logger = Logger(subsystem: "org.openhab", category: "WidgetIconView") + + private var iconURL: URL? { + guard !widget.icon.isEmpty else { return nil } + + return Endpoint.icon( + rootUrl: NetworkTracker.shared.activeConnection?.configuration.url ?? "", + version: NetworkTracker.shared.activeConnection?.version ?? 2, + icon: widget.icon, + state: widget.iconState(), + iconType: iconType, + iconColor: iconColor.isEmpty ? "black" : iconColor + ).url + } + + var body: some View { + Group { + if let iconURL, !imageLoadingFailed { + KFImage(iconURL) + .placeholder { + // Show empty space while loading + Rectangle() + .fill(Color.clear) + .frame(width: size.width, height: size.height) + } + .onFailure { error in + logger.error("Icon loading failed for widget \(widget.label): \(error.localizedDescription)") + imageLoadingFailed = true + } + .onSuccess { _ in + imageLoadingFailed = false + } + .setProcessor(OpenHABImageProcessor()) + .fade(duration: 0.25) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: size.width, height: size.height) + .id(iconURL.absoluteString) + } else { + // No icon or failed to load - show empty space + Rectangle() + .fill(Color.clear) + .frame(width: size.width, height: size.height) + } + } + .onChange(of: widget.icon) { _ in + // Reset loading state when icon changes + imageLoadingFailed = false + } + .onChange(of: widget.iconState()) { _ in + // Reset loading state when icon state changes + imageLoadingFailed = false + } + } +} + +// MARK: - Convenience Extensions + +extension WidgetIconView { + /// Creates a widget icon view with standard size and default styling or custom icon color + init(widget: OpenHABWidget, size: CGSize = CGSize(width: 24, height: 24), iconColor: String = "") { + self.init( + widget: widget, + size: size, + iconType: .svg, + iconColor: iconColor + ) + } +} + +// MARK: - Widget Type Extensions + +extension WidgetIconView { + /// Determines if a widget type should show an icon (equivalent to NoIconDisplayableCell protocol) + static func shouldShowIcon(for widget: OpenHABWidget) -> Bool { + // These widget types should not show icons (equivalent to NoIconDisplayableCell) + switch widget.type { + case .frame, .image, .chart, .video, .webview: + false + default: + !widget.icon.isEmpty + } + } +} + +#Preview { + let widget = OpenHABWidget() + widget.icon = "switch" + widget.label = "Test Switch" + + return WidgetIconView(widget: widget) +} diff --git a/openHAB/SwiftUI/WidgetSetpointView.swift b/openHAB/SwiftUI/WidgetSetpointView.swift index 79c5e5383..3b247ac7e 100644 --- a/openHAB/SwiftUI/WidgetSetpointView.swift +++ b/openHAB/SwiftUI/WidgetSetpointView.swift @@ -40,13 +40,15 @@ struct WidgetSetpointView: View { var body: some View { VStack(alignment: .leading, spacing: 8) { HStack { + // Icon + if WidgetIconView.shouldShowIcon(for: widget) { + WidgetIconView(widget: widget) + .frame(width: 24, height: 24) + } + VStack(alignment: .leading, spacing: 2) { Text(widget.labelText ?? widget.label) .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(UIColor(fromString: widget.labelcolor))) - - Text(formattedValue) - .font(.caption) - .foregroundColor(widget.valuecolor.isEmpty ? .secondary : Color(UIColor(fromString: widget.valuecolor))) } Spacer() @@ -63,6 +65,10 @@ struct WidgetSetpointView: View { .buttonStyle(.plain) .disabled(currentValue <= widget.minValue) + Text(formattedValue) + .font(.caption) + .foregroundColor(widget.valuecolor.isEmpty ? .secondary : Color(UIColor(fromString: widget.valuecolor))) + Button(action: increaseValue) { Image(systemSymbol: .plus) .font(.title2) diff --git a/openHAB/SwiftUI/WidgetSwitchView.swift b/openHAB/SwiftUI/WidgetSwitchView.swift index 2f3fbb144..af350f6fd 100644 --- a/openHAB/SwiftUI/WidgetSwitchView.swift +++ b/openHAB/SwiftUI/WidgetSwitchView.swift @@ -33,6 +33,12 @@ struct WidgetSwitchView: View { var body: some View { HStack { + // Icon + if WidgetIconView.shouldShowIcon(for: widget) { + WidgetIconView(widget: widget) + .frame(width: 24, height: 24) + } + VStack(alignment: .leading, spacing: 2) { Text(widget.labelText ?? widget.label) .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(UIColor(fromString: widget.labelcolor))) diff --git a/openHAB/SwiftUI/WidgetTextInputView.swift b/openHAB/SwiftUI/WidgetTextInputView.swift index e7e940a9d..8e566f10a 100644 --- a/openHAB/SwiftUI/WidgetTextInputView.swift +++ b/openHAB/SwiftUI/WidgetTextInputView.swift @@ -21,23 +21,32 @@ struct WidgetTextInputView: View { private let logger = Logger(subsystem: "org.openhab", category: "WidgetTextInputView") var body: some View { - VStack(alignment: .leading, spacing: 8) { - if let labelText = widget.labelText, !labelText.isEmpty { - Text(labelText) - .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(UIColor(fromString: widget.labelcolor))) + HStack(alignment: .top, spacing: 8) { + // Icon + if WidgetIconView.shouldShowIcon(for: widget) { + WidgetIconView(widget: widget) + .frame(width: 24, height: 24) + .padding(.top, 4) // Align with text } - TextField("Enter text", text: $inputText) - .textFieldStyle(.roundedBorder) - .focused($isTextFieldFocused) - .onSubmit { - sendTextCommand() + VStack(alignment: .leading, spacing: 8) { + if let labelText = widget.labelText, !labelText.isEmpty { + Text(labelText) + .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(UIColor(fromString: widget.labelcolor))) } - if let labelValue = widget.labelValue, !labelValue.isEmpty { - Text(labelValue) - .font(.caption) - .foregroundColor(widget.valuecolor.isEmpty ? .secondary : Color(UIColor(fromString: widget.valuecolor))) + TextField("Enter text", text: $inputText) + .textFieldStyle(.roundedBorder) + .focused($isTextFieldFocused) + .onSubmit { + sendTextCommand() + } + + if let labelValue = widget.labelValue, !labelValue.isEmpty { + Text(labelValue) + .font(.caption) + .foregroundColor(widget.valuecolor.isEmpty ? .secondary : Color(UIColor(fromString: widget.valuecolor))) + } } } .onAppear { diff --git a/openHAB/SwiftUI/WidgetTextView.swift b/openHAB/SwiftUI/WidgetTextView.swift index fd6840647..64ec5630d 100644 --- a/openHAB/SwiftUI/WidgetTextView.swift +++ b/openHAB/SwiftUI/WidgetTextView.swift @@ -16,14 +16,24 @@ struct WidgetTextView: View { @ObservedObject var widget: OpenHABWidget var body: some View { - VStack(alignment: .leading) { - Text(widget.labelText ?? "") - .font(.headline) - if let value = widget.labelValue { - Text(value) - .font(.subheadline) - .foregroundColor(.secondary) + HStack { + // Icon + if WidgetIconView.shouldShowIcon(for: widget) { + WidgetIconView(widget: widget) + .frame(width: 24, height: 24) } + + VStack(alignment: .leading) { + Text(widget.labelText ?? "") + .font(.headline) + if let value = widget.labelValue { + Text(value) + .font(.subheadline) + .foregroundColor(.secondary) + } + } + + Spacer() } .padding() } diff --git a/openHAB/SwiftUI/WidgetViewFactory.swift b/openHAB/SwiftUI/WidgetViewFactory.swift index 6b157bea1..e726a0aaf 100644 --- a/openHAB/SwiftUI/WidgetViewFactory.swift +++ b/openHAB/SwiftUI/WidgetViewFactory.swift @@ -13,7 +13,7 @@ import OpenHABCore import SwiftUI enum WidgetViewFactory { - @ViewBuilder + @MainActor @ViewBuilder static func view(for widget: OpenHABWidget) -> some View { switch widget.type { case .switchWidget: From 8d15193a640050c58b017f84de7e14bb49dbdadc Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Thu, 10 Jul 2025 23:18:59 +0200 Subject: [PATCH 240/476] Introduced Previews for faster dev IconViews added Backport of setpoint functionality to watchOS app Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- openHAB.xcodeproj/project.pbxproj | 6 ++-- .../Utils => openHAB}/PreviewConstants.swift | 18 +++++++++- openHAB/SwiftUI/WidgetRollershutterView.swift | 29 ++++++---------- openHAB/SwiftUI/WidgetSegmentedView.swift | 23 ++++--------- openHAB/SwiftUI/WidgetSetpointView.swift | 16 ++++----- openHAB/SwiftUI/WidgetSliderView.swift | 9 ++++- openHAB/SwiftUI/WidgetSwitchView.swift | 6 +++- openHAB/SwiftUI/WidgetTextInputView.swift | 1 - openHAB/SwiftUI/WidgetTextView.swift | 26 +++++++------- openHABWatch/Views/Rows/SetpointRow.swift | 34 ++++++++++++------- 10 files changed, 91 insertions(+), 77 deletions(-) rename {openHABWatch/Views/Utils => openHAB}/PreviewConstants.swift (94%) diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index 4b2a3c16e..1991b7bad 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -115,6 +115,7 @@ DA50C7BD2B0A51BD0009F716 /* SliderWithSwitchSupportRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA50C7BC2B0A51BD0009F716 /* SliderWithSwitchSupportRow.swift */; }; DA50C7BF2B0A65300009F716 /* SliderWithSwitchSupportUITableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA50C7BE2B0A652F0009F716 /* SliderWithSwitchSupportUITableViewCell.swift */; }; DA5ED9BE2C850955004875E0 /* ClientCertificatesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5ED9BD2C850955004875E0 /* ClientCertificatesViewModel.swift */; }; + DA6454DE2E204B95006005E8 /* PreviewConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA7224D123828D3300712D20 /* PreviewConstants.swift */; }; DA64ACA62DBEAD5600294F60 /* SitemapPageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA64ACA52DBEAD5600294F60 /* SitemapPageViewModel.swift */; }; DA64ACA82DBEAD8300294F60 /* SitemapPageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA64ACA72DBEAD8300294F60 /* SitemapPageView.swift */; }; DA64ACAA2DBEADB000294F60 /* WidgetRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA64ACA92DBEADB000294F60 /* WidgetRow.swift */; }; @@ -858,12 +859,12 @@ DA35E2B62E1EEA9D003987BB /* WidgetRollershutterView.swift */, DA35E2B72E1EEA9D003987BB /* WidgetSegmentedView.swift */, DA35E2B82E1EEA9D003987BB /* WidgetSelectionView.swift */, + DA35E2AF2E1EDB86003987BB /* WidgetSetpointView.swift */, DA35E2B92E1EEA9D003987BB /* WidgetSliderWithSwitchView.swift */, DA35E2BA2E1EEA9D003987BB /* WidgetTextInputView.swift */, DA35E2BB2E1EEA9D003987BB /* WidgetVideoView.swift */, DA35E2BC2E1EEA9D003987BB /* WidgetWebView.swift */, DAEA21D92DBF477E00D54342 /* WidgetSwitchView.swift */, - DA35E2AF2E1EDB86003987BB /* WidgetSetpointView.swift */, DAEA21DB2DBF47DA00D54342 /* WidgetSliderView.swift */, DAEA21DD2DBF481300D54342 /* WidgetTextView.swift */, DA35E2CC2E1F96CA003987BB /* WidgetIconView.swift */, @@ -983,7 +984,6 @@ DAF457A723DBA2C40018B495 /* Utils */ = { isa = PBXGroup; children = ( - DA7224D123828D3300712D20 /* PreviewConstants.swift */, 934B610B2348D2F9009112D5 /* Color+Extension.swift */, DAF4578123D630C70018B495 /* IconView.swift */, DAF4578623D798A50018B495 /* TextLabelView.swift */, @@ -1159,6 +1159,7 @@ DFFD8FCE18EDBD30003B502A /* Util */ = { isa = PBXGroup; children = ( + DA7224D123828D3300712D20 /* PreviewConstants.swift */, 938EDCE022C4FEB800661CA1 /* ScaleAspectFitImageView.swift */, DFFD8FD018EDBD4F003B502A /* UICircleButton.swift */, 938BF9C524EFCC0700E6B52F /* UILabel+Localization.swift */, @@ -1747,6 +1748,7 @@ DFA16EBB18883DE500EDB0BB /* SliderUITableViewCell.swift in Sources */, DFA13CB418872EBD006355C3 /* SwitchUITableViewCell.swift in Sources */, DFFD8FD118EDBD4F003B502A /* UICircleButton.swift in Sources */, + DA6454DE2E204B95006005E8 /* PreviewConstants.swift in Sources */, DA48001C2D837556009CF127 /* SitemapSettingsView.swift in Sources */, 938BF9C624EFCC0700E6B52F /* UILabel+Localization.swift in Sources */, ); diff --git a/openHABWatch/Views/Utils/PreviewConstants.swift b/openHAB/PreviewConstants.swift similarity index 94% rename from openHABWatch/Views/Utils/PreviewConstants.swift rename to openHAB/PreviewConstants.swift index 114d99e61..44d8ed9a3 100644 --- a/openHABWatch/Views/Utils/PreviewConstants.swift +++ b/openHAB/PreviewConstants.swift @@ -10,10 +10,26 @@ // SPDX-License-Identifier: EPL-2.0 import Foundation +import OpenHABCore +import os.log enum PreviewConstants { + static let logger = Logger(subsystem: "org.openhab", category: "PreviewConstants") + static let remoteURLString = "http://192.168.2.10:8080" + static var openHABSitemapPage: OpenHABPage? { + let data = sitemapJson + do { + let sitemapPage = try data.decoded(as: Components.Schemas.PageDTO.self) + let openHABSitemapPage = OpenHABPage(sitemapPage) + return openHABSitemapPage + } catch { + logger.error("Should not throw \(error.localizedDescription)") + return nil + } + } + static let sitemapJson = Data(""" { "id": "watch", @@ -103,7 +119,7 @@ enum PreviewConstants { "sendFrequency": 0, "item": { "link": "http://192.168.2.10:8080/rest/items/lcnLightDimmer", - "state": "100", + "state": "95", "stateDescription": { "pattern": "%s", "readOnly": false, diff --git a/openHAB/SwiftUI/WidgetRollershutterView.swift b/openHAB/SwiftUI/WidgetRollershutterView.swift index e6add90ea..9243c77c1 100644 --- a/openHAB/SwiftUI/WidgetRollershutterView.swift +++ b/openHAB/SwiftUI/WidgetRollershutterView.swift @@ -22,21 +22,18 @@ struct WidgetRollershutterView: View { var body: some View { VStack(alignment: .leading, spacing: 8) { HStack { + if WidgetIconView.shouldShowIcon(for: widget) { + WidgetIconView(widget: widget) + .frame(width: 24, height: 24) + } + VStack(alignment: .leading, spacing: 2) { Text(widget.labelText ?? widget.label) .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(UIColor(fromString: widget.labelcolor))) - - if let labelValue = widget.labelValue, !labelValue.isEmpty { - Text(labelValue) - .font(.caption) - .foregroundColor(widget.valuecolor.isEmpty ? .secondary : Color(UIColor(fromString: widget.valuecolor))) - } } Spacer() - } - HStack(spacing: 12) { Button { logger.info("up button pressed") widget.sendCommand("UP") @@ -44,9 +41,6 @@ struct WidgetRollershutterView: View { Image(systemSymbol: .chevronUp) .font(.title2) .foregroundColor(.primary) - .frame(width: 44, height: 44) - .background(Color.secondary.opacity(0.2)) - .clipShape(RoundedRectangle(cornerRadius: 8)) } .buttonStyle(.plain) @@ -57,9 +51,6 @@ struct WidgetRollershutterView: View { Image(systemSymbol: .stopFill) .font(.title2) .foregroundColor(.primary) - .frame(width: 44, height: 44) - .background(Color.secondary.opacity(0.2)) - .clipShape(RoundedRectangle(cornerRadius: 8)) } .buttonStyle(.plain) @@ -70,14 +61,14 @@ struct WidgetRollershutterView: View { Image(systemSymbol: .chevronDown) .font(.title2) .foregroundColor(.primary) - .frame(width: 44, height: 44) - .background(Color.secondary.opacity(0.2)) - .clipShape(RoundedRectangle(cornerRadius: 8)) } .buttonStyle(.plain) - - Spacer() } } } } + +#Preview { + let widget = PreviewConstants.openHABSitemapPage!.widgets[5] + WidgetRollershutterView(widget: widget) +} diff --git a/openHAB/SwiftUI/WidgetSegmentedView.swift b/openHAB/SwiftUI/WidgetSegmentedView.swift index 89c1ea0f9..2fc2c31b7 100644 --- a/openHAB/SwiftUI/WidgetSegmentedView.swift +++ b/openHAB/SwiftUI/WidgetSegmentedView.swift @@ -32,20 +32,10 @@ struct WidgetSegmentedView: View { } var body: some View { - VStack(alignment: .leading, spacing: 8) { + HStack { if let labelText = widget.labelText, !labelText.isEmpty { - HStack { - Text(labelText) - .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(UIColor(fromString: widget.labelcolor))) - - Spacer() - - if let labelValue = widget.labelValue, !labelValue.isEmpty { - Text(labelValue) - .font(.caption) - .foregroundColor(widget.valuecolor.isEmpty ? .secondary : Color(UIColor(fromString: widget.valuecolor))) - } - } + Text(labelText) + .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(UIColor(fromString: widget.labelcolor))) } if !mappings.isEmpty { @@ -68,8 +58,7 @@ struct WidgetSegmentedView: View { } } -extension Int { - func clamped(to range: ClosedRange) -> Int { - Swift.min(Swift.max(self, range.lowerBound), range.upperBound) - } +#Preview { + let widget = PreviewConstants.openHABSitemapPage!.widgets[4] + WidgetSegmentedView(widget: widget) } diff --git a/openHAB/SwiftUI/WidgetSetpointView.swift b/openHAB/SwiftUI/WidgetSetpointView.swift index 3b247ac7e..022940990 100644 --- a/openHAB/SwiftUI/WidgetSetpointView.swift +++ b/openHAB/SwiftUI/WidgetSetpointView.swift @@ -40,7 +40,6 @@ struct WidgetSetpointView: View { var body: some View { VStack(alignment: .leading, spacing: 8) { HStack { - // Icon if WidgetIconView.shouldShowIcon(for: widget) { WidgetIconView(widget: widget) .frame(width: 24, height: 24) @@ -55,12 +54,9 @@ struct WidgetSetpointView: View { HStack(spacing: 12) { Button(action: decreaseValue) { - Image(systemSymbol: .minus) + Image(systemSymbol: .chevronDownCircleFill) .font(.title2) .foregroundColor(.primary) - .frame(width: 44, height: 44) - .background(Color.secondary.opacity(0.2)) - .clipShape(RoundedRectangle(cornerRadius: 8)) } .buttonStyle(.plain) .disabled(currentValue <= widget.minValue) @@ -70,12 +66,9 @@ struct WidgetSetpointView: View { .foregroundColor(widget.valuecolor.isEmpty ? .secondary : Color(UIColor(fromString: widget.valuecolor))) Button(action: increaseValue) { - Image(systemSymbol: .plus) + Image(systemSymbol: .chevronUpCircleFill) .font(.title2) .foregroundColor(.primary) - .frame(width: 44, height: 44) - .background(Color.secondary.opacity(0.2)) - .clipShape(RoundedRectangle(cornerRadius: 8)) } .buttonStyle(.plain) .disabled(currentValue >= widget.maxValue) @@ -119,3 +112,8 @@ struct WidgetSetpointView: View { widget.sendItemUpdate(state: numberState) } } + +#Preview { + let widget = PreviewConstants.openHABSitemapPage!.widgets[3] + WidgetSetpointView(widget: widget) +} diff --git a/openHAB/SwiftUI/WidgetSliderView.swift b/openHAB/SwiftUI/WidgetSliderView.swift index 66083844a..4a5d75a08 100644 --- a/openHAB/SwiftUI/WidgetSliderView.swift +++ b/openHAB/SwiftUI/WidgetSliderView.swift @@ -20,9 +20,16 @@ struct WidgetSliderView: View { } var body: some View { - VStack(alignment: .leading) { + HStack { + if WidgetIconView.shouldShowIcon(for: widget) { + WidgetIconView(widget: widget) + .frame(width: 24, height: 24) + } + Text(widget.labelText ?? "") .font(.headline) + Spacer() + Slider(value: .constant(currentValue), in: 0 ... 100) .disabled(true) // unless you want editable if let value = widget.labelValue { diff --git a/openHAB/SwiftUI/WidgetSwitchView.swift b/openHAB/SwiftUI/WidgetSwitchView.swift index af350f6fd..3f7e1642d 100644 --- a/openHAB/SwiftUI/WidgetSwitchView.swift +++ b/openHAB/SwiftUI/WidgetSwitchView.swift @@ -33,7 +33,6 @@ struct WidgetSwitchView: View { var body: some View { HStack { - // Icon if WidgetIconView.shouldShowIcon(for: widget) { WidgetIconView(widget: widget) .frame(width: 24, height: 24) @@ -69,3 +68,8 @@ struct WidgetSwitchView: View { .contentShape(Rectangle()) } } + +#Preview { + let widget = PreviewConstants.openHABSitemapPage!.widgets[2] + WidgetSwitchView(widget: widget) +} diff --git a/openHAB/SwiftUI/WidgetTextInputView.swift b/openHAB/SwiftUI/WidgetTextInputView.swift index 8e566f10a..13e6cc9d2 100644 --- a/openHAB/SwiftUI/WidgetTextInputView.swift +++ b/openHAB/SwiftUI/WidgetTextInputView.swift @@ -22,7 +22,6 @@ struct WidgetTextInputView: View { var body: some View { HStack(alignment: .top, spacing: 8) { - // Icon if WidgetIconView.shouldShowIcon(for: widget) { WidgetIconView(widget: widget) .frame(width: 24, height: 24) diff --git a/openHAB/SwiftUI/WidgetTextView.swift b/openHAB/SwiftUI/WidgetTextView.swift index 64ec5630d..b34e7169f 100644 --- a/openHAB/SwiftUI/WidgetTextView.swift +++ b/openHAB/SwiftUI/WidgetTextView.swift @@ -17,28 +17,26 @@ struct WidgetTextView: View { var body: some View { HStack { - // Icon if WidgetIconView.shouldShowIcon(for: widget) { WidgetIconView(widget: widget) .frame(width: 24, height: 24) } - VStack(alignment: .leading) { - Text(widget.labelText ?? "") - .font(.headline) - if let value = widget.labelValue { - Text(value) - .font(.subheadline) - .foregroundColor(.secondary) - } - } + Text(widget.labelText ?? "") + .font(.headline) Spacer() + + if let value = widget.labelValue { + Text(value) + .font(.subheadline) + .foregroundColor(.secondary) + } } - .padding() } } -// #Preview { -// WidgetTextView() -// } +#Preview { + let widget = PreviewConstants.openHABSitemapPage!.widgets[3] + WidgetTextView(widget: widget) +} diff --git a/openHABWatch/Views/Rows/SetpointRow.swift b/openHABWatch/Views/Rows/SetpointRow.swift index fde09ba0e..27fe49f23 100644 --- a/openHABWatch/Views/Rows/SetpointRow.swift +++ b/openHABWatch/Views/Rows/SetpointRow.swift @@ -17,6 +17,7 @@ import SwiftUI struct SetpointRow: View { @ObservedObject var widget: OpenHABWidget @EnvironmentObject var settings: AppSettings + private let setpointService = SetPointService() private var isIntStep: Bool { widget.step.truncatingRemainder(dividingBy: 1) == 0 @@ -58,27 +59,36 @@ struct SetpointRow: View { } } - private func handleUpDown(down: Bool) { + private func handleUpDown(isDecreasing: Bool) { var numberState = widget.stateValueAsNumberState - let stateValue = numberState?.value ?? widget.minValue - let newValue: Double = switch down { - case true: - stateValue - widget.step - case false: - stateValue + widget.step + let currentValue = numberState?.value ?? widget.minValue + + let limitedNewValue = setpointService.calculateNewValue( + currentValue: currentValue, + step: widget.step, + minValue: widget.minValue, + maxValue: widget.maxValue, + isDecreasing: isDecreasing + ) + + guard limitedNewValue != currentValue else { + // nothing to update, skip sending value + return } - if newValue >= widget.minValue, newValue <= widget.maxValue { - numberState?.value = newValue - widget.sendItemUpdate(state: numberState) + + if numberState != nil { + numberState?.value = limitedNewValue + } else { + numberState = NumberState(value: limitedNewValue) } } func decreaseValue() { - handleUpDown(down: true) + handleUpDown(isDecreasing: true) } func increaseValue() { - handleUpDown(down: false) + handleUpDown(isDecreasing: false) } } From be91cb0d96fe6fbb5866a225258ff89a0bee347f Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Fri, 11 Jul 2025 22:27:27 +0200 Subject: [PATCH 241/476] Introduced new package CommonUI for features shared between watchOS and iOS app Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- CommonUI/.gitignore | 8 +++ CommonUI/Package.swift | 55 ++++++++++++++ .../Sources/CommonUI}/ColorExtension.swift | 2 +- .../Tests/CommonUITests/CommonUITests.swift | 14 ++-- NetworkTrackerViewModel 2.swift | 72 +++++++++++++++++++ openHAB.xcodeproj/project.pbxproj | 70 +++++++++++------- openHAB.xcworkspace/contents.xcworkspacedata | 5 +- .../{ => Rows}/WidgetColorPickerView.swift | 5 +- .../WidgetDatePickerInputView.swift | 5 +- openHAB/SwiftUI/Rows/WidgetFrameView.swift | 34 +++++++++ .../{ => Rows}/WidgetGenericView.swift | 0 .../SwiftUI/{ => Rows}/WidgetIconView.swift | 0 .../SwiftUI/{ => Rows}/WidgetImageView.swift | 5 +- .../SwiftUI/{ => Rows}/WidgetMapView.swift | 5 +- .../{ => Rows}/WidgetRollershutterView.swift | 3 +- openHAB/SwiftUI/{ => Rows}/WidgetRow.swift | 0 .../{ => Rows}/WidgetSegmentedView.swift | 3 +- .../{ => Rows}/WidgetSelectionView.swift | 5 +- .../{ => Rows}/WidgetSetpointView.swift | 5 +- .../SwiftUI/{ => Rows}/WidgetSliderView.swift | 0 .../WidgetSliderWithSwitchView.swift | 5 +- .../SwiftUI/{ => Rows}/WidgetSwitchView.swift | 5 +- .../{ => Rows}/WidgetTextInputView.swift | 5 +- .../SwiftUI/{ => Rows}/WidgetTextView.swift | 0 .../SwiftUI/{ => Rows}/WidgetVideoView.swift | 5 +- .../SwiftUI/{ => Rows}/WidgetWebView.swift | 5 +- openHAB/SwiftUI/WidgetViewFactory.swift | 2 +- .../Views/Utils/DetailTextLabelView.swift | 2 +- openHABWatch/Views/Utils/TextLabelView.swift | 1 + 29 files changed, 262 insertions(+), 64 deletions(-) create mode 100644 CommonUI/.gitignore create mode 100644 CommonUI/Package.swift rename {openHAB => CommonUI/Sources/CommonUI}/ColorExtension.swift (95%) rename openHABWatch/Views/Utils/Color+Extension.swift => CommonUI/Tests/CommonUITests/CommonUITests.swift (64%) create mode 100644 NetworkTrackerViewModel 2.swift rename openHAB/SwiftUI/{ => Rows}/WidgetColorPickerView.swift (95%) rename openHAB/SwiftUI/{ => Rows}/WidgetDatePickerInputView.swift (96%) create mode 100644 openHAB/SwiftUI/Rows/WidgetFrameView.swift rename openHAB/SwiftUI/{ => Rows}/WidgetGenericView.swift (100%) rename openHAB/SwiftUI/{ => Rows}/WidgetIconView.swift (100%) rename openHAB/SwiftUI/{ => Rows}/WidgetImageView.swift (94%) rename openHAB/SwiftUI/{ => Rows}/WidgetMapView.swift (94%) rename openHAB/SwiftUI/{ => Rows}/WidgetRollershutterView.swift (97%) rename openHAB/SwiftUI/{ => Rows}/WidgetRow.swift (100%) rename openHAB/SwiftUI/{ => Rows}/WidgetSegmentedView.swift (97%) rename openHAB/SwiftUI/{ => Rows}/WidgetSelectionView.swift (94%) rename openHAB/SwiftUI/{ => Rows}/WidgetSetpointView.swift (96%) rename openHAB/SwiftUI/{ => Rows}/WidgetSliderView.swift (100%) rename openHAB/SwiftUI/{ => Rows}/WidgetSliderWithSwitchView.swift (95%) rename openHAB/SwiftUI/{ => Rows}/WidgetSwitchView.swift (94%) rename openHAB/SwiftUI/{ => Rows}/WidgetTextInputView.swift (94%) rename openHAB/SwiftUI/{ => Rows}/WidgetTextView.swift (100%) rename openHAB/SwiftUI/{ => Rows}/WidgetVideoView.swift (93%) rename openHAB/SwiftUI/{ => Rows}/WidgetWebView.swift (94%) diff --git a/CommonUI/.gitignore b/CommonUI/.gitignore new file mode 100644 index 000000000..0023a5340 --- /dev/null +++ b/CommonUI/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/CommonUI/Package.swift b/CommonUI/Package.swift new file mode 100644 index 000000000..cb227b090 --- /dev/null +++ b/CommonUI/Package.swift @@ -0,0 +1,55 @@ +// swift-tools-version: 6.1 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "CommonUI", + platforms: [.iOS(.v16), .watchOS(.v10), .macOS(.v14)], + + products: [ + // Products define the executables and libraries a package produces, making them visible to other packages. + .library( + name: "CommonUI", + targets: ["CommonUI"] + ) + ], + dependencies: [ + .package(path: "../OpenHABCore") + ], + targets: [ + // Targets are the basic building blocks of a package, defining a module or a test suite. + // Targets can depend on other targets in this package and products from dependencies. + .target( + name: "CommonUI", + dependencies: ["OpenHABCore"], + swiftSettings: [ + .enableUpcomingFeature("BareSlashRegexLiterals"), + .enableUpcomingFeature("ConciseMagicFile"), + .enableUpcomingFeature("DisableOutwardActorInference"), + .enableUpcomingFeature("DynamicActorIsolation"), + .enableUpcomingFeature("ExistentialAny"), + .enableUpcomingFeature("ForwardTrailingClosures"), + .enableUpcomingFeature("GlobalActorIsolatedTypesUsability"), + .enableUpcomingFeature("GlobalConcurrency"), + .enableUpcomingFeature("ImplicitOpenExistentials"), + .enableUpcomingFeature("ImportObjcForwardDeclarations"), + .enableUpcomingFeature("InferSendableFromCaptures"), + // .enableUpcomingFeature("InternalImportsByDefault"), + .enableUpcomingFeature("IsolatedDefaultValues"), + .enableUpcomingFeature("MemberImportVisibility"), + .enableUpcomingFeature("NonfrozenEnumExhaustivity"), + .enableUpcomingFeature("RegionBasedIsolation"), + .enableUpcomingFeature("StrictConcurrency"), + .unsafeFlags([ + "-Xfrontend", "-enable-actor-data-race-checks", + "-Xfrontend", "-strict-concurrency=complete" + ]) + ] + ), + .testTarget( + name: "CommonUITests", + dependencies: ["CommonUI"] + ) + ] +) diff --git a/openHAB/ColorExtension.swift b/CommonUI/Sources/CommonUI/ColorExtension.swift similarity index 95% rename from openHAB/ColorExtension.swift rename to CommonUI/Sources/CommonUI/ColorExtension.swift index 0f1661cf4..bdd4e3e50 100644 --- a/openHAB/ColorExtension.swift +++ b/CommonUI/Sources/CommonUI/ColorExtension.swift @@ -12,7 +12,7 @@ import OpenHABCore import SwiftUI -extension Color { +public extension Color { init(fromString string: String) { self.init(UIColor(fromString: string)) } diff --git a/openHABWatch/Views/Utils/Color+Extension.swift b/CommonUI/Tests/CommonUITests/CommonUITests.swift similarity index 64% rename from openHABWatch/Views/Utils/Color+Extension.swift rename to CommonUI/Tests/CommonUITests/CommonUITests.swift index 0f1661cf4..da19358dd 100644 --- a/openHABWatch/Views/Utils/Color+Extension.swift +++ b/CommonUI/Tests/CommonUITests/CommonUITests.swift @@ -9,15 +9,9 @@ // // SPDX-License-Identifier: EPL-2.0 -import OpenHABCore -import SwiftUI +@testable import CommonUI +import Testing -extension Color { - init(fromString string: String) { - self.init(UIColor(fromString: string)) - } - - init(hex: String) { - self.init(UIColor(hex: hex)) - } +@Test func example() async throws { + // Write your test here and use APIs like `#expect(...)` to check expected conditions. } diff --git a/NetworkTrackerViewModel 2.swift b/NetworkTrackerViewModel 2.swift new file mode 100644 index 000000000..fdde92f00 --- /dev/null +++ b/NetworkTrackerViewModel 2.swift @@ -0,0 +1,72 @@ +// Copyright (c) 2010-2025 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 + +@MainActor +public final class NetworkTrackerViewModel: ObservableObject { + @Published public private(set) var activeConnection: ConnectionInfo? + @Published public private(set) var status: NetworkStatus = .connecting + + private let observer: NetworkObserver + + public init(observer: NetworkObserver = .shared) async { + self.observer = observer + await observer.bind(to: self) // ✅ Now allowed + } + + public func startTracking(with configurations: [ConnectionConfiguration]) async { + await observer.startTracking(connectionConfigurations: configurations) + } + + public func send(to item: OpenHABItem, command: String) async throws { + try await observer.send(to: item.name, command: command) + } + + public func updateState(for item: OpenHABItem, state: String) async throws { + try await observer.updateState(for: item.name, state: state) + } + + public func getItems() async throws -> [OpenHABItem] { + try await observer.getItems() + } + + public func getItemByName(id: String) async throws -> OpenHABItem? { + try await observer.getItemByName(id: id) + } + + public func pollDataForPage(sitemapname: String, pageId: String = "", longPolling: Bool = false) async throws -> OpenHABPage? { + try await observer.pollDataForPage(sitemapname: sitemapname, pageId: pageId, longPolling: longPolling) + } + + public func runNow(ruleUID: String, payload: [String: String]) async throws { + try await observer.runNow(ruleUID: ruleUID, payload: payload) + } + + public func resetFailures() async { + await observer.resetFailures() + } + + // Internal API for observer updates + func updateStatus(_ status: NetworkStatus, connection: ConnectionInfo?) { + Task { @MainActor in + self.status = status + self.activeConnection = connection + } + } + + func activeConnectionStream() -> AsyncStream { + AsyncStream { continuation in + let cancellable = self.$activeConnection + .sink { continuation.yield($0) } + + continuation.onTermination = { [cancellable] _ in cancellable.cancel() } + } + } +} diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index 1991b7bad..dca21a607 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -100,7 +100,6 @@ DA35E2C52E1EEA9D003987BB /* WidgetRollershutterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA35E2B62E1EEA9D003987BB /* WidgetRollershutterView.swift */; }; DA35E2C62E1EEA9D003987BB /* WidgetSegmentedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA35E2B72E1EEA9D003987BB /* WidgetSegmentedView.swift */; }; DA35E2C72E1EEA9D003987BB /* WidgetVideoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA35E2BB2E1EEA9D003987BB /* WidgetVideoView.swift */; }; - DA35E2C92E1EF15B003987BB /* ColorExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA35E2C82E1EF15B003987BB /* ColorExtension.swift */; }; DA35E2CB2E1F93AD003987BB /* ImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA35E2CA2E1F93AD003987BB /* ImageView.swift */; }; DA35E2CD2E1F96CA003987BB /* WidgetIconView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA35E2CC2E1F96CA003987BB /* WidgetIconView.swift */; }; DA4642322D7EE6CA006C3908 /* LoggerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4642312D7EE6CA006C3908 /* LoggerView.swift */; }; @@ -147,6 +146,9 @@ DAC65FC7236EDF3900F4501E /* SpinnerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC65FC6236EDF3900F4501E /* SpinnerViewController.swift */; }; DAC6608D236F771600F4501E /* PreferencesSwiftUIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC6608C236F771600F4501E /* PreferencesSwiftUIView.swift */; }; DAC9395522B00E7600C5F423 /* XCTestCaseExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC9395422B00E7600C5F423 /* XCTestCaseExtension.swift */; }; + DAC949FA2E219F0D007E67B7 /* CommonUI in Frameworks */ = {isa = PBXBuildFile; productRef = DAC949F92E219F0D007E67B7 /* CommonUI */; }; + DAC949FC2E219F30007E67B7 /* CommonUI in Frameworks */ = {isa = PBXBuildFile; productRef = DAC949FB2E219F30007E67B7 /* CommonUI */; }; + DAC949FE2E21A2D1007E67B7 /* WidgetFrameView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC949FD2E21A2D1007E67B7 /* WidgetFrameView.swift */; }; DAC9AF4924F966FA006DAE93 /* LazyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC9AF4824F966FA006DAE93 /* LazyView.swift */; }; DACA368E2D7440B9003CD237 /* OpenHABWidgetExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC9AF4624F9669F006DAE93 /* OpenHABWidgetExtension.swift */; }; DACE664A2C63B0760069E514 /* OpenAPIURLSession in Frameworks */ = {isa = PBXBuildFile; productRef = DACE66492C63B0760069E514 /* OpenAPIURLSession */; }; @@ -171,7 +173,6 @@ DAF231DB27BB828000AB916C /* pantryUseTagPoints2NonExistentElement.svg in Resources */ = {isa = PBXBuildFile; fileRef = DAF231DA27BB828000AB916C /* pantryUseTagPoints2NonExistentElement.svg */; }; DAF231E327BBD1A000AB916C /* embeddedpng_valid.svg in Resources */ = {isa = PBXBuildFile; fileRef = DAF231E227BBD1A000AB916C /* embeddedpng_valid.svg */; }; DAF4578223D630C70018B495 /* IconView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF4578123D630C70018B495 /* IconView.swift */; }; - DAF4578523D7807A0018B495 /* Color+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 934B610B2348D2F9009112D5 /* Color+Extension.swift */; }; DAF4578723D798A50018B495 /* TextLabelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF4578623D798A50018B495 /* TextLabelView.swift */; }; DAF4578923D79AA50018B495 /* DetailTextLabelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF4578823D79AA50018B495 /* DetailTextLabelView.swift */; }; DAF457A023DA3E1C0018B495 /* SegmentRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF4579F23DA3E1C0018B495 /* SegmentRow.swift */; }; @@ -339,7 +340,6 @@ 933D7F0622E7015000621A03 /* OpenHABUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenHABUITests.swift; sourceTree = ""; }; 933D7F0822E7015100621A03 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 933D7F0E22E7030600621A03 /* SnapshotHelper.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = SnapshotHelper.swift; path = fastlane/SnapshotHelper.swift; sourceTree = SOURCE_ROOT; }; - 934B610B2348D2F9009112D5 /* Color+Extension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Color+Extension.swift"; sourceTree = ""; }; 935B484525342B8E00E44CF0 /* URL+Static.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+Static.swift"; sourceTree = ""; }; 935D3412257B7E2F0020A404 /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = Resources/nl.lproj/Intents.strings; sourceTree = ""; }; 935D3419257B7E820020A404 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.intentdefinition; name = Base; path = Resources/Base.lproj/Intents.intentdefinition; sourceTree = ""; }; @@ -439,7 +439,6 @@ DA35E2BA2E1EEA9D003987BB /* WidgetTextInputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetTextInputView.swift; sourceTree = ""; }; DA35E2BB2E1EEA9D003987BB /* WidgetVideoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetVideoView.swift; sourceTree = ""; }; DA35E2BC2E1EEA9D003987BB /* WidgetWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetWebView.swift; sourceTree = ""; }; - DA35E2C82E1EF15B003987BB /* ColorExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorExtension.swift; sourceTree = ""; }; DA35E2CA2E1F93AD003987BB /* ImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageView.swift; sourceTree = ""; }; DA35E2CC2E1F96CA003987BB /* WidgetIconView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetIconView.swift; sourceTree = ""; }; DA4642312D7EE6CA006C3908 /* LoggerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoggerView.swift; sourceTree = ""; }; @@ -482,6 +481,7 @@ DAC6608C236F771600F4501E /* PreferencesSwiftUIView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesSwiftUIView.swift; sourceTree = ""; }; DAC9394322AD4A7A00C5F423 /* OpenHABWatchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenHABWatchTests.swift; sourceTree = ""; }; DAC9395422B00E7600C5F423 /* XCTestCaseExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCTestCaseExtension.swift; sourceTree = ""; }; + DAC949FD2E21A2D1007E67B7 /* WidgetFrameView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetFrameView.swift; sourceTree = ""; }; DAC9AF4624F9669F006DAE93 /* OpenHABWidgetExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenHABWidgetExtension.swift; sourceTree = ""; }; DAC9AF4824F966FA006DAE93 /* LazyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LazyView.swift; sourceTree = ""; }; DACB636127D3FC6500041931 /* error.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = error.png; sourceTree = ""; }; @@ -553,6 +553,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + DAC949FC2E219F30007E67B7 /* CommonUI in Frameworks */, 934E592728F16EBA00162004 /* Kingfisher in Frameworks */, 937E4473270B36DD00A98C26 /* OpenHABCore in Frameworks */, DA2C4FD52B4F573300D1C533 /* SDWebImageSVGCoder in Frameworks */, @@ -624,6 +625,7 @@ 93F8064A27AE7A2E0035A6B0 /* FlexColorPicker in Frameworks */, DA28C362225241DE00AB409C /* WebKit.framework in Frameworks */, DACE664D2C63B0840069E514 /* OpenAPIRuntime in Frameworks */, + DAC949FA2E219F0D007E67B7 /* CommonUI in Frameworks */, 93F8065027AE7A830035A6B0 /* SideMenu in Frameworks */, DFE10414197415F900D94943 /* Security.framework in Frameworks */, 93F8064727AE7A050035A6B0 /* SwiftMessages in Frameworks */, @@ -847,27 +849,10 @@ DA35E2B12E1EEA58003987BB /* SwiftUI */ = { isa = PBXGroup; children = ( - DAEA21DF2DBF483E00D54342 /* WidgetGenericView.swift */, DA35E2CA2E1F93AD003987BB /* ImageView.swift */, DA64ACA72DBEAD8300294F60 /* SitemapPageView.swift */, DAEA21D72DBF472D00D54342 /* WidgetViewFactory.swift */, - DA64ACA92DBEADB000294F60 /* WidgetRow.swift */, - DA35E2B22E1EEA9D003987BB /* WidgetColorPickerView.swift */, - DA35E2B32E1EEA9D003987BB /* WidgetDatePickerInputView.swift */, - DA35E2B42E1EEA9D003987BB /* WidgetImageView.swift */, - DA35E2B52E1EEA9D003987BB /* WidgetMapView.swift */, - DA35E2B62E1EEA9D003987BB /* WidgetRollershutterView.swift */, - DA35E2B72E1EEA9D003987BB /* WidgetSegmentedView.swift */, - DA35E2B82E1EEA9D003987BB /* WidgetSelectionView.swift */, - DA35E2AF2E1EDB86003987BB /* WidgetSetpointView.swift */, - DA35E2B92E1EEA9D003987BB /* WidgetSliderWithSwitchView.swift */, - DA35E2BA2E1EEA9D003987BB /* WidgetTextInputView.swift */, - DA35E2BB2E1EEA9D003987BB /* WidgetVideoView.swift */, - DA35E2BC2E1EEA9D003987BB /* WidgetWebView.swift */, - DAEA21D92DBF477E00D54342 /* WidgetSwitchView.swift */, - DAEA21DB2DBF47DA00D54342 /* WidgetSliderView.swift */, - DAEA21DD2DBF481300D54342 /* WidgetTextView.swift */, - DA35E2CC2E1F96CA003987BB /* WidgetIconView.swift */, + DAC949FF2E21A473007E67B7 /* Rows */, ); path = SwiftUI; sourceTree = ""; @@ -909,6 +894,32 @@ path = openHABIntentsTests; sourceTree = ""; }; + DAC949FF2E21A473007E67B7 /* Rows */ = { + isa = PBXGroup; + children = ( + DA35E2B22E1EEA9D003987BB /* WidgetColorPickerView.swift */, + DA35E2B32E1EEA9D003987BB /* WidgetDatePickerInputView.swift */, + DAC949FD2E21A2D1007E67B7 /* WidgetFrameView.swift */, + DAEA21DF2DBF483E00D54342 /* WidgetGenericView.swift */, + DA35E2CC2E1F96CA003987BB /* WidgetIconView.swift */, + DA35E2B42E1EEA9D003987BB /* WidgetImageView.swift */, + DA35E2B52E1EEA9D003987BB /* WidgetMapView.swift */, + DA35E2B62E1EEA9D003987BB /* WidgetRollershutterView.swift */, + DA64ACA92DBEADB000294F60 /* WidgetRow.swift */, + DA35E2B72E1EEA9D003987BB /* WidgetSegmentedView.swift */, + DA35E2B82E1EEA9D003987BB /* WidgetSelectionView.swift */, + DA35E2AF2E1EDB86003987BB /* WidgetSetpointView.swift */, + DAEA21DB2DBF47DA00D54342 /* WidgetSliderView.swift */, + DA35E2B92E1EEA9D003987BB /* WidgetSliderWithSwitchView.swift */, + DAEA21D92DBF477E00D54342 /* WidgetSwitchView.swift */, + DA35E2BA2E1EEA9D003987BB /* WidgetTextInputView.swift */, + DAEA21DD2DBF481300D54342 /* WidgetTextView.swift */, + DA35E2BB2E1EEA9D003987BB /* WidgetVideoView.swift */, + DA35E2BC2E1EEA9D003987BB /* WidgetWebView.swift */, + ); + path = Rows; + sourceTree = ""; + }; DACE66522C63B2070069E514 /* openapitest */ = { isa = PBXGroup; children = ( @@ -984,7 +995,6 @@ DAF457A723DBA2C40018B495 /* Utils */ = { isa = PBXGroup; children = ( - 934B610B2348D2F9009112D5 /* Color+Extension.swift */, DAF4578123D630C70018B495 /* IconView.swift */, DAF4578623D798A50018B495 /* TextLabelView.swift */, DAF4578823D79AA50018B495 /* DetailTextLabelView.swift */, @@ -999,7 +1009,6 @@ DF4B83FD18857FA100F34902 /* UI */ = { isa = PBXGroup; children = ( - DA35E2C82E1EF15B003987BB /* ColorExtension.swift */, DA2AEB752D92D32000897D80 /* Cells */, DA4642312D7EE6CA006C3908 /* LoggerView.swift */, 653B54C1285E714900298ECD /* OpenHABViewController.swift */, @@ -1253,6 +1262,7 @@ 934E592828F16EBA00162004 /* DeviceKit */, DA2C4FD42B4F573300D1C533 /* SDWebImageSVGCoder */, DA9A7EFC2D668D5900824156 /* SFSafeSymbols */, + DAC949FB2E219F30007E67B7 /* CommonUI */, ); productName = openHABWatchSwift2; productReference = DA0775152346705D0086C685 /* openHABWatch.app */; @@ -1348,6 +1358,7 @@ DACE664C2C63B0840069E514 /* OpenAPIRuntime */, DA9A7EFE2D66915900824156 /* SFSafeSymbols */, DABB5E322D98972F009A4B8A /* SDWebImageSVGCoder */, + DAC949F92E219F0D007E67B7 /* CommonUI */, ); productName = openHAB; productReference = DFB2622718830A3600D3244D /* openHAB.app */; @@ -1633,7 +1644,6 @@ DAF4581823DC4A050018B495 /* ImageRow.swift in Sources */, DA07752F2346705F0086C685 /* NotificationView.swift in Sources */, DA0775312346705F0086C685 /* ComplicationController.swift in Sources */, - DAF4578523D7807A0018B495 /* Color+Extension.swift in Sources */, DAF457A223DB6C640018B495 /* RollershutterRow.swift in Sources */, DAF457A923DBA4990018B495 /* FrameRow.swift in Sources */, ); @@ -1688,7 +1698,6 @@ DAEA21DA2DBF477E00D54342 /* WidgetSwitchView.swift in Sources */, B7D5ECE121499E55001B0EC6 /* MapViewTableViewCell.swift in Sources */, DAEA21DC2DBF47DA00D54342 /* WidgetSliderView.swift in Sources */, - DA35E2C92E1EF15B003987BB /* ColorExtension.swift in Sources */, DA77E19B2D886D9B007CFF0F /* SingleConnectionSettingsView.swift in Sources */, DA35E2B02E1EDB86003987BB /* WidgetSetpointView.swift in Sources */, DA35E2CB2E1F93AD003987BB /* ImageView.swift in Sources */, @@ -1705,6 +1714,7 @@ DAC131112DA3213100075AE2 /* SetSwitchStateIntentHandlerTests.swift in Sources */, DA35E2BD2E1EEA9D003987BB /* WidgetMapView.swift in Sources */, DA35E2BE2E1EEA9D003987BB /* WidgetDatePickerInputView.swift in Sources */, + DAC949FE2E21A2D1007E67B7 /* WidgetFrameView.swift in Sources */, DA35E2BF2E1EEA9D003987BB /* WidgetWebView.swift in Sources */, DA35E2C02E1EEA9D003987BB /* WidgetColorPickerView.swift in Sources */, DA35E2C12E1EEA9D003987BB /* WidgetSliderWithSwitchView.swift in Sources */, @@ -3021,6 +3031,14 @@ package = DA2C4FD32B4F573300D1C533 /* XCRemoteSwiftPackageReference "SDWebImageSVGCoder" */; productName = SDWebImageSVGCoder; }; + DAC949F92E219F0D007E67B7 /* CommonUI */ = { + isa = XCSwiftPackageProductDependency; + productName = CommonUI; + }; + DAC949FB2E219F30007E67B7 /* CommonUI */ = { + isa = XCSwiftPackageProductDependency; + productName = CommonUI; + }; DACE66492C63B0760069E514 /* OpenAPIURLSession */ = { isa = XCSwiftPackageProductDependency; package = DACE66482C63B0760069E514 /* XCRemoteSwiftPackageReference "swift-openapi-urlsession" */; diff --git a/openHAB.xcworkspace/contents.xcworkspacedata b/openHAB.xcworkspace/contents.xcworkspacedata index 42475e0a4..1111aa022 100644 --- a/openHAB.xcworkspace/contents.xcworkspacedata +++ b/openHAB.xcworkspace/contents.xcworkspacedata @@ -8,6 +8,9 @@ location = "group:openHAB.xcodeproj"> + location = "group:NetworkTrackerViewModel 2.swift"> + + diff --git a/openHAB/SwiftUI/WidgetColorPickerView.swift b/openHAB/SwiftUI/Rows/WidgetColorPickerView.swift similarity index 95% rename from openHAB/SwiftUI/WidgetColorPickerView.swift rename to openHAB/SwiftUI/Rows/WidgetColorPickerView.swift index 22b5e1921..529b7bacd 100644 --- a/openHAB/SwiftUI/WidgetColorPickerView.swift +++ b/openHAB/SwiftUI/Rows/WidgetColorPickerView.swift @@ -9,6 +9,7 @@ // // SPDX-License-Identifier: EPL-2.0 +import CommonUI import OpenHABCore import os.log import SwiftUI @@ -23,7 +24,7 @@ struct WidgetColorPickerView: View { VStack(alignment: .leading, spacing: 8) { if let labelText = widget.labelText, !labelText.isEmpty { Text(labelText) - .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(UIColor(fromString: widget.labelcolor))) + .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) } ColorPicker("Color", selection: $selectedColor, supportsOpacity: false) @@ -35,7 +36,7 @@ struct WidgetColorPickerView: View { if let labelValue = widget.labelValue, !labelValue.isEmpty { Text(labelValue) .font(.caption) - .foregroundColor(widget.valuecolor.isEmpty ? .secondary : Color(UIColor(fromString: widget.valuecolor))) + .foregroundColor(widget.valuecolor.isEmpty ? .secondary : Color(fromString: widget.valuecolor)) } } .onAppear { diff --git a/openHAB/SwiftUI/WidgetDatePickerInputView.swift b/openHAB/SwiftUI/Rows/WidgetDatePickerInputView.swift similarity index 96% rename from openHAB/SwiftUI/WidgetDatePickerInputView.swift rename to openHAB/SwiftUI/Rows/WidgetDatePickerInputView.swift index a2881e2c8..4c1a776a8 100644 --- a/openHAB/SwiftUI/WidgetDatePickerInputView.swift +++ b/openHAB/SwiftUI/Rows/WidgetDatePickerInputView.swift @@ -9,6 +9,7 @@ // // SPDX-License-Identifier: EPL-2.0 +import CommonUI import OpenHABCore import os.log import SwiftUI @@ -36,7 +37,7 @@ struct WidgetDatePickerInputView: View { VStack(alignment: .leading, spacing: 8) { if let labelText = widget.labelText, !labelText.isEmpty { Text(labelText) - .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(UIColor(fromString: widget.labelcolor))) + .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) } DatePicker( @@ -57,7 +58,7 @@ struct WidgetDatePickerInputView: View { if let labelValue = widget.labelValue, !labelValue.isEmpty { Text(labelValue) .font(.caption) - .foregroundColor(widget.valuecolor.isEmpty ? .secondary : Color(UIColor(fromString: widget.valuecolor))) + .foregroundColor(widget.valuecolor.isEmpty ? .secondary : Color(fromString: widget.valuecolor)) } } .onAppear { diff --git a/openHAB/SwiftUI/Rows/WidgetFrameView.swift b/openHAB/SwiftUI/Rows/WidgetFrameView.swift new file mode 100644 index 000000000..5cd48a896 --- /dev/null +++ b/openHAB/SwiftUI/Rows/WidgetFrameView.swift @@ -0,0 +1,34 @@ +// Copyright (c) 2010-2025 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 OpenHABCore +import SwiftUI + +struct WidgetFrameView: View { + @ObservedObject var widget: OpenHABWidget + + var body: some View { + HStack { + Text(widget.labelText?.uppercased() ?? "") + .font(.callout) + .lineLimit(1) + Spacer() + } + .listRowBackground(Color(UIColor.systemGroupedBackground)) + } +} + +#Preview { + let widget = PreviewConstants.openHABSitemapPage!.widgets[6] + List([widget]) { widget in + WidgetFrameView(widget: widget) + } +} diff --git a/openHAB/SwiftUI/WidgetGenericView.swift b/openHAB/SwiftUI/Rows/WidgetGenericView.swift similarity index 100% rename from openHAB/SwiftUI/WidgetGenericView.swift rename to openHAB/SwiftUI/Rows/WidgetGenericView.swift diff --git a/openHAB/SwiftUI/WidgetIconView.swift b/openHAB/SwiftUI/Rows/WidgetIconView.swift similarity index 100% rename from openHAB/SwiftUI/WidgetIconView.swift rename to openHAB/SwiftUI/Rows/WidgetIconView.swift diff --git a/openHAB/SwiftUI/WidgetImageView.swift b/openHAB/SwiftUI/Rows/WidgetImageView.swift similarity index 94% rename from openHAB/SwiftUI/WidgetImageView.swift rename to openHAB/SwiftUI/Rows/WidgetImageView.swift index a170bcdab..af6614ec9 100644 --- a/openHAB/SwiftUI/WidgetImageView.swift +++ b/openHAB/SwiftUI/Rows/WidgetImageView.swift @@ -9,6 +9,7 @@ // // SPDX-License-Identifier: EPL-2.0 +import CommonUI import Kingfisher import OpenHABCore import SwiftUI @@ -25,7 +26,7 @@ struct WidgetImageView: View { VStack(alignment: .leading, spacing: 8) { if let labelText = widget.labelText, !labelText.isEmpty { Text(labelText) - .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(UIColor(fromString: widget.labelcolor))) + .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) } if let imageURL { @@ -57,7 +58,7 @@ struct WidgetImageView: View { if let labelValue = widget.labelValue, !labelValue.isEmpty { Text(labelValue) .font(.caption) - .foregroundColor(widget.valuecolor.isEmpty ? .secondary : Color(UIColor(fromString: widget.valuecolor))) + .foregroundColor(widget.valuecolor.isEmpty ? .secondary : Color(fromString: widget.valuecolor)) } } } diff --git a/openHAB/SwiftUI/WidgetMapView.swift b/openHAB/SwiftUI/Rows/WidgetMapView.swift similarity index 94% rename from openHAB/SwiftUI/WidgetMapView.swift rename to openHAB/SwiftUI/Rows/WidgetMapView.swift index 663d075a1..a6f6e075b 100644 --- a/openHAB/SwiftUI/WidgetMapView.swift +++ b/openHAB/SwiftUI/Rows/WidgetMapView.swift @@ -9,6 +9,7 @@ // // SPDX-License-Identifier: EPL-2.0 +import CommonUI import CoreLocation import MapKit import OpenHABCore @@ -36,7 +37,7 @@ struct WidgetMapView: View { VStack(alignment: .leading, spacing: 8) { if let labelText = widget.labelText, !labelText.isEmpty { Text(labelText) - .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(UIColor(fromString: widget.labelcolor))) + .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) } Map(coordinateRegion: $region, annotationItems: coordinates.map { [$0] } ?? []) { coordinate in @@ -53,7 +54,7 @@ struct WidgetMapView: View { if let labelValue = widget.labelValue, !labelValue.isEmpty { Text(labelValue) .font(.caption) - .foregroundColor(widget.valuecolor.isEmpty ? .secondary : Color(UIColor(fromString: widget.valuecolor))) + .foregroundColor(widget.valuecolor.isEmpty ? .secondary : Color(fromString: widget.valuecolor)) } } } diff --git a/openHAB/SwiftUI/WidgetRollershutterView.swift b/openHAB/SwiftUI/Rows/WidgetRollershutterView.swift similarity index 97% rename from openHAB/SwiftUI/WidgetRollershutterView.swift rename to openHAB/SwiftUI/Rows/WidgetRollershutterView.swift index 9243c77c1..22a08321d 100644 --- a/openHAB/SwiftUI/WidgetRollershutterView.swift +++ b/openHAB/SwiftUI/Rows/WidgetRollershutterView.swift @@ -9,6 +9,7 @@ // // SPDX-License-Identifier: EPL-2.0 +import CommonUI import OpenHABCore import os.log import SFSafeSymbols @@ -29,7 +30,7 @@ struct WidgetRollershutterView: View { VStack(alignment: .leading, spacing: 2) { Text(widget.labelText ?? widget.label) - .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(UIColor(fromString: widget.labelcolor))) + .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) } Spacer() diff --git a/openHAB/SwiftUI/WidgetRow.swift b/openHAB/SwiftUI/Rows/WidgetRow.swift similarity index 100% rename from openHAB/SwiftUI/WidgetRow.swift rename to openHAB/SwiftUI/Rows/WidgetRow.swift diff --git a/openHAB/SwiftUI/WidgetSegmentedView.swift b/openHAB/SwiftUI/Rows/WidgetSegmentedView.swift similarity index 97% rename from openHAB/SwiftUI/WidgetSegmentedView.swift rename to openHAB/SwiftUI/Rows/WidgetSegmentedView.swift index 2fc2c31b7..a1ae6219a 100644 --- a/openHAB/SwiftUI/WidgetSegmentedView.swift +++ b/openHAB/SwiftUI/Rows/WidgetSegmentedView.swift @@ -9,6 +9,7 @@ // // SPDX-License-Identifier: EPL-2.0 +import CommonUI import OpenHABCore import os.log import SwiftUI @@ -35,7 +36,7 @@ struct WidgetSegmentedView: View { HStack { if let labelText = widget.labelText, !labelText.isEmpty { Text(labelText) - .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(UIColor(fromString: widget.labelcolor))) + .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) } if !mappings.isEmpty { diff --git a/openHAB/SwiftUI/WidgetSelectionView.swift b/openHAB/SwiftUI/Rows/WidgetSelectionView.swift similarity index 94% rename from openHAB/SwiftUI/WidgetSelectionView.swift rename to openHAB/SwiftUI/Rows/WidgetSelectionView.swift index 05c844a48..b48ad670b 100644 --- a/openHAB/SwiftUI/WidgetSelectionView.swift +++ b/openHAB/SwiftUI/Rows/WidgetSelectionView.swift @@ -9,6 +9,7 @@ // // SPDX-License-Identifier: EPL-2.0 +import CommonUI import OpenHABCore import os.log import SwiftUI @@ -27,7 +28,7 @@ struct WidgetSelectionView: View { VStack(alignment: .leading, spacing: 8) { if let labelText = widget.labelText, !labelText.isEmpty { Text(labelText) - .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(UIColor(fromString: widget.labelcolor))) + .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) } if !mappings.isEmpty { @@ -48,7 +49,7 @@ struct WidgetSelectionView: View { if let labelValue = widget.labelValue, !labelValue.isEmpty { Text(labelValue) .font(.caption) - .foregroundColor(widget.valuecolor.isEmpty ? .secondary : Color(UIColor(fromString: widget.valuecolor))) + .foregroundColor(widget.valuecolor.isEmpty ? .secondary : Color(fromString: widget.valuecolor)) } } .onAppear { diff --git a/openHAB/SwiftUI/WidgetSetpointView.swift b/openHAB/SwiftUI/Rows/WidgetSetpointView.swift similarity index 96% rename from openHAB/SwiftUI/WidgetSetpointView.swift rename to openHAB/SwiftUI/Rows/WidgetSetpointView.swift index 022940990..59a79dbe7 100644 --- a/openHAB/SwiftUI/WidgetSetpointView.swift +++ b/openHAB/SwiftUI/Rows/WidgetSetpointView.swift @@ -9,6 +9,7 @@ // // SPDX-License-Identifier: EPL-2.0 +import CommonUI import OpenHABCore import os.log import SFSafeSymbols @@ -47,7 +48,7 @@ struct WidgetSetpointView: View { VStack(alignment: .leading, spacing: 2) { Text(widget.labelText ?? widget.label) - .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(UIColor(fromString: widget.labelcolor))) + .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) } Spacer() @@ -63,7 +64,7 @@ struct WidgetSetpointView: View { Text(formattedValue) .font(.caption) - .foregroundColor(widget.valuecolor.isEmpty ? .secondary : Color(UIColor(fromString: widget.valuecolor))) + .foregroundColor(widget.valuecolor.isEmpty ? .secondary : Color(fromString: widget.valuecolor)) Button(action: increaseValue) { Image(systemSymbol: .chevronUpCircleFill) diff --git a/openHAB/SwiftUI/WidgetSliderView.swift b/openHAB/SwiftUI/Rows/WidgetSliderView.swift similarity index 100% rename from openHAB/SwiftUI/WidgetSliderView.swift rename to openHAB/SwiftUI/Rows/WidgetSliderView.swift diff --git a/openHAB/SwiftUI/WidgetSliderWithSwitchView.swift b/openHAB/SwiftUI/Rows/WidgetSliderWithSwitchView.swift similarity index 95% rename from openHAB/SwiftUI/WidgetSliderWithSwitchView.swift rename to openHAB/SwiftUI/Rows/WidgetSliderWithSwitchView.swift index 097ab11ac..d5e67bb94 100644 --- a/openHAB/SwiftUI/WidgetSliderWithSwitchView.swift +++ b/openHAB/SwiftUI/Rows/WidgetSliderWithSwitchView.swift @@ -9,6 +9,7 @@ // // SPDX-License-Identifier: EPL-2.0 +import CommonUI import OpenHABCore import os.log import SwiftUI @@ -48,12 +49,12 @@ struct WidgetSliderWithSwitchView: View { HStack { VStack(alignment: .leading, spacing: 2) { Text(widget.labelText ?? widget.label) - .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(UIColor(fromString: widget.labelcolor))) + .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) if let labelValue = widget.labelValue, !labelValue.isEmpty { Text(labelValue) .font(.caption) - .foregroundColor(widget.valuecolor.isEmpty ? .secondary : Color(UIColor(fromString: widget.valuecolor))) + .foregroundColor(widget.valuecolor.isEmpty ? .secondary : Color(fromString: widget.valuecolor)) } else { Text(sliderValue.valueText(step: step)) .font(.caption) diff --git a/openHAB/SwiftUI/WidgetSwitchView.swift b/openHAB/SwiftUI/Rows/WidgetSwitchView.swift similarity index 94% rename from openHAB/SwiftUI/WidgetSwitchView.swift rename to openHAB/SwiftUI/Rows/WidgetSwitchView.swift index 3f7e1642d..2655dbf19 100644 --- a/openHAB/SwiftUI/WidgetSwitchView.swift +++ b/openHAB/SwiftUI/Rows/WidgetSwitchView.swift @@ -9,6 +9,7 @@ // // SPDX-License-Identifier: EPL-2.0 +import CommonUI import OpenHABCore import os.log import SwiftUI @@ -40,12 +41,12 @@ struct WidgetSwitchView: View { VStack(alignment: .leading, spacing: 2) { Text(widget.labelText ?? widget.label) - .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(UIColor(fromString: widget.labelcolor))) + .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) if let labelValue = widget.labelValue, !labelValue.isEmpty { Text(labelValue) .font(.caption) - .foregroundColor(widget.valuecolor.isEmpty ? .secondary : Color(UIColor(fromString: widget.valuecolor))) + .foregroundColor(widget.valuecolor.isEmpty ? .secondary : Color(fromString: widget.valuecolor)) } } diff --git a/openHAB/SwiftUI/WidgetTextInputView.swift b/openHAB/SwiftUI/Rows/WidgetTextInputView.swift similarity index 94% rename from openHAB/SwiftUI/WidgetTextInputView.swift rename to openHAB/SwiftUI/Rows/WidgetTextInputView.swift index 13e6cc9d2..6e612bc60 100644 --- a/openHAB/SwiftUI/WidgetTextInputView.swift +++ b/openHAB/SwiftUI/Rows/WidgetTextInputView.swift @@ -9,6 +9,7 @@ // // SPDX-License-Identifier: EPL-2.0 +import CommonUI import OpenHABCore import os.log import SwiftUI @@ -31,7 +32,7 @@ struct WidgetTextInputView: View { VStack(alignment: .leading, spacing: 8) { if let labelText = widget.labelText, !labelText.isEmpty { Text(labelText) - .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(UIColor(fromString: widget.labelcolor))) + .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) } TextField("Enter text", text: $inputText) @@ -44,7 +45,7 @@ struct WidgetTextInputView: View { if let labelValue = widget.labelValue, !labelValue.isEmpty { Text(labelValue) .font(.caption) - .foregroundColor(widget.valuecolor.isEmpty ? .secondary : Color(UIColor(fromString: widget.valuecolor))) + .foregroundColor(widget.valuecolor.isEmpty ? .secondary : Color(fromString: widget.valuecolor)) } } } diff --git a/openHAB/SwiftUI/WidgetTextView.swift b/openHAB/SwiftUI/Rows/WidgetTextView.swift similarity index 100% rename from openHAB/SwiftUI/WidgetTextView.swift rename to openHAB/SwiftUI/Rows/WidgetTextView.swift diff --git a/openHAB/SwiftUI/WidgetVideoView.swift b/openHAB/SwiftUI/Rows/WidgetVideoView.swift similarity index 93% rename from openHAB/SwiftUI/WidgetVideoView.swift rename to openHAB/SwiftUI/Rows/WidgetVideoView.swift index 2f6d369df..e52e6548a 100644 --- a/openHAB/SwiftUI/WidgetVideoView.swift +++ b/openHAB/SwiftUI/Rows/WidgetVideoView.swift @@ -10,6 +10,7 @@ // SPDX-License-Identifier: EPL-2.0 import AVKit +import CommonUI import OpenHABCore import SwiftUI @@ -26,7 +27,7 @@ struct WidgetVideoView: View { VStack(alignment: .leading, spacing: 8) { if let labelText = widget.labelText, !labelText.isEmpty { Text(labelText) - .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(UIColor(fromString: widget.labelcolor))) + .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) } if let videoURL { @@ -53,7 +54,7 @@ struct WidgetVideoView: View { if let labelValue = widget.labelValue, !labelValue.isEmpty { Text(labelValue) .font(.caption) - .foregroundColor(widget.valuecolor.isEmpty ? .secondary : Color(UIColor(fromString: widget.valuecolor))) + .foregroundColor(widget.valuecolor.isEmpty ? .secondary : Color(fromString: widget.valuecolor)) } } } diff --git a/openHAB/SwiftUI/WidgetWebView.swift b/openHAB/SwiftUI/Rows/WidgetWebView.swift similarity index 94% rename from openHAB/SwiftUI/WidgetWebView.swift rename to openHAB/SwiftUI/Rows/WidgetWebView.swift index d72eb2ba3..391aab756 100644 --- a/openHAB/SwiftUI/WidgetWebView.swift +++ b/openHAB/SwiftUI/Rows/WidgetWebView.swift @@ -9,6 +9,7 @@ // // SPDX-License-Identifier: EPL-2.0 +import CommonUI import OpenHABCore import SwiftUI import WebKit @@ -20,7 +21,7 @@ struct WidgetWebViewContainer: View { VStack(alignment: .leading, spacing: 8) { if let labelText = widget.labelText, !labelText.isEmpty { Text(labelText) - .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(UIColor(fromString: widget.labelcolor))) + .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) } WidgetWebView(widget: widget) @@ -30,7 +31,7 @@ struct WidgetWebViewContainer: View { if let labelValue = widget.labelValue, !labelValue.isEmpty { Text(labelValue) .font(.caption) - .foregroundColor(widget.valuecolor.isEmpty ? .secondary : Color(UIColor(fromString: widget.valuecolor))) + .foregroundColor(widget.valuecolor.isEmpty ? .secondary : Color(fromString: widget.valuecolor)) } } } diff --git a/openHAB/SwiftUI/WidgetViewFactory.swift b/openHAB/SwiftUI/WidgetViewFactory.swift index e726a0aaf..99b9d5a8e 100644 --- a/openHAB/SwiftUI/WidgetViewFactory.swift +++ b/openHAB/SwiftUI/WidgetViewFactory.swift @@ -43,7 +43,7 @@ enum WidgetViewFactory { case .text: WidgetTextView(widget: widget) case .frame: - EmptyView() // ignore frames + WidgetFrameView(widget: widget) case .setpoint: WidgetSetpointView(widget: widget) case .selection: diff --git a/openHABWatch/Views/Utils/DetailTextLabelView.swift b/openHABWatch/Views/Utils/DetailTextLabelView.swift index 99acca81a..096b6d932 100644 --- a/openHABWatch/Views/Utils/DetailTextLabelView.swift +++ b/openHABWatch/Views/Utils/DetailTextLabelView.swift @@ -20,7 +20,7 @@ struct DetailTextLabelView: View { Text(label) .font(.footnote) .lineLimit(1) - .foregroundColor(!widget.valuecolor.isEmpty ? Color(fromString: widget.valuecolor) : .secondary) + .foregroundColor(!widget.valuecolor.isEmpty ? Color(widget.valuecolor) : .secondary) } } } diff --git a/openHABWatch/Views/Utils/TextLabelView.swift b/openHABWatch/Views/Utils/TextLabelView.swift index 10729846e..74c77d173 100644 --- a/openHABWatch/Views/Utils/TextLabelView.swift +++ b/openHABWatch/Views/Utils/TextLabelView.swift @@ -9,6 +9,7 @@ // // SPDX-License-Identifier: EPL-2.0 +import CommonUI import OpenHABCore import SwiftUI From 7de42fab95334d33b5082aab5c56b69ea4506647 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Sat, 12 Jul 2025 09:56:54 +0200 Subject: [PATCH 242/476] Ported the UIKit tableView:didSelectRowAt functionality to SwiftUI Renaming of row views Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- AGENT.md | 1 + openHAB.xcodeproj/project.pbxproj | 136 +++++++++--------- openHAB/SitemapPageViewModel.swift | 10 ++ ...ViewFactory.swift => RowViewFactory.swift} | 36 ++--- ...kerView.swift => ColorPickerRowView.swift} | 2 +- ...iew.swift => DatePickerInputRowView.swift} | 2 +- ...GenericView.swift => GenericRowView.swift} | 2 +- ...dgetImageView.swift => ImageRowView.swift} | 2 +- .../{WidgetMapView.swift => MapRowView.swift} | 2 +- ...rView.swift => RollershutterRowView.swift} | 4 +- ...entedView.swift => SegmentedRowView.swift} | 4 +- ...ctionView.swift => SelectionRowView.swift} | 2 +- ...tpointView.swift => SetpointRowView.swift} | 4 +- ...etSliderView.swift => SliderRowView.swift} | 2 +- ...ew.swift => SliderWithSwitchRowView.swift} | 2 +- ...etSwitchView.swift => SwitchRowView.swift} | 4 +- ...InputView.swift => TextInputRowView.swift} | 2 +- ...WidgetTextView.swift => TextRowView.swift} | 4 +- ...dgetVideoView.swift => VideoRowView.swift} | 2 +- .../{WidgetWebView.swift => WebRowView.swift} | 4 +- openHAB/SwiftUI/SitemapPageView.swift | 55 ++++++- 21 files changed, 171 insertions(+), 111 deletions(-) rename openHAB/SwiftUI/{WidgetViewFactory.swift => RowViewFactory.swift} (62%) rename openHAB/SwiftUI/Rows/{WidgetColorPickerView.swift => ColorPickerRowView.swift} (98%) rename openHAB/SwiftUI/Rows/{WidgetDatePickerInputView.swift => DatePickerInputRowView.swift} (98%) rename openHAB/SwiftUI/Rows/{WidgetGenericView.swift => GenericRowView.swift} (97%) rename openHAB/SwiftUI/Rows/{WidgetImageView.swift => ImageRowView.swift} (98%) rename openHAB/SwiftUI/Rows/{WidgetMapView.swift => MapRowView.swift} (98%) rename openHAB/SwiftUI/Rows/{WidgetRollershutterView.swift => RollershutterRowView.swift} (96%) rename openHAB/SwiftUI/Rows/{WidgetSegmentedView.swift => SegmentedRowView.swift} (96%) rename openHAB/SwiftUI/Rows/{WidgetSelectionView.swift => SelectionRowView.swift} (98%) rename openHAB/SwiftUI/Rows/{WidgetSetpointView.swift => SetpointRowView.swift} (98%) rename openHAB/SwiftUI/Rows/{WidgetSliderView.swift => SliderRowView.swift} (97%) rename openHAB/SwiftUI/Rows/{WidgetSliderWithSwitchView.swift => SliderWithSwitchRowView.swift} (98%) rename openHAB/SwiftUI/Rows/{WidgetSwitchView.swift => SwitchRowView.swift} (97%) rename openHAB/SwiftUI/Rows/{WidgetTextInputView.swift => TextInputRowView.swift} (98%) rename openHAB/SwiftUI/Rows/{WidgetTextView.swift => TextRowView.swift} (94%) rename openHAB/SwiftUI/Rows/{WidgetVideoView.swift => VideoRowView.swift} (98%) rename openHAB/SwiftUI/Rows/{WidgetWebView.swift => WebRowView.swift} (96%) diff --git a/AGENT.md b/AGENT.md index 70e38777c..7e4efe4f4 100644 --- a/AGENT.md +++ b/AGENT.md @@ -22,3 +22,4 @@ - Use SFSafeSymbols for SF Symbols, avoid force unwrapping, prefer optionals - TableViewCell pattern: GenericUITableViewCell subclasses for sitemap widgets - Error handling: Result types in OpenHABCore, UIKit error alerts in main app +- Avoid trailing closure syntax when passing multiple closures (use parentheses for all closures to prevent multiple_closures_with_trailing_closure warnings) diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index dca21a607..7477aec27 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -88,18 +88,18 @@ DA2E0B0E23DCC153009B0A99 /* MapView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2E0B0D23DCC152009B0A99 /* MapView.swift */; }; DA2E0B1023DCC439009B0A99 /* MapViewRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2E0B0F23DCC439009B0A99 /* MapViewRow.swift */; }; DA32D1B42C8C98C40018D974 /* IconWithAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA32D1B32C8C98C40018D974 /* IconWithAction.swift */; }; - DA35E2B02E1EDB86003987BB /* WidgetSetpointView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA35E2AF2E1EDB86003987BB /* WidgetSetpointView.swift */; }; - DA35E2BD2E1EEA9D003987BB /* WidgetMapView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA35E2B52E1EEA9D003987BB /* WidgetMapView.swift */; }; - DA35E2BE2E1EEA9D003987BB /* WidgetDatePickerInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA35E2B32E1EEA9D003987BB /* WidgetDatePickerInputView.swift */; }; - DA35E2BF2E1EEA9D003987BB /* WidgetWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA35E2BC2E1EEA9D003987BB /* WidgetWebView.swift */; }; - DA35E2C02E1EEA9D003987BB /* WidgetColorPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA35E2B22E1EEA9D003987BB /* WidgetColorPickerView.swift */; }; - DA35E2C12E1EEA9D003987BB /* WidgetSliderWithSwitchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA35E2B92E1EEA9D003987BB /* WidgetSliderWithSwitchView.swift */; }; - DA35E2C22E1EEA9D003987BB /* WidgetTextInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA35E2BA2E1EEA9D003987BB /* WidgetTextInputView.swift */; }; - DA35E2C32E1EEA9D003987BB /* WidgetImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA35E2B42E1EEA9D003987BB /* WidgetImageView.swift */; }; - DA35E2C42E1EEA9D003987BB /* WidgetSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA35E2B82E1EEA9D003987BB /* WidgetSelectionView.swift */; }; - DA35E2C52E1EEA9D003987BB /* WidgetRollershutterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA35E2B62E1EEA9D003987BB /* WidgetRollershutterView.swift */; }; - DA35E2C62E1EEA9D003987BB /* WidgetSegmentedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA35E2B72E1EEA9D003987BB /* WidgetSegmentedView.swift */; }; - DA35E2C72E1EEA9D003987BB /* WidgetVideoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA35E2BB2E1EEA9D003987BB /* WidgetVideoView.swift */; }; + DA35E2B02E1EDB86003987BB /* SetpointRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA35E2AF2E1EDB86003987BB /* SetpointRowView.swift */; }; + DA35E2BD2E1EEA9D003987BB /* MapRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA35E2B52E1EEA9D003987BB /* MapRowView.swift */; }; + DA35E2BE2E1EEA9D003987BB /* DatePickerInputRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA35E2B32E1EEA9D003987BB /* DatePickerInputRowView.swift */; }; + DA35E2BF2E1EEA9D003987BB /* WebRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA35E2BC2E1EEA9D003987BB /* WebRowView.swift */; }; + DA35E2C02E1EEA9D003987BB /* ColorPickerRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA35E2B22E1EEA9D003987BB /* ColorPickerRowView.swift */; }; + DA35E2C12E1EEA9D003987BB /* SliderWithSwitchRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA35E2B92E1EEA9D003987BB /* SliderWithSwitchRowView.swift */; }; + DA35E2C22E1EEA9D003987BB /* TextInputRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA35E2BA2E1EEA9D003987BB /* TextInputRowView.swift */; }; + DA35E2C32E1EEA9D003987BB /* ImageRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA35E2B42E1EEA9D003987BB /* ImageRowView.swift */; }; + DA35E2C42E1EEA9D003987BB /* SelectionRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA35E2B82E1EEA9D003987BB /* SelectionRowView.swift */; }; + DA35E2C52E1EEA9D003987BB /* RollershutterRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA35E2B62E1EEA9D003987BB /* RollershutterRowView.swift */; }; + DA35E2C62E1EEA9D003987BB /* SegmentedRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA35E2B72E1EEA9D003987BB /* SegmentedRowView.swift */; }; + DA35E2C72E1EEA9D003987BB /* VideoRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA35E2BB2E1EEA9D003987BB /* VideoRowView.swift */; }; DA35E2CB2E1F93AD003987BB /* ImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA35E2CA2E1F93AD003987BB /* ImageView.swift */; }; DA35E2CD2E1F96CA003987BB /* WidgetIconView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA35E2CC2E1F96CA003987BB /* WidgetIconView.swift */; }; DA4642322D7EE6CA006C3908 /* LoggerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4642312D7EE6CA006C3908 /* LoggerView.swift */; }; @@ -157,11 +157,11 @@ DAD0857B2AE4782F001D36BE /* OpenHABWatchUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAD0857A2AE4782F001D36BE /* OpenHABWatchUITests.swift */; }; DAD0857D2AE4782F001D36BE /* OpenHABWatchLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAD0857C2AE4782F001D36BE /* OpenHABWatchLaunchTests.swift */; }; DAD0858B2AE56F0E001D36BE /* OpenHABWatch.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAD0855F2AE47824001D36BE /* OpenHABWatch.swift */; }; - DAEA21D82DBF472D00D54342 /* WidgetViewFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAEA21D72DBF472D00D54342 /* WidgetViewFactory.swift */; }; - DAEA21DA2DBF477E00D54342 /* WidgetSwitchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAEA21D92DBF477E00D54342 /* WidgetSwitchView.swift */; }; - DAEA21DC2DBF47DA00D54342 /* WidgetSliderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAEA21DB2DBF47DA00D54342 /* WidgetSliderView.swift */; }; - DAEA21DE2DBF481300D54342 /* WidgetTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAEA21DD2DBF481300D54342 /* WidgetTextView.swift */; }; - DAEA21E02DBF483E00D54342 /* WidgetGenericView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAEA21DF2DBF483E00D54342 /* WidgetGenericView.swift */; }; + DAEA21D82DBF472D00D54342 /* RowViewFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAEA21D72DBF472D00D54342 /* RowViewFactory.swift */; }; + DAEA21DA2DBF477E00D54342 /* SwitchRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAEA21D92DBF477E00D54342 /* SwitchRowView.swift */; }; + DAEA21DC2DBF47DA00D54342 /* SliderRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAEA21DB2DBF47DA00D54342 /* SliderRowView.swift */; }; + DAEA21DE2DBF481300D54342 /* TextRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAEA21DD2DBF481300D54342 /* TextRowView.swift */; }; + DAEA21E02DBF483E00D54342 /* GenericRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAEA21DF2DBF483E00D54342 /* GenericRowView.swift */; }; DAEAA89D21E6B06400267EA3 /* ReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAEAA89C21E6B06300267EA3 /* ReusableView.swift */; }; DAEAA89F21E6B16600267EA3 /* UITableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAEAA89E21E6B16600267EA3 /* UITableView.swift */; }; DAF0A28B2C56E3A300A14A6A /* RollershutterCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF0A28A2C56E3A300A14A6A /* RollershutterCell.swift */; }; @@ -427,18 +427,18 @@ DA2E0B0D23DCC152009B0A99 /* MapView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MapView.swift; sourceTree = ""; }; DA2E0B0F23DCC439009B0A99 /* MapViewRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapViewRow.swift; sourceTree = ""; }; DA32D1B32C8C98C40018D974 /* IconWithAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconWithAction.swift; sourceTree = ""; }; - DA35E2AF2E1EDB86003987BB /* WidgetSetpointView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetSetpointView.swift; sourceTree = ""; }; - DA35E2B22E1EEA9D003987BB /* WidgetColorPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetColorPickerView.swift; sourceTree = ""; }; - DA35E2B32E1EEA9D003987BB /* WidgetDatePickerInputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetDatePickerInputView.swift; sourceTree = ""; }; - DA35E2B42E1EEA9D003987BB /* WidgetImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetImageView.swift; sourceTree = ""; }; - DA35E2B52E1EEA9D003987BB /* WidgetMapView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetMapView.swift; sourceTree = ""; }; - DA35E2B62E1EEA9D003987BB /* WidgetRollershutterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetRollershutterView.swift; sourceTree = ""; }; - DA35E2B72E1EEA9D003987BB /* WidgetSegmentedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetSegmentedView.swift; sourceTree = ""; }; - DA35E2B82E1EEA9D003987BB /* WidgetSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetSelectionView.swift; sourceTree = ""; }; - DA35E2B92E1EEA9D003987BB /* WidgetSliderWithSwitchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetSliderWithSwitchView.swift; sourceTree = ""; }; - DA35E2BA2E1EEA9D003987BB /* WidgetTextInputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetTextInputView.swift; sourceTree = ""; }; - DA35E2BB2E1EEA9D003987BB /* WidgetVideoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetVideoView.swift; sourceTree = ""; }; - DA35E2BC2E1EEA9D003987BB /* WidgetWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetWebView.swift; sourceTree = ""; }; + DA35E2AF2E1EDB86003987BB /* SetpointRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetpointRowView.swift; sourceTree = ""; }; + DA35E2B22E1EEA9D003987BB /* ColorPickerRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorPickerRowView.swift; sourceTree = ""; }; + DA35E2B32E1EEA9D003987BB /* DatePickerInputRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatePickerInputRowView.swift; sourceTree = ""; }; + DA35E2B42E1EEA9D003987BB /* ImageRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageRowView.swift; sourceTree = ""; }; + DA35E2B52E1EEA9D003987BB /* MapRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapRowView.swift; sourceTree = ""; }; + DA35E2B62E1EEA9D003987BB /* RollershutterRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RollershutterRowView.swift; sourceTree = ""; }; + DA35E2B72E1EEA9D003987BB /* SegmentedRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SegmentedRowView.swift; sourceTree = ""; }; + DA35E2B82E1EEA9D003987BB /* SelectionRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectionRowView.swift; sourceTree = ""; }; + DA35E2B92E1EEA9D003987BB /* SliderWithSwitchRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SliderWithSwitchRowView.swift; sourceTree = ""; }; + DA35E2BA2E1EEA9D003987BB /* TextInputRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextInputRowView.swift; sourceTree = ""; }; + DA35E2BB2E1EEA9D003987BB /* VideoRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoRowView.swift; sourceTree = ""; }; + DA35E2BC2E1EEA9D003987BB /* WebRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebRowView.swift; sourceTree = ""; }; DA35E2CA2E1F93AD003987BB /* ImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageView.swift; sourceTree = ""; }; DA35E2CC2E1F96CA003987BB /* WidgetIconView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetIconView.swift; sourceTree = ""; }; DA4642312D7EE6CA006C3908 /* LoggerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoggerView.swift; sourceTree = ""; }; @@ -495,11 +495,11 @@ DAD488B3287DDDFE00414693 /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = Resources/nb.lproj/Intents.strings; sourceTree = ""; }; DAD488B4287DDDFF00414693 /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = nb.lproj/Localizable.strings; sourceTree = ""; }; DAD488B5287DDDFF00414693 /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = nb.lproj/InfoPlist.strings; sourceTree = ""; }; - DAEA21D72DBF472D00D54342 /* WidgetViewFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetViewFactory.swift; sourceTree = ""; }; - DAEA21D92DBF477E00D54342 /* WidgetSwitchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetSwitchView.swift; sourceTree = ""; }; - DAEA21DB2DBF47DA00D54342 /* WidgetSliderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetSliderView.swift; sourceTree = ""; }; - DAEA21DD2DBF481300D54342 /* WidgetTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetTextView.swift; sourceTree = ""; }; - DAEA21DF2DBF483E00D54342 /* WidgetGenericView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetGenericView.swift; sourceTree = ""; }; + DAEA21D72DBF472D00D54342 /* RowViewFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RowViewFactory.swift; sourceTree = ""; }; + DAEA21D92DBF477E00D54342 /* SwitchRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwitchRowView.swift; sourceTree = ""; }; + DAEA21DB2DBF47DA00D54342 /* SliderRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SliderRowView.swift; sourceTree = ""; }; + DAEA21DD2DBF481300D54342 /* TextRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextRowView.swift; sourceTree = ""; }; + DAEA21DF2DBF483E00D54342 /* GenericRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GenericRowView.swift; sourceTree = ""; }; DAEAA89C21E6B06300267EA3 /* ReusableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReusableView.swift; sourceTree = ""; }; DAEAA89E21E6B16600267EA3 /* UITableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITableView.swift; sourceTree = ""; }; DAF0A28A2C56E3A300A14A6A /* RollershutterCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RollershutterCell.swift; sourceTree = ""; }; @@ -851,7 +851,7 @@ children = ( DA35E2CA2E1F93AD003987BB /* ImageView.swift */, DA64ACA72DBEAD8300294F60 /* SitemapPageView.swift */, - DAEA21D72DBF472D00D54342 /* WidgetViewFactory.swift */, + DAEA21D72DBF472D00D54342 /* RowViewFactory.swift */, DAC949FF2E21A473007E67B7 /* Rows */, ); path = SwiftUI; @@ -897,25 +897,25 @@ DAC949FF2E21A473007E67B7 /* Rows */ = { isa = PBXGroup; children = ( - DA35E2B22E1EEA9D003987BB /* WidgetColorPickerView.swift */, - DA35E2B32E1EEA9D003987BB /* WidgetDatePickerInputView.swift */, + DA35E2B22E1EEA9D003987BB /* ColorPickerRowView.swift */, + DA35E2B32E1EEA9D003987BB /* DatePickerInputRowView.swift */, DAC949FD2E21A2D1007E67B7 /* WidgetFrameView.swift */, - DAEA21DF2DBF483E00D54342 /* WidgetGenericView.swift */, + DAEA21DF2DBF483E00D54342 /* GenericRowView.swift */, DA35E2CC2E1F96CA003987BB /* WidgetIconView.swift */, - DA35E2B42E1EEA9D003987BB /* WidgetImageView.swift */, - DA35E2B52E1EEA9D003987BB /* WidgetMapView.swift */, - DA35E2B62E1EEA9D003987BB /* WidgetRollershutterView.swift */, + DA35E2B42E1EEA9D003987BB /* ImageRowView.swift */, + DA35E2B52E1EEA9D003987BB /* MapRowView.swift */, + DA35E2B62E1EEA9D003987BB /* RollershutterRowView.swift */, DA64ACA92DBEADB000294F60 /* WidgetRow.swift */, - DA35E2B72E1EEA9D003987BB /* WidgetSegmentedView.swift */, - DA35E2B82E1EEA9D003987BB /* WidgetSelectionView.swift */, - DA35E2AF2E1EDB86003987BB /* WidgetSetpointView.swift */, - DAEA21DB2DBF47DA00D54342 /* WidgetSliderView.swift */, - DA35E2B92E1EEA9D003987BB /* WidgetSliderWithSwitchView.swift */, - DAEA21D92DBF477E00D54342 /* WidgetSwitchView.swift */, - DA35E2BA2E1EEA9D003987BB /* WidgetTextInputView.swift */, - DAEA21DD2DBF481300D54342 /* WidgetTextView.swift */, - DA35E2BB2E1EEA9D003987BB /* WidgetVideoView.swift */, - DA35E2BC2E1EEA9D003987BB /* WidgetWebView.swift */, + DA35E2B72E1EEA9D003987BB /* SegmentedRowView.swift */, + DA35E2B82E1EEA9D003987BB /* SelectionRowView.swift */, + DA35E2AF2E1EDB86003987BB /* SetpointRowView.swift */, + DAEA21DB2DBF47DA00D54342 /* SliderRowView.swift */, + DA35E2B92E1EEA9D003987BB /* SliderWithSwitchRowView.swift */, + DAEA21D92DBF477E00D54342 /* SwitchRowView.swift */, + DA35E2BA2E1EEA9D003987BB /* TextInputRowView.swift */, + DAEA21DD2DBF481300D54342 /* TextRowView.swift */, + DA35E2BB2E1EEA9D003987BB /* VideoRowView.swift */, + DA35E2BC2E1EEA9D003987BB /* WebRowView.swift */, ); path = Rows; sourceTree = ""; @@ -1695,11 +1695,11 @@ DF4B84131886DAC400F34902 /* FrameUITableViewCell.swift in Sources */, DF4B84161886EACA00F34902 /* GenericUITableViewCell.swift in Sources */, 935B484625342B8E00E44CF0 /* URL+Static.swift in Sources */, - DAEA21DA2DBF477E00D54342 /* WidgetSwitchView.swift in Sources */, + DAEA21DA2DBF477E00D54342 /* SwitchRowView.swift in Sources */, B7D5ECE121499E55001B0EC6 /* MapViewTableViewCell.swift in Sources */, - DAEA21DC2DBF47DA00D54342 /* WidgetSliderView.swift in Sources */, + DAEA21DC2DBF47DA00D54342 /* SliderRowView.swift in Sources */, DA77E19B2D886D9B007CFF0F /* SingleConnectionSettingsView.swift in Sources */, - DA35E2B02E1EDB86003987BB /* WidgetSetpointView.swift in Sources */, + DA35E2B02E1EDB86003987BB /* SetpointRowView.swift in Sources */, DA35E2CB2E1F93AD003987BB /* ImageView.swift in Sources */, DA6B2EF52C89F8F200DF77CF /* ColorPickerView.swift in Sources */, DA4800142D836892009CF127 /* ConnectionSettingsView.swift in Sources */, @@ -1712,25 +1712,25 @@ DA64ACA82DBEAD8300294F60 /* SitemapPageView.swift in Sources */, DA4800212D839D3A009CF127 /* AnimatedSecureTextField.swift in Sources */, DAC131112DA3213100075AE2 /* SetSwitchStateIntentHandlerTests.swift in Sources */, - DA35E2BD2E1EEA9D003987BB /* WidgetMapView.swift in Sources */, - DA35E2BE2E1EEA9D003987BB /* WidgetDatePickerInputView.swift in Sources */, + DA35E2BD2E1EEA9D003987BB /* MapRowView.swift in Sources */, + DA35E2BE2E1EEA9D003987BB /* DatePickerInputRowView.swift in Sources */, DAC949FE2E21A2D1007E67B7 /* WidgetFrameView.swift in Sources */, - DA35E2BF2E1EEA9D003987BB /* WidgetWebView.swift in Sources */, - DA35E2C02E1EEA9D003987BB /* WidgetColorPickerView.swift in Sources */, - DA35E2C12E1EEA9D003987BB /* WidgetSliderWithSwitchView.swift in Sources */, - DA35E2C22E1EEA9D003987BB /* WidgetTextInputView.swift in Sources */, - DA35E2C32E1EEA9D003987BB /* WidgetImageView.swift in Sources */, - DA35E2C42E1EEA9D003987BB /* WidgetSelectionView.swift in Sources */, - DA35E2C52E1EEA9D003987BB /* WidgetRollershutterView.swift in Sources */, - DA35E2C62E1EEA9D003987BB /* WidgetSegmentedView.swift in Sources */, - DA35E2C72E1EEA9D003987BB /* WidgetVideoView.swift in Sources */, + DA35E2BF2E1EEA9D003987BB /* WebRowView.swift in Sources */, + DA35E2C02E1EEA9D003987BB /* ColorPickerRowView.swift in Sources */, + DA35E2C12E1EEA9D003987BB /* SliderWithSwitchRowView.swift in Sources */, + DA35E2C22E1EEA9D003987BB /* TextInputRowView.swift in Sources */, + DA35E2C32E1EEA9D003987BB /* ImageRowView.swift in Sources */, + DA35E2C42E1EEA9D003987BB /* SelectionRowView.swift in Sources */, + DA35E2C52E1EEA9D003987BB /* RollershutterRowView.swift in Sources */, + DA35E2C62E1EEA9D003987BB /* SegmentedRowView.swift in Sources */, + DA35E2C72E1EEA9D003987BB /* VideoRowView.swift in Sources */, DA35E2CD2E1F96CA003987BB /* WidgetIconView.swift in Sources */, DAC65FC7236EDF3900F4501E /* SpinnerViewController.swift in Sources */, DA9F81872C85020F00B47B72 /* RTFTextView.swift in Sources */, DA6B2EF12C87B59000DF77CF /* NotificationsView.swift in Sources */, DA50C7BF2B0A65300009F716 /* SliderWithSwitchSupportUITableViewCell.swift in Sources */, DA5ED9BE2C850955004875E0 /* ClientCertificatesViewModel.swift in Sources */, - DAEA21D82DBF472D00D54342 /* WidgetViewFactory.swift in Sources */, + DAEA21D82DBF472D00D54342 /* RowViewFactory.swift in Sources */, DA64ACAA2DBEADB000294F60 /* WidgetRow.swift in Sources */, DA21EAE22339621C001AB415 /* Throttler.swift in Sources */, DAF4F6C0222734D300C24876 /* NewImageUITableViewCell.swift in Sources */, @@ -1744,11 +1744,11 @@ DA2AEBA02D92FB6500897D80 /* NoIconDisplayableCell.swift in Sources */, DA2AEB702D92CF3E00897D80 /* UITableViewCellExtension.swift in Sources */, DA4800162D836EF0009CF127 /* MainUISettingsView.swift in Sources */, - DAEA21DE2DBF481300D54342 /* WidgetTextView.swift in Sources */, + DAEA21DE2DBF481300D54342 /* TextRowView.swift in Sources */, DAEAA89F21E6B16600267EA3 /* UITableView.swift in Sources */, 2F55E7BB2DEE447700EC8350 /* SettingsView.swift in Sources */, DA4800182D837221009CF127 /* AboutSettingsView.swift in Sources */, - DAEA21E02DBF483E00D54342 /* WidgetGenericView.swift in Sources */, + DAEA21E02DBF483E00D54342 /* GenericRowView.swift in Sources */, 653B54C2285E714900298ECD /* OpenHABViewController.swift in Sources */, DA2AEB6E2D92BAD800897D80 /* PageLoader.swift in Sources */, DFA16EC118898A8400EDB0BB /* SegmentedUITableViewCell.swift in Sources */, diff --git a/openHAB/SitemapPageViewModel.swift b/openHAB/SitemapPageViewModel.swift index 58df5126b..680485780 100644 --- a/openHAB/SitemapPageViewModel.swift +++ b/openHAB/SitemapPageViewModel.swift @@ -64,6 +64,16 @@ class SitemapPageViewModel: ObservableObject { setupActiveConnectionObserver() } + init(pageUrl: String, title: String) { + loadSettings() + setupActiveConnectionObserver() + // Set the pageId from the URL for navigation + if let urlComponents = URLComponents(string: pageUrl), + let pageIdValue = urlComponents.queryItems?.first(where: { $0.name == "sitemap" })?.value { + pageId = pageIdValue + } + } + func loadSettings() { defaultSitemap = Preferences.currentHomePreferences.defaultSitemap } diff --git a/openHAB/SwiftUI/WidgetViewFactory.swift b/openHAB/SwiftUI/RowViewFactory.swift similarity index 62% rename from openHAB/SwiftUI/WidgetViewFactory.swift rename to openHAB/SwiftUI/RowViewFactory.swift index 99b9d5a8e..e71b65805 100644 --- a/openHAB/SwiftUI/WidgetViewFactory.swift +++ b/openHAB/SwiftUI/RowViewFactory.swift @@ -12,54 +12,54 @@ import OpenHABCore import SwiftUI -enum WidgetViewFactory { +enum RowViewFactory { @MainActor @ViewBuilder static func view(for widget: OpenHABWidget) -> some View { switch widget.type { case .switchWidget: if !widget.mappings.isEmpty { - WidgetSegmentedView(widget: widget) + SegmentedRowView(widget: widget) } else if widget.item?.isOfTypeOrGroupType(.switchItem) ?? false { - WidgetSwitchView(widget: widget) + SwitchRowView(widget: widget) } else if widget.item?.isOfTypeOrGroupType(.rollershutter) ?? false { - WidgetRollershutterView(widget: widget) + RollershutterRowView(widget: widget) } else if !widget.mappingsOrItemOptions.isEmpty { - WidgetSegmentedView(widget: widget) + SegmentedRowView(widget: widget) } else { - WidgetSwitchView(widget: widget) + SwitchRowView(widget: widget) } case .slider: if widget.switchSupport { - WidgetSliderWithSwitchView(widget: widget) + SliderWithSwitchRowView(widget: widget) } else { - WidgetSliderView(widget: widget) + SliderRowView(widget: widget) } case .input: if [.date, .time, .datetime].contains(widget.inputHint) { - WidgetDatePickerInputView(widget: widget) + DatePickerInputRowView(widget: widget) } else { - WidgetTextInputView(widget: widget) + TextInputRowView(widget: widget) } case .text: - WidgetTextView(widget: widget) + TextRowView(widget: widget) case .frame: WidgetFrameView(widget: widget) case .setpoint: - WidgetSetpointView(widget: widget) + SetpointRowView(widget: widget) case .selection: - WidgetSelectionView(widget: widget) + SelectionRowView(widget: widget) case .colorpicker: - WidgetColorPickerView(widget: widget) + ColorPickerRowView(widget: widget) case .image, .chart: - WidgetImageView(widget: widget) + ImageRowView(widget: widget) case .video: - WidgetVideoView(widget: widget) + VideoRowView(widget: widget) case .webview: WidgetWebViewContainer(widget: widget) case .mapview: - WidgetMapView(widget: widget) + MapRowView(widget: widget) case .group, .defaultWidget, .unknown: - WidgetGenericView(widget: widget) + GenericRowView(widget: widget) } } } diff --git a/openHAB/SwiftUI/Rows/WidgetColorPickerView.swift b/openHAB/SwiftUI/Rows/ColorPickerRowView.swift similarity index 98% rename from openHAB/SwiftUI/Rows/WidgetColorPickerView.swift rename to openHAB/SwiftUI/Rows/ColorPickerRowView.swift index 529b7bacd..cd59e24b9 100644 --- a/openHAB/SwiftUI/Rows/WidgetColorPickerView.swift +++ b/openHAB/SwiftUI/Rows/ColorPickerRowView.swift @@ -14,7 +14,7 @@ import OpenHABCore import os.log import SwiftUI -struct WidgetColorPickerView: View { +struct ColorPickerRowView: View { @ObservedObject var widget: OpenHABWidget @State private var selectedColor: Color = .white diff --git a/openHAB/SwiftUI/Rows/WidgetDatePickerInputView.swift b/openHAB/SwiftUI/Rows/DatePickerInputRowView.swift similarity index 98% rename from openHAB/SwiftUI/Rows/WidgetDatePickerInputView.swift rename to openHAB/SwiftUI/Rows/DatePickerInputRowView.swift index 4c1a776a8..ccf2a1f13 100644 --- a/openHAB/SwiftUI/Rows/WidgetDatePickerInputView.swift +++ b/openHAB/SwiftUI/Rows/DatePickerInputRowView.swift @@ -14,7 +14,7 @@ import OpenHABCore import os.log import SwiftUI -struct WidgetDatePickerInputView: View { +struct DatePickerInputRowView: View { @ObservedObject var widget: OpenHABWidget @State private var selectedDate = Date() diff --git a/openHAB/SwiftUI/Rows/WidgetGenericView.swift b/openHAB/SwiftUI/Rows/GenericRowView.swift similarity index 97% rename from openHAB/SwiftUI/Rows/WidgetGenericView.swift rename to openHAB/SwiftUI/Rows/GenericRowView.swift index 34fbbfab2..227a861d6 100644 --- a/openHAB/SwiftUI/Rows/WidgetGenericView.swift +++ b/openHAB/SwiftUI/Rows/GenericRowView.swift @@ -12,7 +12,7 @@ import OpenHABCore import SwiftUI -struct WidgetGenericView: View { +struct GenericRowView: View { @ObservedObject var widget: OpenHABWidget var body: some View { HStack { diff --git a/openHAB/SwiftUI/Rows/WidgetImageView.swift b/openHAB/SwiftUI/Rows/ImageRowView.swift similarity index 98% rename from openHAB/SwiftUI/Rows/WidgetImageView.swift rename to openHAB/SwiftUI/Rows/ImageRowView.swift index af6614ec9..9b2382db6 100644 --- a/openHAB/SwiftUI/Rows/WidgetImageView.swift +++ b/openHAB/SwiftUI/Rows/ImageRowView.swift @@ -14,7 +14,7 @@ import Kingfisher import OpenHABCore import SwiftUI -struct WidgetImageView: View { +struct ImageRowView: View { @ObservedObject var widget: OpenHABWidget private var imageURL: URL? { diff --git a/openHAB/SwiftUI/Rows/WidgetMapView.swift b/openHAB/SwiftUI/Rows/MapRowView.swift similarity index 98% rename from openHAB/SwiftUI/Rows/WidgetMapView.swift rename to openHAB/SwiftUI/Rows/MapRowView.swift index a6f6e075b..f6cd0607a 100644 --- a/openHAB/SwiftUI/Rows/WidgetMapView.swift +++ b/openHAB/SwiftUI/Rows/MapRowView.swift @@ -15,7 +15,7 @@ import MapKit import OpenHABCore import SwiftUI -struct WidgetMapView: View { +struct MapRowView: View { @ObservedObject var widget: OpenHABWidget @State private var region = MKCoordinateRegion( center: CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194), diff --git a/openHAB/SwiftUI/Rows/WidgetRollershutterView.swift b/openHAB/SwiftUI/Rows/RollershutterRowView.swift similarity index 96% rename from openHAB/SwiftUI/Rows/WidgetRollershutterView.swift rename to openHAB/SwiftUI/Rows/RollershutterRowView.swift index 22a08321d..3b58b76b2 100644 --- a/openHAB/SwiftUI/Rows/WidgetRollershutterView.swift +++ b/openHAB/SwiftUI/Rows/RollershutterRowView.swift @@ -15,7 +15,7 @@ import os.log import SFSafeSymbols import SwiftUI -struct WidgetRollershutterView: View { +struct RollershutterRowView: View { @ObservedObject var widget: OpenHABWidget private let logger = Logger(subsystem: "org.openhab", category: "WidgetRollershutterView") @@ -71,5 +71,5 @@ struct WidgetRollershutterView: View { #Preview { let widget = PreviewConstants.openHABSitemapPage!.widgets[5] - WidgetRollershutterView(widget: widget) + RollershutterRowView(widget: widget) } diff --git a/openHAB/SwiftUI/Rows/WidgetSegmentedView.swift b/openHAB/SwiftUI/Rows/SegmentedRowView.swift similarity index 96% rename from openHAB/SwiftUI/Rows/WidgetSegmentedView.swift rename to openHAB/SwiftUI/Rows/SegmentedRowView.swift index a1ae6219a..21573af63 100644 --- a/openHAB/SwiftUI/Rows/WidgetSegmentedView.swift +++ b/openHAB/SwiftUI/Rows/SegmentedRowView.swift @@ -14,7 +14,7 @@ import OpenHABCore import os.log import SwiftUI -struct WidgetSegmentedView: View { +struct SegmentedRowView: View { @ObservedObject var widget: OpenHABWidget private let logger = Logger(subsystem: "org.openhab", category: "WidgetSegmentedView") @@ -61,5 +61,5 @@ struct WidgetSegmentedView: View { #Preview { let widget = PreviewConstants.openHABSitemapPage!.widgets[4] - WidgetSegmentedView(widget: widget) + SegmentedRowView(widget: widget) } diff --git a/openHAB/SwiftUI/Rows/WidgetSelectionView.swift b/openHAB/SwiftUI/Rows/SelectionRowView.swift similarity index 98% rename from openHAB/SwiftUI/Rows/WidgetSelectionView.swift rename to openHAB/SwiftUI/Rows/SelectionRowView.swift index b48ad670b..e6fc3955c 100644 --- a/openHAB/SwiftUI/Rows/WidgetSelectionView.swift +++ b/openHAB/SwiftUI/Rows/SelectionRowView.swift @@ -14,7 +14,7 @@ import OpenHABCore import os.log import SwiftUI -struct WidgetSelectionView: View { +struct SelectionRowView: View { @ObservedObject var widget: OpenHABWidget @State private var selectedIndex = 0 diff --git a/openHAB/SwiftUI/Rows/WidgetSetpointView.swift b/openHAB/SwiftUI/Rows/SetpointRowView.swift similarity index 98% rename from openHAB/SwiftUI/Rows/WidgetSetpointView.swift rename to openHAB/SwiftUI/Rows/SetpointRowView.swift index 59a79dbe7..b6c6cd82c 100644 --- a/openHAB/SwiftUI/Rows/WidgetSetpointView.swift +++ b/openHAB/SwiftUI/Rows/SetpointRowView.swift @@ -15,7 +15,7 @@ import os.log import SFSafeSymbols import SwiftUI -struct WidgetSetpointView: View { +struct SetpointRowView: View { @ObservedObject var widget: OpenHABWidget private let logger = Logger(subsystem: "org.openhab", category: "WidgetSetpointView") @@ -116,5 +116,5 @@ struct WidgetSetpointView: View { #Preview { let widget = PreviewConstants.openHABSitemapPage!.widgets[3] - WidgetSetpointView(widget: widget) + SetpointRowView(widget: widget) } diff --git a/openHAB/SwiftUI/Rows/WidgetSliderView.swift b/openHAB/SwiftUI/Rows/SliderRowView.swift similarity index 97% rename from openHAB/SwiftUI/Rows/WidgetSliderView.swift rename to openHAB/SwiftUI/Rows/SliderRowView.swift index 4a5d75a08..863f187ea 100644 --- a/openHAB/SwiftUI/Rows/WidgetSliderView.swift +++ b/openHAB/SwiftUI/Rows/SliderRowView.swift @@ -12,7 +12,7 @@ import OpenHABCore import SwiftUI -struct WidgetSliderView: View { +struct SliderRowView: View { @ObservedObject var widget: OpenHABWidget // Example: assuming widget has a numeric value as text var currentValue: Double { diff --git a/openHAB/SwiftUI/Rows/WidgetSliderWithSwitchView.swift b/openHAB/SwiftUI/Rows/SliderWithSwitchRowView.swift similarity index 98% rename from openHAB/SwiftUI/Rows/WidgetSliderWithSwitchView.swift rename to openHAB/SwiftUI/Rows/SliderWithSwitchRowView.swift index d5e67bb94..9f84a8025 100644 --- a/openHAB/SwiftUI/Rows/WidgetSliderWithSwitchView.swift +++ b/openHAB/SwiftUI/Rows/SliderWithSwitchRowView.swift @@ -14,7 +14,7 @@ import OpenHABCore import os.log import SwiftUI -struct WidgetSliderWithSwitchView: View { +struct SliderWithSwitchRowView: View { @ObservedObject var widget: OpenHABWidget @State private var sliderValue: Double = 0 diff --git a/openHAB/SwiftUI/Rows/WidgetSwitchView.swift b/openHAB/SwiftUI/Rows/SwitchRowView.swift similarity index 97% rename from openHAB/SwiftUI/Rows/WidgetSwitchView.swift rename to openHAB/SwiftUI/Rows/SwitchRowView.swift index 2655dbf19..9dddff0d3 100644 --- a/openHAB/SwiftUI/Rows/WidgetSwitchView.swift +++ b/openHAB/SwiftUI/Rows/SwitchRowView.swift @@ -14,7 +14,7 @@ import OpenHABCore import os.log import SwiftUI -struct WidgetSwitchView: View { +struct SwitchRowView: View { @ObservedObject var widget: OpenHABWidget private let logger = Logger(subsystem: "org.openhab", category: "WidgetSwitchView") @@ -72,5 +72,5 @@ struct WidgetSwitchView: View { #Preview { let widget = PreviewConstants.openHABSitemapPage!.widgets[2] - WidgetSwitchView(widget: widget) + SwitchRowView(widget: widget) } diff --git a/openHAB/SwiftUI/Rows/WidgetTextInputView.swift b/openHAB/SwiftUI/Rows/TextInputRowView.swift similarity index 98% rename from openHAB/SwiftUI/Rows/WidgetTextInputView.swift rename to openHAB/SwiftUI/Rows/TextInputRowView.swift index 6e612bc60..a7268c6a0 100644 --- a/openHAB/SwiftUI/Rows/WidgetTextInputView.swift +++ b/openHAB/SwiftUI/Rows/TextInputRowView.swift @@ -14,7 +14,7 @@ import OpenHABCore import os.log import SwiftUI -struct WidgetTextInputView: View { +struct TextInputRowView: View { @ObservedObject var widget: OpenHABWidget @State private var inputText = "" @FocusState private var isTextFieldFocused: Bool diff --git a/openHAB/SwiftUI/Rows/WidgetTextView.swift b/openHAB/SwiftUI/Rows/TextRowView.swift similarity index 94% rename from openHAB/SwiftUI/Rows/WidgetTextView.swift rename to openHAB/SwiftUI/Rows/TextRowView.swift index b34e7169f..79c854d19 100644 --- a/openHAB/SwiftUI/Rows/WidgetTextView.swift +++ b/openHAB/SwiftUI/Rows/TextRowView.swift @@ -12,7 +12,7 @@ import OpenHABCore import SwiftUI -struct WidgetTextView: View { +struct TextRowView: View { @ObservedObject var widget: OpenHABWidget var body: some View { @@ -38,5 +38,5 @@ struct WidgetTextView: View { #Preview { let widget = PreviewConstants.openHABSitemapPage!.widgets[3] - WidgetTextView(widget: widget) + TextRowView(widget: widget) } diff --git a/openHAB/SwiftUI/Rows/WidgetVideoView.swift b/openHAB/SwiftUI/Rows/VideoRowView.swift similarity index 98% rename from openHAB/SwiftUI/Rows/WidgetVideoView.swift rename to openHAB/SwiftUI/Rows/VideoRowView.swift index e52e6548a..389014b38 100644 --- a/openHAB/SwiftUI/Rows/WidgetVideoView.swift +++ b/openHAB/SwiftUI/Rows/VideoRowView.swift @@ -14,7 +14,7 @@ import CommonUI import OpenHABCore import SwiftUI -struct WidgetVideoView: View { +struct VideoRowView: View { @ObservedObject var widget: OpenHABWidget @State private var player: AVPlayer? diff --git a/openHAB/SwiftUI/Rows/WidgetWebView.swift b/openHAB/SwiftUI/Rows/WebRowView.swift similarity index 96% rename from openHAB/SwiftUI/Rows/WidgetWebView.swift rename to openHAB/SwiftUI/Rows/WebRowView.swift index 391aab756..e2159527e 100644 --- a/openHAB/SwiftUI/Rows/WidgetWebView.swift +++ b/openHAB/SwiftUI/Rows/WebRowView.swift @@ -24,7 +24,7 @@ struct WidgetWebViewContainer: View { .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) } - WidgetWebView(widget: widget) + WebRowView(widget: widget) .frame(height: 300) .cornerRadius(8) @@ -37,7 +37,7 @@ struct WidgetWebViewContainer: View { } } -struct WidgetWebView: UIViewRepresentable { +struct WebRowView: UIViewRepresentable { class Coordinator: NSObject, WKNavigationDelegate { func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: any Error) { print("WebView failed to load: \(error.localizedDescription)") diff --git a/openHAB/SwiftUI/SitemapPageView.swift b/openHAB/SwiftUI/SitemapPageView.swift index 33f66a4cf..8f16710a2 100644 --- a/openHAB/SwiftUI/SitemapPageView.swift +++ b/openHAB/SwiftUI/SitemapPageView.swift @@ -14,13 +14,40 @@ import SwiftUI struct SitemapPageView: View { @StateObject public var viewModel = SitemapPageViewModel() + @State private var showSelectionSheet = false + @State private var showInputAlert = false + @State private var selectedWidget: OpenHABWidget? var body: some View { List(viewModel.relevantWidgets) { widget in - WidgetViewFactory.view(for: widget) - .onTapGesture { - viewModel.widgetTapped(widget) + Group { + if let linkedPage = widget.linkedPage { + NavigationLink(destination: SitemapPageView(viewModel: SitemapPageViewModel(pageUrl: linkedPage.link, title: linkedPage.title))) { + RowViewFactory.view(for: widget) + } + } else if widget.type == .selection { + Button { + selectedWidget = widget + showSelectionSheet = true + } label: { + RowViewFactory.view(for: widget) + } + .buttonStyle(PlainButtonStyle()) + } else if widget.type == .input { + Button { + selectedWidget = widget + showInputAlert = true + } label: { + RowViewFactory.view(for: widget) + } + .buttonStyle(PlainButtonStyle()) + } else { + RowViewFactory.view(for: widget) + .onTapGesture { + viewModel.widgetTapped(widget) + } } + } } .navigationTitle(viewModel.pageTitle) .searchable(text: $viewModel.searchText) @@ -33,6 +60,28 @@ struct SitemapPageView: View { .onChange(of: viewModel.networkTracker.activeConnection) { activeConnection in viewModel.handleActiveConnectionChange(activeConnection) } + .sheet(isPresented: $showSelectionSheet) { + if let widget = selectedWidget { + SelectionView( + mappings: widget.mappingsOrItemOptions, + selectionItemState: widget.item?.state + ) { selectedMappingIndex in + let selectedMapping = widget.mappingsOrItemOptions[selectedMappingIndex] + viewModel.sendCommand(widget.item, commandToSend: selectedMapping.command) + showSelectionSheet = false + } + } + } + .alert("Input", isPresented: $showInputAlert) { + if let widget = selectedWidget { + TextField("Enter value", text: .constant(widget.state)) + Button("Cancel", role: .cancel) {} + Button("OK") { + // Handle input submission + showInputAlert = false + } + } + } .alert("Error", isPresented: .constant(viewModel.error != nil), actions: { Button("OK", role: .cancel) {} }, message: { From b5b07d318920ff6595521eb04003bd9664c8537e Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Sat, 12 Jul 2025 10:35:24 +0200 Subject: [PATCH 243/476] draft support for Colortemperaturepicker Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../OpenHABCore/Model/OpenHABWidget.swift | 1 + openHAB.xcodeproj/project.pbxproj | 4 + openHAB/Cells/WidgetCellProvider.swift | 2 +- openHAB/SitemapPageViewModel.swift | 8 +- openHAB/SwiftUI/RowViewFactory.swift | 2 + .../Rows/ColorTemperaturePickerRowView.swift | 208 ++++++++++++++++++ 6 files changed, 220 insertions(+), 5 deletions(-) create mode 100644 openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift index db47942b4..73fa84c24 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift @@ -53,6 +53,7 @@ public class OpenHABWidget: NSObject, MKAnnotation, Identifiable, ObservableObje case text = "Text" case video = "Video" case webview = "Webview" + case colortemperaturepicker = "Colortemperaturepicker" case unknown = "Unknown" } diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index 7477aec27..8a8c51640 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -164,6 +164,7 @@ DAEA21E02DBF483E00D54342 /* GenericRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAEA21DF2DBF483E00D54342 /* GenericRowView.swift */; }; DAEAA89D21E6B06400267EA3 /* ReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAEAA89C21E6B06300267EA3 /* ReusableView.swift */; }; DAEAA89F21E6B16600267EA3 /* UITableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAEAA89E21E6B16600267EA3 /* UITableView.swift */; }; + DAEE35072E224F6000B4A7F1 /* ColorTemperaturePickerRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAEE35062E224F6000B4A7F1 /* ColorTemperaturePickerRowView.swift */; }; DAF0A28B2C56E3A300A14A6A /* RollershutterCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF0A28A2C56E3A300A14A6A /* RollershutterCell.swift */; }; DAF0A28D2C56EF8900A14A6A /* SetpointCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF0A28C2C56EF8900A14A6A /* SetpointCell.swift */; }; DAF0A28F2C56F1EE00A14A6A /* ColorPickerCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF0A28E2C56F1EE00A14A6A /* ColorPickerCell.swift */; }; @@ -502,6 +503,7 @@ DAEA21DF2DBF483E00D54342 /* GenericRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GenericRowView.swift; sourceTree = ""; }; DAEAA89C21E6B06300267EA3 /* ReusableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReusableView.swift; sourceTree = ""; }; DAEAA89E21E6B16600267EA3 /* UITableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITableView.swift; sourceTree = ""; }; + DAEE35062E224F6000B4A7F1 /* ColorTemperaturePickerRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorTemperaturePickerRowView.swift; sourceTree = ""; }; DAF0A28A2C56E3A300A14A6A /* RollershutterCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RollershutterCell.swift; sourceTree = ""; }; DAF0A28C2C56EF8900A14A6A /* SetpointCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetpointCell.swift; sourceTree = ""; }; DAF0A28E2C56F1EE00A14A6A /* ColorPickerCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorPickerCell.swift; sourceTree = ""; }; @@ -916,6 +918,7 @@ DAEA21DD2DBF481300D54342 /* TextRowView.swift */, DA35E2BB2E1EEA9D003987BB /* VideoRowView.swift */, DA35E2BC2E1EEA9D003987BB /* WebRowView.swift */, + DAEE35062E224F6000B4A7F1 /* ColorTemperaturePickerRowView.swift */, ); path = Rows; sourceTree = ""; @@ -1701,6 +1704,7 @@ DA77E19B2D886D9B007CFF0F /* SingleConnectionSettingsView.swift in Sources */, DA35E2B02E1EDB86003987BB /* SetpointRowView.swift in Sources */, DA35E2CB2E1F93AD003987BB /* ImageView.swift in Sources */, + DAEE35072E224F6000B4A7F1 /* ColorTemperaturePickerRowView.swift in Sources */, DA6B2EF52C89F8F200DF77CF /* ColorPickerView.swift in Sources */, DA4800142D836892009CF127 /* ConnectionSettingsView.swift in Sources */, 2F6412EE2CE494A80039FB28 /* DatePickerUITableViewCell.swift in Sources */, diff --git a/openHAB/Cells/WidgetCellProvider.swift b/openHAB/Cells/WidgetCellProvider.swift index b2ac83d54..1c3772d7f 100644 --- a/openHAB/Cells/WidgetCellProvider.swift +++ b/openHAB/Cells/WidgetCellProvider.swift @@ -48,7 +48,7 @@ enum WidgetCellFactory { case .video: VideoCellProvider() case .webview: WebViewCellProvider() case .mapview: MapViewCellProvider() - case .group, .text, .defaultWidget, .unknown: + case .group, .text, .defaultWidget, .colortemperaturepicker, .unknown: GenericCellProvider() } } diff --git a/openHAB/SitemapPageViewModel.swift b/openHAB/SitemapPageViewModel.swift index 680485780..3e025581f 100644 --- a/openHAB/SitemapPageViewModel.swift +++ b/openHAB/SitemapPageViewModel.swift @@ -74,6 +74,10 @@ class SitemapPageViewModel: ObservableObject { } } + deinit { + pageHandlingTask?.cancel() + } + func loadSettings() { defaultSitemap = Preferences.currentHomePreferences.defaultSitemap } @@ -240,10 +244,6 @@ class SitemapPageViewModel: ObservableObject { await startPageHandling() } - deinit { - pageHandlingTask?.cancel() - } - private func setupActiveConnectionObserver() { // The @ObservedObject will automatically trigger view updates // We'll handle the connection changes in the view's onChange modifier diff --git a/openHAB/SwiftUI/RowViewFactory.swift b/openHAB/SwiftUI/RowViewFactory.swift index e71b65805..9c611c1cd 100644 --- a/openHAB/SwiftUI/RowViewFactory.swift +++ b/openHAB/SwiftUI/RowViewFactory.swift @@ -58,6 +58,8 @@ enum RowViewFactory { WidgetWebViewContainer(widget: widget) case .mapview: MapRowView(widget: widget) + case .colortemperaturepicker: + ColorTemperaturePickerRowView(widget: widget) case .group, .defaultWidget, .unknown: GenericRowView(widget: widget) } diff --git a/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift b/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift new file mode 100644 index 000000000..ab53020a8 --- /dev/null +++ b/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift @@ -0,0 +1,208 @@ +// Copyright (c) 2010-2025 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 CommonUI +import OpenHABCore +import os.log +import SFSafeSymbols +import SwiftUI + +struct ColorTemperaturePickerRowView: View { + @ObservedObject var widget: OpenHABWidget + @State private var selectedTemperature: Double = 2700 // Default warm white + + private let logger = Logger(subsystem: "org.openhab", category: "ColorTemperaturePickerRowView") + + // Use widget's min/max values, similar to Android implementation + private var minTemperature: Double { + max(widget.minValue, 1000) + } + + private var maxTemperature: Double { + min(widget.maxValue, 10000) + } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + // Label + if let labelText = widget.labelText, !labelText.isEmpty { + Text(labelText) + .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) + } + + // Color temperature slider with gradient background + VStack(spacing: 8) { + HStack { + // Warm indicator + Image(systemSymbol: .sunMinFill) + .foregroundColor(.orange) + .font(.caption) + + // Slider with custom gradient track + ZStack(alignment: .leading) { + // Gradient background representing color temperature spectrum + // Using realistic color temperature colors like Android app + LinearGradient( + colors: colorTemperatureGradient(), + startPoint: .leading, + endPoint: .trailing + ) + .frame(height: 6) + .cornerRadius(3) + + // Actual slider + Slider( + value: $selectedTemperature, + in: minTemperature ... maxTemperature, + step: 100 + ) { _ in + sendTemperatureCommand() + } + .accentColor(.clear) // Hide default slider track + } + + // Cool indicator + Image(systemSymbol: .snowflake) + .foregroundColor(.blue) + .font(.caption) + } + + // Temperature value display + HStack { + Text("\(Int(selectedTemperature))K") + .font(.caption) + .foregroundColor(.secondary) + + Spacer() + + // Temperature description + Text(temperatureDescription) + .font(.caption2) + .foregroundColor(.secondary) + } + } + + // Widget value (current state) + if let labelValue = widget.labelValue, !labelValue.isEmpty { + Text(labelValue) + .font(.caption) + .foregroundColor(widget.valuecolor.isEmpty ? .secondary : Color(fromString: widget.valuecolor)) + } + } + .onAppear { + loadCurrentTemperature() + } + } + + private var temperatureDescription: String { + switch selectedTemperature { + case 1000 ..< 2000: "Candlelight" + case 2000 ..< 2700: "Very Warm" + case 2700 ..< 3000: "Warm White" + case 3000 ..< 4000: "Soft White" + case 4000 ..< 5000: "Cool White" + case 5000 ..< 6500: "Daylight" + case 6500 ..< 8000: "Cool Daylight" + default: "Very Cool" + } + } + + // Generate gradient colors similar to Android implementation + private func colorTemperatureGradient() -> [Color] { + let steps = 20 + let colors = (0 ..< steps).map { step in + let temperature = minTemperature + (maxTemperature - minTemperature) * Double(step) / Double(steps - 1) + return temperatureToColor(temperature) + } + return colors + } + + private func loadCurrentTemperature() { + guard let state = widget.item?.state, !state.isEmpty else { return } + + // Parse color temperature directly from Kelvin value (like Android app) + if let kelvin = Double(state) { + selectedTemperature = kelvin.clamped(to: minTemperature ... maxTemperature) + } + } + + private func sendTemperatureCommand() { + // Send temperature directly as Kelvin value (like Android app) + let command = "\(Int(selectedTemperature))" + + logger.info("Sending color temperature command: \(command)K") + widget.sendCommand(command) + } + + // Convert temperature in Kelvin to approximate RGB color + private func temperatureToColor(_ kelvin: Double) -> Color { + let temp = kelvin / 100 + + var red: Double = 0 + var green: Double = 0 + var blue: Double = 0 + + // Calculate red + if temp <= 66 { + red = 255 + } else { + red = temp - 60 + red = 329.698727446 * pow(red, -0.1332047592) + red = red.clamped(to: 0 ... 255) + } + + // Calculate green + if temp <= 66 { + green = temp + green = 99.4708025861 * log(green) - 161.1195681661 + green = green.clamped(to: 0 ... 255) + } else { + green = temp - 60 + green = 288.1221695283 * pow(green, -0.0755148492) + green = green.clamped(to: 0 ... 255) + } + + // Calculate blue + if temp >= 66 { + blue = 255 + } else if temp <= 19 { + blue = 0 + } else { + blue = temp - 10 + blue = 138.5177312231 * log(blue) - 305.0447927307 + blue = blue.clamped(to: 0 ... 255) + } + + return Color( + red: red / 255.0, + green: green / 255.0, + blue: blue / 255.0 + ) + } +} + +// Helper extension for clamping values +private extension Double { + func clamped(to range: ClosedRange) -> Double { + Swift.min(Swift.max(self, range.lowerBound), range.upperBound) + } +} + +#Preview { + // Create a mock widget for preview + let widget = OpenHABWidget() +// widget.labelText = "Color Temperature" +// widget.minValue = NSNumber(value: 2000) +// widget.maxValue = NSNumber(value: 6500) + + ColorTemperaturePickerRowView(widget: widget) + .padding() +} From 59ca3ac0b5477e232f22b89824455b253ec30131 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Sat, 12 Jul 2025 17:36:03 +0200 Subject: [PATCH 244/476] First visible slider Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../OpenHABCore/Model/OpenHABWidget.swift | 13 +- .../OpenHABCore/Util/DoubleExtension.swift | 4 + openHAB/PreviewConstants.swift | 120 +++++++++++++++++- .../Rows/ColorTemperaturePickerRowView.swift | 14 +- 4 files changed, 134 insertions(+), 17 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift index 73fa84c24..f361e882e 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift @@ -294,8 +294,7 @@ public extension OpenHABWidget { self.icon = icon self.url = url ?? "" self.period = period ?? "" - self.minValue = minValue ?? 0.0 - self.maxValue = maxValue ?? 100.0 + self.step = step ?? 1.0 // Consider a minimal refresh rate of 100 ms, but 0 is special and means 'no refresh' if let refreshVal = refresh, refreshVal > 0 { @@ -320,7 +319,15 @@ public extension OpenHABWidget { self.widgets = widgets // Sanitize minValue, maxValue and step: min <= max, step >= 0 - self.maxValue = max(self.minValue, self.maxValue) + if type != .colortemperaturepicker { + self.minValue = minValue ?? 0.0 + self.maxValue = maxValue ?? 100.0 + self.maxValue = max(self.minValue, self.maxValue) + } else { + self.minValue = minValue ?? 1000.0 + self.maxValue = maxValue ?? 10000.0 + self.maxValue = max(self.minValue, self.maxValue) + } self.step = abs(self.step) self.visibility = visibility ?? true self.switchSupport = switchSupport ?? false diff --git a/OpenHABCore/Sources/OpenHABCore/Util/DoubleExtension.swift b/OpenHABCore/Sources/OpenHABCore/Util/DoubleExtension.swift index 482b0805e..a9379b229 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/DoubleExtension.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/DoubleExtension.swift @@ -20,4 +20,8 @@ public extension Double { numberFormatter.decimalSeparator = "." return numberFormatter.string(from: NSNumber(value: self)) ?? "" } + + var asColorTemperatureInKelvin: Double { + self < 1000 ? 1_000_000 / self : self + } } diff --git a/openHAB/PreviewConstants.swift b/openHAB/PreviewConstants.swift index 44d8ed9a3..8a0d50011 100644 --- a/openHAB/PreviewConstants.swift +++ b/openHAB/PreviewConstants.swift @@ -300,7 +300,125 @@ enum PreviewConstants { ] }, "widgets": [] - } + }, + { + "widgetId": "11", + "type": "Setpoint", + "visibility": true, + "label": "item in seconds [2400.0 s]", + "labelSource": "SITEMAP_WIDGET", + "icon": "", + "staticIcon": false, + "pattern": "%.1f s", + "unit": "s", + "mappings": [], + "minValue": 300, + "maxValue": 3600, + "step": 60, + "item": { + "link": "http://192.168.2.10:8080/rest/items/testTime", + "state": "2400 s", + "stateDescription": { + "pattern": "%.0f %unit%", + "readOnly": false, + "options": [] + }, + "unitSymbol": "s", + "type": "Number:Time", + "name": "testTime", + "label": "", + "category": "", + "tags": [], + "groupNames": [] + }, + "widgets": [] + }, + { + "widgetId": "12", + "type": "Setpoint", + "visibility": true, + "label": "item in minutes [40.0 min]", + "labelSource": "SITEMAP_WIDGET", + "icon": "", + "staticIcon": false, + "pattern": "%.1f min", + "unit": "min", + "mappings": [], + "minValue": 5, + "maxValue": 60, + "step": 5, + "state": "40 min", + "item": { + "link": "http://192.168.2.10:8080/rest/items/testTime", + "state": "2400 s", + "stateDescription": { + "pattern": "%.0f %unit%", + "readOnly": false, + "options": [] + }, + "unitSymbol": "s", + "type": "Number:Time", + "name": "testTime", + "label": "", + "category": "", + "tags": [], + "groupNames": [] + }, + "widgets": [] + }, + { + "widgetId": "13", + "type": "Colortemperaturepicker", + "visibility": true, + "label": "Color Temperature [1 K]", + "labelSource": "SITEMAP_WIDGET", + "icon": "colorwheel", + "staticIcon": true, + "pattern": "%.0f %unit%", + "unit": "K", + "mappings": [], + "item": { + "link": "http://192.168.2.10:8080/rest/items/test_LEDLight_ColorTemp", + "state": "1000.0 K", + "stateDescription": { + "pattern": "%.0f %unit%", + "readOnly": false, + "options": [] + }, + "unitSymbol": "K", + "type": "Number:Temperature", + "name": "test_LEDLight_ColorTemp", + "label": "", + "category": "", + "tags": [], + "groupNames": [] + }, + "widgets": [] + }, + { + "widgetId": "14", + "type": "Slider", + "visibility": true, + "label": "Brightness", + "labelSource": "SITEMAP_WIDGET", + "icon": "", + "staticIcon": false, + "unit": "", + "mappings": [], + "switchSupport": false, + "releaseOnly": false, + "item": { + "link": "http://192.168.2.10:8080/rest/items/test_LEDLight_Brightness", + "state": "NULL", + "type": "Dimmer", + "name": "test_LEDLight_Brightness", + "label": "", + "category": "", + "tags": [], + "groupNames": [] + }, + "widgets": [] + } ] } """.utf8) diff --git a/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift b/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift index ab53020a8..3e0ce6350 100644 --- a/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift +++ b/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift @@ -189,20 +189,8 @@ struct ColorTemperaturePickerRowView: View { } } -// Helper extension for clamping values -private extension Double { - func clamped(to range: ClosedRange) -> Double { - Swift.min(Swift.max(self, range.lowerBound), range.upperBound) - } -} - #Preview { - // Create a mock widget for preview - let widget = OpenHABWidget() -// widget.labelText = "Color Temperature" -// widget.minValue = NSNumber(value: 2000) -// widget.maxValue = NSNumber(value: 6500) - + let widget = PreviewConstants.openHABSitemapPage!.widgets[13] ColorTemperaturePickerRowView(widget: widget) .padding() } From e52a38edf71a4cfa9a610312b4048fae71e12f44 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Sun, 13 Jul 2025 09:57:01 +0200 Subject: [PATCH 245/476] Refactoring ColorTemperature code Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- BuildTools/.swiftlint.yml | 3 + .../Sources/CommonUI/ColorExtension.swift | 20 ++++++ openHAB/PreviewConstants.swift | 4 +- .../Rows/ColorTemperaturePickerRowView.swift | 63 +------------------ 4 files changed, 28 insertions(+), 62 deletions(-) diff --git a/BuildTools/.swiftlint.yml b/BuildTools/.swiftlint.yml index b1de4d19c..bef4ba4d7 100644 --- a/BuildTools/.swiftlint.yml +++ b/BuildTools/.swiftlint.yml @@ -76,7 +76,10 @@ identifier_name: - id - a - b + - g - i + - k + - r - v private_outlet: diff --git a/CommonUI/Sources/CommonUI/ColorExtension.swift b/CommonUI/Sources/CommonUI/ColorExtension.swift index bdd4e3e50..09ded372e 100644 --- a/CommonUI/Sources/CommonUI/ColorExtension.swift +++ b/CommonUI/Sources/CommonUI/ColorExtension.swift @@ -21,3 +21,23 @@ public extension Color { self.init(UIColor(hex: hex)) } } + +public typealias Kelvin = Double + +public extension Color { + init(temperature: Kelvin) { + let components = componentsForColorTemperature(temperature: temperature) + self.init(red: components.r, green: components.g, blue: components.b) + } +} + +// For algorithm see https://web.archive.org/web/20151024031939/http://www.zombieprototypes.com/?p=210 +// Algorithm see http://www.tannerhelland.com/4435/convert-temperature-rgb-algorithm-code/ +// swiftlint:disable:next large_tuple +func componentsForColorTemperature(temperature: Kelvin) -> (r: Double, g: Double, b: Double) { + let k = temperature / 100 + let r = (k <= 66 ? 255 : (329.698727446 * pow(k - 60, -0.1332047592))).clamped(to: 0 ... 255.0) / 255 + let g = (k <= 66 ? (99.4708025861 * log(k) - 161.1195681661) : 288.1221695283 * pow(k - 60, -0.0755148492)).clamped(to: 0 ... 255.0) / 255 + let b = (k >= 66 ? 255 : (k <= 19 ? 0 : 138.5177312231 * log(k - 10) - 305.0447927307)).clamped(to: 0 ... 255.0) / 255 + return (r: r, g: g, b: b) +} diff --git a/openHAB/PreviewConstants.swift b/openHAB/PreviewConstants.swift index 8a0d50011..86f5af570 100644 --- a/openHAB/PreviewConstants.swift +++ b/openHAB/PreviewConstants.swift @@ -370,7 +370,7 @@ enum PreviewConstants { "widgetId": "13", "type": "Colortemperaturepicker", "visibility": true, - "label": "Color Temperature [1 K]", + "label": "Color Temperature [2700 K]", "labelSource": "SITEMAP_WIDGET", "icon": "colorwheel", "staticIcon": true, @@ -379,7 +379,7 @@ enum PreviewConstants { "mappings": [], "item": { "link": "http://192.168.2.10:8080/rest/items/test_LEDLight_ColorTemp", - "state": "1000.0 K", + "state": "2700.0 K", "stateDescription": { "pattern": "%.0f %unit%", "readOnly": false, diff --git a/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift b/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift index 3e0ce6350..03cbf3b79 100644 --- a/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift +++ b/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift @@ -55,7 +55,7 @@ struct ColorTemperaturePickerRowView: View { startPoint: .leading, endPoint: .trailing ) - .frame(height: 6) + .frame(height: 10) .cornerRadius(3) // Actual slider @@ -90,12 +90,6 @@ struct ColorTemperaturePickerRowView: View { } } - // Widget value (current state) - if let labelValue = widget.labelValue, !labelValue.isEmpty { - Text(labelValue) - .font(.caption) - .foregroundColor(widget.valuecolor.isEmpty ? .secondary : Color(fromString: widget.valuecolor)) - } } .onAppear { loadCurrentTemperature() @@ -116,13 +110,8 @@ struct ColorTemperaturePickerRowView: View { } // Generate gradient colors similar to Android implementation - private func colorTemperatureGradient() -> [Color] { - let steps = 20 - let colors = (0 ..< steps).map { step in - let temperature = minTemperature + (maxTemperature - minTemperature) * Double(step) / Double(steps - 1) - return temperatureToColor(temperature) - } - return colors + private func colorTemperatureGradient(steps: Int = 20) -> [Color] { + stride(from: minTemperature, through: maxTemperature, by: (maxTemperature - minTemperature) / Double(steps)).map { Color(temperature: $0) } } private func loadCurrentTemperature() { @@ -141,52 +130,6 @@ struct ColorTemperaturePickerRowView: View { logger.info("Sending color temperature command: \(command)K") widget.sendCommand(command) } - - // Convert temperature in Kelvin to approximate RGB color - private func temperatureToColor(_ kelvin: Double) -> Color { - let temp = kelvin / 100 - - var red: Double = 0 - var green: Double = 0 - var blue: Double = 0 - - // Calculate red - if temp <= 66 { - red = 255 - } else { - red = temp - 60 - red = 329.698727446 * pow(red, -0.1332047592) - red = red.clamped(to: 0 ... 255) - } - - // Calculate green - if temp <= 66 { - green = temp - green = 99.4708025861 * log(green) - 161.1195681661 - green = green.clamped(to: 0 ... 255) - } else { - green = temp - 60 - green = 288.1221695283 * pow(green, -0.0755148492) - green = green.clamped(to: 0 ... 255) - } - - // Calculate blue - if temp >= 66 { - blue = 255 - } else if temp <= 19 { - blue = 0 - } else { - blue = temp - 10 - blue = 138.5177312231 * log(blue) - 305.0447927307 - blue = blue.clamped(to: 0 ... 255) - } - - return Color( - red: red / 255.0, - green: green / 255.0, - blue: blue / 255.0 - ) - } } #Preview { From ccdff76bf9e45c2c3c3695df76412612c54dcd10 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Sun, 13 Jul 2025 12:00:39 +0200 Subject: [PATCH 246/476] Reproduce visualization of UIKit in SwiftUI Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- openHAB.xcodeproj/project.pbxproj | 8 +-- .../Rows/ColorTemperaturePickerRowView.swift | 1 - .../SwiftUI/Rows/RollershutterRowView.swift | 2 +- openHAB/SwiftUI/Rows/SegmentedRowView.swift | 53 +++++++++++++------ openHAB/SwiftUI/Rows/SelectionRowView.swift | 12 ++--- openHAB/SwiftUI/Rows/SetpointRowView.swift | 6 +-- openHAB/SwiftUI/Rows/SwitchRowView.swift | 17 +++--- openHAB/SwiftUI/Rows/TextRowView.swift | 3 +- openHAB/SwiftUI/Rows/WidgetIconView.swift | 24 ++++++--- openHAB/SwiftUI/SitemapPageView.swift | 2 + 10 files changed, 78 insertions(+), 50 deletions(-) diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index 8a8c51640..57dcaa426 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -900,14 +900,12 @@ isa = PBXGroup; children = ( DA35E2B22E1EEA9D003987BB /* ColorPickerRowView.swift */, + DAEE35062E224F6000B4A7F1 /* ColorTemperaturePickerRowView.swift */, DA35E2B32E1EEA9D003987BB /* DatePickerInputRowView.swift */, - DAC949FD2E21A2D1007E67B7 /* WidgetFrameView.swift */, DAEA21DF2DBF483E00D54342 /* GenericRowView.swift */, - DA35E2CC2E1F96CA003987BB /* WidgetIconView.swift */, DA35E2B42E1EEA9D003987BB /* ImageRowView.swift */, DA35E2B52E1EEA9D003987BB /* MapRowView.swift */, DA35E2B62E1EEA9D003987BB /* RollershutterRowView.swift */, - DA64ACA92DBEADB000294F60 /* WidgetRow.swift */, DA35E2B72E1EEA9D003987BB /* SegmentedRowView.swift */, DA35E2B82E1EEA9D003987BB /* SelectionRowView.swift */, DA35E2AF2E1EDB86003987BB /* SetpointRowView.swift */, @@ -918,7 +916,9 @@ DAEA21DD2DBF481300D54342 /* TextRowView.swift */, DA35E2BB2E1EEA9D003987BB /* VideoRowView.swift */, DA35E2BC2E1EEA9D003987BB /* WebRowView.swift */, - DAEE35062E224F6000B4A7F1 /* ColorTemperaturePickerRowView.swift */, + DAC949FD2E21A2D1007E67B7 /* WidgetFrameView.swift */, + DA35E2CC2E1F96CA003987BB /* WidgetIconView.swift */, + DA64ACA92DBEADB000294F60 /* WidgetRow.swift */, ); path = Rows; sourceTree = ""; diff --git a/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift b/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift index 03cbf3b79..9a3398e57 100644 --- a/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift +++ b/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift @@ -89,7 +89,6 @@ struct ColorTemperaturePickerRowView: View { .foregroundColor(.secondary) } } - } .onAppear { loadCurrentTemperature() diff --git a/openHAB/SwiftUI/Rows/RollershutterRowView.swift b/openHAB/SwiftUI/Rows/RollershutterRowView.swift index 3b58b76b2..e679ba065 100644 --- a/openHAB/SwiftUI/Rows/RollershutterRowView.swift +++ b/openHAB/SwiftUI/Rows/RollershutterRowView.swift @@ -49,7 +49,7 @@ struct RollershutterRowView: View { logger.info("stop button pressed") widget.sendCommand("STOP") } label: { - Image(systemSymbol: .stopFill) + Image(systemSymbol: .stop) .font(.title2) .foregroundColor(.primary) } diff --git a/openHAB/SwiftUI/Rows/SegmentedRowView.swift b/openHAB/SwiftUI/Rows/SegmentedRowView.swift index 21573af63..f82601faf 100644 --- a/openHAB/SwiftUI/Rows/SegmentedRowView.swift +++ b/openHAB/SwiftUI/Rows/SegmentedRowView.swift @@ -23,10 +23,7 @@ struct SegmentedRowView: View { widget.mappingsOrItemOptions } - private var selectedIndex: Int? { - guard !isMomentary else { return nil } - return Int(widget.mappingIndex(byCommand: widget.item?.state) ?? -1).clamped(to: -1 ... mappings.count - 1) == -1 ? nil : Int(widget.mappingIndex(byCommand: widget.item?.state) ?? -1) - } + @State private var selectedIndex: Int? private var isMomentary: Bool { mappings.count == 1 || widget.item?.state == "NULL" @@ -34,26 +31,52 @@ struct SegmentedRowView: View { var body: some View { HStack { + if WidgetIconView.shouldShowIcon(for: widget) { + WidgetIconView(widget: widget) + .frame(width: 24, height: 24) + .padding(.top, 4) // Align with text + } + if let labelText = widget.labelText, !labelText.isEmpty { Text(labelText) .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) } + Spacer() if !mappings.isEmpty { - Picker("", selection: Binding( - get: { selectedIndex }, - set: { newIndex in - guard let index = newIndex, let mapping = mappings[safe: index] else { return } - logger.info("Segment pressed \(index)") - widget.sendCommand(mapping.command) + if isMomentary { + HStack { + ForEach(mappings.indices, id: \.self) { index in + Button { + widget.sendCommand(mappings[index].command) + } label: { + Text(mappings[index].label) + .padding(.horizontal, 6) + } + .buttonStyle(.bordered) + } } - )) { - ForEach(mappings.indices, id: \.self) { index in - Text(mappings[index].label) - .tag(index as Int?) + } else { + Picker("", selection: Binding( + get: { selectedIndex ?? -1 }, + set: { newIndex in + selectedIndex = newIndex + if let mapping = mappings[safe: newIndex] { + widget.sendCommand(mapping.command) + } + } + )) { + ForEach(mappings.indices, id: \.self) { index in + Text(mappings[index].label).tag(index) + } } + .pickerStyle(.segmented) } - .pickerStyle(.segmented) + } + } + .onAppear { + if !isMomentary { + selectedIndex = widget.mappingIndex(byCommand: widget.item?.state) } } } diff --git a/openHAB/SwiftUI/Rows/SelectionRowView.swift b/openHAB/SwiftUI/Rows/SelectionRowView.swift index e6fc3955c..146337b09 100644 --- a/openHAB/SwiftUI/Rows/SelectionRowView.swift +++ b/openHAB/SwiftUI/Rows/SelectionRowView.swift @@ -25,14 +25,16 @@ struct SelectionRowView: View { } var body: some View { - VStack(alignment: .leading, spacing: 8) { + HStack { if let labelText = widget.labelText, !labelText.isEmpty { Text(labelText) .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) } + Spacer() + if !mappings.isEmpty { - Picker("Selection", selection: $selectedIndex) { + Picker("", selection: $selectedIndex) { ForEach(mappings.indices, id: \.self) { index in Text(mappings[index].label) .tag(index) @@ -45,12 +47,6 @@ struct SelectionRowView: View { widget.sendCommand(mapping.command) } } - - if let labelValue = widget.labelValue, !labelValue.isEmpty { - Text(labelValue) - .font(.caption) - .foregroundColor(widget.valuecolor.isEmpty ? .secondary : Color(fromString: widget.valuecolor)) - } } .onAppear { selectedIndex = Int(widget.mappingIndex(byCommand: widget.item?.state) ?? 0) diff --git a/openHAB/SwiftUI/Rows/SetpointRowView.swift b/openHAB/SwiftUI/Rows/SetpointRowView.swift index b6c6cd82c..fb0aa7a9d 100644 --- a/openHAB/SwiftUI/Rows/SetpointRowView.swift +++ b/openHAB/SwiftUI/Rows/SetpointRowView.swift @@ -55,7 +55,7 @@ struct SetpointRowView: View { HStack(spacing: 12) { Button(action: decreaseValue) { - Image(systemSymbol: .chevronDownCircleFill) + Image(systemSymbol: .chevronDown) .font(.title2) .foregroundColor(.primary) } @@ -63,11 +63,11 @@ struct SetpointRowView: View { .disabled(currentValue <= widget.minValue) Text(formattedValue) - .font(.caption) + .font(.caption.monospacedDigit()) .foregroundColor(widget.valuecolor.isEmpty ? .secondary : Color(fromString: widget.valuecolor)) Button(action: increaseValue) { - Image(systemSymbol: .chevronUpCircleFill) + Image(systemSymbol: .chevronUp) .font(.title2) .foregroundColor(.primary) } diff --git a/openHAB/SwiftUI/Rows/SwitchRowView.swift b/openHAB/SwiftUI/Rows/SwitchRowView.swift index 9dddff0d3..b2c38eb31 100644 --- a/openHAB/SwiftUI/Rows/SwitchRowView.swift +++ b/openHAB/SwiftUI/Rows/SwitchRowView.swift @@ -39,19 +39,16 @@ struct SwitchRowView: View { .frame(width: 24, height: 24) } - VStack(alignment: .leading, spacing: 2) { - Text(widget.labelText ?? widget.label) - .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) + Text(widget.labelText ?? widget.label) + .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) + Spacer() - if let labelValue = widget.labelValue, !labelValue.isEmpty { - Text(labelValue) - .font(.caption) - .foregroundColor(widget.valuecolor.isEmpty ? .secondary : Color(fromString: widget.valuecolor)) - } + if let labelValue = widget.labelValue, !labelValue.isEmpty { + Text(labelValue) + .font(.caption) + .foregroundColor(widget.valuecolor.isEmpty ? .secondary : Color(fromString: widget.valuecolor)) } - Spacer() - Toggle("", isOn: Binding( get: { isOn }, set: { newValue in diff --git a/openHAB/SwiftUI/Rows/TextRowView.swift b/openHAB/SwiftUI/Rows/TextRowView.swift index 79c854d19..572e26469 100644 --- a/openHAB/SwiftUI/Rows/TextRowView.swift +++ b/openHAB/SwiftUI/Rows/TextRowView.swift @@ -9,6 +9,7 @@ // // SPDX-License-Identifier: EPL-2.0 +import CommonUI import OpenHABCore import SwiftUI @@ -23,7 +24,7 @@ struct TextRowView: View { } Text(widget.labelText ?? "") - .font(.headline) + .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) Spacer() diff --git a/openHAB/SwiftUI/Rows/WidgetIconView.swift b/openHAB/SwiftUI/Rows/WidgetIconView.swift index f5d8920be..3dd663244 100644 --- a/openHAB/SwiftUI/Rows/WidgetIconView.swift +++ b/openHAB/SwiftUI/Rows/WidgetIconView.swift @@ -17,9 +17,10 @@ import SwiftUI /// A SwiftUI view that displays widget icons with openHAB-specific styling and caching struct WidgetIconView: View { @ObservedObject var widget: OpenHABWidget + @Environment(\.colorScheme) private var colorScheme + let size: CGSize - let iconType: IconType - let iconColor: String + let iconType: IconType = .svg @State private var imageLoadingFailed = false @@ -28,13 +29,24 @@ struct WidgetIconView: View { private var iconURL: URL? { guard !widget.icon.isEmpty else { return nil } + var queriedIconColor: String { + switch colorScheme { + case .light: + return widget.iconColor.isEmpty ? "black" : widget.iconColor + case .dark: + return widget.iconColor.isEmpty ? "white" : widget.iconColor + @unknown default: + return widget.iconColor.isEmpty ? "black" : widget.iconColor + } + } + return Endpoint.icon( rootUrl: NetworkTracker.shared.activeConnection?.configuration.url ?? "", version: NetworkTracker.shared.activeConnection?.version ?? 2, icon: widget.icon, state: widget.iconState(), iconType: iconType, - iconColor: iconColor.isEmpty ? "black" : iconColor + iconColor: queriedIconColor ).url } @@ -83,12 +95,10 @@ struct WidgetIconView: View { extension WidgetIconView { /// Creates a widget icon view with standard size and default styling or custom icon color - init(widget: OpenHABWidget, size: CGSize = CGSize(width: 24, height: 24), iconColor: String = "") { + init(widget: OpenHABWidget) { self.init( widget: widget, - size: size, - iconType: .svg, - iconColor: iconColor + size: CGSize(width: 24, height: 24) ) } } diff --git a/openHAB/SwiftUI/SitemapPageView.swift b/openHAB/SwiftUI/SitemapPageView.swift index 8f16710a2..b2b08ade4 100644 --- a/openHAB/SwiftUI/SitemapPageView.swift +++ b/openHAB/SwiftUI/SitemapPageView.swift @@ -48,7 +48,9 @@ struct SitemapPageView: View { } } } +// .listRowInsets(EdgeInsets()) } + .listStyle(.plain) .navigationTitle(viewModel.pageTitle) .searchable(text: $viewModel.searchText) .refreshable { From e263ffda226811c7aa4543455b7cf7b991aff253 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Sun, 13 Jul 2025 13:43:47 +0200 Subject: [PATCH 247/476] Working subscription to changes Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- openHAB.xcodeproj/project.pbxproj | 8 +-- openHAB/SitemapPageViewModel.swift | 58 +++++++++++++++++-- openHAB/SwiftUI/RowViewFactory.swift | 2 +- ...dgetFrameView.swift => FrameRowView.swift} | 5 +- openHAB/SwiftUI/Rows/GenericRowView.swift | 22 +++---- openHAB/SwiftUI/Rows/SelectionRowView.swift | 1 + openHAB/SwiftUI/SitemapPageView.swift | 7 ++- sitemap.json | 1 + 8 files changed, 76 insertions(+), 28 deletions(-) rename openHAB/SwiftUI/Rows/{WidgetFrameView.swift => FrameRowView.swift} (88%) create mode 100644 sitemap.json diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index 57dcaa426..7306d03d1 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -148,7 +148,7 @@ DAC9395522B00E7600C5F423 /* XCTestCaseExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC9395422B00E7600C5F423 /* XCTestCaseExtension.swift */; }; DAC949FA2E219F0D007E67B7 /* CommonUI in Frameworks */ = {isa = PBXBuildFile; productRef = DAC949F92E219F0D007E67B7 /* CommonUI */; }; DAC949FC2E219F30007E67B7 /* CommonUI in Frameworks */ = {isa = PBXBuildFile; productRef = DAC949FB2E219F30007E67B7 /* CommonUI */; }; - DAC949FE2E21A2D1007E67B7 /* WidgetFrameView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC949FD2E21A2D1007E67B7 /* WidgetFrameView.swift */; }; + DAC949FE2E21A2D1007E67B7 /* FrameRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC949FD2E21A2D1007E67B7 /* FrameRowView.swift */; }; DAC9AF4924F966FA006DAE93 /* LazyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC9AF4824F966FA006DAE93 /* LazyView.swift */; }; DACA368E2D7440B9003CD237 /* OpenHABWidgetExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC9AF4624F9669F006DAE93 /* OpenHABWidgetExtension.swift */; }; DACE664A2C63B0760069E514 /* OpenAPIURLSession in Frameworks */ = {isa = PBXBuildFile; productRef = DACE66492C63B0760069E514 /* OpenAPIURLSession */; }; @@ -482,7 +482,7 @@ DAC6608C236F771600F4501E /* PreferencesSwiftUIView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesSwiftUIView.swift; sourceTree = ""; }; DAC9394322AD4A7A00C5F423 /* OpenHABWatchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenHABWatchTests.swift; sourceTree = ""; }; DAC9395422B00E7600C5F423 /* XCTestCaseExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCTestCaseExtension.swift; sourceTree = ""; }; - DAC949FD2E21A2D1007E67B7 /* WidgetFrameView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetFrameView.swift; sourceTree = ""; }; + DAC949FD2E21A2D1007E67B7 /* FrameRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrameRowView.swift; sourceTree = ""; }; DAC9AF4624F9669F006DAE93 /* OpenHABWidgetExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenHABWidgetExtension.swift; sourceTree = ""; }; DAC9AF4824F966FA006DAE93 /* LazyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LazyView.swift; sourceTree = ""; }; DACB636127D3FC6500041931 /* error.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = error.png; sourceTree = ""; }; @@ -916,7 +916,7 @@ DAEA21DD2DBF481300D54342 /* TextRowView.swift */, DA35E2BB2E1EEA9D003987BB /* VideoRowView.swift */, DA35E2BC2E1EEA9D003987BB /* WebRowView.swift */, - DAC949FD2E21A2D1007E67B7 /* WidgetFrameView.swift */, + DAC949FD2E21A2D1007E67B7 /* FrameRowView.swift */, DA35E2CC2E1F96CA003987BB /* WidgetIconView.swift */, DA64ACA92DBEADB000294F60 /* WidgetRow.swift */, ); @@ -1718,7 +1718,7 @@ DAC131112DA3213100075AE2 /* SetSwitchStateIntentHandlerTests.swift in Sources */, DA35E2BD2E1EEA9D003987BB /* MapRowView.swift in Sources */, DA35E2BE2E1EEA9D003987BB /* DatePickerInputRowView.swift in Sources */, - DAC949FE2E21A2D1007E67B7 /* WidgetFrameView.swift in Sources */, + DAC949FE2E21A2D1007E67B7 /* FrameRowView.swift in Sources */, DA35E2BF2E1EEA9D003987BB /* WebRowView.swift in Sources */, DA35E2C02E1EEA9D003987BB /* ColorPickerRowView.swift in Sources */, DA35E2C12E1EEA9D003987BB /* SliderWithSwitchRowView.swift in Sources */, diff --git a/openHAB/SitemapPageViewModel.swift b/openHAB/SitemapPageViewModel.swift index 3e025581f..8ca5d581c 100644 --- a/openHAB/SitemapPageViewModel.swift +++ b/openHAB/SitemapPageViewModel.swift @@ -85,14 +85,18 @@ class SitemapPageViewModel: ObservableObject { func startPageHandling() { pageHandlingTask?.cancel() - guard !defaultSitemap.isEmpty else { - logger.error("startPageHandling: Cannot run with empty sitemap") - return - } - logger.info("🚀 Starting page load and long polling flow...") pageHandlingTask = Task { + // If no default sitemap is set, try to discover and auto-select one + if defaultSitemap.isEmpty { + await discoverAndSelectSitemap() + } + + guard !defaultSitemap.isEmpty else { + logger.error("startPageHandling: Cannot run with empty sitemap after discovery") + return + } do { // Setup service if needed // if openAPIService == nil { @@ -244,6 +248,50 @@ class SitemapPageViewModel: ObservableObject { await startPageHandling() } + private func discoverAndSelectSitemap() async { + do { + try await setupConnection() + guard let service = openAPIService else { + logger.error("Could not setup service for sitemap discovery") + return + } + + let sitemaps = try await service.openHABSitemaps() + + // Filter out _default sitemap if there are multiple sitemaps available + let filteredSitemaps = sitemaps.count > 1 ? sitemaps.filter { $0.name != "_default" } : sitemaps + + switch filteredSitemaps.count { + case 1: + // Auto-select the only available sitemap + defaultSitemap = filteredSitemaps[0].name + // swiftformat:disable:next redundantSelf + logger.info("Auto-selected single sitemap: \(self.defaultSitemap)") + + // Save as default for future launches + Preferences.modifyActiveHome { homePreferences in + homePreferences.defaultSitemap = defaultSitemap + } + case 2...: + // Multiple sitemaps available - select the first one + defaultSitemap = filteredSitemaps[0].name + // swiftformat:disable:next redundantSelf + logger.info("Auto-selected first sitemap from \(filteredSitemaps.count) available: \(self.defaultSitemap)") + + // Save as default for future launches + Preferences.modifyActiveHome { homePreferences in + homePreferences.defaultSitemap = defaultSitemap + } + default: + logger.error("No sitemaps available") + error = SitemapPageError.serviceUnavailable + } + } catch { + logger.error("Failed to discover sitemaps: \(error)") + self.error = error as? any LocalizedError ?? SitemapPageError.serviceUnavailable + } + } + private func setupActiveConnectionObserver() { // The @ObservedObject will automatically trigger view updates // We'll handle the connection changes in the view's onChange modifier diff --git a/openHAB/SwiftUI/RowViewFactory.swift b/openHAB/SwiftUI/RowViewFactory.swift index 9c611c1cd..52355fc78 100644 --- a/openHAB/SwiftUI/RowViewFactory.swift +++ b/openHAB/SwiftUI/RowViewFactory.swift @@ -43,7 +43,7 @@ enum RowViewFactory { case .text: TextRowView(widget: widget) case .frame: - WidgetFrameView(widget: widget) + FrameRowView(widget: widget) case .setpoint: SetpointRowView(widget: widget) case .selection: diff --git a/openHAB/SwiftUI/Rows/WidgetFrameView.swift b/openHAB/SwiftUI/Rows/FrameRowView.swift similarity index 88% rename from openHAB/SwiftUI/Rows/WidgetFrameView.swift rename to openHAB/SwiftUI/Rows/FrameRowView.swift index 5cd48a896..25c280c42 100644 --- a/openHAB/SwiftUI/Rows/WidgetFrameView.swift +++ b/openHAB/SwiftUI/Rows/FrameRowView.swift @@ -12,13 +12,14 @@ import OpenHABCore import SwiftUI -struct WidgetFrameView: View { +struct FrameRowView: View { @ObservedObject var widget: OpenHABWidget var body: some View { HStack { Text(widget.labelText?.uppercased() ?? "") .font(.callout) + .foregroundColor(.secondary) .lineLimit(1) Spacer() } @@ -29,6 +30,6 @@ struct WidgetFrameView: View { #Preview { let widget = PreviewConstants.openHABSitemapPage!.widgets[6] List([widget]) { widget in - WidgetFrameView(widget: widget) + FrameRowView(widget: widget) } } diff --git a/openHAB/SwiftUI/Rows/GenericRowView.swift b/openHAB/SwiftUI/Rows/GenericRowView.swift index 227a861d6..c6e97c63a 100644 --- a/openHAB/SwiftUI/Rows/GenericRowView.swift +++ b/openHAB/SwiftUI/Rows/GenericRowView.swift @@ -16,19 +16,15 @@ struct GenericRowView: View { @ObservedObject var widget: OpenHABWidget var body: some View { HStack { -// if let iconUrl = widget.iconUrl { -// KFImage(iconUrl) -// .resizable() -// .frame(width: 30, height: 30) -// } - VStack(alignment: .leading) { - Text(widget.labelText ?? "") - .font(.headline) - if let value = widget.labelValue { - Text(value) - .font(.subheadline) - .foregroundColor(.secondary) - } + if WidgetIconView.shouldShowIcon(for: widget) { + WidgetIconView(widget: widget) + .frame(width: 24, height: 24) + } + Text(widget.labelText ?? "") + Spacer() + if let value = widget.labelValue { + Text(value) + .foregroundColor(.secondary) } } .padding() diff --git a/openHAB/SwiftUI/Rows/SelectionRowView.swift b/openHAB/SwiftUI/Rows/SelectionRowView.swift index 146337b09..18ba2c89b 100644 --- a/openHAB/SwiftUI/Rows/SelectionRowView.swift +++ b/openHAB/SwiftUI/Rows/SelectionRowView.swift @@ -41,6 +41,7 @@ struct SelectionRowView: View { } } .pickerStyle(.menu) + .frame(height: 24) // 👈 Restrict height of the Picker .onChange(of: selectedIndex) { newIndex in guard let mapping = mappings[safe: newIndex] else { return } logger.info("Selection changed to: \(mapping.label)") diff --git a/openHAB/SwiftUI/SitemapPageView.swift b/openHAB/SwiftUI/SitemapPageView.swift index b2b08ade4..e47f21be0 100644 --- a/openHAB/SwiftUI/SitemapPageView.swift +++ b/openHAB/SwiftUI/SitemapPageView.swift @@ -25,6 +25,8 @@ struct SitemapPageView: View { NavigationLink(destination: SitemapPageView(viewModel: SitemapPageViewModel(pageUrl: linkedPage.link, title: linkedPage.title))) { RowViewFactory.view(for: widget) } + .buttonStyle(.plain) + .listRowInsets(EdgeInsets(top: 0, leading: 4, bottom: 0, trailing: 24)) } else if widget.type == .selection { Button { selectedWidget = widget @@ -32,7 +34,7 @@ struct SitemapPageView: View { } label: { RowViewFactory.view(for: widget) } - .buttonStyle(PlainButtonStyle()) + .buttonStyle(.plain) } else if widget.type == .input { Button { selectedWidget = widget @@ -40,7 +42,7 @@ struct SitemapPageView: View { } label: { RowViewFactory.view(for: widget) } - .buttonStyle(PlainButtonStyle()) + .buttonStyle(.plain) } else { RowViewFactory.view(for: widget) .onTapGesture { @@ -48,7 +50,6 @@ struct SitemapPageView: View { } } } -// .listRowInsets(EdgeInsets()) } .listStyle(.plain) .navigationTitle(viewModel.pageTitle) diff --git a/sitemap.json b/sitemap.json new file mode 100644 index 000000000..4bab2b82e --- /dev/null +++ b/sitemap.json @@ -0,0 +1 @@ +{"name":"uicomponents_page_myHome","label":"MyHome","link":"http://192.168.2.10:8080/rest/sitemaps/uicomponents_page_myHome","homepage":{"id":"uicomponents_page_myHome","title":"MyHome","link":"http://192.168.2.10:8080/rest/sitemaps/uicomponents_page_myHome/uicomponents_page_myHome","leaf":false,"timeout":false,"widgets":[{"widgetId":"00","type":"Frame","visibility":true,"label":"Treppe","labelSource":"SITEMAP_WIDGET","icon":"frame","staticIcon":false,"unit":"","mappings":[],"widgets":[{"widgetId":"0000","type":"Switch","visibility":true,"label":"Treppe Keller-EG","labelSource":"SITEMAP_WIDGET","icon":"light","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/shelly1pmminiKellerFlur_Power","state":"OFF","type":"Switch","name":"shelly1pmminiKellerFlur_Power","label":"Power","category":"Switch","tags":["Power","Switch"],"groupNames":["shelly1pmminiKellerFlur"]},"widgets":[]},{"widgetId":"0001","type":"Switch","visibility":true,"label":"Treppe EG-1","labelSource":"ITEM_LABEL","icon":"light","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/lcnLightSwitch10_2","state":"OFF","type":"Switch","name":"lcnLightSwitch10_2","label":"Treppe EG-1","category":"light","tags":["Light","Switch"],"groupNames":["Module10EGTreppenachoben"]},"widgets":[]},{"widgetId":"0002","type":"Switch","visibility":true,"label":"Treppe 1-2","labelSource":"ITEM_LABEL","icon":"light","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/lcnLightSwitch32_1","state":"OFF","type":"Switch","name":"lcnLightSwitch32_1","label":"Treppe 1-2","category":"light","tags":["Light","Switch"],"groupNames":["Module321OGTreppenachobe","gPresenceSimulation"]},"widgets":[]},{"widgetId":"0003","type":"Switch","visibility":true,"label":"Flur 1","labelSource":"SITEMAP_WIDGET","icon":"light","staticIcon":false,"unit":"","mappings":[],"state":"OFF","item":{"link":"http://192.168.2.10:8080/rest/items/Module_33_1OG_zum_KiZi_Output_1","state":"0","type":"Dimmer","name":"Module_33_1OG_zum_KiZi_Output_1","label":"Output 1","category":"","tags":["Point"],"groupNames":["Module_33_1OG_zum_KiZi"]},"widgets":[]}]},{"widgetId":"01","type":"Frame","visibility":true,"label":"Eingang","labelSource":"SITEMAP_WIDGET","icon":"frame","staticIcon":false,"unit":"","mappings":[],"widgets":[{"widgetId":"0100","type":"Switch","visibility":true,"label":"Eingang","labelSource":"ITEM_LABEL","icon":"light","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/lcnLightSwitch17_1","state":"OFF","type":"Switch","name":"lcnLightSwitch17_1","label":"Eingang","category":"light","tags":["Light","Switch"],"groupNames":["Module17EGEingang","gPresenceSimulation"]},"widgets":[]},{"widgetId":"0101","type":"Switch","visibility":true,"label":"Eingang aussen","labelSource":"ITEM_LABEL","icon":"light","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/lcnLightSwitch17_2","state":"OFF","type":"Switch","name":"lcnLightSwitch17_2","label":"Eingang aussen","category":"light","tags":["Light","Switch"],"groupNames":["Module17EGEingang","gPresenceSimulation"]},"widgets":[]}]},{"widgetId":"02","type":"Frame","visibility":true,"label":"WC","labelSource":"SITEMAP_WIDGET","icon":"frame","staticIcon":false,"unit":"","mappings":[],"widgets":[{"widgetId":"0200","type":"Switch","visibility":true,"label":"WC [OFF]","labelSource":"SITEMAP_WIDGET","icon":"light","staticIcon":false,"pattern":"%s","unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/Josef021__Shelly_1Mini_Gen_3__WC_MQTT_Switch","state":"OFF","stateDescription":{"pattern":"%s","readOnly":false,"options":[]},"type":"Switch","name":"Josef021__Shelly_1Mini_Gen_3__WC_MQTT_Switch","label":"Switch","tags":["Point"],"groupNames":[]},"widgets":[]}]},{"widgetId":"03","type":"Frame","visibility":true,"label":"Keller","labelSource":"SITEMAP_WIDGET","icon":"frame","staticIcon":false,"unit":"","mappings":[],"widgets":[{"widgetId":"0300","type":"Switch","visibility":true,"label":"Bad Decke","labelSource":"SITEMAP_WIDGET","icon":"light","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/Gastebadshellyswitch25_Power_1","state":"OFF","type":"Switch","name":"Gastebadshellyswitch25_Power_1","label":"Power 1","category":"Switch","tags":["Power","Switch"],"groupNames":["Gastebadshellyswitch25"]},"widgets":[]},{"widgetId":"0301","type":"Switch","visibility":true,"label":"Bad Spiegel","labelSource":"SITEMAP_WIDGET","icon":"light","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/Gastebadshellyswitch25_Power_2","state":"OFF","type":"Switch","name":"Gastebadshellyswitch25_Power_2","label":"Power 2","category":"Switch","tags":["Power","Switch"],"groupNames":["Gastebadshellyswitch25"]},"widgets":[]},{"widgetId":"0302","type":"Switch","visibility":true,"label":"Keller Lager Decke","labelSource":"ITEM_LABEL","icon":"light","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/lcnLightSwitch8_1","state":"OFF","type":"Switch","name":"lcnLightSwitch8_1","label":"Keller Lager Decke","category":"light","tags":["Light","Switch"],"groupNames":["Module8KellerLager"]},"widgets":[]},{"widgetId":"0303","type":"Switch","visibility":true,"label":"Gäste Decke","labelSource":"ITEM_LABEL","icon":"light","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/shellyswitch2540f52005312f_Relay2_Output","state":"OFF","type":"Switch","name":"shellyswitch2540f52005312f_Relay2_Output","label":"Gäste Decke","category":"light","tags":["Light","Switch"],"groupNames":["shellyswitch2540f52005312f"]},"widgets":[]},{"widgetId":"0304","type":"Switch","visibility":true,"label":"Keller Heizung Decke","labelSource":"ITEM_LABEL","icon":"light","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/lcnLightSwitch7_1","state":"OFF","type":"Switch","name":"lcnLightSwitch7_1","label":"Keller Heizung Decke","category":"light","tags":["Light","Switch"],"groupNames":["Module7KellerHeizungsraum"]},"widgets":[]},{"widgetId":"0305","type":"Switch","visibility":true,"label":"Laserjet Power","labelSource":"ITEM_LABEL","icon":"switch","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/ShellyPlugBigshellyplug28433e192168275_Power","state":"OFF","type":"Switch","name":"ShellyPlugBigshellyplug28433e192168275_Power","label":"Laserjet Power","category":"Switch","tags":["Power","Switch"],"groupNames":["ShellyPlugBigshellyplug28433e192168275"]},"widgets":[]}]},{"widgetId":"04","type":"Frame","visibility":true,"label":"DFF","labelSource":"SITEMAP_WIDGET","icon":"frame","staticIcon":false,"unit":"","mappings":[],"widgets":[{"widgetId":"0400","type":"Switch","visibility":true,"label":"DFF Emma","labelSource":"ITEM_LABEL","icon":"blinds","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/lcnDFFWest","state":"100","type":"Rollershutter","name":"lcnDFFWest","label":"DFF Emma","category":"blinds","tags":[],"groupNames":["g2OGEmma"]},"widgets":[]},{"widgetId":"0401","type":"Switch","visibility":true,"label":"DFF Arbeitsplatz","labelSource":"ITEM_LABEL","icon":"blinds","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/lcnDFFOst","state":"100","type":"Rollershutter","name":"lcnDFFOst","label":"DFF Arbeitsplatz","category":"blinds","tags":[],"groupNames":["g2OG"]},"widgets":[]}]},{"widgetId":"05","type":"Frame","visibility":true,"label":"DG","labelSource":"SITEMAP_WIDGET","icon":"frame","staticIcon":false,"unit":"","mappings":[],"widgets":[{"widgetId":"0500","type":"Switch","visibility":true,"label":"Kofferabteil","labelSource":"ITEM_LABEL","icon":"light","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/lcnLightSwitchRel42_8","state":"OFF","type":"Switch","name":"lcnLightSwitchRel42_8","label":"Kofferabteil","category":"light","tags":["Light","Switch"],"groupNames":["Module42DGStrgRelais"]},"widgets":[]},{"widgetId":"0501","type":"Switch","visibility":true,"label":"Arbeitsplatz","labelSource":"SITEMAP_WIDGET","icon":"light","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/lcnLightSwitch44_2","state":"OFF","type":"Switch","name":"lcnLightSwitch44_2","label":"DG Zimmer","category":"light","tags":["Light","Switch"],"groupNames":["gZimmerEmma"]},"widgets":[]}]},{"widgetId":"06","type":"Frame","visibility":true,"label":"Emma","labelSource":"SITEMAP_WIDGET","icon":"frame","staticIcon":false,"unit":"","mappings":[],"widgets":[{"widgetId":"0600","type":"Switch","visibility":true,"label":"DG Zimmer Decke","labelSource":"ITEM_LABEL","icon":"light","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/Shelly1PMDGFenster_Power","state":"OFF","type":"Switch","name":"Shelly1PMDGFenster_Power","label":"DG Zimmer Decke","category":"light","tags":["Power","Switch"],"groupNames":["Shelly1PMDGFenster"]},"widgets":[]},{"widgetId":"0601","type":"Switch","visibility":true,"label":"DG Zimmer Occhio","labelSource":"ITEM_LABEL","icon":"light","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/lcnLightSwitch44_1","state":"OFF","type":"Switch","name":"lcnLightSwitch44_1","label":"DG Zimmer Occhio","category":"light","tags":["Light","Switch"],"groupNames":["Module44DGbeiTuroben"]},"widgets":[]},{"widgetId":"0602","type":"Switch","visibility":true,"label":"Steckdose links","labelSource":"SITEMAP_WIDGET","icon":"poweroutlet_eu","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/shellyplugs978352192168241_Power","state":"OFF","type":"Switch","name":"shellyplugs978352192168241_Power","label":"Power","category":"poweroutlet_eu","tags":["Point"],"groupNames":["shellyplugs978352192168241"]},"widgets":[]},{"widgetId":"0603","type":"Switch","visibility":true,"label":"Heizprofil [2: Abwesenheit]","labelSource":"SITEMAP_WIDGET","icon":"heating","staticIcon":false,"pattern":"%s","unit":"","mappings":[{"command":"1","label":"Normal"},{"command":"2","label":"Abwesenheit"},{"command":"3","label":"NormalReduziert"}],"item":{"link":"http://192.168.2.10:8080/rest/items/shellytrvEmma_Selected_Profile","state":"2","stateDescription":{"pattern":"%s","readOnly":false,"options":[{"value":"1","label":"1: Normal"},{"value":"2","label":"2: Abwesenheit"},{"value":"3","label":"3: AnwesenheitMitDecke"},{"value":"4","label":"4: Bedroom 1"},{"value":"5","label":"5: Holiday"}]},"commandDescription":{"commandOptions":[{"command":"1","label":"1: Normal"},{"command":"2","label":"2: Abwesenheit"},{"command":"3","label":"3: AnwesenheitMitDecke"},{"command":"4","label":"4: Bedroom 1"},{"command":"5","label":"5: Holiday"}]},"type":"String","name":"shellytrvEmma_Selected_Profile","label":"Selected Profile","category":"","tags":["Point"],"groupNames":[]},"widgets":[]},{"widgetId":"0604","type":"Text","visibility":true,"label":"Position [0]","labelSource":"ITEM_LABEL","icon":"heating","staticIcon":false,"labelcolor":"blue","valuecolor":"green","pattern":"%.0f %unit%","unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/shellytrvEmma_Position","state":"0","stateDescription":{"minimum":0,"maximum":100,"step":1,"pattern":"%.0f %unit%","readOnly":false,"options":[]},"type":"Dimmer","name":"shellytrvEmma_Position","label":"Position","category":"heating","tags":["Point"],"groupNames":["shellytrvEmma"]},"widgets":[]}]},{"widgetId":"07","type":"Frame","visibility":true,"label":"Schlafzimmer","labelSource":"SITEMAP_WIDGET","icon":"frame","staticIcon":false,"unit":"","mappings":[],"widgets":[{"widgetId":"0700","type":"Switch","visibility":true,"label":"SZ Decke","labelSource":"ITEM_LABEL","icon":"light","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/lcnLightSwitch37_1","state":"OFF","type":"Switch","name":"lcnLightSwitch37_1","label":"SZ Decke","category":"light","tags":["Light","Switch"],"groupNames":["Module371OGSZbeiTur"]},"widgets":[]},{"widgetId":"0701","type":"Switch","visibility":true,"label":"Ankleide","labelSource":"ITEM_LABEL","icon":"light","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/AnkleideRelais_Power","state":"ON","type":"Switch","name":"AnkleideRelais_Power","label":"Ankleide","category":"light","tags":["Light","Switch"],"groupNames":["AnkleideRelais"]},"widgets":[]},{"widgetId":"0702","type":"Switch","visibility":true,"label":"Jalousie SZ","labelSource":"ITEM_LABEL","icon":"blinds","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/lcnJalousieSZ","state":"100","type":"Rollershutter","name":"lcnJalousieSZ","label":"Jalousie SZ","category":"blinds","tags":["Point"],"groupNames":["Module56StrgJalousie3BS"]},"widgets":[]},{"widgetId":"0703","type":"Selection","visibility":true,"label":"Profil [1]","labelSource":"SITEMAP_WIDGET","icon":"heating","staticIcon":false,"pattern":"%s","unit":"","mappings":[{"command":"1","label":"Normal"},{"command":"2","label":"Abwesenheit"}],"item":{"link":"http://192.168.2.10:8080/rest/items/shellytrv_Schlafzimmer_Selected_Profile","state":"1","stateDescription":{"pattern":"%s","readOnly":false,"options":[]},"type":"String","name":"shellytrv_Schlafzimmer_Selected_Profile","label":"Selected Profile","category":"","tags":["Point"],"groupNames":["shellytrv_Schlafzimmer"]},"widgets":[]}]},{"widgetId":"08","type":"Frame","visibility":true,"label":"Paul","labelSource":"SITEMAP_WIDGET","icon":"frame","staticIcon":false,"unit":"","mappings":[],"widgets":[{"widgetId":"0800","type":"Switch","visibility":true,"label":"Paul Decke","labelSource":"ITEM_LABEL","icon":"light","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/lcnLightSwitch36_2","state":"OFF","type":"Switch","name":"lcnLightSwitch36_2","label":"Paul Decke","category":"light","tags":["Light","Switch"],"groupNames":["Module361OGZimmerPaul"]},"widgets":[]},{"widgetId":"0801","type":"Switch","visibility":true,"label":"Steckdose Zimmer Paul","labelSource":"ITEM_LABEL","icon":"poweroutlet_eu","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/lcnRelay36_1","state":"OFF","type":"Switch","name":"lcnRelay36_1","label":"Steckdose Zimmer Paul","category":"poweroutlet_eu","tags":["Point"],"groupNames":["Module361OGZimmerPaul"]},"widgets":[]},{"widgetId":"0802","type":"Switch","visibility":true,"label":"Jalousien Paul","labelSource":"SITEMAP_WIDGET","icon":"blinds","staticIcon":false,"unit":"","mappings":[],"state":"ON","item":{"members":[],"groupType":"Rollershutter","function":{"name":"EQUALITY"},"link":"http://192.168.2.10:8080/rest/items/g1OGJalousienPaul","state":"100","type":"Group","name":"g1OGJalousienPaul","label":"Jalousien Paul","category":"blinds","tags":["Blinds"],"groupNames":["g1OGJalousien"]},"widgets":[]},{"widgetId":"0803","type":"Group","visibility":true,"label":"Zimmer Paul","labelSource":"SITEMAP_WIDGET","icon":"rollershutter","staticIcon":false,"unit":"","mappings":[],"item":{"members":[],"groupType":"Rollershutter","function":{"name":"EQUALITY"},"link":"http://192.168.2.10:8080/rest/items/g1OGJalousienPaul","state":"100","type":"Group","name":"g1OGJalousienPaul","label":"Jalousien Paul","category":"blinds","tags":["Blinds"],"groupNames":["g1OGJalousien"]},"linkedPage":{"id":"0803","title":"Zimmer Paul","icon":"rollershutter","link":"http://192.168.2.10:8080/rest/sitemaps/uicomponents_page_myHome/0803","leaf":true,"timeout":false,"widgets":[{"widgetId":"080300","type":"Switch","visibility":true,"label":"Jalouise Paul Süd rechts","labelSource":"SITEMAP_WIDGET","icon":"blinds","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/lcnJalousieKZSuedLinks","state":"100","type":"Rollershutter","name":"lcnJalousieKZSuedLinks","label":"Jalousie Paul Süd links","category":"blinds","tags":["Point"],"groupNames":["g1OGJalousien","g1OGJalousienPaul","Module55StrgJalousie2"]},"widgets":[]},{"widgetId":"080301","type":"Switch","visibility":true,"label":"Jalousie Paul Süd links","labelSource":"SITEMAP_WIDGET","icon":"blinds","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/lcnJalousieKZSuedRechts","state":"100","type":"Rollershutter","name":"lcnJalousieKZSuedRechts","label":"Jalousie Paul Süd rechts","category":"blinds","tags":["Point"],"groupNames":["g1OGJalousien","gJalousien","g1OGJalousienPaul","Module55StrgJalousie2"]},"widgets":[]},{"widgetId":"080302","type":"Switch","visibility":true,"label":"Jalousie Paul West","labelSource":"ITEM_LABEL","icon":"blinds","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/lcnJalousieKZWest","state":"100","type":"Rollershutter","name":"lcnJalousieKZWest","label":"Jalousie Paul West","category":"blinds","tags":["Point"],"groupNames":["g1OGJalousien","gJalousien","g1OGJalousienPaul","Module56StrgJalousie3BS"]},"widgets":[]}]},"widgets":[]},{"widgetId":"0804","type":"Setpoint","visibility":true,"label":"Solltemperatur [6.0 °C]","labelSource":"SITEMAP_WIDGET","icon":"heating","staticIcon":false,"pattern":"%.1f %unit%","unit":"°C","mappings":[],"minValue":8.0,"maxValue":25.0,"step":0.5,"item":{"link":"http://192.168.2.10:8080/rest/items/Comet_DECT_4_rechts_Setpoint_Temperature","state":"6 °C","stateDescription":{"pattern":"%.1f %unit%","readOnly":false,"options":[]},"unitSymbol":"°C","type":"Number:Temperature","name":"Comet_DECT_4_rechts_Setpoint_Temperature","label":"Setpoint Temperature","category":"Heating","tags":["Setpoint","Temperature"],"groupNames":["Comet_DECT_4_rechts"]},"widgets":[]},{"widgetId":"0805","type":"Text","visibility":true,"label":"Pauli links [19.0 °C]","labelSource":"SITEMAP_WIDGET","icon":"temperature","staticIcon":false,"pattern":"%.1f %unit%","unit":"°C","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/Comet_DECT_3_links_Actual_Temp","state":"19 °C","stateDescription":{"pattern":"%.1f %unit%","readOnly":true,"options":[]},"unitSymbol":"°C","type":"Number:Temperature","name":"Comet_DECT_3_links_Actual_Temp","label":"Current Temperature","category":"Temperature","tags":["Temperature","Measurement"],"groupNames":["Comet_DECT_3_links"]},"widgets":[]},{"widgetId":"0806","type":"Text","visibility":true,"label":"Pauli rechts [19.5 °C]","labelSource":"SITEMAP_WIDGET","icon":"temperature","staticIcon":false,"pattern":"%.1f %unit%","unit":"°C","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/Comet_DECT_4_rechts_Actual_Temp","state":"19.5 °C","stateDescription":{"pattern":"%.1f %unit%","readOnly":true,"options":[]},"unitSymbol":"°C","type":"Number:Temperature","name":"Comet_DECT_4_rechts_Actual_Temp","label":"Current Temperature","category":"Temperature","tags":["Temperature","Measurement"],"groupNames":["Comet_DECT_4_rechts"]},"widgets":[]},{"widgetId":"0807","type":"Selection","visibility":true,"label":"Zimmer Paul [tmp6B3829-3F8784AF9]","labelSource":"SITEMAP_WIDGET","icon":"heating","staticIcon":false,"pattern":"%s","unit":"","mappings":[{"command":"tmp6B3829-3EC68BF9D","label":"Normal"},{"command":"tmp6B3829-3EC68D7CE","label":"Abwesend"},{"command":"tmp6B3829-3F878534B","label":"Boost"},{"command":"tmp6B3829-3F8784AF9","label":"Arbeiten"}],"item":{"link":"http://192.168.2.10:8080/rest/items/joseffritzfiber_Apply_Template","state":"tmp6B3829-3F8784AF9","stateDescription":{"pattern":"%s","readOnly":false,"options":[]},"commandDescription":{"commandOptions":[{"command":"tmp6B3829-3F8783BB0","label":"Anwesenheit wärmer"},{"command":"tmp6B3829-3EC68C768","label":"Gästebad"},{"command":"tmp6B3829-3EC68D012","label":"Gästebad Abwesenheit"},{"command":"tmp6B3829-3EC68C8E4","label":"Gästezimmer"},{"command":"tmp6B3829-3EC68D5CF","label":"Gästezimmer Abwesenheit"},{"command":"tmp6B3829-3EC68BF9D","label":"Paul"},{"command":"tmp6B3829-3EC68D7CE","label":"Paul Abwesenheit"},{"command":"tmp6B3829-3F8784AF9","label":"PaulArbeiten"},{"command":"tmp6B3829-3F878534B","label":"PaulBoost"},{"command":"tmp6B3829-3EC68C11F","label":"Schlafzimmer"},{"command":"tmp6B3829-3EC68D9CB","label":"Schlafzimmer Abwesenheit"},{"command":"tmp6B3829-3F89206E0","label":"Schlafzimmer Boost"},{"command":"tmp6B3829-3F59B377F","label":"Treppenhaus"},{"command":"tmp6B3829-3F59B3452","label":"Treppenhaus Abwesenheit "}]},"type":"String","name":"joseffritzfiber_Apply_Template","label":"joseffritzfiber Apply Template","category":"","tags":["Point"],"groupNames":["joseffritzfiber"]},"widgets":[]}]},{"widgetId":"09","type":"Frame","visibility":true,"label":"Wohnzimmer","labelSource":"SITEMAP_WIDGET","icon":"frame","staticIcon":false,"unit":"","mappings":[],"widgets":[{"widgetId":"0900","type":"Switch","visibility":true,"label":"Steckdose WZ Nord","labelSource":"ITEM_LABEL","icon":"poweroutlet_eu","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/lcnRelayWZNord","state":"OFF","type":"Switch","name":"lcnRelayWZNord","label":"Steckdose WZ Nord","category":"poweroutlet_eu","tags":["Point"],"groupNames":["Module53StrgSchaltsteckdose"]},"widgets":[]},{"widgetId":"0901","type":"Switch","visibility":true,"label":"Steckdose Ecke SW","labelSource":"SITEMAP_WIDGET","icon":"light","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/shellyplugs163b96192168253_Power","state":"OFF","type":"Switch","name":"shellyplugs163b96192168253_Power","label":"Power","category":"poweroutlet_eu","tags":["Point"],"groupNames":["shellyplugs163b96192168253"]},"widgets":[]},{"widgetId":"0902","type":"Switch","visibility":true,"label":"Tolomeo West","labelSource":"SITEMAP_WIDGET","icon":"light","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/shellyplusplugs08f9e0fe0844__Tolomeo_Power","state":"OFF","type":"Switch","name":"shellyplusplugs08f9e0fe0844__Tolomeo_Power","label":"Power","category":"Switch","tags":["Power","Switch"],"groupNames":["shellyplusplugs08f9e0fe0844__Tolomeo"]},"widgets":[]},{"widgetId":"0903","type":"Switch","visibility":true,"label":"Tolomeo Ost","labelSource":"SITEMAP_WIDGET","icon":"light","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/shellyplusplugsd48afc7a92d0__Tolomeo2_Power","state":"OFF","type":"Switch","name":"shellyplusplugsd48afc7a92d0__Tolomeo2_Power","label":"Power","category":"Switch","tags":["Power","Switch"],"groupNames":["shellyplusplugsd48afc7a92d0__Tolomeo2"]},"widgets":[]},{"widgetId":"0904","type":"Selection","visibility":true,"label":"Szene [-]","labelSource":"SITEMAP_WIDGET","icon":"rgb","staticIcon":false,"pattern":"%s","unit":"","mappings":[{"command":"1","label":"Normal"},{"command":"2","label":"Abwesenheit"}],"item":{"link":"http://192.168.2.10:8080/rest/items/sWohnzimmer","state":"NULL","stateDescription":{"pattern":"%s","readOnly":false,"options":[]},"type":"String","name":"sWohnzimmer","label":"Szene","category":"scene","tags":[],"groupNames":[]},"widgets":[]}]},{"widgetId":"10","type":"Frame","visibility":true,"label":"Küche","labelSource":"SITEMAP_WIDGET","icon":"frame","staticIcon":false,"unit":"","mappings":[],"widgets":[{"widgetId":"1000","type":"Switch","visibility":true,"label":"Oberlicht","labelSource":"ITEM_LABEL","icon":"light","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/lcnLightSwitch14_1","state":"OFF","type":"Switch","name":"lcnLightSwitch14_1","label":"Oberlicht","category":"light","tags":["Light","Switch"],"groupNames":["Module14EGzwKamundKueunten"]},"widgets":[]},{"widgetId":"1001","type":"Switch","visibility":true,"label":"Unterlicht","labelSource":"SITEMAP_WIDGET","icon":"light","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/lcnLightSwitch15_2","state":"OFF","type":"Switch","name":"lcnLightSwitch15_2","label":"Küche Unterlicht","category":"light","tags":["Light","Switch"],"groupNames":["Module15EGzwKamundKueob"]},"widgets":[]},{"widgetId":"1002","type":"Switch","visibility":true,"label":"Küchenblock","labelSource":"SITEMAP_WIDGET","icon":"light","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/lcnLightSwitch15_1","state":"OFF","type":"Switch","name":"lcnLightSwitch15_1","label":"Küche Oberlicht","category":"light","tags":["Light","Switch"],"groupNames":["Module15EGzwKamundKueob","gPresenceSimulation"]},"widgets":[]},{"widgetId":"1003","type":"Switch","visibility":true,"label":"Esstisch","labelSource":"ITEM_LABEL","icon":"light","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/lcnLightSwitch20_1","state":"OFF","type":"Switch","name":"lcnLightSwitch20_1","label":"Esstisch","category":"light","tags":["Light","Switch"],"groupNames":["Module20EGWZnaheVitrine","gPresenceSimulation"]},"widgets":[]}]},{"widgetId":"11","type":"Frame","visibility":true,"label":"Bad","labelSource":"SITEMAP_WIDGET","icon":"frame","staticIcon":false,"unit":"","mappings":[],"widgets":[{"widgetId":"1100","type":"Switch","visibility":true,"label":"Spiegel [OFF]","labelSource":"SITEMAP_WIDGET","icon":"light","staticIcon":false,"pattern":"%s","unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/Generic_MQTT_Thing_Switch","state":"OFF","stateDescription":{"pattern":"%s","readOnly":false,"options":[]},"type":"Switch","name":"Generic_MQTT_Thing_Switch","label":"Bad0","tags":["Point"],"groupNames":["Generic_MQTT_Thing"]},"widgets":[]},{"widgetId":"1101","type":"Switch","visibility":true,"label":"Decke [OFF]","labelSource":"SITEMAP_WIDGET","icon":"light","staticIcon":false,"pattern":"%s","unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/Generic_MQTT_Thing_Switch1","state":"OFF","stateDescription":{"pattern":"%s","readOnly":false,"options":[]},"type":"Switch","name":"Generic_MQTT_Thing_Switch1","label":"Switch1","tags":["Point"],"groupNames":["Generic_MQTT_Thing"]},"widgets":[]},{"widgetId":"1102","type":"Switch","visibility":true,"label":"Jalousie Bad","labelSource":"ITEM_LABEL","icon":"blinds","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/lcnJalousieBad","state":"100","type":"Rollershutter","name":"lcnJalousieBad","label":"Jalousie Bad","category":"blinds","tags":["Point"],"groupNames":["Module56StrgJalousie3BS"]},"widgets":[]},{"widgetId":"1103","type":"Selection","visibility":true,"label":"Profil [2]","labelSource":"SITEMAP_WIDGET","icon":"heating","staticIcon":false,"pattern":"%s","unit":"","mappings":[{"command":"1","label":"Normal"},{"command":"2","label":"Abwesenheit"}],"item":{"link":"http://192.168.2.10:8080/rest/items/shellytrvBad_Selected_Profile","state":"2","stateDescription":{"pattern":"%s","readOnly":false,"options":[]},"type":"String","name":"shellytrvBad_Selected_Profile","label":"Selected Profile","category":"","tags":["Point"],"groupNames":[]},"widgets":[]},{"widgetId":"1104","type":"Text","visibility":true,"label":"Position [5]","labelSource":"ITEM_LABEL","icon":"heating","staticIcon":false,"pattern":"%.0f %unit%","unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/shellytrvBad_Position","state":"5.0","stateDescription":{"minimum":0,"maximum":100,"step":1,"pattern":"%.0f %unit%","readOnly":false,"options":[]},"type":"Dimmer","name":"shellytrvBad_Position","label":"Position","category":"heating","tags":["Point"],"groupNames":["shellytrvBad"]},"widgets":[]},{"widgetId":"1105","type":"Switch","visibility":true,"label":"Heizkörper [away]","labelSource":"SITEMAP_WIDGET","icon":"heating","staticIcon":false,"pattern":"%s","unit":"","mappings":[{"command":"comfort","label":"Comfort"},{"command":"eco","label":"Eco"},{"command":"away","label":"Away"}],"item":{"link":"http://192.168.2.10:8080/rest/items/BlueToothGW_fuer_Heizkoerper_Heating_Profile_200","state":"away","stateDescription":{"pattern":"%s","readOnly":false,"options":[]},"type":"String","name":"BlueToothGW_fuer_Heizkoerper_Heating_Profile_200","label":"Heating Profile 200","tags":["Point"],"groupNames":[]},"widgets":[]},{"widgetId":"1106","type":"Text","visibility":true,"label":"Öffnung [0 %]","labelSource":"SITEMAP_WIDGET","icon":"if:mdi:valve","staticIcon":false,"pattern":"%.0f %unit%","unit":"%","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/BlueToothGW_fuer_Heizkoerper_ValveOpening200","state":"0 %","stateDescription":{"step":1,"pattern":"%.0f %unit%","readOnly":true,"options":[]},"unitSymbol":"%","type":"Number:Dimensionless","name":"BlueToothGW_fuer_Heizkoerper_ValveOpening200","label":"ValveOpening200","tags":["Point"],"groupNames":["BlueToothGW_fuer_Heizkoerper"]},"widgets":[]},{"widgetId":"1107","type":"Text","visibility":true,"label":"Temperatur, gemessen [20.7 °C]","labelSource":"SITEMAP_WIDGET","icon":"material:thermostat","staticIcon":false,"pattern":"%.1f %unit%","unit":"°C","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/BlueToothGW_fuer_Heizkoerper_TemperatureMeasured200inCelsius","state":"20.7 °C","stateDescription":{"step":1,"pattern":"%.1f %unit%","readOnly":true,"options":[]},"unitSymbol":"°C","type":"Number:Temperature","name":"BlueToothGW_fuer_Heizkoerper_TemperatureMeasured200inCelsius","label":"TemperatureMeasured200inCelsius","tags":["Point"],"groupNames":["BlueToothGW_fuer_Heizkoerper"]},"widgets":[]},{"widgetId":"1108","type":"Text","visibility":true,"label":"Temperatur, Ziel [13.0 °C]","labelSource":"SITEMAP_WIDGET","icon":"if:clarity:thermometer-line","staticIcon":false,"labelcolor":"blue","pattern":"%.1f %unit%","unit":"°C","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/BlueToothGW_fuer_Heizkoerper_TemperatureTarget200inCelsius","state":"13 °C","stateDescription":{"step":1,"pattern":"%.1f %unit%","readOnly":true,"options":[]},"unitSymbol":"°C","type":"Number:Temperature","name":"BlueToothGW_fuer_Heizkoerper_TemperatureTarget200inCelsius","label":"TemperatureTarget200inCelsius","tags":["Point"],"groupNames":["BlueToothGW_fuer_Heizkoerper"]},"widgets":[]},{"widgetId":"1109","type":"Switch","visibility":true,"label":"Fußboden [away]","labelSource":"SITEMAP_WIDGET","icon":"heating","staticIcon":false,"pattern":"%s","unit":"","mappings":[{"command":"comfort","label":"Comfort"},{"command":"eco","label":"Eco"},{"command":"away","label":"Away"}],"item":{"link":"http://192.168.2.10:8080/rest/items/BlueToothGW_fuer_Heizkoerper_Heating_Profile_201","state":"away","stateDescription":{"pattern":"%s","readOnly":false,"options":[]},"type":"String","name":"BlueToothGW_fuer_Heizkoerper_Heating_Profile_201","label":"Heating Profile 201","tags":["Point"],"groupNames":[]},"widgets":[]},{"widgetId":"1110","type":"Text","visibility":true,"label":"Öffnung [0 %]","labelSource":"SITEMAP_WIDGET","icon":"f7:airplane","staticIcon":false,"labelcolor":"secondary","iconcolor":"blue","pattern":"%.0f %unit%","unit":"%","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/BlueToothGW_fuer_Heizkoerper_ValveOpening201","state":"0 %","stateDescription":{"step":1,"pattern":"%.0f %unit%","readOnly":true,"options":[]},"unitSymbol":"%","type":"Number:Dimensionless","name":"BlueToothGW_fuer_Heizkoerper_ValveOpening201","label":"ValveOpening201","tags":["Point"],"groupNames":["BlueToothGW_fuer_Heizkoerper"]},"widgets":[]},{"widgetId":"1111","type":"Text","visibility":true,"label":"Temperatur, gemessen [20.8 °C]","labelSource":"SITEMAP_WIDGET","icon":"f7:thermometer","staticIcon":false,"pattern":"%.1f %unit%","unit":"°C","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/BlueToothGW_fuer_Heizkoerper_TemperatureMeasured201inCelsius","state":"20.8 °C","stateDescription":{"step":1,"pattern":"%.1f %unit%","readOnly":true,"options":[]},"unitSymbol":"°C","type":"Number:Temperature","name":"BlueToothGW_fuer_Heizkoerper_TemperatureMeasured201inCelsius","label":"TemperatureMeasured201inCelsius","tags":["Point"],"groupNames":["BlueToothGW_fuer_Heizkoerper"]},"widgets":[]},{"widgetId":"1112","type":"Text","visibility":true,"label":"Temperatur, Ziel [13.0 °C]","labelSource":"SITEMAP_WIDGET","icon":"if:clarity:thermometer-line","staticIcon":true,"pattern":"%.1f %unit%","unit":"°C","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/BlueToothGW_fuer_Heizkoerper_TemperatureTarget201inCelsius","state":"13 °C","stateDescription":{"step":1,"pattern":"%.1f %unit%","readOnly":true,"options":[]},"unitSymbol":"°C","type":"Number:Temperature","name":"BlueToothGW_fuer_Heizkoerper_TemperatureTarget201inCelsius","label":"TemperatureTarget201inCelsius","tags":["Point"],"groupNames":["BlueToothGW_fuer_Heizkoerper"]},"widgets":[]}]},{"widgetId":"12","type":"Frame","visibility":true,"label":"Außen","labelSource":"SITEMAP_WIDGET","icon":"frame","staticIcon":false,"unit":"","mappings":[],"widgets":[{"widgetId":"1200","type":"Switch","visibility":true,"label":"Sprudelstein","labelSource":"SITEMAP_WIDGET","icon":"switch","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/SprudelsteinShellyPlug5shellyplugs9d903d1921682114_Power","state":"ON","type":"Switch","name":"SprudelsteinShellyPlug5shellyplugs9d903d1921682114_Power","label":"Power","category":"Switch","tags":["Point"],"groupNames":["SprudelsteinShellyPlug5shellyplugs9d903d1921682114"]},"widgets":[]},{"widgetId":"1201","type":"Switch","visibility":true,"label":"Weihnachtsbeleuchtung","labelSource":"SITEMAP_WIDGET","icon":"poweroutlet_eu","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/xmasSwitch","state":"OFF","type":"Switch","name":"xmasSwitch","label":"xmasSwitch","category":"poweroutlet_eu","tags":["PowerOutlet"],"groupNames":["gJosefstr8a"]},"widgets":[]},{"widgetId":"1202","type":"Switch","visibility":true,"label":"Außenlichter","labelSource":"ITEM_LABEL","icon":"light","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/lcnLightSwitch11_1","state":"OFF","type":"Switch","name":"lcnLightSwitch11_1","label":"Außenlichter","category":"light","tags":["Lightbulb"],"groupNames":["gAussen"]},"widgets":[]},{"widgetId":"1203","type":"Switch","visibility":true,"label":"Schuppen","labelSource":"ITEM_LABEL","icon":"light","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/shelly110521c4553be192168249_Power","state":"OFF","type":"Switch","name":"shelly110521c4553be192168249_Power","label":"Schuppen","category":"light","tags":["Light","Switch"],"groupNames":["shelly110521c4553be192168249"]},"widgets":[]},{"widgetId":"1204","type":"Text","visibility":true,"label":"Außentemperatur [23.6 °C]","labelSource":"SITEMAP_WIDGET","icon":"temperature","staticIcon":false,"pattern":"%.1f %unit%","unit":"°C","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/BlackShellyHT1_temperature","state":"23.6 °C","stateDescription":{"step":1,"pattern":"%.1f %unit%","readOnly":true,"options":[]},"unitSymbol":"°C","type":"Number:Temperature","name":"BlackShellyHT1_temperature","label":"temperature","tags":["Point"],"groupNames":["BlackShellyHT1"]},"widgets":[]},{"widgetId":"1205","type":"Switch","visibility":true,"label":"Numeric State [2]","labelSource":"ITEM_LABEL","icon":"","staticIcon":false,"pattern":"%.0f","unit":"","mappings":[{"command":"1","label":"Mow"},{"command":"2","label":"Return"},{"command":"3","label":"Pause"}],"item":{"link":"http://192.168.2.10:8080/rest/items/josefschaf_Numeric_State","state":"2.0","stateDescription":{"pattern":"%.0f","readOnly":false,"options":[{"value":"1","label":"Mow"},{"value":"2","label":"Charge/Dock"},{"value":"3","label":"Pause"}]},"commandDescription":{"commandOptions":[{"command":"1","label":"Mow"},{"command":"2","label":"Charge/Dock"},{"command":"3","label":"Pause"}]},"type":"Number","name":"josefschaf_Numeric_State","label":"Numeric State","category":"","tags":["Point"],"groupNames":["josefschaf"]},"widgets":[]}]},{"widgetId":"13","type":"Frame","visibility":true,"label":"Jalousien EG","labelSource":"SITEMAP_WIDGET","icon":"frame","staticIcon":false,"unit":"","mappings":[],"widgets":[{"widgetId":"1300","type":"Switch","visibility":true,"label":"EG Jalousien","labelSource":"ITEM_LABEL","icon":"blinds","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/EGJalousien","state":"0.0","type":"Rollershutter","name":"EGJalousien","label":"EG Jalousien","category":"blinds","tags":[],"groupNames":[]},"widgets":[]},{"widgetId":"1301","type":"Switch","visibility":true,"label":"EG Jalousien Süd","labelSource":"ITEM_LABEL","icon":"blinds","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/EGJalousienSued","state":"100","type":"Rollershutter","name":"EGJalousienSued","label":"EG Jalousien Süd","category":"blinds","tags":[],"groupNames":[]},"widgets":[]},{"widgetId":"1302","type":"Group","visibility":true,"label":"EG Jalousien Süd","labelSource":"ITEM_LABEL","icon":"blinds","staticIcon":false,"unit":"","mappings":[],"item":{"members":[],"groupType":"Rollershutter","function":{"name":"EQUALITY"},"link":"http://192.168.2.10:8080/rest/items/gEGJalousienSued","state":"0","type":"Group","name":"gEGJalousienSued","label":"EG Jalousien Süd","category":"blinds","tags":["Blinds"],"groupNames":["gEGJalousien"]},"linkedPage":{"id":"1302","title":"EG Jalousien Süd","icon":"blinds","link":"http://192.168.2.10:8080/rest/sitemaps/uicomponents_page_myHome/1302","leaf":true,"timeout":false,"widgets":[{"widgetId":"130200","type":"Switch","visibility":true,"label":"Jalousie WZ Süd links","labelSource":"ITEM_LABEL","icon":"blinds","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/lcnJalousieWZSuedLinks","state":"0","type":"Rollershutter","name":"lcnJalousieWZSuedLinks","label":"Jalousie WZ Süd links","category":"blinds","tags":["Point"],"groupNames":["gEGJalousien","gEGJalousienSued","Module54StrgJalousie1"]},"widgets":[]},{"widgetId":"130201","type":"Switch","visibility":true,"label":"Jalousie WZ Süd Mitte","labelSource":"ITEM_LABEL","icon":"blinds","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/lcnJalousieWZSuedMitte","state":"0","type":"Rollershutter","name":"lcnJalousieWZSuedMitte","label":"Jalousie WZ Süd Mitte","category":"blinds","tags":["Point"],"groupNames":["gEGJalousien","gEGJalousienSued","Module54StrgJalousie1"]},"widgets":[]},{"widgetId":"130202","type":"Switch","visibility":true,"label":"Jalousie WZ Süd rechts","labelSource":"ITEM_LABEL","icon":"blinds","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/lcnJalousieWZSuedRechts","state":"0","type":"Rollershutter","name":"lcnJalousieWZSuedRechts","label":"Jalousie WZ Süd rechts","category":"blinds","tags":["Point"],"groupNames":["gEGJalousien","gEGJalousienSued","Module55StrgJalousie2"]},"widgets":[]}]},"widgets":[]},{"widgetId":"1303","type":"Switch","visibility":true,"label":"EG Jalousien West","labelSource":"ITEM_LABEL","icon":"blinds","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/EGJalousienWest","state":"100","type":"Rollershutter","name":"EGJalousienWest","label":"EG Jalousien West","category":"blinds","tags":[],"groupNames":[]},"widgets":[]},{"widgetId":"1304","type":"Group","visibility":true,"label":"EG Jalousien West","labelSource":"ITEM_LABEL","icon":"blinds","staticIcon":false,"unit":"","mappings":[],"item":{"members":[],"groupType":"Rollershutter","function":{"name":"EQUALITY"},"link":"http://192.168.2.10:8080/rest/items/gEGJalousienWest","state":"0","type":"Group","name":"gEGJalousienWest","label":"EG Jalousien West","category":"blinds","tags":["Blinds"],"groupNames":["gEGJalousien"]},"linkedPage":{"id":"1304","title":"EG Jalousien West","icon":"blinds","link":"http://192.168.2.10:8080/rest/sitemaps/uicomponents_page_myHome/1304","leaf":true,"timeout":false,"widgets":[{"widgetId":"130400","type":"Switch","visibility":true,"label":"Jalousie WZ West links","labelSource":"ITEM_LABEL","icon":"blinds","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/lcnJalousieWZWestLinks","state":"0","type":"Rollershutter","name":"lcnJalousieWZWestLinks","label":"Jalousie WZ West links","category":"blinds","tags":["Point"],"groupNames":["gEGJalousien","gEGJalousienWest","Module54StrgJalousie1"]},"widgets":[]},{"widgetId":"130401","type":"Switch","visibility":true,"label":"Jalousie WZ West Mitte","labelSource":"ITEM_LABEL","icon":"blinds","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/lcnJalousieWZWestMitte","state":"0","type":"Rollershutter","name":"lcnJalousieWZWestMitte","label":"Jalousie WZ West Mitte","category":"blinds","tags":["Point","Opening"],"groupNames":["gEGJalousien","gEGJalousienWest","Module54StrgJalousie1"]},"widgets":[]},{"widgetId":"130402","type":"Switch","visibility":true,"label":"Jalousie WZ West rechts","labelSource":"ITEM_LABEL","icon":"blinds","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/lcnJalousieWZWestRechts","state":"0","type":"Rollershutter","name":"lcnJalousieWZWestRechts","label":"Jalousie WZ West rechts","category":"blinds","tags":["Point"],"groupNames":["gEGJalousien","gEGJalousienWest","Module55StrgJalousie2"]},"widgets":[]}]},"widgets":[]}]},{"widgetId":"14","type":"Frame","visibility":true,"label":"Doorbird","labelSource":"SITEMAP_WIDGET","icon":"frame","staticIcon":false,"unit":"","mappings":[],"widgets":[{"widgetId":"1400","type":"Switch","visibility":true,"label":"Power","labelSource":"ITEM_LABEL","icon":"switch","staticIcon":false,"unit":"","mappings":[{"command":"ON","label":"aufdrücken"}],"item":{"link":"http://192.168.2.10:8080/rest/items/Shelly_1_Klingel_Power","state":"OFF","type":"Switch","name":"Shelly_1_Klingel_Power","label":"Power","category":"Switch","tags":["Power","Switch"],"groupNames":["Shelly_1_Klingel"]},"widgets":[]}]},{"widgetId":"15","type":"Frame","visibility":true,"label":"PresenceSimulation","labelSource":"SITEMAP_WIDGET","icon":"frame","staticIcon":false,"unit":"","mappings":[],"widgets":[{"widgetId":"1500","type":"Switch","visibility":true,"label":"Anwesenheitssimulation","labelSource":"ITEM_LABEL","icon":"switch","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/PresenceSimulation","state":"OFF","type":"Switch","name":"PresenceSimulation","label":"Anwesenheitssimulation","tags":[],"groupNames":[]},"widgets":[]}]}]}} From 5bdbd79cdb25611be8944253ce92c70543e9c726 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Sun, 13 Jul 2025 13:45:20 +0200 Subject: [PATCH 248/476] Remove debug data Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- sitemap.json | 1 - 1 file changed, 1 deletion(-) delete mode 100644 sitemap.json diff --git a/sitemap.json b/sitemap.json deleted file mode 100644 index 4bab2b82e..000000000 --- a/sitemap.json +++ /dev/null @@ -1 +0,0 @@ -{"name":"uicomponents_page_myHome","label":"MyHome","link":"http://192.168.2.10:8080/rest/sitemaps/uicomponents_page_myHome","homepage":{"id":"uicomponents_page_myHome","title":"MyHome","link":"http://192.168.2.10:8080/rest/sitemaps/uicomponents_page_myHome/uicomponents_page_myHome","leaf":false,"timeout":false,"widgets":[{"widgetId":"00","type":"Frame","visibility":true,"label":"Treppe","labelSource":"SITEMAP_WIDGET","icon":"frame","staticIcon":false,"unit":"","mappings":[],"widgets":[{"widgetId":"0000","type":"Switch","visibility":true,"label":"Treppe Keller-EG","labelSource":"SITEMAP_WIDGET","icon":"light","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/shelly1pmminiKellerFlur_Power","state":"OFF","type":"Switch","name":"shelly1pmminiKellerFlur_Power","label":"Power","category":"Switch","tags":["Power","Switch"],"groupNames":["shelly1pmminiKellerFlur"]},"widgets":[]},{"widgetId":"0001","type":"Switch","visibility":true,"label":"Treppe EG-1","labelSource":"ITEM_LABEL","icon":"light","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/lcnLightSwitch10_2","state":"OFF","type":"Switch","name":"lcnLightSwitch10_2","label":"Treppe EG-1","category":"light","tags":["Light","Switch"],"groupNames":["Module10EGTreppenachoben"]},"widgets":[]},{"widgetId":"0002","type":"Switch","visibility":true,"label":"Treppe 1-2","labelSource":"ITEM_LABEL","icon":"light","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/lcnLightSwitch32_1","state":"OFF","type":"Switch","name":"lcnLightSwitch32_1","label":"Treppe 1-2","category":"light","tags":["Light","Switch"],"groupNames":["Module321OGTreppenachobe","gPresenceSimulation"]},"widgets":[]},{"widgetId":"0003","type":"Switch","visibility":true,"label":"Flur 1","labelSource":"SITEMAP_WIDGET","icon":"light","staticIcon":false,"unit":"","mappings":[],"state":"OFF","item":{"link":"http://192.168.2.10:8080/rest/items/Module_33_1OG_zum_KiZi_Output_1","state":"0","type":"Dimmer","name":"Module_33_1OG_zum_KiZi_Output_1","label":"Output 1","category":"","tags":["Point"],"groupNames":["Module_33_1OG_zum_KiZi"]},"widgets":[]}]},{"widgetId":"01","type":"Frame","visibility":true,"label":"Eingang","labelSource":"SITEMAP_WIDGET","icon":"frame","staticIcon":false,"unit":"","mappings":[],"widgets":[{"widgetId":"0100","type":"Switch","visibility":true,"label":"Eingang","labelSource":"ITEM_LABEL","icon":"light","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/lcnLightSwitch17_1","state":"OFF","type":"Switch","name":"lcnLightSwitch17_1","label":"Eingang","category":"light","tags":["Light","Switch"],"groupNames":["Module17EGEingang","gPresenceSimulation"]},"widgets":[]},{"widgetId":"0101","type":"Switch","visibility":true,"label":"Eingang aussen","labelSource":"ITEM_LABEL","icon":"light","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/lcnLightSwitch17_2","state":"OFF","type":"Switch","name":"lcnLightSwitch17_2","label":"Eingang aussen","category":"light","tags":["Light","Switch"],"groupNames":["Module17EGEingang","gPresenceSimulation"]},"widgets":[]}]},{"widgetId":"02","type":"Frame","visibility":true,"label":"WC","labelSource":"SITEMAP_WIDGET","icon":"frame","staticIcon":false,"unit":"","mappings":[],"widgets":[{"widgetId":"0200","type":"Switch","visibility":true,"label":"WC [OFF]","labelSource":"SITEMAP_WIDGET","icon":"light","staticIcon":false,"pattern":"%s","unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/Josef021__Shelly_1Mini_Gen_3__WC_MQTT_Switch","state":"OFF","stateDescription":{"pattern":"%s","readOnly":false,"options":[]},"type":"Switch","name":"Josef021__Shelly_1Mini_Gen_3__WC_MQTT_Switch","label":"Switch","tags":["Point"],"groupNames":[]},"widgets":[]}]},{"widgetId":"03","type":"Frame","visibility":true,"label":"Keller","labelSource":"SITEMAP_WIDGET","icon":"frame","staticIcon":false,"unit":"","mappings":[],"widgets":[{"widgetId":"0300","type":"Switch","visibility":true,"label":"Bad Decke","labelSource":"SITEMAP_WIDGET","icon":"light","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/Gastebadshellyswitch25_Power_1","state":"OFF","type":"Switch","name":"Gastebadshellyswitch25_Power_1","label":"Power 1","category":"Switch","tags":["Power","Switch"],"groupNames":["Gastebadshellyswitch25"]},"widgets":[]},{"widgetId":"0301","type":"Switch","visibility":true,"label":"Bad Spiegel","labelSource":"SITEMAP_WIDGET","icon":"light","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/Gastebadshellyswitch25_Power_2","state":"OFF","type":"Switch","name":"Gastebadshellyswitch25_Power_2","label":"Power 2","category":"Switch","tags":["Power","Switch"],"groupNames":["Gastebadshellyswitch25"]},"widgets":[]},{"widgetId":"0302","type":"Switch","visibility":true,"label":"Keller Lager Decke","labelSource":"ITEM_LABEL","icon":"light","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/lcnLightSwitch8_1","state":"OFF","type":"Switch","name":"lcnLightSwitch8_1","label":"Keller Lager Decke","category":"light","tags":["Light","Switch"],"groupNames":["Module8KellerLager"]},"widgets":[]},{"widgetId":"0303","type":"Switch","visibility":true,"label":"Gäste Decke","labelSource":"ITEM_LABEL","icon":"light","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/shellyswitch2540f52005312f_Relay2_Output","state":"OFF","type":"Switch","name":"shellyswitch2540f52005312f_Relay2_Output","label":"Gäste Decke","category":"light","tags":["Light","Switch"],"groupNames":["shellyswitch2540f52005312f"]},"widgets":[]},{"widgetId":"0304","type":"Switch","visibility":true,"label":"Keller Heizung Decke","labelSource":"ITEM_LABEL","icon":"light","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/lcnLightSwitch7_1","state":"OFF","type":"Switch","name":"lcnLightSwitch7_1","label":"Keller Heizung Decke","category":"light","tags":["Light","Switch"],"groupNames":["Module7KellerHeizungsraum"]},"widgets":[]},{"widgetId":"0305","type":"Switch","visibility":true,"label":"Laserjet Power","labelSource":"ITEM_LABEL","icon":"switch","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/ShellyPlugBigshellyplug28433e192168275_Power","state":"OFF","type":"Switch","name":"ShellyPlugBigshellyplug28433e192168275_Power","label":"Laserjet Power","category":"Switch","tags":["Power","Switch"],"groupNames":["ShellyPlugBigshellyplug28433e192168275"]},"widgets":[]}]},{"widgetId":"04","type":"Frame","visibility":true,"label":"DFF","labelSource":"SITEMAP_WIDGET","icon":"frame","staticIcon":false,"unit":"","mappings":[],"widgets":[{"widgetId":"0400","type":"Switch","visibility":true,"label":"DFF Emma","labelSource":"ITEM_LABEL","icon":"blinds","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/lcnDFFWest","state":"100","type":"Rollershutter","name":"lcnDFFWest","label":"DFF Emma","category":"blinds","tags":[],"groupNames":["g2OGEmma"]},"widgets":[]},{"widgetId":"0401","type":"Switch","visibility":true,"label":"DFF Arbeitsplatz","labelSource":"ITEM_LABEL","icon":"blinds","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/lcnDFFOst","state":"100","type":"Rollershutter","name":"lcnDFFOst","label":"DFF Arbeitsplatz","category":"blinds","tags":[],"groupNames":["g2OG"]},"widgets":[]}]},{"widgetId":"05","type":"Frame","visibility":true,"label":"DG","labelSource":"SITEMAP_WIDGET","icon":"frame","staticIcon":false,"unit":"","mappings":[],"widgets":[{"widgetId":"0500","type":"Switch","visibility":true,"label":"Kofferabteil","labelSource":"ITEM_LABEL","icon":"light","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/lcnLightSwitchRel42_8","state":"OFF","type":"Switch","name":"lcnLightSwitchRel42_8","label":"Kofferabteil","category":"light","tags":["Light","Switch"],"groupNames":["Module42DGStrgRelais"]},"widgets":[]},{"widgetId":"0501","type":"Switch","visibility":true,"label":"Arbeitsplatz","labelSource":"SITEMAP_WIDGET","icon":"light","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/lcnLightSwitch44_2","state":"OFF","type":"Switch","name":"lcnLightSwitch44_2","label":"DG Zimmer","category":"light","tags":["Light","Switch"],"groupNames":["gZimmerEmma"]},"widgets":[]}]},{"widgetId":"06","type":"Frame","visibility":true,"label":"Emma","labelSource":"SITEMAP_WIDGET","icon":"frame","staticIcon":false,"unit":"","mappings":[],"widgets":[{"widgetId":"0600","type":"Switch","visibility":true,"label":"DG Zimmer Decke","labelSource":"ITEM_LABEL","icon":"light","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/Shelly1PMDGFenster_Power","state":"OFF","type":"Switch","name":"Shelly1PMDGFenster_Power","label":"DG Zimmer Decke","category":"light","tags":["Power","Switch"],"groupNames":["Shelly1PMDGFenster"]},"widgets":[]},{"widgetId":"0601","type":"Switch","visibility":true,"label":"DG Zimmer Occhio","labelSource":"ITEM_LABEL","icon":"light","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/lcnLightSwitch44_1","state":"OFF","type":"Switch","name":"lcnLightSwitch44_1","label":"DG Zimmer Occhio","category":"light","tags":["Light","Switch"],"groupNames":["Module44DGbeiTuroben"]},"widgets":[]},{"widgetId":"0602","type":"Switch","visibility":true,"label":"Steckdose links","labelSource":"SITEMAP_WIDGET","icon":"poweroutlet_eu","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/shellyplugs978352192168241_Power","state":"OFF","type":"Switch","name":"shellyplugs978352192168241_Power","label":"Power","category":"poweroutlet_eu","tags":["Point"],"groupNames":["shellyplugs978352192168241"]},"widgets":[]},{"widgetId":"0603","type":"Switch","visibility":true,"label":"Heizprofil [2: Abwesenheit]","labelSource":"SITEMAP_WIDGET","icon":"heating","staticIcon":false,"pattern":"%s","unit":"","mappings":[{"command":"1","label":"Normal"},{"command":"2","label":"Abwesenheit"},{"command":"3","label":"NormalReduziert"}],"item":{"link":"http://192.168.2.10:8080/rest/items/shellytrvEmma_Selected_Profile","state":"2","stateDescription":{"pattern":"%s","readOnly":false,"options":[{"value":"1","label":"1: Normal"},{"value":"2","label":"2: Abwesenheit"},{"value":"3","label":"3: AnwesenheitMitDecke"},{"value":"4","label":"4: Bedroom 1"},{"value":"5","label":"5: Holiday"}]},"commandDescription":{"commandOptions":[{"command":"1","label":"1: Normal"},{"command":"2","label":"2: Abwesenheit"},{"command":"3","label":"3: AnwesenheitMitDecke"},{"command":"4","label":"4: Bedroom 1"},{"command":"5","label":"5: Holiday"}]},"type":"String","name":"shellytrvEmma_Selected_Profile","label":"Selected Profile","category":"","tags":["Point"],"groupNames":[]},"widgets":[]},{"widgetId":"0604","type":"Text","visibility":true,"label":"Position [0]","labelSource":"ITEM_LABEL","icon":"heating","staticIcon":false,"labelcolor":"blue","valuecolor":"green","pattern":"%.0f %unit%","unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/shellytrvEmma_Position","state":"0","stateDescription":{"minimum":0,"maximum":100,"step":1,"pattern":"%.0f %unit%","readOnly":false,"options":[]},"type":"Dimmer","name":"shellytrvEmma_Position","label":"Position","category":"heating","tags":["Point"],"groupNames":["shellytrvEmma"]},"widgets":[]}]},{"widgetId":"07","type":"Frame","visibility":true,"label":"Schlafzimmer","labelSource":"SITEMAP_WIDGET","icon":"frame","staticIcon":false,"unit":"","mappings":[],"widgets":[{"widgetId":"0700","type":"Switch","visibility":true,"label":"SZ Decke","labelSource":"ITEM_LABEL","icon":"light","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/lcnLightSwitch37_1","state":"OFF","type":"Switch","name":"lcnLightSwitch37_1","label":"SZ Decke","category":"light","tags":["Light","Switch"],"groupNames":["Module371OGSZbeiTur"]},"widgets":[]},{"widgetId":"0701","type":"Switch","visibility":true,"label":"Ankleide","labelSource":"ITEM_LABEL","icon":"light","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/AnkleideRelais_Power","state":"ON","type":"Switch","name":"AnkleideRelais_Power","label":"Ankleide","category":"light","tags":["Light","Switch"],"groupNames":["AnkleideRelais"]},"widgets":[]},{"widgetId":"0702","type":"Switch","visibility":true,"label":"Jalousie SZ","labelSource":"ITEM_LABEL","icon":"blinds","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/lcnJalousieSZ","state":"100","type":"Rollershutter","name":"lcnJalousieSZ","label":"Jalousie SZ","category":"blinds","tags":["Point"],"groupNames":["Module56StrgJalousie3BS"]},"widgets":[]},{"widgetId":"0703","type":"Selection","visibility":true,"label":"Profil [1]","labelSource":"SITEMAP_WIDGET","icon":"heating","staticIcon":false,"pattern":"%s","unit":"","mappings":[{"command":"1","label":"Normal"},{"command":"2","label":"Abwesenheit"}],"item":{"link":"http://192.168.2.10:8080/rest/items/shellytrv_Schlafzimmer_Selected_Profile","state":"1","stateDescription":{"pattern":"%s","readOnly":false,"options":[]},"type":"String","name":"shellytrv_Schlafzimmer_Selected_Profile","label":"Selected Profile","category":"","tags":["Point"],"groupNames":["shellytrv_Schlafzimmer"]},"widgets":[]}]},{"widgetId":"08","type":"Frame","visibility":true,"label":"Paul","labelSource":"SITEMAP_WIDGET","icon":"frame","staticIcon":false,"unit":"","mappings":[],"widgets":[{"widgetId":"0800","type":"Switch","visibility":true,"label":"Paul Decke","labelSource":"ITEM_LABEL","icon":"light","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/lcnLightSwitch36_2","state":"OFF","type":"Switch","name":"lcnLightSwitch36_2","label":"Paul Decke","category":"light","tags":["Light","Switch"],"groupNames":["Module361OGZimmerPaul"]},"widgets":[]},{"widgetId":"0801","type":"Switch","visibility":true,"label":"Steckdose Zimmer Paul","labelSource":"ITEM_LABEL","icon":"poweroutlet_eu","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/lcnRelay36_1","state":"OFF","type":"Switch","name":"lcnRelay36_1","label":"Steckdose Zimmer Paul","category":"poweroutlet_eu","tags":["Point"],"groupNames":["Module361OGZimmerPaul"]},"widgets":[]},{"widgetId":"0802","type":"Switch","visibility":true,"label":"Jalousien Paul","labelSource":"SITEMAP_WIDGET","icon":"blinds","staticIcon":false,"unit":"","mappings":[],"state":"ON","item":{"members":[],"groupType":"Rollershutter","function":{"name":"EQUALITY"},"link":"http://192.168.2.10:8080/rest/items/g1OGJalousienPaul","state":"100","type":"Group","name":"g1OGJalousienPaul","label":"Jalousien Paul","category":"blinds","tags":["Blinds"],"groupNames":["g1OGJalousien"]},"widgets":[]},{"widgetId":"0803","type":"Group","visibility":true,"label":"Zimmer Paul","labelSource":"SITEMAP_WIDGET","icon":"rollershutter","staticIcon":false,"unit":"","mappings":[],"item":{"members":[],"groupType":"Rollershutter","function":{"name":"EQUALITY"},"link":"http://192.168.2.10:8080/rest/items/g1OGJalousienPaul","state":"100","type":"Group","name":"g1OGJalousienPaul","label":"Jalousien Paul","category":"blinds","tags":["Blinds"],"groupNames":["g1OGJalousien"]},"linkedPage":{"id":"0803","title":"Zimmer Paul","icon":"rollershutter","link":"http://192.168.2.10:8080/rest/sitemaps/uicomponents_page_myHome/0803","leaf":true,"timeout":false,"widgets":[{"widgetId":"080300","type":"Switch","visibility":true,"label":"Jalouise Paul Süd rechts","labelSource":"SITEMAP_WIDGET","icon":"blinds","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/lcnJalousieKZSuedLinks","state":"100","type":"Rollershutter","name":"lcnJalousieKZSuedLinks","label":"Jalousie Paul Süd links","category":"blinds","tags":["Point"],"groupNames":["g1OGJalousien","g1OGJalousienPaul","Module55StrgJalousie2"]},"widgets":[]},{"widgetId":"080301","type":"Switch","visibility":true,"label":"Jalousie Paul Süd links","labelSource":"SITEMAP_WIDGET","icon":"blinds","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/lcnJalousieKZSuedRechts","state":"100","type":"Rollershutter","name":"lcnJalousieKZSuedRechts","label":"Jalousie Paul Süd rechts","category":"blinds","tags":["Point"],"groupNames":["g1OGJalousien","gJalousien","g1OGJalousienPaul","Module55StrgJalousie2"]},"widgets":[]},{"widgetId":"080302","type":"Switch","visibility":true,"label":"Jalousie Paul West","labelSource":"ITEM_LABEL","icon":"blinds","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/lcnJalousieKZWest","state":"100","type":"Rollershutter","name":"lcnJalousieKZWest","label":"Jalousie Paul West","category":"blinds","tags":["Point"],"groupNames":["g1OGJalousien","gJalousien","g1OGJalousienPaul","Module56StrgJalousie3BS"]},"widgets":[]}]},"widgets":[]},{"widgetId":"0804","type":"Setpoint","visibility":true,"label":"Solltemperatur [6.0 °C]","labelSource":"SITEMAP_WIDGET","icon":"heating","staticIcon":false,"pattern":"%.1f %unit%","unit":"°C","mappings":[],"minValue":8.0,"maxValue":25.0,"step":0.5,"item":{"link":"http://192.168.2.10:8080/rest/items/Comet_DECT_4_rechts_Setpoint_Temperature","state":"6 °C","stateDescription":{"pattern":"%.1f %unit%","readOnly":false,"options":[]},"unitSymbol":"°C","type":"Number:Temperature","name":"Comet_DECT_4_rechts_Setpoint_Temperature","label":"Setpoint Temperature","category":"Heating","tags":["Setpoint","Temperature"],"groupNames":["Comet_DECT_4_rechts"]},"widgets":[]},{"widgetId":"0805","type":"Text","visibility":true,"label":"Pauli links [19.0 °C]","labelSource":"SITEMAP_WIDGET","icon":"temperature","staticIcon":false,"pattern":"%.1f %unit%","unit":"°C","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/Comet_DECT_3_links_Actual_Temp","state":"19 °C","stateDescription":{"pattern":"%.1f %unit%","readOnly":true,"options":[]},"unitSymbol":"°C","type":"Number:Temperature","name":"Comet_DECT_3_links_Actual_Temp","label":"Current Temperature","category":"Temperature","tags":["Temperature","Measurement"],"groupNames":["Comet_DECT_3_links"]},"widgets":[]},{"widgetId":"0806","type":"Text","visibility":true,"label":"Pauli rechts [19.5 °C]","labelSource":"SITEMAP_WIDGET","icon":"temperature","staticIcon":false,"pattern":"%.1f %unit%","unit":"°C","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/Comet_DECT_4_rechts_Actual_Temp","state":"19.5 °C","stateDescription":{"pattern":"%.1f %unit%","readOnly":true,"options":[]},"unitSymbol":"°C","type":"Number:Temperature","name":"Comet_DECT_4_rechts_Actual_Temp","label":"Current Temperature","category":"Temperature","tags":["Temperature","Measurement"],"groupNames":["Comet_DECT_4_rechts"]},"widgets":[]},{"widgetId":"0807","type":"Selection","visibility":true,"label":"Zimmer Paul [tmp6B3829-3F8784AF9]","labelSource":"SITEMAP_WIDGET","icon":"heating","staticIcon":false,"pattern":"%s","unit":"","mappings":[{"command":"tmp6B3829-3EC68BF9D","label":"Normal"},{"command":"tmp6B3829-3EC68D7CE","label":"Abwesend"},{"command":"tmp6B3829-3F878534B","label":"Boost"},{"command":"tmp6B3829-3F8784AF9","label":"Arbeiten"}],"item":{"link":"http://192.168.2.10:8080/rest/items/joseffritzfiber_Apply_Template","state":"tmp6B3829-3F8784AF9","stateDescription":{"pattern":"%s","readOnly":false,"options":[]},"commandDescription":{"commandOptions":[{"command":"tmp6B3829-3F8783BB0","label":"Anwesenheit wärmer"},{"command":"tmp6B3829-3EC68C768","label":"Gästebad"},{"command":"tmp6B3829-3EC68D012","label":"Gästebad Abwesenheit"},{"command":"tmp6B3829-3EC68C8E4","label":"Gästezimmer"},{"command":"tmp6B3829-3EC68D5CF","label":"Gästezimmer Abwesenheit"},{"command":"tmp6B3829-3EC68BF9D","label":"Paul"},{"command":"tmp6B3829-3EC68D7CE","label":"Paul Abwesenheit"},{"command":"tmp6B3829-3F8784AF9","label":"PaulArbeiten"},{"command":"tmp6B3829-3F878534B","label":"PaulBoost"},{"command":"tmp6B3829-3EC68C11F","label":"Schlafzimmer"},{"command":"tmp6B3829-3EC68D9CB","label":"Schlafzimmer Abwesenheit"},{"command":"tmp6B3829-3F89206E0","label":"Schlafzimmer Boost"},{"command":"tmp6B3829-3F59B377F","label":"Treppenhaus"},{"command":"tmp6B3829-3F59B3452","label":"Treppenhaus Abwesenheit "}]},"type":"String","name":"joseffritzfiber_Apply_Template","label":"joseffritzfiber Apply Template","category":"","tags":["Point"],"groupNames":["joseffritzfiber"]},"widgets":[]}]},{"widgetId":"09","type":"Frame","visibility":true,"label":"Wohnzimmer","labelSource":"SITEMAP_WIDGET","icon":"frame","staticIcon":false,"unit":"","mappings":[],"widgets":[{"widgetId":"0900","type":"Switch","visibility":true,"label":"Steckdose WZ Nord","labelSource":"ITEM_LABEL","icon":"poweroutlet_eu","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/lcnRelayWZNord","state":"OFF","type":"Switch","name":"lcnRelayWZNord","label":"Steckdose WZ Nord","category":"poweroutlet_eu","tags":["Point"],"groupNames":["Module53StrgSchaltsteckdose"]},"widgets":[]},{"widgetId":"0901","type":"Switch","visibility":true,"label":"Steckdose Ecke SW","labelSource":"SITEMAP_WIDGET","icon":"light","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/shellyplugs163b96192168253_Power","state":"OFF","type":"Switch","name":"shellyplugs163b96192168253_Power","label":"Power","category":"poweroutlet_eu","tags":["Point"],"groupNames":["shellyplugs163b96192168253"]},"widgets":[]},{"widgetId":"0902","type":"Switch","visibility":true,"label":"Tolomeo West","labelSource":"SITEMAP_WIDGET","icon":"light","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/shellyplusplugs08f9e0fe0844__Tolomeo_Power","state":"OFF","type":"Switch","name":"shellyplusplugs08f9e0fe0844__Tolomeo_Power","label":"Power","category":"Switch","tags":["Power","Switch"],"groupNames":["shellyplusplugs08f9e0fe0844__Tolomeo"]},"widgets":[]},{"widgetId":"0903","type":"Switch","visibility":true,"label":"Tolomeo Ost","labelSource":"SITEMAP_WIDGET","icon":"light","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/shellyplusplugsd48afc7a92d0__Tolomeo2_Power","state":"OFF","type":"Switch","name":"shellyplusplugsd48afc7a92d0__Tolomeo2_Power","label":"Power","category":"Switch","tags":["Power","Switch"],"groupNames":["shellyplusplugsd48afc7a92d0__Tolomeo2"]},"widgets":[]},{"widgetId":"0904","type":"Selection","visibility":true,"label":"Szene [-]","labelSource":"SITEMAP_WIDGET","icon":"rgb","staticIcon":false,"pattern":"%s","unit":"","mappings":[{"command":"1","label":"Normal"},{"command":"2","label":"Abwesenheit"}],"item":{"link":"http://192.168.2.10:8080/rest/items/sWohnzimmer","state":"NULL","stateDescription":{"pattern":"%s","readOnly":false,"options":[]},"type":"String","name":"sWohnzimmer","label":"Szene","category":"scene","tags":[],"groupNames":[]},"widgets":[]}]},{"widgetId":"10","type":"Frame","visibility":true,"label":"Küche","labelSource":"SITEMAP_WIDGET","icon":"frame","staticIcon":false,"unit":"","mappings":[],"widgets":[{"widgetId":"1000","type":"Switch","visibility":true,"label":"Oberlicht","labelSource":"ITEM_LABEL","icon":"light","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/lcnLightSwitch14_1","state":"OFF","type":"Switch","name":"lcnLightSwitch14_1","label":"Oberlicht","category":"light","tags":["Light","Switch"],"groupNames":["Module14EGzwKamundKueunten"]},"widgets":[]},{"widgetId":"1001","type":"Switch","visibility":true,"label":"Unterlicht","labelSource":"SITEMAP_WIDGET","icon":"light","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/lcnLightSwitch15_2","state":"OFF","type":"Switch","name":"lcnLightSwitch15_2","label":"Küche Unterlicht","category":"light","tags":["Light","Switch"],"groupNames":["Module15EGzwKamundKueob"]},"widgets":[]},{"widgetId":"1002","type":"Switch","visibility":true,"label":"Küchenblock","labelSource":"SITEMAP_WIDGET","icon":"light","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/lcnLightSwitch15_1","state":"OFF","type":"Switch","name":"lcnLightSwitch15_1","label":"Küche Oberlicht","category":"light","tags":["Light","Switch"],"groupNames":["Module15EGzwKamundKueob","gPresenceSimulation"]},"widgets":[]},{"widgetId":"1003","type":"Switch","visibility":true,"label":"Esstisch","labelSource":"ITEM_LABEL","icon":"light","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/lcnLightSwitch20_1","state":"OFF","type":"Switch","name":"lcnLightSwitch20_1","label":"Esstisch","category":"light","tags":["Light","Switch"],"groupNames":["Module20EGWZnaheVitrine","gPresenceSimulation"]},"widgets":[]}]},{"widgetId":"11","type":"Frame","visibility":true,"label":"Bad","labelSource":"SITEMAP_WIDGET","icon":"frame","staticIcon":false,"unit":"","mappings":[],"widgets":[{"widgetId":"1100","type":"Switch","visibility":true,"label":"Spiegel [OFF]","labelSource":"SITEMAP_WIDGET","icon":"light","staticIcon":false,"pattern":"%s","unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/Generic_MQTT_Thing_Switch","state":"OFF","stateDescription":{"pattern":"%s","readOnly":false,"options":[]},"type":"Switch","name":"Generic_MQTT_Thing_Switch","label":"Bad0","tags":["Point"],"groupNames":["Generic_MQTT_Thing"]},"widgets":[]},{"widgetId":"1101","type":"Switch","visibility":true,"label":"Decke [OFF]","labelSource":"SITEMAP_WIDGET","icon":"light","staticIcon":false,"pattern":"%s","unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/Generic_MQTT_Thing_Switch1","state":"OFF","stateDescription":{"pattern":"%s","readOnly":false,"options":[]},"type":"Switch","name":"Generic_MQTT_Thing_Switch1","label":"Switch1","tags":["Point"],"groupNames":["Generic_MQTT_Thing"]},"widgets":[]},{"widgetId":"1102","type":"Switch","visibility":true,"label":"Jalousie Bad","labelSource":"ITEM_LABEL","icon":"blinds","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/lcnJalousieBad","state":"100","type":"Rollershutter","name":"lcnJalousieBad","label":"Jalousie Bad","category":"blinds","tags":["Point"],"groupNames":["Module56StrgJalousie3BS"]},"widgets":[]},{"widgetId":"1103","type":"Selection","visibility":true,"label":"Profil [2]","labelSource":"SITEMAP_WIDGET","icon":"heating","staticIcon":false,"pattern":"%s","unit":"","mappings":[{"command":"1","label":"Normal"},{"command":"2","label":"Abwesenheit"}],"item":{"link":"http://192.168.2.10:8080/rest/items/shellytrvBad_Selected_Profile","state":"2","stateDescription":{"pattern":"%s","readOnly":false,"options":[]},"type":"String","name":"shellytrvBad_Selected_Profile","label":"Selected Profile","category":"","tags":["Point"],"groupNames":[]},"widgets":[]},{"widgetId":"1104","type":"Text","visibility":true,"label":"Position [5]","labelSource":"ITEM_LABEL","icon":"heating","staticIcon":false,"pattern":"%.0f %unit%","unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/shellytrvBad_Position","state":"5.0","stateDescription":{"minimum":0,"maximum":100,"step":1,"pattern":"%.0f %unit%","readOnly":false,"options":[]},"type":"Dimmer","name":"shellytrvBad_Position","label":"Position","category":"heating","tags":["Point"],"groupNames":["shellytrvBad"]},"widgets":[]},{"widgetId":"1105","type":"Switch","visibility":true,"label":"Heizkörper [away]","labelSource":"SITEMAP_WIDGET","icon":"heating","staticIcon":false,"pattern":"%s","unit":"","mappings":[{"command":"comfort","label":"Comfort"},{"command":"eco","label":"Eco"},{"command":"away","label":"Away"}],"item":{"link":"http://192.168.2.10:8080/rest/items/BlueToothGW_fuer_Heizkoerper_Heating_Profile_200","state":"away","stateDescription":{"pattern":"%s","readOnly":false,"options":[]},"type":"String","name":"BlueToothGW_fuer_Heizkoerper_Heating_Profile_200","label":"Heating Profile 200","tags":["Point"],"groupNames":[]},"widgets":[]},{"widgetId":"1106","type":"Text","visibility":true,"label":"Öffnung [0 %]","labelSource":"SITEMAP_WIDGET","icon":"if:mdi:valve","staticIcon":false,"pattern":"%.0f %unit%","unit":"%","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/BlueToothGW_fuer_Heizkoerper_ValveOpening200","state":"0 %","stateDescription":{"step":1,"pattern":"%.0f %unit%","readOnly":true,"options":[]},"unitSymbol":"%","type":"Number:Dimensionless","name":"BlueToothGW_fuer_Heizkoerper_ValveOpening200","label":"ValveOpening200","tags":["Point"],"groupNames":["BlueToothGW_fuer_Heizkoerper"]},"widgets":[]},{"widgetId":"1107","type":"Text","visibility":true,"label":"Temperatur, gemessen [20.7 °C]","labelSource":"SITEMAP_WIDGET","icon":"material:thermostat","staticIcon":false,"pattern":"%.1f %unit%","unit":"°C","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/BlueToothGW_fuer_Heizkoerper_TemperatureMeasured200inCelsius","state":"20.7 °C","stateDescription":{"step":1,"pattern":"%.1f %unit%","readOnly":true,"options":[]},"unitSymbol":"°C","type":"Number:Temperature","name":"BlueToothGW_fuer_Heizkoerper_TemperatureMeasured200inCelsius","label":"TemperatureMeasured200inCelsius","tags":["Point"],"groupNames":["BlueToothGW_fuer_Heizkoerper"]},"widgets":[]},{"widgetId":"1108","type":"Text","visibility":true,"label":"Temperatur, Ziel [13.0 °C]","labelSource":"SITEMAP_WIDGET","icon":"if:clarity:thermometer-line","staticIcon":false,"labelcolor":"blue","pattern":"%.1f %unit%","unit":"°C","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/BlueToothGW_fuer_Heizkoerper_TemperatureTarget200inCelsius","state":"13 °C","stateDescription":{"step":1,"pattern":"%.1f %unit%","readOnly":true,"options":[]},"unitSymbol":"°C","type":"Number:Temperature","name":"BlueToothGW_fuer_Heizkoerper_TemperatureTarget200inCelsius","label":"TemperatureTarget200inCelsius","tags":["Point"],"groupNames":["BlueToothGW_fuer_Heizkoerper"]},"widgets":[]},{"widgetId":"1109","type":"Switch","visibility":true,"label":"Fußboden [away]","labelSource":"SITEMAP_WIDGET","icon":"heating","staticIcon":false,"pattern":"%s","unit":"","mappings":[{"command":"comfort","label":"Comfort"},{"command":"eco","label":"Eco"},{"command":"away","label":"Away"}],"item":{"link":"http://192.168.2.10:8080/rest/items/BlueToothGW_fuer_Heizkoerper_Heating_Profile_201","state":"away","stateDescription":{"pattern":"%s","readOnly":false,"options":[]},"type":"String","name":"BlueToothGW_fuer_Heizkoerper_Heating_Profile_201","label":"Heating Profile 201","tags":["Point"],"groupNames":[]},"widgets":[]},{"widgetId":"1110","type":"Text","visibility":true,"label":"Öffnung [0 %]","labelSource":"SITEMAP_WIDGET","icon":"f7:airplane","staticIcon":false,"labelcolor":"secondary","iconcolor":"blue","pattern":"%.0f %unit%","unit":"%","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/BlueToothGW_fuer_Heizkoerper_ValveOpening201","state":"0 %","stateDescription":{"step":1,"pattern":"%.0f %unit%","readOnly":true,"options":[]},"unitSymbol":"%","type":"Number:Dimensionless","name":"BlueToothGW_fuer_Heizkoerper_ValveOpening201","label":"ValveOpening201","tags":["Point"],"groupNames":["BlueToothGW_fuer_Heizkoerper"]},"widgets":[]},{"widgetId":"1111","type":"Text","visibility":true,"label":"Temperatur, gemessen [20.8 °C]","labelSource":"SITEMAP_WIDGET","icon":"f7:thermometer","staticIcon":false,"pattern":"%.1f %unit%","unit":"°C","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/BlueToothGW_fuer_Heizkoerper_TemperatureMeasured201inCelsius","state":"20.8 °C","stateDescription":{"step":1,"pattern":"%.1f %unit%","readOnly":true,"options":[]},"unitSymbol":"°C","type":"Number:Temperature","name":"BlueToothGW_fuer_Heizkoerper_TemperatureMeasured201inCelsius","label":"TemperatureMeasured201inCelsius","tags":["Point"],"groupNames":["BlueToothGW_fuer_Heizkoerper"]},"widgets":[]},{"widgetId":"1112","type":"Text","visibility":true,"label":"Temperatur, Ziel [13.0 °C]","labelSource":"SITEMAP_WIDGET","icon":"if:clarity:thermometer-line","staticIcon":true,"pattern":"%.1f %unit%","unit":"°C","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/BlueToothGW_fuer_Heizkoerper_TemperatureTarget201inCelsius","state":"13 °C","stateDescription":{"step":1,"pattern":"%.1f %unit%","readOnly":true,"options":[]},"unitSymbol":"°C","type":"Number:Temperature","name":"BlueToothGW_fuer_Heizkoerper_TemperatureTarget201inCelsius","label":"TemperatureTarget201inCelsius","tags":["Point"],"groupNames":["BlueToothGW_fuer_Heizkoerper"]},"widgets":[]}]},{"widgetId":"12","type":"Frame","visibility":true,"label":"Außen","labelSource":"SITEMAP_WIDGET","icon":"frame","staticIcon":false,"unit":"","mappings":[],"widgets":[{"widgetId":"1200","type":"Switch","visibility":true,"label":"Sprudelstein","labelSource":"SITEMAP_WIDGET","icon":"switch","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/SprudelsteinShellyPlug5shellyplugs9d903d1921682114_Power","state":"ON","type":"Switch","name":"SprudelsteinShellyPlug5shellyplugs9d903d1921682114_Power","label":"Power","category":"Switch","tags":["Point"],"groupNames":["SprudelsteinShellyPlug5shellyplugs9d903d1921682114"]},"widgets":[]},{"widgetId":"1201","type":"Switch","visibility":true,"label":"Weihnachtsbeleuchtung","labelSource":"SITEMAP_WIDGET","icon":"poweroutlet_eu","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/xmasSwitch","state":"OFF","type":"Switch","name":"xmasSwitch","label":"xmasSwitch","category":"poweroutlet_eu","tags":["PowerOutlet"],"groupNames":["gJosefstr8a"]},"widgets":[]},{"widgetId":"1202","type":"Switch","visibility":true,"label":"Außenlichter","labelSource":"ITEM_LABEL","icon":"light","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/lcnLightSwitch11_1","state":"OFF","type":"Switch","name":"lcnLightSwitch11_1","label":"Außenlichter","category":"light","tags":["Lightbulb"],"groupNames":["gAussen"]},"widgets":[]},{"widgetId":"1203","type":"Switch","visibility":true,"label":"Schuppen","labelSource":"ITEM_LABEL","icon":"light","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/shelly110521c4553be192168249_Power","state":"OFF","type":"Switch","name":"shelly110521c4553be192168249_Power","label":"Schuppen","category":"light","tags":["Light","Switch"],"groupNames":["shelly110521c4553be192168249"]},"widgets":[]},{"widgetId":"1204","type":"Text","visibility":true,"label":"Außentemperatur [23.6 °C]","labelSource":"SITEMAP_WIDGET","icon":"temperature","staticIcon":false,"pattern":"%.1f %unit%","unit":"°C","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/BlackShellyHT1_temperature","state":"23.6 °C","stateDescription":{"step":1,"pattern":"%.1f %unit%","readOnly":true,"options":[]},"unitSymbol":"°C","type":"Number:Temperature","name":"BlackShellyHT1_temperature","label":"temperature","tags":["Point"],"groupNames":["BlackShellyHT1"]},"widgets":[]},{"widgetId":"1205","type":"Switch","visibility":true,"label":"Numeric State [2]","labelSource":"ITEM_LABEL","icon":"","staticIcon":false,"pattern":"%.0f","unit":"","mappings":[{"command":"1","label":"Mow"},{"command":"2","label":"Return"},{"command":"3","label":"Pause"}],"item":{"link":"http://192.168.2.10:8080/rest/items/josefschaf_Numeric_State","state":"2.0","stateDescription":{"pattern":"%.0f","readOnly":false,"options":[{"value":"1","label":"Mow"},{"value":"2","label":"Charge/Dock"},{"value":"3","label":"Pause"}]},"commandDescription":{"commandOptions":[{"command":"1","label":"Mow"},{"command":"2","label":"Charge/Dock"},{"command":"3","label":"Pause"}]},"type":"Number","name":"josefschaf_Numeric_State","label":"Numeric State","category":"","tags":["Point"],"groupNames":["josefschaf"]},"widgets":[]}]},{"widgetId":"13","type":"Frame","visibility":true,"label":"Jalousien EG","labelSource":"SITEMAP_WIDGET","icon":"frame","staticIcon":false,"unit":"","mappings":[],"widgets":[{"widgetId":"1300","type":"Switch","visibility":true,"label":"EG Jalousien","labelSource":"ITEM_LABEL","icon":"blinds","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/EGJalousien","state":"0.0","type":"Rollershutter","name":"EGJalousien","label":"EG Jalousien","category":"blinds","tags":[],"groupNames":[]},"widgets":[]},{"widgetId":"1301","type":"Switch","visibility":true,"label":"EG Jalousien Süd","labelSource":"ITEM_LABEL","icon":"blinds","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/EGJalousienSued","state":"100","type":"Rollershutter","name":"EGJalousienSued","label":"EG Jalousien Süd","category":"blinds","tags":[],"groupNames":[]},"widgets":[]},{"widgetId":"1302","type":"Group","visibility":true,"label":"EG Jalousien Süd","labelSource":"ITEM_LABEL","icon":"blinds","staticIcon":false,"unit":"","mappings":[],"item":{"members":[],"groupType":"Rollershutter","function":{"name":"EQUALITY"},"link":"http://192.168.2.10:8080/rest/items/gEGJalousienSued","state":"0","type":"Group","name":"gEGJalousienSued","label":"EG Jalousien Süd","category":"blinds","tags":["Blinds"],"groupNames":["gEGJalousien"]},"linkedPage":{"id":"1302","title":"EG Jalousien Süd","icon":"blinds","link":"http://192.168.2.10:8080/rest/sitemaps/uicomponents_page_myHome/1302","leaf":true,"timeout":false,"widgets":[{"widgetId":"130200","type":"Switch","visibility":true,"label":"Jalousie WZ Süd links","labelSource":"ITEM_LABEL","icon":"blinds","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/lcnJalousieWZSuedLinks","state":"0","type":"Rollershutter","name":"lcnJalousieWZSuedLinks","label":"Jalousie WZ Süd links","category":"blinds","tags":["Point"],"groupNames":["gEGJalousien","gEGJalousienSued","Module54StrgJalousie1"]},"widgets":[]},{"widgetId":"130201","type":"Switch","visibility":true,"label":"Jalousie WZ Süd Mitte","labelSource":"ITEM_LABEL","icon":"blinds","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/lcnJalousieWZSuedMitte","state":"0","type":"Rollershutter","name":"lcnJalousieWZSuedMitte","label":"Jalousie WZ Süd Mitte","category":"blinds","tags":["Point"],"groupNames":["gEGJalousien","gEGJalousienSued","Module54StrgJalousie1"]},"widgets":[]},{"widgetId":"130202","type":"Switch","visibility":true,"label":"Jalousie WZ Süd rechts","labelSource":"ITEM_LABEL","icon":"blinds","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/lcnJalousieWZSuedRechts","state":"0","type":"Rollershutter","name":"lcnJalousieWZSuedRechts","label":"Jalousie WZ Süd rechts","category":"blinds","tags":["Point"],"groupNames":["gEGJalousien","gEGJalousienSued","Module55StrgJalousie2"]},"widgets":[]}]},"widgets":[]},{"widgetId":"1303","type":"Switch","visibility":true,"label":"EG Jalousien West","labelSource":"ITEM_LABEL","icon":"blinds","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/EGJalousienWest","state":"100","type":"Rollershutter","name":"EGJalousienWest","label":"EG Jalousien West","category":"blinds","tags":[],"groupNames":[]},"widgets":[]},{"widgetId":"1304","type":"Group","visibility":true,"label":"EG Jalousien West","labelSource":"ITEM_LABEL","icon":"blinds","staticIcon":false,"unit":"","mappings":[],"item":{"members":[],"groupType":"Rollershutter","function":{"name":"EQUALITY"},"link":"http://192.168.2.10:8080/rest/items/gEGJalousienWest","state":"0","type":"Group","name":"gEGJalousienWest","label":"EG Jalousien West","category":"blinds","tags":["Blinds"],"groupNames":["gEGJalousien"]},"linkedPage":{"id":"1304","title":"EG Jalousien West","icon":"blinds","link":"http://192.168.2.10:8080/rest/sitemaps/uicomponents_page_myHome/1304","leaf":true,"timeout":false,"widgets":[{"widgetId":"130400","type":"Switch","visibility":true,"label":"Jalousie WZ West links","labelSource":"ITEM_LABEL","icon":"blinds","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/lcnJalousieWZWestLinks","state":"0","type":"Rollershutter","name":"lcnJalousieWZWestLinks","label":"Jalousie WZ West links","category":"blinds","tags":["Point"],"groupNames":["gEGJalousien","gEGJalousienWest","Module54StrgJalousie1"]},"widgets":[]},{"widgetId":"130401","type":"Switch","visibility":true,"label":"Jalousie WZ West Mitte","labelSource":"ITEM_LABEL","icon":"blinds","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/lcnJalousieWZWestMitte","state":"0","type":"Rollershutter","name":"lcnJalousieWZWestMitte","label":"Jalousie WZ West Mitte","category":"blinds","tags":["Point","Opening"],"groupNames":["gEGJalousien","gEGJalousienWest","Module54StrgJalousie1"]},"widgets":[]},{"widgetId":"130402","type":"Switch","visibility":true,"label":"Jalousie WZ West rechts","labelSource":"ITEM_LABEL","icon":"blinds","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/lcnJalousieWZWestRechts","state":"0","type":"Rollershutter","name":"lcnJalousieWZWestRechts","label":"Jalousie WZ West rechts","category":"blinds","tags":["Point"],"groupNames":["gEGJalousien","gEGJalousienWest","Module55StrgJalousie2"]},"widgets":[]}]},"widgets":[]}]},{"widgetId":"14","type":"Frame","visibility":true,"label":"Doorbird","labelSource":"SITEMAP_WIDGET","icon":"frame","staticIcon":false,"unit":"","mappings":[],"widgets":[{"widgetId":"1400","type":"Switch","visibility":true,"label":"Power","labelSource":"ITEM_LABEL","icon":"switch","staticIcon":false,"unit":"","mappings":[{"command":"ON","label":"aufdrücken"}],"item":{"link":"http://192.168.2.10:8080/rest/items/Shelly_1_Klingel_Power","state":"OFF","type":"Switch","name":"Shelly_1_Klingel_Power","label":"Power","category":"Switch","tags":["Power","Switch"],"groupNames":["Shelly_1_Klingel"]},"widgets":[]}]},{"widgetId":"15","type":"Frame","visibility":true,"label":"PresenceSimulation","labelSource":"SITEMAP_WIDGET","icon":"frame","staticIcon":false,"unit":"","mappings":[],"widgets":[{"widgetId":"1500","type":"Switch","visibility":true,"label":"Anwesenheitssimulation","labelSource":"ITEM_LABEL","icon":"switch","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/PresenceSimulation","state":"OFF","type":"Switch","name":"PresenceSimulation","label":"Anwesenheitssimulation","tags":[],"groupNames":[]},"widgets":[]}]}]}} From d41525377d0e163f19e13b35ce9e4f72641fe04a Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Sun, 13 Jul 2025 16:56:44 +0200 Subject: [PATCH 249/476] Test for Color Temperature --no-gpg-sign --- CommonUI/Package.swift | 8 +++- .../Tests/CommonUITests/CommonUITests.swift | 35 ++++++++++++++++- openHAB.xcodeproj/project.pbxproj | 12 ++---- .../xcshareddata/swiftpm/Package.resolved | 9 +++++ .../WidgetIconView.swift => IconView.swift} | 8 ++-- openHAB/SwiftUI/Rows/GenericRowView.swift | 4 +- .../SwiftUI/Rows/RollershutterRowView.swift | 4 +- openHAB/SwiftUI/Rows/SegmentedRowView.swift | 4 +- openHAB/SwiftUI/Rows/SetpointRowView.swift | 4 +- openHAB/SwiftUI/Rows/SliderRowView.swift | 4 +- openHAB/SwiftUI/Rows/SwitchRowView.swift | 4 +- openHAB/SwiftUI/Rows/TextInputRowView.swift | 4 +- openHAB/SwiftUI/Rows/TextRowView.swift | 4 +- openHAB/SwiftUI/Rows/WidgetRow.swift | 39 ------------------- 14 files changed, 72 insertions(+), 71 deletions(-) rename openHAB/SwiftUI/{Rows/WidgetIconView.swift => IconView.swift} (97%) delete mode 100644 openHAB/SwiftUI/Rows/WidgetRow.swift diff --git a/CommonUI/Package.swift b/CommonUI/Package.swift index cb227b090..40ed00ad9 100644 --- a/CommonUI/Package.swift +++ b/CommonUI/Package.swift @@ -15,7 +15,8 @@ let package = Package( ) ], dependencies: [ - .package(path: "../OpenHABCore") + .package(path: "../OpenHABCore"), + .package(url: "https://github.com/apple/swift-numerics", from: "1.0.0") ], targets: [ // Targets are the basic building blocks of a package, defining a module or a test suite. @@ -49,7 +50,10 @@ let package = Package( ), .testTarget( name: "CommonUITests", - dependencies: ["CommonUI"] + dependencies: [ + .product(name: "Numerics", package: "swift-numerics"), + "CommonUI" + ] ) ] ) diff --git a/CommonUI/Tests/CommonUITests/CommonUITests.swift b/CommonUI/Tests/CommonUITests/CommonUITests.swift index da19358dd..ddddb113f 100644 --- a/CommonUI/Tests/CommonUITests/CommonUITests.swift +++ b/CommonUI/Tests/CommonUITests/CommonUITests.swift @@ -11,7 +11,38 @@ @testable import CommonUI import Testing +import Numerics -@Test func example() async throws { - // Write your test here and use APIs like `#expect(...)` to check expected conditions. +struct ColorTemperatureTests { + @Test func lowKelvinValue() { + let color = componentsForColorTemperature(temperature: 1000) + #expect(color.r.isApproximatelyEqual(to: 1.0, relativeTolerance: 0.01)) + #expect(color.g < 0.5) + #expect(color.b.isApproximatelyEqual(to: 0.0, relativeTolerance: 0.01)) + } + + @Test func midKelvinValue() { + let color = componentsForColorTemperature(temperature: 5000) + #expect(color.r > 0.9) + #expect(color.g > 0.7) + #expect(color.b > 0.5) + } + + @Test func highKelvinValue() { + let color = componentsForColorTemperature(temperature: 10000) + #expect(color.r > 0.7) + #expect(color.g > 0.8) + #expect(color.b.isApproximatelyEqual(to: 1.0, relativeTolerance: 0.01)) + } + + @Test func edgeKelvinValues() { + let veryLow = componentsForColorTemperature(temperature: 100) + #expect(veryLow.r.isApproximatelyEqual(to: 1.0, relativeTolerance: 0.01)) + #expect(veryLow.b.isApproximatelyEqual(to: 0.0, relativeTolerance: 0.01)) + + let veryHigh = componentsForColorTemperature(temperature: 40000) + #expect(veryHigh.r <= 1.0) + #expect(veryHigh.g <= 1.0) + #expect(veryHigh.b.isApproximatelyEqual(to: 1.0, relativeTolerance: 0.01)) + } } diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index 7306d03d1..4fd5dd4a6 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -101,7 +101,7 @@ DA35E2C62E1EEA9D003987BB /* SegmentedRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA35E2B72E1EEA9D003987BB /* SegmentedRowView.swift */; }; DA35E2C72E1EEA9D003987BB /* VideoRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA35E2BB2E1EEA9D003987BB /* VideoRowView.swift */; }; DA35E2CB2E1F93AD003987BB /* ImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA35E2CA2E1F93AD003987BB /* ImageView.swift */; }; - DA35E2CD2E1F96CA003987BB /* WidgetIconView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA35E2CC2E1F96CA003987BB /* WidgetIconView.swift */; }; + DA35E2CD2E1F96CA003987BB /* IconView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA35E2CC2E1F96CA003987BB /* IconView.swift */; }; DA4642322D7EE6CA006C3908 /* LoggerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4642312D7EE6CA006C3908 /* LoggerView.swift */; }; DA4800142D836892009CF127 /* ConnectionSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4800132D836892009CF127 /* ConnectionSettingsView.swift */; }; DA4800162D836EF0009CF127 /* MainUISettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4800152D836EF0009CF127 /* MainUISettingsView.swift */; }; @@ -117,7 +117,6 @@ DA6454DE2E204B95006005E8 /* PreviewConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA7224D123828D3300712D20 /* PreviewConstants.swift */; }; DA64ACA62DBEAD5600294F60 /* SitemapPageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA64ACA52DBEAD5600294F60 /* SitemapPageViewModel.swift */; }; DA64ACA82DBEAD8300294F60 /* SitemapPageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA64ACA72DBEAD8300294F60 /* SitemapPageView.swift */; }; - DA64ACAA2DBEADB000294F60 /* WidgetRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA64ACA92DBEADB000294F60 /* WidgetRow.swift */; }; DA65871F236F83CE007E2E7F /* UserDefaultsExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA65871E236F83CD007E2E7F /* UserDefaultsExtension.swift */; }; DA6B2EEF2C861BC900DF77CF /* DrawerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA6B2EEE2C861BC900DF77CF /* DrawerView.swift */; }; DA6B2EF12C87B59000DF77CF /* NotificationsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA6B2EF02C87B59000DF77CF /* NotificationsView.swift */; }; @@ -441,7 +440,7 @@ DA35E2BB2E1EEA9D003987BB /* VideoRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoRowView.swift; sourceTree = ""; }; DA35E2BC2E1EEA9D003987BB /* WebRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebRowView.swift; sourceTree = ""; }; DA35E2CA2E1F93AD003987BB /* ImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageView.swift; sourceTree = ""; }; - DA35E2CC2E1F96CA003987BB /* WidgetIconView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetIconView.swift; sourceTree = ""; }; + DA35E2CC2E1F96CA003987BB /* IconView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconView.swift; sourceTree = ""; }; DA4642312D7EE6CA006C3908 /* LoggerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoggerView.swift; sourceTree = ""; }; DA4800132D836892009CF127 /* ConnectionSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionSettingsView.swift; sourceTree = ""; }; DA4800152D836EF0009CF127 /* MainUISettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainUISettingsView.swift; sourceTree = ""; }; @@ -457,7 +456,6 @@ DA5ED9BD2C850955004875E0 /* ClientCertificatesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientCertificatesViewModel.swift; sourceTree = ""; }; DA64ACA52DBEAD5600294F60 /* SitemapPageViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SitemapPageViewModel.swift; sourceTree = ""; }; DA64ACA72DBEAD8300294F60 /* SitemapPageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SitemapPageView.swift; sourceTree = ""; }; - DA64ACA92DBEADB000294F60 /* WidgetRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetRow.swift; sourceTree = ""; }; DA65871E236F83CD007E2E7F /* UserDefaultsExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaultsExtension.swift; sourceTree = ""; }; DA6B2EEE2C861BC900DF77CF /* DrawerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DrawerView.swift; sourceTree = ""; }; DA6B2EF02C87B59000DF77CF /* NotificationsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsView.swift; sourceTree = ""; }; @@ -852,6 +850,7 @@ isa = PBXGroup; children = ( DA35E2CA2E1F93AD003987BB /* ImageView.swift */, + DA35E2CC2E1F96CA003987BB /* IconView.swift */, DA64ACA72DBEAD8300294F60 /* SitemapPageView.swift */, DAEA21D72DBF472D00D54342 /* RowViewFactory.swift */, DAC949FF2E21A473007E67B7 /* Rows */, @@ -917,8 +916,6 @@ DA35E2BB2E1EEA9D003987BB /* VideoRowView.swift */, DA35E2BC2E1EEA9D003987BB /* WebRowView.swift */, DAC949FD2E21A2D1007E67B7 /* FrameRowView.swift */, - DA35E2CC2E1F96CA003987BB /* WidgetIconView.swift */, - DA64ACA92DBEADB000294F60 /* WidgetRow.swift */, ); path = Rows; sourceTree = ""; @@ -1728,14 +1725,13 @@ DA35E2C52E1EEA9D003987BB /* RollershutterRowView.swift in Sources */, DA35E2C62E1EEA9D003987BB /* SegmentedRowView.swift in Sources */, DA35E2C72E1EEA9D003987BB /* VideoRowView.swift in Sources */, - DA35E2CD2E1F96CA003987BB /* WidgetIconView.swift in Sources */, + DA35E2CD2E1F96CA003987BB /* IconView.swift in Sources */, DAC65FC7236EDF3900F4501E /* SpinnerViewController.swift in Sources */, DA9F81872C85020F00B47B72 /* RTFTextView.swift in Sources */, DA6B2EF12C87B59000DF77CF /* NotificationsView.swift in Sources */, DA50C7BF2B0A65300009F716 /* SliderWithSwitchSupportUITableViewCell.swift in Sources */, DA5ED9BE2C850955004875E0 /* ClientCertificatesViewModel.swift in Sources */, DAEA21D82DBF472D00D54342 /* RowViewFactory.swift in Sources */, - DA64ACAA2DBEADB000294F60 /* WidgetRow.swift in Sources */, DA21EAE22339621C001AB415 /* Throttler.swift in Sources */, DAF4F6C0222734D300C24876 /* NewImageUITableViewCell.swift in Sources */, DA6B2EEF2C861BC900DF77CF /* DrawerView.swift in Sources */, diff --git a/openHAB.xcworkspace/xcshareddata/swiftpm/Package.resolved b/openHAB.xcworkspace/xcshareddata/swiftpm/Package.resolved index 1f75a70cb..ddc8f1c78 100644 --- a/openHAB.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/openHAB.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -198,6 +198,15 @@ "version" : "1.3.1" } }, + { + "identity" : "swift-numerics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-numerics", + "state" : { + "revision" : "e0ec0f5f3af6f3e4d5e7a19d2af26b481acb6ba8", + "version" : "1.0.3" + } + }, { "identity" : "swift-openapi-runtime", "kind" : "remoteSourceControl", diff --git a/openHAB/SwiftUI/Rows/WidgetIconView.swift b/openHAB/SwiftUI/IconView.swift similarity index 97% rename from openHAB/SwiftUI/Rows/WidgetIconView.swift rename to openHAB/SwiftUI/IconView.swift index 3dd663244..7b5f5454f 100644 --- a/openHAB/SwiftUI/Rows/WidgetIconView.swift +++ b/openHAB/SwiftUI/IconView.swift @@ -15,7 +15,7 @@ import os.log import SwiftUI /// A SwiftUI view that displays widget icons with openHAB-specific styling and caching -struct WidgetIconView: View { +struct IconView: View { @ObservedObject var widget: OpenHABWidget @Environment(\.colorScheme) private var colorScheme @@ -93,7 +93,7 @@ struct WidgetIconView: View { // MARK: - Convenience Extensions -extension WidgetIconView { +extension IconView { /// Creates a widget icon view with standard size and default styling or custom icon color init(widget: OpenHABWidget) { self.init( @@ -105,7 +105,7 @@ extension WidgetIconView { // MARK: - Widget Type Extensions -extension WidgetIconView { +extension IconView { /// Determines if a widget type should show an icon (equivalent to NoIconDisplayableCell protocol) static func shouldShowIcon(for widget: OpenHABWidget) -> Bool { // These widget types should not show icons (equivalent to NoIconDisplayableCell) @@ -123,5 +123,5 @@ extension WidgetIconView { widget.icon = "switch" widget.label = "Test Switch" - return WidgetIconView(widget: widget) + return IconView(widget: widget) } diff --git a/openHAB/SwiftUI/Rows/GenericRowView.swift b/openHAB/SwiftUI/Rows/GenericRowView.swift index c6e97c63a..6db5616e1 100644 --- a/openHAB/SwiftUI/Rows/GenericRowView.swift +++ b/openHAB/SwiftUI/Rows/GenericRowView.swift @@ -16,8 +16,8 @@ struct GenericRowView: View { @ObservedObject var widget: OpenHABWidget var body: some View { HStack { - if WidgetIconView.shouldShowIcon(for: widget) { - WidgetIconView(widget: widget) + if IconView.shouldShowIcon(for: widget) { + IconView(widget: widget) .frame(width: 24, height: 24) } Text(widget.labelText ?? "") diff --git a/openHAB/SwiftUI/Rows/RollershutterRowView.swift b/openHAB/SwiftUI/Rows/RollershutterRowView.swift index e679ba065..1546a5878 100644 --- a/openHAB/SwiftUI/Rows/RollershutterRowView.swift +++ b/openHAB/SwiftUI/Rows/RollershutterRowView.swift @@ -23,8 +23,8 @@ struct RollershutterRowView: View { var body: some View { VStack(alignment: .leading, spacing: 8) { HStack { - if WidgetIconView.shouldShowIcon(for: widget) { - WidgetIconView(widget: widget) + if IconView.shouldShowIcon(for: widget) { + IconView(widget: widget) .frame(width: 24, height: 24) } diff --git a/openHAB/SwiftUI/Rows/SegmentedRowView.swift b/openHAB/SwiftUI/Rows/SegmentedRowView.swift index f82601faf..4b2438771 100644 --- a/openHAB/SwiftUI/Rows/SegmentedRowView.swift +++ b/openHAB/SwiftUI/Rows/SegmentedRowView.swift @@ -31,8 +31,8 @@ struct SegmentedRowView: View { var body: some View { HStack { - if WidgetIconView.shouldShowIcon(for: widget) { - WidgetIconView(widget: widget) + if IconView.shouldShowIcon(for: widget) { + IconView(widget: widget) .frame(width: 24, height: 24) .padding(.top, 4) // Align with text } diff --git a/openHAB/SwiftUI/Rows/SetpointRowView.swift b/openHAB/SwiftUI/Rows/SetpointRowView.swift index fb0aa7a9d..c133d1863 100644 --- a/openHAB/SwiftUI/Rows/SetpointRowView.swift +++ b/openHAB/SwiftUI/Rows/SetpointRowView.swift @@ -41,8 +41,8 @@ struct SetpointRowView: View { var body: some View { VStack(alignment: .leading, spacing: 8) { HStack { - if WidgetIconView.shouldShowIcon(for: widget) { - WidgetIconView(widget: widget) + if IconView.shouldShowIcon(for: widget) { + IconView(widget: widget) .frame(width: 24, height: 24) } diff --git a/openHAB/SwiftUI/Rows/SliderRowView.swift b/openHAB/SwiftUI/Rows/SliderRowView.swift index 863f187ea..e7e269e25 100644 --- a/openHAB/SwiftUI/Rows/SliderRowView.swift +++ b/openHAB/SwiftUI/Rows/SliderRowView.swift @@ -21,8 +21,8 @@ struct SliderRowView: View { var body: some View { HStack { - if WidgetIconView.shouldShowIcon(for: widget) { - WidgetIconView(widget: widget) + if IconView.shouldShowIcon(for: widget) { + IconView(widget: widget) .frame(width: 24, height: 24) } diff --git a/openHAB/SwiftUI/Rows/SwitchRowView.swift b/openHAB/SwiftUI/Rows/SwitchRowView.swift index b2c38eb31..2d69b8be4 100644 --- a/openHAB/SwiftUI/Rows/SwitchRowView.swift +++ b/openHAB/SwiftUI/Rows/SwitchRowView.swift @@ -34,8 +34,8 @@ struct SwitchRowView: View { var body: some View { HStack { - if WidgetIconView.shouldShowIcon(for: widget) { - WidgetIconView(widget: widget) + if IconView.shouldShowIcon(for: widget) { + IconView(widget: widget) .frame(width: 24, height: 24) } diff --git a/openHAB/SwiftUI/Rows/TextInputRowView.swift b/openHAB/SwiftUI/Rows/TextInputRowView.swift index a7268c6a0..4f7f40d88 100644 --- a/openHAB/SwiftUI/Rows/TextInputRowView.swift +++ b/openHAB/SwiftUI/Rows/TextInputRowView.swift @@ -23,8 +23,8 @@ struct TextInputRowView: View { var body: some View { HStack(alignment: .top, spacing: 8) { - if WidgetIconView.shouldShowIcon(for: widget) { - WidgetIconView(widget: widget) + if IconView.shouldShowIcon(for: widget) { + IconView(widget: widget) .frame(width: 24, height: 24) .padding(.top, 4) // Align with text } diff --git a/openHAB/SwiftUI/Rows/TextRowView.swift b/openHAB/SwiftUI/Rows/TextRowView.swift index 572e26469..d12adcb89 100644 --- a/openHAB/SwiftUI/Rows/TextRowView.swift +++ b/openHAB/SwiftUI/Rows/TextRowView.swift @@ -18,8 +18,8 @@ struct TextRowView: View { var body: some View { HStack { - if WidgetIconView.shouldShowIcon(for: widget) { - WidgetIconView(widget: widget) + if IconView.shouldShowIcon(for: widget) { + IconView(widget: widget) .frame(width: 24, height: 24) } diff --git a/openHAB/SwiftUI/Rows/WidgetRow.swift b/openHAB/SwiftUI/Rows/WidgetRow.swift deleted file mode 100644 index 1b705200f..000000000 --- a/openHAB/SwiftUI/Rows/WidgetRow.swift +++ /dev/null @@ -1,39 +0,0 @@ -// Copyright (c) 2010-2025 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 Kingfisher -import OpenHABCore -import os.log -import SwiftUI - -struct WidgetRow: View { - let widget: OpenHABWidget - - var body: some View { - HStack { -// if let iconUrl = widget.iconUrl { -// KFImage(iconUrl) -// .resizable() -// .scaledToFit() -// .frame(width: 30, height: 30) -// } - VStack(alignment: .leading) { - Text(widget.labelText ?? "") - .font(.headline) - if let value = widget.labelValue, !value.isEmpty { - Text(value) - .font(.subheadline) - .foregroundColor(.secondary) - } - } - } - } -} From 75faa05f1e7707ec59a058d06ac98a4ee90ae254 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Sun, 13 Jul 2025 19:31:00 +0200 Subject: [PATCH 250/476] Port features: search bar, copy on long press Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../Tests/CommonUITests/CommonUITests.swift | 2 +- openHAB/OpenHABRootViewController.swift | 7 +- openHAB/SitemapPageViewModel.swift | 44 +++++----- openHAB/SwiftUI/Rows/ColorPickerRowView.swift | 4 +- openHAB/SwiftUI/Rows/TextRowView.swift | 10 +++ openHAB/SwiftUI/SitemapPageView.swift | 81 ++++++++++--------- 6 files changed, 82 insertions(+), 66 deletions(-) diff --git a/CommonUI/Tests/CommonUITests/CommonUITests.swift b/CommonUI/Tests/CommonUITests/CommonUITests.swift index ddddb113f..7db35c731 100644 --- a/CommonUI/Tests/CommonUITests/CommonUITests.swift +++ b/CommonUI/Tests/CommonUITests/CommonUITests.swift @@ -10,8 +10,8 @@ // SPDX-License-Identifier: EPL-2.0 @testable import CommonUI -import Testing import Numerics +import Testing struct ColorTemperatureTests { @Test func lowKelvinValue() { diff --git a/openHAB/OpenHABRootViewController.swift b/openHAB/OpenHABRootViewController.swift index 720b8beeb..c3759af3a 100644 --- a/openHAB/OpenHABRootViewController.swift +++ b/openHAB/OpenHABRootViewController.swift @@ -43,7 +43,12 @@ class HostingSitemapViewController: UIHostingController, OpenHA let viewModel = SitemapPageViewModel() self.viewModel = viewModel super.init(rootView: SitemapPageView(viewModel: viewModel)) - navigationItem.title = "Test" // viewModel.currentPage?.title.components(separatedBy: "[")[0] + } + + override func viewDidLoad() { + super.viewDidLoad() + // Hide UIKit navigation bar to let SwiftUI handle navigation + navigationController?.setNavigationBarHidden(true, animated: false) } @available(*, unavailable) diff --git a/openHAB/SitemapPageViewModel.swift b/openHAB/SitemapPageViewModel.swift index 8ca5d581c..8ccd94777 100644 --- a/openHAB/SitemapPageViewModel.swift +++ b/openHAB/SitemapPageViewModel.swift @@ -34,7 +34,6 @@ enum SitemapPageError: LocalizedError { @MainActor class SitemapPageViewModel: ObservableObject { @Published var currentPage: OpenHABPage? - @Published var filteredWidgets: [OpenHABWidget] = [] @Published var searchText = "" @Published var error: (any LocalizedError)? @Published var isLoading = false @@ -48,11 +47,11 @@ class SitemapPageViewModel: ObservableObject { private var pageId = "" var relevantWidgets: [OpenHABWidget] { - if searchText.isEmpty { - currentPage?.widgets ?? [] - } else { - filteredWidgets - } + guard !searchText.isEmpty else { return currentPage?.widgets ?? [] } + + return currentPage?.widgets.filter { + $0.label.lowercased().contains(searchText.lowercased()) && $0.type != .frame + } ?? [] } var pageTitle: String { @@ -64,14 +63,23 @@ class SitemapPageViewModel: ObservableObject { setupActiveConnectionObserver() } - init(pageUrl: String, title: String) { +// +// init(pageUrl: String, title: String) { +// loadSettings() +// setupActiveConnectionObserver() +// // Set the pageId from the URL for navigation +// if let urlComponents = URLComponents(string: pageUrl), +// let pageIdValue = urlComponents.queryItems?.first(where: { $0.name == "sitemap" })?.value { +// pageId = pageIdValue +// } +// } + + init(pageUrl: String, title: String, pageId: String = "") { loadSettings() setupActiveConnectionObserver() - // Set the pageId from the URL for navigation - if let urlComponents = URLComponents(string: pageUrl), - let pageIdValue = urlComponents.queryItems?.first(where: { $0.name == "sitemap" })?.value { - pageId = pageIdValue - } +// self.pageUrl = pageUrl +// self.pageTitle = title + self.pageId = pageId } deinit { @@ -176,7 +184,6 @@ class SitemapPageViewModel: ObservableObject { private func updateUI(with page: OpenHABPage) { injectSendCommand(for: page.widgets) currentPage = page - filterWidgets() } func reload() async { @@ -210,7 +217,6 @@ class SitemapPageViewModel: ObservableObject { injectSendCommand(for: page!.widgets) currentPage = page - filterWidgets() } private func injectSendCommand(for widgets: [OpenHABWidget]) { @@ -224,16 +230,6 @@ class SitemapPageViewModel: ObservableObject { } } - func filterWidgets() { - if searchText.isEmpty { - filteredWidgets = [] - } else { - filteredWidgets = currentPage?.widgets.filter { - $0.label.lowercased().contains(searchText.lowercased()) && $0.type != .frame - } ?? [] - } - } - func widgetTapped(_ widget: OpenHABWidget) { if let linkedPage = widget.linkedPage { // Push a new view (handled in the SwiftUI view) diff --git a/openHAB/SwiftUI/Rows/ColorPickerRowView.swift b/openHAB/SwiftUI/Rows/ColorPickerRowView.swift index cd59e24b9..8b74513ec 100644 --- a/openHAB/SwiftUI/Rows/ColorPickerRowView.swift +++ b/openHAB/SwiftUI/Rows/ColorPickerRowView.swift @@ -21,12 +21,14 @@ struct ColorPickerRowView: View { private let logger = Logger(subsystem: "org.openhab", category: "WidgetColorPickerView") var body: some View { - VStack(alignment: .leading, spacing: 8) { + HStack { if let labelText = widget.labelText, !labelText.isEmpty { Text(labelText) .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) } + Spacer() + ColorPicker("Color", selection: $selectedColor, supportsOpacity: false) .labelsHidden() .onChange(of: selectedColor) { newColor in diff --git a/openHAB/SwiftUI/Rows/TextRowView.swift b/openHAB/SwiftUI/Rows/TextRowView.swift index d12adcb89..7aef1f6c7 100644 --- a/openHAB/SwiftUI/Rows/TextRowView.swift +++ b/openHAB/SwiftUI/Rows/TextRowView.swift @@ -11,6 +11,7 @@ import CommonUI import OpenHABCore +import SFSafeSymbols import SwiftUI struct TextRowView: View { @@ -34,6 +35,15 @@ struct TextRowView: View { .foregroundColor(.secondary) } } + .contextMenu { + if let text = widget.labelValue ?? widget.labelText, !text.isEmpty { + Button { + UIPasteboard.general.string = text + } label: { + Label("Copy", systemSymbol: .squareAndArrowUp) + } + } + } } } diff --git a/openHAB/SwiftUI/SitemapPageView.swift b/openHAB/SwiftUI/SitemapPageView.swift index e47f21be0..d4fabe01c 100644 --- a/openHAB/SwiftUI/SitemapPageView.swift +++ b/openHAB/SwiftUI/SitemapPageView.swift @@ -19,49 +19,52 @@ struct SitemapPageView: View { @State private var selectedWidget: OpenHABWidget? var body: some View { - List(viewModel.relevantWidgets) { widget in - Group { - if let linkedPage = widget.linkedPage { - NavigationLink(destination: SitemapPageView(viewModel: SitemapPageViewModel(pageUrl: linkedPage.link, title: linkedPage.title))) { - RowViewFactory.view(for: widget) - } - .buttonStyle(.plain) - .listRowInsets(EdgeInsets(top: 0, leading: 4, bottom: 0, trailing: 24)) - } else if widget.type == .selection { - Button { - selectedWidget = widget - showSelectionSheet = true - } label: { - RowViewFactory.view(for: widget) - } - .buttonStyle(.plain) - } else if widget.type == .input { - Button { - selectedWidget = widget - showInputAlert = true - } label: { + NavigationStack { + List(viewModel.relevantWidgets) { widget in + Group { + if let linkedPage = widget.linkedPage { + NavigationLink(destination: SitemapPageView(viewModel: SitemapPageViewModel(pageUrl: linkedPage.link, title: ""))) { + RowViewFactory.view(for: widget) + } + .buttonStyle(.plain) + .listRowInsets(EdgeInsets(top: 0, leading: 4, bottom: 0, trailing: 24)) + } else if widget.type == .selection { + Button { + selectedWidget = widget + showSelectionSheet = true + } label: { + RowViewFactory.view(for: widget) + } + .buttonStyle(.plain) + } else if widget.type == .input { + Button { + selectedWidget = widget + showInputAlert = true + } label: { + RowViewFactory.view(for: widget) + } + .buttonStyle(.plain) + } else { RowViewFactory.view(for: widget) + .onTapGesture { + viewModel.widgetTapped(widget) + } } - .buttonStyle(.plain) - } else { - RowViewFactory.view(for: widget) - .onTapGesture { - viewModel.widgetTapped(widget) - } } } - } - .listStyle(.plain) - .navigationTitle(viewModel.pageTitle) - .searchable(text: $viewModel.searchText) - .refreshable { - await viewModel.reload() - } - .task { - viewModel.startPageHandling() - } - .onChange(of: viewModel.networkTracker.activeConnection) { activeConnection in - viewModel.handleActiveConnectionChange(activeConnection) + .listStyle(.plain) + .navigationTitle(viewModel.pageTitle) + .navigationBarTitleDisplayMode(.large) + .searchable(text: $viewModel.searchText, prompt: "Search items in sitemap") + .refreshable { + await viewModel.reload() + } + .task { + viewModel.startPageHandling() + } + .onChange(of: viewModel.networkTracker.activeConnection) { activeConnection in + viewModel.handleActiveConnectionChange(activeConnection) + } } .sheet(isPresented: $showSelectionSheet) { if let widget = selectedWidget { From 411914d39f9bf55a9256f71fecd63263a1477ec4 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Sun, 13 Jul 2025 19:42:16 +0200 Subject: [PATCH 251/476] Enabled transition to linked pages Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- openHAB/SitemapPageViewModel.swift | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/openHAB/SitemapPageViewModel.swift b/openHAB/SitemapPageViewModel.swift index 8ccd94777..6c07a878e 100644 --- a/openHAB/SitemapPageViewModel.swift +++ b/openHAB/SitemapPageViewModel.swift @@ -63,23 +63,21 @@ class SitemapPageViewModel: ObservableObject { setupActiveConnectionObserver() } -// -// init(pageUrl: String, title: String) { -// loadSettings() -// setupActiveConnectionObserver() -// // Set the pageId from the URL for navigation -// if let urlComponents = URLComponents(string: pageUrl), -// let pageIdValue = urlComponents.queryItems?.first(where: { $0.name == "sitemap" })?.value { -// pageId = pageIdValue -// } -// } - init(pageUrl: String, title: String, pageId: String = "") { loadSettings() setupActiveConnectionObserver() -// self.pageUrl = pageUrl -// self.pageTitle = title - self.pageId = pageId + + // Extract pageId from URL if not provided + if pageId.isEmpty { + if let urlComponents = URLComponents(string: pageUrl), + let extractedPageId = urlComponents.queryItems?.first(where: { $0.name == "sitemap" })?.value { + self.pageId = extractedPageId + } else if let lastPathComponent = URL(string: pageUrl)?.lastPathComponent { + self.pageId = lastPathComponent + } + } else { + self.pageId = pageId + } } deinit { From 66a37ac4cdccf6f4948a707d2770c41fd2b2f2e5 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Sun, 13 Jul 2025 20:30:44 +0200 Subject: [PATCH 252/476] Fix missing back button on linked pages / Different setup for linked pages and root pages Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- openHAB/OpenHABRootViewController.swift | 62 ++++++++++++++++++---- openHAB/OpenHABSitemapViewController.swift | 4 ++ openHAB/SitemapPageViewModel.swift | 6 +++ openHAB/SwiftUI/SitemapPageView.swift | 12 +++-- 4 files changed, 71 insertions(+), 13 deletions(-) diff --git a/openHAB/OpenHABRootViewController.swift b/openHAB/OpenHABRootViewController.swift index c3759af3a..8bb3074d5 100644 --- a/openHAB/OpenHABRootViewController.swift +++ b/openHAB/OpenHABRootViewController.swift @@ -38,6 +38,7 @@ private let logger = Logger(subsystem: "org.openhab.UI", category: "OpenHABRootV class HostingSitemapViewController: UIHostingController, OpenHABViewable { private let viewModel: SitemapPageViewModel + private let searchController = UISearchController(searchResultsController: nil) init() { let viewModel = SitemapPageViewModel() @@ -45,17 +46,47 @@ class HostingSitemapViewController: UIHostingController, OpenHA super.init(rootView: SitemapPageView(viewModel: viewModel)) } + @available(*, unavailable) + @objc dynamic required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + override func viewDidLoad() { super.viewDidLoad() - // Hide UIKit navigation bar to let SwiftUI handle navigation - navigationController?.setNavigationBarHidden(true, animated: false) + // Keep UIKit navigation bar visible for hamburger menu + navigationController?.setNavigationBarHidden(false, animated: false) + setupSearchController() } - @available(*, unavailable) - @objc dynamic required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + // Ensure hamburger menu is preserved when search controller is set up + if parent?.navigationItem.searchController !== searchController { + let existingRightBarButtonItem = parent?.navigationItem.rightBarButtonItem + parent?.navigationItem.searchController = searchController + parent?.navigationItem.hidesSearchBarWhenScrolling = true + if let rightButton = existingRightBarButtonItem { + parent?.navigationItem.rightBarButtonItem = rightButton + } + } } + private func setupSearchController() { + searchController.searchResultsUpdater = self + searchController.obscuresBackgroundDuringPresentation = false + searchController.searchBar.autocapitalizationType = .none + searchController.searchBar.delegate = self + searchController.delegate = self + searchController.searchBar.placeholder = NSLocalizedString("search_items", comment: "") + definesPresentationContext = true + + // Assign to navigation item + navigationItem.searchController = searchController + navigationItem.hidesSearchBarWhenScrolling = false + } + + func viewName() -> String { "sitemap" } func reloadView() { @@ -71,9 +102,21 @@ class HostingSitemapViewController: UIHostingController, OpenHA } } +// MARK: - Search Controller Delegates + +extension HostingSitemapViewController: UISearchResultsUpdating, UISearchBarDelegate, UISearchControllerDelegate { + func updateSearchResults(for searchController: UISearchController) { + viewModel.searchText = searchController.searchBar.text ?? "" + } + + func searchBarCancelButtonClicked(_ searchBar: UISearchBar) { + viewModel.searchText = "" + } +} + // swiftlint:disable type_body_length class OpenHABRootViewController: UIViewController { - var currentView: (UIViewController & OpenHABViewable)! + var currentView: (any UIViewController & OpenHABViewable)! var isDemoMode = false var cancellables = Set() @@ -85,7 +128,7 @@ class OpenHABRootViewController: UIViewController { return viewController }() - private lazy var sitemapViewController: (UIViewController & OpenHABViewable) = HostingSitemapViewController() + private lazy var sitemapViewController: any (UIViewController & OpenHABViewable) = HostingSitemapViewController() private var activeConnection: ConnectionInfo? @@ -138,7 +181,7 @@ class OpenHABRootViewController: UIViewController { override func viewWillAppear(_ animated: Bool) { logger.info("OpenHABRootController viewWillAppear") super.viewWillAppear(animated) - navigationController?.navigationBar.prefersLargeTitles = true + navigationController?.navigationBar.prefersLargeTitles = false // if we have turned demo mode off/on, reset view if isDemoMode != Preferences.currentHomePreferences.demomode { switchToSavedView() @@ -763,8 +806,9 @@ extension OpenHABRootViewController: ModalHandler { nonisolated func modalDismissed(to: TargetController) { Task { @MainActor in switch to { - case .sitemap: + case let .sitemap(sitemapName): switchView(target: to) + await (sitemapViewController as? HostingSitemapViewController)?.pushSitemap(name: sitemapName, path: nil) case .settings: let hostingController = UIHostingController(rootView: SettingsView()) navigationController?.pushViewController(hostingController, animated: true) diff --git a/openHAB/OpenHABSitemapViewController.swift b/openHAB/OpenHABSitemapViewController.swift index c292bfa15..2fe734fad 100644 --- a/openHAB/OpenHABSitemapViewController.swift +++ b/openHAB/OpenHABSitemapViewController.swift @@ -123,8 +123,12 @@ class OpenHABSitemapViewController: OpenHABViewController, UISearchControllerDel super.viewDidAppear(animated) if parent?.navigationItem.searchController !== searchController { + let existingRightBarButtonItem = parent?.navigationItem.rightBarButtonItem parent?.navigationItem.searchController = searchController parent?.navigationItem.hidesSearchBarWhenScrolling = true + if let rightButton = existingRightBarButtonItem { + parent?.navigationItem.rightBarButtonItem = rightButton + } } } diff --git a/openHAB/SitemapPageViewModel.swift b/openHAB/SitemapPageViewModel.swift index 6c07a878e..880784822 100644 --- a/openHAB/SitemapPageViewModel.swift +++ b/openHAB/SitemapPageViewModel.swift @@ -45,6 +45,7 @@ class SitemapPageViewModel: ObservableObject { private var pageHandlingTask: Task? private var defaultSitemap = "" private var pageId = "" + private var isLinkedPage = false var relevantWidgets: [OpenHABWidget] { guard !searchText.isEmpty else { return currentPage?.widgets ?? [] } @@ -58,6 +59,10 @@ class SitemapPageViewModel: ObservableObject { currentPage?.title.components(separatedBy: "[")[0] ?? "Sitemap" } + var isLinked: Bool { + isLinkedPage + } + init() { loadSettings() setupActiveConnectionObserver() @@ -66,6 +71,7 @@ class SitemapPageViewModel: ObservableObject { init(pageUrl: String, title: String, pageId: String = "") { loadSettings() setupActiveConnectionObserver() + isLinkedPage = true // Extract pageId from URL if not provided if pageId.isEmpty { diff --git a/openHAB/SwiftUI/SitemapPageView.swift b/openHAB/SwiftUI/SitemapPageView.swift index d4fabe01c..02d336e26 100644 --- a/openHAB/SwiftUI/SitemapPageView.swift +++ b/openHAB/SwiftUI/SitemapPageView.swift @@ -18,12 +18,16 @@ struct SitemapPageView: View { @State private var showInputAlert = false @State private var selectedWidget: OpenHABWidget? + private var isLinkedPage: Bool { + viewModel.isLinked + } + var body: some View { NavigationStack { List(viewModel.relevantWidgets) { widget in Group { if let linkedPage = widget.linkedPage { - NavigationLink(destination: SitemapPageView(viewModel: SitemapPageViewModel(pageUrl: linkedPage.link, title: ""))) { + NavigationLink(destination: SitemapPageView(viewModel: SitemapPageViewModel(pageUrl: linkedPage.link, title: linkedPage.title))) { RowViewFactory.view(for: widget) } .buttonStyle(.plain) @@ -53,9 +57,9 @@ struct SitemapPageView: View { } } .listStyle(.plain) - .navigationTitle(viewModel.pageTitle) - .navigationBarTitleDisplayMode(.large) - .searchable(text: $viewModel.searchText, prompt: "Search items in sitemap") + .navigationBarHidden(!isLinkedPage) + .navigationTitle(isLinkedPage ? viewModel.pageTitle : "") + .navigationBarTitleDisplayMode(.inline) .refreshable { await viewModel.reload() } From 9e09418beca69ec57a1083e6598f44f4130bb19d Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Sun, 13 Jul 2025 21:41:10 +0200 Subject: [PATCH 253/476] Presentable port of OpenHABSitemapViewController to SwiftUI Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- openHAB/OpenHABRootViewController.swift | 7 ++- openHAB/OpenHABSitemapViewController.swift | 9 ++-- openHAB/OpenHABWebViewController.swift | 12 ++++- openHAB/SitemapPageViewModel.swift | 8 +-- openHAB/SwiftUI/Rows/SetpointRowView.swift | 60 +++++++++++----------- openHAB/SwiftUI/Rows/SliderRowView.swift | 14 +++-- openHAB/SwiftUI/SitemapPageView.swift | 1 + 7 files changed, 63 insertions(+), 48 deletions(-) diff --git a/openHAB/OpenHABRootViewController.swift b/openHAB/OpenHABRootViewController.swift index 8bb3074d5..1f8821b8c 100644 --- a/openHAB/OpenHABRootViewController.swift +++ b/openHAB/OpenHABRootViewController.swift @@ -50,7 +50,7 @@ class HostingSitemapViewController: UIHostingController, OpenHA @objc dynamic required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + override func viewDidLoad() { super.viewDidLoad() // Keep UIKit navigation bar visible for hamburger menu @@ -86,6 +86,9 @@ class HostingSitemapViewController: UIHostingController, OpenHA navigationItem.hidesSearchBarWhenScrolling = false } + func getSitemapTitle() -> String { + viewModel.pageTitle + } func viewName() -> String { "sitemap" } @@ -128,7 +131,7 @@ class OpenHABRootViewController: UIViewController { return viewController }() - private lazy var sitemapViewController: any (UIViewController & OpenHABViewable) = HostingSitemapViewController() + lazy var sitemapViewController: any (UIViewController & OpenHABViewable) = HostingSitemapViewController() private var activeConnection: ConnectionInfo? diff --git a/openHAB/OpenHABSitemapViewController.swift b/openHAB/OpenHABSitemapViewController.swift index 2fe734fad..98dab30ec 100644 --- a/openHAB/OpenHABSitemapViewController.swift +++ b/openHAB/OpenHABSitemapViewController.swift @@ -390,7 +390,8 @@ extension OpenHABSitemapViewController { // on initial load ??? refreshControl?.endRefreshing() widgetTableView.reloadData() - parent?.navigationItem.title = currentPage?.title.components(separatedBy: "[")[0] + let pageTitle = currentPage?.title.components(separatedBy: "[")[0] + parent?.navigationItem.title = pageTitle?.isEmpty == false ? pageTitle : defaultSitemap.isEmpty ? "Sitemap" : defaultSitemap } // Select sitemap @@ -484,9 +485,11 @@ extension OpenHABSitemapViewController { do { // Initial page load - guard let configuration = NetworkTracker.shared.activeConnection?.configuration else { - throw NetworkTrackerError.noActiveConnection + guard let activeConnection = await NetworkTracker.shared.waitForActiveConnection() else { + logger.error("Failed to establish connection within timeout") + return } + let configuration = activeConnection.configuration if openAPIService == nil { openAPIService = try OpenAPIService(connectionConfiguration: configuration) diff --git a/openHAB/OpenHABWebViewController.swift b/openHAB/OpenHABWebViewController.swift index 8e0386939..e574b142d 100644 --- a/openHAB/OpenHABWebViewController.swift +++ b/openHAB/OpenHABWebViewController.swift @@ -69,7 +69,7 @@ class OpenHABWebViewController: OpenHABViewController { super.viewWillAppear(animated) navigationController?.setNavigationBarHidden(hideNavBar, animated: animated) navigationController?.navigationBar.prefersLargeTitles = false - parent?.navigationItem.title = "Main View" + updateNavigationTitle() NetworkTracker.shared.$activeConnection .receive(on: DispatchQueue.main) .sink { activeConnection in @@ -94,6 +94,7 @@ class OpenHABWebViewController: OpenHABViewController { self.pageLoadError(message: NSLocalizedString("network_not_available", comment: "")) case .connected: self.hidePopupMessages() + self.updateNavigationTitle() default: break } } @@ -187,6 +188,15 @@ class OpenHABWebViewController: OpenHABViewController { navigationController?.setNavigationBarHidden(hideNavBar, animated: true) } + private func updateNavigationTitle() { + if let rootController = parent as? OpenHABRootViewController, + let sitemapController = rootController.sitemapViewController as? HostingSitemapViewController { + parent?.navigationItem.title = sitemapController.getSitemapTitle() + } else { + parent?.navigationItem.title = "Main View" + } + } + // swiftformat:disable redundantSelf func clearExistingPage() { logger.info("clearExistingPage - webView.url \(String(describing: self.webView.url?.description))") diff --git a/openHAB/SitemapPageViewModel.swift b/openHAB/SitemapPageViewModel.swift index 880784822..3d77c5c75 100644 --- a/openHAB/SitemapPageViewModel.swift +++ b/openHAB/SitemapPageViewModel.swift @@ -56,7 +56,7 @@ class SitemapPageViewModel: ObservableObject { } var pageTitle: String { - currentPage?.title.components(separatedBy: "[")[0] ?? "Sitemap" + currentPage?.title ?? (defaultSitemap.isEmpty ? "Sitemap" : defaultSitemap) } var isLinked: Bool { @@ -120,9 +120,11 @@ class SitemapPageViewModel: ObservableObject { // ) // } - guard let configuration = NetworkTracker.shared.activeConnection?.configuration else { - throw NetworkTrackerError.noActiveConnection + guard let activeConnection = await NetworkTracker.shared.waitForActiveConnection() else { + logger.error("Failed to establish connection within timeout") + return } + let configuration = activeConnection.configuration if openAPIService == nil { openAPIService = try OpenAPIService(connectionConfiguration: configuration) diff --git a/openHAB/SwiftUI/Rows/SetpointRowView.swift b/openHAB/SwiftUI/Rows/SetpointRowView.swift index c133d1863..ef929dfb8 100644 --- a/openHAB/SwiftUI/Rows/SetpointRowView.swift +++ b/openHAB/SwiftUI/Rows/SetpointRowView.swift @@ -39,41 +39,39 @@ struct SetpointRowView: View { } var body: some View { - VStack(alignment: .leading, spacing: 8) { - HStack { - if IconView.shouldShowIcon(for: widget) { - IconView(widget: widget) - .frame(width: 24, height: 24) - } + HStack { + if IconView.shouldShowIcon(for: widget) { + IconView(widget: widget) + .frame(width: 24, height: 24) + } + + VStack(alignment: .leading, spacing: 2) { + Text(widget.labelText ?? widget.label) + .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) + } + + Spacer() - VStack(alignment: .leading, spacing: 2) { - Text(widget.labelText ?? widget.label) - .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) + HStack(spacing: 12) { + Button(action: decreaseValue) { + Image(systemSymbol: .chevronDown) + .font(.title2) + .foregroundColor(.primary) } + .buttonStyle(.plain) + .disabled(currentValue <= widget.minValue) + + Text(formattedValue) + .font(.caption.monospacedDigit()) + .foregroundColor(widget.valuecolor.isEmpty ? .secondary : Color(fromString: widget.valuecolor)) - Spacer() - - HStack(spacing: 12) { - Button(action: decreaseValue) { - Image(systemSymbol: .chevronDown) - .font(.title2) - .foregroundColor(.primary) - } - .buttonStyle(.plain) - .disabled(currentValue <= widget.minValue) - - Text(formattedValue) - .font(.caption.monospacedDigit()) - .foregroundColor(widget.valuecolor.isEmpty ? .secondary : Color(fromString: widget.valuecolor)) - - Button(action: increaseValue) { - Image(systemSymbol: .chevronUp) - .font(.title2) - .foregroundColor(.primary) - } - .buttonStyle(.plain) - .disabled(currentValue >= widget.maxValue) + Button(action: increaseValue) { + Image(systemSymbol: .chevronUp) + .font(.title2) + .foregroundColor(.primary) } + .buttonStyle(.plain) + .disabled(currentValue >= widget.maxValue) } } } diff --git a/openHAB/SwiftUI/Rows/SliderRowView.swift b/openHAB/SwiftUI/Rows/SliderRowView.swift index e7e269e25..9be90911e 100644 --- a/openHAB/SwiftUI/Rows/SliderRowView.swift +++ b/openHAB/SwiftUI/Rows/SliderRowView.swift @@ -27,21 +27,19 @@ struct SliderRowView: View { } Text(widget.labelText ?? "") - .font(.headline) Spacer() - - Slider(value: .constant(currentValue), in: 0 ... 100) - .disabled(true) // unless you want editable if let value = widget.labelValue { Text(value) .font(.caption) .foregroundColor(.secondary) } + Slider(value: .constant(currentValue), in: 0 ... 100) + .disabled(true) // unless you want editable } - .padding() } } -// #Preview { -// WidgetSliderView() -// } +#Preview { + let widget = PreviewConstants.openHABSitemapPage!.widgets[3] + SliderRowView(widget: widget) +} diff --git a/openHAB/SwiftUI/SitemapPageView.swift b/openHAB/SwiftUI/SitemapPageView.swift index 02d336e26..9c4de37f9 100644 --- a/openHAB/SwiftUI/SitemapPageView.swift +++ b/openHAB/SwiftUI/SitemapPageView.swift @@ -31,6 +31,7 @@ struct SitemapPageView: View { RowViewFactory.view(for: widget) } .buttonStyle(.plain) + .padding(.vertical, -6) .listRowInsets(EdgeInsets(top: 0, leading: 4, bottom: 0, trailing: 24)) } else if widget.type == .selection { Button { From 67e37d1764bbb89bf04804d3067b35801f88bc11 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Sun, 13 Jul 2025 21:53:34 +0200 Subject: [PATCH 254/476] Add support for primary and secondary color (#894) Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../Sources/OpenHABCore/Util/UIColorExtension.swift | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/UIColorExtension.swift b/OpenHABCore/Sources/OpenHABCore/Util/UIColorExtension.swift index b991a93b5..f6cd9665d 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/UIColorExtension.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/UIColorExtension.swift @@ -9,6 +9,7 @@ // // SPDX-License-Identifier: EPL-2.0 +import SwiftUI import UIKit public enum OHInterfaceStyle: Int { @@ -145,6 +146,14 @@ public extension UIColor { class var ohGray: UIColor { OHInterfaceStyle.current == .light ? UIColor(hex: "#808080") : UIColor(hex: "#808080") } + + class var ohPrimary: UIColor { + UIColor(.primary) + } + + class var ohSecondary: UIColor { + UIColor(.secondary) + } } public extension UIColor { @@ -166,7 +175,9 @@ public extension UIColor { "aqua": UIColor.ohAqua, "black": UIColor.ohBlack, "silver": UIColor.ohSilver, - "gray": UIColor.ohGray + "gray": UIColor.ohGray, + "primary": UIColor.ohPrimary, + "secondary": UIColor.ohSecondary ] self.init(cgColor: namedColors.first { $0.key == string.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }?.value.cgColor ?? UIColor(hex: string).cgColor) From 55410d7727d1076f6e31ebf7a6cfd2e59192879a Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Mon, 14 Jul 2025 09:13:41 +0200 Subject: [PATCH 255/476] Fix regression Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- openHABWatch/Views/Rows/SetpointRow.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openHABWatch/Views/Rows/SetpointRow.swift b/openHABWatch/Views/Rows/SetpointRow.swift index 27fe49f23..5036a8113 100644 --- a/openHABWatch/Views/Rows/SetpointRow.swift +++ b/openHABWatch/Views/Rows/SetpointRow.swift @@ -81,6 +81,8 @@ struct SetpointRow: View { } else { numberState = NumberState(value: limitedNewValue) } + + widget.sendItemUpdate(state: numberState) } func decreaseValue() { From 60611e567556049f8ca0664dde2efcd55f15a79f Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Tue, 15 Jul 2025 12:42:24 +0200 Subject: [PATCH 256/476] Implementing copilot review suggestions Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift | 2 +- openHAB/SwiftUI/Rows/DatePickerInputRowView.swift | 6 +----- openHAB/SwiftUI/Rows/MapRowView.swift | 2 +- openHAB/SwiftUI/Rows/SelectionRowView.swift | 3 ++- openHAB/SwiftUI/Rows/SetpointRowView.swift | 7 ++----- openHAB/SwiftUI/SitemapPageView.swift | 6 +++++- openHABWatch/Views/Rows/SetpointRow.swift | 9 +++------ 7 files changed, 15 insertions(+), 20 deletions(-) diff --git a/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift b/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift index 9a3398e57..190397680 100644 --- a/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift +++ b/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift @@ -66,7 +66,7 @@ struct ColorTemperaturePickerRowView: View { ) { _ in sendTemperatureCommand() } - .accentColor(.clear) // Hide default slider track + .tint(.clear) // Hide default slider track } // Cool indicator diff --git a/openHAB/SwiftUI/Rows/DatePickerInputRowView.swift b/openHAB/SwiftUI/Rows/DatePickerInputRowView.swift index ccf2a1f13..a456b132d 100644 --- a/openHAB/SwiftUI/Rows/DatePickerInputRowView.swift +++ b/openHAB/SwiftUI/Rows/DatePickerInputRowView.swift @@ -46,11 +46,7 @@ struct DatePickerInputRowView: View { ) { EmptyView() } -// if useWheelStyle { -// .datePickerStyle(.wheel) -// } else { -// .datePickerStyle(.compact) -// } + // TODO: Consider reintroducing a dynamic DatePicker style based on `useWheelStyle` if needed in the future. .onChange(of: selectedDate) { newDate in sendDateCommand(newDate) } diff --git a/openHAB/SwiftUI/Rows/MapRowView.swift b/openHAB/SwiftUI/Rows/MapRowView.swift index f6cd0607a..bf4db5f47 100644 --- a/openHAB/SwiftUI/Rows/MapRowView.swift +++ b/openHAB/SwiftUI/Rows/MapRowView.swift @@ -40,7 +40,7 @@ struct MapRowView: View { .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) } - Map(coordinateRegion: $region, annotationItems: coordinates.map { [$0] } ?? []) { coordinate in + Map(coordinateRegion: $region, annotationItems: coordinates != nil ? [coordinates!] : []) { coordinate in MapPin(coordinate: coordinate, tint: .red) } .frame(height: 200) diff --git a/openHAB/SwiftUI/Rows/SelectionRowView.swift b/openHAB/SwiftUI/Rows/SelectionRowView.swift index 18ba2c89b..1b423b210 100644 --- a/openHAB/SwiftUI/Rows/SelectionRowView.swift +++ b/openHAB/SwiftUI/Rows/SelectionRowView.swift @@ -17,6 +17,7 @@ import SwiftUI struct SelectionRowView: View { @ObservedObject var widget: OpenHABWidget @State private var selectedIndex = 0 + @ScaledMetric(relativeTo: .body) private var pickerHeight: CGFloat = 24 private let logger = Logger(subsystem: "org.openhab", category: "WidgetSelectionView") @@ -41,7 +42,7 @@ struct SelectionRowView: View { } } .pickerStyle(.menu) - .frame(height: 24) // 👈 Restrict height of the Picker + .frame(height: pickerHeight) // 👈 Restrict height of the Picker .onChange(of: selectedIndex) { newIndex in guard let mapping = mappings[safe: newIndex] else { return } logger.info("Selection changed to: \(mapping.label)") diff --git a/openHAB/SwiftUI/Rows/SetpointRowView.swift b/openHAB/SwiftUI/Rows/SetpointRowView.swift index ef929dfb8..db63be462 100644 --- a/openHAB/SwiftUI/Rows/SetpointRowView.swift +++ b/openHAB/SwiftUI/Rows/SetpointRowView.swift @@ -101,11 +101,8 @@ struct SetpointRowView: View { return } - if numberState != nil { - numberState?.value = limitedNewValue - } else { - numberState = NumberState(value: limitedNewValue) - } + numberState = numberState ?? NumberState(value: limitedNewValue) + numberState?.value = limitedNewValue logger.info("Setpoint \(isDecreasing ? "decreased" : "increased") to \(limitedNewValue)") widget.sendItemUpdate(state: numberState) diff --git a/openHAB/SwiftUI/SitemapPageView.swift b/openHAB/SwiftUI/SitemapPageView.swift index 9c4de37f9..cb76472ee 100644 --- a/openHAB/SwiftUI/SitemapPageView.swift +++ b/openHAB/SwiftUI/SitemapPageView.swift @@ -17,6 +17,7 @@ struct SitemapPageView: View { @State private var showSelectionSheet = false @State private var showInputAlert = false @State private var selectedWidget: OpenHABWidget? + @State private var inputText = "" private var isLinkedPage: Bool { viewModel.isLinked @@ -85,11 +86,14 @@ struct SitemapPageView: View { } .alert("Input", isPresented: $showInputAlert) { if let widget = selectedWidget { - TextField("Enter value", text: .constant(widget.state)) + TextField("Enter value", text: $inputText) Button("Cancel", role: .cancel) {} Button("OK") { // Handle input submission showInputAlert = false + if let item = widget.item { + viewModel.sendCommand(item, commandToSend: inputText) + } } } } diff --git a/openHABWatch/Views/Rows/SetpointRow.swift b/openHABWatch/Views/Rows/SetpointRow.swift index 5036a8113..a430b8153 100644 --- a/openHABWatch/Views/Rows/SetpointRow.swift +++ b/openHABWatch/Views/Rows/SetpointRow.swift @@ -76,12 +76,9 @@ struct SetpointRow: View { return } - if numberState != nil { - numberState?.value = limitedNewValue - } else { - numberState = NumberState(value: limitedNewValue) - } - + numberState = numberState ?? NumberState(value: limitedNewValue) + numberState?.value = limitedNewValue + widget.sendItemUpdate(state: numberState) } From f683e5a36b76267d80dcb84b61f85f1561b384f8 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Tue, 15 Jul 2025 16:36:29 +0200 Subject: [PATCH 257/476] Port ButtonGrid functionality to iOS app Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../OpenHABCore/Model/OpenHABWidget.swift | 6 + .../Model/OpenHABWidgetMapping.swift | 11 +- openHAB.xcodeproj/project.pbxproj | 4 + openHAB/Cells/WidgetCellProvider.swift | 2 +- openHAB/SwiftUI/IconView.swift | 5 + openHAB/SwiftUI/RowViewFactory.swift | 2 + openHAB/SwiftUI/Rows/ButtonGridRowView.swift | 177 ++++++++++++++++++ sitemap.json | 1 + 8 files changed, 205 insertions(+), 3 deletions(-) create mode 100644 openHAB/SwiftUI/Rows/ButtonGridRowView.swift create mode 100644 sitemap.json diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift index f361e882e..24b2ce628 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift @@ -54,6 +54,7 @@ public class OpenHABWidget: NSObject, MKAnnotation, Identifiable, ObservableObje case video = "Video" case webview = "Webview" case colortemperaturepicker = "Colortemperaturepicker" + case buttongrid = "Buttongrid" case unknown = "Unknown" } @@ -335,6 +336,11 @@ public extension OpenHABWidget { self.forceAsItem = forceAsItem stateEnumBinding = stateEnum } + + convenience init(icon: String, iconColor: String? = nil) { + // swiftlint:disable:next line_length + self.init(widgetId: "\(UUID())", label: "", icon: icon, type: .unknown, url: nil, period: nil, minValue: nil, maxValue: nil, step: nil, refresh: nil, height: nil, isLeaf: nil, iconColor: iconColor, labelColor: nil, valueColor: nil, service: nil, state: nil, text: nil, legend: nil, inputHint: nil, encoding: nil, item: nil, linkedPage: nil, mappings: [], widgets: [], visibility: nil, switchSupport: nil, forceAsItem: nil) + } } public extension OpenHABWidget { diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidgetMapping.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidgetMapping.swift index c02733166..3d683ef9b 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidgetMapping.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidgetMapping.swift @@ -14,15 +14,22 @@ import Foundation public struct OpenHABWidgetMapping: Decodable, Sendable { public var command = "" public var label = "" + public var row: Int? + public var column: Int? + public var icon: String? + public var releaseCommand: String? - public init(command: String?, label: String?) { + public init(command: String?, label: String?, row: Int? = nil, column: Int? = nil, icon: String? = nil, releaseCommand: String? = nil) { self.command = command.orEmpty self.label = label.orEmpty + self.row = row + self.column = column + self.icon = icon } } extension OpenHABWidgetMapping { init(_ mapping: Components.Schemas.MappingDTO) { - self.init(command: mapping.command, label: mapping.label) + self.init(command: mapping.command, label: mapping.label, row: mapping.row.map { Int($0) }, column: mapping.column.map { Int($0) }, icon: mapping.icon, releaseCommand: mapping.releaseCommand) } } diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index 4fd5dd4a6..5256b5c63 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -156,6 +156,7 @@ DAD0857B2AE4782F001D36BE /* OpenHABWatchUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAD0857A2AE4782F001D36BE /* OpenHABWatchUITests.swift */; }; DAD0857D2AE4782F001D36BE /* OpenHABWatchLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAD0857C2AE4782F001D36BE /* OpenHABWatchLaunchTests.swift */; }; DAD0858B2AE56F0E001D36BE /* OpenHABWatch.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAD0855F2AE47824001D36BE /* OpenHABWatch.swift */; }; + DAE7B4A72E26927C00B9FE99 /* ButtonGridRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAE7B4A62E26927C00B9FE99 /* ButtonGridRowView.swift */; }; DAEA21D82DBF472D00D54342 /* RowViewFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAEA21D72DBF472D00D54342 /* RowViewFactory.swift */; }; DAEA21DA2DBF477E00D54342 /* SwitchRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAEA21D92DBF477E00D54342 /* SwitchRowView.swift */; }; DAEA21DC2DBF47DA00D54342 /* SliderRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAEA21DB2DBF47DA00D54342 /* SliderRowView.swift */; }; @@ -494,6 +495,7 @@ DAD488B3287DDDFE00414693 /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = Resources/nb.lproj/Intents.strings; sourceTree = ""; }; DAD488B4287DDDFF00414693 /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = nb.lproj/Localizable.strings; sourceTree = ""; }; DAD488B5287DDDFF00414693 /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = nb.lproj/InfoPlist.strings; sourceTree = ""; }; + DAE7B4A62E26927C00B9FE99 /* ButtonGridRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ButtonGridRowView.swift; sourceTree = ""; }; DAEA21D72DBF472D00D54342 /* RowViewFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RowViewFactory.swift; sourceTree = ""; }; DAEA21D92DBF477E00D54342 /* SwitchRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwitchRowView.swift; sourceTree = ""; }; DAEA21DB2DBF47DA00D54342 /* SliderRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SliderRowView.swift; sourceTree = ""; }; @@ -898,6 +900,7 @@ DAC949FF2E21A473007E67B7 /* Rows */ = { isa = PBXGroup; children = ( + DAE7B4A62E26927C00B9FE99 /* ButtonGridRowView.swift */, DA35E2B22E1EEA9D003987BB /* ColorPickerRowView.swift */, DAEE35062E224F6000B4A7F1 /* ColorTemperaturePickerRowView.swift */, DA35E2B32E1EEA9D003987BB /* DatePickerInputRowView.swift */, @@ -1730,6 +1733,7 @@ DA9F81872C85020F00B47B72 /* RTFTextView.swift in Sources */, DA6B2EF12C87B59000DF77CF /* NotificationsView.swift in Sources */, DA50C7BF2B0A65300009F716 /* SliderWithSwitchSupportUITableViewCell.swift in Sources */, + DAE7B4A72E26927C00B9FE99 /* ButtonGridRowView.swift in Sources */, DA5ED9BE2C850955004875E0 /* ClientCertificatesViewModel.swift in Sources */, DAEA21D82DBF472D00D54342 /* RowViewFactory.swift in Sources */, DA21EAE22339621C001AB415 /* Throttler.swift in Sources */, diff --git a/openHAB/Cells/WidgetCellProvider.swift b/openHAB/Cells/WidgetCellProvider.swift index 1c3772d7f..7e32c570f 100644 --- a/openHAB/Cells/WidgetCellProvider.swift +++ b/openHAB/Cells/WidgetCellProvider.swift @@ -48,7 +48,7 @@ enum WidgetCellFactory { case .video: VideoCellProvider() case .webview: WebViewCellProvider() case .mapview: MapViewCellProvider() - case .group, .text, .defaultWidget, .colortemperaturepicker, .unknown: + case .group, .text, .defaultWidget, .colortemperaturepicker, .buttongrid, .unknown: GenericCellProvider() } } diff --git a/openHAB/SwiftUI/IconView.swift b/openHAB/SwiftUI/IconView.swift index 7b5f5454f..abb9a7897 100644 --- a/openHAB/SwiftUI/IconView.swift +++ b/openHAB/SwiftUI/IconView.swift @@ -101,6 +101,11 @@ extension IconView { size: CGSize(width: 24, height: 24) ) } + + init(icon: String, colorColor: Color = .primary) { + let widget = OpenHABWidget(icon: icon, iconColor: iconColor) + self.init(widget: widget) + } } // MARK: - Widget Type Extensions diff --git a/openHAB/SwiftUI/RowViewFactory.swift b/openHAB/SwiftUI/RowViewFactory.swift index 52355fc78..51e92c53c 100644 --- a/openHAB/SwiftUI/RowViewFactory.swift +++ b/openHAB/SwiftUI/RowViewFactory.swift @@ -60,6 +60,8 @@ enum RowViewFactory { MapRowView(widget: widget) case .colortemperaturepicker: ColorTemperaturePickerRowView(widget: widget) + case .buttongrid: + ButtonGridRowView(widget: widget) case .group, .defaultWidget, .unknown: GenericRowView(widget: widget) } diff --git a/openHAB/SwiftUI/Rows/ButtonGridRowView.swift b/openHAB/SwiftUI/Rows/ButtonGridRowView.swift new file mode 100644 index 000000000..f9d2f1250 --- /dev/null +++ b/openHAB/SwiftUI/Rows/ButtonGridRowView.swift @@ -0,0 +1,177 @@ +// Copyright (c) 2010-2025 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 CommonUI +import OpenHABCore +import os.log +import SwiftUI + +struct ButtonGridRowView: View { + @ObservedObject var widget: OpenHABWidget + + private let logger = Logger(subsystem: "org.openhab", category: "ButtonGridRowView") + + // Maximum number of columns based on screen width + private let maxColumns = 12 + + private var buttons: [OpenHABWidgetMapping] { + widget.mappings + } + +// private var showLabelAndIcon: Bool { +// !widget.label.isEmpty && widget.labelSource == .sitemapDefinition +// } + + private var gridRows: Int { + buttons.map { $0.row ?? 1 }.max() ?? 1 + } + + private var gridColumns: Int { + min(buttons.map { $0.column ?? 1 }.max() ?? 1, maxColumns) + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { +// if showLabelAndIcon { + HStack { + if IconView.shouldShowIcon(for: widget) { + IconView(widget: widget) + .frame(width: 24, height: 24) + } + + Text(widget.labelText ?? widget.label) + .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) + + Spacer() + } +// } + + LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: gridColumns), spacing: 8) { + ForEach(0 ..< gridRows, id: \.self) { row in + ForEach(0 ..< gridColumns, id: \.self) { column in + let button = buttonForPosition(row: row, column: column) + + if let button { + ButtonGridButton(button: button, widget: widget) + } else { + // Empty cell to maintain grid structure + Rectangle() + .fill(Color.clear) + .frame(height: 44) + } + } + } + } + } + } + + private func buttonForPosition(row: Int, column: Int) -> OpenHABWidgetMapping? { + buttons.first { button in + // OpenHAB uses 1-based indexing, convert to 0-based + (button.row ?? 1) - 1 == row && (button.column ?? 1) - 1 == column + } + } +} + +struct ButtonGridButton: View { + let button: OpenHABWidgetMapping + let widget: OpenHABWidget + + @State private var isPressed = false + + private let logger = Logger(subsystem: "org.openhab", category: "ButtonGridButton") + + private var isStateful: Bool { + // Mappings are typically stateless unless specified otherwise + false + } + + private var isSelected: Bool { + guard isStateful else { return false } + return widget.item?.state == button.command + } + + var body: some View { + Button { + handleButtonPress() + } label: { + HStack { + if let icon = button.icon, !icon.isEmpty { + IconView(icon: icon) + .frame(width: 16, height: 16) + } + + Text(button.label) + .font(.caption) + .foregroundColor(.primary) + .lineLimit(1) + .truncationMode(.tail) + } + .frame(maxWidth: .infinity) + .frame(height: 44) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(isSelected ? Color.accentColor : Color.secondary.opacity(0.1)) + ) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(isSelected ? Color.accentColor : Color.secondary.opacity(0.3), lineWidth: 1) + ) + .scaleEffect(isPressed ? 0.95 : 1.0) + } + .buttonStyle(PlainButtonStyle()) +// .disabled(widget.readOnly) + .onPressGesture( + onPress: { + handleTouchDown() + }, + onRelease: { + handleTouchUp() + } + ) + } + + private func handleButtonPress() { + // Send command on tap for mappings + if !button.command.isEmpty { + logger.info("Sending command: \(button.command)") + widget.sendCommand(button.command) + } + } + + private func handleTouchDown() { + isPressed = true + } + + private func handleTouchUp() { + isPressed = false + } +} + +extension View { + func onPressGesture(onPress: @escaping () -> Void, + onRelease: @escaping () -> Void) -> some View { + gesture( + DragGesture(minimumDistance: 0) + .onChanged { _ in + onPress() + } + .onEnded { _ in + onRelease() + } + ) + } +} + +#Preview { + let widget = PreviewConstants.openHABSitemapPage!.widgets.first { $0.type == .switchWidget }! + ButtonGridRowView(widget: widget) +} diff --git a/sitemap.json b/sitemap.json new file mode 100644 index 000000000..c866d1d25 --- /dev/null +++ b/sitemap.json @@ -0,0 +1 @@ +{"name":"uicomponents_page_dddf943769","label":"Test Widgets","link":"http://192.168.2.10:8080/rest/sitemaps/uicomponents_page_dddf943769","homepage":{"id":"uicomponents_page_dddf943769","title":"Test Widgets","link":"http://192.168.2.10:8080/rest/sitemaps/uicomponents_page_dddf943769/uicomponents_page_dddf943769","leaf":false,"timeout":false,"widgets":[{"widgetId":"00","type":"Image","visibility":true,"label":"","labelSource":"NONE","icon":"image","staticIcon":false,"unit":"","mappings":[],"url":"http://192.168.2.10:8080/proxy?sitemap\u003duicomponents_page_dddf943769\u0026widgetId\u003d00","widgets":[]},{"widgetId":"01","type":"Video","visibility":true,"label":"testvideo","labelSource":"SITEMAP_WIDGET","icon":"video","staticIcon":false,"unit":"","mappings":[],"url":"http://192.168.2.10:8080/proxy?sitemap\u003duicomponents_page_dddf943769\u0026widgetId\u003d01","encoding":"mjpeg","widgets":[]},{"widgetId":"02","type":"Text","visibility":true,"label":"Public IP [-]","labelSource":"SITEMAP_WIDGET","icon":"if:mdi:lightbulb","staticIcon":false,"pattern":"%s","unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/testStringItem","state":"NULL","stateDescription":{"pattern":"%s","readOnly":false,"options":[]},"type":"String","name":"testStringItem","label":"testString","category":"","tags":[],"groupNames":[]},"widgets":[]},{"widgetId":"03","type":"Text","visibility":true,"label":"Access Point [dd-wrt-unifi-0]","labelSource":"ITEM_LABEL","icon":"","staticIcon":false,"pattern":"%s","unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/UniFiWirelessClientiphonetim11pro_AccessPoint","state":"dd-wrt-unifi-0","stateDescription":{"pattern":"%s","readOnly":true,"options":[]},"type":"String","name":"UniFiWirelessClientiphonetim11pro_AccessPoint","label":"Access Point","category":"","tags":["Point"],"groupNames":["UniFiWirelessClientiphonetim11pro"]},"widgets":[]},{"widgetId":"04","type":"Setpoint","visibility":true,"label":"Kitchen [102.2 °C]","labelSource":"SITEMAP_WIDGET","icon":"","staticIcon":false,"pattern":"%.1f °C","unit":"°C","mappings":[],"minValue":0.0,"maxValue":30.0,"step":5.0,"item":{"link":"http://192.168.2.10:8080/rest/items/TestSetpoint","state":"102.2222222222222222222222222222223 °C","stateDescription":{"pattern":"%.1f %unit%","readOnly":false,"options":[]},"unitSymbol":"°C","type":"Number:Temperature","name":"TestSetpoint","label":"TestSetpoint","category":"","tags":[],"groupNames":[]},"widgets":[]},{"widgetId":"05","type":"Setpoint","visibility":true,"label":"default [102.2 °C]","labelSource":"SITEMAP_WIDGET","icon":"","staticIcon":false,"pattern":"%.1f %unit%","unit":"°C","mappings":[],"minValue":100.0,"maxValue":100.0,"item":{"link":"http://192.168.2.10:8080/rest/items/TestSetpoint","state":"102.2222222222222222222222222222223 °C","stateDescription":{"pattern":"%.1f %unit%","readOnly":false,"options":[]},"unitSymbol":"°C","type":"Number:Temperature","name":"TestSetpoint","label":"TestSetpoint","category":"","tags":[],"groupNames":[]},"widgets":[]},{"widgetId":"06","type":"Setpoint","visibility":true,"label":"C[102.2 °C]","labelSource":"SITEMAP_WIDGET","icon":"","staticIcon":false,"pattern":"%.1f °C","unit":"°C","mappings":[],"minValue":0.0,"maxValue":100.0,"item":{"link":"http://192.168.2.10:8080/rest/items/TestSetpoint","state":"102.2222222222222222222222222222223 °C","stateDescription":{"pattern":"%.1f %unit%","readOnly":false,"options":[]},"unitSymbol":"°C","type":"Number:Temperature","name":"TestSetpoint","label":"TestSetpoint","category":"","tags":[],"groupNames":[]},"widgets":[]},{"widgetId":"07","type":"Setpoint","visibility":true,"label":"F[216.0 °F]","labelSource":"SITEMAP_WIDGET","icon":"","staticIcon":false,"pattern":"%.1f °F","unit":"°F","mappings":[],"minValue":0.0,"maxValue":1000.0,"state":"216.0000000000000000000000000000001 °F","item":{"link":"http://192.168.2.10:8080/rest/items/TestSetpoint","state":"102.2222222222222222222222222222223 °C","stateDescription":{"pattern":"%.1f %unit%","readOnly":false,"options":[]},"unitSymbol":"°C","type":"Number:Temperature","name":"TestSetpoint","label":"TestSetpoint","category":"","tags":[],"groupNames":[]},"widgets":[]},{"widgetId":"08","type":"Setpoint","visibility":true,"label":"K[375.4 K]","labelSource":"SITEMAP_WIDGET","icon":"","staticIcon":false,"pattern":"%.1f K","unit":"K","mappings":[],"minValue":100.0,"maxValue":1000.0,"state":"375.3722222222222222222222222222223 K","item":{"link":"http://192.168.2.10:8080/rest/items/TestSetpoint","state":"102.2222222222222222222222222222223 °C","stateDescription":{"pattern":"%.1f %unit%","readOnly":false,"options":[]},"unitSymbol":"°C","type":"Number:Temperature","name":"TestSetpoint","label":"TestSetpoint","category":"","tags":[],"groupNames":[]},"widgets":[]},{"widgetId":"09","type":"Input","visibility":true,"label":"Meter [166000]","labelSource":"SITEMAP_WIDGET","icon":"energy","staticIcon":true,"pattern":"%.0f %unit%","unit":"","mappings":[],"inputHint":"number","item":{"link":"http://192.168.2.10:8080/rest/items/Test_Meter_Reading","state":"166000.0","stateDescription":{"pattern":"%.0f","readOnly":false,"options":[]},"type":"Number","name":"Test_Meter_Reading","label":"Test_Meter_Reading","category":"","tags":[],"groupNames":[]},"widgets":[]},{"widgetId":"10","type":"Setpoint","visibility":true,"label":"item in seconds [2400.0 s]","labelSource":"SITEMAP_WIDGET","icon":"","staticIcon":false,"pattern":"%.1f s","unit":"s","mappings":[],"minValue":300.0,"maxValue":3600.0,"step":60.0,"item":{"link":"http://192.168.2.10:8080/rest/items/testTime","state":"2400 s","stateDescription":{"pattern":"%.0f %unit%","readOnly":false,"options":[]},"unitSymbol":"s","type":"Number:Time","name":"testTime","label":"","category":"","tags":[],"groupNames":[]},"widgets":[]},{"widgetId":"11","type":"Setpoint","visibility":true,"label":"item in minutes [40.0 min]","labelSource":"SITEMAP_WIDGET","icon":"","staticIcon":false,"pattern":"%.1f min","unit":"min","mappings":[],"minValue":5.0,"maxValue":60.0,"step":5.0,"state":"40 min","item":{"link":"http://192.168.2.10:8080/rest/items/testTime","state":"2400 s","stateDescription":{"pattern":"%.0f %unit%","readOnly":false,"options":[]},"unitSymbol":"s","type":"Number:Time","name":"testTime","label":"","category":"","tags":[],"groupNames":[]},"widgets":[]},{"widgetId":"13","type":"Colortemperaturepicker","visibility":true,"label":"Color Temperature [5600 K]","labelSource":"SITEMAP_WIDGET","icon":"colorwheel","staticIcon":true,"pattern":"%.0f %unit%","unit":"K","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/test_LEDLight_ColorTemp","state":"5600 K","stateDescription":{"pattern":"%.0f %unit%","readOnly":false,"options":[]},"unitSymbol":"K","type":"Number:Temperature","name":"test_LEDLight_ColorTemp","label":"","category":"","tags":[],"groupNames":[]},"widgets":[]},{"widgetId":"14","type":"Slider","visibility":true,"label":"Brightness","labelSource":"SITEMAP_WIDGET","icon":"","staticIcon":false,"unit":"","mappings":[],"switchSupport":false,"releaseOnly":false,"item":{"link":"http://192.168.2.10:8080/rest/items/test_LEDLight_Brightness","state":"71","type":"Dimmer","name":"test_LEDLight_Brightness","label":"","category":"","tags":[],"groupNames":[]},"widgets":[]},{"widgetId":"15","type":"Colorpicker","visibility":true,"label":"Color","labelSource":"SITEMAP_WIDGET","icon":"colorwheel","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/test_LEDLight_color","state":"0,0,73","type":"Color","name":"test_LEDLight_color","label":"test_LEDLight_color","category":"","tags":[],"groupNames":[]},"widgets":[]},{"widgetId":"16","type":"Buttongrid","visibility":true,"label":"Remote Control [-]","labelSource":"SITEMAP_WIDGET","icon":"screen","staticIcon":true,"pattern":"%s","unit":"","mappings":[{"row":1,"column":1,"command":"POWER","label":"Power","icon":"switch-off"},{"row":1,"column":2,"command":"MENU","label":"Menu"},{"row":1,"column":3,"command":"EXIT","label":"Exit"},{"row":2,"column":2,"command":"UP","label":"Up","icon":"f7:arrowtriangle_up"},{"row":2,"column":4,"command":"VOL_PLUS","label":"Volume +"},{"row":3,"column":1,"command":"LEFT","label":"Left","icon":"f7:arrowtriangle_left"},{"row":3,"column":2,"command":"OK","label":"Ok"},{"row":3,"column":3,"command":"RIGHT","label":"Right","icon":"f7:arrowtriangle_right"},{"row":3,"column":4,"command":"MUTE","label":"Mute","icon":"soundvolume_mute"},{"row":4,"column":2,"command":"DOWN","label":"Down","icon":"f7:arrowtriangle_down"},{"row":4,"column":4,"command":"VOL_MINUS","label":"Volume -"}],"item":{"link":"http://192.168.2.10:8080/rest/items/test_RemoteControl","state":"NULL","stateDescription":{"pattern":"%s","readOnly":false,"options":[]},"type":"String","name":"test_RemoteControl","label":"test_RemoteControl","category":"","tags":[],"groupNames":[]},"widgets":[]}]}} From 3f8c9d5d41802aca7afc6f00cd2746245cc822fb Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Tue, 15 Jul 2025 19:27:07 +0200 Subject: [PATCH 258/476] Handle dateTime as inputHint with DateTimePicker Introduce Previews SliderRowView reacts on remote changes Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../OpenHABCore/Model/OpenHABWidget.swift | 2 +- openHAB/Cells/WidgetCellProvider.swift | 2 +- openHAB/DatePickerUITableViewCell.swift | 2 +- openHAB/OpenHABSitemapViewController.swift | 2 +- openHAB/PreviewConstants.swift | 129 +++++++++++++++++- openHAB/SwiftUI/IconView.swift | 2 +- openHAB/SwiftUI/RowViewFactory.swift | 2 +- openHAB/SwiftUI/Rows/ButtonGridRowView.swift | 11 +- openHAB/SwiftUI/Rows/ColorPickerRowView.swift | 14 ++ .../Rows/ColorTemperaturePickerRowView.swift | 54 +++++++- .../SwiftUI/Rows/DatePickerInputRowView.swift | 28 ++-- .../SwiftUI/Rows/RollershutterRowView.swift | 5 +- openHAB/SwiftUI/Rows/SegmentedRowView.swift | 5 +- openHAB/SwiftUI/Rows/SetpointRowView.swift | 5 +- openHAB/SwiftUI/Rows/SliderRowView.swift | 52 +++++-- openHAB/SwiftUI/Rows/SwitchRowView.swift | 5 +- openHAB/SwiftUI/Rows/TextRowView.swift | 5 +- 17 files changed, 285 insertions(+), 40 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift index 24b2ce628..c1ef776ef 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift @@ -59,7 +59,7 @@ public class OpenHABWidget: NSObject, MKAnnotation, Identifiable, ObservableObje } public enum InputHint: String, Decodable { - case text, number, date, time, datetime, unknown + case text, number, date, time, dateTime, unknown } private let logger = Logger(subsystem: "org.openhab", category: "OpenHABWidget") diff --git a/openHAB/Cells/WidgetCellProvider.swift b/openHAB/Cells/WidgetCellProvider.swift index 7e32c570f..5edd14f7f 100644 --- a/openHAB/Cells/WidgetCellProvider.swift +++ b/openHAB/Cells/WidgetCellProvider.swift @@ -35,7 +35,7 @@ enum WidgetCellFactory { SliderProvider() } case .input: - if [.date, .time, .datetime].contains(widget.inputHint) { + if [.date, .time, .dateTime].contains(widget.inputHint) { DatePickerInputProvider() } else { TextInputProvider() diff --git a/openHAB/DatePickerUITableViewCell.swift b/openHAB/DatePickerUITableViewCell.swift index 3f971eb83..e9463352e 100644 --- a/openHAB/DatePickerUITableViewCell.swift +++ b/openHAB/DatePickerUITableViewCell.swift @@ -30,7 +30,7 @@ class DatePickerUITableViewCell: GenericUITableViewCell { datePicker.datePickerMode = .date case .time: datePicker.datePickerMode = .time - case .datetime: + case .dateTime: datePicker.datePickerMode = .dateAndTime default: fatalError("Must not use this cell for input other than date and time") diff --git a/openHAB/OpenHABSitemapViewController.swift b/openHAB/OpenHABSitemapViewController.swift index 98dab30ec..f0b70db14 100644 --- a/openHAB/OpenHABSitemapViewController.swift +++ b/openHAB/OpenHABSitemapViewController.swift @@ -818,7 +818,7 @@ extension OpenHABSitemapViewController: UITableViewDelegate, UITableViewDataSour let textFieldAdder: ((UITextField) -> Void)? switch hint { - case .date, .time, .datetime: + case .date, .time, .dateTime: // value setting is handeled by the cell itself textExtractor = nil textFieldAdder = nil diff --git a/openHAB/PreviewConstants.swift b/openHAB/PreviewConstants.swift index 86f5af570..ef28ef8f8 100644 --- a/openHAB/PreviewConstants.swift +++ b/openHAB/PreviewConstants.swift @@ -13,6 +13,7 @@ import Foundation import OpenHABCore import os.log +// swiftlint:disable type_body_length enum PreviewConstants { static let logger = Logger(subsystem: "org.openhab", category: "PreviewConstants") @@ -418,8 +419,134 @@ enum PreviewConstants { "groupNames": [] }, "widgets": [] - } + }, + { + "widgetId": "15", + "type": "Colorpicker", + "visibility": true, + "label": "Color", + "labelSource": "SITEMAP_WIDGET", + "icon": "colorwheel", + "staticIcon": false, + "unit": "", + "mappings": [], + "item": { + "link": "http://192.168.2.10:8080/rest/items/test_LEDLight_color", + "state": "0,0,73", + "type": "Color", + "name": "test_LEDLight_color", + "label": "test_LEDLight_color", + "category": "", + "tags": [], + "groupNames": [] + }, + "widgets": [] + }, + { + "widgetId": "16", + "type": "Buttongrid", + "visibility": true, + "label": "Remote Control [-]", + "labelSource": "SITEMAP_WIDGET", + "icon": "screen", + "staticIcon": true, + "pattern": "%s", + "unit": "", + "mappings": [ + { + "row": 1, + "column": 1, + "command": "POWER", + "label": "Power", + "icon": "switch-off" + }, + { + "row": 1, + "column": 2, + "command": "MENU", + "label": "Menu" + }, + { + "row": 1, + "column": 3, + "command": "EXIT", + "label": "Exit" + }, + { + "row": 2, + "column": 2, + "command": "UP", + "label": "Up", + "icon": "f7:arrowtriangle_up" + }, + { + "row": 2, + "column": 4, + "command": "VOL_PLUS", + "label": "Volume +" + }, + { + "row": 3, + "column": 1, + "command": "LEFT", + "label": "Left", + "icon": "f7:arrowtriangle_left" + }, + { + "row": 3, + "column": 2, + "command": "OK", + "label": "Ok" + }, + { + "row": 3, + "column": 3, + "command": "RIGHT", + "label": "Right", + "icon": "f7:arrowtriangle_right" + }, + { + "row": 3, + "column": 4, + "command": "MUTE", + "label": "Mute", + "icon": "soundvolume_mute" + }, + { + "row": 4, + "column": 2, + "command": "DOWN", + "label": "Down", + "icon": "f7:arrowtriangle_down" + }, + { + "row": 4, + "column": 4, + "command": "VOL_MINUS", + "label": "Volume -" + } + ], + "item": { + "link": "http://192.168.2.10:8080/rest/items/test_RemoteControl", + "state": "NULL", + "stateDescription": { + "pattern": "%s", + "readOnly": false, + "options": [] + }, + "type": "String", + "name": "test_RemoteControl", + "label": "test_RemoteControl", + "category": "", + "tags": [], + "groupNames": [] + }, + "widgets": [] + } + ] } """.utf8) } + +// swiftlint:enable type_body_length diff --git a/openHAB/SwiftUI/IconView.swift b/openHAB/SwiftUI/IconView.swift index abb9a7897..229a4d300 100644 --- a/openHAB/SwiftUI/IconView.swift +++ b/openHAB/SwiftUI/IconView.swift @@ -102,7 +102,7 @@ extension IconView { ) } - init(icon: String, colorColor: Color = .primary) { + init(icon: String, iconColor: String = "primary") { let widget = OpenHABWidget(icon: icon, iconColor: iconColor) self.init(widget: widget) } diff --git a/openHAB/SwiftUI/RowViewFactory.swift b/openHAB/SwiftUI/RowViewFactory.swift index 51e92c53c..aedc4ac3c 100644 --- a/openHAB/SwiftUI/RowViewFactory.swift +++ b/openHAB/SwiftUI/RowViewFactory.swift @@ -35,7 +35,7 @@ enum RowViewFactory { SliderRowView(widget: widget) } case .input: - if [.date, .time, .datetime].contains(widget.inputHint) { + if [.date, .time, .dateTime].contains(widget.inputHint) { DatePickerInputRowView(widget: widget) } else { TextInputRowView(widget: widget) diff --git a/openHAB/SwiftUI/Rows/ButtonGridRowView.swift b/openHAB/SwiftUI/Rows/ButtonGridRowView.swift index f9d2f1250..dd286bf66 100644 --- a/openHAB/SwiftUI/Rows/ButtonGridRowView.swift +++ b/openHAB/SwiftUI/Rows/ButtonGridRowView.swift @@ -172,6 +172,13 @@ extension View { } #Preview { - let widget = PreviewConstants.openHABSitemapPage!.widgets.first { $0.type == .switchWidget }! - ButtonGridRowView(widget: widget) + if let widget = PreviewConstants.openHABSitemapPage!.widgets.first(where: { $0.type == .buttongrid }) { + VStack { + ButtonGridRowView(widget: widget) + .padding() + Spacer() + } + } else { + Text("No button grid widget found") + } } diff --git a/openHAB/SwiftUI/Rows/ColorPickerRowView.swift b/openHAB/SwiftUI/Rows/ColorPickerRowView.swift index 8b74513ec..c4bcba086 100644 --- a/openHAB/SwiftUI/Rows/ColorPickerRowView.swift +++ b/openHAB/SwiftUI/Rows/ColorPickerRowView.swift @@ -22,6 +22,11 @@ struct ColorPickerRowView: View { var body: some View { HStack { + if IconView.shouldShowIcon(for: widget) { + IconView(widget: widget) + .frame(width: 24, height: 24) + } + if let labelText = widget.labelText, !labelText.isEmpty { Text(labelText) .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) @@ -78,3 +83,12 @@ struct ColorPickerRowView: View { return Color(hue: hue / 360.0, saturation: saturation / 100.0, brightness: brightness / 100.0) } } + +#Preview { + let widget = PreviewConstants.openHABSitemapPage!.widgets[15] + VStack { + ColorPickerRowView(widget: widget) + .padding() + Spacer() + } +} diff --git a/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift b/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift index 190397680..3ac0305ff 100644 --- a/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift +++ b/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift @@ -59,14 +59,14 @@ struct ColorTemperaturePickerRowView: View { .cornerRadius(3) // Actual slider - Slider( + CustomSliderView( value: $selectedTemperature, - in: minTemperature ... maxTemperature, + range: minTemperature ... maxTemperature, step: 100 - ) { _ in + ) { sendTemperatureCommand() } - .tint(.clear) // Hide default slider track + .frame(height: 28) } // Cool indicator @@ -131,8 +131,50 @@ struct ColorTemperaturePickerRowView: View { } } +struct CustomSliderView: View { + @Binding var value: Double + let range: ClosedRange + let step: Double + let onEditingChanged: () -> Void + + @GestureState private var dragOffset: CGSize = .zero + + var body: some View { + GeometryReader { geometry in + let width = geometry.size.width + let height = geometry.size.height + let normalized = CGFloat((value - range.lowerBound) / (range.upperBound - range.lowerBound)) + let xPos = normalized * width + + ZStack(alignment: .leading) { + Color.clear + + Circle() + .frame(width: 20, height: 20) + .foregroundColor(.white) + .shadow(radius: 1) + .overlay(Circle().stroke(Color.gray.opacity(0.6), lineWidth: 1)) + .position(x: xPos, y: height / 2) + .gesture( + DragGesture() + .onChanged { gesture in + let location = gesture.location.x.clamped(to: 0 ... width) + let raw = Double(location / width) * (range.upperBound - range.lowerBound) + range.lowerBound + let stepped = (raw / step).rounded() * step + value = stepped.clamped(to: range) + onEditingChanged() + } + ) + } + } + } +} + #Preview { let widget = PreviewConstants.openHABSitemapPage!.widgets[13] - ColorTemperaturePickerRowView(widget: widget) - .padding() + VStack { + ColorTemperaturePickerRowView(widget: widget) + .padding() + Spacer() + } } diff --git a/openHAB/SwiftUI/Rows/DatePickerInputRowView.swift b/openHAB/SwiftUI/Rows/DatePickerInputRowView.swift index a456b132d..76921bdaa 100644 --- a/openHAB/SwiftUI/Rows/DatePickerInputRowView.swift +++ b/openHAB/SwiftUI/Rows/DatePickerInputRowView.swift @@ -24,7 +24,7 @@ struct DatePickerInputRowView: View { switch widget.inputHint { case .date: .date case .time: .hourAndMinute - case .datetime: [.date, .hourAndMinute] + case .dateTime: [.date, .hourAndMinute] default: [.date, .hourAndMinute] } } @@ -34,28 +34,27 @@ struct DatePickerInputRowView: View { } var body: some View { - VStack(alignment: .leading, spacing: 8) { + HStack { if let labelText = widget.labelText, !labelText.isEmpty { Text(labelText) .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) } +// if let labelValue = widget.labelValue, !labelValue.isEmpty { +// Text(labelValue) +// .font(.caption) +// .foregroundColor(widget.valuecolor.isEmpty ? .secondary : Color(fromString: widget.valuecolor)) +// } + DatePicker( selection: $selectedDate, displayedComponents: datePickerComponents ) { EmptyView() } - // TODO: Consider reintroducing a dynamic DatePicker style based on `useWheelStyle` if needed in the future. .onChange(of: selectedDate) { newDate in sendDateCommand(newDate) } - - if let labelValue = widget.labelValue, !labelValue.isEmpty { - Text(labelValue) - .font(.caption) - .foregroundColor(widget.valuecolor.isEmpty ? .secondary : Color(fromString: widget.valuecolor)) - } } .onAppear { if let state = widget.item?.state, !state.isEmpty { @@ -72,7 +71,7 @@ struct DatePickerInputRowView: View { formatter.dateFormat = "yyyy-MM-dd" case .time: formatter.dateFormat = "HH:mm" - case .datetime: + case .dateTime: formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss" default: formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss" @@ -100,3 +99,12 @@ struct DatePickerInputRowView: View { return nil } } + +#Preview { + let widget = PreviewConstants.openHABSitemapPage!.widgets[13] + VStack { + ColorTemperaturePickerRowView(widget: widget) + .padding() + Spacer() + } +} diff --git a/openHAB/SwiftUI/Rows/RollershutterRowView.swift b/openHAB/SwiftUI/Rows/RollershutterRowView.swift index 1546a5878..4a15cfe9f 100644 --- a/openHAB/SwiftUI/Rows/RollershutterRowView.swift +++ b/openHAB/SwiftUI/Rows/RollershutterRowView.swift @@ -71,5 +71,8 @@ struct RollershutterRowView: View { #Preview { let widget = PreviewConstants.openHABSitemapPage!.widgets[5] - RollershutterRowView(widget: widget) + VStack { + RollershutterRowView(widget: widget) + Spacer() + } } diff --git a/openHAB/SwiftUI/Rows/SegmentedRowView.swift b/openHAB/SwiftUI/Rows/SegmentedRowView.swift index 4b2438771..a73e36adf 100644 --- a/openHAB/SwiftUI/Rows/SegmentedRowView.swift +++ b/openHAB/SwiftUI/Rows/SegmentedRowView.swift @@ -84,5 +84,8 @@ struct SegmentedRowView: View { #Preview { let widget = PreviewConstants.openHABSitemapPage!.widgets[4] - SegmentedRowView(widget: widget) + VStack { + SegmentedRowView(widget: widget) + Spacer() + } } diff --git a/openHAB/SwiftUI/Rows/SetpointRowView.swift b/openHAB/SwiftUI/Rows/SetpointRowView.swift index db63be462..a71604267 100644 --- a/openHAB/SwiftUI/Rows/SetpointRowView.swift +++ b/openHAB/SwiftUI/Rows/SetpointRowView.swift @@ -111,5 +111,8 @@ struct SetpointRowView: View { #Preview { let widget = PreviewConstants.openHABSitemapPage!.widgets[3] - SetpointRowView(widget: widget) + VStack { + SetpointRowView(widget: widget) + Spacer() + } } diff --git a/openHAB/SwiftUI/Rows/SliderRowView.swift b/openHAB/SwiftUI/Rows/SliderRowView.swift index 9be90911e..4d8ca32f8 100644 --- a/openHAB/SwiftUI/Rows/SliderRowView.swift +++ b/openHAB/SwiftUI/Rows/SliderRowView.swift @@ -14,32 +14,64 @@ import SwiftUI struct SliderRowView: View { @ObservedObject var widget: OpenHABWidget - // Example: assuming widget has a numeric value as text - var currentValue: Double { - Double(widget.labelValue ?? "") ?? 0.0 + @State private var currentValue = 0.0 + @State private var isUserInteracting = false + + private var displayValue: Double { + isUserInteracting ? currentValue : (widget.stateValueAsNumberState?.value ?? widget.minValue) + } + + private var sliderRange: ClosedRange { + widget.minValue ... widget.maxValue } var body: some View { HStack { - if IconView.shouldShowIcon(for: widget) { - IconView(widget: widget) - .frame(width: 24, height: 24) - } + IconView(widget: widget) + .frame(width: 24, height: 24) Text(widget.labelText ?? "") + Spacer() + if let value = widget.labelValue { Text(value) .font(.caption) .foregroundColor(.secondary) } - Slider(value: .constant(currentValue), in: 0 ... 100) - .disabled(true) // unless you want editable + Slider(value: $currentValue, in: sliderRange) { isEditing in + isUserInteracting = isEditing + if !isEditing { + sendSliderUpdate(currentValue) + } + } + } + .onAppear { + loadCurrentValue() } + .onChange(of: widget.stateValueAsNumberState?.value) { newValue in + if !isUserInteracting, let newValue { + currentValue = newValue + } + } + } + + private func loadCurrentValue() { + currentValue = widget.stateValueAsNumberState?.value ?? widget.minValue + } + + private func sendSliderUpdate(_ newValue: Double) { + var numberState = widget.stateValueAsNumberState + numberState = numberState ?? NumberState(value: newValue) + numberState?.value = newValue + widget.sendItemUpdate(state: numberState) } } #Preview { let widget = PreviewConstants.openHABSitemapPage!.widgets[3] - SliderRowView(widget: widget) + VStack { + SliderRowView(widget: widget) + Spacer() + } } diff --git a/openHAB/SwiftUI/Rows/SwitchRowView.swift b/openHAB/SwiftUI/Rows/SwitchRowView.swift index 2d69b8be4..00c017123 100644 --- a/openHAB/SwiftUI/Rows/SwitchRowView.swift +++ b/openHAB/SwiftUI/Rows/SwitchRowView.swift @@ -69,5 +69,8 @@ struct SwitchRowView: View { #Preview { let widget = PreviewConstants.openHABSitemapPage!.widgets[2] - SwitchRowView(widget: widget) + VStack { + SwitchRowView(widget: widget) + Spacer() + } } diff --git a/openHAB/SwiftUI/Rows/TextRowView.swift b/openHAB/SwiftUI/Rows/TextRowView.swift index 7aef1f6c7..f75250707 100644 --- a/openHAB/SwiftUI/Rows/TextRowView.swift +++ b/openHAB/SwiftUI/Rows/TextRowView.swift @@ -49,5 +49,8 @@ struct TextRowView: View { #Preview { let widget = PreviewConstants.openHABSitemapPage!.widgets[3] - TextRowView(widget: widget) + VStack { + TextRowView(widget: widget) + Spacer() + } } From 86905276298613aedaa68e2caf2098294df2de2b Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Tue, 15 Jul 2025 20:19:20 +0200 Subject: [PATCH 259/476] Integrate CommonUI tests into standard testing Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- TestPlans/openHABTests.xctestplan | 15 +++++--- openHAB/OpenHABSitemapViewController.swift | 1 + openHAB/PreviewConstants.swift | 31 ++++++++++++++++- openHAB/SwiftUI/Rows/ColorPickerRowView.swift | 6 ++-- .../Rows/ColorTemperaturePickerRowView.swift | 14 +++++--- .../SwiftUI/Rows/DatePickerInputRowView.swift | 9 +++-- openHAB/SwiftUI/Rows/SetpointRowView.swift | 12 +++---- openHAB/SwiftUI/Rows/TextInputRowView.swift | 34 ++++++++++--------- openHAB/SwiftUI/Rows/TextRowView.swift | 6 ++-- openHAB/SwiftUI/SitemapPageView.swift | 1 + 10 files changed, 83 insertions(+), 46 deletions(-) diff --git a/TestPlans/openHABTests.xctestplan b/TestPlans/openHABTests.xctestplan index 3ed419e45..19f3494b5 100644 --- a/TestPlans/openHABTests.xctestplan +++ b/TestPlans/openHABTests.xctestplan @@ -15,6 +15,14 @@ "codeCoverage" : false }, "testTargets" : [ + { + "parallelizable" : true, + "target" : { + "containerPath" : "container:OpenHABCore", + "identifier" : "OpenHABCoreTests", + "name" : "OpenHABCoreTests" + } + }, { "parallelizable" : true, "target" : { @@ -24,11 +32,10 @@ } }, { - "parallelizable" : true, "target" : { - "containerPath" : "container:OpenHABCore", - "identifier" : "OpenHABCoreTests", - "name" : "OpenHABCoreTests" + "containerPath" : "container:CommonUI", + "identifier" : "CommonUITests", + "name" : "CommonUITests" } } ], diff --git a/openHAB/OpenHABSitemapViewController.swift b/openHAB/OpenHABSitemapViewController.swift index f0b70db14..5ab3e5265 100644 --- a/openHAB/OpenHABSitemapViewController.swift +++ b/openHAB/OpenHABSitemapViewController.swift @@ -803,6 +803,7 @@ extension OpenHABSitemapViewController: UITableViewDelegate, UITableViewDataSour logger.info("Selected selection widget in status: \(selectionItemState ?? "unknown")") let hostingController = UIHostingController( rootView: SelectionView( + labelText: widget.labelText, mappings: widget.mappingsOrItemOptions, selectionItemState: selectionItemState ) { selectedMappingIndex in diff --git a/openHAB/PreviewConstants.swift b/openHAB/PreviewConstants.swift index ef28ef8f8..cb3999958 100644 --- a/openHAB/PreviewConstants.swift +++ b/openHAB/PreviewConstants.swift @@ -542,7 +542,36 @@ enum PreviewConstants { "groupNames": [] }, "widgets": [] - } + }, + { + "widgetId": "17", + "type": "Input", + "visibility": true, + "label": "Meter [166000]", + "labelSource": "SITEMAP_WIDGET", + "icon": "energy", + "staticIcon": true, + "pattern": "%.0f %unit%", + "unit": "", + "mappings": [], + "inputHint": "number", + "item": { + "link": "http://192.168.2.10:8080/rest/items/Test_Meter_Reading", + "state": "166000.0", + "stateDescription": { + "pattern": "%.0f", + "readOnly": false, + "options": [] + }, + "type": "Number", + "name": "Test_Meter_Reading", + "label": "Test_Meter_Reading", + "category": "", + "tags": [], + "groupNames": [] + }, + "widgets": [] + }, ] } diff --git a/openHAB/SwiftUI/Rows/ColorPickerRowView.swift b/openHAB/SwiftUI/Rows/ColorPickerRowView.swift index c4bcba086..d05df4607 100644 --- a/openHAB/SwiftUI/Rows/ColorPickerRowView.swift +++ b/openHAB/SwiftUI/Rows/ColorPickerRowView.swift @@ -22,10 +22,8 @@ struct ColorPickerRowView: View { var body: some View { HStack { - if IconView.shouldShowIcon(for: widget) { - IconView(widget: widget) - .frame(width: 24, height: 24) - } + IconView(widget: widget) + .frame(width: 24, height: 24) if let labelText = widget.labelText, !labelText.isEmpty { Text(labelText) diff --git a/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift b/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift index 3ac0305ff..80ef9a365 100644 --- a/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift +++ b/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift @@ -32,10 +32,16 @@ struct ColorTemperaturePickerRowView: View { var body: some View { VStack(alignment: .leading, spacing: 12) { - // Label - if let labelText = widget.labelText, !labelText.isEmpty { - Text(labelText) - .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) + HStack { + IconView(widget: widget) + .frame(width: 24, height: 24) + + if let labelText = widget.labelText, !labelText.isEmpty { + Text(labelText) + .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) + } + + Spacer() } // Color temperature slider with gradient background diff --git a/openHAB/SwiftUI/Rows/DatePickerInputRowView.swift b/openHAB/SwiftUI/Rows/DatePickerInputRowView.swift index 76921bdaa..5313c7071 100644 --- a/openHAB/SwiftUI/Rows/DatePickerInputRowView.swift +++ b/openHAB/SwiftUI/Rows/DatePickerInputRowView.swift @@ -35,16 +35,15 @@ struct DatePickerInputRowView: View { var body: some View { HStack { + IconView(widget: widget) + .frame(width: 24, height: 24) + if let labelText = widget.labelText, !labelText.isEmpty { Text(labelText) .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) } -// if let labelValue = widget.labelValue, !labelValue.isEmpty { -// Text(labelValue) -// .font(.caption) -// .foregroundColor(widget.valuecolor.isEmpty ? .secondary : Color(fromString: widget.valuecolor)) -// } + Spacer() DatePicker( selection: $selectedDate, diff --git a/openHAB/SwiftUI/Rows/SetpointRowView.swift b/openHAB/SwiftUI/Rows/SetpointRowView.swift index a71604267..e768ab78c 100644 --- a/openHAB/SwiftUI/Rows/SetpointRowView.swift +++ b/openHAB/SwiftUI/Rows/SetpointRowView.swift @@ -40,15 +40,11 @@ struct SetpointRowView: View { var body: some View { HStack { - if IconView.shouldShowIcon(for: widget) { - IconView(widget: widget) - .frame(width: 24, height: 24) - } + IconView(widget: widget) + .frame(width: 24, height: 24) - VStack(alignment: .leading, spacing: 2) { - Text(widget.labelText ?? widget.label) - .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) - } + Text(widget.labelText ?? widget.label) + .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) Spacer() diff --git a/openHAB/SwiftUI/Rows/TextInputRowView.swift b/openHAB/SwiftUI/Rows/TextInputRowView.swift index 4f7f40d88..1e66cd796 100644 --- a/openHAB/SwiftUI/Rows/TextInputRowView.swift +++ b/openHAB/SwiftUI/Rows/TextInputRowView.swift @@ -22,31 +22,25 @@ struct TextInputRowView: View { private let logger = Logger(subsystem: "org.openhab", category: "WidgetTextInputView") var body: some View { - HStack(alignment: .top, spacing: 8) { - if IconView.shouldShowIcon(for: widget) { - IconView(widget: widget) - .frame(width: 24, height: 24) - .padding(.top, 4) // Align with text + HStack { + IconView(widget: widget) + .frame(width: 24, height: 24) + + if let labelText = widget.labelText, !labelText.isEmpty { + Text(labelText) + .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) } - VStack(alignment: .leading, spacing: 8) { - if let labelText = widget.labelText, !labelText.isEmpty { - Text(labelText) - .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) - } + Spacer() + if let labelValue = widget.labelValue, !labelValue.isEmpty { TextField("Enter text", text: $inputText) + .multilineTextAlignment(widget.inputHint == .number ? .trailing : .leading) .textFieldStyle(.roundedBorder) .focused($isTextFieldFocused) .onSubmit { sendTextCommand() } - - if let labelValue = widget.labelValue, !labelValue.isEmpty { - Text(labelValue) - .font(.caption) - .foregroundColor(widget.valuecolor.isEmpty ? .secondary : Color(fromString: widget.valuecolor)) - } } } .onAppear { @@ -65,3 +59,11 @@ struct TextInputRowView: View { isTextFieldFocused = false } } + +#Preview { + let widget = PreviewConstants.openHABSitemapPage!.widgets[17] + VStack { + TextInputRowView(widget: widget) + Spacer() + } +} diff --git a/openHAB/SwiftUI/Rows/TextRowView.swift b/openHAB/SwiftUI/Rows/TextRowView.swift index f75250707..53ec8da1e 100644 --- a/openHAB/SwiftUI/Rows/TextRowView.swift +++ b/openHAB/SwiftUI/Rows/TextRowView.swift @@ -19,10 +19,8 @@ struct TextRowView: View { var body: some View { HStack { - if IconView.shouldShowIcon(for: widget) { - IconView(widget: widget) - .frame(width: 24, height: 24) - } + IconView(widget: widget) + .frame(width: 24, height: 24) Text(widget.labelText ?? "") .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) diff --git a/openHAB/SwiftUI/SitemapPageView.swift b/openHAB/SwiftUI/SitemapPageView.swift index cb76472ee..bbda9bf40 100644 --- a/openHAB/SwiftUI/SitemapPageView.swift +++ b/openHAB/SwiftUI/SitemapPageView.swift @@ -75,6 +75,7 @@ struct SitemapPageView: View { .sheet(isPresented: $showSelectionSheet) { if let widget = selectedWidget { SelectionView( + labelText: widget.labelText, mappings: widget.mappingsOrItemOptions, selectionItemState: widget.item?.state ) { selectedMappingIndex in From 6c845e00b8a5a9f672c55096f45a685438779cf8 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Tue, 15 Jul 2025 21:07:47 +0200 Subject: [PATCH 260/476] Call SelectionView with labelText Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- openHAB/OpenHABSitemapViewController.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/openHAB/OpenHABSitemapViewController.swift b/openHAB/OpenHABSitemapViewController.swift index 689904f7d..b8d80c5b6 100644 --- a/openHAB/OpenHABSitemapViewController.swift +++ b/openHAB/OpenHABSitemapViewController.swift @@ -791,6 +791,7 @@ extension OpenHABSitemapViewController: UITableViewDelegate, UITableViewDataSour logger.info("Selected selection widget in status: \(selectionItemState ?? "unknown")") let hostingController = UIHostingController( rootView: SelectionView( + labelText: widget.labelText, mappings: widget.mappingsOrItemOptions, selectionItemState: selectionItemState ) { selectedMappingIndex in From 85d46b79860247aa8fb5c6d8d156f7bef1ec7776 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Tue, 15 Jul 2025 21:32:13 +0200 Subject: [PATCH 261/476] addressing some swiftlint warnings Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../Tests/CommonUITests/CommonUITests.swift | 2 +- .../OpenHABCore/Util/DoubleExtension.swift | 8 +- ...arableTest.swift => ComparableTests.swift} | 0 openHAB/OpenHABRootViewController.swift | 20 +-- openHAB/SitemapPageViewModel.swift | 10 +- openHAB/SwiftUI/Rows/ButtonGridRowView.swift | 134 +++++++++--------- .../Rows/ColorTemperaturePickerRowView.swift | 78 +++++----- .../Model/OpenHABWidgetExtension.swift | 2 +- 8 files changed, 127 insertions(+), 127 deletions(-) rename OpenHABCore/Tests/OpenHABCoreTests/{ComparableTest.swift => ComparableTests.swift} (100%) diff --git a/CommonUI/Tests/CommonUITests/CommonUITests.swift b/CommonUI/Tests/CommonUITests/CommonUITests.swift index 7db35c731..8a669177d 100644 --- a/CommonUI/Tests/CommonUITests/CommonUITests.swift +++ b/CommonUI/Tests/CommonUITests/CommonUITests.swift @@ -13,7 +13,7 @@ import Numerics import Testing -struct ColorTemperatureTests { +struct CommonUITests { @Test func lowKelvinValue() { let color = componentsForColorTemperature(temperature: 1000) #expect(color.r.isApproximatelyEqual(to: 1.0, relativeTolerance: 0.01)) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/DoubleExtension.swift b/OpenHABCore/Sources/OpenHABCore/Util/DoubleExtension.swift index a9379b229..d308fb9e5 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/DoubleExtension.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/DoubleExtension.swift @@ -12,6 +12,10 @@ import Foundation public extension Double { + var asColorTemperatureInKelvin: Double { + self < 1000 ? 1_000_000 / self : self + } + func valueText(step: Double) -> String { let digits = max(-Decimal(step).exponent, 0) let numberFormatter = NumberFormatter() @@ -20,8 +24,4 @@ public extension Double { numberFormatter.decimalSeparator = "." return numberFormatter.string(from: NSNumber(value: self)) ?? "" } - - var asColorTemperatureInKelvin: Double { - self < 1000 ? 1_000_000 / self : self - } } diff --git a/OpenHABCore/Tests/OpenHABCoreTests/ComparableTest.swift b/OpenHABCore/Tests/OpenHABCoreTests/ComparableTests.swift similarity index 100% rename from OpenHABCore/Tests/OpenHABCoreTests/ComparableTest.swift rename to OpenHABCore/Tests/OpenHABCoreTests/ComparableTests.swift diff --git a/openHAB/OpenHABRootViewController.swift b/openHAB/OpenHABRootViewController.swift index 1f8821b8c..2b41c0ead 100644 --- a/openHAB/OpenHABRootViewController.swift +++ b/openHAB/OpenHABRootViewController.swift @@ -107,16 +107,6 @@ class HostingSitemapViewController: UIHostingController, OpenHA // MARK: - Search Controller Delegates -extension HostingSitemapViewController: UISearchResultsUpdating, UISearchBarDelegate, UISearchControllerDelegate { - func updateSearchResults(for searchController: UISearchController) { - viewModel.searchText = searchController.searchBar.text ?? "" - } - - func searchBarCancelButtonClicked(_ searchBar: UISearchBar) { - viewModel.searchText = "" - } -} - // swiftlint:disable type_body_length class OpenHABRootViewController: UIViewController { var currentView: (any UIViewController & OpenHABViewable)! @@ -831,3 +821,13 @@ extension OpenHABRootViewController: ModalHandler { } } } + +extension HostingSitemapViewController: UISearchResultsUpdating, UISearchBarDelegate, UISearchControllerDelegate { + func updateSearchResults(for searchController: UISearchController) { + viewModel.searchText = searchController.searchBar.text ?? "" + } + + func searchBarCancelButtonClicked(_ searchBar: UISearchBar) { + viewModel.searchText = "" + } +} diff --git a/openHAB/SitemapPageViewModel.swift b/openHAB/SitemapPageViewModel.swift index 3d77c5c75..c4d4510d3 100644 --- a/openHAB/SitemapPageViewModel.swift +++ b/openHAB/SitemapPageViewModel.swift @@ -86,10 +86,6 @@ class SitemapPageViewModel: ObservableObject { } } - deinit { - pageHandlingTask?.cancel() - } - func loadSettings() { defaultSitemap = Preferences.currentHomePreferences.defaultSitemap } @@ -247,7 +243,7 @@ class SitemapPageViewModel: ObservableObject { func pushSitemap(name: String, path: String?) async { defaultSitemap = name pageId = path ?? "" - await startPageHandling() + startPageHandling() } private func discoverAndSelectSitemap() async { @@ -346,6 +342,10 @@ class SitemapPageViewModel: ObservableObject { } } } + + deinit { + pageHandlingTask?.cancel() + } } extension Published.Publisher { diff --git a/openHAB/SwiftUI/Rows/ButtonGridRowView.swift b/openHAB/SwiftUI/Rows/ButtonGridRowView.swift index dd286bf66..0c5aede4e 100644 --- a/openHAB/SwiftUI/Rows/ButtonGridRowView.swift +++ b/openHAB/SwiftUI/Rows/ButtonGridRowView.swift @@ -14,73 +14,6 @@ import OpenHABCore import os.log import SwiftUI -struct ButtonGridRowView: View { - @ObservedObject var widget: OpenHABWidget - - private let logger = Logger(subsystem: "org.openhab", category: "ButtonGridRowView") - - // Maximum number of columns based on screen width - private let maxColumns = 12 - - private var buttons: [OpenHABWidgetMapping] { - widget.mappings - } - -// private var showLabelAndIcon: Bool { -// !widget.label.isEmpty && widget.labelSource == .sitemapDefinition -// } - - private var gridRows: Int { - buttons.map { $0.row ?? 1 }.max() ?? 1 - } - - private var gridColumns: Int { - min(buttons.map { $0.column ?? 1 }.max() ?? 1, maxColumns) - } - - var body: some View { - VStack(alignment: .leading, spacing: 8) { -// if showLabelAndIcon { - HStack { - if IconView.shouldShowIcon(for: widget) { - IconView(widget: widget) - .frame(width: 24, height: 24) - } - - Text(widget.labelText ?? widget.label) - .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) - - Spacer() - } -// } - - LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: gridColumns), spacing: 8) { - ForEach(0 ..< gridRows, id: \.self) { row in - ForEach(0 ..< gridColumns, id: \.self) { column in - let button = buttonForPosition(row: row, column: column) - - if let button { - ButtonGridButton(button: button, widget: widget) - } else { - // Empty cell to maintain grid structure - Rectangle() - .fill(Color.clear) - .frame(height: 44) - } - } - } - } - } - } - - private func buttonForPosition(row: Int, column: Int) -> OpenHABWidgetMapping? { - buttons.first { button in - // OpenHAB uses 1-based indexing, convert to 0-based - (button.row ?? 1) - 1 == row && (button.column ?? 1) - 1 == column - } - } -} - struct ButtonGridButton: View { let button: OpenHABWidgetMapping let widget: OpenHABWidget @@ -156,6 +89,73 @@ struct ButtonGridButton: View { } } +struct ButtonGridRowView: View { + @ObservedObject var widget: OpenHABWidget + + private let logger = Logger(subsystem: "org.openhab", category: "ButtonGridRowView") + + // Maximum number of columns based on screen width + private let maxColumns = 12 + + private var buttons: [OpenHABWidgetMapping] { + widget.mappings + } + +// private var showLabelAndIcon: Bool { +// !widget.label.isEmpty && widget.labelSource == .sitemapDefinition +// } + + private var gridRows: Int { + buttons.map { $0.row ?? 1 }.max() ?? 1 + } + + private var gridColumns: Int { + min(buttons.map { $0.column ?? 1 }.max() ?? 1, maxColumns) + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { +// if showLabelAndIcon { + HStack { + if IconView.shouldShowIcon(for: widget) { + IconView(widget: widget) + .frame(width: 24, height: 24) + } + + Text(widget.labelText ?? widget.label) + .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) + + Spacer() + } +// } + + LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: gridColumns), spacing: 8) { + ForEach(0 ..< gridRows, id: \.self) { row in + ForEach(0 ..< gridColumns, id: \.self) { column in + let button = buttonForPosition(row: row, column: column) + + if let button { + ButtonGridButton(button: button, widget: widget) + } else { + // Empty cell to maintain grid structure + Rectangle() + .fill(Color.clear) + .frame(height: 44) + } + } + } + } + } + } + + private func buttonForPosition(row: Int, column: Int) -> OpenHABWidgetMapping? { + buttons.first { button in + // OpenHAB uses 1-based indexing, convert to 0-based + (button.row ?? 1) - 1 == row && (button.column ?? 1) - 1 == column + } + } +} + extension View { func onPressGesture(onPress: @escaping () -> Void, onRelease: @escaping () -> Void) -> some View { diff --git a/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift b/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift index 80ef9a365..e48055f01 100644 --- a/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift +++ b/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift @@ -15,6 +15,45 @@ import os.log import SFSafeSymbols import SwiftUI +struct CustomSliderView: View { + @Binding var value: Double + let range: ClosedRange + let step: Double + let onEditingChanged: () -> Void + + @GestureState private var dragOffset: CGSize = .zero + + var body: some View { + GeometryReader { geometry in + let width = geometry.size.width + let height = geometry.size.height + let normalized = CGFloat((value - range.lowerBound) / (range.upperBound - range.lowerBound)) + let xPos = normalized * width + + ZStack(alignment: .leading) { + Color.clear + + Circle() + .frame(width: 20, height: 20) + .foregroundColor(.white) + .shadow(radius: 1) + .overlay(Circle().stroke(Color.gray.opacity(0.6), lineWidth: 1)) + .position(x: xPos, y: height / 2) + .gesture( + DragGesture() + .onChanged { gesture in + let location = gesture.location.x.clamped(to: 0 ... width) + let raw = Double(location / width) * (range.upperBound - range.lowerBound) + range.lowerBound + let stepped = (raw / step).rounded() * step + value = stepped.clamped(to: range) + onEditingChanged() + } + ) + } + } + } +} + struct ColorTemperaturePickerRowView: View { @ObservedObject var widget: OpenHABWidget @State private var selectedTemperature: Double = 2700 // Default warm white @@ -137,45 +176,6 @@ struct ColorTemperaturePickerRowView: View { } } -struct CustomSliderView: View { - @Binding var value: Double - let range: ClosedRange - let step: Double - let onEditingChanged: () -> Void - - @GestureState private var dragOffset: CGSize = .zero - - var body: some View { - GeometryReader { geometry in - let width = geometry.size.width - let height = geometry.size.height - let normalized = CGFloat((value - range.lowerBound) / (range.upperBound - range.lowerBound)) - let xPos = normalized * width - - ZStack(alignment: .leading) { - Color.clear - - Circle() - .frame(width: 20, height: 20) - .foregroundColor(.white) - .shadow(radius: 1) - .overlay(Circle().stroke(Color.gray.opacity(0.6), lineWidth: 1)) - .position(x: xPos, y: height / 2) - .gesture( - DragGesture() - .onChanged { gesture in - let location = gesture.location.x.clamped(to: 0 ... width) - let raw = Double(location / width) * (range.upperBound - range.lowerBound) + range.lowerBound - let stepped = (raw / step).rounded() * step - value = stepped.clamped(to: range) - onEditingChanged() - } - ) - } - } - } -} - #Preview { let widget = PreviewConstants.openHABSitemapPage!.widgets[13] VStack { diff --git a/openHABWatch/Model/OpenHABWidgetExtension.swift b/openHABWatch/Model/OpenHABWidgetExtension.swift index 5b9a1d740..6371ddfc5 100644 --- a/openHABWatch/Model/OpenHABWidgetExtension.swift +++ b/openHABWatch/Model/OpenHABWidgetExtension.swift @@ -16,7 +16,7 @@ import SFSafeSymbols import SwiftUI extension OpenHABWidget { - @ViewBuilder func makeView(settings: AppSettings) -> some View { + @ViewBuilder @MainActor func makeView(settings: AppSettings) -> some View { if linkedPage != nil { NavigationLink(destination: LazyView( From 34a56b0a0f4a32d1b05a43cc2e5cba3a9857194e Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Wed, 16 Jul 2025 12:05:46 +0200 Subject: [PATCH 262/476] Cleaning up on functionality: Receiving remote updates Aligning with layout of current app Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../OpenHABCore/Model/OpenHABWidget.swift | 21 +++++- openHAB/SwiftUI/Rows/ButtonGridRowView.swift | 38 +++++----- openHAB/SwiftUI/Rows/ColorPickerRowView.swift | 4 ++ .../Rows/ColorTemperaturePickerRowView.swift | 70 ++++++++++--------- openHAB/SwiftUI/Rows/GenericRowView.swift | 7 +- openHAB/SwiftUI/Rows/MapRowView.swift | 2 +- openHAB/SwiftUI/Rows/SegmentedRowView.swift | 5 +- openHAB/SwiftUI/Rows/SelectionRowView.swift | 10 ++- openHAB/SwiftUI/Rows/SetpointRowView.swift | 6 +- 9 files changed, 98 insertions(+), 65 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift index c1ef776ef..21c759b54 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift @@ -58,6 +58,13 @@ public class OpenHABWidget: NSObject, MKAnnotation, Identifiable, ObservableObje case unknown = "Unknown" } + public enum LabelSource: String, Decodable { + case sitemapDefinition = "SITEMAP_WIDGET" + case itemLabel = "ITEM_LABEL" + case itemName = "ITEM_NAME" + case unknown = "UNKNOWN" + } + public enum InputHint: String, Decodable { case text, number, date, time, dateTime, unknown } @@ -95,6 +102,7 @@ public class OpenHABWidget: NSObject, MKAnnotation, Identifiable, ObservableObje public var widgets: [OpenHABWidget] = [] public var visibility = true public var switchSupport = false + public var labelSource = LabelSource.unknown @Published public var stateEnumBinding: WidgetTypeEnum = .unassigned @@ -223,6 +231,10 @@ public class OpenHABWidget: NSObject, MKAnnotation, Identifiable, ObservableObje mappingsOrItemOptions.firstIndex { $0.command == command } } + public func mapCommandtoIndex(with command: String?) -> Int { + Int(mappingIndex(byCommand: command) ?? 0) + } + public func iconState() -> String { var iconState = item?.state ?? "" if let item, let itemState = item.state { @@ -286,7 +298,8 @@ public extension OpenHABWidget { widgets: [OpenHABWidget], visibility: Bool?, switchSupport: Bool?, - forceAsItem: Bool?) { + forceAsItem: Bool?, + labelSource: LabelSource = .unknown) { self.init() id = widgetId self.widgetId = widgetId @@ -335,11 +348,12 @@ public extension OpenHABWidget { self.forceAsItem = forceAsItem stateEnumBinding = stateEnum + self.labelSource = labelSource } convenience init(icon: String, iconColor: String? = nil) { // swiftlint:disable:next line_length - self.init(widgetId: "\(UUID())", label: "", icon: icon, type: .unknown, url: nil, period: nil, minValue: nil, maxValue: nil, step: nil, refresh: nil, height: nil, isLeaf: nil, iconColor: iconColor, labelColor: nil, valueColor: nil, service: nil, state: nil, text: nil, legend: nil, inputHint: nil, encoding: nil, item: nil, linkedPage: nil, mappings: [], widgets: [], visibility: nil, switchSupport: nil, forceAsItem: nil) + self.init(widgetId: "\(UUID())", label: "", icon: icon, type: .unknown, url: nil, period: nil, minValue: nil, maxValue: nil, step: nil, refresh: nil, height: nil, isLeaf: nil, iconColor: iconColor, labelColor: nil, valueColor: nil, service: nil, state: nil, text: nil, legend: nil, inputHint: nil, encoding: nil, item: nil, linkedPage: nil, mappings: [], widgets: [], visibility: nil, switchSupport: nil, forceAsItem: nil, labelSource: .unknown) } } @@ -458,7 +472,8 @@ extension OpenHABWidget { widgets: widget.widgets?.compactMap { OpenHABWidget($0) } ?? [], visibility: widget.visibility, switchSupport: widget.switchSupport, - forceAsItem: widget.forceAsItem + forceAsItem: widget.forceAsItem, + labelSource: OpenHABWidget.LabelSource(rawValue: widget.labelSource ?? "") ?? .unknown ) } } diff --git a/openHAB/SwiftUI/Rows/ButtonGridRowView.swift b/openHAB/SwiftUI/Rows/ButtonGridRowView.swift index 0c5aede4e..3dc7deb2f 100644 --- a/openHAB/SwiftUI/Rows/ButtonGridRowView.swift +++ b/openHAB/SwiftUI/Rows/ButtonGridRowView.swift @@ -40,13 +40,13 @@ struct ButtonGridButton: View { if let icon = button.icon, !icon.isEmpty { IconView(icon: icon) .frame(width: 16, height: 16) + } else { + Text(button.label) + .font(.caption) + .foregroundColor(.primary) + .lineLimit(1) + .truncationMode(.tail) } - - Text(button.label) - .font(.caption) - .foregroundColor(.primary) - .lineLimit(1) - .truncationMode(.tail) } .frame(maxWidth: .infinity) .frame(height: 44) @@ -101,9 +101,9 @@ struct ButtonGridRowView: View { widget.mappings } -// private var showLabelAndIcon: Bool { -// !widget.label.isEmpty && widget.labelSource == .sitemapDefinition -// } + private var showLabelAndIcon: Bool { + !widget.label.isEmpty && widget.labelSource == .sitemapDefinition + } private var gridRows: Int { buttons.map { $0.row ?? 1 }.max() ?? 1 @@ -115,19 +115,19 @@ struct ButtonGridRowView: View { var body: some View { VStack(alignment: .leading, spacing: 8) { -// if showLabelAndIcon { - HStack { - if IconView.shouldShowIcon(for: widget) { - IconView(widget: widget) - .frame(width: 24, height: 24) - } + if showLabelAndIcon { + HStack { + if IconView.shouldShowIcon(for: widget) { + IconView(widget: widget) + .frame(width: 24, height: 24) + } - Text(widget.labelText ?? widget.label) - .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) + Text(widget.labelText ?? widget.label) + .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) - Spacer() + Spacer() + } } -// } LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: gridColumns), spacing: 8) { ForEach(0 ..< gridRows, id: \.self) { row in diff --git a/openHAB/SwiftUI/Rows/ColorPickerRowView.swift b/openHAB/SwiftUI/Rows/ColorPickerRowView.swift index d05df4607..fd6ff8710 100644 --- a/openHAB/SwiftUI/Rows/ColorPickerRowView.swift +++ b/openHAB/SwiftUI/Rows/ColorPickerRowView.swift @@ -49,6 +49,10 @@ struct ColorPickerRowView: View { selectedColor = parseColor(from: state) ?? .white } } + .onChange(of: widget.item?.state ?? "") { newState in + guard !newState.isEmpty else { return } + selectedColor = parseColor(from: newState) ?? .white + } } private func sendColorCommand(_ color: Color) { diff --git a/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift b/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift index e48055f01..b51c47d33 100644 --- a/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift +++ b/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift @@ -70,21 +70,37 @@ struct ColorTemperaturePickerRowView: View { } var body: some View { - VStack(alignment: .leading, spacing: 12) { - HStack { - IconView(widget: widget) - .frame(width: 24, height: 24) - - if let labelText = widget.labelText, !labelText.isEmpty { - Text(labelText) - .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) - } - - Spacer() - } + HStack(alignment: .top) { + IconView(widget: widget) + .frame(width: 24, height: 24) - // Color temperature slider with gradient background VStack(spacing: 8) { + HStack { + if let labelText = widget.labelText, !labelText.isEmpty { + Text(labelText) + .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) + } + + Spacer() + + // Temperature value display + HStack { + Text("\(Int(selectedTemperature))K") + .font(.caption) + .foregroundColor(.secondary) + + Text(" - ") + .font(.caption) + .foregroundColor(.secondary) + + // Temperature description + Text(temperatureDescription) + .font(.caption2) + .foregroundColor(.secondary) + } + } + + // Color temperature slider with gradient background HStack { // Warm indicator Image(systemSymbol: .sunMinFill) @@ -119,24 +135,13 @@ struct ColorTemperaturePickerRowView: View { .foregroundColor(.blue) .font(.caption) } - - // Temperature value display - HStack { - Text("\(Int(selectedTemperature))K") - .font(.caption) - .foregroundColor(.secondary) - - Spacer() - - // Temperature description - Text(temperatureDescription) - .font(.caption2) - .foregroundColor(.secondary) - } } } .onAppear { - loadCurrentTemperature() + selectedTemperature = loadCurrentTemperature(state: widget.item?.state) ?? 2700 + } + .onChange(of: widget.item?.state ?? "") { newState in + selectedTemperature = loadCurrentTemperature(state: newState) ?? 2700 } } @@ -158,13 +163,12 @@ struct ColorTemperaturePickerRowView: View { stride(from: minTemperature, through: maxTemperature, by: (maxTemperature - minTemperature) / Double(steps)).map { Color(temperature: $0) } } - private func loadCurrentTemperature() { - guard let state = widget.item?.state, !state.isEmpty else { return } + private func loadCurrentTemperature(state: String?) -> Double? { + guard let state, !state.isEmpty else { return nil } // Parse color temperature directly from Kelvin value (like Android app) - if let kelvin = Double(state) { - selectedTemperature = kelvin.clamped(to: minTemperature ... maxTemperature) - } + let kelvin = state.parseAsNumber().value + return kelvin.clamped(to: minTemperature ... maxTemperature) } private func sendTemperatureCommand() { diff --git a/openHAB/SwiftUI/Rows/GenericRowView.swift b/openHAB/SwiftUI/Rows/GenericRowView.swift index 6db5616e1..68a6cbdb5 100644 --- a/openHAB/SwiftUI/Rows/GenericRowView.swift +++ b/openHAB/SwiftUI/Rows/GenericRowView.swift @@ -16,10 +16,9 @@ struct GenericRowView: View { @ObservedObject var widget: OpenHABWidget var body: some View { HStack { - if IconView.shouldShowIcon(for: widget) { - IconView(widget: widget) - .frame(width: 24, height: 24) - } + IconView(widget: widget) + .frame(width: 24, height: 24) + Text(widget.labelText ?? "") Spacer() if let value = widget.labelValue { diff --git a/openHAB/SwiftUI/Rows/MapRowView.swift b/openHAB/SwiftUI/Rows/MapRowView.swift index bf4db5f47..1311fd439 100644 --- a/openHAB/SwiftUI/Rows/MapRowView.swift +++ b/openHAB/SwiftUI/Rows/MapRowView.swift @@ -41,7 +41,7 @@ struct MapRowView: View { } Map(coordinateRegion: $region, annotationItems: coordinates != nil ? [coordinates!] : []) { coordinate in - MapPin(coordinate: coordinate, tint: .red) + MapMarker(coordinate: coordinate, tint: .red) } .frame(height: 200) .cornerRadius(8) diff --git a/openHAB/SwiftUI/Rows/SegmentedRowView.swift b/openHAB/SwiftUI/Rows/SegmentedRowView.swift index a73e36adf..379eeaa30 100644 --- a/openHAB/SwiftUI/Rows/SegmentedRowView.swift +++ b/openHAB/SwiftUI/Rows/SegmentedRowView.swift @@ -76,9 +76,12 @@ struct SegmentedRowView: View { } .onAppear { if !isMomentary { - selectedIndex = widget.mappingIndex(byCommand: widget.item?.state) + selectedIndex = widget.mapCommandtoIndex(with: widget.item?.state) } } + .onChange(of: widget.item?.state) { newState in + selectedIndex = widget.mapCommandtoIndex(with: newState) + } } } diff --git a/openHAB/SwiftUI/Rows/SelectionRowView.swift b/openHAB/SwiftUI/Rows/SelectionRowView.swift index 1b423b210..92f7f7f8e 100644 --- a/openHAB/SwiftUI/Rows/SelectionRowView.swift +++ b/openHAB/SwiftUI/Rows/SelectionRowView.swift @@ -27,6 +27,10 @@ struct SelectionRowView: View { var body: some View { HStack { + IconView(widget: widget) + .frame(width: 24, height: 24) + .padding(.top, 4) // Align with text + if let labelText = widget.labelText, !labelText.isEmpty { Text(labelText) .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) @@ -51,7 +55,11 @@ struct SelectionRowView: View { } } .onAppear { - selectedIndex = Int(widget.mappingIndex(byCommand: widget.item?.state) ?? 0) + selectedIndex = widget.mapCommandtoIndex(with: widget.item?.state) + } + .onChange(of: widget.item?.state ?? "") { newState in + guard !newState.isEmpty else { return } + selectedIndex = widget.mapCommandtoIndex(with: newState) } } } diff --git a/openHAB/SwiftUI/Rows/SetpointRowView.swift b/openHAB/SwiftUI/Rows/SetpointRowView.swift index e768ab78c..411a9e173 100644 --- a/openHAB/SwiftUI/Rows/SetpointRowView.swift +++ b/openHAB/SwiftUI/Rows/SetpointRowView.swift @@ -51,19 +51,19 @@ struct SetpointRowView: View { HStack(spacing: 12) { Button(action: decreaseValue) { Image(systemSymbol: .chevronDown) - .font(.title2) + .font(.body) .foregroundColor(.primary) } .buttonStyle(.plain) .disabled(currentValue <= widget.minValue) Text(formattedValue) - .font(.caption.monospacedDigit()) + .font(.body.monospacedDigit()) .foregroundColor(widget.valuecolor.isEmpty ? .secondary : Color(fromString: widget.valuecolor)) Button(action: increaseValue) { Image(systemSymbol: .chevronUp) - .font(.title2) + .font(.body) .foregroundColor(.primary) } .buttonStyle(.plain) From c0f22613d4c797d0083b3c8c4bccdb426a02432a Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Wed, 16 Jul 2025 19:32:14 +0200 Subject: [PATCH 263/476] Handling of switchSupport Making MapRowView zoomable on iOS17+ Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../OpenHABCore/Model/OpenHABWidget.swift | 18 +++- openHAB.xcodeproj/project.pbxproj | 4 - openHAB/SwiftUI/RowViewFactory.swift | 8 +- openHAB/SwiftUI/Rows/MapRowView.swift | 75 ++++++++----- .../SwiftUI/Rows/RollershutterRowView.swift | 12 ++- openHAB/SwiftUI/Rows/SliderRowView.swift | 26 +++-- .../Rows/SliderWithSwitchRowView.swift | 102 ------------------ openHAB/SwiftUI/Rows/TextRowView.swift | 4 +- 8 files changed, 95 insertions(+), 154 deletions(-) delete mode 100644 openHAB/SwiftUI/Rows/SliderWithSwitchRowView.swift diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift index 21c759b54..7698741f7 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift @@ -103,6 +103,7 @@ public class OpenHABWidget: NSObject, MKAnnotation, Identifiable, ObservableObje public var visibility = true public var switchSupport = false public var labelSource = LabelSource.unknown + public var releaseOnly: Bool? @Published public var stateEnumBinding: WidgetTypeEnum = .unassigned @@ -299,7 +300,11 @@ public extension OpenHABWidget { visibility: Bool?, switchSupport: Bool?, forceAsItem: Bool?, - labelSource: LabelSource = .unknown) { + labelSource: LabelSource = .unknown, + releaseOnly: Bool? = nil, + row: Int? = nil, + column: Int? = nil, + releaseCommand: String? = nil) { self.init() id = widgetId self.widgetId = widgetId @@ -349,11 +354,12 @@ public extension OpenHABWidget { self.forceAsItem = forceAsItem stateEnumBinding = stateEnum self.labelSource = labelSource + self.releaseOnly = releaseOnly } convenience init(icon: String, iconColor: String? = nil) { // swiftlint:disable:next line_length - self.init(widgetId: "\(UUID())", label: "", icon: icon, type: .unknown, url: nil, period: nil, minValue: nil, maxValue: nil, step: nil, refresh: nil, height: nil, isLeaf: nil, iconColor: iconColor, labelColor: nil, valueColor: nil, service: nil, state: nil, text: nil, legend: nil, inputHint: nil, encoding: nil, item: nil, linkedPage: nil, mappings: [], widgets: [], visibility: nil, switchSupport: nil, forceAsItem: nil, labelSource: .unknown) + self.init(widgetId: "\(UUID())", label: "", icon: icon, type: .unknown, url: nil, period: nil, minValue: nil, maxValue: nil, step: nil, refresh: nil, height: nil, isLeaf: nil, iconColor: iconColor, labelColor: nil, valueColor: nil, service: nil, state: nil, text: nil, legend: nil, inputHint: nil, encoding: nil, item: nil, linkedPage: nil, mappings: [], widgets: [], visibility: nil, switchSupport: nil, forceAsItem: nil, labelSource: .unknown, releaseOnly: nil) } } @@ -441,8 +447,6 @@ extension OpenHABWidget { convenience init(_ widget: Components.Schemas.WidgetDTO) { // widget.unit // widget.staticIcon -// widget.visibility -// widget.labelSource // widget.pattern self.init( widgetId: widget.widgetId.orEmpty, @@ -473,7 +477,11 @@ extension OpenHABWidget { visibility: widget.visibility, switchSupport: widget.switchSupport, forceAsItem: widget.forceAsItem, - labelSource: OpenHABWidget.LabelSource(rawValue: widget.labelSource ?? "") ?? .unknown + labelSource: OpenHABWidget.LabelSource(rawValue: widget.labelSource ?? "") ?? .unknown, + releaseOnly: widget.releaseOnly, + row: widget.row.map{ Int($0) }, + column: widget.column.map{ Int($0) }, + releaseCommand: widget.releaseCommand ) } } diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index 4d210247b..e908c89cd 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -93,7 +93,6 @@ DA35E2BE2E1EEA9D003987BB /* DatePickerInputRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA35E2B32E1EEA9D003987BB /* DatePickerInputRowView.swift */; }; DA35E2BF2E1EEA9D003987BB /* WebRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA35E2BC2E1EEA9D003987BB /* WebRowView.swift */; }; DA35E2C02E1EEA9D003987BB /* ColorPickerRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA35E2B22E1EEA9D003987BB /* ColorPickerRowView.swift */; }; - DA35E2C12E1EEA9D003987BB /* SliderWithSwitchRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA35E2B92E1EEA9D003987BB /* SliderWithSwitchRowView.swift */; }; DA35E2C22E1EEA9D003987BB /* TextInputRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA35E2BA2E1EEA9D003987BB /* TextInputRowView.swift */; }; DA35E2C32E1EEA9D003987BB /* ImageRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA35E2B42E1EEA9D003987BB /* ImageRowView.swift */; }; DA35E2C42E1EEA9D003987BB /* SelectionRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA35E2B82E1EEA9D003987BB /* SelectionRowView.swift */; }; @@ -436,7 +435,6 @@ DA35E2B62E1EEA9D003987BB /* RollershutterRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RollershutterRowView.swift; sourceTree = ""; }; DA35E2B72E1EEA9D003987BB /* SegmentedRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SegmentedRowView.swift; sourceTree = ""; }; DA35E2B82E1EEA9D003987BB /* SelectionRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectionRowView.swift; sourceTree = ""; }; - DA35E2B92E1EEA9D003987BB /* SliderWithSwitchRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SliderWithSwitchRowView.swift; sourceTree = ""; }; DA35E2BA2E1EEA9D003987BB /* TextInputRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextInputRowView.swift; sourceTree = ""; }; DA35E2BB2E1EEA9D003987BB /* VideoRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoRowView.swift; sourceTree = ""; }; DA35E2BC2E1EEA9D003987BB /* WebRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebRowView.swift; sourceTree = ""; }; @@ -912,7 +910,6 @@ DA35E2B82E1EEA9D003987BB /* SelectionRowView.swift */, DA35E2AF2E1EDB86003987BB /* SetpointRowView.swift */, DAEA21DB2DBF47DA00D54342 /* SliderRowView.swift */, - DA35E2B92E1EEA9D003987BB /* SliderWithSwitchRowView.swift */, DAEA21D92DBF477E00D54342 /* SwitchRowView.swift */, DA35E2BA2E1EEA9D003987BB /* TextInputRowView.swift */, DAEA21DD2DBF481300D54342 /* TextRowView.swift */, @@ -1721,7 +1718,6 @@ DAC949FE2E21A2D1007E67B7 /* FrameRowView.swift in Sources */, DA35E2BF2E1EEA9D003987BB /* WebRowView.swift in Sources */, DA35E2C02E1EEA9D003987BB /* ColorPickerRowView.swift in Sources */, - DA35E2C12E1EEA9D003987BB /* SliderWithSwitchRowView.swift in Sources */, DA35E2C22E1EEA9D003987BB /* TextInputRowView.swift in Sources */, DA35E2C32E1EEA9D003987BB /* ImageRowView.swift in Sources */, DA35E2C42E1EEA9D003987BB /* SelectionRowView.swift in Sources */, diff --git a/openHAB/SwiftUI/RowViewFactory.swift b/openHAB/SwiftUI/RowViewFactory.swift index aedc4ac3c..b22a4cef5 100644 --- a/openHAB/SwiftUI/RowViewFactory.swift +++ b/openHAB/SwiftUI/RowViewFactory.swift @@ -28,12 +28,8 @@ enum RowViewFactory { } else { SwitchRowView(widget: widget) } - case .slider: - if widget.switchSupport { - SliderWithSwitchRowView(widget: widget) - } else { - SliderRowView(widget: widget) - } + case .slider: // SliderRowView also handles switchSupport + SliderRowView(widget: widget) case .input: if [.date, .time, .dateTime].contains(widget.inputHint) { DatePickerInputRowView(widget: widget) diff --git a/openHAB/SwiftUI/Rows/MapRowView.swift b/openHAB/SwiftUI/Rows/MapRowView.swift index 1311fd439..87ecd85cf 100644 --- a/openHAB/SwiftUI/Rows/MapRowView.swift +++ b/openHAB/SwiftUI/Rows/MapRowView.swift @@ -15,22 +15,16 @@ import MapKit import OpenHABCore import SwiftUI -struct MapRowView: View { +struct MapRowViewLegacy: View { @ObservedObject var widget: OpenHABWidget - @State private var region = MKCoordinateRegion( - center: CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194), - span: MKCoordinateSpan(latitudeDelta: 0.05, longitudeDelta: 0.05) - ) - private var coordinates: CLLocationCoordinate2D? { - guard let state = widget.item?.state, !state.isEmpty else { return nil } - let components = state.split(separator: ",") - guard components.count >= 2, - let latitude = Double(components[0]), - let longitude = Double(components[1]) else { - return nil - } - return CLLocationCoordinate2D(latitude: latitude, longitude: longitude) + private var region: MKCoordinateRegion { + let coordinate = CLLocationCoordinate2DIsValid(widget.coordinate) ? widget.coordinate : CLLocationCoordinate2D(latitude: 0, longitude: 0) + return MKCoordinateRegion( + center: coordinate, + latitudinalMeters: 1000.0, + longitudinalMeters: 1000.0 + ) } var body: some View { @@ -40,22 +34,55 @@ struct MapRowView: View { .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) } - Map(coordinateRegion: $region, annotationItems: coordinates != nil ? [coordinates!] : []) { coordinate in - MapMarker(coordinate: coordinate, tint: .red) + Map(coordinateRegion: .constant(region), annotationItems: CLLocationCoordinate2DIsValid(widget.coordinate) ? [widget.coordinate] : []) { location in + MapMarker(coordinate: location, tint: .red) } .frame(height: 200) .cornerRadius(8) - .onAppear { - if let coordinates { - region.center = coordinates + } + } +} + +@available(iOS 17.0, *) +private struct MapRowViewNew: View { + @ObservedObject var widget: OpenHABWidget + @State private var cameraPosition = MapCameraPosition.region( + MKCoordinateRegion( + center: CLLocationCoordinate2D(latitude: 0, longitude: 0), + latitudinalMeters: 1000, + longitudinalMeters: 1000 + ) + ) + + var body: some View { + VStack { + if CLLocationCoordinate2DIsValid(widget.coordinate) { + Map(position: $cameraPosition) { + Marker("", coordinate: widget.coordinate) + } + .frame(height: 200) + .onAppear { + cameraPosition = .region( + MKCoordinateRegion( + center: widget.coordinate, + latitudinalMeters: 1000, + longitudinalMeters: 1000 + ) + ) } } + } + } +} - if let labelValue = widget.labelValue, !labelValue.isEmpty { - Text(labelValue) - .font(.caption) - .foregroundColor(widget.valuecolor.isEmpty ? .secondary : Color(fromString: widget.valuecolor)) - } +struct MapRowView: View { + @ObservedObject var widget: OpenHABWidget + + var body: some View { + if #available(iOS 17.0, *) { + MapRowViewNew(widget: widget) + } else { + MapRowViewLegacy(widget: widget) } } } diff --git a/openHAB/SwiftUI/Rows/RollershutterRowView.swift b/openHAB/SwiftUI/Rows/RollershutterRowView.swift index 4a15cfe9f..5d7d8c640 100644 --- a/openHAB/SwiftUI/Rows/RollershutterRowView.swift +++ b/openHAB/SwiftUI/Rows/RollershutterRowView.swift @@ -15,6 +15,12 @@ import os.log import SFSafeSymbols import SwiftUI +enum RollerShutterCommand: String { + case up = "UP" + case down = "DOWN" + case stop = "STOP" +} + struct RollershutterRowView: View { @ObservedObject var widget: OpenHABWidget @@ -37,7 +43,7 @@ struct RollershutterRowView: View { Button { logger.info("up button pressed") - widget.sendCommand("UP") + widget.sendCommand(RollerShutterCommand.up.rawValue) } label: { Image(systemSymbol: .chevronUp) .font(.title2) @@ -47,7 +53,7 @@ struct RollershutterRowView: View { Button { logger.info("stop button pressed") - widget.sendCommand("STOP") + widget.sendCommand(RollerShutterCommand.stop.rawValue) } label: { Image(systemSymbol: .stop) .font(.title2) @@ -57,7 +63,7 @@ struct RollershutterRowView: View { Button { logger.info("down button pressed") - widget.sendCommand("DOWN") + widget.sendCommand(RollerShutterCommand.down.rawValue) } label: { Image(systemSymbol: .chevronDown) .font(.title2) diff --git a/openHAB/SwiftUI/Rows/SliderRowView.swift b/openHAB/SwiftUI/Rows/SliderRowView.swift index 4d8ca32f8..5225778bf 100644 --- a/openHAB/SwiftUI/Rows/SliderRowView.swift +++ b/openHAB/SwiftUI/Rows/SliderRowView.swift @@ -27,18 +27,28 @@ struct SliderRowView: View { var body: some View { HStack { - IconView(widget: widget) - .frame(width: 24, height: 24) + HStack { + IconView(widget: widget) + .frame(width: 24, height: 24) - Text(widget.labelText ?? "") + Text(widget.labelText ?? "") - Spacer() + Spacer() - if let value = widget.labelValue { - Text(value) - .font(.caption) - .foregroundColor(.secondary) + if let value = widget.labelValue { + Text(value) + .font(.caption) + .foregroundColor(.secondary) + } + } + .contentShape(Rectangle()) // 🔍 Make row but not slider tappable + .onTapGesture { + // 🔄 Only send ON/OFF if not dragging the slider + if !isUserInteracting, widget.switchSupport { + widget.sendCommand(currentValue <= widget.minValue ? "ON" : "OFF") + } } + Slider(value: $currentValue, in: sliderRange) { isEditing in isUserInteracting = isEditing if !isEditing { diff --git a/openHAB/SwiftUI/Rows/SliderWithSwitchRowView.swift b/openHAB/SwiftUI/Rows/SliderWithSwitchRowView.swift deleted file mode 100644 index 9f84a8025..000000000 --- a/openHAB/SwiftUI/Rows/SliderWithSwitchRowView.swift +++ /dev/null @@ -1,102 +0,0 @@ -// Copyright (c) 2010-2025 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 CommonUI -import OpenHABCore -import os.log -import SwiftUI - -struct SliderWithSwitchRowView: View { - @ObservedObject var widget: OpenHABWidget - @State private var sliderValue: Double = 0 - - private let logger = Logger(subsystem: "org.openhab", category: "WidgetSliderWithSwitchView") - - private var step: Double { - widget.step - } - - private var effectiveState: String { - var state = widget.state - if state.isEmpty { - state = widget.item?.state ?? "" - } - return state - } - - private var isSwitchOn: Bool { - effectiveState.parseAsBool() - } - - private var adjustedValue: Double { - if let item = widget.item { - adj(item.stateAsDouble()) - } else { - widget.minValue - } - } - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - HStack { - VStack(alignment: .leading, spacing: 2) { - Text(widget.labelText ?? widget.label) - .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) - - if let labelValue = widget.labelValue, !labelValue.isEmpty { - Text(labelValue) - .font(.caption) - .foregroundColor(widget.valuecolor.isEmpty ? .secondary : Color(fromString: widget.valuecolor)) - } else { - Text(sliderValue.valueText(step: step)) - .font(.caption) - .foregroundColor(.secondary) - } - } - - Spacer() - - Toggle("", isOn: Binding( - get: { isSwitchOn }, - set: { newValue in - let command = newValue ? "ON" : "OFF" - logger.info("Switch to \(command)") - widget.sendCommand(command) - } - )) - .labelsHidden() - } - - Slider( - value: $sliderValue, - in: widget.minValue ... widget.maxValue, - step: step - ) { editing in - if !editing { - logger.info("Slider new value = \(sliderValue)") - widget.sendCommand(sliderValue.valueText(step: step)) - } - } - } - .onAppear { - sliderValue = adjustedValue - } - .onChange(of: widget.item?.state) { _ in - sliderValue = adjustedValue - } - } - - private func adj(_ raw: Double) -> Double { - var valueAdjustedToStep = Double(floor(Float((raw - widget.minValue) / step)) * Float(step)) - valueAdjustedToStep += widget.minValue - return valueAdjustedToStep.clamped(to: widget.minValue ... widget.maxValue) - } -} diff --git a/openHAB/SwiftUI/Rows/TextRowView.swift b/openHAB/SwiftUI/Rows/TextRowView.swift index 53ec8da1e..3266074c8 100644 --- a/openHAB/SwiftUI/Rows/TextRowView.swift +++ b/openHAB/SwiftUI/Rows/TextRowView.swift @@ -29,8 +29,8 @@ struct TextRowView: View { if let value = widget.labelValue { Text(value) - .font(.subheadline) - .foregroundColor(.secondary) + .font(.body) + .foregroundColor(widget.valuecolor.isEmpty ? .secondary : Color(fromString: widget.valuecolor)) } } .contextMenu { From 83df08659f6387386327b0eef04b8135de756506 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Wed, 16 Jul 2025 19:36:16 +0200 Subject: [PATCH 264/476] Parse ButtonGrid Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift | 4 ++-- sitemap.json | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) delete mode 100644 sitemap.json diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift index 7698741f7..7602a7f03 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift @@ -479,8 +479,8 @@ extension OpenHABWidget { forceAsItem: widget.forceAsItem, labelSource: OpenHABWidget.LabelSource(rawValue: widget.labelSource ?? "") ?? .unknown, releaseOnly: widget.releaseOnly, - row: widget.row.map{ Int($0) }, - column: widget.column.map{ Int($0) }, + row: widget.row.map { Int($0) }, + column: widget.column.map { Int($0) }, releaseCommand: widget.releaseCommand ) } diff --git a/sitemap.json b/sitemap.json deleted file mode 100644 index c866d1d25..000000000 --- a/sitemap.json +++ /dev/null @@ -1 +0,0 @@ -{"name":"uicomponents_page_dddf943769","label":"Test Widgets","link":"http://192.168.2.10:8080/rest/sitemaps/uicomponents_page_dddf943769","homepage":{"id":"uicomponents_page_dddf943769","title":"Test Widgets","link":"http://192.168.2.10:8080/rest/sitemaps/uicomponents_page_dddf943769/uicomponents_page_dddf943769","leaf":false,"timeout":false,"widgets":[{"widgetId":"00","type":"Image","visibility":true,"label":"","labelSource":"NONE","icon":"image","staticIcon":false,"unit":"","mappings":[],"url":"http://192.168.2.10:8080/proxy?sitemap\u003duicomponents_page_dddf943769\u0026widgetId\u003d00","widgets":[]},{"widgetId":"01","type":"Video","visibility":true,"label":"testvideo","labelSource":"SITEMAP_WIDGET","icon":"video","staticIcon":false,"unit":"","mappings":[],"url":"http://192.168.2.10:8080/proxy?sitemap\u003duicomponents_page_dddf943769\u0026widgetId\u003d01","encoding":"mjpeg","widgets":[]},{"widgetId":"02","type":"Text","visibility":true,"label":"Public IP [-]","labelSource":"SITEMAP_WIDGET","icon":"if:mdi:lightbulb","staticIcon":false,"pattern":"%s","unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/testStringItem","state":"NULL","stateDescription":{"pattern":"%s","readOnly":false,"options":[]},"type":"String","name":"testStringItem","label":"testString","category":"","tags":[],"groupNames":[]},"widgets":[]},{"widgetId":"03","type":"Text","visibility":true,"label":"Access Point [dd-wrt-unifi-0]","labelSource":"ITEM_LABEL","icon":"","staticIcon":false,"pattern":"%s","unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/UniFiWirelessClientiphonetim11pro_AccessPoint","state":"dd-wrt-unifi-0","stateDescription":{"pattern":"%s","readOnly":true,"options":[]},"type":"String","name":"UniFiWirelessClientiphonetim11pro_AccessPoint","label":"Access Point","category":"","tags":["Point"],"groupNames":["UniFiWirelessClientiphonetim11pro"]},"widgets":[]},{"widgetId":"04","type":"Setpoint","visibility":true,"label":"Kitchen [102.2 °C]","labelSource":"SITEMAP_WIDGET","icon":"","staticIcon":false,"pattern":"%.1f °C","unit":"°C","mappings":[],"minValue":0.0,"maxValue":30.0,"step":5.0,"item":{"link":"http://192.168.2.10:8080/rest/items/TestSetpoint","state":"102.2222222222222222222222222222223 °C","stateDescription":{"pattern":"%.1f %unit%","readOnly":false,"options":[]},"unitSymbol":"°C","type":"Number:Temperature","name":"TestSetpoint","label":"TestSetpoint","category":"","tags":[],"groupNames":[]},"widgets":[]},{"widgetId":"05","type":"Setpoint","visibility":true,"label":"default [102.2 °C]","labelSource":"SITEMAP_WIDGET","icon":"","staticIcon":false,"pattern":"%.1f %unit%","unit":"°C","mappings":[],"minValue":100.0,"maxValue":100.0,"item":{"link":"http://192.168.2.10:8080/rest/items/TestSetpoint","state":"102.2222222222222222222222222222223 °C","stateDescription":{"pattern":"%.1f %unit%","readOnly":false,"options":[]},"unitSymbol":"°C","type":"Number:Temperature","name":"TestSetpoint","label":"TestSetpoint","category":"","tags":[],"groupNames":[]},"widgets":[]},{"widgetId":"06","type":"Setpoint","visibility":true,"label":"C[102.2 °C]","labelSource":"SITEMAP_WIDGET","icon":"","staticIcon":false,"pattern":"%.1f °C","unit":"°C","mappings":[],"minValue":0.0,"maxValue":100.0,"item":{"link":"http://192.168.2.10:8080/rest/items/TestSetpoint","state":"102.2222222222222222222222222222223 °C","stateDescription":{"pattern":"%.1f %unit%","readOnly":false,"options":[]},"unitSymbol":"°C","type":"Number:Temperature","name":"TestSetpoint","label":"TestSetpoint","category":"","tags":[],"groupNames":[]},"widgets":[]},{"widgetId":"07","type":"Setpoint","visibility":true,"label":"F[216.0 °F]","labelSource":"SITEMAP_WIDGET","icon":"","staticIcon":false,"pattern":"%.1f °F","unit":"°F","mappings":[],"minValue":0.0,"maxValue":1000.0,"state":"216.0000000000000000000000000000001 °F","item":{"link":"http://192.168.2.10:8080/rest/items/TestSetpoint","state":"102.2222222222222222222222222222223 °C","stateDescription":{"pattern":"%.1f %unit%","readOnly":false,"options":[]},"unitSymbol":"°C","type":"Number:Temperature","name":"TestSetpoint","label":"TestSetpoint","category":"","tags":[],"groupNames":[]},"widgets":[]},{"widgetId":"08","type":"Setpoint","visibility":true,"label":"K[375.4 K]","labelSource":"SITEMAP_WIDGET","icon":"","staticIcon":false,"pattern":"%.1f K","unit":"K","mappings":[],"minValue":100.0,"maxValue":1000.0,"state":"375.3722222222222222222222222222223 K","item":{"link":"http://192.168.2.10:8080/rest/items/TestSetpoint","state":"102.2222222222222222222222222222223 °C","stateDescription":{"pattern":"%.1f %unit%","readOnly":false,"options":[]},"unitSymbol":"°C","type":"Number:Temperature","name":"TestSetpoint","label":"TestSetpoint","category":"","tags":[],"groupNames":[]},"widgets":[]},{"widgetId":"09","type":"Input","visibility":true,"label":"Meter [166000]","labelSource":"SITEMAP_WIDGET","icon":"energy","staticIcon":true,"pattern":"%.0f %unit%","unit":"","mappings":[],"inputHint":"number","item":{"link":"http://192.168.2.10:8080/rest/items/Test_Meter_Reading","state":"166000.0","stateDescription":{"pattern":"%.0f","readOnly":false,"options":[]},"type":"Number","name":"Test_Meter_Reading","label":"Test_Meter_Reading","category":"","tags":[],"groupNames":[]},"widgets":[]},{"widgetId":"10","type":"Setpoint","visibility":true,"label":"item in seconds [2400.0 s]","labelSource":"SITEMAP_WIDGET","icon":"","staticIcon":false,"pattern":"%.1f s","unit":"s","mappings":[],"minValue":300.0,"maxValue":3600.0,"step":60.0,"item":{"link":"http://192.168.2.10:8080/rest/items/testTime","state":"2400 s","stateDescription":{"pattern":"%.0f %unit%","readOnly":false,"options":[]},"unitSymbol":"s","type":"Number:Time","name":"testTime","label":"","category":"","tags":[],"groupNames":[]},"widgets":[]},{"widgetId":"11","type":"Setpoint","visibility":true,"label":"item in minutes [40.0 min]","labelSource":"SITEMAP_WIDGET","icon":"","staticIcon":false,"pattern":"%.1f min","unit":"min","mappings":[],"minValue":5.0,"maxValue":60.0,"step":5.0,"state":"40 min","item":{"link":"http://192.168.2.10:8080/rest/items/testTime","state":"2400 s","stateDescription":{"pattern":"%.0f %unit%","readOnly":false,"options":[]},"unitSymbol":"s","type":"Number:Time","name":"testTime","label":"","category":"","tags":[],"groupNames":[]},"widgets":[]},{"widgetId":"13","type":"Colortemperaturepicker","visibility":true,"label":"Color Temperature [5600 K]","labelSource":"SITEMAP_WIDGET","icon":"colorwheel","staticIcon":true,"pattern":"%.0f %unit%","unit":"K","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/test_LEDLight_ColorTemp","state":"5600 K","stateDescription":{"pattern":"%.0f %unit%","readOnly":false,"options":[]},"unitSymbol":"K","type":"Number:Temperature","name":"test_LEDLight_ColorTemp","label":"","category":"","tags":[],"groupNames":[]},"widgets":[]},{"widgetId":"14","type":"Slider","visibility":true,"label":"Brightness","labelSource":"SITEMAP_WIDGET","icon":"","staticIcon":false,"unit":"","mappings":[],"switchSupport":false,"releaseOnly":false,"item":{"link":"http://192.168.2.10:8080/rest/items/test_LEDLight_Brightness","state":"71","type":"Dimmer","name":"test_LEDLight_Brightness","label":"","category":"","tags":[],"groupNames":[]},"widgets":[]},{"widgetId":"15","type":"Colorpicker","visibility":true,"label":"Color","labelSource":"SITEMAP_WIDGET","icon":"colorwheel","staticIcon":false,"unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/test_LEDLight_color","state":"0,0,73","type":"Color","name":"test_LEDLight_color","label":"test_LEDLight_color","category":"","tags":[],"groupNames":[]},"widgets":[]},{"widgetId":"16","type":"Buttongrid","visibility":true,"label":"Remote Control [-]","labelSource":"SITEMAP_WIDGET","icon":"screen","staticIcon":true,"pattern":"%s","unit":"","mappings":[{"row":1,"column":1,"command":"POWER","label":"Power","icon":"switch-off"},{"row":1,"column":2,"command":"MENU","label":"Menu"},{"row":1,"column":3,"command":"EXIT","label":"Exit"},{"row":2,"column":2,"command":"UP","label":"Up","icon":"f7:arrowtriangle_up"},{"row":2,"column":4,"command":"VOL_PLUS","label":"Volume +"},{"row":3,"column":1,"command":"LEFT","label":"Left","icon":"f7:arrowtriangle_left"},{"row":3,"column":2,"command":"OK","label":"Ok"},{"row":3,"column":3,"command":"RIGHT","label":"Right","icon":"f7:arrowtriangle_right"},{"row":3,"column":4,"command":"MUTE","label":"Mute","icon":"soundvolume_mute"},{"row":4,"column":2,"command":"DOWN","label":"Down","icon":"f7:arrowtriangle_down"},{"row":4,"column":4,"command":"VOL_MINUS","label":"Volume -"}],"item":{"link":"http://192.168.2.10:8080/rest/items/test_RemoteControl","state":"NULL","stateDescription":{"pattern":"%s","readOnly":false,"options":[]},"type":"String","name":"test_RemoteControl","label":"test_RemoteControl","category":"","tags":[],"groupNames":[]},"widgets":[]}]}} From b916e797699d98501e6a385a5dfbf7f872db0e12 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Wed, 16 Jul 2025 21:37:44 +0200 Subject: [PATCH 265/476] Handle buttongrid send in widgets Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../OpenHABCore/Model/OpenHABPage.swift | 7 +-- .../OpenHABCore/Model/OpenHABWidget.swift | 14 +++++- openHAB/SwiftUI/Rows/ButtonGridRowView.swift | 48 ++++++++++++++----- 3 files changed, 51 insertions(+), 18 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABPage.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABPage.swift index 39ac4b0c4..6ded0ba66 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABPage.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABPage.swift @@ -29,9 +29,10 @@ public class OpenHABPage: NSObject, @unchecked Sendable { self.title = title self.link = link self.leaf = leaf - var tempWidgets = [OpenHABWidget]() - tempWidgets.flatten(widgets) - self.widgets = tempWidgets +// var tempWidgets = [OpenHABWidget]() +// tempWidgets.flatten(widgets) +// self.widgets = tempWidgets + self.widgets = widgets for widget in self.widgets { widget.sendCommand = { [weak self] item, command in self?.sendCommand(item, commandToSend: command) diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift index 7602a7f03..17fe9facd 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift @@ -104,6 +104,10 @@ public class OpenHABWidget: NSObject, MKAnnotation, Identifiable, ObservableObje public var switchSupport = false public var labelSource = LabelSource.unknown public var releaseOnly: Bool? + public var row: Int? + public var column: Int? + public var releaseCommand: String? + public var command: String? @Published public var stateEnumBinding: WidgetTypeEnum = .unassigned @@ -304,7 +308,8 @@ public extension OpenHABWidget { releaseOnly: Bool? = nil, row: Int? = nil, column: Int? = nil, - releaseCommand: String? = nil) { + releaseCommand: String? = nil, + command: String? = nil) { self.init() id = widgetId self.widgetId = widgetId @@ -355,6 +360,10 @@ public extension OpenHABWidget { stateEnumBinding = stateEnum self.labelSource = labelSource self.releaseOnly = releaseOnly + self.row = row + self.column = column + self.releaseCommand = releaseCommand + self.command = command } convenience init(icon: String, iconColor: String? = nil) { @@ -481,7 +490,8 @@ extension OpenHABWidget { releaseOnly: widget.releaseOnly, row: widget.row.map { Int($0) }, column: widget.column.map { Int($0) }, - releaseCommand: widget.releaseCommand + releaseCommand: widget.releaseCommand, + command: widget.command ) } } diff --git a/openHAB/SwiftUI/Rows/ButtonGridRowView.swift b/openHAB/SwiftUI/Rows/ButtonGridRowView.swift index 3dc7deb2f..91f137caa 100644 --- a/openHAB/SwiftUI/Rows/ButtonGridRowView.swift +++ b/openHAB/SwiftUI/Rows/ButtonGridRowView.swift @@ -15,21 +15,22 @@ import os.log import SwiftUI struct ButtonGridButton: View { - let button: OpenHABWidgetMapping - let widget: OpenHABWidget + let button: OpenHABWidget + let targetWidget: OpenHABWidget @State private var isPressed = false private let logger = Logger(subsystem: "org.openhab", category: "ButtonGridButton") private var isStateful: Bool { - // Mappings are typically stateless unless specified otherwise + // TODO: Mappings are typically stateless unless specified otherwise + // Handle widgets as well false } private var isSelected: Bool { guard isStateful else { return false } - return widget.item?.state == button.command + return targetWidget.item?.state == button.command } var body: some View { @@ -37,8 +38,8 @@ struct ButtonGridButton: View { handleButtonPress() } label: { HStack { - if let icon = button.icon, !icon.isEmpty { - IconView(icon: icon) + if !button.icon.isEmpty { + IconView(icon: button.icon) .frame(width: 16, height: 16) } else { Text(button.label) @@ -74,9 +75,9 @@ struct ButtonGridButton: View { private func handleButtonPress() { // Send command on tap for mappings - if !button.command.isEmpty { - logger.info("Sending command: \(button.command)") - widget.sendCommand(button.command) + if let command = button.command, !command.isEmpty { + logger.info("Sending command: \(command)") + targetWidget.sendCommand(button.command) } } @@ -97,8 +98,12 @@ struct ButtonGridRowView: View { // Maximum number of columns based on screen width private let maxColumns = 12 - private var buttons: [OpenHABWidgetMapping] { - widget.mappings + private var buttons: [OpenHABWidget] { + let childButtons = widget.widgets // .filter(\.visibility) + let mappingButtons = widget.mappings.enumerated().map { (index, mapping) in + mapping.toWidget(widgetId: "\(widget.widgetId)-mappings-\(index)", item: widget.item) + } + return childButtons + mappingButtons } private var showLabelAndIcon: Bool { @@ -135,7 +140,7 @@ struct ButtonGridRowView: View { let button = buttonForPosition(row: row, column: column) if let button { - ButtonGridButton(button: button, widget: widget) + ButtonGridButton(button: button, targetWidget: widget) } else { // Empty cell to maintain grid structure Rectangle() @@ -148,7 +153,7 @@ struct ButtonGridRowView: View { } } - private func buttonForPosition(row: Int, column: Int) -> OpenHABWidgetMapping? { + private func buttonForPosition(row: Int, column: Int) -> OpenHABWidget? { buttons.first { button in // OpenHAB uses 1-based indexing, convert to 0-based (button.row ?? 1) - 1 == row && (button.column ?? 1) - 1 == column @@ -171,6 +176,23 @@ extension View { } } +// Extension to convert OpenHABWidgetMapping to OpenHABWidget +extension OpenHABWidgetMapping { + func toWidget(widgetId: String, item: OpenHABItem?) -> OpenHABWidget { + let widget = OpenHABWidget() + widget.widgetId = widgetId + widget.label = label + widget.command = command + widget.item = item + widget.type = .switchWidget + widget.visibility = true + widget.row = row + widget.column = column + widget.releaseCommand = releaseCommand + return widget + } +} + #Preview { if let widget = PreviewConstants.openHABSitemapPage!.widgets.first(where: { $0.type == .buttongrid }) { VStack { From 486e619ef3ecfe5b9a9853bf1dc8c22ca8991f07 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Thu, 17 Jul 2025 12:47:28 +0200 Subject: [PATCH 266/476] Flatten widgets only if not within buttongrid. Push flattening to display widget of type Button More stability for IconView to wait for activeConnection Reworked Clear Image Cache in Settings to show how much will be cleaned Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../OpenHABCore/Model/OpenHABWidget.swift | 15 +++- openHAB.xcodeproj/project.pbxproj | 2 +- openHAB/Cells/WidgetCellProvider.swift | 2 +- .../SettingsView/SitemapSettingsView.swift | 36 +++++++-- openHAB/SitemapPageViewModel.swift | 9 ++- openHAB/SwiftUI/IconView.swift | 46 +++++++++-- openHAB/SwiftUI/RowViewFactory.swift | 2 +- openHAB/SwiftUI/Rows/ButtonGridRowView.swift | 6 +- openHABWatch/Views/Utils/IconView.swift | 79 ++++++++++++++++--- 9 files changed, 163 insertions(+), 34 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift index 17fe9facd..d5db1b09b 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift @@ -55,6 +55,7 @@ public class OpenHABWidget: NSObject, MKAnnotation, Identifiable, ObservableObje case webview = "Webview" case colortemperaturepicker = "Colortemperaturepicker" case buttongrid = "Buttongrid" + case button = "Button" case unknown = "Unknown" } @@ -108,6 +109,8 @@ public class OpenHABWidget: NSObject, MKAnnotation, Identifiable, ObservableObje public var column: Int? public var releaseCommand: String? public var command: String? + public var stateless: Bool? + public var readOnly = false @Published public var stateEnumBinding: WidgetTypeEnum = .unassigned @@ -309,7 +312,8 @@ public extension OpenHABWidget { row: Int? = nil, column: Int? = nil, releaseCommand: String? = nil, - command: String? = nil) { + command: String? = nil, + stateless: Bool? = nil) { self.init() id = widgetId self.widgetId = widgetId @@ -443,11 +447,13 @@ public extension OpenHABWidget.CodingData { } // Recursive parsing of nested widget structure -extension [OpenHABWidget] { +public extension [OpenHABWidget] { mutating func flatten(_ widgets: [Element]) { for widget in widgets { append(widget) - flatten(widget.widgets) + if widget.type != .buttongrid { + flatten(widget.widgets) + } } } } @@ -491,7 +497,8 @@ extension OpenHABWidget { row: widget.row.map { Int($0) }, column: widget.column.map { Int($0) }, releaseCommand: widget.releaseCommand, - command: widget.command + command: widget.command, + stateless: widget.stateless ) } } diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index e908c89cd..f1e3c9747 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -902,6 +902,7 @@ DA35E2B22E1EEA9D003987BB /* ColorPickerRowView.swift */, DAEE35062E224F6000B4A7F1 /* ColorTemperaturePickerRowView.swift */, DA35E2B32E1EEA9D003987BB /* DatePickerInputRowView.swift */, + DAC949FD2E21A2D1007E67B7 /* FrameRowView.swift */, DAEA21DF2DBF483E00D54342 /* GenericRowView.swift */, DA35E2B42E1EEA9D003987BB /* ImageRowView.swift */, DA35E2B52E1EEA9D003987BB /* MapRowView.swift */, @@ -915,7 +916,6 @@ DAEA21DD2DBF481300D54342 /* TextRowView.swift */, DA35E2BB2E1EEA9D003987BB /* VideoRowView.swift */, DA35E2BC2E1EEA9D003987BB /* WebRowView.swift */, - DAC949FD2E21A2D1007E67B7 /* FrameRowView.swift */, ); path = Rows; sourceTree = ""; diff --git a/openHAB/Cells/WidgetCellProvider.swift b/openHAB/Cells/WidgetCellProvider.swift index 5edd14f7f..9e70303e3 100644 --- a/openHAB/Cells/WidgetCellProvider.swift +++ b/openHAB/Cells/WidgetCellProvider.swift @@ -48,7 +48,7 @@ enum WidgetCellFactory { case .video: VideoCellProvider() case .webview: WebViewCellProvider() case .mapview: MapViewCellProvider() - case .group, .text, .defaultWidget, .colortemperaturepicker, .buttongrid, .unknown: + case .group, .text, .defaultWidget, .colortemperaturepicker, .buttongrid, .button, .unknown: GenericCellProvider() } } diff --git a/openHAB/SettingsView/SitemapSettingsView.swift b/openHAB/SettingsView/SitemapSettingsView.swift index fc28972a9..a0031ff2e 100644 --- a/openHAB/SettingsView/SitemapSettingsView.swift +++ b/openHAB/SettingsView/SitemapSettingsView.swift @@ -22,6 +22,8 @@ struct SitemapSettingsView: View { @Binding var sitemaps: [OpenHABSitemap] @State private var showingCacheAlert = false + @State var cacheSizeResult: Result? = nil + private let logger = Logger(subsystem: "org.openhab.app", category: "SitemapSettingsView") var body: some View { @@ -31,15 +33,37 @@ struct SitemapSettingsView: View { } Button { - clearWebsiteCache() - showingCacheAlert = true + KingfisherManager.shared.cache.calculateDiskStorageSize { result in + cacheSizeResult = result + showingCacheAlert = true + } } label: { - NavigationLink("Clear Image Cache", destination: EmptyView()) + NavigationLink("Check & Clear Image Cache", destination: EmptyView()) } .foregroundColor(Color(uiColor: .label)) - .alert("cache_cleared", isPresented: $showingCacheAlert) { - Button("OK", role: .cancel) {} - } + .alert( + "Image Cache", + isPresented: $showingCacheAlert, + presenting: cacheSizeResult, + actions: { result in + switch result { + case .success: + Button("Clear") { + clearWebsiteCache() + } + Button("Cancel", role: .cancel) {} + case .failure: + Button("OK") {} + } + }, message: { result in + switch result { + case let .success(size): + Text("Size: \(size / 1_048_576) MB") + case let .failure(error): + Text(error.localizedDescription) + } + } + ) Picker(selection: $settingsIconType) { ForEach(IconType.allCases, id: \.self) { icontype in diff --git a/openHAB/SitemapPageViewModel.swift b/openHAB/SitemapPageViewModel.swift index c4d4510d3..1d6e551c9 100644 --- a/openHAB/SitemapPageViewModel.swift +++ b/openHAB/SitemapPageViewModel.swift @@ -48,11 +48,14 @@ class SitemapPageViewModel: ObservableObject { private var isLinkedPage = false var relevantWidgets: [OpenHABWidget] { - guard !searchText.isEmpty else { return currentPage?.widgets ?? [] } + var flattenedWidgets = [OpenHABWidget]() + flattenedWidgets.flatten(currentPage?.widgets ?? []) - return currentPage?.widgets.filter { + guard !searchText.isEmpty else { return flattenedWidgets } + + return flattenedWidgets.filter { $0.label.lowercased().contains(searchText.lowercased()) && $0.type != .frame - } ?? [] + } } var pageTitle: String { diff --git a/openHAB/SwiftUI/IconView.swift b/openHAB/SwiftUI/IconView.swift index 229a4d300..be182a314 100644 --- a/openHAB/SwiftUI/IconView.swift +++ b/openHAB/SwiftUI/IconView.swift @@ -17,17 +17,25 @@ import SwiftUI /// A SwiftUI view that displays widget icons with openHAB-specific styling and caching struct IconView: View { @ObservedObject var widget: OpenHABWidget + @ObservedObject private var networkTracker = NetworkTracker.shared @Environment(\.colorScheme) private var colorScheme let size: CGSize let iconType: IconType = .svg @State private var imageLoadingFailed = false + @State private var retryCount = 0 + private let maxRetries = 3 + private let retryDelay: TimeInterval = 1.0 private let logger = Logger(subsystem: "org.openhab", category: "WidgetIconView") private var iconURL: URL? { - guard !widget.icon.isEmpty else { return nil } + guard !widget.icon.isEmpty, + let activeConnection = networkTracker.activeConnection, + !activeConnection.configuration.url.isEmpty else { + return nil + } var queriedIconColor: String { switch colorScheme { @@ -41,8 +49,8 @@ struct IconView: View { } return Endpoint.icon( - rootUrl: NetworkTracker.shared.activeConnection?.configuration.url ?? "", - version: NetworkTracker.shared.activeConnection?.version ?? 2, + rootUrl: activeConnection.configuration.url, + version: activeConnection.version, icon: widget.icon, state: widget.iconState(), iconType: iconType, @@ -53,7 +61,7 @@ struct IconView: View { var body: some View { Group { if let iconURL, !imageLoadingFailed { - KFImage(iconURL) + KFImage.url(iconURL) .placeholder { // Show empty space while loading Rectangle() @@ -62,10 +70,12 @@ struct IconView: View { } .onFailure { error in logger.error("Icon loading failed for widget \(widget.label): \(error.localizedDescription)") +// handleLoadingFailure() imageLoadingFailed = true } .onSuccess { _ in imageLoadingFailed = false + retryCount = 0 } .setProcessor(OpenHABImageProcessor()) .fade(duration: 0.25) @@ -82,13 +92,37 @@ struct IconView: View { } .onChange(of: widget.icon) { _ in // Reset loading state when icon changes - imageLoadingFailed = false + resetLoadingState() } .onChange(of: widget.iconState()) { _ in // Reset loading state when icon state changes - imageLoadingFailed = false + resetLoadingState() + } + .onChange(of: networkTracker.activeConnection) { _ in + // Reset loading state when connection changes + resetLoadingState() } } + + private func handleLoadingFailure() { + if retryCount < maxRetries { + retryCount += 1 + logger.info("Retrying icon load for widget \(widget.label), attempt \(retryCount)/\(maxRetries)") + + DispatchQueue.main.asyncAfter(deadline: .now() + retryDelay * Double(retryCount)) { + // Force reload by toggling imageLoadingFailed + imageLoadingFailed = false + } + } else { + logger.warning("Max retries reached for widget \(widget.label), giving up") + imageLoadingFailed = true + } + } + + private func resetLoadingState() { + imageLoadingFailed = false + retryCount = 0 + } } // MARK: - Convenience Extensions diff --git a/openHAB/SwiftUI/RowViewFactory.swift b/openHAB/SwiftUI/RowViewFactory.swift index b22a4cef5..fba3a05b9 100644 --- a/openHAB/SwiftUI/RowViewFactory.swift +++ b/openHAB/SwiftUI/RowViewFactory.swift @@ -58,7 +58,7 @@ enum RowViewFactory { ColorTemperaturePickerRowView(widget: widget) case .buttongrid: ButtonGridRowView(widget: widget) - case .group, .defaultWidget, .unknown: + case .group, .defaultWidget, .button, .unknown: GenericRowView(widget: widget) } } diff --git a/openHAB/SwiftUI/Rows/ButtonGridRowView.swift b/openHAB/SwiftUI/Rows/ButtonGridRowView.swift index 91f137caa..480f09d83 100644 --- a/openHAB/SwiftUI/Rows/ButtonGridRowView.swift +++ b/openHAB/SwiftUI/Rows/ButtonGridRowView.swift @@ -139,7 +139,8 @@ struct ButtonGridRowView: View { ForEach(0 ..< gridColumns, id: \.self) { column in let button = buttonForPosition(row: row, column: column) - if let button { + if let button, + button.visibility { ButtonGridButton(button: button, targetWidget: widget) } else { // Empty cell to maintain grid structure @@ -184,11 +185,12 @@ extension OpenHABWidgetMapping { widget.label = label widget.command = command widget.item = item - widget.type = .switchWidget + widget.type = .button widget.visibility = true widget.row = row widget.column = column widget.releaseCommand = releaseCommand + widget.icon = icon ?? "" return widget } } diff --git a/openHABWatch/Views/Utils/IconView.swift b/openHABWatch/Views/Utils/IconView.swift index 10948b102..79250c209 100644 --- a/openHABWatch/Views/Utils/IconView.swift +++ b/openHABWatch/Views/Utils/IconView.swift @@ -18,15 +18,29 @@ import SwiftUI struct IconView: View { @ObservedObject var widget: OpenHABWidget @ObservedObject var settings = AppSettings.shared + @ObservedObject private var networkTracker = NetworkTracker.shared + + @State private var imageLoadingFailed = false + @State private var retryCount = 0 + private let maxRetries = 3 + private let retryDelay: TimeInterval = 1.0 + + private let logger = Logger(subsystem: "org.openhab", category: "WatchIconView") var iconURL: URL? { + guard !widget.icon.isEmpty, + let activeConnection = networkTracker.activeConnection, + !activeConnection.configuration.url.isEmpty else { + return nil + } + var iconColor = widget.iconColor if iconColor.isEmpty { iconColor = "white" } return Endpoint.icon( - rootUrl: settings.openHABRootUrl, - version: settings.openHABVersion, + rootUrl: activeConnection.configuration.url, + version: activeConnection.version, icon: widget.icon, state: widget.item?.state ?? "", iconType: settings.iconType, @@ -35,17 +49,62 @@ struct IconView: View { } var body: some View { - KFImage(iconURL) - .placeholder { + Group { + if let iconURL, !imageLoadingFailed { + KFImage(iconURL) + .placeholder { + Image(systemSymbol: .circle) + .frame(width: 20, height: 20) + } + .onFailure { error in + logger.error("Icon loading failed for widget \(widget.label): \(error.localizedDescription)") + handleLoadingFailure() + } + .onSuccess { _ in + imageLoadingFailed = false + retryCount = 0 + } + .setProcessor(OpenHABImageProcessor()) + .fade(duration: 0.25) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 20, height: 20) + .id(iconURL.absoluteString) + } else { + // Show fallback when no icon or failed to load Image(systemSymbol: .circle) .frame(width: 20, height: 20) + .opacity(0.3) + } + } + .onChange(of: widget.icon) { _ in + resetLoadingState() + } + .onChange(of: widget.iconState()) { _ in + resetLoadingState() + } + .onChange(of: networkTracker.activeConnection) { _ in + resetLoadingState() + } + } + + private func handleLoadingFailure() { + if retryCount < maxRetries { + retryCount += 1 + logger.info("Retrying icon load for widget \(widget.label), attempt \(retryCount)/\(maxRetries)") + + DispatchQueue.main.asyncAfter(deadline: .now() + retryDelay * Double(retryCount)) { + imageLoadingFailed = false } - .setProcessor(OpenHABImageProcessor()) - .fade(duration: 0.25) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 20, height: 20) - .id(iconURL?.absoluteString ?? "") + } else { + logger.warning("Max retries reached for widget \(widget.label), giving up") + imageLoadingFailed = true + } + } + + private func resetLoadingState() { + imageLoadingFailed = false + retryCount = 0 } } From 37c10a460c33a2ae08dd9011914ab08c38f674e7 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Thu, 17 Jul 2025 19:30:12 +0200 Subject: [PATCH 267/476] Slider enabled for releaseOnly, porting logic of android app Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../OpenHABCore/Model/OpenHABPage.swift | 20 ++++--- .../OpenHABCore/Model/OpenHABWidget.swift | 26 +++++++++ openHAB/SwiftUI/Rows/SliderRowView.swift | 56 ++++++++++++++----- 3 files changed, 80 insertions(+), 22 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABPage.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABPage.swift index 6ded0ba66..c3e1f2e60 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABPage.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABPage.swift @@ -23,22 +23,24 @@ public class OpenHABPage: NSObject, @unchecked Sendable { public var leaf = false public var icon = "" - public init(pageId: String, title: String, link: String, leaf: Bool, widgets: [OpenHABWidget], icon: String) { + public init(pageId: String, title: String, link: String, leaf: Bool, widgets tempWidgets: [OpenHABWidget], icon: String) { super.init() self.pageId = pageId self.title = title self.link = link self.leaf = leaf -// var tempWidgets = [OpenHABWidget]() -// tempWidgets.flatten(widgets) -// self.widgets = tempWidgets - self.widgets = widgets - for widget in self.widgets { - widget.sendCommand = { [weak self] item, command in - self?.sendCommand(item, commandToSend: command) + decorateWithSendCommand(tempWidgets) + widgets = tempWidgets + self.icon = icon + + func decorateWithSendCommand(_ widgets: [OpenHABWidget]) { + for widget in widgets { + widget.sendCommand = { [weak self] item, command in + self?.sendCommand(item, commandToSend: command) + } + decorateWithSendCommand(widget.widgets) } } - self.icon = icon } private func sendCommand(_ item: OpenHABItem?, commandToSend command: String?) { diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift index d5db1b09b..1378f7960 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift @@ -502,3 +502,29 @@ extension OpenHABWidget { ) } } + +// Required for behavior of Slider +public extension OpenHABWidget { + func shouldUseSliderUpdatesDuringMove() -> Bool { + if let releaseOnly { + return !releaseOnly + } + + guard let item else { + return false + } + + if item.isOfTypeOrGroupType(.dimmer) || + item.isOfTypeOrGroupType(.number) || + item.isOfTypeOrGroupType(.color) { + return true + } + + if item.isOfTypeOrGroupType(.numberWithDimension) { + // Allow live updates for percent values, but not for e.g. temperatures + return stateValueAsNumberState?.unit == "%" + } + + return false + } +} diff --git a/openHAB/SwiftUI/Rows/SliderRowView.swift b/openHAB/SwiftUI/Rows/SliderRowView.swift index 5225778bf..34ab59ed7 100644 --- a/openHAB/SwiftUI/Rows/SliderRowView.swift +++ b/openHAB/SwiftUI/Rows/SliderRowView.swift @@ -14,11 +14,15 @@ import SwiftUI struct SliderRowView: View { @ObservedObject var widget: OpenHABWidget - @State private var currentValue = 0.0 - @State private var isUserInteracting = false + @State private var sliderValue = 0.0 + @State private var isDragging = false + + @State private var updateTask: Task? + @State private var lastSentTime = Date.distantPast + private let throttleInterval: TimeInterval = 0.9 // in seconds private var displayValue: Double { - isUserInteracting ? currentValue : (widget.stateValueAsNumberState?.value ?? widget.minValue) + isDragging ? sliderValue : (widget.stateValueAsNumberState?.value ?? widget.minValue) } private var sliderRange: ClosedRange { @@ -43,16 +47,26 @@ struct SliderRowView: View { } .contentShape(Rectangle()) // 🔍 Make row but not slider tappable .onTapGesture { - // 🔄 Only send ON/OFF if not dragging the slider - if !isUserInteracting, widget.switchSupport { - widget.sendCommand(currentValue <= widget.minValue ? "ON" : "OFF") + if widget.switchSupport { + widget.sendCommand(sliderValue <= widget.minValue ? "ON" : "OFF") } } - Slider(value: $currentValue, in: sliderRange) { isEditing in - isUserInteracting = isEditing - if !isEditing { - sendSliderUpdate(currentValue) + Slider( + value: Binding( + get: { sliderValue }, + set: { newValue in + sliderValue = newValue + if widget.shouldUseSliderUpdatesDuringMove() { + sendSliderUpdate(newValue) + } + } + ), + in: sliderRange + ) { editing in + isDragging = editing + if !editing, !widget.shouldUseSliderUpdatesDuringMove() { + sendSliderUpdate(sliderValue) } } } @@ -60,14 +74,17 @@ struct SliderRowView: View { loadCurrentValue() } .onChange(of: widget.stateValueAsNumberState?.value) { newValue in - if !isUserInteracting, let newValue { - currentValue = newValue + if !isDragging, let newValue { + sliderValue = newValue } } + .onDisappear { + updateTask?.cancel() + } } private func loadCurrentValue() { - currentValue = widget.stateValueAsNumberState?.value ?? widget.minValue + sliderValue = widget.stateValueAsNumberState?.value ?? widget.minValue } private func sendSliderUpdate(_ newValue: Double) { @@ -76,6 +93,19 @@ struct SliderRowView: View { numberState?.value = newValue widget.sendItemUpdate(state: numberState) } + + private func throttledsendSliderUpdate(_ newValue: Double) { + updateTask?.cancel() + + updateTask = Task { + try? await Task.sleep(nanoseconds: UInt64(throttleInterval * 1_000_000_000)) + guard !Task.isCancelled else { return } + await MainActor.run { + sendSliderUpdate(sliderValue) + lastSentTime = Date() + } + } + } } #Preview { From 45f1a657927a23853c3c2ce73f00793e401e1981 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Fri, 18 Jul 2025 14:19:47 +0200 Subject: [PATCH 268/476] Haptic feedback on button press Using viewModel.sendCommand instead widget.sendCommand/ passed into via EnvironmentObject Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- openHAB/SitemapPageViewModel.swift | 14 +++++++ openHAB/SwiftUI/Rows/ButtonGridRowView.swift | 6 ++- openHAB/SwiftUI/Rows/ColorPickerRowView.swift | 3 +- .../Rows/ColorTemperaturePickerRowView.swift | 3 +- .../SwiftUI/Rows/DatePickerInputRowView.swift | 3 +- openHAB/SwiftUI/Rows/FrameRowView.swift | 1 + openHAB/SwiftUI/Rows/GenericRowView.swift | 2 + openHAB/SwiftUI/Rows/ImageRowView.swift | 1 + openHAB/SwiftUI/Rows/MapRowView.swift | 1 + .../SwiftUI/Rows/RollershutterRowView.swift | 42 ++++++++++++++++--- openHAB/SwiftUI/Rows/SegmentedRowView.swift | 5 ++- openHAB/SwiftUI/Rows/SelectionRowView.swift | 3 +- openHAB/SwiftUI/Rows/SetpointRowView.swift | 20 ++++++--- openHAB/SwiftUI/Rows/SliderRowView.swift | 5 ++- openHAB/SwiftUI/Rows/SwitchRowView.swift | 3 +- openHAB/SwiftUI/Rows/TextInputRowView.swift | 3 +- openHAB/SwiftUI/Rows/TextRowView.swift | 1 + openHAB/SwiftUI/Rows/VideoRowView.swift | 1 + openHAB/SwiftUI/Rows/WebRowView.swift | 1 + openHAB/SwiftUI/SitemapPageView.swift | 1 + 20 files changed, 97 insertions(+), 22 deletions(-) diff --git a/openHAB/SitemapPageViewModel.swift b/openHAB/SitemapPageViewModel.swift index 1d6e551c9..29a298520 100644 --- a/openHAB/SitemapPageViewModel.swift +++ b/openHAB/SitemapPageViewModel.swift @@ -346,6 +346,20 @@ class SitemapPageViewModel: ObservableObject { } } + func sendToUpdate(item: OpenHABItem?, state: NumberState?) { + guard let item, let state else { + logger.info("ItemUpdate for Item or State = nil") + return + } + if item.isOfTypeOrGroupType(.numberWithDimension) { + // For number items, include unit (if present) in command + sendCommand(item, commandToSend: state.toString(locale: Locale(identifier: "US"))) + } else { + // For all other items, send the plain value + sendCommand(item, commandToSend: state.stringValue) + } + } + deinit { pageHandlingTask?.cancel() } diff --git a/openHAB/SwiftUI/Rows/ButtonGridRowView.swift b/openHAB/SwiftUI/Rows/ButtonGridRowView.swift index 480f09d83..ef6ac72e9 100644 --- a/openHAB/SwiftUI/Rows/ButtonGridRowView.swift +++ b/openHAB/SwiftUI/Rows/ButtonGridRowView.swift @@ -19,6 +19,8 @@ struct ButtonGridButton: View { let targetWidget: OpenHABWidget @State private var isPressed = false + @EnvironmentObject var viewModel: SitemapPageViewModel + @State private var triggerFeedback = false private let logger = Logger(subsystem: "org.openhab", category: "ButtonGridButton") @@ -35,6 +37,7 @@ struct ButtonGridButton: View { var body: some View { Button { + triggerFeedback.toggle() handleButtonPress() } label: { HStack { @@ -63,6 +66,7 @@ struct ButtonGridButton: View { } .buttonStyle(PlainButtonStyle()) // .disabled(widget.readOnly) + .sensoryHeavyFeedbackIfAvailable(trigger: triggerFeedback) .onPressGesture( onPress: { handleTouchDown() @@ -77,7 +81,7 @@ struct ButtonGridButton: View { // Send command on tap for mappings if let command = button.command, !command.isEmpty { logger.info("Sending command: \(command)") - targetWidget.sendCommand(button.command) + viewModel.sendCommand(targetWidget.item, commandToSend: button.command) } } diff --git a/openHAB/SwiftUI/Rows/ColorPickerRowView.swift b/openHAB/SwiftUI/Rows/ColorPickerRowView.swift index fd6ff8710..1e7c5a3e9 100644 --- a/openHAB/SwiftUI/Rows/ColorPickerRowView.swift +++ b/openHAB/SwiftUI/Rows/ColorPickerRowView.swift @@ -17,6 +17,7 @@ import SwiftUI struct ColorPickerRowView: View { @ObservedObject var widget: OpenHABWidget @State private var selectedColor: Color = .white + @EnvironmentObject var viewModel: SitemapPageViewModel private let logger = Logger(subsystem: "org.openhab", category: "WidgetColorPickerView") @@ -70,7 +71,7 @@ struct ColorPickerRowView: View { let command = "\(hueValue),\(saturationValue),\(brightnessValue)" logger.info("Sending color command: \(command)") - widget.sendCommand(command) + viewModel.sendCommand(widget.item, commandToSend: command) } private func parseColor(from state: String) -> Color? { diff --git a/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift b/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift index b51c47d33..e6e7527d1 100644 --- a/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift +++ b/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift @@ -57,6 +57,7 @@ struct CustomSliderView: View { struct ColorTemperaturePickerRowView: View { @ObservedObject var widget: OpenHABWidget @State private var selectedTemperature: Double = 2700 // Default warm white + @EnvironmentObject var viewModel: SitemapPageViewModel private let logger = Logger(subsystem: "org.openhab", category: "ColorTemperaturePickerRowView") @@ -176,7 +177,7 @@ struct ColorTemperaturePickerRowView: View { let command = "\(Int(selectedTemperature))" logger.info("Sending color temperature command: \(command)K") - widget.sendCommand(command) + viewModel.sendCommand(widget.item, commandToSend: command) } } diff --git a/openHAB/SwiftUI/Rows/DatePickerInputRowView.swift b/openHAB/SwiftUI/Rows/DatePickerInputRowView.swift index 5313c7071..6ecbb506b 100644 --- a/openHAB/SwiftUI/Rows/DatePickerInputRowView.swift +++ b/openHAB/SwiftUI/Rows/DatePickerInputRowView.swift @@ -17,6 +17,7 @@ import SwiftUI struct DatePickerInputRowView: View { @ObservedObject var widget: OpenHABWidget @State private var selectedDate = Date() + @EnvironmentObject var viewModel: SitemapPageViewModel private let logger = Logger(subsystem: "org.openhab", category: "WidgetDatePickerInputView") @@ -78,7 +79,7 @@ struct DatePickerInputRowView: View { let command = formatter.string(from: date) logger.info("Sending date command: \(command)") - widget.sendCommand(command) + viewModel.sendCommand(widget.item, commandToSend: command) } private func parseDate(from state: String) -> Date? { diff --git a/openHAB/SwiftUI/Rows/FrameRowView.swift b/openHAB/SwiftUI/Rows/FrameRowView.swift index 25c280c42..808a43704 100644 --- a/openHAB/SwiftUI/Rows/FrameRowView.swift +++ b/openHAB/SwiftUI/Rows/FrameRowView.swift @@ -14,6 +14,7 @@ import SwiftUI struct FrameRowView: View { @ObservedObject var widget: OpenHABWidget + @EnvironmentObject var viewModel: SitemapPageViewModel var body: some View { HStack { diff --git a/openHAB/SwiftUI/Rows/GenericRowView.swift b/openHAB/SwiftUI/Rows/GenericRowView.swift index 68a6cbdb5..7600f1bb1 100644 --- a/openHAB/SwiftUI/Rows/GenericRowView.swift +++ b/openHAB/SwiftUI/Rows/GenericRowView.swift @@ -14,6 +14,8 @@ import SwiftUI struct GenericRowView: View { @ObservedObject var widget: OpenHABWidget + @EnvironmentObject var viewModel: SitemapPageViewModel + var body: some View { HStack { IconView(widget: widget) diff --git a/openHAB/SwiftUI/Rows/ImageRowView.swift b/openHAB/SwiftUI/Rows/ImageRowView.swift index 9b2382db6..b6f1c4e51 100644 --- a/openHAB/SwiftUI/Rows/ImageRowView.swift +++ b/openHAB/SwiftUI/Rows/ImageRowView.swift @@ -16,6 +16,7 @@ import SwiftUI struct ImageRowView: View { @ObservedObject var widget: OpenHABWidget + @EnvironmentObject var viewModel: SitemapPageViewModel private var imageURL: URL? { guard !widget.url.isEmpty else { return nil } diff --git a/openHAB/SwiftUI/Rows/MapRowView.swift b/openHAB/SwiftUI/Rows/MapRowView.swift index 87ecd85cf..2a4f6df6f 100644 --- a/openHAB/SwiftUI/Rows/MapRowView.swift +++ b/openHAB/SwiftUI/Rows/MapRowView.swift @@ -53,6 +53,7 @@ private struct MapRowViewNew: View { longitudinalMeters: 1000 ) ) + @EnvironmentObject var viewModel: SitemapPageViewModel var body: some View { VStack { diff --git a/openHAB/SwiftUI/Rows/RollershutterRowView.swift b/openHAB/SwiftUI/Rows/RollershutterRowView.swift index 5d7d8c640..c1fc0790f 100644 --- a/openHAB/SwiftUI/Rows/RollershutterRowView.swift +++ b/openHAB/SwiftUI/Rows/RollershutterRowView.swift @@ -23,6 +23,10 @@ enum RollerShutterCommand: String { struct RollershutterRowView: View { @ObservedObject var widget: OpenHABWidget + @EnvironmentObject var viewModel: SitemapPageViewModel + @State private var triggerUpFeedback = false + @State private var triggerStopFeedback = false + @State private var triggerDownFeedback = false private let logger = Logger(subsystem: "org.openhab", category: "WidgetRollershutterView") @@ -42,39 +46,65 @@ struct RollershutterRowView: View { Spacer() Button { + triggerUpFeedback.toggle() logger.info("up button pressed") - widget.sendCommand(RollerShutterCommand.up.rawValue) + viewModel.sendCommand(widget.item, commandToSend: RollerShutterCommand.up.rawValue) } label: { Image(systemSymbol: .chevronUp) .font(.title2) - .foregroundColor(.primary) + .foregroundColor(Color(UIColor.systemBlue)) } .buttonStyle(.plain) + .sensoryHeavyFeedbackIfAvailable(trigger: triggerUpFeedback) Button { + triggerStopFeedback.toggle() logger.info("stop button pressed") - widget.sendCommand(RollerShutterCommand.stop.rawValue) + viewModel.sendCommand(widget.item, commandToSend: RollerShutterCommand.stop.rawValue) } label: { Image(systemSymbol: .stop) .font(.title2) - .foregroundColor(.primary) + .foregroundColor(Color(UIColor.systemBlue)) } .buttonStyle(.plain) + .sensoryHeavyFeedbackIfAvailable(trigger: triggerStopFeedback) Button { + triggerDownFeedback.toggle() logger.info("down button pressed") - widget.sendCommand(RollerShutterCommand.down.rawValue) + viewModel.sendCommand(widget.item, commandToSend: RollerShutterCommand.down.rawValue) } label: { Image(systemSymbol: .chevronDown) .font(.title2) - .foregroundColor(.primary) + .foregroundColor(Color(UIColor.systemBlue)) } .buttonStyle(.plain) + .sensoryHeavyFeedbackIfAvailable(trigger: triggerDownFeedback) } } } } +extension View { + @ViewBuilder + func sensoryHeavyFeedbackIfAvailable(trigger: Bool) -> some View { + if #available(iOS 17.0, *) { + self.sensoryFeedback(.impact(weight: .heavy, intensity: 0.9), trigger: trigger) + } else { + self + } + } + + @ViewBuilder + func sensoryStopFeedbackIfAvailable(trigger: Bool) -> some View { + if #available(iOS 17.0, *) { + self.sensoryFeedback(.impact(flexibility: .rigid), trigger: trigger) + } else { + self + } + } +} + #Preview { let widget = PreviewConstants.openHABSitemapPage!.widgets[5] VStack { diff --git a/openHAB/SwiftUI/Rows/SegmentedRowView.swift b/openHAB/SwiftUI/Rows/SegmentedRowView.swift index 379eeaa30..bb34565d1 100644 --- a/openHAB/SwiftUI/Rows/SegmentedRowView.swift +++ b/openHAB/SwiftUI/Rows/SegmentedRowView.swift @@ -16,6 +16,7 @@ import SwiftUI struct SegmentedRowView: View { @ObservedObject var widget: OpenHABWidget + @EnvironmentObject var viewModel: SitemapPageViewModel private let logger = Logger(subsystem: "org.openhab", category: "WidgetSegmentedView") @@ -48,7 +49,7 @@ struct SegmentedRowView: View { HStack { ForEach(mappings.indices, id: \.self) { index in Button { - widget.sendCommand(mappings[index].command) + viewModel.sendCommand(widget.item, commandToSend: mappings[index].command) } label: { Text(mappings[index].label) .padding(.horizontal, 6) @@ -62,7 +63,7 @@ struct SegmentedRowView: View { set: { newIndex in selectedIndex = newIndex if let mapping = mappings[safe: newIndex] { - widget.sendCommand(mapping.command) + viewModel.sendCommand(widget.item, commandToSend: mapping.command) } } )) { diff --git a/openHAB/SwiftUI/Rows/SelectionRowView.swift b/openHAB/SwiftUI/Rows/SelectionRowView.swift index 92f7f7f8e..d43a98099 100644 --- a/openHAB/SwiftUI/Rows/SelectionRowView.swift +++ b/openHAB/SwiftUI/Rows/SelectionRowView.swift @@ -18,6 +18,7 @@ struct SelectionRowView: View { @ObservedObject var widget: OpenHABWidget @State private var selectedIndex = 0 @ScaledMetric(relativeTo: .body) private var pickerHeight: CGFloat = 24 + @EnvironmentObject var viewModel: SitemapPageViewModel private let logger = Logger(subsystem: "org.openhab", category: "WidgetSelectionView") @@ -50,7 +51,7 @@ struct SelectionRowView: View { .onChange(of: selectedIndex) { newIndex in guard let mapping = mappings[safe: newIndex] else { return } logger.info("Selection changed to: \(mapping.label)") - widget.sendCommand(mapping.command) + viewModel.sendCommand(widget.item, commandToSend: mapping.command) } } } diff --git a/openHAB/SwiftUI/Rows/SetpointRowView.swift b/openHAB/SwiftUI/Rows/SetpointRowView.swift index 411a9e173..2e7bf6b99 100644 --- a/openHAB/SwiftUI/Rows/SetpointRowView.swift +++ b/openHAB/SwiftUI/Rows/SetpointRowView.swift @@ -17,6 +17,8 @@ import SwiftUI struct SetpointRowView: View { @ObservedObject var widget: OpenHABWidget + @EnvironmentObject var viewModel: SitemapPageViewModel + @State private var triggerFeedback = false private let logger = Logger(subsystem: "org.openhab", category: "WidgetSetpointView") private let setpointService = SetPointService() @@ -49,25 +51,33 @@ struct SetpointRowView: View { Spacer() HStack(spacing: 12) { - Button(action: decreaseValue) { + Button { + triggerFeedback.toggle() + decreaseValue() + } label: { Image(systemSymbol: .chevronDown) .font(.body) - .foregroundColor(.primary) + .foregroundColor(currentValue <= widget.minValue ? Color(.systemGray2) : Color(UIColor.systemBlue)) } .buttonStyle(.plain) .disabled(currentValue <= widget.minValue) + .sensoryHeavyFeedbackIfAvailable(trigger: triggerFeedback) Text(formattedValue) .font(.body.monospacedDigit()) .foregroundColor(widget.valuecolor.isEmpty ? .secondary : Color(fromString: widget.valuecolor)) - Button(action: increaseValue) { + Button { + triggerFeedback.toggle() + increaseValue() + } label: { Image(systemSymbol: .chevronUp) .font(.body) - .foregroundColor(.primary) + .foregroundColor(currentValue >= widget.maxValue ? Color(.systemGray2) : Color(UIColor.systemBlue)) } .buttonStyle(.plain) .disabled(currentValue >= widget.maxValue) + .sensoryHeavyFeedbackIfAvailable(trigger: triggerFeedback) } } } @@ -101,7 +111,7 @@ struct SetpointRowView: View { numberState?.value = limitedNewValue logger.info("Setpoint \(isDecreasing ? "decreased" : "increased") to \(limitedNewValue)") - widget.sendItemUpdate(state: numberState) + viewModel.sendToUpdate(item: widget.item, state: numberState) } } diff --git a/openHAB/SwiftUI/Rows/SliderRowView.swift b/openHAB/SwiftUI/Rows/SliderRowView.swift index 34ab59ed7..cfabd898a 100644 --- a/openHAB/SwiftUI/Rows/SliderRowView.swift +++ b/openHAB/SwiftUI/Rows/SliderRowView.swift @@ -20,6 +20,7 @@ struct SliderRowView: View { @State private var updateTask: Task? @State private var lastSentTime = Date.distantPast private let throttleInterval: TimeInterval = 0.9 // in seconds + @EnvironmentObject var viewModel: SitemapPageViewModel private var displayValue: Double { isDragging ? sliderValue : (widget.stateValueAsNumberState?.value ?? widget.minValue) @@ -48,7 +49,7 @@ struct SliderRowView: View { .contentShape(Rectangle()) // 🔍 Make row but not slider tappable .onTapGesture { if widget.switchSupport { - widget.sendCommand(sliderValue <= widget.minValue ? "ON" : "OFF") + viewModel.sendCommand(widget.item, commandToSend: sliderValue <= widget.minValue ? "ON" : "OFF") } } @@ -91,7 +92,7 @@ struct SliderRowView: View { var numberState = widget.stateValueAsNumberState numberState = numberState ?? NumberState(value: newValue) numberState?.value = newValue - widget.sendItemUpdate(state: numberState) + viewModel.sendToUpdate(item: widget.item, state: numberState) } private func throttledsendSliderUpdate(_ newValue: Double) { diff --git a/openHAB/SwiftUI/Rows/SwitchRowView.swift b/openHAB/SwiftUI/Rows/SwitchRowView.swift index 00c017123..dd46fa891 100644 --- a/openHAB/SwiftUI/Rows/SwitchRowView.swift +++ b/openHAB/SwiftUI/Rows/SwitchRowView.swift @@ -16,6 +16,7 @@ import SwiftUI struct SwitchRowView: View { @ObservedObject var widget: OpenHABWidget + @EnvironmentObject var viewModel: SitemapPageViewModel private let logger = Logger(subsystem: "org.openhab", category: "WidgetSwitchView") @@ -58,7 +59,7 @@ struct SwitchRowView: View { } else { logger.info("Switch to OFF") } - widget.sendCommand(newState) + viewModel.sendCommand(widget.item, commandToSend: newState) } )) .labelsHidden() diff --git a/openHAB/SwiftUI/Rows/TextInputRowView.swift b/openHAB/SwiftUI/Rows/TextInputRowView.swift index 1e66cd796..ce267d66f 100644 --- a/openHAB/SwiftUI/Rows/TextInputRowView.swift +++ b/openHAB/SwiftUI/Rows/TextInputRowView.swift @@ -18,6 +18,7 @@ struct TextInputRowView: View { @ObservedObject var widget: OpenHABWidget @State private var inputText = "" @FocusState private var isTextFieldFocused: Bool + @EnvironmentObject var viewModel: SitemapPageViewModel private let logger = Logger(subsystem: "org.openhab", category: "WidgetTextInputView") @@ -55,7 +56,7 @@ struct TextInputRowView: View { private func sendTextCommand() { logger.info("Sending text command: \(inputText)") - widget.sendCommand(inputText) + viewModel.sendCommand(widget.item, commandToSend: inputText) isTextFieldFocused = false } } diff --git a/openHAB/SwiftUI/Rows/TextRowView.swift b/openHAB/SwiftUI/Rows/TextRowView.swift index 3266074c8..7071c61f0 100644 --- a/openHAB/SwiftUI/Rows/TextRowView.swift +++ b/openHAB/SwiftUI/Rows/TextRowView.swift @@ -16,6 +16,7 @@ import SwiftUI struct TextRowView: View { @ObservedObject var widget: OpenHABWidget + @EnvironmentObject var viewModel: SitemapPageViewModel var body: some View { HStack { diff --git a/openHAB/SwiftUI/Rows/VideoRowView.swift b/openHAB/SwiftUI/Rows/VideoRowView.swift index 389014b38..8813daa0f 100644 --- a/openHAB/SwiftUI/Rows/VideoRowView.swift +++ b/openHAB/SwiftUI/Rows/VideoRowView.swift @@ -17,6 +17,7 @@ import SwiftUI struct VideoRowView: View { @ObservedObject var widget: OpenHABWidget @State private var player: AVPlayer? + @EnvironmentObject var viewModel: SitemapPageViewModel private var videoURL: URL? { guard !widget.url.isEmpty else { return nil } diff --git a/openHAB/SwiftUI/Rows/WebRowView.swift b/openHAB/SwiftUI/Rows/WebRowView.swift index e2159527e..cf30854d0 100644 --- a/openHAB/SwiftUI/Rows/WebRowView.swift +++ b/openHAB/SwiftUI/Rows/WebRowView.swift @@ -45,6 +45,7 @@ struct WebRowView: UIViewRepresentable { } @ObservedObject var widget: OpenHABWidget + @EnvironmentObject var viewModel: SitemapPageViewModel private var webURL: URL? { guard !widget.url.isEmpty else { return nil } diff --git a/openHAB/SwiftUI/SitemapPageView.swift b/openHAB/SwiftUI/SitemapPageView.swift index bbda9bf40..25175f86d 100644 --- a/openHAB/SwiftUI/SitemapPageView.swift +++ b/openHAB/SwiftUI/SitemapPageView.swift @@ -58,6 +58,7 @@ struct SitemapPageView: View { } } } + .environmentObject(viewModel) .listStyle(.plain) .navigationBarHidden(!isLinkedPage) .navigationTitle(isLinkedPage ? viewModel.pageTitle : "") From 29175aea20d04d6d9cdc2e67e310d344478ca446 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Sat, 19 Jul 2025 11:25:35 +0200 Subject: [PATCH 269/476] Change inset for Group sitemap item Eliminate swift 6 warning Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- openHAB/SettingsView/SitemapSettingsView.swift | 8 +++++--- openHAB/SwiftUI/SitemapPageView.swift | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/openHAB/SettingsView/SitemapSettingsView.swift b/openHAB/SettingsView/SitemapSettingsView.swift index a0031ff2e..2a0fd251d 100644 --- a/openHAB/SettingsView/SitemapSettingsView.swift +++ b/openHAB/SettingsView/SitemapSettingsView.swift @@ -22,7 +22,7 @@ struct SitemapSettingsView: View { @Binding var sitemaps: [OpenHABSitemap] @State private var showingCacheAlert = false - @State var cacheSizeResult: Result? = nil + @State var cacheSizeResult: Result? private let logger = Logger(subsystem: "org.openhab.app", category: "SitemapSettingsView") @@ -34,8 +34,10 @@ struct SitemapSettingsView: View { Button { KingfisherManager.shared.cache.calculateDiskStorageSize { result in - cacheSizeResult = result - showingCacheAlert = true + Task { @MainActor in + cacheSizeResult = result + showingCacheAlert = true + } } } label: { NavigationLink("Check & Clear Image Cache", destination: EmptyView()) diff --git a/openHAB/SwiftUI/SitemapPageView.swift b/openHAB/SwiftUI/SitemapPageView.swift index 25175f86d..10bf516f3 100644 --- a/openHAB/SwiftUI/SitemapPageView.swift +++ b/openHAB/SwiftUI/SitemapPageView.swift @@ -33,7 +33,7 @@ struct SitemapPageView: View { } .buttonStyle(.plain) .padding(.vertical, -6) - .listRowInsets(EdgeInsets(top: 0, leading: 4, bottom: 0, trailing: 24)) + .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 24)) } else if widget.type == .selection { Button { selectedWidget = widget From b78377a19f1888c492d1f6f89d80d7eb98f3998a Mon Sep 17 00:00:00 2001 From: Tassilo Karge Date: Sun, 20 Jul 2025 17:17:17 +0200 Subject: [PATCH 270/476] Intent adaptations for multiple homes (#897) * add intent parameter for home Signed-off-by: Tassilo Karge * remove nonexisting "new group" Signed-off-by: Tassilo Karge * Some steps towards multi-home compatible intents Signed-off-by: Tassilo Karge * larger refactoring of intents (WIP) Signed-off-by: Tassilo Karge * adapt getItemStateIntentHandler Signed-off-by: Tassilo Karge * adapt all intents for multiple homes Signed-off-by: Tassilo Karge * warning when renaming homes Signed-off-by: Tassilo Karge * add warning about changing home names Signed-off-by: Tassilo Karge * use home intent object to make identification easier also, remove static part from item cache to profit from actor isolation (networkTrackers array always produced data races) Signed-off-by: Tassilo Karge --------- Signed-off-by: Tassilo Karge --- .../OpenHABCore/Util/NetworkTracker.swift | 15 +- .../OpenHABCore/Util/OpenHABItemCache.swift | 167 +++-- .../OpenHABCore/Util/Preferences.swift | 5 +- openHAB.xcodeproj/project.pbxproj | 12 +- openHAB/HomeSelectionView.swift | 6 +- .../Base.lproj/Intents.intentdefinition | 667 +++++++++++++++++- .../GetItemStateIntentHandler.swift | 32 +- openHABIntents/IntentHandler.swift | 2 +- openHABIntents/OpenHABIntentHelper.swift | 73 ++ .../SetColorValueIntentHandler.swift | 36 +- .../SetContactStateValueIntentHandler.swift | 36 +- .../SetDimmerRollerValueIntentHandler.swift | 40 +- .../SetNumberValueIntentHandler.swift | 36 +- .../SetStringValueIntentHandler.swift | 36 +- .../SetSwitchStateIntentHandler.swift | 39 +- 15 files changed, 969 insertions(+), 233 deletions(-) create mode 100644 openHABIntents/OpenHABIntentHelper.swift diff --git a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift index c8a31ef61..7b0401b03 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift @@ -353,6 +353,7 @@ public final class NetworkTracker: ObservableObject { activeConnection = connection status = connection == nil ? .notConnected : .connected if let connection { + // TODO: suspicious call to "shared" instance with specific connection KingfisherManager.shared.defaultOptions = [.requestModifier(OpenHABAccessTokenAdapter(connectionConfiguration: connection.configuration))] } else { startRetryTask(30) @@ -381,42 +382,42 @@ public extension NetworkTracker { } func send(to item: String, command: String) async throws { - guard let activeConnection = await NetworkTracker.shared.waitForActiveConnection() else { return } + guard let activeConnection = await waitForActiveConnection() else { return } let configuration = activeConnection.configuration let service = try await connectionPool.getOrCreateService(for: configuration) try await service.sendItemCommand(itemname: item, command: command) } - func updateState(for item: OpenHABItem, state: String) async throws { - guard let activeConnection = await NetworkTracker.shared.waitForActiveConnection() else { return } + func updateState(item: OpenHABItem, state: String) async throws { + guard let activeConnection = await waitForActiveConnection() else { return } let configuration = activeConnection.configuration let service = try await connectionPool.getOrCreateService(for: configuration) try await service.updateItemState(itemname: item.name, with: state) } func getItems() async throws -> [OpenHABItem] { - guard let activeConnection = await NetworkTracker.shared.waitForActiveConnection() else { return [] } + guard let activeConnection = await waitForActiveConnection() else { return [] } let configuration = activeConnection.configuration let service = try await connectionPool.getOrCreateService(for: configuration) return try await service.getItems() } func getItemByName(id: String) async throws -> OpenHABItem? { - guard let activeConnection = await NetworkTracker.shared.waitForActiveConnection() else { return nil } + guard let activeConnection = await waitForActiveConnection() else { return nil } let configuration = activeConnection.configuration let service = try await connectionPool.getOrCreateService(for: configuration) return try await service.getItemByName(id: id) } func pollDataForPage(sitemapname: String, pageId: String = "", longPolling: Bool = false) async throws -> OpenHABPage? { - guard let activeConnection = await NetworkTracker.shared.waitForActiveConnection() else { return nil } + guard let activeConnection = await waitForActiveConnection() else { return nil } let configuration = activeConnection.configuration let service = try await connectionPool.getOrCreateService(for: configuration) return try await service.pollDataForPage(sitemapname: sitemapname, pageId: pageId, longPolling: longPolling) } func runNow(ruleUID: String, payload: [String: String]) async throws { - guard let activeConnection = await NetworkTracker.shared.waitForActiveConnection() else { throw NetworkTrackerError.noActiveConnection } + guard let activeConnection = await waitForActiveConnection() else { throw NetworkTrackerError.noActiveConnection } let configuration = activeConnection.configuration let service = try await connectionPool.getOrCreateService(for: configuration) try await service.runNow(ruleUID: ruleUID, payload: payload) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift index 5fe4d1599..5a01fee2c 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift @@ -13,110 +13,151 @@ import Combine import Foundation import os.log -public protocol ItemCacheProtocol { - func getItem(name: String) async -> OpenHABItem? - func sendCommand(_ item: OpenHABItem, commandToSend: String) async - func getItemNames(searchTerm: String?, types: [OpenHABItem.ItemType]?) async -> [String] - func sendState(_ item: OpenHABItem, stateToSend: String) async -} - public actor OpenHABItemCache { public static let instance = OpenHABItemCache() - private lazy var setupTask: Task = Task { [weak self] in - await self?.setup() - } + private var networkTrackers: [UUID: NetworkTracker] = [:] - public var items: [OpenHABItem]? + public var items: [UUID: [OpenHABItem]] = [:] private let ttl: TimeInterval = 20 - var lastLoad = Date() + var lastLoad: [UUID: Date] = [:] - private let logger = Logger(subsystem: "org.openhab.app.watchkitapp", category: "OpenHABItemCache") + private let logger = Logger(subsystem: "org.openhab.app.openHABIntents", category: "OpenHABItemCache") private init() {} - public func waitUntilReady() async { - await setupTask.value + public func getAllCachedItems() async -> [UUID: [OpenHABItem]] { + await reloadCacheIfNeeded(homes: Preferences.listStoredHomes()) + return items } - public func setup() async { - let connection1: ConnectionConfiguration = await Preferences.currentHomePreferences.localConnectionConfig - let connection2: ConnectionConfiguration = await Preferences.currentHomePreferences.remoteConnectionConfig - logger.info("Local: \(connection1.url), Remote: \(connection2.url)") - await NetworkTracker.shared.startTracking(connectionConfigurations: [connection1, connection2]) + public func getCachedItems(home: UUID) async -> [OpenHABItem]? { + await reloadCacheIfNeeded(homes: [home]) + return items[home] } - public func getItemNames(searchTerm: String?, types: [OpenHABItem.ItemType]?) async -> [String] { - logger.info("getItemNames") - guard let items else { - return await reload(searchTerm: searchTerm, types: types) - } - - return items.filtered(by: searchTerm, for: types) - .sorted(by: \.name) - .map(\.name) + public func getCachedItem(name: String, home: UUID) async -> [OpenHABItem]? { + await reloadCacheIfNeeded(homes: [home]) + return items[home]?.filter { $0.name == name } } - public func getItem(name: String) async -> OpenHABItem? { - logger.info("getItem") - let now = Date() - - if items == nil || now.timeIntervalSince(lastLoad) > ttl { - return await reload(name: name) + public func sendCommand(to item: OpenHABItem, home: UUID, command: String) async { + guard let networkTracker = await assureNetworkTracker(homeId: home) else { + logger.error("Home \(home) not reachable") + return } - return getItem(name) - } - func getItem(_ name: String) -> OpenHABItem? { - items?.first { $0.name == name } - } - - public func sendCommand(_ item: OpenHABItem, commandToSend command: String) async { do { - try await NetworkTracker.shared.send(to: item, command: command) + try await networkTracker.send(to: item, command: command) } catch { logger.info("Could not send command: \(error.localizedDescription)") } } - public func sendState(_ item: OpenHABItem, stateToSend state: String) async { + public func sendState(_ item: OpenHABItem, home: UUID, state: String) async { + guard let networkTracker = await assureNetworkTracker(homeId: home) else { + logger.error("Home \(home) not reachable") + return + } + do { - try await NetworkTracker.shared.updateState(for: item, state: state) + try await networkTracker.updateState(item: item, state: state) } catch { logger.info("Could not send state: \(error.localizedDescription)") } } - public func reload(searchTerm: String?, types: [OpenHABItem.ItemType]?) async -> [String] { - logger.info("OpenHABItemCache Loading items ") + public func reloadCacheIfNeeded(homes: [UUID]) async { + let homesNeedingReload = homes.filter { Date.now.timeIntervalSince(lastLoad[$0] ?? Date.distantPast) > ttl } + await forceCacheReload(homes: homesNeedingReload) + } + public func forceCacheReload(homes: [UUID]) async { + logger.info("reload items") do { - items = try await NetworkTracker.shared.getItems().filter { $0.type != .group } - // swiftformat:disable next redundantSelf - lastLoad = Date() - logger.info("Loaded \(self.items?.count ?? 0) items to cache") - return items?.filtered(by: searchTerm, for: types).sorted(by: \.name).map(\.name) ?? [] + let loadedItems = try await loadNonGroupItemsForHomes(homes) + homes.forEach { items[$0] = loadedItems[$0] } + let now = Date.now + homes.forEach { lastLoad[$0] = now } + let itemCounts = items.map { ($0.key, $0.value.count) } + logger.info("Loaded \(itemCounts) items to cache") } catch { logger.error("Could not reload \(error.localizedDescription)") - return [] } } - public func reload(name: String) async -> OpenHABItem? { - do { - items = try await NetworkTracker.shared.getItems().filter { $0.type != .group } - return items?.first { $0.name == name } - } catch { - logger.error("Could not reload \(error.localizedDescription)") + public func forceCacheReload() async { + let homes = Preferences.listStoredHomes() + // some house keeping + let networkTrackersToRemove = networkTrackers.filter { !homes.contains($0.key) } + for networkTracker in networkTrackersToRemove { + await networkTracker.value.stopTracking() + networkTrackers.removeValue(forKey: networkTracker.key) + } + items = [:] + lastLoad = [:] + await forceCacheReload(homes: homes) + } + + func matchingItemNames(_ itemsList: [OpenHABItem], searchTerm: String? = nil, types: [OpenHABItem.ItemType]? = nil) -> [String] { + itemsList + .filtered(by: searchTerm, for: types) + .map(\.name) + .sorted() + } + + private func loadNonGroupItemsForHomes(_ homes: [UUID]) async throws -> [UUID: [OpenHABItem]] { + let allItemsArray = try await loadItemsForHomes(homes).map { (uuid, items) in (uuid, items.filter { $0.type != .group }) } + return Dictionary(uniqueKeysWithValues: allItemsArray) + } + + private func loadItemsForHomes(_ homes: [UUID]) async throws -> [UUID: [OpenHABItem]] { + await withThrowingTaskGroup { @Sendable group in + for homeId in homes { + group.addTask { + // TODO: consider the possibility that two local connections might be the same + guard let items = await self.loadItems(homeId: homeId) else { + self.logger.error("Item search for home with id \(homeId) failed") + return (id: homeId, items: [] as [OpenHABItem]) + } + return (id: homeId, items: items) + } + } + let homeItemsDictionary: [UUID: [OpenHABItem]] = [:] + let allHomeItems = try? await group.reduce(into: homeItemsDictionary) { partialResult, nextElement in + logger.debug("Found \(nextElement.items.count) items for \(nextElement.id)") + partialResult[nextElement.id] = nextElement.items + } + guard let allHomeItems else { + logger.error("Item search failed!") + return [:] + } + return allHomeItems + } + } + + private func loadItems(homeId: UUID) async -> [OpenHABItem]? { + guard let networkTracker = await assureNetworkTracker(homeId: homeId) else { + logger.error("Home \(homeId) not reachable") return nil } + return try? await networkTracker.getItems() } -} -extension OpenHABItemCache: ItemCacheProtocol {} + private func assureNetworkTracker(homeId: UUID) async -> NetworkTracker? { + if networkTrackers[homeId] == nil, let homePreferences = Preferences.storedHomes[homeId] { + let tracker = NetworkTracker() + networkTrackers[homeId] = tracker + await tracker.startTracking(connectionConfigurations: [homePreferences.localConnectionConfig, homePreferences.remoteConnectionConfig]) + } + // TODO: do we need to make sure / wait that the connection is live? + return networkTrackers[homeId] + } +} -private extension [OpenHABItem] { - func filtered(by searchTerm: String?, for types: [OpenHABItem.ItemType]?) -> [OpenHABItem] { +public extension [OpenHABItem] { + func filtered(by searchTerm: String? = nil, for types: [OpenHABItem.ItemType]? = nil) -> [OpenHABItem] { + // TODO: maybe allow home name for filtering and fuzzier search filter { (searchTerm == nil || $0.name.contains(searchTerm.orEmpty)) && (types == nil || ($0.type != nil && types!.contains($0.type!))) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift index da2c92846..a6b933ba4 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift @@ -15,7 +15,7 @@ import UIKit private let logger = Logger(subsystem: "org.openhab", category: "Preferences") -@propertyWrapper @MainActor +@propertyWrapper public struct UserDefault { private let key: String private let defaultValue: T @@ -44,7 +44,7 @@ public struct UserDefault { } } -@propertyWrapper @MainActor +@propertyWrapper public struct UserDefaultObject { private let key: String private let defaultValue: T @@ -105,7 +105,6 @@ public struct HomePreferences: Codable, Sendable, Equatable { } } -@MainActor public enum Preferences { /// the currently applied settings set from storedHomes @UserDefaultObject("currentHomePreferences", defaultValue: HomePreferences(id: Preferences.activeHomeId)) diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index a31dd5afd..1b9e5f466 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -13,6 +13,7 @@ 2F6412EE2CE494A80039FB28 /* DatePickerUITableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F6412ED2CE494A80039FB28 /* DatePickerUITableViewCell.swift */; }; 2FBCF58C2DEB0B7700CD5D83 /* HomeSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FBCF58B2DEB0B7700CD5D83 /* HomeSelectionView.swift */; }; 2FEFD8F62BE7C5BE00E387B9 /* TextInputUITableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FEFD8F52BE7C5BE00E387B9 /* TextInputUITableViewCell.swift */; }; + 2FF459362E230C6A00C0B640 /* OpenHABIntentHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FF459352E230C6A00C0B640 /* OpenHABIntentHelper.swift */; }; 4D6470DA2561F935007B03FC /* openHABIntents.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 4D6470D32561F935007B03FC /* openHABIntents.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 653B54C2285E714900298ECD /* OpenHABViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 653B54C1285E714900298ECD /* OpenHABViewController.swift */; }; 65570A7D2476D16A00D524EA /* OpenHABWebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65570A7C2476D16A00D524EA /* OpenHABWebViewController.swift */; }; @@ -281,6 +282,7 @@ 2F6412ED2CE494A80039FB28 /* DatePickerUITableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatePickerUITableViewCell.swift; sourceTree = ""; }; 2FBCF58B2DEB0B7700CD5D83 /* HomeSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeSelectionView.swift; sourceTree = ""; }; 2FEFD8F52BE7C5BE00E387B9 /* TextInputUITableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextInputUITableViewCell.swift; sourceTree = ""; }; + 2FF459352E230C6A00C0B640 /* OpenHABIntentHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenHABIntentHelper.swift; sourceTree = ""; }; 4D38D951256897490039DA6E /* SetNumberValueIntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetNumberValueIntentHandler.swift; sourceTree = ""; }; 4D38D959256897770039DA6E /* SetStringValueIntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetStringValueIntentHandler.swift; sourceTree = ""; }; 4D38D9612568978E0039DA6E /* SetColorValueIntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetColorValueIntentHandler.swift; sourceTree = ""; }; @@ -629,6 +631,7 @@ 4D38D959256897770039DA6E /* SetStringValueIntentHandler.swift */, 4D38D9612568978E0039DA6E /* SetColorValueIntentHandler.swift */, 4D38D969256897AD0039DA6E /* SetContactStateValueIntentHandler.swift */, + 2FF459352E230C6A00C0B640 /* OpenHABIntentHelper.swift */, ); path = openHABIntents; sourceTree = ""; @@ -712,13 +715,6 @@ path = Extension; sourceTree = ""; }; - DA0DA9E02E0C9B51000C5D0A /* New Group */ = { - isa = PBXGroup; - children = ( - ); - path = "New Group"; - sourceTree = ""; - }; DA1C2E4A230DC28F00FACFB0 /* fastlane */ = { isa = PBXGroup; children = ( @@ -989,7 +985,6 @@ DFB2621E18830A3600D3244D = { isa = PBXGroup; children = ( - DA0DA9E02E0C9B51000C5D0A /* New Group */, 6557AF8E2C0241C10094D0C8 /* PrivacyInfo.xcprivacy */, DA4D4DB4233F9ACB00B37E37 /* README.md */, DA4D4E0E2340A00200B37E37 /* Changes.md */, @@ -1490,6 +1485,7 @@ buildActionMask = 2147483647; files = ( 937C8B0C2800A738009C055E /* Intents.intentdefinition in Sources */, + 2FF459362E230C6A00C0B640 /* OpenHABIntentHelper.swift in Sources */, 93AEE42B27D9D796008EB207 /* SetContactStateValueIntentHandler.swift in Sources */, 93AEE42927D9D790008EB207 /* SetStringValueIntentHandler.swift in Sources */, 93AEE42527D9D76E008EB207 /* IntentHandler.swift in Sources */, diff --git a/openHAB/HomeSelectionView.swift b/openHAB/HomeSelectionView.swift index 7a350004e..741c6710b 100644 --- a/openHAB/HomeSelectionView.swift +++ b/openHAB/HomeSelectionView.swift @@ -84,7 +84,7 @@ struct HomeSelectionView: View { } } } - .alert("Enter new name for home \(homeNameForAlert)", isPresented: $showingRenameHomeAlert) { + .alert("Enter new name for home \(homeNameForAlert)", isPresented: $showingRenameHomeAlert, actions: { TextField("New name", text: $newHomeName) HStack { Button("Abort", role: .cancel) { @@ -95,7 +95,9 @@ struct HomeSelectionView: View { showingRenameHomeAlert.toggle() } } - } + }, message: { + Text("Warning: Renaming the home might cause external integrations like shortcuts to fail until reconfigured") + }) .alert("Delete home \(homeNameForAlert)?", isPresented: $showingDeleteHomeAlert) { HStack { Button("Abort", role: .cancel) { diff --git a/openHAB/Resources/Base.lproj/Intents.intentdefinition b/openHAB/Resources/Base.lproj/Intents.intentdefinition index 73f3b0574..c93e0564e 100644 --- a/openHAB/Resources/Base.lproj/Intents.intentdefinition +++ b/openHAB/Resources/Base.lproj/Intents.intentdefinition @@ -9,11 +9,11 @@ INIntentDefinitionNamespace aK4nIm INIntentDefinitionSystemVersion - 21C52 + 24F74 INIntentDefinitionToolsBuildVersion - 13C100 + 16F6 INIntentDefinitionToolsVersion - 13.2.1 + 16.4 INIntents @@ -32,10 +32,10 @@ INIntentKeyParameter item INIntentLastParameterTag - 1 + 5 INIntentManagedParameterCombinations - item + item,home INIntentParameterCombinationSupportsBackgroundExecution @@ -95,6 +95,85 @@ INIntentParameterType String + + INIntentParameterConfigurable + + INIntentParameterCustomDisambiguation + + INIntentParameterDisplayName + home + INIntentParameterDisplayNameID + k6pj4o + INIntentParameterDisplayPriority + 2 + INIntentParameterName + home + INIntentParameterObjectType + Home + INIntentParameterObjectTypeNamespace + aK4nIm + INIntentParameterPromptDialogs + + + INIntentParameterPromptDialogCustom + + INIntentParameterPromptDialogFormatString + Home name + INIntentParameterPromptDialogFormatStringID + P9kblb + INIntentParameterPromptDialogType + Configuration + + + INIntentParameterPromptDialogCustom + + INIntentParameterPromptDialogFormatString + blabla + INIntentParameterPromptDialogFormatStringID + rsrXB9 + INIntentParameterPromptDialogType + Primary + + + INIntentParameterPromptDialogCustom + + INIntentParameterPromptDialogFormatString + There are ${count} configured homes with an item named '${item}'’. + INIntentParameterPromptDialogFormatStringID + 7BE642 + INIntentParameterPromptDialogType + DisambiguationIntroduction + + + INIntentParameterPromptDialogCustom + + INIntentParameterPromptDialogFormatString + For which home do you want to get the value? + INIntentParameterPromptDialogFormatStringID + 4WCzFG + INIntentParameterPromptDialogType + DisambiguationSelection + + + INIntentParameterPromptDialogCustom + + INIntentParameterPromptDialogFormatString + Just to confirm, you wanted ‘${home}’? + INIntentParameterPromptDialogFormatStringID + iKSmqU + INIntentParameterPromptDialogType + Confirmation + + + INIntentParameterSupportsDynamicEnumeration + + INIntentParameterSupportsResolution + + INIntentParameterTag + 5 + INIntentParameterType + Object + INIntentResponse @@ -178,10 +257,10 @@ INIntentKeyParameter item INIntentLastParameterTag - 2 + 4 INIntentManagedParameterCombinations - item,action + item,action,home INIntentParameterCombinationSupportsBackgroundExecution @@ -197,16 +276,18 @@ SetSwitchState INIntentParameterCombinations - item,action + item,action,home INIntentParameterCombinationIsLinked + INIntentParameterCombinationIsPrimary + INIntentParameterCombinationSupportsBackgroundExecution INIntentParameterCombinationTitle Send ${action} to ${item} INIntentParameterCombinationTitleID - IpbXHW + jyXazc INIntentParameters @@ -299,6 +380,81 @@ INIntentParameterType String + + INIntentParameterConfigurable + + INIntentParameterCustomDisambiguation + + INIntentParameterDisplayName + home + INIntentParameterDisplayNameID + X9cN9h + INIntentParameterDisplayPriority + 3 + INIntentParameterName + home + INIntentParameterObjectType + Home + INIntentParameterObjectTypeNamespace + aK4nIm + INIntentParameterPromptDialogs + + + INIntentParameterPromptDialogCustom + + INIntentParameterPromptDialogFormatString + Home name + INIntentParameterPromptDialogFormatStringID + v3hsWV + INIntentParameterPromptDialogType + Configuration + + + INIntentParameterPromptDialogCustom + + INIntentParameterPromptDialogType + Primary + + + INIntentParameterPromptDialogCustom + + INIntentParameterPromptDialogFormatString + There are ${count} configured homes with an item named '${item}'’. + INIntentParameterPromptDialogFormatStringID + YUoeAL + INIntentParameterPromptDialogType + DisambiguationIntroduction + + + INIntentParameterPromptDialogCustom + + INIntentParameterPromptDialogFormatString + For which home do you want to get the value? + INIntentParameterPromptDialogFormatStringID + h91G9C + INIntentParameterPromptDialogType + DisambiguationSelection + + + INIntentParameterPromptDialogCustom + + INIntentParameterPromptDialogFormatString + Just to confirm, you wanted ‘${home}’? + INIntentParameterPromptDialogFormatStringID + Jn2aNw + INIntentParameterPromptDialogType + Confirmation + + + INIntentParameterSupportsDynamicEnumeration + + INIntentParameterSupportsResolution + + INIntentParameterTag + 4 + INIntentParameterType + Object + INIntentResponse @@ -392,10 +548,10 @@ INIntentKeyParameter item INIntentLastParameterTag - 3 + 5 INIntentManagedParameterCombinations - item,value + item,value,home INIntentParameterCombinationSupportsBackgroundExecution @@ -411,16 +567,18 @@ SetDimmerRollerValue INIntentParameterCombinations - item,value + item,value,home INIntentParameterCombinationIsLinked + INIntentParameterCombinationIsPrimary + INIntentParameterCombinationSupportsBackgroundExecution INIntentParameterCombinationTitle Set ${item} to ${value} INIntentParameterCombinationTitleID - Cdg015 + Ch9Akw INIntentParameters @@ -507,6 +665,81 @@ INIntentParameterType Integer + + INIntentParameterConfigurable + + INIntentParameterCustomDisambiguation + + INIntentParameterDisplayName + home + INIntentParameterDisplayNameID + ZuBsOg + INIntentParameterDisplayPriority + 3 + INIntentParameterName + home + INIntentParameterObjectType + Home + INIntentParameterObjectTypeNamespace + aK4nIm + INIntentParameterPromptDialogs + + + INIntentParameterPromptDialogCustom + + INIntentParameterPromptDialogFormatString + Home name + INIntentParameterPromptDialogFormatStringID + rVXwR2 + INIntentParameterPromptDialogType + Configuration + + + INIntentParameterPromptDialogCustom + + INIntentParameterPromptDialogType + Primary + + + INIntentParameterPromptDialogCustom + + INIntentParameterPromptDialogFormatString + There are ${count} configured homes with an item named '${item}'’. + INIntentParameterPromptDialogFormatStringID + BaNYuS + INIntentParameterPromptDialogType + DisambiguationIntroduction + + + INIntentParameterPromptDialogCustom + + INIntentParameterPromptDialogFormatString + For which home do you want to get the value? + INIntentParameterPromptDialogFormatStringID + QATq1i + INIntentParameterPromptDialogType + DisambiguationSelection + + + INIntentParameterPromptDialogCustom + + INIntentParameterPromptDialogFormatString + Just to confirm, you wanted ‘${home}’? + INIntentParameterPromptDialogFormatStringID + exrmKW + INIntentParameterPromptDialogType + Confirmation + + + INIntentParameterSupportsDynamicEnumeration + + INIntentParameterSupportsResolution + + INIntentParameterTag + 5 + INIntentParameterType + Object + INIntentResponse @@ -608,10 +841,10 @@ INIntentKeyParameter item INIntentLastParameterTag - 4 + 6 INIntentManagedParameterCombinations - item,value + item,value,home INIntentParameterCombinationSupportsBackgroundExecution @@ -627,16 +860,18 @@ SetNumberValue INIntentParameterCombinations - item,value + item,value,home INIntentParameterCombinationIsLinked + INIntentParameterCombinationIsPrimary + INIntentParameterCombinationSupportsBackgroundExecution INIntentParameterCombinationTitle Set ${item} to ${value} INIntentParameterCombinationTitleID - Gjd9MH + qYPBPC INIntentParameters @@ -723,6 +958,81 @@ INIntentParameterType Decimal + + INIntentParameterConfigurable + + INIntentParameterCustomDisambiguation + + INIntentParameterDisplayName + home + INIntentParameterDisplayNameID + zLlY3g + INIntentParameterDisplayPriority + 3 + INIntentParameterName + home + INIntentParameterObjectType + Home + INIntentParameterObjectTypeNamespace + aK4nIm + INIntentParameterPromptDialogs + + + INIntentParameterPromptDialogCustom + + INIntentParameterPromptDialogFormatString + Home name + INIntentParameterPromptDialogFormatStringID + IS2nO0 + INIntentParameterPromptDialogType + Configuration + + + INIntentParameterPromptDialogCustom + + INIntentParameterPromptDialogType + Primary + + + INIntentParameterPromptDialogCustom + + INIntentParameterPromptDialogFormatString + There are ${count} configured homes with an item named '${item}'’. + INIntentParameterPromptDialogFormatStringID + lLFYtM + INIntentParameterPromptDialogType + DisambiguationIntroduction + + + INIntentParameterPromptDialogCustom + + INIntentParameterPromptDialogFormatString + For which home do you want to get the value? + INIntentParameterPromptDialogFormatStringID + jbUbYw + INIntentParameterPromptDialogType + DisambiguationSelection + + + INIntentParameterPromptDialogCustom + + INIntentParameterPromptDialogFormatString + Just to confirm, you wanted ‘${home}’? + INIntentParameterPromptDialogFormatStringID + VEApie + INIntentParameterPromptDialogType + Confirmation + + + INIntentParameterSupportsDynamicEnumeration + + INIntentParameterSupportsResolution + + INIntentParameterTag + 6 + INIntentParameterType + Object + INIntentResponse @@ -816,10 +1126,10 @@ INIntentKeyParameter item INIntentLastParameterTag - 5 + 7 INIntentManagedParameterCombinations - item,value + item,value,home INIntentParameterCombinationSupportsBackgroundExecution @@ -835,16 +1145,18 @@ SetStringValue INIntentParameterCombinations - item,value + item,value,home INIntentParameterCombinationIsLinked + INIntentParameterCombinationIsPrimary + INIntentParameterCombinationSupportsBackgroundExecution INIntentParameterCombinationTitle Set ${item} to ${value} INIntentParameterCombinationTitleID - EEj00Q + 1cBKdm INIntentParameters @@ -937,6 +1249,81 @@ INIntentParameterType String + + INIntentParameterConfigurable + + INIntentParameterCustomDisambiguation + + INIntentParameterDisplayName + home + INIntentParameterDisplayNameID + 71MWtI + INIntentParameterDisplayPriority + 3 + INIntentParameterName + home + INIntentParameterObjectType + Home + INIntentParameterObjectTypeNamespace + aK4nIm + INIntentParameterPromptDialogs + + + INIntentParameterPromptDialogCustom + + INIntentParameterPromptDialogFormatString + Home name + INIntentParameterPromptDialogFormatStringID + YFIIBn + INIntentParameterPromptDialogType + Configuration + + + INIntentParameterPromptDialogCustom + + INIntentParameterPromptDialogType + Primary + + + INIntentParameterPromptDialogCustom + + INIntentParameterPromptDialogFormatString + There are ${count} configured homes with an item named '${item}'’. + INIntentParameterPromptDialogFormatStringID + MIqlld + INIntentParameterPromptDialogType + DisambiguationIntroduction + + + INIntentParameterPromptDialogCustom + + INIntentParameterPromptDialogFormatString + For which home do you want to get the value? + INIntentParameterPromptDialogFormatStringID + 3LMXV8 + INIntentParameterPromptDialogType + DisambiguationSelection + + + INIntentParameterPromptDialogCustom + + INIntentParameterPromptDialogFormatString + Just to confirm, you wanted ‘${home}’? + INIntentParameterPromptDialogFormatStringID + gobcZi + INIntentParameterPromptDialogType + Confirmation + + + INIntentParameterSupportsDynamicEnumeration + + INIntentParameterSupportsResolution + + INIntentParameterTag + 7 + INIntentParameterType + Object + INIntentResponse @@ -1030,10 +1417,10 @@ INIntentKeyParameter item INIntentLastParameterTag - 5 + 7 INIntentManagedParameterCombinations - item,value + item,value,home INIntentParameterCombinationSupportsBackgroundExecution @@ -1049,16 +1436,18 @@ SetColorValue INIntentParameterCombinations - item,value + item,value,home INIntentParameterCombinationIsLinked + INIntentParameterCombinationIsPrimary + INIntentParameterCombinationSupportsBackgroundExecution INIntentParameterCombinationTitle Set ${item} to ${value} (HSB) INIntentParameterCombinationTitleID - 1SeS3V + E07xjp INIntentParameters @@ -1153,6 +1542,81 @@ INIntentParameterType String + + INIntentParameterConfigurable + + INIntentParameterCustomDisambiguation + + INIntentParameterDisplayName + home + INIntentParameterDisplayNameID + iBrALm + INIntentParameterDisplayPriority + 3 + INIntentParameterName + home + INIntentParameterObjectType + Home + INIntentParameterObjectTypeNamespace + aK4nIm + INIntentParameterPromptDialogs + + + INIntentParameterPromptDialogCustom + + INIntentParameterPromptDialogFormatString + Home name + INIntentParameterPromptDialogFormatStringID + eFNxrT + INIntentParameterPromptDialogType + Configuration + + + INIntentParameterPromptDialogCustom + + INIntentParameterPromptDialogType + Primary + + + INIntentParameterPromptDialogCustom + + INIntentParameterPromptDialogFormatString + There are ${count} configured homes with an item named '${item}'’. + INIntentParameterPromptDialogFormatStringID + jP4VSa + INIntentParameterPromptDialogType + DisambiguationIntroduction + + + INIntentParameterPromptDialogCustom + + INIntentParameterPromptDialogFormatString + For which home do you want to get the value? + INIntentParameterPromptDialogFormatStringID + mHbznY + INIntentParameterPromptDialogType + DisambiguationSelection + + + INIntentParameterPromptDialogCustom + + INIntentParameterPromptDialogFormatString + Just to confirm, you wanted ‘${home}’? + INIntentParameterPromptDialogFormatStringID + tkWfxz + INIntentParameterPromptDialogType + Confirmation + + + INIntentParameterSupportsDynamicEnumeration + + INIntentParameterSupportsResolution + + INIntentParameterTag + 7 + INIntentParameterType + Object + INIntentResponse @@ -1246,10 +1710,10 @@ INIntentKeyParameter item INIntentLastParameterTag - 2 + 4 INIntentManagedParameterCombinations - item,state + item,state,home INIntentParameterCombinationSupportsBackgroundExecution @@ -1265,16 +1729,18 @@ SetContactStateValue INIntentParameterCombinations - item,state + item,state,home INIntentParameterCombinationIsLinked + INIntentParameterCombinationIsPrimary + INIntentParameterCombinationSupportsBackgroundExecution INIntentParameterCombinationTitle Set the state of ${item} to ${state} INIntentParameterCombinationTitleID - qu9K9v + WsMKz2 INIntentParameters @@ -1367,6 +1833,81 @@ INIntentParameterType String + + INIntentParameterConfigurable + + INIntentParameterCustomDisambiguation + + INIntentParameterDisplayName + home + INIntentParameterDisplayNameID + 7GA2x6 + INIntentParameterDisplayPriority + 3 + INIntentParameterName + home + INIntentParameterObjectType + Home + INIntentParameterObjectTypeNamespace + aK4nIm + INIntentParameterPromptDialogs + + + INIntentParameterPromptDialogCustom + + INIntentParameterPromptDialogFormatString + Home name + INIntentParameterPromptDialogFormatStringID + Ncumus + INIntentParameterPromptDialogType + Configuration + + + INIntentParameterPromptDialogCustom + + INIntentParameterPromptDialogType + Primary + + + INIntentParameterPromptDialogCustom + + INIntentParameterPromptDialogFormatString + There are ${count} configured homes with an item named '${item}'’. + INIntentParameterPromptDialogFormatStringID + H2V4UV + INIntentParameterPromptDialogType + DisambiguationIntroduction + + + INIntentParameterPromptDialogCustom + + INIntentParameterPromptDialogFormatString + For which home do you want to get the value? + INIntentParameterPromptDialogFormatStringID + 9Pu17E + INIntentParameterPromptDialogType + DisambiguationSelection + + + INIntentParameterPromptDialogCustom + + INIntentParameterPromptDialogFormatString + Just to confirm, you wanted ‘${home}’? + INIntentParameterPromptDialogFormatStringID + 0hYnWM + INIntentParameterPromptDialogType + Confirmation + + + INIntentParameterSupportsDynamicEnumeration + + INIntentParameterSupportsResolution + + INIntentParameterTag + 4 + INIntentParameterType + Object + INIntentResponse @@ -1448,6 +1989,72 @@ INTypes - + + + INTypeClassPrefix + OpenHAB + INTypeDisplayName + Home + INTypeDisplayNameID + zV2TH2 + INTypeLastPropertyTag + 100 + INTypeName + Home + INTypeProperties + + + INTypePropertyDefault + + INTypePropertyDisplayPriority + 1 + INTypePropertyName + identifier + INTypePropertyTag + 1 + INTypePropertyType + String + + + INTypePropertyDefault + + INTypePropertyDisplayPriority + 2 + INTypePropertyName + displayString + INTypePropertyTag + 2 + INTypePropertyType + String + + + INTypePropertyDefault + + INTypePropertyDisplayPriority + 3 + INTypePropertyName + pronunciationHint + INTypePropertyTag + 3 + INTypePropertyType + String + + + INTypePropertyDefault + + INTypePropertyDisplayPriority + 4 + INTypePropertyName + alternativeSpeakableMatches + INTypePropertySupportsMultipleValues + + INTypePropertyTag + 4 + INTypePropertyType + SpeakableString + + + + diff --git a/openHABIntents/GetItemStateIntentHandler.swift b/openHABIntents/GetItemStateIntentHandler.swift index 11b357ed9..6dfc0d439 100644 --- a/openHABIntents/GetItemStateIntentHandler.swift +++ b/openHABIntents/GetItemStateIntentHandler.swift @@ -16,20 +16,22 @@ import os.log class GetItemStateIntentHandler: NSObject, OpenHABGetItemStateIntentHandling { private let logger = Logger(subsystem: "org.openhab.app", category: "GetItemStateIntent") - private let itemCache: any ItemCacheProtocol - init(itemCache: any ItemCacheProtocol = OpenHABItemCache.instance) { - self.itemCache = itemCache + func resolveHome(for intent: OpenHABGetItemStateIntent) async -> OpenHABHomeResolutionResult { + logger.info("Resolving home for intent: \(intent)") + return await OpenHABIntentHelper.resolveHome(home: intent.home, item: intent.item) + } + + func provideHomeOptionsCollection(for intent: OpenHABGetItemStateIntent) async throws -> INObjectCollection { + OpenHABIntentHelper.getHomeOptions() } func provideItemOptionsCollection(for intent: OpenHABGetItemStateIntent, searchTerm: String?) async throws -> INObjectCollection { - let items = await itemCache.getItemNames(searchTerm: searchTerm, types: nil).map(NSString.init) - return INObjectCollection(items: items) + await OpenHABIntentHelper.getItemOptions(home: intent.home, searchTerm: searchTerm) } func provideItemOptionsCollection(for intent: OpenHABGetItemStateIntent) async throws -> INObjectCollection { - let items = await itemCache.getItemNames(searchTerm: nil, types: nil).map(NSString.init) - return INObjectCollection(items: items) + await OpenHABIntentHelper.getItemOptions(home: intent.home) } func confirm(intent: OpenHABGetItemStateIntent) async -> OpenHABGetItemStateIntentResponse { @@ -38,24 +40,26 @@ class GetItemStateIntentHandler: NSObject, OpenHABGetItemStateIntentHandling { func handle(intent: OpenHABGetItemStateIntent) async -> OpenHABGetItemStateIntentResponse { logger.info("GetItemStateIntent for \(intent.item ?? "")") - await OpenHABItemCache.instance.waitUntilReady() - // Proceed to fetch item and complete - guard let itemName = intent.item else { + guard let itemName = intent.item, let home = intent.home else { return .failureInvalidItem( - NSLocalizedString("empty", comment: "empty item name") + NSLocalizedString("empty", comment: "empty item / home name") ) } - let item = await itemCache.getItem(name: itemName) + guard let homeId = home.uuid, Preferences.storedHomes[homeId] != nil else { + return .failureInvalidItem(NSLocalizedString("unknownHome", comment: "unknown home")) + } + + let items = await OpenHABItemCache.instance.getCachedItem(name: itemName, home: homeId) - guard let item else { + guard let items, items.count == 1 else { return .failureInvalidItem(itemName) } return .success( item: itemName, - state: item.state ?? NSLocalizedString("unknown", comment: "unknown item") + state: items[0].state ?? NSLocalizedString("unknownState", comment: "unknown item state") ) } } diff --git a/openHABIntents/IntentHandler.swift b/openHABIntents/IntentHandler.swift index 4e2f9c027..a41ac74cd 100644 --- a/openHABIntents/IntentHandler.swift +++ b/openHABIntents/IntentHandler.swift @@ -17,7 +17,7 @@ class IntentHandler: INExtension { super.init() Task { - await OpenHABItemCache.instance.setup() + await OpenHABItemCache.instance.forceCacheReload() } } diff --git a/openHABIntents/OpenHABIntentHelper.swift b/openHABIntents/OpenHABIntentHelper.swift new file mode 100644 index 000000000..b52ed404c --- /dev/null +++ b/openHABIntents/OpenHABIntentHelper.swift @@ -0,0 +1,73 @@ +// Copyright (c) 2010-2025 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 Foundation +import Intents +import OpenHABCore + +public enum OpenHABIntentHelper { + static func resolveHome(home: OpenHABHome?, item: String?) async -> OpenHABHomeResolutionResult { + if let home, let homeId = home.uuid { + // TODO: fuzzy matching / account for potential renaming? + // TODO: accept potential mismatches if item name is unique + let homePrefs = Preferences.storedHomes.first { $0.key == homeId } + if homePrefs != nil { + return .success(with: home) + } else { + return .unsupported() // given home is not found in preferences + } + } else if let item { + // try to find the home by home-specific item selection + let allItems = await OpenHABItemCache.instance.getAllCachedItems() + let homeIdsWithMatchingItems = allItems.map(\.key).filter { uuid in + allItems[uuid]?.filtered(by: item).isEmpty != true + } + let potentialHomes = homeIdsWithMatchingItems + .compactMap { Preferences.storedHomes[$0] } + .map { OpenHABHome(home: $0) } + if potentialHomes.count == 1 { + return .success(with: potentialHomes[0]) + } else { + return .disambiguation(with: potentialHomes) + } + } else { + return .needsValue() + } + } + + static func getHomeOptions() -> INObjectCollection { + INObjectCollection(items: Preferences.storedHomes.map { OpenHABHome(home: $0.value) }) + } + + static func getItemOptions(home: OpenHABHome?, searchTerm: String? = nil, itemTypes: [OpenHABItem.ItemType]? = nil) async -> INObjectCollection { + let allItems = await getAllItems(home: home) + let items = allItems.filtered(by: searchTerm, for: itemTypes) + return INObjectCollection(items: items.map(\.name).map { $0 as NSString }) + } + + private static func getAllItems(home: OpenHABHome?) async -> [OpenHABItem] { + if let home, let homeId = home.uuid { + await OpenHABItemCache.instance.getCachedItems(home: homeId) ?? [] + } else { + await OpenHABItemCache.instance.getAllCachedItems().flatMap(\.value) + } + } +} + +extension OpenHABHome { + var uuid: UUID? { + UUID(uuidString: identifier ?? "") + } + + convenience init(home: HomePreferences) { + self.init(identifier: home.id.uuidString, display: home.homeName) + } +} diff --git a/openHABIntents/SetColorValueIntentHandler.swift b/openHABIntents/SetColorValueIntentHandler.swift index 97bfae34e..2093b6ac7 100644 --- a/openHABIntents/SetColorValueIntentHandler.swift +++ b/openHABIntents/SetColorValueIntentHandler.swift @@ -16,24 +16,22 @@ import os.log class SetColorValueIntentHandler: NSObject, OpenHABSetColorValueIntentHandling { private let logger = Logger(subsystem: "org.openhab.app", category: "SetColorValueIntent") - private let itemCache: any ItemCacheProtocol - init(itemCache: any ItemCacheProtocol = OpenHABItemCache.instance) { - self.itemCache = itemCache + func resolveHome(for intent: OpenHABSetColorValueIntent) async -> OpenHABHomeResolutionResult { + logger.info("Resolving home for intent: \(intent)") + return await OpenHABIntentHelper.resolveHome(home: intent.home, item: intent.item) + } + + func provideHomeOptionsCollection(for intent: OpenHABSetColorValueIntent) async throws -> INObjectCollection { + OpenHABIntentHelper.getHomeOptions() } func provideItemOptionsCollection(for intent: OpenHABSetColorValueIntent, searchTerm: String?) async throws -> INObjectCollection { - let items = await itemCache - .getItemNames(searchTerm: searchTerm, types: [.color]) - .map(NSString.init) - return INObjectCollection(items: items) + await OpenHABIntentHelper.getItemOptions(home: intent.home, searchTerm: searchTerm, itemTypes: [.color]) } func provideItemOptionsCollection(for intent: OpenHABSetColorValueIntent) async throws -> INObjectCollection { - let items = await itemCache - .getItemNames(searchTerm: nil, types: [.color]) - .map(NSString.init) - return INObjectCollection(items: items) + await OpenHABIntentHelper.getItemOptions(home: intent.home, itemTypes: [.color]) } func confirm(intent: OpenHABSetColorValueIntent) async -> OpenHABSetColorValueIntentResponse { @@ -43,10 +41,14 @@ class SetColorValueIntentHandler: NSObject, OpenHABSetColorValueIntentHandling { func handle(intent: OpenHABSetColorValueIntent) async -> OpenHABSetColorValueIntentResponse { logger.info("SetColorValueIntent for \(intent.item ?? "")") - await OpenHABItemCache.instance.waitUntilReady() + guard let itemName = intent.item, let home = intent.home else { + return .failureInvalidItem( + NSLocalizedString("empty", comment: "empty item / home name") + ) + } - guard let itemName = intent.item else { - return .failureInvalidItem(NSLocalizedString("empty", comment: "empty item name")) + guard let homeId = home.uuid, Preferences.storedHomes[homeId] != nil else { + return .failureInvalidItem(NSLocalizedString("unknownHome", comment: "unknown home")) } guard var value = intent.value else { @@ -66,11 +68,13 @@ class SetColorValueIntentHandler: NSObject, OpenHABSetColorValueIntentHandling { value = "\(hue),\(sat),\(val)" - guard let item = await itemCache.getItem(name: itemName) else { + guard let items = await OpenHABItemCache.instance.getCachedItem(name: itemName, home: homeId), !items.isEmpty else { return .failureInvalidItem(itemName) } - await itemCache.sendCommand(item, commandToSend: value) + let item = items[0] + + await OpenHABItemCache.instance.sendCommand(to: item, home: homeId, command: value) return .success(value: value, item: itemName) } diff --git a/openHABIntents/SetContactStateValueIntentHandler.swift b/openHABIntents/SetContactStateValueIntentHandler.swift index f7ec4a730..a277a759b 100644 --- a/openHABIntents/SetContactStateValueIntentHandler.swift +++ b/openHABIntents/SetContactStateValueIntentHandler.swift @@ -25,10 +25,14 @@ class SetContactStateValueIntentHandler: NSObject, OpenHABSetContactStateValueIn ] private let logger = Logger(subsystem: "org.openhab.app", category: "SetColorValueIntent") - private let itemCache: any ItemCacheProtocol - init(itemCache: any ItemCacheProtocol = OpenHABItemCache.instance) { - self.itemCache = itemCache + func resolveHome(for intent: OpenHABSetContactStateValueIntent) async -> OpenHABHomeResolutionResult { + logger.info("Resolving home for intent: \(intent)") + return await OpenHABIntentHelper.resolveHome(home: intent.home, item: intent.item) + } + + func provideHomeOptionsCollection(for intent: OpenHABSetContactStateValueIntent) async throws -> INObjectCollection { + OpenHABIntentHelper.getHomeOptions() } func provideStateOptionsCollection(for intent: OpenHABSetContactStateValueIntent) async throws -> INObjectCollection { @@ -36,17 +40,11 @@ class SetContactStateValueIntentHandler: NSObject, OpenHABSetContactStateValueIn } func provideItemOptionsCollection(for intent: OpenHABSetContactStateValueIntent, searchTerm: String?) async throws -> INObjectCollection { - let items = await itemCache - .getItemNames(searchTerm: searchTerm, types: [.contact]) - .map(NSString.init) - return INObjectCollection(items: items) + await OpenHABIntentHelper.getItemOptions(home: intent.home, searchTerm: searchTerm, itemTypes: [.contact]) } func provideItemOptionsCollection(for intent: OpenHABSetContactStateValueIntent) async throws -> INObjectCollection { - let items = await itemCache - .getItemNames(searchTerm: nil, types: [.contact]) - .map(NSString.init) - return INObjectCollection(items: items) + await OpenHABIntentHelper.getItemOptions(home: intent.home, itemTypes: [.contact]) } func confirm(intent: OpenHABSetContactStateValueIntent) async -> OpenHABSetContactStateValueIntentResponse { @@ -56,10 +54,14 @@ class SetContactStateValueIntentHandler: NSObject, OpenHABSetContactStateValueIn func handle(intent: OpenHABSetContactStateValueIntent) async -> OpenHABSetContactStateValueIntentResponse { logger.info("SetContactStateValueIntent for \(intent.item ?? "")") - await OpenHABItemCache.instance.waitUntilReady() + guard let itemName = intent.item, let home = intent.home else { + return .failureInvalidItem( + NSLocalizedString("empty", comment: "empty item / home name") + ) + } - guard let itemName = intent.item else { - return .failureInvalidItem(NSLocalizedString("empty", comment: "empty item name")) + guard let homeId = home.uuid, Preferences.storedHomes[homeId] != nil else { + return .failureInvalidItem(NSLocalizedString("unknownHome", comment: "unknown home")) } guard let state = intent.state else { @@ -73,11 +75,13 @@ class SetContactStateValueIntentHandler: NSObject, OpenHABSetContactStateValueIn return .failureInvalidAction(state: state, item: itemName) } - guard let item = await itemCache.getItem(name: itemName) else { + guard let items = await OpenHABItemCache.instance.getCachedItem(name: itemName, home: homeId), !items.isEmpty else { return .failureInvalidItem(itemName) } - await itemCache.sendState(item, stateToSend: realState) + let item = items[0] + + await OpenHABItemCache.instance.sendCommand(to: item, home: homeId, command: realState) return .success(item: itemName, state: state) } diff --git a/openHABIntents/SetDimmerRollerValueIntentHandler.swift b/openHABIntents/SetDimmerRollerValueIntentHandler.swift index d952c4f2e..02ac89b32 100644 --- a/openHABIntents/SetDimmerRollerValueIntentHandler.swift +++ b/openHABIntents/SetDimmerRollerValueIntentHandler.swift @@ -16,28 +16,22 @@ import os.log class SetDimmerRollerValueIntentHandler: NSObject, OpenHABSetDimmerRollerValueIntentHandling { private let logger = Logger(subsystem: "org.openhab.app", category: "SetDimmerRollerValueIntent") - private let itemCache: any ItemCacheProtocol - init(itemCache: any ItemCacheProtocol = OpenHABItemCache.instance) { - self.itemCache = itemCache + func resolveHome(for intent: OpenHABSetDimmerRollerValueIntent) async -> OpenHABHomeResolutionResult { + logger.info("Resolving home for intent: \(intent)") + return await OpenHABIntentHelper.resolveHome(home: intent.home, item: intent.item) + } + + func provideHomeOptionsCollection(for intent: OpenHABSetDimmerRollerValueIntent) async throws -> INObjectCollection { + OpenHABIntentHelper.getHomeOptions() } func provideItemOptionsCollection(for intent: OpenHABSetDimmerRollerValueIntent, searchTerm: String?) async throws -> INObjectCollection { - let items = await itemCache.getItemNames( - searchTerm: searchTerm, - types: [.dimmer, .rollershutter] - ) - .map(NSString.init) - return INObjectCollection(items: items) + await OpenHABIntentHelper.getItemOptions(home: intent.home, searchTerm: searchTerm, itemTypes: [.dimmer, .rollershutter]) } func provideItemOptionsCollection(for intent: OpenHABSetDimmerRollerValueIntent) async throws -> INObjectCollection { - let items = await itemCache.getItemNames( - searchTerm: nil, - types: [.dimmer, .rollershutter] - ) - .map(NSString.init) - return INObjectCollection(items: items) + await OpenHABIntentHelper.getItemOptions(home: intent.home, itemTypes: [.dimmer, .rollershutter]) } func confirm(intent: OpenHABSetDimmerRollerValueIntent) async -> OpenHABSetDimmerRollerValueIntentResponse { @@ -47,14 +41,16 @@ class SetDimmerRollerValueIntentHandler: NSObject, OpenHABSetDimmerRollerValueIn func handle(intent: OpenHABSetDimmerRollerValueIntent) async -> OpenHABSetDimmerRollerValueIntentResponse { logger.info("SetDimmerRollerValueIntent for \(intent.item ?? "")") - await OpenHABItemCache.instance.waitUntilReady() - - guard let itemName = intent.item else { + guard let itemName = intent.item, let home = intent.home else { return .failureInvalidItem( - NSLocalizedString("empty", comment: "empty item name") + NSLocalizedString("empty", comment: "empty item / home name") ) } + guard let homeId = home.uuid, Preferences.storedHomes[homeId] != nil else { + return .failureInvalidItem(NSLocalizedString("unknownHome", comment: "unknown home")) + } + guard let value = intent.value else { return .failureEmptyValue(item: itemName) } @@ -65,11 +61,13 @@ class SetDimmerRollerValueIntentHandler: NSObject, OpenHABSetDimmerRollerValueIn return .failureInvalidValue(value, item: itemName) } - guard let item = await itemCache.getItem(name: itemName) else { + guard let items = await OpenHABItemCache.instance.getCachedItem(name: itemName, home: homeId), !items.isEmpty else { return .failureInvalidItem(itemName) } - await itemCache.sendCommand(item, commandToSend: "\(number)") + let item = items[0] + + await OpenHABItemCache.instance.sendCommand(to: item, home: homeId, command: "\(number)") return .success(value: NSNumber(value: number), item: itemName) } diff --git a/openHABIntents/SetNumberValueIntentHandler.swift b/openHABIntents/SetNumberValueIntentHandler.swift index de6203ca0..232d9ac6f 100644 --- a/openHABIntents/SetNumberValueIntentHandler.swift +++ b/openHABIntents/SetNumberValueIntentHandler.swift @@ -16,24 +16,22 @@ import os.log class SetNumberValueIntentHandler: NSObject, OpenHABSetNumberValueIntentHandling { private let logger = Logger(subsystem: "org.openhab.app", category: "SetNumberValueIntent") - private let itemCache: any ItemCacheProtocol - init(itemCache: any ItemCacheProtocol = OpenHABItemCache.instance) { - self.itemCache = itemCache + func resolveHome(for intent: OpenHABSetNumberValueIntent) async -> OpenHABHomeResolutionResult { + logger.info("Resolving home for intent: \(intent)") + return await OpenHABIntentHelper.resolveHome(home: intent.home, item: intent.item) + } + + func provideHomeOptionsCollection(for intent: OpenHABSetNumberValueIntent) async throws -> INObjectCollection { + OpenHABIntentHelper.getHomeOptions() } func provideItemOptionsCollection(for intent: OpenHABSetNumberValueIntent, searchTerm: String?) async throws -> INObjectCollection { - let items = await itemCache - .getItemNames(searchTerm: searchTerm, types: [.number]) - .map(NSString.init) - return INObjectCollection(items: items) + await OpenHABIntentHelper.getItemOptions(home: intent.home, searchTerm: searchTerm, itemTypes: [.number]) } func provideItemOptionsCollection(for intent: OpenHABSetNumberValueIntent) async throws -> INObjectCollection { - let items = await itemCache - .getItemNames(searchTerm: nil, types: [.number]) - .map(NSString.init) - return INObjectCollection(items: items) + await OpenHABIntentHelper.getItemOptions(home: intent.home, itemTypes: [.number]) } func confirm(intent: OpenHABSetNumberValueIntent) async -> OpenHABSetNumberValueIntentResponse { @@ -43,23 +41,27 @@ class SetNumberValueIntentHandler: NSObject, OpenHABSetNumberValueIntentHandling func handle(intent: OpenHABSetNumberValueIntent) async -> OpenHABSetNumberValueIntentResponse { logger.info("SetNumberValueIntent for \(intent.item ?? "")") - await OpenHABItemCache.instance.waitUntilReady() - - guard let itemName = intent.item else { + guard let itemName = intent.item, let home = intent.home else { return .failureInvalidItem( - NSLocalizedString("empty", comment: "empty item name") + NSLocalizedString("empty", comment: "empty item / home name") ) } + guard let homeId = home.uuid, Preferences.storedHomes[homeId] != nil else { + return .failureInvalidItem(NSLocalizedString("unknownHome", comment: "unknown home")) + } + guard let value = intent.value else { return .failureEmptyValue(item: itemName) } - guard let item = await itemCache.getItem(name: itemName) else { + guard let items = await OpenHABItemCache.instance.getCachedItem(name: itemName, home: homeId), !items.isEmpty else { return .failureInvalidItem(itemName) } - await itemCache.sendCommand(item, commandToSend: value.stringValue) + let item = items[0] + + await OpenHABItemCache.instance.sendCommand(to: item, home: homeId, command: value.stringValue) return .success(value: value, item: itemName) } diff --git a/openHABIntents/SetStringValueIntentHandler.swift b/openHABIntents/SetStringValueIntentHandler.swift index 830192a24..678e40287 100644 --- a/openHABIntents/SetStringValueIntentHandler.swift +++ b/openHABIntents/SetStringValueIntentHandler.swift @@ -16,24 +16,22 @@ import os.log class SetStringValueIntentHandler: NSObject, OpenHABSetStringValueIntentHandling { private let logger = Logger(subsystem: "org.openhab.app", category: "SetStringValueIntent") - private let itemCache: any ItemCacheProtocol - init(itemCache: any ItemCacheProtocol = OpenHABItemCache.instance) { - self.itemCache = itemCache + func resolveHome(for intent: OpenHABSetStringValueIntent) async -> OpenHABHomeResolutionResult { + logger.info("Resolving home for intent: \(intent)") + return await OpenHABIntentHelper.resolveHome(home: intent.home, item: intent.item) + } + + func provideHomeOptionsCollection(for intent: OpenHABSetStringValueIntent) async throws -> INObjectCollection { + OpenHABIntentHelper.getHomeOptions() } func provideItemOptionsCollection(for intent: OpenHABSetStringValueIntent, searchTerm: String?) async throws -> INObjectCollection { - let items = await itemCache - .getItemNames(searchTerm: searchTerm, types: [.stringItem]) - .map(NSString.init) - return INObjectCollection(items: items) + await OpenHABIntentHelper.getItemOptions(home: intent.home, searchTerm: searchTerm, itemTypes: [.stringItem]) } func provideItemOptionsCollection(for intent: OpenHABSetStringValueIntent) async throws -> INObjectCollection { - let items = await itemCache - .getItemNames(searchTerm: nil, types: [.stringItem]) - .map(NSString.init) - return INObjectCollection(items: items) + await OpenHABIntentHelper.getItemOptions(home: intent.home, itemTypes: [.stringItem]) } func confirm(intent: OpenHABSetStringValueIntent) async -> OpenHABSetStringValueIntentResponse { @@ -43,23 +41,27 @@ class SetStringValueIntentHandler: NSObject, OpenHABSetStringValueIntentHandling func handle(intent: OpenHABSetStringValueIntent) async -> OpenHABSetStringValueIntentResponse { logger.info("SetStringValueIntent for \(intent.item ?? "")") - await OpenHABItemCache.instance.waitUntilReady() - - guard let itemName = intent.item else { + guard let itemName = intent.item, let home = intent.home else { return .failureInvalidItem( - NSLocalizedString("empty", comment: "empty item name") + NSLocalizedString("empty", comment: "empty item / home name") ) } + guard let homeId = home.uuid, Preferences.storedHomes[homeId] != nil else { + return .failureInvalidItem(NSLocalizedString("unknownHome", comment: "unknown home")) + } + guard let value = intent.value else { return .failureEmptyValue(item: itemName) } - guard let item = await itemCache.getItem(name: itemName) else { + guard let items = await OpenHABItemCache.instance.getCachedItem(name: itemName, home: homeId), !items.isEmpty else { return .failureInvalidItem(itemName) } - await itemCache.sendCommand(item, commandToSend: value) + let item = items[0] + + await OpenHABItemCache.instance.sendCommand(to: item, home: homeId, command: value) return .success(value: value, item: itemName) } diff --git a/openHABIntents/SetSwitchStateIntentHandler.swift b/openHABIntents/SetSwitchStateIntentHandler.swift index 48782f21e..95950fd20 100644 --- a/openHABIntents/SetSwitchStateIntentHandler.swift +++ b/openHABIntents/SetSwitchStateIntentHandler.swift @@ -26,10 +26,13 @@ final class SetSwitchStateIntentHandler: NSObject, OpenHABSetSwitchStateIntentHa private let logger = Logger(subsystem: "org.openhab.app", category: "SetSwitchStateIntent") - private let itemCache: any ItemCacheProtocol + func resolveHome(for intent: OpenHABSetSwitchStateIntent) async -> OpenHABHomeResolutionResult { + logger.info("Resolving home for intent: \(intent)") + return await OpenHABIntentHelper.resolveHome(home: intent.home, item: intent.item) + } - init(itemCache: any ItemCacheProtocol = OpenHABItemCache.instance) { - self.itemCache = itemCache + func provideHomeOptionsCollection(for intent: OpenHABSetSwitchStateIntent) async throws -> INObjectCollection { + OpenHABIntentHelper.getHomeOptions() } func provideActionOptionsCollection(for intent: OpenHABSetSwitchStateIntent) async throws -> INObjectCollection { @@ -39,21 +42,12 @@ final class SetSwitchStateIntentHandler: NSObject, OpenHABSetSwitchStateIntentHa func provideItemOptionsCollection(for intent: OpenHABSetSwitchStateIntent, searchTerm: String?) async throws -> INObjectCollection { logger.info("SetSwitchStateIntentHandler provideItemOptionsCollection with searchTerm: \(searchTerm ?? "", privacy: .public)") - - let itemNames = await itemCache - .getItemNames( - searchTerm: searchTerm, - types: [.switchItem] - ) - .map(NSString.init) - - return INObjectCollection(items: itemNames) + return await OpenHABIntentHelper.getItemOptions(home: intent.home, searchTerm: searchTerm, itemTypes: [.switchItem]) } func provideItemOptionsCollection(for intent: OpenHABSetSwitchStateIntent) async throws -> INObjectCollection { logger.info("SetSwitchStateIntentHandler provideItemOptionsCollection") - - return try await provideItemOptionsCollection(for: intent, searchTerm: nil) + return await OpenHABIntentHelper.getItemOptions(home: intent.home, itemTypes: [.switchItem]) } func confirm(intent: OpenHABSetSwitchStateIntent) async -> OpenHABSetSwitchStateIntentResponse { @@ -61,10 +55,17 @@ final class SetSwitchStateIntentHandler: NSObject, OpenHABSetSwitchStateIntentHa } func handle(intent: OpenHABSetSwitchStateIntent) async -> OpenHABSetSwitchStateIntentResponse { - let itemName = intent.item ?? "" logger.info("SetSwitchStateIntent for item: \(intent.item ?? "", privacy: .public)") - await OpenHABItemCache.instance.waitUntilReady() + guard let itemName = intent.item, let home = intent.home else { + return .failureInvalidItem( + NSLocalizedString("empty", comment: "empty item / home name") + ) + } + + guard let homeId = home.uuid, Preferences.storedHomes[homeId] != nil else { + return .failureInvalidItem(NSLocalizedString("unknownHome", comment: "unknown home")) + } guard !itemName.isEmpty else { return .failureInvalidItem(NSLocalizedString("empty", comment: "empty item name")) @@ -78,11 +79,13 @@ final class SetSwitchStateIntentHandler: NSObject, OpenHABSetSwitchStateIntentHa return .failureInvalidAction(action, item: itemName) } - guard let item = await itemCache.getItem(name: itemName) else { + guard let items = await OpenHABItemCache.instance.getCachedItem(name: itemName, home: homeId), !items.isEmpty else { return .failureInvalidItem(itemName) } - await itemCache.sendCommand(item, commandToSend: command) + let item = items[0] + + await OpenHABItemCache.instance.sendCommand(to: item, home: homeId, command: command) return .success(action: action, item: itemName) } } From a7d0894f81aea54f78135dd6bb0455ed94ce4e84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20M=C3=BCller-Seydlitz?= Date: Sun, 20 Jul 2025 18:10:26 +0200 Subject: [PATCH 271/476] Handle input hint of type datetime Eliminate a swift 6 error Improve handling of ButtonGrid with stateful MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tim Müller-Seydlitz --- .../OpenHABCore/Model/OpenHABWidget.swift | 22 +++++++- .../SettingsView/SitemapSettingsView.swift | 6 +-- openHAB/SwiftUI/Rows/ButtonGridRowView.swift | 50 +++++++++++-------- 3 files changed, 54 insertions(+), 24 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift index 1378f7960..4fa226e46 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift @@ -68,6 +68,23 @@ public class OpenHABWidget: NSObject, MKAnnotation, Identifiable, ObservableObje public enum InputHint: String, Decodable { case text, number, date, time, dateTime, unknown + + public init(rawValue: String) { + switch rawValue.lowercased() { + case "text": + self = .text + case "number": + self = .number + case "date": + self = .date + case "time": + self = .time + case "datetime", "dateTime": + self = .dateTime + default: + self = .unknown + } + } } private let logger = Logger(subsystem: "org.openhab", category: "OpenHABWidget") @@ -110,7 +127,9 @@ public class OpenHABWidget: NSObject, MKAnnotation, Identifiable, ObservableObje public var releaseCommand: String? public var command: String? public var stateless: Bool? - public var readOnly = false + public var readOnly : Bool? { + item?.stateDescription?.readOnly + } @Published public var stateEnumBinding: WidgetTypeEnum = .unassigned @@ -368,6 +387,7 @@ public extension OpenHABWidget { self.column = column self.releaseCommand = releaseCommand self.command = command + self.stateless = stateless } convenience init(icon: String, iconColor: String? = nil) { diff --git a/openHAB/SettingsView/SitemapSettingsView.swift b/openHAB/SettingsView/SitemapSettingsView.swift index 2a0fd251d..5e877ceee 100644 --- a/openHAB/SettingsView/SitemapSettingsView.swift +++ b/openHAB/SettingsView/SitemapSettingsView.swift @@ -35,9 +35,9 @@ struct SitemapSettingsView: View { Button { KingfisherManager.shared.cache.calculateDiskStorageSize { result in Task { @MainActor in - cacheSizeResult = result - showingCacheAlert = true - } + cacheSizeResult = result + showingCacheAlert = true + } } } label: { NavigationLink("Check & Clear Image Cache", destination: EmptyView()) diff --git a/openHAB/SwiftUI/Rows/ButtonGridRowView.swift b/openHAB/SwiftUI/Rows/ButtonGridRowView.swift index ef6ac72e9..15b8788e9 100644 --- a/openHAB/SwiftUI/Rows/ButtonGridRowView.swift +++ b/openHAB/SwiftUI/Rows/ButtonGridRowView.swift @@ -15,8 +15,7 @@ import os.log import SwiftUI struct ButtonGridButton: View { - let button: OpenHABWidget - let targetWidget: OpenHABWidget + let widget: OpenHABWidget @State private var isPressed = false @EnvironmentObject var viewModel: SitemapPageViewModel @@ -24,15 +23,14 @@ struct ButtonGridButton: View { private let logger = Logger(subsystem: "org.openhab", category: "ButtonGridButton") - private var isStateful: Bool { - // TODO: Mappings are typically stateless unless specified otherwise - // Handle widgets as well - false - } - - private var isSelected: Bool { - guard isStateful else { return false } - return targetWidget.item?.state == button.command + private var isChecked: Bool { + if let stateless = widget.stateless { + logger.debug("button.stateless : \(stateless)") + } else { + logger.debug("button.stateless : nil") + } + if let stateless = widget.stateless, stateless { return false } + return widget.item?.state == widget.command } var body: some View { @@ -41,11 +39,11 @@ struct ButtonGridButton: View { handleButtonPress() } label: { HStack { - if !button.icon.isEmpty { - IconView(icon: button.icon) + if !widget.icon.isEmpty { + IconView(icon: widget.icon) .frame(width: 16, height: 16) } else { - Text(button.label) + Text(widget.label) .font(.caption) .foregroundColor(.primary) .lineLimit(1) @@ -56,11 +54,11 @@ struct ButtonGridButton: View { .frame(height: 44) .background( RoundedRectangle(cornerRadius: 8) - .fill(isSelected ? Color.accentColor : Color.secondary.opacity(0.1)) + .fill(isChecked ? Color.accentColor : Color.secondary.opacity(0.1)) ) .overlay( RoundedRectangle(cornerRadius: 8) - .stroke(isSelected ? Color.accentColor : Color.secondary.opacity(0.3), lineWidth: 1) + .stroke(isChecked ? Color.accentColor : Color.secondary.opacity(0.3), lineWidth: 1) ) .scaleEffect(isPressed ? 0.95 : 1.0) } @@ -79,18 +77,29 @@ struct ButtonGridButton: View { private func handleButtonPress() { // Send command on tap for mappings - if let command = button.command, !command.isEmpty { + if let command = widget.command, !command.isEmpty { logger.info("Sending command: \(command)") - viewModel.sendCommand(targetWidget.item, commandToSend: button.command) + viewModel.sendCommand(widget.item, commandToSend: widget.command) } } private func handleTouchDown() { isPressed = true + // For buttons with releaseCommand, send command on press + if let releaseCommand = widget.releaseCommand, !releaseCommand.isEmpty, + let command = widget.command { + logger.info("Sending press command: \(command)") + widget.sendCommand(command) + } } private func handleTouchUp() { isPressed = false + // For buttons with releaseCommand, send release command on release + if let releaseCommand = widget.releaseCommand, !releaseCommand.isEmpty { + logger.info("Sending release command: \(releaseCommand)") + widget.sendCommand(releaseCommand) + } } } @@ -145,7 +154,7 @@ struct ButtonGridRowView: View { if let button, button.visibility { - ButtonGridButton(button: button, targetWidget: widget) + ButtonGridButton(widget: button) } else { // Empty cell to maintain grid structure Rectangle() @@ -193,7 +202,8 @@ extension OpenHABWidgetMapping { widget.visibility = true widget.row = row widget.column = column - widget.releaseCommand = releaseCommand + widget.releaseCommand = "" + widget.stateless = true widget.icon = icon ?? "" return widget } From 5c84cb580a7869054bdd25305fc698ad01397494 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20M=C3=BCller-Seydlitz?= Date: Mon, 21 Jul 2025 22:38:46 +0200 Subject: [PATCH 272/476] Handling of sitemap element Chart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tim Müller-Seydlitz --- .../OpenHABCore/Model/OpenHABItem.swift | 14 ++++++++ .../OpenHABCore/Model/OpenHABWidget.swift | 36 ++++++++++++++++++- .../Sources/OpenHABCore/Util/ImageType.swift | 25 +++++++++++++ openHAB/NewImageUITableViewCell.swift | 6 ---- openHAB/SwiftUI/Rows/ImageRowView.swift | 22 ++++++------ 5 files changed, 84 insertions(+), 19 deletions(-) create mode 100644 OpenHABCore/Sources/OpenHABCore/Util/ImageType.swift diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABItem.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABItem.swift index 45fd7d2af..9b88ae6c4 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABItem.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABItem.swift @@ -124,6 +124,20 @@ public extension OpenHABItem { } return nil } + + func getImagePayload() -> ImagePayload { + switch type { + case .image: + guard let data = state?.components(separatedBy: ",")[safe: 1], let decodedData = Data(base64Encoded: data, options: .ignoreUnknownCharacters) else { + return .empty + } + return .embedded(data: decodedData) + case .stringItem: + return .link(url: URL(string: state ?? "")) + default: + return .empty + } + } } public extension OpenHABItem { diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift index 4fa226e46..9c9f695bd 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift @@ -127,7 +127,7 @@ public class OpenHABWidget: NSObject, MKAnnotation, Identifiable, ObservableObje public var releaseCommand: String? public var command: String? public var stateless: Bool? - public var readOnly : Bool? { + public var readOnly: Bool? { item?.stateDescription?.readOnly } @@ -294,6 +294,40 @@ public class OpenHABWidget: NSObject, MKAnnotation, Identifiable, ObservableObje valueAdjustedToStep += minValue return valueAdjustedToStep.clamped(to: minValue ... maxValue) } + + public func generateImageResult(rootUrl: String, + chartStyle: ChartStyle = .light) -> ImagePayload { + switch type { + case .chart: + guard let url = Endpoint.chart( + rootUrl: rootUrl, + period: period, + type: item?.type, + service: service, + name: item?.name, + legend: legend, + theme: chartStyle, + forceAsItem: forceAsItem + ).url else { + logger.error("Failed to generate chart URL") + return .empty + } + return .link(url: url) + + case .image: + if let item { + return item.getImagePayload() + } + guard let url = URL(string: url) else { + logger.error("Invalid image URL: \(self.url)") + return .empty + } + return .link(url: url) + + default: + return .empty + } + } } public extension OpenHABWidget { diff --git a/OpenHABCore/Sources/OpenHABCore/Util/ImageType.swift b/OpenHABCore/Sources/OpenHABCore/Util/ImageType.swift new file mode 100644 index 000000000..db70bdb37 --- /dev/null +++ b/OpenHABCore/Sources/OpenHABCore/Util/ImageType.swift @@ -0,0 +1,25 @@ +// Copyright (c) 2010-2025 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 Foundation +import UIKit + +public enum ImagePayload { + case link(url: URL?) + case embedded(data: Data) + case empty +} + +public enum ImageType { + case link(url: URL?) + case embedded(image: UIImage?) + case empty +} diff --git a/openHAB/NewImageUITableViewCell.swift b/openHAB/NewImageUITableViewCell.swift index 046aef2fe..6d2c6406e 100644 --- a/openHAB/NewImageUITableViewCell.swift +++ b/openHAB/NewImageUITableViewCell.swift @@ -14,12 +14,6 @@ import OpenHABCore import os.log import UIKit -enum ImageType { - case link(url: URL?) - case embedded(image: UIImage?) - case empty -} - class NewImageUITableViewCell: GenericUITableViewCell, NoIconDisplayableCell { private let logger = Logger(subsystem: "org.openhab", category: "NewImageUITableViewCell") diff --git a/openHAB/SwiftUI/Rows/ImageRowView.swift b/openHAB/SwiftUI/Rows/ImageRowView.swift index b6f1c4e51..6530ee460 100644 --- a/openHAB/SwiftUI/Rows/ImageRowView.swift +++ b/openHAB/SwiftUI/Rows/ImageRowView.swift @@ -12,12 +12,15 @@ import CommonUI import Kingfisher import OpenHABCore +import os.log import SwiftUI struct ImageRowView: View { @ObservedObject var widget: OpenHABWidget @EnvironmentObject var viewModel: SitemapPageViewModel + private let logger = Logger(subsystem: "org.openhab", category: "ImageRowView") + private var imageURL: URL? { guard !widget.url.isEmpty else { return nil } return URL(string: widget.url) @@ -30,22 +33,17 @@ struct ImageRowView: View { .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) } - if let imageURL { - KFImage(imageURL) - .placeholder { - Rectangle() - .fill(Color.gray.opacity(0.3)) - .frame(height: 200) - .overlay( - ProgressView() - .progressViewStyle(CircularProgressViewStyle()) - ) - } + switch widget.generateImageResult(rootUrl: viewModel.openHABRootUrl ?? "") { + case let .embedded(data: data): + let provider = RawImageDataProvider(data: data, cacheKey: UUID().uuidString) + KFImage(source: .provider(provider)).resizable() + case let .link(url): + KFImage(url) .resizable() .aspectRatio(contentMode: .fit) .frame(maxHeight: 300) .cornerRadius(8) - } else { + case .empty: Rectangle() .fill(Color.gray.opacity(0.3)) .frame(height: 200) From 743bb2bdc3e4b76e8790a821ba52d79a16458313 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Mon, 21 Jul 2025 22:48:44 +0200 Subject: [PATCH 273/476] include comments by copilot Signed-off-by: Tim Mueller-Seydlitz --- openHAB/SitemapPageViewModel.swift | 10 ---------- openHAB/SwiftUI/Rows/GenericRowView.swift | 9 ++++++--- openHAB/SwiftUI/Rows/SliderRowView.swift | 2 +- 3 files changed, 7 insertions(+), 14 deletions(-) diff --git a/openHAB/SitemapPageViewModel.swift b/openHAB/SitemapPageViewModel.swift index 29a298520..6921d8389 100644 --- a/openHAB/SitemapPageViewModel.swift +++ b/openHAB/SitemapPageViewModel.swift @@ -109,16 +109,6 @@ class SitemapPageViewModel: ObservableObject { return } do { - // Setup service if needed -// if openAPIService == nil { -// guard let activeConnection = NetworkTracker.shared.activeConnection else { -// throw SitemapPageError.noActiveConnection -// } -// openAPIService = try OpenAPIService( -// connectionConfiguration: activeConnection.configuration -// ) -// } - guard let activeConnection = await NetworkTracker.shared.waitForActiveConnection() else { logger.error("Failed to establish connection within timeout") return diff --git a/openHAB/SwiftUI/Rows/GenericRowView.swift b/openHAB/SwiftUI/Rows/GenericRowView.swift index 7600f1bb1..f88aeaeba 100644 --- a/openHAB/SwiftUI/Rows/GenericRowView.swift +++ b/openHAB/SwiftUI/Rows/GenericRowView.swift @@ -32,6 +32,9 @@ struct GenericRowView: View { } } -// #Preview { -// WidgetGenericView() -// } + #Preview { + let widget = PreviewConstants.openHABSitemapPage!.widgets[6] + List([widget]) { widget in + GenericRowView(widget: widget) + } + } diff --git a/openHAB/SwiftUI/Rows/SliderRowView.swift b/openHAB/SwiftUI/Rows/SliderRowView.swift index cfabd898a..3452b9fb7 100644 --- a/openHAB/SwiftUI/Rows/SliderRowView.swift +++ b/openHAB/SwiftUI/Rows/SliderRowView.swift @@ -95,7 +95,7 @@ struct SliderRowView: View { viewModel.sendToUpdate(item: widget.item, state: numberState) } - private func throttledsendSliderUpdate(_ newValue: Double) { + private func throttledSendSliderUpdate(_ newValue: Double) { updateTask?.cancel() updateTask = Task { From a4c9614d912ed73290d4caa11726ee27dc0b1d33 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Mon, 21 Jul 2025 23:06:42 +0200 Subject: [PATCH 274/476] Provoke change Signed-off-by: Tim Mueller-Seydlitz --- openHAB/SwiftUI/Rows/ButtonGridRowView.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openHAB/SwiftUI/Rows/ButtonGridRowView.swift b/openHAB/SwiftUI/Rows/ButtonGridRowView.swift index 15b8788e9..9a66604e7 100644 --- a/openHAB/SwiftUI/Rows/ButtonGridRowView.swift +++ b/openHAB/SwiftUI/Rows/ButtonGridRowView.swift @@ -25,9 +25,9 @@ struct ButtonGridButton: View { private var isChecked: Bool { if let stateless = widget.stateless { - logger.debug("button.stateless : \(stateless)") + logger.debug("button.stateless: \(stateless)") } else { - logger.debug("button.stateless : nil") + logger.debug("button.stateless: nil") } if let stateless = widget.stateless, stateless { return false } return widget.item?.state == widget.command From 2e09d68fd840dfe553b6798d19de246c0d7da4eb Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Tue, 22 Jul 2025 21:25:41 +0200 Subject: [PATCH 275/476] Debugging of IconView Handling of labelSource Signed-off-by: Tim Mueller-Seydlitz --- openHAB/SwiftUI/IconView.swift | 12 ++++++------ openHAB/SwiftUI/Rows/ButtonGridRowView.swift | 6 ++++-- openHAB/SwiftUI/Rows/ColorPickerRowView.swift | 2 +- .../SwiftUI/Rows/ColorTemperaturePickerRowView.swift | 2 +- openHAB/SwiftUI/Rows/DatePickerInputRowView.swift | 2 +- openHAB/SwiftUI/Rows/GenericRowView.swift | 12 ++++++------ openHAB/SwiftUI/Rows/ImageRowView.swift | 2 +- openHAB/SwiftUI/Rows/MapRowView.swift | 2 +- openHAB/SwiftUI/Rows/RollershutterRowView.swift | 6 ++++-- openHAB/SwiftUI/Rows/SegmentedRowView.swift | 2 +- openHAB/SwiftUI/Rows/SelectionRowView.swift | 2 +- openHAB/SwiftUI/Rows/SetpointRowView.swift | 6 ++++-- openHAB/SwiftUI/Rows/SliderRowView.swift | 6 +++++- openHAB/SwiftUI/Rows/SwitchRowView.swift | 7 +++++-- openHAB/SwiftUI/Rows/TextInputRowView.swift | 2 +- openHAB/SwiftUI/Rows/VideoRowView.swift | 2 +- openHAB/SwiftUI/Rows/WebRowView.swift | 2 +- 17 files changed, 44 insertions(+), 31 deletions(-) diff --git a/openHAB/SwiftUI/IconView.swift b/openHAB/SwiftUI/IconView.swift index be182a314..91b041ad6 100644 --- a/openHAB/SwiftUI/IconView.swift +++ b/openHAB/SwiftUI/IconView.swift @@ -59,7 +59,12 @@ struct IconView: View { } var body: some View { - Group { + ZStack { + // No icon or failed to load - show empty space + Rectangle() + .fill(Color.clear) + .frame(width: size.width, height: size.height) + if let iconURL, !imageLoadingFailed { KFImage.url(iconURL) .placeholder { @@ -83,11 +88,6 @@ struct IconView: View { .aspectRatio(contentMode: .fit) .frame(width: size.width, height: size.height) .id(iconURL.absoluteString) - } else { - // No icon or failed to load - show empty space - Rectangle() - .fill(Color.clear) - .frame(width: size.width, height: size.height) } } .onChange(of: widget.icon) { _ in diff --git a/openHAB/SwiftUI/Rows/ButtonGridRowView.swift b/openHAB/SwiftUI/Rows/ButtonGridRowView.swift index 9a66604e7..3b645a2ca 100644 --- a/openHAB/SwiftUI/Rows/ButtonGridRowView.swift +++ b/openHAB/SwiftUI/Rows/ButtonGridRowView.swift @@ -140,8 +140,10 @@ struct ButtonGridRowView: View { .frame(width: 24, height: 24) } - Text(widget.labelText ?? widget.label) - .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) + if let labelText = widget.labelText, !labelText.isEmpty, widget.labelSource == .sitemapDefinition { + Text(labelText) + .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) + } Spacer() } diff --git a/openHAB/SwiftUI/Rows/ColorPickerRowView.swift b/openHAB/SwiftUI/Rows/ColorPickerRowView.swift index 1e7c5a3e9..c37d89c76 100644 --- a/openHAB/SwiftUI/Rows/ColorPickerRowView.swift +++ b/openHAB/SwiftUI/Rows/ColorPickerRowView.swift @@ -26,7 +26,7 @@ struct ColorPickerRowView: View { IconView(widget: widget) .frame(width: 24, height: 24) - if let labelText = widget.labelText, !labelText.isEmpty { + if let labelText = widget.labelText, !labelText.isEmpty, widget.labelSource == .sitemapDefinition { Text(labelText) .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) } diff --git a/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift b/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift index e6e7527d1..5eb6e2511 100644 --- a/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift +++ b/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift @@ -77,7 +77,7 @@ struct ColorTemperaturePickerRowView: View { VStack(spacing: 8) { HStack { - if let labelText = widget.labelText, !labelText.isEmpty { + if let labelText = widget.labelText, !labelText.isEmpty, widget.labelSource == .sitemapDefinition { Text(labelText) .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) } diff --git a/openHAB/SwiftUI/Rows/DatePickerInputRowView.swift b/openHAB/SwiftUI/Rows/DatePickerInputRowView.swift index 6ecbb506b..ae6ca6bcb 100644 --- a/openHAB/SwiftUI/Rows/DatePickerInputRowView.swift +++ b/openHAB/SwiftUI/Rows/DatePickerInputRowView.swift @@ -39,7 +39,7 @@ struct DatePickerInputRowView: View { IconView(widget: widget) .frame(width: 24, height: 24) - if let labelText = widget.labelText, !labelText.isEmpty { + if let labelText = widget.labelText, !labelText.isEmpty, widget.labelSource == .sitemapDefinition { Text(labelText) .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) } diff --git a/openHAB/SwiftUI/Rows/GenericRowView.swift b/openHAB/SwiftUI/Rows/GenericRowView.swift index f88aeaeba..3174a3e38 100644 --- a/openHAB/SwiftUI/Rows/GenericRowView.swift +++ b/openHAB/SwiftUI/Rows/GenericRowView.swift @@ -32,9 +32,9 @@ struct GenericRowView: View { } } - #Preview { - let widget = PreviewConstants.openHABSitemapPage!.widgets[6] - List([widget]) { widget in - GenericRowView(widget: widget) - } - } +#Preview { + let widget = PreviewConstants.openHABSitemapPage!.widgets[6] + List([widget]) { widget in + GenericRowView(widget: widget) + } +} diff --git a/openHAB/SwiftUI/Rows/ImageRowView.swift b/openHAB/SwiftUI/Rows/ImageRowView.swift index 6530ee460..dcac6b1a4 100644 --- a/openHAB/SwiftUI/Rows/ImageRowView.swift +++ b/openHAB/SwiftUI/Rows/ImageRowView.swift @@ -28,7 +28,7 @@ struct ImageRowView: View { var body: some View { VStack(alignment: .leading, spacing: 8) { - if let labelText = widget.labelText, !labelText.isEmpty { + if let labelText = widget.labelText, !labelText.isEmpty, widget.labelSource == .sitemapDefinition { Text(labelText) .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) } diff --git a/openHAB/SwiftUI/Rows/MapRowView.swift b/openHAB/SwiftUI/Rows/MapRowView.swift index 2a4f6df6f..22db2a1c2 100644 --- a/openHAB/SwiftUI/Rows/MapRowView.swift +++ b/openHAB/SwiftUI/Rows/MapRowView.swift @@ -29,7 +29,7 @@ struct MapRowViewLegacy: View { var body: some View { VStack(alignment: .leading, spacing: 8) { - if let labelText = widget.labelText, !labelText.isEmpty { + if let labelText = widget.labelText, !labelText.isEmpty, widget.labelSource == .sitemapDefinition { Text(labelText) .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) } diff --git a/openHAB/SwiftUI/Rows/RollershutterRowView.swift b/openHAB/SwiftUI/Rows/RollershutterRowView.swift index c1fc0790f..1d51b7321 100644 --- a/openHAB/SwiftUI/Rows/RollershutterRowView.swift +++ b/openHAB/SwiftUI/Rows/RollershutterRowView.swift @@ -39,8 +39,10 @@ struct RollershutterRowView: View { } VStack(alignment: .leading, spacing: 2) { - Text(widget.labelText ?? widget.label) - .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) + if let labelText = widget.labelText, !labelText.isEmpty, widget.labelSource == .sitemapDefinition { + Text(labelText) + .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) + } } Spacer() diff --git a/openHAB/SwiftUI/Rows/SegmentedRowView.swift b/openHAB/SwiftUI/Rows/SegmentedRowView.swift index bb34565d1..cd0bfc91f 100644 --- a/openHAB/SwiftUI/Rows/SegmentedRowView.swift +++ b/openHAB/SwiftUI/Rows/SegmentedRowView.swift @@ -38,7 +38,7 @@ struct SegmentedRowView: View { .padding(.top, 4) // Align with text } - if let labelText = widget.labelText, !labelText.isEmpty { + if let labelText = widget.labelText, !labelText.isEmpty, widget.labelSource == .sitemapDefinition { Text(labelText) .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) } diff --git a/openHAB/SwiftUI/Rows/SelectionRowView.swift b/openHAB/SwiftUI/Rows/SelectionRowView.swift index d43a98099..099ddc726 100644 --- a/openHAB/SwiftUI/Rows/SelectionRowView.swift +++ b/openHAB/SwiftUI/Rows/SelectionRowView.swift @@ -32,7 +32,7 @@ struct SelectionRowView: View { .frame(width: 24, height: 24) .padding(.top, 4) // Align with text - if let labelText = widget.labelText, !labelText.isEmpty { + if let labelText = widget.labelText, !labelText.isEmpty, widget.labelSource == .sitemapDefinition { Text(labelText) .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) } diff --git a/openHAB/SwiftUI/Rows/SetpointRowView.swift b/openHAB/SwiftUI/Rows/SetpointRowView.swift index 2e7bf6b99..1052bba67 100644 --- a/openHAB/SwiftUI/Rows/SetpointRowView.swift +++ b/openHAB/SwiftUI/Rows/SetpointRowView.swift @@ -45,8 +45,10 @@ struct SetpointRowView: View { IconView(widget: widget) .frame(width: 24, height: 24) - Text(widget.labelText ?? widget.label) - .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) + if let labelText = widget.labelText, !labelText.isEmpty, widget.labelSource == .sitemapDefinition { + Text(labelText) + .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) + } Spacer() diff --git a/openHAB/SwiftUI/Rows/SliderRowView.swift b/openHAB/SwiftUI/Rows/SliderRowView.swift index 3452b9fb7..d95871832 100644 --- a/openHAB/SwiftUI/Rows/SliderRowView.swift +++ b/openHAB/SwiftUI/Rows/SliderRowView.swift @@ -9,6 +9,7 @@ // // SPDX-License-Identifier: EPL-2.0 +import CommonUI import OpenHABCore import SwiftUI @@ -36,7 +37,10 @@ struct SliderRowView: View { IconView(widget: widget) .frame(width: 24, height: 24) - Text(widget.labelText ?? "") + if let labelText = widget.labelText, !labelText.isEmpty, widget.labelSource == .sitemapDefinition { + Text(labelText) + .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) + } Spacer() diff --git a/openHAB/SwiftUI/Rows/SwitchRowView.swift b/openHAB/SwiftUI/Rows/SwitchRowView.swift index dd46fa891..ce1088a35 100644 --- a/openHAB/SwiftUI/Rows/SwitchRowView.swift +++ b/openHAB/SwiftUI/Rows/SwitchRowView.swift @@ -40,8 +40,11 @@ struct SwitchRowView: View { .frame(width: 24, height: 24) } - Text(widget.labelText ?? widget.label) - .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) + if let labelText = widget.labelText, !labelText.isEmpty, widget.labelSource == .sitemapDefinition { + Text(labelText) + .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) + } + Spacer() if let labelValue = widget.labelValue, !labelValue.isEmpty { diff --git a/openHAB/SwiftUI/Rows/TextInputRowView.swift b/openHAB/SwiftUI/Rows/TextInputRowView.swift index ce267d66f..927a35122 100644 --- a/openHAB/SwiftUI/Rows/TextInputRowView.swift +++ b/openHAB/SwiftUI/Rows/TextInputRowView.swift @@ -27,7 +27,7 @@ struct TextInputRowView: View { IconView(widget: widget) .frame(width: 24, height: 24) - if let labelText = widget.labelText, !labelText.isEmpty { + if let labelText = widget.labelText, !labelText.isEmpty, widget.labelSource == .sitemapDefinition { Text(labelText) .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) } diff --git a/openHAB/SwiftUI/Rows/VideoRowView.swift b/openHAB/SwiftUI/Rows/VideoRowView.swift index 8813daa0f..88f08ce96 100644 --- a/openHAB/SwiftUI/Rows/VideoRowView.swift +++ b/openHAB/SwiftUI/Rows/VideoRowView.swift @@ -26,7 +26,7 @@ struct VideoRowView: View { var body: some View { VStack(alignment: .leading, spacing: 8) { - if let labelText = widget.labelText, !labelText.isEmpty { + if let labelText = widget.labelText, !labelText.isEmpty, widget.labelSource == .sitemapDefinition { Text(labelText) .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) } diff --git a/openHAB/SwiftUI/Rows/WebRowView.swift b/openHAB/SwiftUI/Rows/WebRowView.swift index cf30854d0..9369ea948 100644 --- a/openHAB/SwiftUI/Rows/WebRowView.swift +++ b/openHAB/SwiftUI/Rows/WebRowView.swift @@ -19,7 +19,7 @@ struct WidgetWebViewContainer: View { var body: some View { VStack(alignment: .leading, spacing: 8) { - if let labelText = widget.labelText, !labelText.isEmpty { + if let labelText = widget.labelText, !labelText.isEmpty, widget.labelSource == .sitemapDefinition { Text(labelText) .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) } From faf4d25dad5261fc4ad1eb424ea40b5df5631895 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Wed, 23 Jul 2025 08:22:41 +0200 Subject: [PATCH 276/476] More stable definition of Endpoint Signed-off-by: Tim Mueller-Seydlitz --- .../OpenHABCore/Model/OpenHABWidget.swift | 44 ++++++++++--------- .../Sources/OpenHABCore/Util/Endpoint.swift | 11 +++-- 2 files changed, 31 insertions(+), 24 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift index 9c9f695bd..874a35bb2 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift @@ -262,31 +262,35 @@ public class OpenHABWidget: NSObject, MKAnnotation, Identifiable, ObservableObje Int(mappingIndex(byCommand: command) ?? 0) } - public func iconState() -> String { - var iconState = item?.state ?? "" - if let item, let itemState = item.state { - if item.isOfTypeOrGroupType(.color) { - // For items that control a color item fetch the correct icon - if type == .slider || (type == .switchWidget && mappings.isEmpty) { - if let brightness = itemState.parseAsBrightness() { - iconState = String(brightness) - if type == .switchWidget { - iconState = iconState == "0" ? "OFF" : "ON" - } + public func iconState() -> String? { + guard let item, let itemState = item.state else { return nil } + if item.isOfTypeOrGroupType(.color) { + // For items that control a color item fetch the correct icon + if type == .slider || (type == .switchWidget && mappings.isEmpty) { + if let brightness = itemState.parseAsBrightness() { + let brightness = String(brightness) + if type == .switchWidget { + return brightness == "0" ? "OFF" : "ON" } else { - iconState = "OFF" + return brightness } - } else if let color = itemState.parseAsUIColor() { - iconState = "#\(color.toHex() ?? "000000")" + } else { + return "OFF" } - } else if type == .switchWidget, mappings.isEmpty, !item.isOfTypeOrGroupType(.rollershutter) { - // For switch items without mappings (just ON and OFF) that control a dimmer item - // and which are not ON or OFF already, set the state to "OFF" instead of 0 - // or to "ON" to fetch the correct icon - iconState = (itemState == "0" || itemState == "OFF") ? "OFF" : "ON" + } else if let color = itemState.parseAsUIColor() { + return "#\(color.toHex() ?? "000000")" } + } else if item.isOfTypeOrGroupType(.number) || item.isOfTypeOrGroupType(.numberWithDimension) { + let numberState = itemState.parseAsNumber(format: item.stateDescription?.numberPattern) + return numberState.toString(locale: Locale(identifier: "US")) + } else if type == .switchWidget, mappings.isEmpty, !item.isOfTypeOrGroupType(.rollershutter) { + // For switch items without mappings (just ON and OFF) that control a dimmer item + // and which are not ON or OFF already, set the state to "OFF" instead of 0 + // or to "ON" to fetch the correct icon + return (itemState == "0" || itemState == "OFF") ? "OFF" : "ON" } - return iconState + + return itemState } private func adj(_ raw: Double) -> Double { diff --git a/OpenHABCore/Sources/OpenHABCore/Util/Endpoint.swift b/OpenHABCore/Sources/OpenHABCore/Util/Endpoint.swift index c78061817..89a4340e6 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/Endpoint.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/Endpoint.swift @@ -131,15 +131,18 @@ public extension Endpoint { } // swiftlint:disable:next function_parameter_count - static func icon(rootUrl: String, version: Int, icon: String?, state: String, iconType: IconType, iconColor: String) -> Endpoint { + static func icon(rootUrl: String, version: Int, icon: String?, state: String?, iconType: IconType, iconColor: String) -> Endpoint { guard var icon, !icon.isEmpty else { return Endpoint(baseURL: "", path: "", queryItems: []) } // determineOH2IconPath - var queryItems = [ - URLQueryItem(name: "state", value: state) - ] + var queryItems: [URLQueryItem] = [] + if let state { + queryItems.append(contentsOf: [ + URLQueryItem(name: "state", value: state) + ]) + } if version >= 4 { let components = icon.components(separatedBy: ":") var source = "" From d3f78bf4d429247538d48e75958a92d7127ce1d0 Mon Sep 17 00:00:00 2001 From: Dan Cunningham Date: Wed, 23 Jul 2025 06:33:51 -1000 Subject: [PATCH 277/476] Enhances Webview for multiple homes (#900) * Enhances Webview for multiple homes Signed-off-by: Dan Cunningham * remove commented out code Signed-off-by: Dan Cunningham --------- Signed-off-by: Dan Cunningham --- openHAB/OpenHABWebViewController.swift | 78 ++++++++++++++++++++------ 1 file changed, 62 insertions(+), 16 deletions(-) diff --git a/openHAB/OpenHABWebViewController.swift b/openHAB/OpenHABWebViewController.swift index 8e0386939..d4ba95baf 100644 --- a/openHAB/OpenHABWebViewController.swift +++ b/openHAB/OpenHABWebViewController.swift @@ -27,6 +27,9 @@ class OpenHABWebViewController: OpenHABViewController { private var sseTimer: Timer? private var commandQueue: [String] = [] private var acceptsCommands = false + private var views: [UUID: WKWebView] = [:] + // TODO: remove myOhViews when we drop iOS 16 support + private var myOhViews: [UUID: WKWebView] = [:] private var js = """ window.OHApp = { @@ -49,7 +52,7 @@ class OpenHABWebViewController: OpenHABViewController { true } - private lazy var webView: WKWebView = newWebView() + private var webView: WKWebView = .init(frame: .zero) private var logger = Logger(subsystem: "org.openhab.app", category: "OpenHABWebViewController") @@ -115,6 +118,7 @@ class OpenHABWebViewController: OpenHABViewController { } } + @MainActor func loadWebView(force: Bool = false, path: String? = nil) { logger.info("loadWebView tracked URL: \(self.activeConfig?.url ?? "") forced \(force ? "true" : "false")") guard let activeConfig else { return } @@ -130,10 +134,27 @@ class OpenHABWebViewController: OpenHABViewController { let url = URL(string: activeConfig.url) if let modifiedUrl = modifyUrl(orig: url, path: path) { - let request = URLRequest(url: modifiedUrl) - clearExistingPage() acceptsCommands = false + let request = URLRequest(url: modifiedUrl) + // TODO: remove this check once iOS 16 is dropped + let isMyOh = url?.host?.contains("myopenhab.org") ?? false + // create new (or resuse existing) + let newWebview = webView(for: Preferences.currentHomePreferences.id, isMyopenhab: isMyOh) + if newWebview != webView { + // Detach old instance + webView.stopLoading() + webView.navigationDelegate = nil + webView.uiDelegate = nil + webView.removeFromSuperview() + newWebview.navigationDelegate = self + newWebview.uiDelegate = self + webView = newWebview + view.addSubview(newWebview) + } + // DispatchQueue.main.async { + logger.info("Loading URL: \(modifiedUrl)") webView.load(request) + // } } } @@ -143,7 +164,6 @@ class OpenHABWebViewController: OpenHABViewController { if url.host == "myopenhab.org" { url = URL(string: "https://home.myopenhab.org") ?? url } - if let path { url = appendPathToURL(baseURL: url, path: path) ?? url } else if !Preferences.currentHomePreferences.defaultMainUIPath.isEmpty { @@ -187,10 +207,8 @@ class OpenHABWebViewController: OpenHABViewController { navigationController?.setNavigationBarHidden(hideNavBar, animated: true) } - // swiftformat:disable redundantSelf func clearExistingPage() { - logger.info("clearExistingPage - webView.url \(String(describing: self.webView.url?.description))") - + logger.info("clearExistingPage") setHideNavBar(shouldHide: false) // clear out existing page while we load. webView.stopLoading() @@ -198,8 +216,7 @@ class OpenHABWebViewController: OpenHABViewController { } func pageLoadError(message: String) { - logger.info("pageLoadError - webView.url \(String(describing: self.webView.url?.description)) \(message)") - showActivityIndicator(show: false) + showActivityIndicator(show: true) showPopupMessage(seconds: 60, title: NSLocalizedString("error", comment: ""), message: message, theme: .error) } @@ -243,20 +260,39 @@ class OpenHABWebViewController: OpenHABViewController { } } - private func newWebView() -> WKWebView { + func webView(for id: UUID, isMyopenhab: Bool) -> WKWebView { + // TODO: remove all iOS < 17 code when we drop iOS 16 support + if #unavailable(iOS 17) { + if isMyopenhab, let myExsiting = myOhViews[id] { + logger.info("Reusing myopenhab webview for id:\(id.uuidString)") + return myExsiting + } + } + if let existing = views[id] { + logger.info("Reusing webview for id:\(id.uuidString)") + return existing + } let config = WKWebViewConfiguration() + config.processPool = WKProcessPool() // isolates credential cache config.allowsInlineMediaPlayback = true config.mediaTypesRequiringUserActionForPlayback = [] // adds: window.webkit.messageHandlers.xxxx.postMessage to JS env config.userContentController.add(self, name: "Native") config.userContentController.addUserScript(WKUserScript(source: js, injectionTime: .atDocumentStart, forMainFrameOnly: false)) - let webView = WKWebView(frame: view.bounds, configuration: config) - // Alow rotation of webview + // iOS 17 allows Sandboxed profiles, which is fantastic, iOS 16 does not and agressively caches everything + if #available(iOS 17, *) { + config.websiteDataStore = WKWebsiteDataStore(forIdentifier: id) + } else if isMyopenhab { + // for myopenhab, create a instance that does not persist or share states (private) + config.websiteDataStore = .nonPersistent() + } + + let webview = WKWebView(frame: view.bounds, configuration: config) + webview.navigationDelegate = self + webview.uiDelegate = self webView.autoresizingMask = [.flexibleWidth, .flexibleHeight] webView.scrollView.bounces = false - webView.navigationDelegate = self - webView.uiDelegate = self // support dark mode and avoid white flashing when loading webView.isOpaque = false webView.backgroundColor = UIColor.clear @@ -268,7 +304,14 @@ class OpenHABWebViewController: OpenHABViewController { webView.isInspectable = true } - return webView + if #unavailable(iOS 17) { + if isMyopenhab { + myOhViews[id] = webview + return webview + } + } + views[id] = webview + return webview } } @@ -351,7 +394,6 @@ extension OpenHABWebViewController: WKNavigationDelegate { logger.info("didFinish - webView.url: \(String(describing: webView.url?.description))") showActivityIndicator(show: false) hidePopupMessages() - // watch for URL changes so we can store the last visited path if let webviewURL = webView.url { let url = URL(string: webviewURL.path, relativeTo: URL(string: openHABTrackedRootUrl)) @@ -388,6 +430,10 @@ extension OpenHABWebViewController: WKNavigationDelegate { logger.warning("webViewWebContentProcessDidTerminate - reloading view") reloadView() } + + func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: any Error) { + reloadView() + } } extension OpenHABWebViewController: WKUIDelegate { From 8a97dc1b526ee8b6595ea270042723eb4f55f098 Mon Sep 17 00:00:00 2001 From: Dan Cunningham Date: Thu, 24 Jul 2025 03:22:24 -1000 Subject: [PATCH 278/476] [WIP] New features for kiosk mode (#896) * New features for kiosk mode Screensavers Fullscreen option Signed-off-by: Dan Cunningham * disable the screensaver when we enter the background Signed-off-by: Dan Cunningham * More tweaks and features Signed-off-by: Dan Cunningham * Clean up on hiding nav bar Signed-off-by: Dan Cunningham * Adds test button Signed-off-by: Dan Cunningham * Custom Wake Brightness Signed-off-by: Dan Cunningham * Small tweaks Signed-off-by: Dan Cunningham * typo Signed-off-by: Dan Cunningham * typo Signed-off-by: Dan Cunningham * Small formatting change Signed-off-by: Dan Cunningham --------- Signed-off-by: Dan Cunningham --- .../OpenHABCore/Util/Preferences.swift | 48 ++++ openHAB.xcodeproj/project.pbxproj | 29 +++ openHAB/AppDelegate.swift | 44 ++++ openHAB/Main.storyboard | 8 +- openHAB/OpenHABNavigationController.swift | 36 +++ openHAB/OpenHABViewController.swift | 4 + openHAB/OpenHABWebViewController.swift | 3 +- .../ScreenSaverConfiguration.swift | 47 ++++ openHAB/ScreenSaver/ScreenSaverManager.swift | 211 ++++++++++++++++++ openHAB/ScreenSaver/ScreenSaverView.swift | 205 +++++++++++++++++ .../ApplicationSettingsView.swift | 10 + .../ScreenSaverSettingsView.swift | 209 +++++++++++++++++ openHAB/SettingsView/SettingsView.swift | 7 + 13 files changed, 856 insertions(+), 5 deletions(-) create mode 100644 openHAB/OpenHABNavigationController.swift create mode 100644 openHAB/ScreenSaver/ScreenSaverConfiguration.swift create mode 100644 openHAB/ScreenSaver/ScreenSaverManager.swift create mode 100644 openHAB/ScreenSaver/ScreenSaverView.swift create mode 100644 openHAB/SettingsView/ScreenSaverSettingsView.swift diff --git a/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift index a6b933ba4..421a15ee1 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift @@ -116,6 +116,54 @@ public enum Preferences { @UserDefault("idleOff", defaultValue: false) public static var idleOff: Bool + @UserDefault("screensaverEnabled", defaultValue: false) + public static var screensaverEnabled: Bool + + @UserDefault("screensaverShowsTime", defaultValue: true) + public static var screensaverShowsTime: Bool + + @UserDefault("screensaverShowsDate", defaultValue: true) + public static var screensaverShowsDate: Bool + + @UserDefault("screensaverIdleInterval", defaultValue: 120.0) + public static var screensaverIdleInterval: Double + + @UserDefault("screensaverMovementInterval", defaultValue: 8.0) + public static var screensaverMovementInterval: Double + + @UserDefault("screensaverFontName", defaultValue: "") + public static var screensaverFontName: String + + @UserDefault("screensaverTimeFontRatio", defaultValue: 0.2) + public static var screensaverTimeFontRatio: Double + + @UserDefault("screensaverDateFontRatio", defaultValue: 0.4) + public static var screensaverDateFontRatio: Double + + @UserDefault("screensaverEnableDimming", defaultValue: true) + public static var screensaverEnableDimming: Bool + + @UserDefault("screensaverDimLevel", defaultValue: 0.3) + public static var screensaverDimLevel: Double + + @UserDefault("screensaverShowsSeconds", defaultValue: false) + public static var screensaverShowsSeconds: Bool + + @UserDefault("screensaverUse24Hour", defaultValue: false) + public static var screensaverUse24Hour: Bool + + @UserDefault("screensaverFadeDuration", defaultValue: 2.0) + public static var screensaverFadeDuration: Double + + @UserDefault("screensaverRestoreBrightness", defaultValue: true) + public static var screensaverRestoreBrightness: Bool + + @UserDefault("screensaverWakeBrightness", defaultValue: 1.0) + public static var screensaverWakeBrightness: Double + + @UserDefault("hideStatusBar", defaultValue: false) + public static var hideStatusBar: Bool + @UserDefault("currentWebViewPath", defaultValue: "") public static var currentWebViewPath: String diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index 4d53218c8..3e3c83434 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -15,6 +15,10 @@ 2FEFD8F62BE7C5BE00E387B9 /* TextInputUITableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FEFD8F52BE7C5BE00E387B9 /* TextInputUITableViewCell.swift */; }; 2FF459362E230C6A00C0B640 /* OpenHABIntentHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FF459352E230C6A00C0B640 /* OpenHABIntentHelper.swift */; }; 4D6470DA2561F935007B03FC /* openHABIntents.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 4D6470D32561F935007B03FC /* openHABIntents.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 652B81042E2193B500648510 /* ScreenSaverSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 652B81032E2193B500648510 /* ScreenSaverSettingsView.swift */; }; + 652B81092E2193DA00648510 /* ScreenSaverManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 652B81062E2193DA00648510 /* ScreenSaverManager.swift */; }; + 652B810A2E2193DA00648510 /* ScreenSaverConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 652B81052E2193DA00648510 /* ScreenSaverConfiguration.swift */; }; + 652B810B2E2193DA00648510 /* ScreenSaverView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 652B81072E2193DA00648510 /* ScreenSaverView.swift */; }; 653B54C2285E714900298ECD /* OpenHABViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 653B54C1285E714900298ECD /* OpenHABViewController.swift */; }; 65570A7D2476D16A00D524EA /* OpenHABWebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65570A7C2476D16A00D524EA /* OpenHABWebViewController.swift */; }; 6557AF8F2C0241C10094D0C8 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 6557AF8E2C0241C10094D0C8 /* PrivacyInfo.xcprivacy */; }; @@ -24,6 +28,7 @@ 657144512C1E438700C8A1F3 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 657144502C1E438700C8A1F3 /* NotificationService.swift */; }; 657144552C1E438700C8A1F3 /* NotificationService.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 6571444E2C1E438700C8A1F3 /* NotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 657144962C30A16700C8A1F3 /* OpenHABCore in Frameworks */ = {isa = PBXBuildFile; productRef = 657144952C30A16700C8A1F3 /* OpenHABCore */; }; + 65C2EF492E244C8500A0C19F /* OpenHABNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65C2EF482E244C8500A0C19F /* OpenHABNavigationController.swift */; }; 932602EE2382892B00EAD685 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DAC6608B236F6F4200F4501E /* Assets.xcassets */; }; 933D7F0722E7015100621A03 /* OpenHABUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 933D7F0622E7015000621A03 /* OpenHABUITests.swift */; }; 933D7F0F22E7030600621A03 /* SnapshotHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 933D7F0E22E7030600621A03 /* SnapshotHelper.swift */; }; @@ -294,6 +299,10 @@ 4D64720D256315D9007B03FC /* openHABIntents.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = openHABIntents.entitlements; sourceTree = ""; }; 4D647220256331B9007B03FC /* SetSwitchStateIntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetSwitchStateIntentHandler.swift; sourceTree = ""; }; 4D64724C256346BD007B03FC /* SetDimmerRollerValueIntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetDimmerRollerValueIntentHandler.swift; sourceTree = ""; }; + 652B81032E2193B500648510 /* ScreenSaverSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenSaverSettingsView.swift; sourceTree = ""; }; + 652B81052E2193DA00648510 /* ScreenSaverConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenSaverConfiguration.swift; sourceTree = ""; }; + 652B81062E2193DA00648510 /* ScreenSaverManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenSaverManager.swift; sourceTree = ""; }; + 652B81072E2193DA00648510 /* ScreenSaverView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenSaverView.swift; sourceTree = ""; }; 653B54C1285E714900298ECD /* OpenHABViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenHABViewController.swift; sourceTree = ""; }; 653C09D41EAD691A00BA4C4A /* openHAB.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = openHAB.entitlements; sourceTree = ""; }; 65570A7C2476D16A00D524EA /* OpenHABWebViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenHABWebViewController.swift; sourceTree = ""; }; @@ -303,6 +312,7 @@ 657144502C1E438700C8A1F3 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; 657144522C1E438700C8A1F3 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 657144972C30A3E300C8A1F3 /* NotificationService.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = NotificationService.entitlements; sourceTree = ""; }; + 65C2EF482E244C8500A0C19F /* OpenHABNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenHABNavigationController.swift; sourceTree = ""; }; 931384B324F259BC00A73AB5 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = ""; }; 931384B424F259BD00A73AB5 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/InfoPlist.strings; sourceTree = ""; }; 931384BB24F2691B00A73AB5 /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/Localizable.strings; sourceTree = ""; }; @@ -636,6 +646,16 @@ path = openHABIntents; sourceTree = ""; }; + 652B81082E2193DA00648510 /* ScreenSaver */ = { + isa = PBXGroup; + children = ( + 652B81052E2193DA00648510 /* ScreenSaverConfiguration.swift */, + 652B81062E2193DA00648510 /* ScreenSaverManager.swift */, + 652B81072E2193DA00648510 /* ScreenSaverView.swift */, + ); + path = ScreenSaver; + sourceTree = ""; + }; 6571444F2C1E438700C8A1F3 /* NotificationService */ = { isa = PBXGroup; children = ( @@ -796,6 +816,7 @@ DA48001F2D837CD8009CF127 /* SettingsView */ = { isa = PBXGroup; children = ( + 652B81032E2193B500648510 /* ScreenSaverSettingsView.swift */, DA4800172D837221009CF127 /* AboutSettingsView.swift */, DA48001D2D837905009CF127 /* ApplicationSettingsView.swift */, 2F55E7BC2DEE44A800EC8350 /* ClientCertificatesView.swift */, @@ -921,6 +942,8 @@ DF4B83FD18857FA100F34902 /* UI */ = { isa = PBXGroup; children = ( + 65C2EF482E244C8500A0C19F /* OpenHABNavigationController.swift */, + 652B81082E2193DA00648510 /* ScreenSaver */, DA2AEB752D92D32000897D80 /* Cells */, DA4642312D7EE6CA006C3908 /* LoggerView.swift */, 653B54C1285E714900298ECD /* OpenHABViewController.swift */, @@ -1610,6 +1633,9 @@ DA95F3352E0F2C1600FE4474 /* OpenHABSitemapViewController.swift in Sources */, DAA42BAA21DC983B00244B2A /* VideoUITableViewCell.swift in Sources */, DFB2623B18830A3600D3244D /* AppDelegate.swift in Sources */, + 652B81092E2193DA00648510 /* ScreenSaverManager.swift in Sources */, + 652B810A2E2193DA00648510 /* ScreenSaverConfiguration.swift in Sources */, + 652B810B2E2193DA00648510 /* ScreenSaverView.swift in Sources */, 2F55E7BD2DEE44A800EC8350 /* ClientCertificatesView.swift in Sources */, DA6B2EF72C8B92E800DF77CF /* SelectionView.swift in Sources */, DA4800212D839D3A009CF127 /* AnimatedSecureTextField.swift in Sources */, @@ -1631,10 +1657,12 @@ DA2AEBA02D92FB6500897D80 /* NoIconDisplayableCell.swift in Sources */, DA2AEB702D92CF3E00897D80 /* UITableViewCellExtension.swift in Sources */, DA4800162D836EF0009CF127 /* MainUISettingsView.swift in Sources */, + 652B81042E2193B500648510 /* ScreenSaverSettingsView.swift in Sources */, DAEAA89F21E6B16600267EA3 /* UITableView.swift in Sources */, 2F55E7BB2DEE447700EC8350 /* SettingsView.swift in Sources */, DA4800182D837221009CF127 /* AboutSettingsView.swift in Sources */, 653B54C2285E714900298ECD /* OpenHABViewController.swift in Sources */, + 65C2EF492E244C8500A0C19F /* OpenHABNavigationController.swift in Sources */, DA2AEB6E2D92BAD800897D80 /* PageLoader.swift in Sources */, DFA16EC118898A8400EDB0BB /* SegmentedUITableViewCell.swift in Sources */, DAF0A28D2C56EF8900A14A6A /* SetpointCell.swift in Sources */, @@ -2525,6 +2553,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 50; DEVELOPMENT_TEAM = PBAPXHRAM9; + ENABLE_DEBUG_DYLIB = YES; GCC_PRECOMPILE_PREFIX_HEADER = YES; GCC_PREFIX_HEADER = "openHAB/openHAB-Prefix.pch"; INFOPLIST_FILE = "openHAB/openHAB-Info.plist"; diff --git a/openHAB/AppDelegate.swift b/openHAB/AppDelegate.swift index 1c770788f..ca5d3d0f9 100644 --- a/openHAB/AppDelegate.swift +++ b/openHAB/AppDelegate.swift @@ -103,6 +103,27 @@ class AppDelegate: UIResponder, UIApplicationDelegate { let SVGCoder = SDImageSVGCoder.shared SDImageCodersManager.shared.addCoder(SVGCoder) + /// load and start the screensaver + if let keyWindow = UIApplication.shared.firstKeyWindow { + var config = ScreenSaverConfiguration() + config.isEnabled = Preferences.screensaverEnabled + config.showsTime = Preferences.screensaverShowsTime + config.showsDate = Preferences.screensaverShowsDate + config.idleInterval = Preferences.screensaverIdleInterval + config.movementInterval = Preferences.screensaverMovementInterval + config.fontName = Preferences.screensaverFontName.isEmpty ? nil : Preferences.screensaverFontName + config.timeFontSizeRatio = CGFloat(Preferences.screensaverTimeFontRatio) + config.dateFontRelativeSize = CGFloat(Preferences.screensaverDateFontRatio) + config.enablesAutoDimming = Preferences.screensaverEnableDimming + config.dimLevel = CGFloat(Preferences.screensaverDimLevel) + config.wakeBrightnessLevel = CGFloat(Preferences.screensaverWakeBrightness) + config.showsSeconds = Preferences.screensaverShowsSeconds + config.uses24HourTime = Preferences.screensaverUse24Hour + config.restoresBrightness = Preferences.screensaverRestoreBrightness + + ScreenSaverManager.shared.startMonitoring(window: keyWindow, configuration: config) + } + return true } @@ -307,6 +328,9 @@ extension AppDelegate: UNUserNotificationCenterDelegate { // ✅ Ensure this runs on the MainActor @MainActor private func notifyNotificationListeners(action: String?, cloudUserId: String? = nil) { + // Wake up screen saver immediately on incoming notification interaction + NotificationCenter.default.post(name: .wakeScreenSaver, object: nil) + if let navigationController = window?.rootViewController as? UINavigationController, let rootViewController = navigationController.viewControllers.first as? OpenHABRootViewController { rootViewController.handleNotification(action: action, cloudUserId: cloudUserId) @@ -322,6 +346,7 @@ extension AppDelegate { func applicationWillResignActive(_ application: UIApplication) { // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state. // Use this method to pause ongoing tasks, disable timers, and throttle down OpenGL ES frame rates. Games should use this method to pause the game. + NotificationCenter.default.post(name: .disableScreenSaver, object: nil) } func applicationDidEnterBackground(_ application: UIApplication) { @@ -335,6 +360,25 @@ extension AppDelegate { func applicationDidBecomeActive(_ application: UIApplication) { // Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface. + if let keyWindow = UIApplication.shared.firstKeyWindow { + var config = ScreenSaverConfiguration() + config.isEnabled = Preferences.screensaverEnabled + config.showsTime = Preferences.screensaverShowsTime + config.showsDate = Preferences.screensaverShowsDate + config.idleInterval = Preferences.screensaverIdleInterval + config.movementInterval = Preferences.screensaverMovementInterval + config.fontName = Preferences.screensaverFontName.isEmpty ? nil : Preferences.screensaverFontName + config.timeFontSizeRatio = CGFloat(Preferences.screensaverTimeFontRatio) + config.dateFontRelativeSize = CGFloat(Preferences.screensaverDateFontRatio) + config.enablesAutoDimming = Preferences.screensaverEnableDimming + config.dimLevel = CGFloat(Preferences.screensaverDimLevel) + config.wakeBrightnessLevel = CGFloat(Preferences.screensaverWakeBrightness) + config.showsSeconds = Preferences.screensaverShowsSeconds + config.uses24HourTime = Preferences.screensaverUse24Hour + config.restoresBrightness = Preferences.screensaverRestoreBrightness + + ScreenSaverManager.shared.startMonitoring(window: keyWindow, configuration: config) + } } func applicationWillTerminate(_ application: UIApplication) { diff --git a/openHAB/Main.storyboard b/openHAB/Main.storyboard index 0cea1c05d..46c53046d 100644 --- a/openHAB/Main.storyboard +++ b/openHAB/Main.storyboard @@ -57,12 +57,12 @@ - + @@ -732,10 +732,10 @@ - + - + diff --git a/openHAB/OpenHABNavigationController.swift b/openHAB/OpenHABNavigationController.swift new file mode 100644 index 000000000..294896c6b --- /dev/null +++ b/openHAB/OpenHABNavigationController.swift @@ -0,0 +1,36 @@ +// Copyright (c) 2010-2025 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 + +// Copyright (c) 2010-2025 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 OpenHABCore +import UIKit + +/// This is a wrapper around UINavigationController that allows the status bar to be hidden or shown. +/// It is used to control the status bar for the entire app and is loaded from the Main storyboard entry point. +class OpenHABNavigationController: UINavigationController { + override var childForStatusBarHidden: UIViewController? { nil } + + override var prefersStatusBarHidden: Bool { + Preferences.hideStatusBar + } + + override var preferredStatusBarUpdateAnimation: UIStatusBarAnimation { .fade } +} diff --git a/openHAB/OpenHABViewController.swift b/openHAB/OpenHABViewController.swift index d5a457000..c6763e661 100644 --- a/openHAB/OpenHABViewController.swift +++ b/openHAB/OpenHABViewController.swift @@ -11,10 +11,13 @@ import Combine import OpenHABCore +import os.log import SideMenu import SwiftMessages import UIKit +private let logger = Logger(subsystem: "org.openhab.UI", category: "OpenHABViewController") + class OpenHABViewController: UIViewController { var trackerCancellables = Set() @@ -32,6 +35,7 @@ class OpenHABViewController: UIViewController { var config = SwiftMessages.Config() config.duration = .seconds(seconds: seconds) config.presentationStyle = .bottom + config.presentationContext = .view(view) SwiftMessages.hideAll() SwiftMessages.show(config: config) { let view = MessageView.viewFromNib(layout: .cardView) diff --git a/openHAB/OpenHABWebViewController.swift b/openHAB/OpenHABWebViewController.swift index d4ba95baf..bf4ab87ee 100644 --- a/openHAB/OpenHABWebViewController.swift +++ b/openHAB/OpenHABWebViewController.swift @@ -407,7 +407,8 @@ extension OpenHABWebViewController: WKNavigationDelegate { func webView(_ webView: WKWebView, respondTo challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { logger.info("Challenge.protectionSpace.authenticationMethod: \(String(describing: challenge.protectionSpace.authenticationMethod))") - + // Wake up screen saver immediately on changes to the webview + NotificationCenter.default.post(name: .wakeScreenSaver, object: nil) if let url = modifyUrl(orig: URL(string: openHABTrackedRootUrl)), challenge.protectionSpace.host == url.host { if challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust { guard let serverTrust = challenge.protectionSpace.serverTrust else { diff --git a/openHAB/ScreenSaver/ScreenSaverConfiguration.swift b/openHAB/ScreenSaver/ScreenSaverConfiguration.swift new file mode 100644 index 000000000..10a15bd8f --- /dev/null +++ b/openHAB/ScreenSaver/ScreenSaverConfiguration.swift @@ -0,0 +1,47 @@ +// Copyright (c) 2010-2025 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 CoreGraphics +import Foundation + +struct ScreenSaverConfiguration { + var idleInterval: TimeInterval = 15 + + var movementInterval: TimeInterval = 8 + + var showsTime = true + + var showsDate = true + + var showsSeconds = false + + var uses24HourTime = false + + var isEnabled = false + + var enablesAutoDimming = true + + var dimLevel: CGFloat = 0.3 + + var restoresBrightness = true + + var wakeBrightnessLevel: CGFloat = 1.0 + + /// If `nil` the system font is used for the time/date + var fontName: String? + + var timeFontSizeRatio: CGFloat = 0.2 + + /// The size of the date text, percentage value compared to the clock + var dateFontRelativeSize: CGFloat = 0.4 + + var fadeDuration: TimeInterval = 2.0 +} diff --git a/openHAB/ScreenSaver/ScreenSaverManager.swift b/openHAB/ScreenSaver/ScreenSaverManager.swift new file mode 100644 index 000000000..83c186066 --- /dev/null +++ b/openHAB/ScreenSaver/ScreenSaverManager.swift @@ -0,0 +1,211 @@ +// Copyright (c) 2010-2025 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 os.log +import UIKit + +private class ScreenSaverHostingViewController: UIViewController { + override var prefersStatusBarHidden: Bool { true } + override var preferredStatusBarUpdateAnimation: UIStatusBarAnimation { .fade } +} + +@MainActor +final class ScreenSaverManager: NSObject { + static let shared = ScreenSaverManager() + + private let logger = Logger(subsystem: "org.openhab", category: "ScreenSaver") + + private(set) var configuration = ScreenSaverConfiguration() + + private var idleTimer: Timer? + + private weak var window: UIWindow? + + private var saverView: ScreenSaverView? + + private var overlayWindow: UIWindow? + + private var previousBrightness: CGFloat? + + override private init() { + super.init() + NotificationCenter.default.addObserver(self, selector: #selector(handleDisableNotification), name: .disableScreenSaver, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(handleWakeNotification), name: .wakeScreenSaver, object: nil) + } + + func startMonitoring(window: UIWindow, configuration: ScreenSaverConfiguration = ScreenSaverConfiguration()) { + self.configuration = configuration + self.window = window + attachGestureRecognizers(to: window) + resetIdleTimer() + } + + public func updateConfiguration(_ newConfiguration: ScreenSaverConfiguration) { + configuration = newConfiguration + if saverView != nil { + dismissSaverIfNeeded() + } + + if configuration.isEnabled { + resetIdleTimer() + } + } + + private func attachGestureRecognizers(to window: UIWindow) { + let tap = UITapGestureRecognizer(target: self, action: #selector(userInteracted)) + tap.cancelsTouchesInView = false + tap.delaysTouchesEnded = false + tap.delegate = self + window.addGestureRecognizer(tap) + + let pan = UIPanGestureRecognizer(target: self, action: #selector(userInteracted)) + pan.cancelsTouchesInView = false + pan.delegate = self + window.addGestureRecognizer(pan) + } + + @objc private func userInteracted() { + dismissSaverIfNeeded() + resetIdleTimer() + } + + private func resetIdleTimer() { + idleTimer?.invalidate() + + idleTimer = Timer.scheduledTimer( + withTimeInterval: configuration.idleInterval, + repeats: false + ) { [weak self] _ in + guard let self else { return } + + Task { @MainActor in + self.showSaver() + } + } + } + + private func showSaver() { + guard configuration.isEnabled else { return } + guard saverView == nil, let baseWindow = window else { return } + logger.debug("Presenting screen saver (overlay window)") + + let overlay: UIWindow + if let scene = baseWindow.windowScene { + overlay = UIWindow(windowScene: scene) + overlay.frame = scene.coordinateSpace.bounds + } else { + overlay = UIWindow(frame: UIScreen.main.bounds) + } + overlay.windowLevel = .alert + 1 // ensure above status bar + overlay.backgroundColor = .clear + + let hostVC = ScreenSaverHostingViewController() + hostVC.view.backgroundColor = .clear + overlay.rootViewController = hostVC + overlay.makeKeyAndVisible() + + hostVC.setNeedsStatusBarAppearanceUpdate() + + let saver = ScreenSaverView(configuration: configuration) + saver.translatesAutoresizingMaskIntoConstraints = false + hostVC.view.addSubview(saver) + NSLayoutConstraint.activate([ + saver.leadingAnchor.constraint(equalTo: hostVC.view.leadingAnchor), + saver.trailingAnchor.constraint(equalTo: hostVC.view.trailingAnchor), + saver.topAnchor.constraint(equalTo: hostVC.view.topAnchor), + saver.bottomAnchor.constraint(equalTo: hostVC.view.bottomAnchor) + ]) + + // wake up if the user taps anywhere + attachGestureRecognizers(to: overlay) + + saver.alpha = 0 + UIView.animate(withDuration: 0.3) { + saver.alpha = 1.0 + } completion: { _ in + saver.startAnimation() + } + + saverView = saver + overlayWindow = overlay + applyDimming() + } + + private func dismissSaverIfNeeded() { + guard let saver = saverView else { return } + logger.debug("Dismissing screen saver") + saver.stopAnimation() + if configuration.enablesAutoDimming { + if configuration.restoresBrightness { + restoreBrightnessIfNeeded() + } else { + let target = min(max(configuration.wakeBrightnessLevel, 0.0), 1.0) + UIScreen.main.brightness = target + } + } + UIView.animate(withDuration: 0.2, animations: { + saver.alpha = 0 + }) { _ in + saver.removeFromSuperview() + } + saverView = nil + + // Tear down overlay window + overlayWindow?.isHidden = true + overlayWindow = nil + } + + private func applyDimming() { + guard configuration.enablesAutoDimming else { return } + previousBrightness = UIScreen.main.brightness + var target = configuration.dimLevel + target = min(max(target, 0.0), 1.0) + UIScreen.main.brightness = target + } + + private func restoreBrightnessIfNeeded() { + guard let original = previousBrightness else { return } + UIScreen.main.brightness = original + previousBrightness = nil + } + + /// This is used fo testing the screen saver in the Settings view (before settings are saved) + @MainActor + func presentSaver(configuration: ScreenSaverConfiguration) { + self.configuration = configuration + dismissSaverIfNeeded() + showSaver() + } + + @objc private func handleDisableNotification() { + logger.debug("Received disable screen saver notification") + idleTimer?.invalidate() + dismissSaverIfNeeded() + } + + @objc private func handleWakeNotification() { + logger.debug("Received wake screen saver notification") + resetIdleTimer() + dismissSaverIfNeeded() + } +} + +/// Notifications that other parts of the app can send to control the screensaver +extension Notification.Name { + static let disableScreenSaver = Notification.Name("disableScreenSaver") + static let wakeScreenSaver = Notification.Name("wakeScreenSaver") +} + +extension ScreenSaverManager: UIGestureRecognizerDelegate { + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { + true + } +} diff --git a/openHAB/ScreenSaver/ScreenSaverView.swift b/openHAB/ScreenSaver/ScreenSaverView.swift new file mode 100644 index 000000000..d4887b9dc --- /dev/null +++ b/openHAB/ScreenSaver/ScreenSaverView.swift @@ -0,0 +1,205 @@ +// Copyright (c) 2010-2025 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 os.log +import UIKit + +@MainActor +final class ScreenSaverView: UIView { + private let logger = Logger(subsystem: "org.openhab", category: "ScreenSaverView") + + private let configuration: ScreenSaverConfiguration + + private lazy var label: UILabel = { + let label = UILabel() + label.textColor = .white + label.textAlignment = .center + label.numberOfLines = 2 + label.translatesAutoresizingMaskIntoConstraints = false + label.alpha = 0.0 // start invisible + return label + }() + + private var movementTimer: Timer? + private let dateFormatter: DateFormatter = { + let df = DateFormatter() + df.dateStyle = .medium + df.timeStyle = .medium + return df + }() + + init(configuration: ScreenSaverConfiguration) { + self.configuration = configuration + super.init(frame: .zero) + commonInit() + } + + deinit { + movementTimer?.invalidate() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func commonInit() { + backgroundColor = UIColor.black + addSubview(label) + // pin label size but not position (we move it manually) + NSLayoutConstraint.activate([ + label.widthAnchor.constraint(lessThanOrEqualTo: widthAnchor, multiplier: 0.9) + ]) + + updateLabelText() + } + + func startAnimation() { + scheduleMovement() + } + + func stopAnimation() { + movementTimer?.invalidate() + } + + // MARK: - Private helpers + + private func scheduleMovement() { + movementTimer?.invalidate() + movementTimer = Timer.scheduledTimer(withTimeInterval: configuration.movementInterval, repeats: true) { [weak self] _ in + self?.moveLabelToRandomPosition(animated: true) + } + // perform first move immediately + moveLabelToRandomPosition(animated: false) + } + + private func updateLabelText() { + let now = Date() + + // Prepare strings + var timeString: String? + var dateString: String? + + if configuration.showsTime { + let tf = DateFormatter() + tf.dateStyle = .none + if configuration.showsSeconds { + tf.dateFormat = configuration.uses24HourTime ? "H:mm:ss" : "h:mm:ss a" + } else { + tf.dateFormat = configuration.uses24HourTime ? "H:mm" : "h:mm a" + } + timeString = tf.string(from: now) + } + + if configuration.showsDate { + let df = DateFormatter() + df.dateStyle = .medium + df.timeStyle = .none + dateString = df.string(from: now) + } + + // Compute dynamic font sizes based on the current view size. + let shortSide = min(bounds.width, bounds.height) + let timeFontSize = max(shortSide * configuration.timeFontSizeRatio, 48) + let dateFontSize = timeFontSize * configuration.dateFontRelativeSize + + let timeFont: UIFont = if let name = configuration.fontName, let custom = UIFont(name: name, size: timeFontSize) { + custom + } else { + UIFont.monospacedDigitSystemFont(ofSize: timeFontSize, weight: .thin) + } + + let dateFont: UIFont = if let name = configuration.fontName, let custom = UIFont(name: name, size: dateFontSize) { + custom + } else { + UIFont.systemFont(ofSize: dateFontSize, weight: .regular) + } + + // Use a square-root curve so the text dims more gently at first and + // only gets very dark at the lowest levels + let alphaFactor: CGFloat = { + let clamped = min(max(configuration.dimLevel, 0.0), 1.0) + // .5 is about as dark as we can go while still visible + let alpha = 0.5 + 0.5 * sqrt(clamped) + return min(1.0, alpha) + }() + + let attributed = NSMutableAttributedString() + + // Date above time + if let dateString { + let dateAttr: [NSAttributedString.Key: Any] = [ + .font: dateFont, + .foregroundColor: UIColor.white.withAlphaComponent(0.85 * alphaFactor) + ] + attributed.append(NSAttributedString(string: dateString, attributes: dateAttr)) + } + + if let timeString { + if attributed.length > 0 { + attributed.append(NSAttributedString(string: "\n")) + } + let timeAttr: [NSAttributedString.Key: Any] = [ + .font: timeFont, + .foregroundColor: UIColor.white.withAlphaComponent(alphaFactor) + ] + attributed.append(NSAttributedString(string: timeString, attributes: timeAttr)) + } + + label.attributedText = attributed + } + + private func moveLabelToRandomPosition(animated: Bool) { + updateLabelText() + // Ensure layout pass so we know label size + layoutIfNeeded() + + let labelSize = label.intrinsicContentSize + // Ensure the label fully fits within the view + guard bounds.width > labelSize.width, bounds.height > labelSize.height else { return } + + // Keep the label away from the very edges by introducing a small margin. + let edgeMargin: CGFloat = 20 + + // Calculate the area the label can occupy after accounting for the margin on all sides. + let availableWidth = bounds.width - labelSize.width - edgeMargin * 2 + let availableHeight = bounds.height - labelSize.height - edgeMargin * 2 + + // If the view is too small to honour the margin, fall back to the original screen size. + guard availableWidth > 0, availableHeight > 0 else { + let fallbackX = CGFloat.random(in: 0 ... (bounds.width - labelSize.width)) + let fallbackY = CGFloat.random(in: 0 ... (bounds.height - labelSize.height)) + label.frame = CGRect(origin: CGPoint(x: fallbackX, y: fallbackY), size: labelSize) + return + } + + let randomX = edgeMargin + CGFloat.random(in: 0 ... availableWidth) + let randomY = edgeMargin + CGFloat.random(in: 0 ... availableHeight) + + let animations = { + self.label.frame = CGRect(origin: CGPoint(x: randomX, y: randomY), size: labelSize) + } + + if animated { + UIView.animate(withDuration: configuration.fadeDuration, delay: 0, options: [.curveEaseInOut, .allowUserInteraction]) { + self.label.alpha = 0.0 + } completion: { _ in + animations() + UIView.animate(withDuration: self.configuration.fadeDuration, delay: 0, options: [.curveEaseInOut, .allowUserInteraction]) { + self.label.alpha = 1.0 + } + } + } else { + label.alpha = 1.0 + animations() + } + } +} diff --git a/openHAB/SettingsView/ApplicationSettingsView.swift b/openHAB/SettingsView/ApplicationSettingsView.swift index 29de5f1bd..6483b722a 100644 --- a/openHAB/SettingsView/ApplicationSettingsView.swift +++ b/openHAB/SettingsView/ApplicationSettingsView.swift @@ -12,6 +12,7 @@ import OpenHABCore import os import SwiftUI +import UIKit struct ApplicationSettingsView: View { @Binding var settingsIdleOff: Bool @@ -22,6 +23,15 @@ struct ApplicationSettingsView: View { Section(header: Text(LocalizedStringKey("application_settings"))) { Toggle("Disable Idle Timeout", isOn: $settingsIdleOff) + NavigationLink("Screen Saver Settings") { + ScreenSaverSettingsView() + } + + Toggle("Hide Status Bar", isOn: Binding( + get: { Preferences.hideStatusBar }, + set: { Preferences.hideStatusBar = $0; UIApplication.shared.windows.first?.rootViewController?.setNeedsStatusBarAppearanceUpdate() } + )) + NavigationLink("Client Certificates") { ClientCertificatesView() } diff --git a/openHAB/SettingsView/ScreenSaverSettingsView.swift b/openHAB/SettingsView/ScreenSaverSettingsView.swift new file mode 100644 index 000000000..88c7d6463 --- /dev/null +++ b/openHAB/SettingsView/ScreenSaverSettingsView.swift @@ -0,0 +1,209 @@ +// Copyright (c) 2010-2025 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 OpenHABCore +import SwiftUI +import UIKit + +struct ScreenSaverSettingsView: View { + @State private var config: ScreenSaverConfiguration = { + var config = ScreenSaverConfiguration() + config.isEnabled = Preferences.screensaverEnabled + config.showsTime = Preferences.screensaverShowsTime + config.showsDate = Preferences.screensaverShowsDate + config.idleInterval = Preferences.screensaverIdleInterval + config.movementInterval = Preferences.screensaverMovementInterval + config.fontName = Preferences.screensaverFontName.isEmpty ? nil : Preferences.screensaverFontName + config.timeFontSizeRatio = CGFloat(Preferences.screensaverTimeFontRatio) + config.dateFontRelativeSize = CGFloat(Preferences.screensaverDateFontRatio) + config.enablesAutoDimming = Preferences.screensaverEnableDimming + config.dimLevel = CGFloat(Preferences.screensaverDimLevel) + config.wakeBrightnessLevel = CGFloat(Preferences.screensaverWakeBrightness) + config.showsSeconds = Preferences.screensaverShowsSeconds + config.uses24HourTime = Preferences.screensaverUse24Hour + config.fadeDuration = Preferences.screensaverFadeDuration + config.restoresBrightness = Preferences.screensaverRestoreBrightness + return config + }() + + var body: some View { + Form { + Section { + Toggle("Enable Screen Saver", isOn: Binding( + get: { config.isEnabled }, + set: { config.isEnabled = $0 } + )) + } + + Section("Appearance") { + Toggle("Show Time", isOn: Binding( + get: { config.showsTime }, + set: { newVal in config.showsTime = newVal } + )) + + Toggle("Show Date", isOn: Binding( + get: { config.showsDate }, + set: { newVal in config.showsDate = newVal } + )) + + Toggle("Show Seconds", isOn: Binding( + get: { config.showsSeconds }, + set: { config.showsSeconds = $0 } + )) + + Toggle("24-Hour Clock", isOn: Binding( + get: { config.uses24HourTime }, + set: { config.uses24HourTime = $0 } + )) + + let fontOptions: [String] = ["", "Arial", "Helvetica Neue", "Courier New", "Menlo", "Avenir Next"] + Picker("Font", selection: Binding( + get: { config.fontName ?? "" }, + set: { config.fontName = $0.isEmpty ? nil : $0 } + )) { + ForEach(fontOptions, id: \.self) { name in + Text(name.isEmpty ? "Default" : name).tag(name) + } + } + } + .disabled(!config.isEnabled) + + Section("Timing") { + Stepper(value: Binding( + get: { Int(config.idleInterval) }, + set: { config.idleInterval = TimeInterval($0) } + ), in: 5 ... 600, step: 5) { + Text("Idle Interval: \(Int(config.idleInterval)) s") + } + + Stepper(value: Binding( + get: { Int(config.movementInterval) }, + set: { config.movementInterval = TimeInterval($0) } + ), in: 2 ... 60, step: 1) { + Text("Movement Interval: \(Int(config.movementInterval)) s") + } + } + .disabled(!config.isEnabled) + + Section("Font Size") { + VStack(alignment: .leading) { + Text("Clock Size: \(Int(config.timeFontSizeRatio * 100)) %") + .font(.caption) + Slider(value: Binding( + get: { Double(config.timeFontSizeRatio) }, + set: { config.timeFontSizeRatio = CGFloat($0) } + ), in: 0.05 ... 0.4, step: 0.01) + } + + VStack(alignment: .leading) { + Text("Date relative: \(Int(config.dateFontRelativeSize * 100)) %") + .font(.caption) + Slider(value: Binding( + get: { Double(config.dateFontRelativeSize) }, + set: { config.dateFontRelativeSize = CGFloat($0) } + ), in: 0.1 ... 1.0, step: 0.05) + } + } + .disabled(!config.isEnabled) + + Section("Animation") { + VStack(alignment: .leading) { + Text("Fade Duration: \(String(format: "%.1f", config.fadeDuration)) s") + .font(.caption) + Slider(value: Binding( + get: { config.fadeDuration }, + set: { config.fadeDuration = $0 } + ), in: 0.1 ... 3.0, step: 0.1) + } + } + .disabled(!config.isEnabled) + + Section("Brightness") { + Toggle("Enable Dimming", isOn: Binding( + get: { config.enablesAutoDimming }, + set: { config.enablesAutoDimming = $0 } + )) + + VStack(alignment: .leading) { + Text("Dim Level: \(Int(config.dimLevel * 100)) %") + .font(.caption) + Slider(value: Binding( + get: { Double(config.dimLevel * 100) }, + set: { config.dimLevel = CGFloat($0) / 100 } + ), in: 0 ... 100, step: 1) + } + .disabled(!config.enablesAutoDimming) + + Toggle("Restore Previous Brightness on Wake", isOn: Binding( + get: { config.restoresBrightness }, + set: { config.restoresBrightness = $0 } + )).disabled(!config.enablesAutoDimming) + + VStack(alignment: .leading) { + Text("Restore Brightness: \(Int(config.wakeBrightnessLevel * 100)) %") + .font(.caption) + Slider(value: Binding( + get: { Double(config.wakeBrightnessLevel * 100) }, + set: { config.wakeBrightnessLevel = CGFloat($0) / 100 } + ), in: 0 ... 100, step: 1) + } + .disabled(!config.enablesAutoDimming || config.restoresBrightness) + } + .disabled(!config.isEnabled) + + Section { + Button("Test Screen Saver") { + if let keyWindow = UIApplication.shared.keyWindowActiveScene { + // Ensure the manager knows about the current key window in case monitoring was not started yet. + ScreenSaverManager.shared.startMonitoring(window: keyWindow, configuration: config) + } + ScreenSaverManager.shared.presentSaver(configuration: config) + } + } + } + .navigationTitle("Screen Saver") + .onDisappear { + ScreenSaverManager.shared.updateConfiguration(config) + // Persist to Preferences + Preferences.screensaverEnabled = config.isEnabled + Preferences.screensaverShowsTime = config.showsTime + Preferences.screensaverShowsDate = config.showsDate + Preferences.screensaverIdleInterval = config.idleInterval + Preferences.screensaverMovementInterval = config.movementInterval + Preferences.screensaverFontName = config.fontName ?? "" + Preferences.screensaverTimeFontRatio = Double(config.timeFontSizeRatio) + Preferences.screensaverDateFontRatio = Double(config.dateFontRelativeSize) + Preferences.screensaverEnableDimming = config.enablesAutoDimming + Preferences.screensaverDimLevel = Double(config.dimLevel) + Preferences.screensaverWakeBrightness = Double(config.wakeBrightnessLevel) + Preferences.screensaverShowsSeconds = config.showsSeconds + Preferences.screensaverUse24Hour = config.uses24HourTime + Preferences.screensaverFadeDuration = config.fadeDuration + Preferences.screensaverRestoreBrightness = config.restoresBrightness + } + } +} + +extension UIApplication { + var keyWindowActiveScene: UIWindow? { + connectedScenes + .compactMap { $0 as? UIWindowScene } + .first { $0.activationState == .foregroundActive }? + .windows + .first { $0.isKeyWindow } + } +} + +#Preview { + NavigationView { + ScreenSaverSettingsView() + } +} diff --git a/openHAB/SettingsView/SettingsView.swift b/openHAB/SettingsView/SettingsView.swift index 4f2145716..6460361cc 100644 --- a/openHAB/SettingsView/SettingsView.swift +++ b/openHAB/SettingsView/SettingsView.swift @@ -145,6 +145,13 @@ struct SettingsView: View { } Preferences.idleOff = settingsIdleOff Preferences.sendCrashReports = settingsSendCrashReports + + // Apply global UI changes immediately (status bar visibility) + UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap(\.windows) + .first?.rootViewController? + .setNeedsStatusBarAppearanceUpdate() } } From 5c36868d3b707f53c3348e13c8daf38ad99b25dd Mon Sep 17 00:00:00 2001 From: Dan Cunningham Date: Thu, 24 Jul 2025 06:39:13 -0700 Subject: [PATCH 279/476] Make sure webviews rotates Signed-off-by: Dan Cunningham --- openHAB/OpenHABWebViewController.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openHAB/OpenHABWebViewController.swift b/openHAB/OpenHABWebViewController.swift index bf4ab87ee..18cc96edc 100644 --- a/openHAB/OpenHABWebViewController.swift +++ b/openHAB/OpenHABWebViewController.swift @@ -291,7 +291,8 @@ class OpenHABWebViewController: OpenHABViewController { let webview = WKWebView(frame: view.bounds, configuration: config) webview.navigationDelegate = self webview.uiDelegate = self - webView.autoresizingMask = [.flexibleWidth, .flexibleHeight] +q // Ensure the newly created webview resizes properly on rotation + webview.autoresizingMask = [.flexibleWidth, .flexibleHeight] webView.scrollView.bounces = false // support dark mode and avoid white flashing when loading webView.isOpaque = false @@ -407,8 +408,7 @@ extension OpenHABWebViewController: WKNavigationDelegate { func webView(_ webView: WKWebView, respondTo challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { logger.info("Challenge.protectionSpace.authenticationMethod: \(String(describing: challenge.protectionSpace.authenticationMethod))") - // Wake up screen saver immediately on changes to the webview - NotificationCenter.default.post(name: .wakeScreenSaver, object: nil) + if let url = modifyUrl(orig: URL(string: openHABTrackedRootUrl)), challenge.protectionSpace.host == url.host { if challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust { guard let serverTrust = challenge.protectionSpace.serverTrust else { From f4debbf0365fd0fa569e23057bc95e9b42d1fb36 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Thu, 24 Jul 2025 23:14:52 +0200 Subject: [PATCH 280/476] staticIcon tests for Endpoint Revisited handling of labelText Signed-off-by: Tim Mueller-Seydlitz --- .../OpenHABCore/Model/OpenHABWidget.swift | 9 +- .../Sources/OpenHABCore/Util/Endpoint.swift | 54 ++- .../OpenHABCoreTests/EndpointTests.swift | 350 ++++++++++++++++++ openHAB/SwiftUI/Rows/ColorPickerRowView.swift | 2 +- .../Rows/ColorTemperaturePickerRowView.swift | 2 +- .../SwiftUI/Rows/DatePickerInputRowView.swift | 2 +- .../SwiftUI/Rows/RollershutterRowView.swift | 2 +- openHAB/SwiftUI/Rows/SegmentedRowView.swift | 2 +- openHAB/SwiftUI/Rows/SelectionRowView.swift | 2 +- openHAB/SwiftUI/Rows/SetpointRowView.swift | 2 +- openHAB/SwiftUI/Rows/SliderRowView.swift | 2 +- openHAB/SwiftUI/Rows/SwitchRowView.swift | 2 +- openHAB/SwiftUI/Rows/TextInputRowView.swift | 2 +- 13 files changed, 401 insertions(+), 32 deletions(-) create mode 100644 OpenHABCore/Tests/OpenHABCoreTests/EndpointTests.swift diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift index 874a35bb2..db2f674cd 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift @@ -131,6 +131,8 @@ public class OpenHABWidget: NSObject, MKAnnotation, Identifiable, ObservableObje item?.stateDescription?.readOnly } + public var staticIcon: Bool? + @Published public var stateEnumBinding: WidgetTypeEnum = .unassigned // Text prior to "[" @@ -370,7 +372,8 @@ public extension OpenHABWidget { column: Int? = nil, releaseCommand: String? = nil, command: String? = nil, - stateless: Bool? = nil) { + stateless: Bool? = nil, + staticIcon: Bool? = nil) { self.init() id = widgetId self.widgetId = widgetId @@ -426,6 +429,7 @@ public extension OpenHABWidget { self.releaseCommand = releaseCommand self.command = command self.stateless = stateless + self.staticIcon = staticIcon } convenience init(icon: String, iconColor: String? = nil) { @@ -556,7 +560,8 @@ extension OpenHABWidget { column: widget.column.map { Int($0) }, releaseCommand: widget.releaseCommand, command: widget.command, - stateless: widget.stateless + stateless: widget.stateless, + staticIcon: widget.staticIcon ) } } diff --git a/OpenHABCore/Sources/OpenHABCore/Util/Endpoint.swift b/OpenHABCore/Sources/OpenHABCore/Util/Endpoint.swift index 89a4340e6..73449f3b7 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/Endpoint.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/Endpoint.swift @@ -11,6 +11,7 @@ import Foundation import os.log +import UIKit public enum ChartStyle { case dark @@ -61,7 +62,7 @@ public extension Endpoint { components?.path = path components?.queryItems = queryItems let url = components?.url -// Endpoint.logger.debug("URL: \(url?.absoluteString ?? "", privacy: .private)") + Endpoint.logger.debug("URL: \(url?.absoluteString ?? "", privacy: .private)") return url } @@ -131,26 +132,25 @@ public extension Endpoint { } // swiftlint:disable:next function_parameter_count - static func icon(rootUrl: String, version: Int, icon: String?, state: String?, iconType: IconType, iconColor: String) -> Endpoint { - guard var icon, !icon.isEmpty else { + static func icon(rootUrl: String, version: Int, icon: String?, state: String?, iconType: IconType, iconColor: String, staticIcon: Bool? = nil) -> Endpoint { + guard let icon, !icon.isEmpty else { return Endpoint(baseURL: "", path: "", queryItems: []) } // determineOH2IconPath var queryItems: [URLQueryItem] = [] - if let state { - queryItems.append(contentsOf: [ - URLQueryItem(name: "state", value: state) - ]) - } + + var source = "oh" + var set = "classic" + var iconName = "none" + if version >= 4 { let components = icon.components(separatedBy: ":") - var source = "" - var set = "" + if components.count == 3 { source = components[0] set = components[1] - icon = components[2] + iconName = components[2] } else if components.count == 2 { source = components[0] if source == "material" { @@ -158,35 +158,49 @@ public extension Endpoint { } else { set = "classic" } - icon = components[1] + iconName = components[1] + } else if components.count == 1 { + iconName = icon } if source == "material" { source = "iconify" - icon = icon.replacingOccurrences(of: "_", with: "-") - icon = "\(set)-\(icon)" + iconName = iconName.replacingOccurrences(of: "_", with: "-") + iconName = "\(set)-\(iconName)" set = "ic" } if source == "f7" { source = "iconify" set = "f7" - icon = icon.replacingOccurrences(of: "_", with: "-") + iconName = iconName.replacingOccurrences(of: "_", with: "-") } if source == "if" || source == "iconify" { queryItems = [URLQueryItem(name: "height", value: "64")] - if !iconColor.isEmpty { - queryItems.append(URLQueryItem(name: "color", value: iconColor)) + if !iconColor.isEmpty, let colorString = UIColor(fromString: iconColor).toHex() { + queryItems.append(URLQueryItem(name: "color", value: "#\(colorString)")) } return Endpoint( baseURL: "https://api.iconify.design/", - path: "/\(set)/\(icon).svg", + path: "/\(set)/\(iconName).svg", queryItems: queryItems ) } } + + // set unknown iconSource to oh:classic:none icon + if source != "oh" { + set = "classic" + iconName = "none" + } + + if staticIcon != true { + queryItems.append(URLQueryItem(name: "state", value: state ?? "null")) + } + if version >= 3 { queryItems.append(contentsOf: [ URLQueryItem(name: "format", value: (iconType == .png) ? "PNG" : "SVG"), - URLQueryItem(name: "anyFormat", value: "true") + URLQueryItem(name: "anyFormat", value: "true"), + URLQueryItem(name: "iconset", value: set) ]) } else { queryItems.append( @@ -196,7 +210,7 @@ public extension Endpoint { return Endpoint( baseURL: rootUrl, - path: "/icon/\(icon)", + path: "/icon/\(iconName)", queryItems: queryItems ) } diff --git a/OpenHABCore/Tests/OpenHABCoreTests/EndpointTests.swift b/OpenHABCore/Tests/OpenHABCoreTests/EndpointTests.swift new file mode 100644 index 000000000..2d411f840 --- /dev/null +++ b/OpenHABCore/Tests/OpenHABCoreTests/EndpointTests.swift @@ -0,0 +1,350 @@ +// Copyright (c) 2010-2025 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 Foundation +@testable import OpenHABCore +import Testing + +struct EndpointsTests { + @Test + func emptyIconReturnsEmptyEndpoint() { + let endpoint = Endpoint.icon( + rootUrl: "https://example.org", + version: 3, + icon: nil, + state: "ON", + iconType: .svg, + iconColor: "#FF0000" + ) + + #expect(endpoint.baseURL.isEmpty) + #expect(endpoint.path.isEmpty) + #expect(endpoint.queryItems.isEmpty) + } + + @Test + func simpleIconName() { + let endpoint = Endpoint.icon( + rootUrl: "https://example.org", + version: 4, + icon: "switch", + state: "OFF", + iconType: .svg, + iconColor: "#00FF00" + ) + + #expect(endpoint.baseURL == "https://example.org") + #expect(endpoint.path == "/icon/switch") + #expect(endpoint.queryItems.contains(URLQueryItem(name: "state", value: "OFF"))) + #expect(endpoint.queryItems.contains(URLQueryItem(name: "format", value: "SVG"))) + } + +// @Test +// func iconWithTwoSegments_material() { +// let endpoint = Endpoint.icon( +// rootUrl: "https://example.org", +// version: 4, +// icon: "material:bolt", +// state: "ON", +// iconType: .png, +// iconColor: "#123456" +// ) +// +// #expect(endpoint.path == "/ic/baseline.svg") +// #expect(endpoint.queryItems.contains(URLQueryItem(name: "color", value: "#123456"))) +// } + + @Test + func iconWithThreeSegments_customSource() { + let endpoint = Endpoint.icon( + rootUrl: "https://example.org", + version: 4, + icon: "f7:solid:home", + state: "ON", + iconType: .svg, + iconColor: "#ABCDEF" + ) + + #expect(endpoint.path == "/f7/home.svg") + #expect(endpoint.queryItems.contains(URLQueryItem(name: "color", value: "#ABCDEF"))) + } + + @Test + func iconColorString() { + let endpoint = Endpoint.icon( + rootUrl: "https://example.org", + version: 4, + icon: "f7:solid:home", + state: "ON", + iconType: .svg, + iconColor: "red" + ) + + #expect(endpoint.path == "/f7/home.svg") + #expect(endpoint.queryItems.contains(URLQueryItem(name: "color", value: "#FF0000"))) + } + + @Test + func ohIcon1() { + let endpoint = Endpoint.icon( + rootUrl: "https://example.org", + version: 4, + icon: "light", + state: "ON", + iconType: .png, + iconColor: "red" + ) + + #expect(endpoint.path == "/icon/light") + #expect(endpoint.queryItems.contains(URLQueryItem(name: "format", value: "PNG"))) + } + + @Test + func ohIcon2() { + let endpoint = Endpoint.icon( + rootUrl: "https://example.org", + version: 4, + icon: "oh:light", + state: "ON", + iconType: .png, + iconColor: "red" + ) + + #expect(endpoint.path == "/icon/light") + #expect(endpoint.queryItems.contains(URLQueryItem(name: "format", value: "PNG"))) + } + + @Test + func ohIcon3() { + let endpoint = Endpoint.icon( + rootUrl: "https://example.org", + version: 4, + icon: "oh:classic:light", + state: "ON", + iconType: .png, + iconColor: "red" + ) + + #expect(endpoint.path == "/icon/light") + #expect(endpoint.queryItems.contains(URLQueryItem(name: "format", value: "PNG"))) + } + + @Test + func ohIcon4() { + let endpoint = Endpoint.icon( + rootUrl: "https://example.org", + version: 4, + icon: "oh:custom:light", + state: "ON", + iconType: .png, + iconColor: "red" + ) + + #expect(endpoint.path == "/icon/light") + #expect(endpoint.queryItems.contains(URLQueryItem(name: "format", value: "PNG"))) + } + + // TODO: test for iconset +// "light" to "icon/light?format=PNG&anyFormat=true&iconset=classic", +// "oh:light" to "icon/light?format=PNG&anyFormat=true&iconset=classic", +// "oh:classic:light" to "icon/light?format=PNG&anyFormat=true&iconset=classic", +// "oh:custom:light" to "icon/light?format=PNG&anyFormat=true&iconset=custom" + + // Test no state is transmitted + // Test baseURL + @Test + func materialIcon1() { + let endpoint = Endpoint.icon( + rootUrl: "https://example.org", + version: 4, + icon: "material:light", + state: "UP", + iconType: .svg, + iconColor: "#000000" + ) + + // underscore should become "-" + #expect(endpoint.path == "/ic/baseline-light.svg") + // Test api.iconifyd.design + } + + @Test + func materialIcon2() { + let endpoint = Endpoint.icon( + rootUrl: "https://example.org", + version: 4, + icon: "material:outline:light", + state: "UP", + iconType: .svg, + iconColor: "#000000" + ) + + // underscore should become "-" + #expect(endpoint.path == "/ic/outline-light.svg") + // Test api.iconifyd.design + } + + @Test + func f7Icons1() { + let endpoint = Endpoint.icon( + rootUrl: "https://example.org", + version: 4, + icon: "f7:airplane", + state: "UP", + iconType: .svg, + iconColor: "#000000" + ) + + #expect(endpoint.path == "/f7/airplane.svg") + // Test api.iconifyd.design + } + + @Test + func f7Icons2() { + let endpoint = Endpoint.icon( + rootUrl: "https://example.org", + version: 4, + icon: "f7:IGNORED:airplane", + state: "UP", + iconType: .svg, + iconColor: "#000000" + ) + + #expect(endpoint.path == "/f7/airplane.svg") + // Test api.iconifyd.design + } + + @Test + func iconifyIcons1() { + let endpoint = Endpoint.icon( + rootUrl: "https://example.org", + version: 4, + icon: "if:codicon:lightbulb", + state: "UP", + iconType: .svg, + iconColor: "#000000" + ) + + #expect(endpoint.path == "/codicon/lightbulb.svg") + } + + @Test + func iconifyIcons2() { + let endpoint = Endpoint.icon( + rootUrl: "https://example.org", + version: 4, + icon: "iconify:codicon:lightbulb", + state: "UP", + iconType: .svg, + iconColor: "#000000" + ) + + #expect(endpoint.path == "/codicon/lightbulb.svg") + } + + @Test + func unknownIconSources1() { + let endpoint = Endpoint.icon( + rootUrl: "https://example.org", + version: 4, + icon: "unknown:ignored", + state: "UP", + iconType: .svg, + iconColor: "#000000" + ) + + #expect(endpoint.path == "/icon/none") + } + + @Test + func unknownIconSources2() { + let endpoint = Endpoint.icon( + rootUrl: "https://example.org", + version: 4, + icon: "unknown:ignored:ignored", + state: "UP", + iconType: .svg, + iconColor: "#000000" + ) + + #expect(endpoint.path == "/icon/none") + } + + @Test + func noneIcons1() { + let endpoint = Endpoint.icon( + rootUrl: "https://example.org", + version: 4, + icon: "none", + state: "UP", + iconType: .svg, + iconColor: "#000000" + ) + + #expect(endpoint.path == "/icon/none") + } + + @Test + func noneIcons2() { + let endpoint = Endpoint.icon( + rootUrl: "https://example.org", + version: 4, + icon: "oh:none", + state: "UP", + iconType: .svg, + iconColor: "#000000" + ) + + #expect(endpoint.path == "/icon/none") + } + + @Test + func noneIcons3() { + let endpoint = Endpoint.icon( + rootUrl: "https://example.org", + version: 4, + icon: "oh:classic:none", + state: "UP", + iconType: .svg, + iconColor: "#000000" + ) + + #expect(endpoint.path == "/icon/none") + } + + @Test + func noneIcons4() { + let endpoint = Endpoint.icon( + rootUrl: "https://example.org", + version: 4, + icon: "oh:foo:none", + state: "UP", + iconType: .svg, + iconColor: "#000000" + ) + + #expect(endpoint.path == "/icon/none") + } + + @Test + func noneIcons5() { + let endpoint = Endpoint.icon( + rootUrl: "https://example.org", + version: 4, + icon: "f7:none", + state: "UP", + iconType: .svg, + iconColor: "#000000" + ) + + #expect(endpoint.path == "/f7/none.svg") + } +} diff --git a/openHAB/SwiftUI/Rows/ColorPickerRowView.swift b/openHAB/SwiftUI/Rows/ColorPickerRowView.swift index c37d89c76..1e7c5a3e9 100644 --- a/openHAB/SwiftUI/Rows/ColorPickerRowView.swift +++ b/openHAB/SwiftUI/Rows/ColorPickerRowView.swift @@ -26,7 +26,7 @@ struct ColorPickerRowView: View { IconView(widget: widget) .frame(width: 24, height: 24) - if let labelText = widget.labelText, !labelText.isEmpty, widget.labelSource == .sitemapDefinition { + if let labelText = widget.labelText, !labelText.isEmpty { Text(labelText) .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) } diff --git a/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift b/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift index 5eb6e2511..e6e7527d1 100644 --- a/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift +++ b/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift @@ -77,7 +77,7 @@ struct ColorTemperaturePickerRowView: View { VStack(spacing: 8) { HStack { - if let labelText = widget.labelText, !labelText.isEmpty, widget.labelSource == .sitemapDefinition { + if let labelText = widget.labelText, !labelText.isEmpty { Text(labelText) .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) } diff --git a/openHAB/SwiftUI/Rows/DatePickerInputRowView.swift b/openHAB/SwiftUI/Rows/DatePickerInputRowView.swift index ae6ca6bcb..6ecbb506b 100644 --- a/openHAB/SwiftUI/Rows/DatePickerInputRowView.swift +++ b/openHAB/SwiftUI/Rows/DatePickerInputRowView.swift @@ -39,7 +39,7 @@ struct DatePickerInputRowView: View { IconView(widget: widget) .frame(width: 24, height: 24) - if let labelText = widget.labelText, !labelText.isEmpty, widget.labelSource == .sitemapDefinition { + if let labelText = widget.labelText, !labelText.isEmpty { Text(labelText) .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) } diff --git a/openHAB/SwiftUI/Rows/RollershutterRowView.swift b/openHAB/SwiftUI/Rows/RollershutterRowView.swift index 1d51b7321..0aa66e975 100644 --- a/openHAB/SwiftUI/Rows/RollershutterRowView.swift +++ b/openHAB/SwiftUI/Rows/RollershutterRowView.swift @@ -39,7 +39,7 @@ struct RollershutterRowView: View { } VStack(alignment: .leading, spacing: 2) { - if let labelText = widget.labelText, !labelText.isEmpty, widget.labelSource == .sitemapDefinition { + if let labelText = widget.labelText, !labelText.isEmpty { Text(labelText) .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) } diff --git a/openHAB/SwiftUI/Rows/SegmentedRowView.swift b/openHAB/SwiftUI/Rows/SegmentedRowView.swift index cd0bfc91f..bb34565d1 100644 --- a/openHAB/SwiftUI/Rows/SegmentedRowView.swift +++ b/openHAB/SwiftUI/Rows/SegmentedRowView.swift @@ -38,7 +38,7 @@ struct SegmentedRowView: View { .padding(.top, 4) // Align with text } - if let labelText = widget.labelText, !labelText.isEmpty, widget.labelSource == .sitemapDefinition { + if let labelText = widget.labelText, !labelText.isEmpty { Text(labelText) .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) } diff --git a/openHAB/SwiftUI/Rows/SelectionRowView.swift b/openHAB/SwiftUI/Rows/SelectionRowView.swift index 099ddc726..d43a98099 100644 --- a/openHAB/SwiftUI/Rows/SelectionRowView.swift +++ b/openHAB/SwiftUI/Rows/SelectionRowView.swift @@ -32,7 +32,7 @@ struct SelectionRowView: View { .frame(width: 24, height: 24) .padding(.top, 4) // Align with text - if let labelText = widget.labelText, !labelText.isEmpty, widget.labelSource == .sitemapDefinition { + if let labelText = widget.labelText, !labelText.isEmpty { Text(labelText) .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) } diff --git a/openHAB/SwiftUI/Rows/SetpointRowView.swift b/openHAB/SwiftUI/Rows/SetpointRowView.swift index 1052bba67..d457d0cd3 100644 --- a/openHAB/SwiftUI/Rows/SetpointRowView.swift +++ b/openHAB/SwiftUI/Rows/SetpointRowView.swift @@ -45,7 +45,7 @@ struct SetpointRowView: View { IconView(widget: widget) .frame(width: 24, height: 24) - if let labelText = widget.labelText, !labelText.isEmpty, widget.labelSource == .sitemapDefinition { + if let labelText = widget.labelText, !labelText.isEmpty { Text(labelText) .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) } diff --git a/openHAB/SwiftUI/Rows/SliderRowView.swift b/openHAB/SwiftUI/Rows/SliderRowView.swift index d95871832..442f9417d 100644 --- a/openHAB/SwiftUI/Rows/SliderRowView.swift +++ b/openHAB/SwiftUI/Rows/SliderRowView.swift @@ -37,7 +37,7 @@ struct SliderRowView: View { IconView(widget: widget) .frame(width: 24, height: 24) - if let labelText = widget.labelText, !labelText.isEmpty, widget.labelSource == .sitemapDefinition { + if let labelText = widget.labelText, !labelText.isEmpty { Text(labelText) .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) } diff --git a/openHAB/SwiftUI/Rows/SwitchRowView.swift b/openHAB/SwiftUI/Rows/SwitchRowView.swift index ce1088a35..6a6f8bd44 100644 --- a/openHAB/SwiftUI/Rows/SwitchRowView.swift +++ b/openHAB/SwiftUI/Rows/SwitchRowView.swift @@ -40,7 +40,7 @@ struct SwitchRowView: View { .frame(width: 24, height: 24) } - if let labelText = widget.labelText, !labelText.isEmpty, widget.labelSource == .sitemapDefinition { + if let labelText = widget.labelText, !labelText.isEmpty { Text(labelText) .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) } diff --git a/openHAB/SwiftUI/Rows/TextInputRowView.swift b/openHAB/SwiftUI/Rows/TextInputRowView.swift index 927a35122..ce267d66f 100644 --- a/openHAB/SwiftUI/Rows/TextInputRowView.swift +++ b/openHAB/SwiftUI/Rows/TextInputRowView.swift @@ -27,7 +27,7 @@ struct TextInputRowView: View { IconView(widget: widget) .frame(width: 24, height: 24) - if let labelText = widget.labelText, !labelText.isEmpty, widget.labelSource == .sitemapDefinition { + if let labelText = widget.labelText, !labelText.isEmpty { Text(labelText) .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) } From 86971144b23911225d1456701a05679f77270c55 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Fri, 25 Jul 2025 00:06:24 +0200 Subject: [PATCH 281/476] Blocking letter q in OpenHABWebViewController.swift Signed-off-by: Tim Mueller-Seydlitz --- openHAB/OpenHABWebViewController.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openHAB/OpenHABWebViewController.swift b/openHAB/OpenHABWebViewController.swift index 18cc96edc..a78f2e015 100644 --- a/openHAB/OpenHABWebViewController.swift +++ b/openHAB/OpenHABWebViewController.swift @@ -291,7 +291,7 @@ class OpenHABWebViewController: OpenHABViewController { let webview = WKWebView(frame: view.bounds, configuration: config) webview.navigationDelegate = self webview.uiDelegate = self -q // Ensure the newly created webview resizes properly on rotation + // Ensure the newly created webview resizes properly on rotation webview.autoresizingMask = [.flexibleWidth, .flexibleHeight] webView.scrollView.bounces = false // support dark mode and avoid white flashing when loading From 14cd35c723f5cf5c9cb21cab5901876e8ab65b22 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Sun, 6 Jul 2025 14:00:46 +0200 Subject: [PATCH 282/476] Enable f7 icons as for android app. Align with android app Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../Sources/OpenHABCore/Util/Endpoint.swift | 91 ++++++++++++++++- .../OpenHABCoreTests/EndpointTests.swift | 98 +++++++++++++++++++ .../OpenHABCoreGeneralTests.swift | 12 --- 3 files changed, 187 insertions(+), 14 deletions(-) create mode 100644 OpenHABCore/Tests/OpenHABCoreTests/EndpointTests.swift diff --git a/OpenHABCore/Sources/OpenHABCore/Util/Endpoint.swift b/OpenHABCore/Sources/OpenHABCore/Util/Endpoint.swift index c78061817..4eadb1bea 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/Endpoint.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/Endpoint.swift @@ -47,7 +47,7 @@ public enum SortSitemapsOrder: Int, CaseIterable, CustomStringConvertible { } } -public struct Endpoint { +public struct Endpoint: Equatable { static let logger = Logger(subsystem: "org.openhab.app", category: "EndPoint") let baseURL: String @@ -131,7 +131,7 @@ public extension Endpoint { } // swiftlint:disable:next function_parameter_count - static func icon(rootUrl: String, version: Int, icon: String?, state: String, iconType: IconType, iconColor: String) -> Endpoint { + static func icon1(rootUrl: String, version: Int, icon: String?, state: String, iconType: IconType, iconColor: String) -> Endpoint { guard var icon, !icon.isEmpty else { return Endpoint(baseURL: "", path: "", queryItems: []) } @@ -198,6 +198,93 @@ public extension Endpoint { ) } + // swiftlint:disable:next function_parameter_count + static func icon(rootUrl: String, version: Int, icon: String?, state: String, iconType: IconType, iconColor: String) -> Endpoint { + guard let icon, !icon.isEmpty else { + return Endpoint(baseURL: "", path: "", queryItems: []) + } + + guard version >= 2 else { + return Endpoint( + baseURL: rootUrl, + path: "/icon/\(icon)", + queryItems: [] + ) + } + + var queryItems: [URLQueryItem] = [] + + var iconSource = "oh" + var iconSet = "classic" + var iconName = "none" + + let segments = icon.components(separatedBy: ":") + switch segments.count { + case 1: + iconName = segments[0] + case 2: + iconSource = segments[0] + iconName = segments[1] + if iconSource == "material" { + iconSet = "baseline" + } + case 3: + iconSource = segments[0] + iconSet = segments[1] + iconName = segments[2] + default: + break + } + + switch iconSource { + case "material": + iconSource = "iconify" + iconName = iconName.replacingOccurrences(of: "_", with: "-") + iconName = "\(iconSet)-\(iconName)" + iconSet = "ic" + case "f7": + iconSource = "iconify" + iconSet = "f7" + iconName = iconName.replacingOccurrences(of: "_", with: "-") + default: + break + } + + if iconSource == "if" || iconSource == "iconify" { + queryItems.append(URLQueryItem(name: "height", value: "64")) + if !iconColor.isEmpty { + queryItems.append(URLQueryItem(name: "color", value: iconColor)) + } + return Endpoint( + baseURL: "https://api.iconify.design/", + path: "/\(iconSet)/\(iconName).svg", + queryItems: queryItems + ) + } else { + let format = (iconType == .png) ? "PNG" : "SVG" + if iconSource != "oh" { + iconSet = "classic" + iconName = "none" + } + + var queryItems = [ + URLQueryItem(name: "format", value: format), + URLQueryItem(name: "anyFormat", value: "true"), + URLQueryItem(name: "iconset", value: iconSet) + ] + + if !state.isEmpty { + queryItems.append(URLQueryItem(name: "state", value: state)) + } + + return Endpoint( + baseURL: rootUrl, + path: "/icon/\(iconName)", + queryItems: queryItems + ) + } + } + static func iconForDrawer(rootUrl: String, icon: String) -> Endpoint { Endpoint( baseURL: rootUrl, diff --git a/OpenHABCore/Tests/OpenHABCoreTests/EndpointTests.swift b/OpenHABCore/Tests/OpenHABCoreTests/EndpointTests.swift new file mode 100644 index 000000000..22f5cf601 --- /dev/null +++ b/OpenHABCore/Tests/OpenHABCoreTests/EndpointTests.swift @@ -0,0 +1,98 @@ +// Copyright (c) 2010-2025 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 Foundation +@testable import OpenHABCore +import Testing + +struct EndpointTests { + @Test + func returnsEmptyEndpointForNilOrEmptyIcon() { + #expect( + Endpoint.icon(rootUrl: "http://192.168.2.10:8080", version: 3, icon: nil, state: "ON", iconType: .png, iconColor: "") + == Endpoint(baseURL: "", path: "", queryItems: []) + ) + + #expect( + Endpoint.icon(rootUrl: "http://192.168.2.10:8080", version: 3, icon: "", state: "OFF", iconType: .svg, iconColor: "blue") + == Endpoint(baseURL: "", path: "", queryItems: []) + ) + } + + @Test + func buildsClassicIconEndpoint() { + let result = Endpoint.icon(rootUrl: "http://192.168.2.10:8080", version: 3, icon: "light", state: "ON", iconType: .svg, iconColor: "") + + #expect(result.baseURL == "http://192.168.2.10:8080") + #expect(result.path == "/icon/light") + #expect(result.queryItems.contains(URLQueryItem(name: "format", value: "SVG"))) + #expect(result.queryItems.contains(URLQueryItem(name: "state", value: "ON"))) + } + + @Test + func buildsMaterialIconWithIconify() { + let result = Endpoint.icon(rootUrl: "http://192.168.2.10:8080", version: 3, icon: "material:light_on", state: "", iconType: .svg, iconColor: "") + + #expect(result.baseURL == "https://api.iconify.design/") + #expect(result.path == "/ic/baseline-light-on.svg") + #expect(result.queryItems.contains(URLQueryItem(name: "height", value: "64"))) + } + + @Test + func buildsF7IconWithIconify() { + let result = Endpoint.icon(rootUrl: "http://192.168.2.10:8080", version: 3, icon: "f7:alarm", state: "", iconType: .svg, iconColor: "") + + #expect(result.baseURL == "https://api.iconify.design/") + #expect(result.path == "/f7/alarm.svg") + #expect(result.queryItems.contains(URLQueryItem(name: "height", value: "64"))) + } + + @Test + func addsColorQueryForIconify() { + let result = Endpoint.icon(rootUrl: "http://192.168.2.10:8080", version: 3, icon: "material:door_open", state: "", iconType: .png, iconColor: "#ff0000") + + #expect(result.baseURL == "https://api.iconify.design/") + #expect(result.path.contains("door-open.svg")) + #expect(result.queryItems.contains(URLQueryItem(name: "color", value: "#ff0000"))) + } + + @Test + func returnsPNGIcon() { + let result = Endpoint.icon(rootUrl: "http://192.168.2.10:8080", version: 3, icon: "switch", state: "", iconType: .png, iconColor: "") + + #expect(result.queryItems.contains(URLQueryItem(name: "format", value: "PNG"))) + } + + @Test + func handlesThreeSegmentIcon() { + let result = Endpoint.icon(rootUrl: "http://192.168.2.10:8080", version: 3, icon: "if:modern:fan", state: "", iconType: .svg, iconColor: "") + + #expect(result.baseURL == "https://api.iconify.design/") + #expect(result.path == "/modern/fan.svg") + } + + @Test + func defaultsToNoneIconIfMalformed() { + let result = Endpoint.icon(rootUrl: "http://192.168.2.10:8080", version: 3, icon: "unknown:iconicIcon", state: "ON", iconType: .png, iconColor: "") + + #expect(result.baseURL == "http://192.168.2.10:8080") + #expect(result.path == "/icon/none") // fallback to 2-part icon + } + + @Test + func version2() { + let result = Endpoint.icon(rootUrl: "http://192.168.2.10:8080", version: 2, icon: "switch", state: "OFF", iconType: .svg, iconColor: "") + #expect(result.baseURL == "http://192.168.2.10:8080") + #expect(result.path == "/icon/switch") + #expect(result.queryItems.contains(URLQueryItem(name: "format", value: "SVG"))) + #expect(result.queryItems.contains(URLQueryItem(name: "state", value: "OFF"))) + } +} diff --git a/OpenHABCore/Tests/OpenHABCoreTests/OpenHABCoreGeneralTests.swift b/OpenHABCore/Tests/OpenHABCoreTests/OpenHABCoreGeneralTests.swift index ad59bf84e..ecb7aa754 100644 --- a/OpenHABCore/Tests/OpenHABCoreTests/OpenHABCoreGeneralTests.swift +++ b/OpenHABCore/Tests/OpenHABCoreTests/OpenHABCoreGeneralTests.swift @@ -16,18 +16,6 @@ import Foundation import XCTest final class OpenHABCoreGeneralTests: XCTestCase { - func testEndPoints() { - let urlc = Endpoint.icon( - rootUrl: "http://192.169.2.1", - version: 2, - icon: "switch", - state: "OFF", - iconType: .svg, - iconColor: "" - ).url - XCTAssertEqual(urlc, URL(string: "http://192.169.2.1/icon/switch?state=OFF&format=SVG"), "Check endpoint creation") - } - func testLabelValue() { let widget = OpenHABWidget() widget.label = "llldl [llsl]" From 411cc70977ce7a8548d853574f876e89b648905e Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Mon, 7 Jul 2025 13:05:08 +0200 Subject: [PATCH 283/476] CSS injection as used in android app is ineffective with SDImageSVGCoder. Therefore passing the color information in the call to the iconify api in line with https://iconify.design/docs/api/svg.html Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- BuildTools/.swiftlint.yml | 2 ++ .../OpenHABCore/Util/OpenHABImageProcessor.swift | 11 ++++++----- openHAB/OpenHABSitemapViewController.swift | 3 ++- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/BuildTools/.swiftlint.yml b/BuildTools/.swiftlint.yml index b1de4d19c..9f7fccd77 100644 --- a/BuildTools/.swiftlint.yml +++ b/BuildTools/.swiftlint.yml @@ -76,7 +76,9 @@ identifier_name: - id - a - b + - g - i + - r - v private_outlet: diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OpenHABImageProcessor.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABImageProcessor.swift index 35ee34b57..8111f81db 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/OpenHABImageProcessor.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABImageProcessor.swift @@ -18,10 +18,13 @@ import SFSafeSymbols public struct OpenHABImageProcessor: ImageProcessor { // `identifier` should be the same for processors with the same properties/functionality // It will be used when storing and retrieving the image to/from cache. - public let identifier = "org.openhab.svgprocessor" + public let identifier: String private let logger = Logger(subsystem: "org.openhab", category: "OpenHABImageProcessor") - public init() {} + /// - Parameter tint: The tint color used to tint the input image. + public init() { + identifier = "org.openhab.svgprocessor" + } // Convert input data/image to target image and return it. public func process(item: ImageProcessItem, options: KingfisherParsedOptionsInfo) -> KFCrossPlatformImage? { @@ -33,9 +36,7 @@ public struct OpenHABImageProcessor: ImageProcessor { guard !data.isEmpty else { return nil } switch data[0] { - case 0x3C: // svg - // - // 1000 || size.height > 1000 { diff --git a/openHAB/OpenHABSitemapViewController.swift b/openHAB/OpenHABSitemapViewController.swift index b8d80c5b6..0bb7ec943 100644 --- a/openHAB/OpenHABSitemapViewController.swift +++ b/openHAB/OpenHABSitemapViewController.swift @@ -715,10 +715,11 @@ extension OpenHABSitemapViewController: UITableViewDelegate, UITableViewDataSour iconType: iconType, iconColor: iconColor ).url { + logger.info("URL: \(urlc.absoluteString, privacy: .private) , color: \(iconColor)") var imageRequest = URLRequest(url: urlc) imageRequest.timeoutInterval = 10.0 cell.imageView?.kf.setImage( - with: KF.ImageResource(downloadURL: urlc, cacheKey: urlc.path + (urlc.query ?? "")), + with: KF.ImageResource(downloadURL: urlc), // , cacheKey: urlc.path + (urlc.query ?? "")), placeholder: nil, options: [.processor(OpenHABImageProcessor())] ) { result in From e50266ed92d9a5f3975087fa3fed3c0c6c4b2577 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Thu, 24 Jul 2025 23:40:40 +0200 Subject: [PATCH 284/476] Extending tests Signed-off-by: Tim Mueller-Seydlitz --- .../Sources/OpenHABCore/Util/Endpoint.swift | 150 +++----- .../OpenHABCoreTests/EndpointTests.swift | 321 +++++++++++++++++- 2 files changed, 362 insertions(+), 109 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/Endpoint.swift b/OpenHABCore/Sources/OpenHABCore/Util/Endpoint.swift index 4eadb1bea..52d714e76 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/Endpoint.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/Endpoint.swift @@ -11,6 +11,7 @@ import Foundation import os.log +import UIKit public enum ChartStyle { case dark @@ -61,7 +62,7 @@ public extension Endpoint { components?.path = path components?.queryItems = queryItems let url = components?.url -// Endpoint.logger.debug("URL: \(url?.absoluteString ?? "", privacy: .private)") + // Endpoint.logger.debug("URL: \(url?.absoluteString ?? "", privacy: .private)") return url } @@ -131,75 +132,7 @@ public extension Endpoint { } // swiftlint:disable:next function_parameter_count - static func icon1(rootUrl: String, version: Int, icon: String?, state: String, iconType: IconType, iconColor: String) -> Endpoint { - guard var icon, !icon.isEmpty else { - return Endpoint(baseURL: "", path: "", queryItems: []) - } - - // determineOH2IconPath - var queryItems = [ - URLQueryItem(name: "state", value: state) - ] - if version >= 4 { - let components = icon.components(separatedBy: ":") - var source = "" - var set = "" - if components.count == 3 { - source = components[0] - set = components[1] - icon = components[2] - } else if components.count == 2 { - source = components[0] - if source == "material" { - set = "baseline" - } else { - set = "classic" - } - icon = components[1] - } - if source == "material" { - source = "iconify" - icon = icon.replacingOccurrences(of: "_", with: "-") - icon = "\(set)-\(icon)" - set = "ic" - } - if source == "f7" { - source = "iconify" - set = "f7" - icon = icon.replacingOccurrences(of: "_", with: "-") - } - if source == "if" || source == "iconify" { - queryItems = [URLQueryItem(name: "height", value: "64")] - if !iconColor.isEmpty { - queryItems.append(URLQueryItem(name: "color", value: iconColor)) - } - return Endpoint( - baseURL: "https://api.iconify.design/", - path: "/\(set)/\(icon).svg", - queryItems: queryItems - ) - } - } - if version >= 3 { - queryItems.append(contentsOf: [ - URLQueryItem(name: "format", value: (iconType == .png) ? "PNG" : "SVG"), - URLQueryItem(name: "anyFormat", value: "true") - ]) - } else { - queryItems.append( - URLQueryItem(name: "format", value: (iconType == .png) ? "PNG" : "SVG") - ) - } - - return Endpoint( - baseURL: rootUrl, - path: "/icon/\(icon)", - queryItems: queryItems - ) - } - - // swiftlint:disable:next function_parameter_count - static func icon(rootUrl: String, version: Int, icon: String?, state: String, iconType: IconType, iconColor: String) -> Endpoint { + static func icon(rootUrl: String, version: Int, icon: String?, state: String?, iconType: IconType, iconColor: String, staticIcon: Bool? = nil) -> Endpoint { guard let icon, !icon.isEmpty else { return Endpoint(baseURL: "", path: "", queryItems: []) } @@ -212,10 +145,11 @@ public extension Endpoint { ) } + // determineOH2IconPath var queryItems: [URLQueryItem] = [] - var iconSource = "oh" - var iconSet = "classic" + var source = "oh" + var set = "classic" var iconName = "none" let segments = icon.components(separatedBy: ":") @@ -223,66 +157,66 @@ public extension Endpoint { case 1: iconName = segments[0] case 2: - iconSource = segments[0] + source = segments[0] iconName = segments[1] - if iconSource == "material" { - iconSet = "baseline" + if source == "material" { + set = "baseline" } case 3: - iconSource = segments[0] - iconSet = segments[1] + source = segments[0] + set = segments[1] iconName = segments[2] default: break } - switch iconSource { + switch source { case "material": - iconSource = "iconify" + source = "iconify" iconName = iconName.replacingOccurrences(of: "_", with: "-") - iconName = "\(iconSet)-\(iconName)" - iconSet = "ic" + iconName = "\(set)-\(iconName)" + set = "ic" case "f7": - iconSource = "iconify" - iconSet = "f7" + source = "iconify" + set = "f7" iconName = iconName.replacingOccurrences(of: "_", with: "-") default: break } - if iconSource == "if" || iconSource == "iconify" { - queryItems.append(URLQueryItem(name: "height", value: "64")) - if !iconColor.isEmpty { - queryItems.append(URLQueryItem(name: "color", value: iconColor)) + if source == "if" || source == "iconify" { + queryItems = [URLQueryItem(name: "height", value: "64")] + if !iconColor.isEmpty, let colorString = UIColor(fromString: iconColor).toHex() { + queryItems.append(URLQueryItem(name: "color", value: "#\(colorString)")) } return Endpoint( baseURL: "https://api.iconify.design/", - path: "/\(iconSet)/\(iconName).svg", + path: "/\(set)/\(iconName).svg", queryItems: queryItems ) - } else { - let format = (iconType == .png) ? "PNG" : "SVG" - if iconSource != "oh" { - iconSet = "classic" - iconName = "none" - } - - var queryItems = [ - URLQueryItem(name: "format", value: format), - URLQueryItem(name: "anyFormat", value: "true"), - URLQueryItem(name: "iconset", value: iconSet) - ] + } - if !state.isEmpty { - queryItems.append(URLQueryItem(name: "state", value: state)) - } + // set unknown iconSource to oh:classic:none icon + if source != "oh" { + set = "classic" + iconName = "none" + } - return Endpoint( - baseURL: rootUrl, - path: "/icon/\(iconName)", - queryItems: queryItems - ) + if staticIcon != true { + queryItems.append(URLQueryItem(name: "state", value: state ?? "null")) } + + queryItems.append(contentsOf: [ + URLQueryItem(name: "format", value: (iconType == .png) ? "PNG" : "SVG"), + URLQueryItem(name: "anyFormat", value: "true"), + URLQueryItem(name: "iconset", value: set) + ]) + + return Endpoint( + baseURL: rootUrl, + path: "/icon/\(iconName)", + queryItems: queryItems + ) } static func iconForDrawer(rootUrl: String, icon: String) -> Endpoint { diff --git a/OpenHABCore/Tests/OpenHABCoreTests/EndpointTests.swift b/OpenHABCore/Tests/OpenHABCoreTests/EndpointTests.swift index 22f5cf601..8004bb5a0 100644 --- a/OpenHABCore/Tests/OpenHABCoreTests/EndpointTests.swift +++ b/OpenHABCore/Tests/OpenHABCoreTests/EndpointTests.swift @@ -61,7 +61,7 @@ struct EndpointTests { #expect(result.baseURL == "https://api.iconify.design/") #expect(result.path.contains("door-open.svg")) - #expect(result.queryItems.contains(URLQueryItem(name: "color", value: "#ff0000"))) + #expect(result.queryItems.contains(URLQueryItem(name: "color", value: "#FF0000"))) } @Test @@ -95,4 +95,323 @@ struct EndpointTests { #expect(result.queryItems.contains(URLQueryItem(name: "format", value: "SVG"))) #expect(result.queryItems.contains(URLQueryItem(name: "state", value: "OFF"))) } + + @Test + func emptyIconReturnsEmptyEndpoint() { + let endpoint = Endpoint.icon( + rootUrl: "https://example.org", + version: 3, + icon: nil, + state: "ON", + iconType: .svg, + iconColor: "#FF0000" + ) + + #expect(endpoint.baseURL.isEmpty) + #expect(endpoint.path.isEmpty) + #expect(endpoint.queryItems.isEmpty) + } + + @Test + func simpleIconName() { + let endpoint = Endpoint.icon( + rootUrl: "https://example.org", + version: 4, + icon: "switch", + state: "OFF", + iconType: .svg, + iconColor: "#00FF00" + ) + + #expect(endpoint.baseURL == "https://example.org") + #expect(endpoint.path == "/icon/switch") + #expect(endpoint.queryItems.contains(URLQueryItem(name: "state", value: "OFF"))) + #expect(endpoint.queryItems.contains(URLQueryItem(name: "format", value: "SVG"))) + } + + @Test + func iconWithThreeSegments_customSource() { + let endpoint = Endpoint.icon( + rootUrl: "https://example.org", + version: 4, + icon: "f7:solid:home", + state: "ON", + iconType: .svg, + iconColor: "#ABCDEF" + ) + + #expect(endpoint.path == "/f7/home.svg") + #expect(endpoint.queryItems.contains(URLQueryItem(name: "color", value: "#ABCDEF"))) + } + + @Test + func iconColorString() { + let endpoint = Endpoint.icon( + rootUrl: "https://example.org", + version: 4, + icon: "f7:solid:home", + state: "ON", + iconType: .svg, + iconColor: "red" + ) + + #expect(endpoint.path == "/f7/home.svg") + #expect(endpoint.queryItems.contains(URLQueryItem(name: "color", value: "#FF0000"))) + } + + @Test + func ohIcon1() { + let endpoint = Endpoint.icon( + rootUrl: "https://example.org", + version: 4, + icon: "light", + state: "ON", + iconType: .png, + iconColor: "red" + ) + + #expect(endpoint.path == "/icon/light") + #expect(endpoint.queryItems.contains(URLQueryItem(name: "format", value: "PNG"))) + } + + @Test + func ohIcon2() { + let endpoint = Endpoint.icon( + rootUrl: "https://example.org", + version: 4, + icon: "oh:light", + state: "ON", + iconType: .png, + iconColor: "red" + ) + + #expect(endpoint.path == "/icon/light") + #expect(endpoint.queryItems.contains(URLQueryItem(name: "format", value: "PNG"))) + } + + @Test + func ohIcon3() { + let endpoint = Endpoint.icon( + rootUrl: "https://example.org", + version: 4, + icon: "oh:classic:light", + state: "ON", + iconType: .png, + iconColor: "red" + ) + + #expect(endpoint.path == "/icon/light") + #expect(endpoint.queryItems.contains(URLQueryItem(name: "format", value: "PNG"))) + } + + @Test + func ohIcon4() { + let endpoint = Endpoint.icon( + rootUrl: "https://example.org", + version: 4, + icon: "oh:custom:light", + state: "ON", + iconType: .png, + iconColor: "red" + ) + + #expect(endpoint.path == "/icon/light") + #expect(endpoint.queryItems.contains(URLQueryItem(name: "format", value: "PNG"))) + } + + // TODO: test for iconset +// "light" to "icon/light?format=PNG&anyFormat=true&iconset=classic", +// "oh:light" to "icon/light?format=PNG&anyFormat=true&iconset=classic", +// "oh:classic:light" to "icon/light?format=PNG&anyFormat=true&iconset=classic", +// "oh:custom:light" to "icon/light?format=PNG&anyFormat=true&iconset=custom" + + // Test no state is transmitted + // Test baseURL + @Test + func materialIcon1() { + let endpoint = Endpoint.icon( + rootUrl: "https://example.org", + version: 4, + icon: "material:light", + state: "UP", + iconType: .svg, + iconColor: "#000000" + ) + + // underscore should become "-" + #expect(endpoint.path == "/ic/baseline-light.svg") + // Test api.iconifyd.design + } + + @Test + func materialIcon2() { + let endpoint = Endpoint.icon( + rootUrl: "https://example.org", + version: 4, + icon: "material:outline:light", + state: "UP", + iconType: .svg, + iconColor: "#000000" + ) + + // underscore should become "-" + #expect(endpoint.path == "/ic/outline-light.svg") + // Test api.iconifyd.design + } + + @Test + func f7Icons1() { + let endpoint = Endpoint.icon( + rootUrl: "https://example.org", + version: 4, + icon: "f7:airplane", + state: "UP", + iconType: .svg, + iconColor: "#000000" + ) + + #expect(endpoint.path == "/f7/airplane.svg") + // Test api.iconifyd.design + } + + @Test + func f7Icons2() { + let endpoint = Endpoint.icon( + rootUrl: "https://example.org", + version: 4, + icon: "f7:IGNORED:airplane", + state: "UP", + iconType: .svg, + iconColor: "#000000" + ) + + #expect(endpoint.path == "/f7/airplane.svg") + // Test api.iconifyd.design + } + + @Test + func iconifyIcons1() { + let endpoint = Endpoint.icon( + rootUrl: "https://example.org", + version: 4, + icon: "if:codicon:lightbulb", + state: "UP", + iconType: .svg, + iconColor: "#000000" + ) + + #expect(endpoint.path == "/codicon/lightbulb.svg") + } + + @Test + func iconifyIcons2() { + let endpoint = Endpoint.icon( + rootUrl: "https://example.org", + version: 4, + icon: "iconify:codicon:lightbulb", + state: "UP", + iconType: .svg, + iconColor: "#000000" + ) + + #expect(endpoint.path == "/codicon/lightbulb.svg") + } + + @Test + func unknownIconSources1() { + let endpoint = Endpoint.icon( + rootUrl: "https://example.org", + version: 4, + icon: "unknown:ignored", + state: "UP", + iconType: .svg, + iconColor: "#000000" + ) + + #expect(endpoint.path == "/icon/none") + } + + @Test + func unknownIconSources2() { + let endpoint = Endpoint.icon( + rootUrl: "https://example.org", + version: 4, + icon: "unknown:ignored:ignored", + state: "UP", + iconType: .svg, + iconColor: "#000000" + ) + + #expect(endpoint.path == "/icon/none") + } + + @Test + func noneIcons1() { + let endpoint = Endpoint.icon( + rootUrl: "https://example.org", + version: 4, + icon: "none", + state: "UP", + iconType: .svg, + iconColor: "#000000" + ) + + #expect(endpoint.path == "/icon/none") + } + + @Test + func noneIcons2() { + let endpoint = Endpoint.icon( + rootUrl: "https://example.org", + version: 4, + icon: "oh:none", + state: "UP", + iconType: .svg, + iconColor: "#000000" + ) + + #expect(endpoint.path == "/icon/none") + } + + @Test + func noneIcons3() { + let endpoint = Endpoint.icon( + rootUrl: "https://example.org", + version: 4, + icon: "oh:classic:none", + state: "UP", + iconType: .svg, + iconColor: "#000000" + ) + + #expect(endpoint.path == "/icon/none") + } + + @Test + func noneIcons4() { + let endpoint = Endpoint.icon( + rootUrl: "https://example.org", + version: 4, + icon: "oh:foo:none", + state: "UP", + iconType: .svg, + iconColor: "#000000" + ) + + #expect(endpoint.path == "/icon/none") + } + + @Test + func noneIcons5() { + let endpoint = Endpoint.icon( + rootUrl: "https://example.org", + version: 4, + icon: "f7:none", + state: "UP", + iconType: .svg, + iconColor: "#000000" + ) + + #expect(endpoint.path == "/f7/none.svg") + } } From fc035f0a2834b9d64be48c21f6451dbcd5a21e0a Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Fri, 25 Jul 2025 06:46:40 +0200 Subject: [PATCH 285/476] Handling of F7 icons and icon color (#890) * Enable f7 icons as for android app. Align with android app * CSS injection as used in android app is ineffective with SDImageSVGCoder. Therefore passing the color information in the call to the iconify api in line with https://iconify.design/docs/api/svg.html * Extending tests Signed-off-by: Tim Mueller-Seydlitz --------- Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> Signed-off-by: Tim Mueller-Seydlitz From ada7d8cb807acf27ad3fd4b49c5399c0f13b998a Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Fri, 4 Jul 2025 12:01:36 +0200 Subject: [PATCH 286/476] Fix #721 Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- .../OpenHABCore/Model/OpenHABWidget.swift | 37 ++++++++++++++----- sitemap.json | 1 + 2 files changed, 29 insertions(+), 9 deletions(-) create mode 100644 sitemap.json diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift index db47942b4..ba4e2a8d1 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift @@ -92,6 +92,10 @@ public class OpenHABWidget: NSObject, MKAnnotation, Identifiable, ObservableObje public var mappings: [OpenHABWidgetMapping] = [] public var widgets: [OpenHABWidget] = [] public var visibility = true + public var unit = "" + public var pattern = "" + public var staticIcon: Bool? + public var labelSource = "" public var switchSupport = false @Published public var stateEnumBinding: WidgetTypeEnum = .unassigned @@ -136,7 +140,7 @@ public class OpenHABWidget: NSObject, MKAnnotation, Identifiable, ObservableObje } public var stateValueAsNumberState: NumberState? { - item?.state?.parseAsNumber(format: item?.stateDescription?.numberPattern) + state.parseAsNumber(format: item?.stateDescription?.numberPattern) } public var adjustedValue: Double { @@ -284,7 +288,11 @@ public extension OpenHABWidget { widgets: [OpenHABWidget], visibility: Bool?, switchSupport: Bool?, - forceAsItem: Bool?) { + forceAsItem: Bool?, + unit: String?, + pattern: String?, + staticIcon: Bool?, + labelSource: String?) { self.init() id = widgetId self.widgetId = widgetId @@ -325,6 +333,10 @@ public extension OpenHABWidget { self.switchSupport = switchSupport ?? false self.forceAsItem = forceAsItem + self.unit = unit ?? "" + self.pattern = pattern ?? "" + self.staticIcon = staticIcon ?? false + self.labelSource = labelSource ?? "" stateEnumBinding = stateEnum } } @@ -360,6 +372,10 @@ public extension OpenHABWidget { let visibility: Bool? let switchSupport: Bool? let forceAsItem: Bool? + let unit: String? + let pattern: String? + let staticIcon: Bool? + let labelSource: String? } } @@ -394,7 +410,11 @@ public extension OpenHABWidget.CodingData { widgets: mappedWidgets, visibility: visibility, switchSupport: switchSupport, - forceAsItem: forceAsItem + forceAsItem: forceAsItem, + unit: unit, + pattern: pattern, + staticIcon: staticIcon, + labelSource: labelSource ) } } @@ -411,11 +431,6 @@ extension [OpenHABWidget] { extension OpenHABWidget { convenience init(_ widget: Components.Schemas.WidgetDTO) { -// widget.unit -// widget.staticIcon -// widget.visibility -// widget.labelSource -// widget.pattern self.init( widgetId: widget.widgetId.orEmpty, label: widget.label.orEmpty, @@ -444,7 +459,11 @@ extension OpenHABWidget { widgets: widget.widgets?.compactMap { OpenHABWidget($0) } ?? [], visibility: widget.visibility, switchSupport: widget.switchSupport, - forceAsItem: widget.forceAsItem + forceAsItem: widget.forceAsItem, + unit: widget.unit, + pattern: widget.pattern, + staticIcon: widget.staticIcon, + labelSource: widget.labelSource ) } } diff --git a/sitemap.json b/sitemap.json new file mode 100644 index 000000000..70beef3d0 --- /dev/null +++ b/sitemap.json @@ -0,0 +1 @@ +{"name":"uicomponents_page_dddf943769","label":"Test Widgets","link":"http://192.168.2.10:8080/rest/sitemaps/uicomponents_page_dddf943769","homepage":{"id":"uicomponents_page_dddf943769","title":"Test Widgets","link":"http://192.168.2.10:8080/rest/sitemaps/uicomponents_page_dddf943769/uicomponents_page_dddf943769","leaf":false,"timeout":false,"widgets":[{"widgetId":"00","type":"Image","visibility":true,"label":"","labelSource":"NONE","icon":"image","staticIcon":false,"unit":"","mappings":[],"url":"http://192.168.2.10:8080/proxy?sitemap\u003duicomponents_page_dddf943769\u0026widgetId\u003d00","widgets":[]},{"widgetId":"01","type":"Video","visibility":true,"label":"testvideo","labelSource":"SITEMAP_WIDGET","icon":"video","staticIcon":false,"unit":"","mappings":[],"url":"http://192.168.2.10:8080/proxy?sitemap\u003duicomponents_page_dddf943769\u0026widgetId\u003d01","encoding":"mjpeg","widgets":[]},{"widgetId":"02","type":"Text","visibility":true,"label":"Public IP [-]","labelSource":"SITEMAP_WIDGET","icon":"if:mdi:lightbulb","staticIcon":false,"pattern":"%s","unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/testStringItem","state":"NULL","stateDescription":{"pattern":"%s","readOnly":false,"options":[]},"type":"String","name":"testStringItem","label":"testString","category":"","tags":[],"groupNames":[]},"widgets":[]},{"widgetId":"03","type":"Text","visibility":true,"label":"Access Point [dd-wrt-unifi-0]","labelSource":"ITEM_LABEL","icon":"","staticIcon":false,"pattern":"%s","unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/UniFiWirelessClientiphonetim11pro_AccessPoint","state":"dd-wrt-unifi-0","stateDescription":{"pattern":"%s","readOnly":true,"options":[]},"type":"String","name":"UniFiWirelessClientiphonetim11pro_AccessPoint","label":"Access Point","category":"","tags":["Point"],"groupNames":["UniFiWirelessClientiphonetim11pro"]},"widgets":[]},{"widgetId":"04","type":"Setpoint","visibility":true,"label":"Kitchen [16.6 °C]","labelSource":"SITEMAP_WIDGET","icon":"","staticIcon":false,"pattern":"%.1f °C","unit":"°C","mappings":[],"minValue":0.0,"maxValue":30.0,"step":5.0,"item":{"link":"http://192.168.2.10:8080/rest/items/TestSetpoint","state":"16.6 °C","stateDescription":{"pattern":"%.1f %unit%","readOnly":false,"options":[]},"unitSymbol":"°C","type":"Number:Temperature","name":"TestSetpoint","label":"TestSetpoint","category":"","tags":[],"groupNames":[]},"widgets":[]},{"widgetId":"05","type":"Setpoint","visibility":true,"label":"default [16.6 °C]","labelSource":"SITEMAP_WIDGET","icon":"","staticIcon":false,"pattern":"%.1f %unit%","unit":"°C","mappings":[],"minValue":-100.0,"maxValue":100.0,"item":{"link":"http://192.168.2.10:8080/rest/items/TestSetpoint","state":"16.6 °C","stateDescription":{"pattern":"%.1f %unit%","readOnly":false,"options":[]},"unitSymbol":"°C","type":"Number:Temperature","name":"TestSetpoint","label":"TestSetpoint","category":"","tags":[],"groupNames":[]},"widgets":[]},{"widgetId":"06","type":"Setpoint","visibility":true,"label":"C[16.6 °C]","labelSource":"SITEMAP_WIDGET","icon":"","staticIcon":false,"pattern":"%.1f °C","unit":"°C","mappings":[],"minValue":0.0,"maxValue":100.0,"item":{"link":"http://192.168.2.10:8080/rest/items/TestSetpoint","state":"16.6 °C","stateDescription":{"pattern":"%.1f %unit%","readOnly":false,"options":[]},"unitSymbol":"°C","type":"Number:Temperature","name":"TestSetpoint","label":"TestSetpoint","category":"","tags":[],"groupNames":[]},"widgets":[]},{"widgetId":"07","type":"Setpoint","visibility":true,"label":"F[61.9 °F]","labelSource":"SITEMAP_WIDGET","icon":"","staticIcon":false,"pattern":"%.1f °F","unit":"°F","mappings":[],"minValue":0.0,"maxValue":1000.0,"state":"61.880 °F","item":{"link":"http://192.168.2.10:8080/rest/items/TestSetpoint","state":"16.6 °C","stateDescription":{"pattern":"%.1f %unit%","readOnly":false,"options":[]},"unitSymbol":"°C","type":"Number:Temperature","name":"TestSetpoint","label":"TestSetpoint","category":"","tags":[],"groupNames":[]},"widgets":[]},{"widgetId":"08","type":"Setpoint","visibility":true,"label":"K[289.8 K]","labelSource":"SITEMAP_WIDGET","icon":"","staticIcon":false,"pattern":"%.1f K","unit":"K","mappings":[],"minValue":-100.0,"maxValue":1000.0,"state":"289.75 K","item":{"link":"http://192.168.2.10:8080/rest/items/TestSetpoint","state":"16.6 °C","stateDescription":{"pattern":"%.1f %unit%","readOnly":false,"options":[]},"unitSymbol":"°C","type":"Number:Temperature","name":"TestSetpoint","label":"TestSetpoint","category":"","tags":[],"groupNames":[]},"widgets":[]},{"widgetId":"09","type":"Input","visibility":true,"label":"Meter [1]","labelSource":"SITEMAP_WIDGET","icon":"energy","staticIcon":true,"pattern":"%.0f %unit%","unit":"","mappings":[],"inputHint":"number","item":{"link":"http://192.168.2.10:8080/rest/items/Test_Meter_Reading","state":"0.6","stateDescription":{"pattern":"%.0f","readOnly":false,"options":[]},"type":"Number","name":"Test_Meter_Reading","label":"Test_Meter_Reading","category":"","tags":[],"groupNames":[]},"widgets":[]}]}} From 6e11fcbf24fc91301c113da6f5dfd46133e7c3b6 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Fri, 4 Jul 2025 13:30:00 +0200 Subject: [PATCH 287/476] Other cases to be handled. Monospaceddigit font for better handling Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift | 6 +++++- openHAB/SetpointCell.swift | 5 +++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift index ba4e2a8d1..2dfd8bf76 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift @@ -140,7 +140,11 @@ public class OpenHABWidget: NSObject, MKAnnotation, Identifiable, ObservableObje } public var stateValueAsNumberState: NumberState? { - state.parseAsNumber(format: item?.stateDescription?.numberPattern) + if state != "" { + state.parseAsNumber(format: item?.stateDescription?.numberPattern) + } else { + item?.state?.parseAsNumber(format: item?.stateDescription?.numberPattern) + } } public var adjustedValue: Double { diff --git a/openHAB/SetpointCell.swift b/openHAB/SetpointCell.swift index 7642bc3fe..59ffee9dc 100644 --- a/openHAB/SetpointCell.swift +++ b/openHAB/SetpointCell.swift @@ -32,6 +32,11 @@ class SetpointCell: GenericUITableViewCell { super.init(style: style, reuseIdentifier: reuseIdentifier) } + override func awakeFromNib() { + super.awakeFromNib() + customDetailTextLabel.font = UIFont.monospacedDigitSystemFont(ofSize: customDetailTextLabel.font.pointSize, weight: .regular) + } + override func displayWidget() { downButton.addTarget(self, action: #selector(SetpointCell.decreaseValue), for: .touchUpInside) upButton.addTarget(self, action: #selector(SetpointCell.increaseValue), for: .touchUpInside) From 37bc048ea1d5a93f2f785f22ac1ce85148e7bb60 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Thu, 24 Jul 2025 23:17:15 +0200 Subject: [PATCH 288/476] rm sitemap.json Signed-off-by: Tim Mueller-Seydlitz --- sitemap.json | 1 - 1 file changed, 1 deletion(-) delete mode 100644 sitemap.json diff --git a/sitemap.json b/sitemap.json deleted file mode 100644 index 70beef3d0..000000000 --- a/sitemap.json +++ /dev/null @@ -1 +0,0 @@ -{"name":"uicomponents_page_dddf943769","label":"Test Widgets","link":"http://192.168.2.10:8080/rest/sitemaps/uicomponents_page_dddf943769","homepage":{"id":"uicomponents_page_dddf943769","title":"Test Widgets","link":"http://192.168.2.10:8080/rest/sitemaps/uicomponents_page_dddf943769/uicomponents_page_dddf943769","leaf":false,"timeout":false,"widgets":[{"widgetId":"00","type":"Image","visibility":true,"label":"","labelSource":"NONE","icon":"image","staticIcon":false,"unit":"","mappings":[],"url":"http://192.168.2.10:8080/proxy?sitemap\u003duicomponents_page_dddf943769\u0026widgetId\u003d00","widgets":[]},{"widgetId":"01","type":"Video","visibility":true,"label":"testvideo","labelSource":"SITEMAP_WIDGET","icon":"video","staticIcon":false,"unit":"","mappings":[],"url":"http://192.168.2.10:8080/proxy?sitemap\u003duicomponents_page_dddf943769\u0026widgetId\u003d01","encoding":"mjpeg","widgets":[]},{"widgetId":"02","type":"Text","visibility":true,"label":"Public IP [-]","labelSource":"SITEMAP_WIDGET","icon":"if:mdi:lightbulb","staticIcon":false,"pattern":"%s","unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/testStringItem","state":"NULL","stateDescription":{"pattern":"%s","readOnly":false,"options":[]},"type":"String","name":"testStringItem","label":"testString","category":"","tags":[],"groupNames":[]},"widgets":[]},{"widgetId":"03","type":"Text","visibility":true,"label":"Access Point [dd-wrt-unifi-0]","labelSource":"ITEM_LABEL","icon":"","staticIcon":false,"pattern":"%s","unit":"","mappings":[],"item":{"link":"http://192.168.2.10:8080/rest/items/UniFiWirelessClientiphonetim11pro_AccessPoint","state":"dd-wrt-unifi-0","stateDescription":{"pattern":"%s","readOnly":true,"options":[]},"type":"String","name":"UniFiWirelessClientiphonetim11pro_AccessPoint","label":"Access Point","category":"","tags":["Point"],"groupNames":["UniFiWirelessClientiphonetim11pro"]},"widgets":[]},{"widgetId":"04","type":"Setpoint","visibility":true,"label":"Kitchen [16.6 °C]","labelSource":"SITEMAP_WIDGET","icon":"","staticIcon":false,"pattern":"%.1f °C","unit":"°C","mappings":[],"minValue":0.0,"maxValue":30.0,"step":5.0,"item":{"link":"http://192.168.2.10:8080/rest/items/TestSetpoint","state":"16.6 °C","stateDescription":{"pattern":"%.1f %unit%","readOnly":false,"options":[]},"unitSymbol":"°C","type":"Number:Temperature","name":"TestSetpoint","label":"TestSetpoint","category":"","tags":[],"groupNames":[]},"widgets":[]},{"widgetId":"05","type":"Setpoint","visibility":true,"label":"default [16.6 °C]","labelSource":"SITEMAP_WIDGET","icon":"","staticIcon":false,"pattern":"%.1f %unit%","unit":"°C","mappings":[],"minValue":-100.0,"maxValue":100.0,"item":{"link":"http://192.168.2.10:8080/rest/items/TestSetpoint","state":"16.6 °C","stateDescription":{"pattern":"%.1f %unit%","readOnly":false,"options":[]},"unitSymbol":"°C","type":"Number:Temperature","name":"TestSetpoint","label":"TestSetpoint","category":"","tags":[],"groupNames":[]},"widgets":[]},{"widgetId":"06","type":"Setpoint","visibility":true,"label":"C[16.6 °C]","labelSource":"SITEMAP_WIDGET","icon":"","staticIcon":false,"pattern":"%.1f °C","unit":"°C","mappings":[],"minValue":0.0,"maxValue":100.0,"item":{"link":"http://192.168.2.10:8080/rest/items/TestSetpoint","state":"16.6 °C","stateDescription":{"pattern":"%.1f %unit%","readOnly":false,"options":[]},"unitSymbol":"°C","type":"Number:Temperature","name":"TestSetpoint","label":"TestSetpoint","category":"","tags":[],"groupNames":[]},"widgets":[]},{"widgetId":"07","type":"Setpoint","visibility":true,"label":"F[61.9 °F]","labelSource":"SITEMAP_WIDGET","icon":"","staticIcon":false,"pattern":"%.1f °F","unit":"°F","mappings":[],"minValue":0.0,"maxValue":1000.0,"state":"61.880 °F","item":{"link":"http://192.168.2.10:8080/rest/items/TestSetpoint","state":"16.6 °C","stateDescription":{"pattern":"%.1f %unit%","readOnly":false,"options":[]},"unitSymbol":"°C","type":"Number:Temperature","name":"TestSetpoint","label":"TestSetpoint","category":"","tags":[],"groupNames":[]},"widgets":[]},{"widgetId":"08","type":"Setpoint","visibility":true,"label":"K[289.8 K]","labelSource":"SITEMAP_WIDGET","icon":"","staticIcon":false,"pattern":"%.1f K","unit":"K","mappings":[],"minValue":-100.0,"maxValue":1000.0,"state":"289.75 K","item":{"link":"http://192.168.2.10:8080/rest/items/TestSetpoint","state":"16.6 °C","stateDescription":{"pattern":"%.1f %unit%","readOnly":false,"options":[]},"unitSymbol":"°C","type":"Number:Temperature","name":"TestSetpoint","label":"TestSetpoint","category":"","tags":[],"groupNames":[]},"widgets":[]},{"widgetId":"09","type":"Input","visibility":true,"label":"Meter [1]","labelSource":"SITEMAP_WIDGET","icon":"energy","staticIcon":true,"pattern":"%.0f %unit%","unit":"","mappings":[],"inputHint":"number","item":{"link":"http://192.168.2.10:8080/rest/items/Test_Meter_Reading","state":"0.6","stateDescription":{"pattern":"%.0f","readOnly":false,"options":[]},"type":"Number","name":"Test_Meter_Reading","label":"Test_Meter_Reading","category":"","tags":[],"groupNames":[]},"widgets":[]}]}} From ab12f8b0d2105be41618bdbc342c8d51cc3b8d4f Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Fri, 25 Jul 2025 08:53:17 +0200 Subject: [PATCH 289/476] Stuck on updateNavigationTitle Signed-off-by: Tim Mueller-Seydlitz --- .../OpenHABCore/Model/OpenHABWidget.swift | 38 +++- .../Sources/OpenHABCore/Util/Endpoint.swift | 102 +++++---- .../Util/OpenHABImageProcessor.swift | 11 +- .../OpenHABCore/Util/Preferences.swift | 48 ++++ .../OpenHABCoreTests/EndpointTests.swift | 99 ++++++-- .../OpenHABCoreGeneralTests.swift | 12 - openHAB.xcodeproj/project.pbxproj | 28 +++ openHAB/AppDelegate.swift | 44 ++++ openHAB/Main.storyboard | 8 +- openHAB/OpenHABNavigationController.swift | 36 +++ openHAB/OpenHABSitemapViewController.swift | 3 +- openHAB/OpenHABViewController.swift | 4 + openHAB/OpenHABWebViewController.swift | 3 +- .../ScreenSaverConfiguration.swift | 47 ++++ openHAB/ScreenSaver/ScreenSaverManager.swift | 211 ++++++++++++++++++ openHAB/ScreenSaver/ScreenSaverView.swift | 205 +++++++++++++++++ openHAB/SetpointCell.swift | 5 + .../ApplicationSettingsView.swift | 10 + .../ScreenSaverSettingsView.swift | 209 +++++++++++++++++ openHAB/SettingsView/SettingsView.swift | 7 + 20 files changed, 1033 insertions(+), 97 deletions(-) create mode 100644 openHAB/OpenHABNavigationController.swift create mode 100644 openHAB/ScreenSaver/ScreenSaverConfiguration.swift create mode 100644 openHAB/ScreenSaver/ScreenSaverManager.swift create mode 100644 openHAB/ScreenSaver/ScreenSaverView.swift create mode 100644 openHAB/SettingsView/ScreenSaverSettingsView.swift diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift index db2f674cd..727bbee03 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift @@ -119,6 +119,9 @@ public class OpenHABWidget: NSObject, MKAnnotation, Identifiable, ObservableObje public var mappings: [OpenHABWidgetMapping] = [] public var widgets: [OpenHABWidget] = [] public var visibility = true + public var unit: String? + public var pattern: String? + public var staticIcon: Bool? public var switchSupport = false public var labelSource = LabelSource.unknown public var releaseOnly: Bool? @@ -131,8 +134,6 @@ public class OpenHABWidget: NSObject, MKAnnotation, Identifiable, ObservableObje item?.stateDescription?.readOnly } - public var staticIcon: Bool? - @Published public var stateEnumBinding: WidgetTypeEnum = .unassigned // Text prior to "[" @@ -175,7 +176,11 @@ public class OpenHABWidget: NSObject, MKAnnotation, Identifiable, ObservableObje } public var stateValueAsNumberState: NumberState? { - item?.state?.parseAsNumber(format: item?.stateDescription?.numberPattern) + if state != "" { + state.parseAsNumber(format: item?.stateDescription?.numberPattern) + } else { + item?.state?.parseAsNumber(format: item?.stateDescription?.numberPattern) + } } public var adjustedValue: Double { @@ -373,7 +378,9 @@ public extension OpenHABWidget { releaseCommand: String? = nil, command: String? = nil, stateless: Bool? = nil, - staticIcon: Bool? = nil) { + staticIcon: Bool? = nil, + unit: String? = nil, + pattern: String? = nil) { self.init() id = widgetId self.widgetId = widgetId @@ -421,6 +428,10 @@ public extension OpenHABWidget { self.switchSupport = switchSupport ?? false self.forceAsItem = forceAsItem + self.unit = unit ?? "" + self.pattern = pattern ?? "" + self.staticIcon = staticIcon ?? false + self.labelSource = labelSource stateEnumBinding = stateEnum self.labelSource = labelSource self.releaseOnly = releaseOnly @@ -430,6 +441,8 @@ public extension OpenHABWidget { self.command = command self.stateless = stateless self.staticIcon = staticIcon + self.unit = unit + self.pattern = pattern } convenience init(icon: String, iconColor: String? = nil) { @@ -469,6 +482,10 @@ public extension OpenHABWidget { let visibility: Bool? let switchSupport: Bool? let forceAsItem: Bool? + let unit: String? + let pattern: String? + let staticIcon: Bool? + let labelSource: String? } } @@ -503,7 +520,11 @@ public extension OpenHABWidget.CodingData { widgets: mappedWidgets, visibility: visibility, switchSupport: switchSupport, - forceAsItem: forceAsItem + forceAsItem: forceAsItem, + labelSource: OpenHABWidget.LabelSource(rawValue: labelSource ?? "") ?? .unknown, + staticIcon: staticIcon, + unit: unit, + pattern: pattern, ) } } @@ -522,9 +543,6 @@ public extension [OpenHABWidget] { extension OpenHABWidget { convenience init(_ widget: Components.Schemas.WidgetDTO) { -// widget.unit -// widget.staticIcon -// widget.pattern self.init( widgetId: widget.widgetId.orEmpty, label: widget.label.orEmpty, @@ -561,7 +579,9 @@ extension OpenHABWidget { releaseCommand: widget.releaseCommand, command: widget.command, stateless: widget.stateless, - staticIcon: widget.staticIcon + staticIcon: widget.staticIcon, + unit: widget.unit, + pattern: widget.pattern ) } } diff --git a/OpenHABCore/Sources/OpenHABCore/Util/Endpoint.swift b/OpenHABCore/Sources/OpenHABCore/Util/Endpoint.swift index 73449f3b7..84653374c 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/Endpoint.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/Endpoint.swift @@ -48,7 +48,7 @@ public enum SortSitemapsOrder: Int, CaseIterable, CustomStringConvertible { } } -public struct Endpoint { +public struct Endpoint: Equatable { static let logger = Logger(subsystem: "org.openhab.app", category: "EndPoint") let baseURL: String @@ -137,6 +137,14 @@ public extension Endpoint { return Endpoint(baseURL: "", path: "", queryItems: []) } + guard version >= 2 else { + return Endpoint( + baseURL: rootUrl, + path: "/icon/\(icon)", + queryItems: [] + ) + } + // determineOH2IconPath var queryItems: [URLQueryItem] = [] @@ -144,46 +152,48 @@ public extension Endpoint { var set = "classic" var iconName = "none" - if version >= 4 { - let components = icon.components(separatedBy: ":") - - if components.count == 3 { - source = components[0] - set = components[1] - iconName = components[2] - } else if components.count == 2 { - source = components[0] - if source == "material" { - set = "baseline" - } else { - set = "classic" - } - iconName = components[1] - } else if components.count == 1 { - iconName = icon - } + let segments = icon.components(separatedBy: ":") + switch segments.count { + case 1: + iconName = segments[0] + case 2: + source = segments[0] + iconName = segments[1] if source == "material" { - source = "iconify" - iconName = iconName.replacingOccurrences(of: "_", with: "-") - iconName = "\(set)-\(iconName)" - set = "ic" + set = "baseline" } - if source == "f7" { - source = "iconify" - set = "f7" - iconName = iconName.replacingOccurrences(of: "_", with: "-") - } - if source == "if" || source == "iconify" { - queryItems = [URLQueryItem(name: "height", value: "64")] - if !iconColor.isEmpty, let colorString = UIColor(fromString: iconColor).toHex() { - queryItems.append(URLQueryItem(name: "color", value: "#\(colorString)")) - } - return Endpoint( - baseURL: "https://api.iconify.design/", - path: "/\(set)/\(iconName).svg", - queryItems: queryItems - ) + case 3: + source = segments[0] + set = segments[1] + iconName = segments[2] + default: + break + } + + switch source { + case "material": + source = "iconify" + iconName = iconName.replacingOccurrences(of: "_", with: "-") + iconName = "\(set)-\(iconName)" + set = "ic" + case "f7": + source = "iconify" + set = "f7" + iconName = iconName.replacingOccurrences(of: "_", with: "-") + default: + break + } + + if source == "if" || source == "iconify" { + queryItems = [URLQueryItem(name: "height", value: "64")] + if !iconColor.isEmpty, let colorString = UIColor(fromString: iconColor).toHex() { + queryItems.append(URLQueryItem(name: "color", value: "#\(colorString)")) } + return Endpoint( + baseURL: "https://api.iconify.design/", + path: "/\(set)/\(iconName).svg", + queryItems: queryItems + ) } // set unknown iconSource to oh:classic:none icon @@ -196,17 +206,11 @@ public extension Endpoint { queryItems.append(URLQueryItem(name: "state", value: state ?? "null")) } - if version >= 3 { - queryItems.append(contentsOf: [ - URLQueryItem(name: "format", value: (iconType == .png) ? "PNG" : "SVG"), - URLQueryItem(name: "anyFormat", value: "true"), - URLQueryItem(name: "iconset", value: set) - ]) - } else { - queryItems.append( - URLQueryItem(name: "format", value: (iconType == .png) ? "PNG" : "SVG") - ) - } + queryItems.append(contentsOf: [ + URLQueryItem(name: "format", value: (iconType == .png) ? "PNG" : "SVG"), + URLQueryItem(name: "anyFormat", value: "true"), + URLQueryItem(name: "iconset", value: set) + ]) return Endpoint( baseURL: rootUrl, diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OpenHABImageProcessor.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABImageProcessor.swift index 35ee34b57..8111f81db 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/OpenHABImageProcessor.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABImageProcessor.swift @@ -18,10 +18,13 @@ import SFSafeSymbols public struct OpenHABImageProcessor: ImageProcessor { // `identifier` should be the same for processors with the same properties/functionality // It will be used when storing and retrieving the image to/from cache. - public let identifier = "org.openhab.svgprocessor" + public let identifier: String private let logger = Logger(subsystem: "org.openhab", category: "OpenHABImageProcessor") - public init() {} + /// - Parameter tint: The tint color used to tint the input image. + public init() { + identifier = "org.openhab.svgprocessor" + } // Convert input data/image to target image and return it. public func process(item: ImageProcessItem, options: KingfisherParsedOptionsInfo) -> KFCrossPlatformImage? { @@ -33,9 +36,7 @@ public struct OpenHABImageProcessor: ImageProcessor { guard !data.isEmpty else { return nil } switch data[0] { - case 0x3C: // svg - // - // 1000 || size.height > 1000 { diff --git a/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift index a6b933ba4..421a15ee1 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift @@ -116,6 +116,54 @@ public enum Preferences { @UserDefault("idleOff", defaultValue: false) public static var idleOff: Bool + @UserDefault("screensaverEnabled", defaultValue: false) + public static var screensaverEnabled: Bool + + @UserDefault("screensaverShowsTime", defaultValue: true) + public static var screensaverShowsTime: Bool + + @UserDefault("screensaverShowsDate", defaultValue: true) + public static var screensaverShowsDate: Bool + + @UserDefault("screensaverIdleInterval", defaultValue: 120.0) + public static var screensaverIdleInterval: Double + + @UserDefault("screensaverMovementInterval", defaultValue: 8.0) + public static var screensaverMovementInterval: Double + + @UserDefault("screensaverFontName", defaultValue: "") + public static var screensaverFontName: String + + @UserDefault("screensaverTimeFontRatio", defaultValue: 0.2) + public static var screensaverTimeFontRatio: Double + + @UserDefault("screensaverDateFontRatio", defaultValue: 0.4) + public static var screensaverDateFontRatio: Double + + @UserDefault("screensaverEnableDimming", defaultValue: true) + public static var screensaverEnableDimming: Bool + + @UserDefault("screensaverDimLevel", defaultValue: 0.3) + public static var screensaverDimLevel: Double + + @UserDefault("screensaverShowsSeconds", defaultValue: false) + public static var screensaverShowsSeconds: Bool + + @UserDefault("screensaverUse24Hour", defaultValue: false) + public static var screensaverUse24Hour: Bool + + @UserDefault("screensaverFadeDuration", defaultValue: 2.0) + public static var screensaverFadeDuration: Double + + @UserDefault("screensaverRestoreBrightness", defaultValue: true) + public static var screensaverRestoreBrightness: Bool + + @UserDefault("screensaverWakeBrightness", defaultValue: 1.0) + public static var screensaverWakeBrightness: Double + + @UserDefault("hideStatusBar", defaultValue: false) + public static var hideStatusBar: Bool + @UserDefault("currentWebViewPath", defaultValue: "") public static var currentWebViewPath: String diff --git a/OpenHABCore/Tests/OpenHABCoreTests/EndpointTests.swift b/OpenHABCore/Tests/OpenHABCoreTests/EndpointTests.swift index 2d411f840..8004bb5a0 100644 --- a/OpenHABCore/Tests/OpenHABCoreTests/EndpointTests.swift +++ b/OpenHABCore/Tests/OpenHABCoreTests/EndpointTests.swift @@ -13,7 +13,89 @@ import Foundation @testable import OpenHABCore import Testing -struct EndpointsTests { +struct EndpointTests { + @Test + func returnsEmptyEndpointForNilOrEmptyIcon() { + #expect( + Endpoint.icon(rootUrl: "http://192.168.2.10:8080", version: 3, icon: nil, state: "ON", iconType: .png, iconColor: "") + == Endpoint(baseURL: "", path: "", queryItems: []) + ) + + #expect( + Endpoint.icon(rootUrl: "http://192.168.2.10:8080", version: 3, icon: "", state: "OFF", iconType: .svg, iconColor: "blue") + == Endpoint(baseURL: "", path: "", queryItems: []) + ) + } + + @Test + func buildsClassicIconEndpoint() { + let result = Endpoint.icon(rootUrl: "http://192.168.2.10:8080", version: 3, icon: "light", state: "ON", iconType: .svg, iconColor: "") + + #expect(result.baseURL == "http://192.168.2.10:8080") + #expect(result.path == "/icon/light") + #expect(result.queryItems.contains(URLQueryItem(name: "format", value: "SVG"))) + #expect(result.queryItems.contains(URLQueryItem(name: "state", value: "ON"))) + } + + @Test + func buildsMaterialIconWithIconify() { + let result = Endpoint.icon(rootUrl: "http://192.168.2.10:8080", version: 3, icon: "material:light_on", state: "", iconType: .svg, iconColor: "") + + #expect(result.baseURL == "https://api.iconify.design/") + #expect(result.path == "/ic/baseline-light-on.svg") + #expect(result.queryItems.contains(URLQueryItem(name: "height", value: "64"))) + } + + @Test + func buildsF7IconWithIconify() { + let result = Endpoint.icon(rootUrl: "http://192.168.2.10:8080", version: 3, icon: "f7:alarm", state: "", iconType: .svg, iconColor: "") + + #expect(result.baseURL == "https://api.iconify.design/") + #expect(result.path == "/f7/alarm.svg") + #expect(result.queryItems.contains(URLQueryItem(name: "height", value: "64"))) + } + + @Test + func addsColorQueryForIconify() { + let result = Endpoint.icon(rootUrl: "http://192.168.2.10:8080", version: 3, icon: "material:door_open", state: "", iconType: .png, iconColor: "#ff0000") + + #expect(result.baseURL == "https://api.iconify.design/") + #expect(result.path.contains("door-open.svg")) + #expect(result.queryItems.contains(URLQueryItem(name: "color", value: "#FF0000"))) + } + + @Test + func returnsPNGIcon() { + let result = Endpoint.icon(rootUrl: "http://192.168.2.10:8080", version: 3, icon: "switch", state: "", iconType: .png, iconColor: "") + + #expect(result.queryItems.contains(URLQueryItem(name: "format", value: "PNG"))) + } + + @Test + func handlesThreeSegmentIcon() { + let result = Endpoint.icon(rootUrl: "http://192.168.2.10:8080", version: 3, icon: "if:modern:fan", state: "", iconType: .svg, iconColor: "") + + #expect(result.baseURL == "https://api.iconify.design/") + #expect(result.path == "/modern/fan.svg") + } + + @Test + func defaultsToNoneIconIfMalformed() { + let result = Endpoint.icon(rootUrl: "http://192.168.2.10:8080", version: 3, icon: "unknown:iconicIcon", state: "ON", iconType: .png, iconColor: "") + + #expect(result.baseURL == "http://192.168.2.10:8080") + #expect(result.path == "/icon/none") // fallback to 2-part icon + } + + @Test + func version2() { + let result = Endpoint.icon(rootUrl: "http://192.168.2.10:8080", version: 2, icon: "switch", state: "OFF", iconType: .svg, iconColor: "") + #expect(result.baseURL == "http://192.168.2.10:8080") + #expect(result.path == "/icon/switch") + #expect(result.queryItems.contains(URLQueryItem(name: "format", value: "SVG"))) + #expect(result.queryItems.contains(URLQueryItem(name: "state", value: "OFF"))) + } + @Test func emptyIconReturnsEmptyEndpoint() { let endpoint = Endpoint.icon( @@ -47,21 +129,6 @@ struct EndpointsTests { #expect(endpoint.queryItems.contains(URLQueryItem(name: "format", value: "SVG"))) } -// @Test -// func iconWithTwoSegments_material() { -// let endpoint = Endpoint.icon( -// rootUrl: "https://example.org", -// version: 4, -// icon: "material:bolt", -// state: "ON", -// iconType: .png, -// iconColor: "#123456" -// ) -// -// #expect(endpoint.path == "/ic/baseline.svg") -// #expect(endpoint.queryItems.contains(URLQueryItem(name: "color", value: "#123456"))) -// } - @Test func iconWithThreeSegments_customSource() { let endpoint = Endpoint.icon( diff --git a/OpenHABCore/Tests/OpenHABCoreTests/OpenHABCoreGeneralTests.swift b/OpenHABCore/Tests/OpenHABCoreTests/OpenHABCoreGeneralTests.swift index ad59bf84e..ecb7aa754 100644 --- a/OpenHABCore/Tests/OpenHABCoreTests/OpenHABCoreGeneralTests.swift +++ b/OpenHABCore/Tests/OpenHABCoreTests/OpenHABCoreGeneralTests.swift @@ -16,18 +16,6 @@ import Foundation import XCTest final class OpenHABCoreGeneralTests: XCTestCase { - func testEndPoints() { - let urlc = Endpoint.icon( - rootUrl: "http://192.169.2.1", - version: 2, - icon: "switch", - state: "OFF", - iconType: .svg, - iconColor: "" - ).url - XCTAssertEqual(urlc, URL(string: "http://192.169.2.1/icon/switch?state=OFF&format=SVG"), "Check endpoint creation") - } - func testLabelValue() { let widget = OpenHABWidget() widget.label = "llldl [llsl]" diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index da7749dd4..881a24391 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -15,6 +15,10 @@ 2FEFD8F62BE7C5BE00E387B9 /* TextInputUITableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FEFD8F52BE7C5BE00E387B9 /* TextInputUITableViewCell.swift */; }; 2FF459362E230C6A00C0B640 /* OpenHABIntentHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FF459352E230C6A00C0B640 /* OpenHABIntentHelper.swift */; }; 4D6470DA2561F935007B03FC /* openHABIntents.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 4D6470D32561F935007B03FC /* openHABIntents.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 652B81042E2193B500648510 /* ScreenSaverSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 652B81032E2193B500648510 /* ScreenSaverSettingsView.swift */; }; + 652B81092E2193DA00648510 /* ScreenSaverManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 652B81062E2193DA00648510 /* ScreenSaverManager.swift */; }; + 652B810A2E2193DA00648510 /* ScreenSaverConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 652B81052E2193DA00648510 /* ScreenSaverConfiguration.swift */; }; + 652B810B2E2193DA00648510 /* ScreenSaverView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 652B81072E2193DA00648510 /* ScreenSaverView.swift */; }; 653B54C2285E714900298ECD /* OpenHABViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 653B54C1285E714900298ECD /* OpenHABViewController.swift */; }; 65570A7D2476D16A00D524EA /* OpenHABWebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65570A7C2476D16A00D524EA /* OpenHABWebViewController.swift */; }; 6557AF8F2C0241C10094D0C8 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 6557AF8E2C0241C10094D0C8 /* PrivacyInfo.xcprivacy */; }; @@ -24,6 +28,7 @@ 657144512C1E438700C8A1F3 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 657144502C1E438700C8A1F3 /* NotificationService.swift */; }; 657144552C1E438700C8A1F3 /* NotificationService.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 6571444E2C1E438700C8A1F3 /* NotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 657144962C30A16700C8A1F3 /* OpenHABCore in Frameworks */ = {isa = PBXBuildFile; productRef = 657144952C30A16700C8A1F3 /* OpenHABCore */; }; + 65C2EF492E244C8500A0C19F /* OpenHABNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65C2EF482E244C8500A0C19F /* OpenHABNavigationController.swift */; }; 932602EE2382892B00EAD685 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DAC6608B236F6F4200F4501E /* Assets.xcassets */; }; 933D7F0722E7015100621A03 /* OpenHABUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 933D7F0622E7015000621A03 /* OpenHABUITests.swift */; }; 933D7F0F22E7030600621A03 /* SnapshotHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 933D7F0E22E7030600621A03 /* SnapshotHelper.swift */; }; @@ -319,6 +324,10 @@ 4D64720D256315D9007B03FC /* openHABIntents.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = openHABIntents.entitlements; sourceTree = ""; }; 4D647220256331B9007B03FC /* SetSwitchStateIntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetSwitchStateIntentHandler.swift; sourceTree = ""; }; 4D64724C256346BD007B03FC /* SetDimmerRollerValueIntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetDimmerRollerValueIntentHandler.swift; sourceTree = ""; }; + 652B81032E2193B500648510 /* ScreenSaverSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenSaverSettingsView.swift; sourceTree = ""; }; + 652B81052E2193DA00648510 /* ScreenSaverConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenSaverConfiguration.swift; sourceTree = ""; }; + 652B81062E2193DA00648510 /* ScreenSaverManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenSaverManager.swift; sourceTree = ""; }; + 652B81072E2193DA00648510 /* ScreenSaverView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenSaverView.swift; sourceTree = ""; }; 653B54C1285E714900298ECD /* OpenHABViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenHABViewController.swift; sourceTree = ""; }; 653C09D41EAD691A00BA4C4A /* openHAB.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = openHAB.entitlements; sourceTree = ""; }; 65570A7C2476D16A00D524EA /* OpenHABWebViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenHABWebViewController.swift; sourceTree = ""; }; @@ -328,6 +337,7 @@ 657144502C1E438700C8A1F3 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; 657144522C1E438700C8A1F3 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 657144972C30A3E300C8A1F3 /* NotificationService.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = NotificationService.entitlements; sourceTree = ""; }; + 65C2EF482E244C8500A0C19F /* OpenHABNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenHABNavigationController.swift; sourceTree = ""; }; 931384B324F259BC00A73AB5 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = ""; }; 931384B424F259BD00A73AB5 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/InfoPlist.strings; sourceTree = ""; }; 931384BB24F2691B00A73AB5 /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/Localizable.strings; sourceTree = ""; }; @@ -685,6 +695,16 @@ path = openHABIntents; sourceTree = ""; }; + 652B81082E2193DA00648510 /* ScreenSaver */ = { + isa = PBXGroup; + children = ( + 652B81052E2193DA00648510 /* ScreenSaverConfiguration.swift */, + 652B81062E2193DA00648510 /* ScreenSaverManager.swift */, + 652B81072E2193DA00648510 /* ScreenSaverView.swift */, + ); + path = ScreenSaver; + sourceTree = ""; + }; 6571444F2C1E438700C8A1F3 /* NotificationService */ = { isa = PBXGroup; children = ( @@ -857,6 +877,7 @@ DA48001F2D837CD8009CF127 /* SettingsView */ = { isa = PBXGroup; children = ( + 652B81032E2193B500648510 /* ScreenSaverSettingsView.swift */, DA4800172D837221009CF127 /* AboutSettingsView.swift */, DA48001D2D837905009CF127 /* ApplicationSettingsView.swift */, 2F55E7BC2DEE44A800EC8350 /* ClientCertificatesView.swift */, @@ -1005,6 +1026,8 @@ DF4B83FD18857FA100F34902 /* UI */ = { isa = PBXGroup; children = ( + 65C2EF482E244C8500A0C19F /* OpenHABNavigationController.swift */, + 652B81082E2193DA00648510 /* ScreenSaver */, DA2AEB752D92D32000897D80 /* Cells */, DA4642312D7EE6CA006C3908 /* LoggerView.swift */, 653B54C1285E714900298ECD /* OpenHABViewController.swift */, @@ -1704,6 +1727,9 @@ DA95F3352E0F2C1600FE4474 /* OpenHABSitemapViewController.swift in Sources */, DAA42BAA21DC983B00244B2A /* VideoUITableViewCell.swift in Sources */, DFB2623B18830A3600D3244D /* AppDelegate.swift in Sources */, + 652B81092E2193DA00648510 /* ScreenSaverManager.swift in Sources */, + 652B810A2E2193DA00648510 /* ScreenSaverConfiguration.swift in Sources */, + 652B810B2E2193DA00648510 /* ScreenSaverView.swift in Sources */, 2F55E7BD2DEE44A800EC8350 /* ClientCertificatesView.swift in Sources */, DA6B2EF72C8B92E800DF77CF /* SelectionView.swift in Sources */, DA64ACA82DBEAD8300294F60 /* SitemapPageView.swift in Sources */, @@ -1741,11 +1767,13 @@ DA2AEB702D92CF3E00897D80 /* UITableViewCellExtension.swift in Sources */, DA4800162D836EF0009CF127 /* MainUISettingsView.swift in Sources */, DAEA21DE2DBF481300D54342 /* TextRowView.swift in Sources */, + 652B81042E2193B500648510 /* ScreenSaverSettingsView.swift in Sources */, DAEAA89F21E6B16600267EA3 /* UITableView.swift in Sources */, 2F55E7BB2DEE447700EC8350 /* SettingsView.swift in Sources */, DA4800182D837221009CF127 /* AboutSettingsView.swift in Sources */, DAEA21E02DBF483E00D54342 /* GenericRowView.swift in Sources */, 653B54C2285E714900298ECD /* OpenHABViewController.swift in Sources */, + 65C2EF492E244C8500A0C19F /* OpenHABNavigationController.swift in Sources */, DA2AEB6E2D92BAD800897D80 /* PageLoader.swift in Sources */, DFA16EC118898A8400EDB0BB /* SegmentedUITableViewCell.swift in Sources */, DAF0A28D2C56EF8900A14A6A /* SetpointCell.swift in Sources */, diff --git a/openHAB/AppDelegate.swift b/openHAB/AppDelegate.swift index 1c770788f..ca5d3d0f9 100644 --- a/openHAB/AppDelegate.swift +++ b/openHAB/AppDelegate.swift @@ -103,6 +103,27 @@ class AppDelegate: UIResponder, UIApplicationDelegate { let SVGCoder = SDImageSVGCoder.shared SDImageCodersManager.shared.addCoder(SVGCoder) + /// load and start the screensaver + if let keyWindow = UIApplication.shared.firstKeyWindow { + var config = ScreenSaverConfiguration() + config.isEnabled = Preferences.screensaverEnabled + config.showsTime = Preferences.screensaverShowsTime + config.showsDate = Preferences.screensaverShowsDate + config.idleInterval = Preferences.screensaverIdleInterval + config.movementInterval = Preferences.screensaverMovementInterval + config.fontName = Preferences.screensaverFontName.isEmpty ? nil : Preferences.screensaverFontName + config.timeFontSizeRatio = CGFloat(Preferences.screensaverTimeFontRatio) + config.dateFontRelativeSize = CGFloat(Preferences.screensaverDateFontRatio) + config.enablesAutoDimming = Preferences.screensaverEnableDimming + config.dimLevel = CGFloat(Preferences.screensaverDimLevel) + config.wakeBrightnessLevel = CGFloat(Preferences.screensaverWakeBrightness) + config.showsSeconds = Preferences.screensaverShowsSeconds + config.uses24HourTime = Preferences.screensaverUse24Hour + config.restoresBrightness = Preferences.screensaverRestoreBrightness + + ScreenSaverManager.shared.startMonitoring(window: keyWindow, configuration: config) + } + return true } @@ -307,6 +328,9 @@ extension AppDelegate: UNUserNotificationCenterDelegate { // ✅ Ensure this runs on the MainActor @MainActor private func notifyNotificationListeners(action: String?, cloudUserId: String? = nil) { + // Wake up screen saver immediately on incoming notification interaction + NotificationCenter.default.post(name: .wakeScreenSaver, object: nil) + if let navigationController = window?.rootViewController as? UINavigationController, let rootViewController = navigationController.viewControllers.first as? OpenHABRootViewController { rootViewController.handleNotification(action: action, cloudUserId: cloudUserId) @@ -322,6 +346,7 @@ extension AppDelegate { func applicationWillResignActive(_ application: UIApplication) { // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state. // Use this method to pause ongoing tasks, disable timers, and throttle down OpenGL ES frame rates. Games should use this method to pause the game. + NotificationCenter.default.post(name: .disableScreenSaver, object: nil) } func applicationDidEnterBackground(_ application: UIApplication) { @@ -335,6 +360,25 @@ extension AppDelegate { func applicationDidBecomeActive(_ application: UIApplication) { // Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface. + if let keyWindow = UIApplication.shared.firstKeyWindow { + var config = ScreenSaverConfiguration() + config.isEnabled = Preferences.screensaverEnabled + config.showsTime = Preferences.screensaverShowsTime + config.showsDate = Preferences.screensaverShowsDate + config.idleInterval = Preferences.screensaverIdleInterval + config.movementInterval = Preferences.screensaverMovementInterval + config.fontName = Preferences.screensaverFontName.isEmpty ? nil : Preferences.screensaverFontName + config.timeFontSizeRatio = CGFloat(Preferences.screensaverTimeFontRatio) + config.dateFontRelativeSize = CGFloat(Preferences.screensaverDateFontRatio) + config.enablesAutoDimming = Preferences.screensaverEnableDimming + config.dimLevel = CGFloat(Preferences.screensaverDimLevel) + config.wakeBrightnessLevel = CGFloat(Preferences.screensaverWakeBrightness) + config.showsSeconds = Preferences.screensaverShowsSeconds + config.uses24HourTime = Preferences.screensaverUse24Hour + config.restoresBrightness = Preferences.screensaverRestoreBrightness + + ScreenSaverManager.shared.startMonitoring(window: keyWindow, configuration: config) + } } func applicationWillTerminate(_ application: UIApplication) { diff --git a/openHAB/Main.storyboard b/openHAB/Main.storyboard index 0cea1c05d..46c53046d 100644 --- a/openHAB/Main.storyboard +++ b/openHAB/Main.storyboard @@ -57,12 +57,12 @@ - + @@ -732,10 +732,10 @@ - + - + diff --git a/openHAB/OpenHABNavigationController.swift b/openHAB/OpenHABNavigationController.swift new file mode 100644 index 000000000..294896c6b --- /dev/null +++ b/openHAB/OpenHABNavigationController.swift @@ -0,0 +1,36 @@ +// Copyright (c) 2010-2025 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 + +// Copyright (c) 2010-2025 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 OpenHABCore +import UIKit + +/// This is a wrapper around UINavigationController that allows the status bar to be hidden or shown. +/// It is used to control the status bar for the entire app and is loaded from the Main storyboard entry point. +class OpenHABNavigationController: UINavigationController { + override var childForStatusBarHidden: UIViewController? { nil } + + override var prefersStatusBarHidden: Bool { + Preferences.hideStatusBar + } + + override var preferredStatusBarUpdateAnimation: UIStatusBarAnimation { .fade } +} diff --git a/openHAB/OpenHABSitemapViewController.swift b/openHAB/OpenHABSitemapViewController.swift index 5ab3e5265..be8a6c335 100644 --- a/openHAB/OpenHABSitemapViewController.swift +++ b/openHAB/OpenHABSitemapViewController.swift @@ -727,10 +727,11 @@ extension OpenHABSitemapViewController: UITableViewDelegate, UITableViewDataSour iconType: iconType, iconColor: iconColor ).url { + logger.info("URL: \(urlc.absoluteString, privacy: .private) , color: \(iconColor)") var imageRequest = URLRequest(url: urlc) imageRequest.timeoutInterval = 10.0 cell.imageView?.kf.setImage( - with: KF.ImageResource(downloadURL: urlc, cacheKey: urlc.path + (urlc.query ?? "")), + with: KF.ImageResource(downloadURL: urlc), // , cacheKey: urlc.path + (urlc.query ?? "")), placeholder: nil, options: [.processor(OpenHABImageProcessor())] ) { result in diff --git a/openHAB/OpenHABViewController.swift b/openHAB/OpenHABViewController.swift index d09d852ef..0f1f7aeb9 100644 --- a/openHAB/OpenHABViewController.swift +++ b/openHAB/OpenHABViewController.swift @@ -11,6 +11,7 @@ import Combine import OpenHABCore +import os.log import SideMenu import SwiftMessages import UIKit @@ -22,6 +23,8 @@ protocol OpenHABViewable: AnyObject { } class OpenHABViewController: UIViewController, OpenHABViewable { +private let logger = Logger(subsystem: "org.openhab.UI", category: "OpenHABViewController") + var trackerCancellables = Set() var activeTasks = Set>() @@ -38,6 +41,7 @@ class OpenHABViewController: UIViewController, OpenHABViewable { var config = SwiftMessages.Config() config.duration = .seconds(seconds: seconds) config.presentationStyle = .bottom + config.presentationContext = .view(view) SwiftMessages.hideAll() SwiftMessages.show(config: config) { let view = MessageView.viewFromNib(layout: .cardView) diff --git a/openHAB/OpenHABWebViewController.swift b/openHAB/OpenHABWebViewController.swift index a3171d0bf..5b77b8198 100644 --- a/openHAB/OpenHABWebViewController.swift +++ b/openHAB/OpenHABWebViewController.swift @@ -292,7 +292,8 @@ class OpenHABWebViewController: OpenHABViewController { let webview = WKWebView(frame: view.bounds, configuration: config) webview.navigationDelegate = self webview.uiDelegate = self - webView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + // Ensure the newly created webview resizes properly on rotation + webview.autoresizingMask = [.flexibleWidth, .flexibleHeight] webView.scrollView.bounces = false // support dark mode and avoid white flashing when loading webView.isOpaque = false diff --git a/openHAB/ScreenSaver/ScreenSaverConfiguration.swift b/openHAB/ScreenSaver/ScreenSaverConfiguration.swift new file mode 100644 index 000000000..10a15bd8f --- /dev/null +++ b/openHAB/ScreenSaver/ScreenSaverConfiguration.swift @@ -0,0 +1,47 @@ +// Copyright (c) 2010-2025 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 CoreGraphics +import Foundation + +struct ScreenSaverConfiguration { + var idleInterval: TimeInterval = 15 + + var movementInterval: TimeInterval = 8 + + var showsTime = true + + var showsDate = true + + var showsSeconds = false + + var uses24HourTime = false + + var isEnabled = false + + var enablesAutoDimming = true + + var dimLevel: CGFloat = 0.3 + + var restoresBrightness = true + + var wakeBrightnessLevel: CGFloat = 1.0 + + /// If `nil` the system font is used for the time/date + var fontName: String? + + var timeFontSizeRatio: CGFloat = 0.2 + + /// The size of the date text, percentage value compared to the clock + var dateFontRelativeSize: CGFloat = 0.4 + + var fadeDuration: TimeInterval = 2.0 +} diff --git a/openHAB/ScreenSaver/ScreenSaverManager.swift b/openHAB/ScreenSaver/ScreenSaverManager.swift new file mode 100644 index 000000000..83c186066 --- /dev/null +++ b/openHAB/ScreenSaver/ScreenSaverManager.swift @@ -0,0 +1,211 @@ +// Copyright (c) 2010-2025 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 os.log +import UIKit + +private class ScreenSaverHostingViewController: UIViewController { + override var prefersStatusBarHidden: Bool { true } + override var preferredStatusBarUpdateAnimation: UIStatusBarAnimation { .fade } +} + +@MainActor +final class ScreenSaverManager: NSObject { + static let shared = ScreenSaverManager() + + private let logger = Logger(subsystem: "org.openhab", category: "ScreenSaver") + + private(set) var configuration = ScreenSaverConfiguration() + + private var idleTimer: Timer? + + private weak var window: UIWindow? + + private var saverView: ScreenSaverView? + + private var overlayWindow: UIWindow? + + private var previousBrightness: CGFloat? + + override private init() { + super.init() + NotificationCenter.default.addObserver(self, selector: #selector(handleDisableNotification), name: .disableScreenSaver, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(handleWakeNotification), name: .wakeScreenSaver, object: nil) + } + + func startMonitoring(window: UIWindow, configuration: ScreenSaverConfiguration = ScreenSaverConfiguration()) { + self.configuration = configuration + self.window = window + attachGestureRecognizers(to: window) + resetIdleTimer() + } + + public func updateConfiguration(_ newConfiguration: ScreenSaverConfiguration) { + configuration = newConfiguration + if saverView != nil { + dismissSaverIfNeeded() + } + + if configuration.isEnabled { + resetIdleTimer() + } + } + + private func attachGestureRecognizers(to window: UIWindow) { + let tap = UITapGestureRecognizer(target: self, action: #selector(userInteracted)) + tap.cancelsTouchesInView = false + tap.delaysTouchesEnded = false + tap.delegate = self + window.addGestureRecognizer(tap) + + let pan = UIPanGestureRecognizer(target: self, action: #selector(userInteracted)) + pan.cancelsTouchesInView = false + pan.delegate = self + window.addGestureRecognizer(pan) + } + + @objc private func userInteracted() { + dismissSaverIfNeeded() + resetIdleTimer() + } + + private func resetIdleTimer() { + idleTimer?.invalidate() + + idleTimer = Timer.scheduledTimer( + withTimeInterval: configuration.idleInterval, + repeats: false + ) { [weak self] _ in + guard let self else { return } + + Task { @MainActor in + self.showSaver() + } + } + } + + private func showSaver() { + guard configuration.isEnabled else { return } + guard saverView == nil, let baseWindow = window else { return } + logger.debug("Presenting screen saver (overlay window)") + + let overlay: UIWindow + if let scene = baseWindow.windowScene { + overlay = UIWindow(windowScene: scene) + overlay.frame = scene.coordinateSpace.bounds + } else { + overlay = UIWindow(frame: UIScreen.main.bounds) + } + overlay.windowLevel = .alert + 1 // ensure above status bar + overlay.backgroundColor = .clear + + let hostVC = ScreenSaverHostingViewController() + hostVC.view.backgroundColor = .clear + overlay.rootViewController = hostVC + overlay.makeKeyAndVisible() + + hostVC.setNeedsStatusBarAppearanceUpdate() + + let saver = ScreenSaverView(configuration: configuration) + saver.translatesAutoresizingMaskIntoConstraints = false + hostVC.view.addSubview(saver) + NSLayoutConstraint.activate([ + saver.leadingAnchor.constraint(equalTo: hostVC.view.leadingAnchor), + saver.trailingAnchor.constraint(equalTo: hostVC.view.trailingAnchor), + saver.topAnchor.constraint(equalTo: hostVC.view.topAnchor), + saver.bottomAnchor.constraint(equalTo: hostVC.view.bottomAnchor) + ]) + + // wake up if the user taps anywhere + attachGestureRecognizers(to: overlay) + + saver.alpha = 0 + UIView.animate(withDuration: 0.3) { + saver.alpha = 1.0 + } completion: { _ in + saver.startAnimation() + } + + saverView = saver + overlayWindow = overlay + applyDimming() + } + + private func dismissSaverIfNeeded() { + guard let saver = saverView else { return } + logger.debug("Dismissing screen saver") + saver.stopAnimation() + if configuration.enablesAutoDimming { + if configuration.restoresBrightness { + restoreBrightnessIfNeeded() + } else { + let target = min(max(configuration.wakeBrightnessLevel, 0.0), 1.0) + UIScreen.main.brightness = target + } + } + UIView.animate(withDuration: 0.2, animations: { + saver.alpha = 0 + }) { _ in + saver.removeFromSuperview() + } + saverView = nil + + // Tear down overlay window + overlayWindow?.isHidden = true + overlayWindow = nil + } + + private func applyDimming() { + guard configuration.enablesAutoDimming else { return } + previousBrightness = UIScreen.main.brightness + var target = configuration.dimLevel + target = min(max(target, 0.0), 1.0) + UIScreen.main.brightness = target + } + + private func restoreBrightnessIfNeeded() { + guard let original = previousBrightness else { return } + UIScreen.main.brightness = original + previousBrightness = nil + } + + /// This is used fo testing the screen saver in the Settings view (before settings are saved) + @MainActor + func presentSaver(configuration: ScreenSaverConfiguration) { + self.configuration = configuration + dismissSaverIfNeeded() + showSaver() + } + + @objc private func handleDisableNotification() { + logger.debug("Received disable screen saver notification") + idleTimer?.invalidate() + dismissSaverIfNeeded() + } + + @objc private func handleWakeNotification() { + logger.debug("Received wake screen saver notification") + resetIdleTimer() + dismissSaverIfNeeded() + } +} + +/// Notifications that other parts of the app can send to control the screensaver +extension Notification.Name { + static let disableScreenSaver = Notification.Name("disableScreenSaver") + static let wakeScreenSaver = Notification.Name("wakeScreenSaver") +} + +extension ScreenSaverManager: UIGestureRecognizerDelegate { + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { + true + } +} diff --git a/openHAB/ScreenSaver/ScreenSaverView.swift b/openHAB/ScreenSaver/ScreenSaverView.swift new file mode 100644 index 000000000..d4887b9dc --- /dev/null +++ b/openHAB/ScreenSaver/ScreenSaverView.swift @@ -0,0 +1,205 @@ +// Copyright (c) 2010-2025 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 os.log +import UIKit + +@MainActor +final class ScreenSaverView: UIView { + private let logger = Logger(subsystem: "org.openhab", category: "ScreenSaverView") + + private let configuration: ScreenSaverConfiguration + + private lazy var label: UILabel = { + let label = UILabel() + label.textColor = .white + label.textAlignment = .center + label.numberOfLines = 2 + label.translatesAutoresizingMaskIntoConstraints = false + label.alpha = 0.0 // start invisible + return label + }() + + private var movementTimer: Timer? + private let dateFormatter: DateFormatter = { + let df = DateFormatter() + df.dateStyle = .medium + df.timeStyle = .medium + return df + }() + + init(configuration: ScreenSaverConfiguration) { + self.configuration = configuration + super.init(frame: .zero) + commonInit() + } + + deinit { + movementTimer?.invalidate() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func commonInit() { + backgroundColor = UIColor.black + addSubview(label) + // pin label size but not position (we move it manually) + NSLayoutConstraint.activate([ + label.widthAnchor.constraint(lessThanOrEqualTo: widthAnchor, multiplier: 0.9) + ]) + + updateLabelText() + } + + func startAnimation() { + scheduleMovement() + } + + func stopAnimation() { + movementTimer?.invalidate() + } + + // MARK: - Private helpers + + private func scheduleMovement() { + movementTimer?.invalidate() + movementTimer = Timer.scheduledTimer(withTimeInterval: configuration.movementInterval, repeats: true) { [weak self] _ in + self?.moveLabelToRandomPosition(animated: true) + } + // perform first move immediately + moveLabelToRandomPosition(animated: false) + } + + private func updateLabelText() { + let now = Date() + + // Prepare strings + var timeString: String? + var dateString: String? + + if configuration.showsTime { + let tf = DateFormatter() + tf.dateStyle = .none + if configuration.showsSeconds { + tf.dateFormat = configuration.uses24HourTime ? "H:mm:ss" : "h:mm:ss a" + } else { + tf.dateFormat = configuration.uses24HourTime ? "H:mm" : "h:mm a" + } + timeString = tf.string(from: now) + } + + if configuration.showsDate { + let df = DateFormatter() + df.dateStyle = .medium + df.timeStyle = .none + dateString = df.string(from: now) + } + + // Compute dynamic font sizes based on the current view size. + let shortSide = min(bounds.width, bounds.height) + let timeFontSize = max(shortSide * configuration.timeFontSizeRatio, 48) + let dateFontSize = timeFontSize * configuration.dateFontRelativeSize + + let timeFont: UIFont = if let name = configuration.fontName, let custom = UIFont(name: name, size: timeFontSize) { + custom + } else { + UIFont.monospacedDigitSystemFont(ofSize: timeFontSize, weight: .thin) + } + + let dateFont: UIFont = if let name = configuration.fontName, let custom = UIFont(name: name, size: dateFontSize) { + custom + } else { + UIFont.systemFont(ofSize: dateFontSize, weight: .regular) + } + + // Use a square-root curve so the text dims more gently at first and + // only gets very dark at the lowest levels + let alphaFactor: CGFloat = { + let clamped = min(max(configuration.dimLevel, 0.0), 1.0) + // .5 is about as dark as we can go while still visible + let alpha = 0.5 + 0.5 * sqrt(clamped) + return min(1.0, alpha) + }() + + let attributed = NSMutableAttributedString() + + // Date above time + if let dateString { + let dateAttr: [NSAttributedString.Key: Any] = [ + .font: dateFont, + .foregroundColor: UIColor.white.withAlphaComponent(0.85 * alphaFactor) + ] + attributed.append(NSAttributedString(string: dateString, attributes: dateAttr)) + } + + if let timeString { + if attributed.length > 0 { + attributed.append(NSAttributedString(string: "\n")) + } + let timeAttr: [NSAttributedString.Key: Any] = [ + .font: timeFont, + .foregroundColor: UIColor.white.withAlphaComponent(alphaFactor) + ] + attributed.append(NSAttributedString(string: timeString, attributes: timeAttr)) + } + + label.attributedText = attributed + } + + private func moveLabelToRandomPosition(animated: Bool) { + updateLabelText() + // Ensure layout pass so we know label size + layoutIfNeeded() + + let labelSize = label.intrinsicContentSize + // Ensure the label fully fits within the view + guard bounds.width > labelSize.width, bounds.height > labelSize.height else { return } + + // Keep the label away from the very edges by introducing a small margin. + let edgeMargin: CGFloat = 20 + + // Calculate the area the label can occupy after accounting for the margin on all sides. + let availableWidth = bounds.width - labelSize.width - edgeMargin * 2 + let availableHeight = bounds.height - labelSize.height - edgeMargin * 2 + + // If the view is too small to honour the margin, fall back to the original screen size. + guard availableWidth > 0, availableHeight > 0 else { + let fallbackX = CGFloat.random(in: 0 ... (bounds.width - labelSize.width)) + let fallbackY = CGFloat.random(in: 0 ... (bounds.height - labelSize.height)) + label.frame = CGRect(origin: CGPoint(x: fallbackX, y: fallbackY), size: labelSize) + return + } + + let randomX = edgeMargin + CGFloat.random(in: 0 ... availableWidth) + let randomY = edgeMargin + CGFloat.random(in: 0 ... availableHeight) + + let animations = { + self.label.frame = CGRect(origin: CGPoint(x: randomX, y: randomY), size: labelSize) + } + + if animated { + UIView.animate(withDuration: configuration.fadeDuration, delay: 0, options: [.curveEaseInOut, .allowUserInteraction]) { + self.label.alpha = 0.0 + } completion: { _ in + animations() + UIView.animate(withDuration: self.configuration.fadeDuration, delay: 0, options: [.curveEaseInOut, .allowUserInteraction]) { + self.label.alpha = 1.0 + } + } + } else { + label.alpha = 1.0 + animations() + } + } +} diff --git a/openHAB/SetpointCell.swift b/openHAB/SetpointCell.swift index 7642bc3fe..59ffee9dc 100644 --- a/openHAB/SetpointCell.swift +++ b/openHAB/SetpointCell.swift @@ -32,6 +32,11 @@ class SetpointCell: GenericUITableViewCell { super.init(style: style, reuseIdentifier: reuseIdentifier) } + override func awakeFromNib() { + super.awakeFromNib() + customDetailTextLabel.font = UIFont.monospacedDigitSystemFont(ofSize: customDetailTextLabel.font.pointSize, weight: .regular) + } + override func displayWidget() { downButton.addTarget(self, action: #selector(SetpointCell.decreaseValue), for: .touchUpInside) upButton.addTarget(self, action: #selector(SetpointCell.increaseValue), for: .touchUpInside) diff --git a/openHAB/SettingsView/ApplicationSettingsView.swift b/openHAB/SettingsView/ApplicationSettingsView.swift index 29de5f1bd..6483b722a 100644 --- a/openHAB/SettingsView/ApplicationSettingsView.swift +++ b/openHAB/SettingsView/ApplicationSettingsView.swift @@ -12,6 +12,7 @@ import OpenHABCore import os import SwiftUI +import UIKit struct ApplicationSettingsView: View { @Binding var settingsIdleOff: Bool @@ -22,6 +23,15 @@ struct ApplicationSettingsView: View { Section(header: Text(LocalizedStringKey("application_settings"))) { Toggle("Disable Idle Timeout", isOn: $settingsIdleOff) + NavigationLink("Screen Saver Settings") { + ScreenSaverSettingsView() + } + + Toggle("Hide Status Bar", isOn: Binding( + get: { Preferences.hideStatusBar }, + set: { Preferences.hideStatusBar = $0; UIApplication.shared.windows.first?.rootViewController?.setNeedsStatusBarAppearanceUpdate() } + )) + NavigationLink("Client Certificates") { ClientCertificatesView() } diff --git a/openHAB/SettingsView/ScreenSaverSettingsView.swift b/openHAB/SettingsView/ScreenSaverSettingsView.swift new file mode 100644 index 000000000..88c7d6463 --- /dev/null +++ b/openHAB/SettingsView/ScreenSaverSettingsView.swift @@ -0,0 +1,209 @@ +// Copyright (c) 2010-2025 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 OpenHABCore +import SwiftUI +import UIKit + +struct ScreenSaverSettingsView: View { + @State private var config: ScreenSaverConfiguration = { + var config = ScreenSaverConfiguration() + config.isEnabled = Preferences.screensaverEnabled + config.showsTime = Preferences.screensaverShowsTime + config.showsDate = Preferences.screensaverShowsDate + config.idleInterval = Preferences.screensaverIdleInterval + config.movementInterval = Preferences.screensaverMovementInterval + config.fontName = Preferences.screensaverFontName.isEmpty ? nil : Preferences.screensaverFontName + config.timeFontSizeRatio = CGFloat(Preferences.screensaverTimeFontRatio) + config.dateFontRelativeSize = CGFloat(Preferences.screensaverDateFontRatio) + config.enablesAutoDimming = Preferences.screensaverEnableDimming + config.dimLevel = CGFloat(Preferences.screensaverDimLevel) + config.wakeBrightnessLevel = CGFloat(Preferences.screensaverWakeBrightness) + config.showsSeconds = Preferences.screensaverShowsSeconds + config.uses24HourTime = Preferences.screensaverUse24Hour + config.fadeDuration = Preferences.screensaverFadeDuration + config.restoresBrightness = Preferences.screensaverRestoreBrightness + return config + }() + + var body: some View { + Form { + Section { + Toggle("Enable Screen Saver", isOn: Binding( + get: { config.isEnabled }, + set: { config.isEnabled = $0 } + )) + } + + Section("Appearance") { + Toggle("Show Time", isOn: Binding( + get: { config.showsTime }, + set: { newVal in config.showsTime = newVal } + )) + + Toggle("Show Date", isOn: Binding( + get: { config.showsDate }, + set: { newVal in config.showsDate = newVal } + )) + + Toggle("Show Seconds", isOn: Binding( + get: { config.showsSeconds }, + set: { config.showsSeconds = $0 } + )) + + Toggle("24-Hour Clock", isOn: Binding( + get: { config.uses24HourTime }, + set: { config.uses24HourTime = $0 } + )) + + let fontOptions: [String] = ["", "Arial", "Helvetica Neue", "Courier New", "Menlo", "Avenir Next"] + Picker("Font", selection: Binding( + get: { config.fontName ?? "" }, + set: { config.fontName = $0.isEmpty ? nil : $0 } + )) { + ForEach(fontOptions, id: \.self) { name in + Text(name.isEmpty ? "Default" : name).tag(name) + } + } + } + .disabled(!config.isEnabled) + + Section("Timing") { + Stepper(value: Binding( + get: { Int(config.idleInterval) }, + set: { config.idleInterval = TimeInterval($0) } + ), in: 5 ... 600, step: 5) { + Text("Idle Interval: \(Int(config.idleInterval)) s") + } + + Stepper(value: Binding( + get: { Int(config.movementInterval) }, + set: { config.movementInterval = TimeInterval($0) } + ), in: 2 ... 60, step: 1) { + Text("Movement Interval: \(Int(config.movementInterval)) s") + } + } + .disabled(!config.isEnabled) + + Section("Font Size") { + VStack(alignment: .leading) { + Text("Clock Size: \(Int(config.timeFontSizeRatio * 100)) %") + .font(.caption) + Slider(value: Binding( + get: { Double(config.timeFontSizeRatio) }, + set: { config.timeFontSizeRatio = CGFloat($0) } + ), in: 0.05 ... 0.4, step: 0.01) + } + + VStack(alignment: .leading) { + Text("Date relative: \(Int(config.dateFontRelativeSize * 100)) %") + .font(.caption) + Slider(value: Binding( + get: { Double(config.dateFontRelativeSize) }, + set: { config.dateFontRelativeSize = CGFloat($0) } + ), in: 0.1 ... 1.0, step: 0.05) + } + } + .disabled(!config.isEnabled) + + Section("Animation") { + VStack(alignment: .leading) { + Text("Fade Duration: \(String(format: "%.1f", config.fadeDuration)) s") + .font(.caption) + Slider(value: Binding( + get: { config.fadeDuration }, + set: { config.fadeDuration = $0 } + ), in: 0.1 ... 3.0, step: 0.1) + } + } + .disabled(!config.isEnabled) + + Section("Brightness") { + Toggle("Enable Dimming", isOn: Binding( + get: { config.enablesAutoDimming }, + set: { config.enablesAutoDimming = $0 } + )) + + VStack(alignment: .leading) { + Text("Dim Level: \(Int(config.dimLevel * 100)) %") + .font(.caption) + Slider(value: Binding( + get: { Double(config.dimLevel * 100) }, + set: { config.dimLevel = CGFloat($0) / 100 } + ), in: 0 ... 100, step: 1) + } + .disabled(!config.enablesAutoDimming) + + Toggle("Restore Previous Brightness on Wake", isOn: Binding( + get: { config.restoresBrightness }, + set: { config.restoresBrightness = $0 } + )).disabled(!config.enablesAutoDimming) + + VStack(alignment: .leading) { + Text("Restore Brightness: \(Int(config.wakeBrightnessLevel * 100)) %") + .font(.caption) + Slider(value: Binding( + get: { Double(config.wakeBrightnessLevel * 100) }, + set: { config.wakeBrightnessLevel = CGFloat($0) / 100 } + ), in: 0 ... 100, step: 1) + } + .disabled(!config.enablesAutoDimming || config.restoresBrightness) + } + .disabled(!config.isEnabled) + + Section { + Button("Test Screen Saver") { + if let keyWindow = UIApplication.shared.keyWindowActiveScene { + // Ensure the manager knows about the current key window in case monitoring was not started yet. + ScreenSaverManager.shared.startMonitoring(window: keyWindow, configuration: config) + } + ScreenSaverManager.shared.presentSaver(configuration: config) + } + } + } + .navigationTitle("Screen Saver") + .onDisappear { + ScreenSaverManager.shared.updateConfiguration(config) + // Persist to Preferences + Preferences.screensaverEnabled = config.isEnabled + Preferences.screensaverShowsTime = config.showsTime + Preferences.screensaverShowsDate = config.showsDate + Preferences.screensaverIdleInterval = config.idleInterval + Preferences.screensaverMovementInterval = config.movementInterval + Preferences.screensaverFontName = config.fontName ?? "" + Preferences.screensaverTimeFontRatio = Double(config.timeFontSizeRatio) + Preferences.screensaverDateFontRatio = Double(config.dateFontRelativeSize) + Preferences.screensaverEnableDimming = config.enablesAutoDimming + Preferences.screensaverDimLevel = Double(config.dimLevel) + Preferences.screensaverWakeBrightness = Double(config.wakeBrightnessLevel) + Preferences.screensaverShowsSeconds = config.showsSeconds + Preferences.screensaverUse24Hour = config.uses24HourTime + Preferences.screensaverFadeDuration = config.fadeDuration + Preferences.screensaverRestoreBrightness = config.restoresBrightness + } + } +} + +extension UIApplication { + var keyWindowActiveScene: UIWindow? { + connectedScenes + .compactMap { $0 as? UIWindowScene } + .first { $0.activationState == .foregroundActive }? + .windows + .first { $0.isKeyWindow } + } +} + +#Preview { + NavigationView { + ScreenSaverSettingsView() + } +} diff --git a/openHAB/SettingsView/SettingsView.swift b/openHAB/SettingsView/SettingsView.swift index 4f2145716..6460361cc 100644 --- a/openHAB/SettingsView/SettingsView.swift +++ b/openHAB/SettingsView/SettingsView.swift @@ -145,6 +145,13 @@ struct SettingsView: View { } Preferences.idleOff = settingsIdleOff Preferences.sendCrashReports = settingsSendCrashReports + + // Apply global UI changes immediately (status bar visibility) + UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap(\.windows) + .first?.rootViewController? + .setNeedsStatusBarAppearanceUpdate() } } From 503574812ba2540b41e83125154f29d6acce1fb5 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Fri, 25 Jul 2025 09:39:15 +0200 Subject: [PATCH 290/476] swiftline remove updateNavigationTitle Signed-off-by: Tim Mueller-Seydlitz --- OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift | 4 ++-- openHAB/OpenHABViewController.swift | 2 +- openHAB/OpenHABWebViewController.swift | 2 -- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift index 727bbee03..f0f1a045d 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift @@ -521,10 +521,10 @@ public extension OpenHABWidget.CodingData { visibility: visibility, switchSupport: switchSupport, forceAsItem: forceAsItem, - labelSource: OpenHABWidget.LabelSource(rawValue: labelSource ?? "") ?? .unknown, + labelSource: OpenHABWidget.LabelSource(rawValue: labelSource ?? "") ?? .unknown, staticIcon: staticIcon, unit: unit, - pattern: pattern, + pattern: pattern ) } } diff --git a/openHAB/OpenHABViewController.swift b/openHAB/OpenHABViewController.swift index 0f1f7aeb9..d6fe5acb5 100644 --- a/openHAB/OpenHABViewController.swift +++ b/openHAB/OpenHABViewController.swift @@ -23,7 +23,7 @@ protocol OpenHABViewable: AnyObject { } class OpenHABViewController: UIViewController, OpenHABViewable { -private let logger = Logger(subsystem: "org.openhab.UI", category: "OpenHABViewController") + private let logger = Logger(subsystem: "org.openhab.UI", category: "OpenHABViewController") var trackerCancellables = Set() diff --git a/openHAB/OpenHABWebViewController.swift b/openHAB/OpenHABWebViewController.swift index 5b77b8198..9fd4c5434 100644 --- a/openHAB/OpenHABWebViewController.swift +++ b/openHAB/OpenHABWebViewController.swift @@ -72,7 +72,6 @@ class OpenHABWebViewController: OpenHABViewController { super.viewWillAppear(animated) navigationController?.setNavigationBarHidden(hideNavBar, animated: animated) navigationController?.navigationBar.prefersLargeTitles = false - updateNavigationTitle() NetworkTracker.shared.$activeConnection .receive(on: DispatchQueue.main) .sink { activeConnection in @@ -97,7 +96,6 @@ class OpenHABWebViewController: OpenHABViewController { self.pageLoadError(message: NSLocalizedString("network_not_available", comment: "")) case .connected: self.hidePopupMessages() - self.updateNavigationTitle() default: break } } From 7b3c3583eea16c7fc24d644aed836eb7acd9c077 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Fri, 25 Jul 2025 11:01:11 +0200 Subject: [PATCH 291/476] Stable display of icon Signed-off-by: Tim Mueller-Seydlitz --- openHAB/SwiftUI/IconView.swift | 4 ++-- openHAB/SwiftUI/Rows/ButtonGridRowView.swift | 8 +++----- openHAB/SwiftUI/Rows/RollershutterRowView.swift | 6 ++---- openHAB/SwiftUI/Rows/SegmentedRowView.swift | 8 +++----- openHAB/SwiftUI/Rows/SwitchRowView.swift | 6 ++---- 5 files changed, 12 insertions(+), 20 deletions(-) diff --git a/openHAB/SwiftUI/IconView.swift b/openHAB/SwiftUI/IconView.swift index 91b041ad6..d98d89a31 100644 --- a/openHAB/SwiftUI/IconView.swift +++ b/openHAB/SwiftUI/IconView.swift @@ -54,7 +54,8 @@ struct IconView: View { icon: widget.icon, state: widget.iconState(), iconType: iconType, - iconColor: queriedIconColor + iconColor: queriedIconColor, + staticIcon: widget.staticIcon ).url } @@ -87,7 +88,6 @@ struct IconView: View { .resizable() .aspectRatio(contentMode: .fit) .frame(width: size.width, height: size.height) - .id(iconURL.absoluteString) } } .onChange(of: widget.icon) { _ in diff --git a/openHAB/SwiftUI/Rows/ButtonGridRowView.swift b/openHAB/SwiftUI/Rows/ButtonGridRowView.swift index 3b645a2ca..a5f91087f 100644 --- a/openHAB/SwiftUI/Rows/ButtonGridRowView.swift +++ b/openHAB/SwiftUI/Rows/ButtonGridRowView.swift @@ -135,12 +135,10 @@ struct ButtonGridRowView: View { VStack(alignment: .leading, spacing: 8) { if showLabelAndIcon { HStack { - if IconView.shouldShowIcon(for: widget) { - IconView(widget: widget) - .frame(width: 24, height: 24) - } + IconView(widget: widget) + .frame(width: 24, height: 24) - if let labelText = widget.labelText, !labelText.isEmpty, widget.labelSource == .sitemapDefinition { + if let labelText = widget.labelText, !labelText.isEmpty { Text(labelText) .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) } diff --git a/openHAB/SwiftUI/Rows/RollershutterRowView.swift b/openHAB/SwiftUI/Rows/RollershutterRowView.swift index 0aa66e975..f97d81da6 100644 --- a/openHAB/SwiftUI/Rows/RollershutterRowView.swift +++ b/openHAB/SwiftUI/Rows/RollershutterRowView.swift @@ -33,10 +33,8 @@ struct RollershutterRowView: View { var body: some View { VStack(alignment: .leading, spacing: 8) { HStack { - if IconView.shouldShowIcon(for: widget) { - IconView(widget: widget) - .frame(width: 24, height: 24) - } + IconView(widget: widget) + .frame(width: 24, height: 24) VStack(alignment: .leading, spacing: 2) { if let labelText = widget.labelText, !labelText.isEmpty { diff --git a/openHAB/SwiftUI/Rows/SegmentedRowView.swift b/openHAB/SwiftUI/Rows/SegmentedRowView.swift index bb34565d1..d84ee4cb5 100644 --- a/openHAB/SwiftUI/Rows/SegmentedRowView.swift +++ b/openHAB/SwiftUI/Rows/SegmentedRowView.swift @@ -32,11 +32,9 @@ struct SegmentedRowView: View { var body: some View { HStack { - if IconView.shouldShowIcon(for: widget) { - IconView(widget: widget) - .frame(width: 24, height: 24) - .padding(.top, 4) // Align with text - } + IconView(widget: widget) + .frame(width: 24, height: 24) + .padding(.top, 4) // Align with text if let labelText = widget.labelText, !labelText.isEmpty { Text(labelText) diff --git a/openHAB/SwiftUI/Rows/SwitchRowView.swift b/openHAB/SwiftUI/Rows/SwitchRowView.swift index 6a6f8bd44..ea99d463f 100644 --- a/openHAB/SwiftUI/Rows/SwitchRowView.swift +++ b/openHAB/SwiftUI/Rows/SwitchRowView.swift @@ -35,10 +35,8 @@ struct SwitchRowView: View { var body: some View { HStack { - if IconView.shouldShowIcon(for: widget) { - IconView(widget: widget) - .frame(width: 24, height: 24) - } + IconView(widget: widget) + .frame(width: 24, height: 24) if let labelText = widget.labelText, !labelText.isEmpty { Text(labelText) From b46e8a8f247b40c3583c742548175dccded5313d Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Fri, 25 Jul 2025 22:29:45 +0200 Subject: [PATCH 292/476] testing for more robust icon loading Signed-off-by: Tim Mueller-Seydlitz --- .../Util/OpenHABImageProcessor.swift | 1 + .../OpenHABWidgetIconStateTests.swift | 55 +++++++++++++++++++ openHAB/SwiftUI/IconView.swift | 29 ++++++---- 3 files changed, 75 insertions(+), 10 deletions(-) create mode 100644 OpenHABCore/Tests/OpenHABCoreTests/OpenHABWidgetIconStateTests.swift diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OpenHABImageProcessor.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABImageProcessor.swift index 8111f81db..3ad8ee378 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/OpenHABImageProcessor.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABImageProcessor.swift @@ -47,6 +47,7 @@ public struct OpenHABImageProcessor: ImageProcessor { return UIImage(systemSymbol: .exclamationmarkTriangle).withTintColor(.orange, renderingMode: .alwaysOriginal) } default: + logger.error("Not an SVG image") return Kingfisher.DefaultImageProcessor().process(item: item, options: KingfisherParsedOptionsInfo(KingfisherManager.shared.defaultOptions)) } } diff --git a/OpenHABCore/Tests/OpenHABCoreTests/OpenHABWidgetIconStateTests.swift b/OpenHABCore/Tests/OpenHABCoreTests/OpenHABWidgetIconStateTests.swift new file mode 100644 index 000000000..c662a222f --- /dev/null +++ b/OpenHABCore/Tests/OpenHABCoreTests/OpenHABWidgetIconStateTests.swift @@ -0,0 +1,55 @@ +// Copyright (c) 2010-2025 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 OpenHABCore +import SwiftUI +import Testing + +@Suite +struct OpenHABWidgetIconStateTests { +// @Test +// func returnsLowercasedState() { +// let widget = makeTestWidget(itemState: "ON") +// #expect(widget.iconState() == "on") +// } + +// @Test +// func handlesMixedCase() { +// let widget = makeTestWidget(itemState: "ClOsEd") +// #expect(widget.iconState() == "closed") +// } + +// @Test +// func returnsNilWhenStateIsEmpty() { +// let widget = makeTestWidget(itemState: "") +// #expect(widget.iconState() == nil) +// } + + @Test + func returnsNilWhenStateIsNil() { + let widget = makeTestWidget(itemState: nil) + #expect(widget.iconState() == nil) + } + + @Test + func acceptsNumericString() { + let widget = makeTestWidget(itemState: "123") + #expect(widget.iconState() == "123") + } + + // MARK: - Helpers + + private func makeTestWidget(itemState: String?) -> OpenHABWidget { + let dto = OpenHABWidget() + dto.item = OpenHABItem(name: "String", type: "String", state: itemState, link: "122", label: "labe", groupType: nil, stateDescription: nil, commandDescription: nil, members: [], category: nil, options: nil) + return dto + } +} diff --git a/openHAB/SwiftUI/IconView.swift b/openHAB/SwiftUI/IconView.swift index d98d89a31..85317fe47 100644 --- a/openHAB/SwiftUI/IconView.swift +++ b/openHAB/SwiftUI/IconView.swift @@ -25,6 +25,7 @@ struct IconView: View { @State private var imageLoadingFailed = false @State private var retryCount = 0 + @State private var refreshKey = 0 private let maxRetries = 3 private let retryDelay: TimeInterval = 1.0 @@ -67,17 +68,22 @@ struct IconView: View { .frame(width: size.width, height: size.height) if let iconURL, !imageLoadingFailed { - KFImage.url(iconURL) + var resource: any Resource { + KF.ImageResource(downloadURL: iconURL, cacheKey: nil) + } + + KFImage(source: .network(resource)) + +// KFImage(iconURL) + .placeholder { - // Show empty space while loading Rectangle() - .fill(Color.clear) + .fill(Color.red) .frame(width: size.width, height: size.height) } .onFailure { error in logger.error("Icon loading failed for widget \(widget.label): \(error.localizedDescription)") -// handleLoadingFailure() - imageLoadingFailed = true + handleLoadingFailure() } .onSuccess { _ in imageLoadingFailed = false @@ -88,16 +94,13 @@ struct IconView: View { .resizable() .aspectRatio(contentMode: .fit) .frame(width: size.width, height: size.height) + .id("\(iconURL.absoluteString)-\(refreshKey)") } } - .onChange(of: widget.icon) { _ in + .onChange(of: widget) { _ in // Reset loading state when icon changes resetLoadingState() } - .onChange(of: widget.iconState()) { _ in - // Reset loading state when icon state changes - resetLoadingState() - } .onChange(of: networkTracker.activeConnection) { _ in // Reset loading state when connection changes resetLoadingState() @@ -122,6 +125,12 @@ struct IconView: View { private func resetLoadingState() { imageLoadingFailed = false retryCount = 0 + refreshKey += 1 + + // Force reload by invalidating cache for this URL + if let iconURL { + ImageCache.default.removeImage(forKey: iconURL.absoluteString, processorIdentifier: "org.openhab.svgprocessor") + } } } From be814ea11fd25da8a2cd93e3eb48e8a301a75bb6 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Fri, 25 Jul 2025 22:52:10 +0200 Subject: [PATCH 293/476] Small cleanup in OpenHABSitemapViewController Signed-off-by: Tim Mueller-Seydlitz --- openHAB/OpenHABSitemapViewController.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/openHAB/OpenHABSitemapViewController.swift b/openHAB/OpenHABSitemapViewController.swift index 0bb7ec943..168ba58bc 100644 --- a/openHAB/OpenHABSitemapViewController.swift +++ b/openHAB/OpenHABSitemapViewController.swift @@ -716,8 +716,6 @@ extension OpenHABSitemapViewController: UITableViewDelegate, UITableViewDataSour iconColor: iconColor ).url { logger.info("URL: \(urlc.absoluteString, privacy: .private) , color: \(iconColor)") - var imageRequest = URLRequest(url: urlc) - imageRequest.timeoutInterval = 10.0 cell.imageView?.kf.setImage( with: KF.ImageResource(downloadURL: urlc), // , cacheKey: urlc.path + (urlc.query ?? "")), placeholder: nil, From 1fcfbb24bd25820273d8cd126e9a4ad105c843bf Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Sun, 27 Jul 2025 09:22:09 +0200 Subject: [PATCH 294/476] Upgraded package to Kingfisher > 8.5.0 Improving stability of IconView Signed-off-by: Tim Mueller-Seydlitz --- .../OpenHABCore/Model/OpenHABItem.swift | 2 +- .../OpenHABCore/Model/OpenHABWidget.swift | 4 +- .../Util/OpenHABImageProcessor.swift | 3 + .../OpenHABCore/Util/StringExtension.swift | 2 +- .../Tests/OpenHABCoreTests/UIColorTests.swift | 44 +++++++++ openHAB.xcodeproj/project.pbxproj | 7 +- .../xcshareddata/swiftpm/Package.resolved | 4 +- openHAB/SwiftUI/IconURLView.swift | 52 ++++++++++ openHAB/SwiftUI/IconView.swift | 98 +++++++------------ openHAB/SwiftUI/Rows/ButtonGridRowView.swift | 5 +- 10 files changed, 148 insertions(+), 73 deletions(-) create mode 100644 OpenHABCore/Tests/OpenHABCoreTests/UIColorTests.swift create mode 100644 openHAB/SwiftUI/IconURLView.swift diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABItem.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABItem.swift index 9b88ae6c4..ac81b0f7b 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABItem.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABItem.swift @@ -97,7 +97,7 @@ public extension OpenHABItem { let hue = CGFloat(state: values[0], divisor: 360) let saturation = CGFloat(state: values[1], divisor: 100) let brightness = CGFloat(state: values[2], divisor: 100) - logger.info("hue saturation brightness: \(hue) \(saturation) \(brightness)") + logger.info("State as UIColor hue saturation brightness: \(hue) \(saturation) \(brightness)") return UIColor(hue: hue, saturation: saturation, brightness: brightness, alpha: 1.0) } else { return .black diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift index f0f1a045d..1c4104b67 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift @@ -94,7 +94,7 @@ public class OpenHABWidget: NSObject, MKAnnotation, Identifiable, ObservableObje public var sendCommand: ((_ item: OpenHABItem, _ command: String?) -> Void)? public var widgetId = "" @Published public var label = "" - public var icon = "" + @Published public var icon = "" public var type: WidgetType = .unknown public var url = "" public var period = "" @@ -121,7 +121,7 @@ public class OpenHABWidget: NSObject, MKAnnotation, Identifiable, ObservableObje public var visibility = true public var unit: String? public var pattern: String? - public var staticIcon: Bool? + @Published public var staticIcon: Bool? public var switchSupport = false public var labelSource = LabelSource.unknown public var releaseOnly: Bool? diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OpenHABImageProcessor.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABImageProcessor.swift index 3ad8ee378..b78801a62 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/OpenHABImageProcessor.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABImageProcessor.swift @@ -37,13 +37,16 @@ public struct OpenHABImageProcessor: ImageProcessor { switch data[0] { case 0x3C: // Likely SVG, since it starts with '<' + logger.info("Processing as SVG") if let image = SDImageSVGCoder.shared.decodedImage(with: data, options: nil) { let size = image.size if size.width > 1000 || size.height > 1000 { return UIImage(systemSymbol: .exclamationmarkTriangle).withTintColor(.orange, renderingMode: .alwaysOriginal) } + logger.info("SVG decoded successfully") return image } else { + logger.error("Failed to decode SVG") return UIImage(systemSymbol: .exclamationmarkTriangle).withTintColor(.orange, renderingMode: .alwaysOriginal) } default: diff --git a/OpenHABCore/Sources/OpenHABCore/Util/StringExtension.swift b/OpenHABCore/Sources/OpenHABCore/Util/StringExtension.swift index f28abf52f..9dd81e6f0 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/StringExtension.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/StringExtension.swift @@ -114,7 +114,7 @@ public extension String { let hue = CGFloat(state: values[0], divisor: 360) let saturation = CGFloat(state: values[1], divisor: 100) let brightness = CGFloat(state: values[2], divisor: 100) - logger.info("hue saturation brightness: \(hue) \(saturation) \(brightness)") + logger.info("Parsing statue as UIColor hue saturation brightness: \(hue) \(saturation) \(brightness)") return UIColor(hue: hue, saturation: saturation, brightness: brightness, alpha: 1.0) } diff --git a/OpenHABCore/Tests/OpenHABCoreTests/UIColorTests.swift b/OpenHABCore/Tests/OpenHABCoreTests/UIColorTests.swift new file mode 100644 index 000000000..ae4614412 --- /dev/null +++ b/OpenHABCore/Tests/OpenHABCoreTests/UIColorTests.swift @@ -0,0 +1,44 @@ +// Copyright (c) 2010-2025 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 OpenHABCore +import Testing +import UIKit + +@Suite +struct UIColorTests { + @Test + func resolvesNamedColor_exactMatch() { + #expect(UIColor(fromString: "red") == .ohRed) + } + + @Test + func resolvesNamedColor_caseInsensitive() { + #expect(UIColor(fromString: "NAvY") == .ohNavy) + } + + @Test + func resolvesNamedColor_withWhitespace() { + #expect(UIColor(fromString: " green ") == .ohGreen) + } + + @Test + func fallsBackToHexParsing() { + #expect(UIColor(fromString: "#FF0000") == UIColor(hex: "#FF0000")) + } + + @Test + func returnsFallbackForInvalidColor() { + let color = UIColor(fromString: "notAColor") + let fallback = UIColor(hex: "notAColor") + #expect(color == fallback) + } +} diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index 881a24391..f1c6c29ca 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -161,6 +161,7 @@ DAD0857B2AE4782F001D36BE /* OpenHABWatchUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAD0857A2AE4782F001D36BE /* OpenHABWatchUITests.swift */; }; DAD0857D2AE4782F001D36BE /* OpenHABWatchLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAD0857C2AE4782F001D36BE /* OpenHABWatchLaunchTests.swift */; }; DAD0858B2AE56F0E001D36BE /* OpenHABWatch.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAD0855F2AE47824001D36BE /* OpenHABWatch.swift */; }; + DAE2800A2E35F5590028EE24 /* IconURLView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAE280092E35F5590028EE24 /* IconURLView.swift */; }; DAE7B4A72E26927C00B9FE99 /* ButtonGridRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAE7B4A62E26927C00B9FE99 /* ButtonGridRowView.swift */; }; DAEA21D82DBF472D00D54342 /* RowViewFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAEA21D72DBF472D00D54342 /* RowViewFactory.swift */; }; DAEA21DA2DBF477E00D54342 /* SwitchRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAEA21D92DBF477E00D54342 /* SwitchRowView.swift */; }; @@ -505,6 +506,7 @@ DAD488B3287DDDFE00414693 /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = Resources/nb.lproj/Intents.strings; sourceTree = ""; }; DAD488B4287DDDFF00414693 /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = nb.lproj/Localizable.strings; sourceTree = ""; }; DAD488B5287DDDFF00414693 /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = nb.lproj/InfoPlist.strings; sourceTree = ""; }; + DAE280092E35F5590028EE24 /* IconURLView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconURLView.swift; sourceTree = ""; }; DAE7B4A62E26927C00B9FE99 /* ButtonGridRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ButtonGridRowView.swift; sourceTree = ""; }; DAEA21D72DBF472D00D54342 /* RowViewFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RowViewFactory.swift; sourceTree = ""; }; DAEA21D92DBF477E00D54342 /* SwitchRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwitchRowView.swift; sourceTree = ""; }; @@ -867,6 +869,7 @@ children = ( DA35E2CA2E1F93AD003987BB /* ImageView.swift */, DA35E2CC2E1F96CA003987BB /* IconView.swift */, + DAE280092E35F5590028EE24 /* IconURLView.swift */, DA64ACA72DBEAD8300294F60 /* SitemapPageView.swift */, DAEA21D72DBF472D00D54342 /* RowViewFactory.swift */, DAC949FF2E21A473007E67B7 /* Rows */, @@ -1755,6 +1758,7 @@ DA5ED9BE2C850955004875E0 /* ClientCertificatesViewModel.swift in Sources */, DAEA21D82DBF472D00D54342 /* RowViewFactory.swift in Sources */, DA21EAE22339621C001AB415 /* Throttler.swift in Sources */, + DAE2800A2E35F5590028EE24 /* IconURLView.swift in Sources */, DAF4F6C0222734D300C24876 /* NewImageUITableViewCell.swift in Sources */, DA6B2EEF2C861BC900DF77CF /* DrawerView.swift in Sources */, DA48001A2D83742A009CF127 /* DebugSettingsView.swift in Sources */, @@ -2357,7 +2361,6 @@ CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 50; DEBUG_INFORMATION_FORMAT = dwarf; - DEVELOPMENT_TEAM = PBAPXHRAM9; DEVELOPMENT_TEAM = D6A95UZXVC; "DEVELOPMENT_TEAM[sdk=watchos*]" = PBAPXHRAM9; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -2882,7 +2885,7 @@ repositoryURL = "https://github.com/onevcat/Kingfisher.git"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 8.0.0; + minimumVersion = 8.5.0; }; }; 93F8063327AE6C620035A6B0 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */ = { diff --git a/openHAB.xcworkspace/xcshareddata/swiftpm/Package.resolved b/openHAB.xcworkspace/xcshareddata/swiftpm/Package.resolved index ddc8f1c78..879cc6ee6 100644 --- a/openHAB.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/openHAB.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -113,8 +113,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/onevcat/Kingfisher.git", "state" : { - "revision" : "4c6b067f96953ee19526e49e4189403a2be21fb3", - "version" : "8.3.1" + "revision" : "2015fda791daa72c8058619545a593bf8c1dd59f", + "version" : "8.5.0" } }, { diff --git a/openHAB/SwiftUI/IconURLView.swift b/openHAB/SwiftUI/IconURLView.swift new file mode 100644 index 000000000..c6c57a507 --- /dev/null +++ b/openHAB/SwiftUI/IconURLView.swift @@ -0,0 +1,52 @@ +// Copyright (c) 2010-2025 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 Kingfisher +import OpenHABCore +import os.log +import SwiftUI + +/// A SwiftUI view that displays widget icons with openHAB-specific styling and caching +struct IconURLView: View { + @State var iconURL: URL + + let size: CGSize + + private let logger = Logger(subsystem: "org.openhab", category: "IconURLView") + + var body: some View { + ZStack { + // No icon or failed to load - show empty space + Rectangle() + .fill(Color.clear) + .frame(width: size.width, height: size.height) + + KFImage(iconURL) + .retry(maxCount: 3, interval: .seconds(5)) + .resizable() + .setProcessor(OpenHABImageProcessor()) + .onFailure { error in + logger.error("Icon loading failed for URL : \(iconURL): \(error.localizedDescription)") + } + .onSuccess { _ in + logger.info("Loading succeeded for URL : \(iconURL)") + } + .fade(duration: 0.25) + .cancelOnDisappear(true) + .aspectRatio(contentMode: .fit) + .frame(width: size.width, height: size.height) + } + } +} + +#Preview { + IconURLView(iconURL: URL(string: "https://github.com/onevcat/Flower-Data-Set/raw/master/rose/rose-1.jpg")!, size: CGSize(width: 100, height: 100)) +} diff --git a/openHAB/SwiftUI/IconView.swift b/openHAB/SwiftUI/IconView.swift index 85317fe47..3f2c8e844 100644 --- a/openHAB/SwiftUI/IconView.swift +++ b/openHAB/SwiftUI/IconView.swift @@ -9,11 +9,24 @@ // // SPDX-License-Identifier: EPL-2.0 +import Combine import Kingfisher import OpenHABCore import os.log import SwiftUI +/// Shared storage for tracking cached icon keys +class IconCacheTracker: ObservableObject { + static let shared = IconCacheTracker() + @Published var cachedKeys: [String] = [] + + func addCacheKey(_ key: String) { + if !cachedKeys.contains(key) { + cachedKeys.append(key) + } + } +} + /// A SwiftUI view that displays widget icons with openHAB-specific styling and caching struct IconView: View { @ObservedObject var widget: OpenHABWidget @@ -23,18 +36,15 @@ struct IconView: View { let size: CGSize let iconType: IconType = .svg - @State private var imageLoadingFailed = false - @State private var retryCount = 0 - @State private var refreshKey = 0 - private let maxRetries = 3 - private let retryDelay: TimeInterval = 1.0 - - private let logger = Logger(subsystem: "org.openhab", category: "WidgetIconView") + private let logger = Logger(subsystem: "org.openhab", category: "IconView") private var iconURL: URL? { - guard !widget.icon.isEmpty, - let activeConnection = networkTracker.activeConnection, - !activeConnection.configuration.url.isEmpty else { + guard !widget.icon.isEmpty else { return nil } + + guard + let activeConnection = networkTracker.activeConnection, + !activeConnection.configuration.url.isEmpty else { + logger.debug("No active connection to fetch icon") return nil } @@ -55,7 +65,7 @@ struct IconView: View { icon: widget.icon, state: widget.iconState(), iconType: iconType, - iconColor: queriedIconColor, + iconColor: "black", staticIcon: widget.staticIcon ).url } @@ -67,69 +77,29 @@ struct IconView: View { .fill(Color.clear) .frame(width: size.width, height: size.height) - if let iconURL, !imageLoadingFailed { - var resource: any Resource { - KF.ImageResource(downloadURL: iconURL, cacheKey: nil) - } - - KFImage(source: .network(resource)) - -// KFImage(iconURL) - - .placeholder { - Rectangle() - .fill(Color.red) - .frame(width: size.width, height: size.height) - } + if let iconURL { + KFImage(iconURL) + .retry(maxCount: 3, interval: .seconds(5)) + .resizable() + .setProcessor(OpenHABImageProcessor()) .onFailure { error in logger.error("Icon loading failed for widget \(widget.label): \(error.localizedDescription)") - handleLoadingFailure() } - .onSuccess { _ in - imageLoadingFailed = false - retryCount = 0 + .onSuccess { result in + logger.debug("Loading of icon succeeded for widget \(widget.label)") + if result.cacheType != .none { + let cacheKey = iconURL.absoluteString + IconCacheTracker.shared.addCacheKey(cacheKey) + logger.debug("Icon loaded from cache: \(cacheKey)") + } } - .setProcessor(OpenHABImageProcessor()) .fade(duration: 0.25) - .resizable() + .cancelOnDisappear(true) .aspectRatio(contentMode: .fit) .frame(width: size.width, height: size.height) - .id("\(iconURL.absoluteString)-\(refreshKey)") } } - .onChange(of: widget) { _ in - // Reset loading state when icon changes - resetLoadingState() - } .onChange(of: networkTracker.activeConnection) { _ in - // Reset loading state when connection changes - resetLoadingState() - } - } - - private func handleLoadingFailure() { - if retryCount < maxRetries { - retryCount += 1 - logger.info("Retrying icon load for widget \(widget.label), attempt \(retryCount)/\(maxRetries)") - - DispatchQueue.main.asyncAfter(deadline: .now() + retryDelay * Double(retryCount)) { - // Force reload by toggling imageLoadingFailed - imageLoadingFailed = false - } - } else { - logger.warning("Max retries reached for widget \(widget.label), giving up") - imageLoadingFailed = true - } - } - - private func resetLoadingState() { - imageLoadingFailed = false - retryCount = 0 - refreshKey += 1 - - // Force reload by invalidating cache for this URL - if let iconURL { - ImageCache.default.removeImage(forKey: iconURL.absoluteString, processorIdentifier: "org.openhab.svgprocessor") } } } diff --git a/openHAB/SwiftUI/Rows/ButtonGridRowView.swift b/openHAB/SwiftUI/Rows/ButtonGridRowView.swift index a5f91087f..a9af6dff8 100644 --- a/openHAB/SwiftUI/Rows/ButtonGridRowView.swift +++ b/openHAB/SwiftUI/Rows/ButtonGridRowView.swift @@ -130,7 +130,7 @@ struct ButtonGridRowView: View { private var gridColumns: Int { min(buttons.map { $0.column ?? 1 }.max() ?? 1, maxColumns) } - + var body: some View { VStack(alignment: .leading, spacing: 8) { if showLabelAndIcon { @@ -146,6 +146,9 @@ struct ButtonGridRowView: View { Spacer() } } +// else { +// logger.debug("No label or icon") +// } LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: gridColumns), spacing: 8) { ForEach(0 ..< gridRows, id: \.self) { row in From e87be9330ffad930ad514ae4a6bd2db7d66a6e87 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Sun, 27 Jul 2025 16:19:26 +0200 Subject: [PATCH 295/476] Implement IconCacheTracker as actor Signed-off-by: Tim Mueller-Seydlitz --- openHAB/SwiftUI/IconView.swift | 22 ++++++++++++++++---- openHAB/SwiftUI/Rows/ButtonGridRowView.swift | 2 +- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/openHAB/SwiftUI/IconView.swift b/openHAB/SwiftUI/IconView.swift index 3f2c8e844..08a82cbc1 100644 --- a/openHAB/SwiftUI/IconView.swift +++ b/openHAB/SwiftUI/IconView.swift @@ -15,16 +15,28 @@ import OpenHABCore import os.log import SwiftUI -/// Shared storage for tracking cached icon keys -class IconCacheTracker: ObservableObject { +/// Thread-safe actor for tracking cached icon keys +actor IconCacheTracker { static let shared = IconCacheTracker() - @Published var cachedKeys: [String] = [] + private var cachedKeys: [String] = [] func addCacheKey(_ key: String) { if !cachedKeys.contains(key) { cachedKeys.append(key) } } + + func getCachedKeys() -> [String] { + cachedKeys + } + + func clearCache() { + cachedKeys.removeAll() + } + + func getCacheCount() -> Int { + cachedKeys.count + } } /// A SwiftUI view that displays widget icons with openHAB-specific styling and caching @@ -89,7 +101,9 @@ struct IconView: View { logger.debug("Loading of icon succeeded for widget \(widget.label)") if result.cacheType != .none { let cacheKey = iconURL.absoluteString - IconCacheTracker.shared.addCacheKey(cacheKey) + Task { + await IconCacheTracker.shared.addCacheKey(cacheKey) + } logger.debug("Icon loaded from cache: \(cacheKey)") } } diff --git a/openHAB/SwiftUI/Rows/ButtonGridRowView.swift b/openHAB/SwiftUI/Rows/ButtonGridRowView.swift index a9af6dff8..e32f03f97 100644 --- a/openHAB/SwiftUI/Rows/ButtonGridRowView.swift +++ b/openHAB/SwiftUI/Rows/ButtonGridRowView.swift @@ -130,7 +130,7 @@ struct ButtonGridRowView: View { private var gridColumns: Int { min(buttons.map { $0.column ?? 1 }.max() ?? 1, maxColumns) } - + var body: some View { VStack(alignment: .leading, spacing: 8) { if showLabelAndIcon { From c6a15c1bde5db36d824e35a1a75978b9a81d6ed5 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Tue, 29 Jul 2025 21:43:55 +0200 Subject: [PATCH 296/476] minor evolution Signed-off-by: Tim Mueller-Seydlitz --- .../Sources/CommonUI/ColorExtension.swift | 10 ++++++ .../OpenHABCore/Model/OpenHABWidget.swift | 2 +- .../Sources/OpenHABCore/Util/Endpoint.swift | 18 ++++++++-- .../OpenHABCore/Util/UIColorExtension.swift | 34 +++++++++++++++++-- openHAB/SwiftUI/IconView.swift | 3 +- openHAB/SwiftUI/Rows/ButtonGridRowView.swift | 1 + openHAB/SwiftUI/SitemapPageView.swift | 2 +- 7 files changed, 63 insertions(+), 7 deletions(-) diff --git a/CommonUI/Sources/CommonUI/ColorExtension.swift b/CommonUI/Sources/CommonUI/ColorExtension.swift index 09ded372e..98c122031 100644 --- a/CommonUI/Sources/CommonUI/ColorExtension.swift +++ b/CommonUI/Sources/CommonUI/ColorExtension.swift @@ -29,6 +29,16 @@ public extension Color { let components = componentsForColorTemperature(temperature: temperature) self.init(red: components.r, green: components.g, blue: components.b) } + + func hexString() -> String { + let components = cgColor?.components + let r: CGFloat = components?[0] ?? 0.0 + let g: CGFloat = components?[1] ?? 0.0 + let b: CGFloat = components?[2] ?? 0.0 + + let hexString = String(format: "#%02lX%02lX%02lX", lroundf(Float(r * 255)), lroundf(Float(g * 255)), lroundf(Float(b * 255))) + return hexString + } } // For algorithm see https://web.archive.org/web/20151024031939/http://www.zombieprototypes.com/?p=210 diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift index 1c4104b67..ac8f958f2 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift @@ -285,7 +285,7 @@ public class OpenHABWidget: NSObject, MKAnnotation, Identifiable, ObservableObje return "OFF" } } else if let color = itemState.parseAsUIColor() { - return "#\(color.toHex() ?? "000000")" + return "#\(color.hexString ?? "000000")" } } else if item.isOfTypeOrGroupType(.number) || item.isOfTypeOrGroupType(.numberWithDimension) { let numberState = itemState.parseAsNumber(format: item.stateDescription?.numberPattern) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/Endpoint.swift b/OpenHABCore/Sources/OpenHABCore/Util/Endpoint.swift index 84653374c..cb026abf7 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/Endpoint.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/Endpoint.swift @@ -56,6 +56,14 @@ public struct Endpoint: Equatable { var queryItems: [URLQueryItem] } +extension UIColor { + var rgbaDescription: String { + var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 + getRed(&r, green: &g, blue: &b, alpha: &a) + return String(format: "r: %.2f, g: %.2f, b: %.2f, a: %.2f", r, g, b, a) + } +} + public extension Endpoint { var url: URL? { var components = URLComponents(string: baseURL) @@ -186,8 +194,14 @@ public extension Endpoint { if source == "if" || source == "iconify" { queryItems = [URLQueryItem(name: "height", value: "64")] - if !iconColor.isEmpty, let colorString = UIColor(fromString: iconColor).toHex() { - queryItems.append(URLQueryItem(name: "color", value: "#\(colorString)")) + if !iconColor.isEmpty { + let uiColor = UIColor(fromString: iconColor) + logger.info("\(uiColor.rgbaDescription)") + let colorString = uiColor.hexString + logger.debug("color : \(colorString ?? "No proper color")") + if let colorString { + queryItems.append(URLQueryItem(name: "color", value: "#\(colorString)")) + } } return Endpoint( baseURL: "https://api.iconify.design/", diff --git a/OpenHABCore/Sources/OpenHABCore/Util/UIColorExtension.swift b/OpenHABCore/Sources/OpenHABCore/Util/UIColorExtension.swift index f6cd9665d..a7d1e0ab8 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/UIColorExtension.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/UIColorExtension.swift @@ -166,14 +166,14 @@ public extension UIColor { "yellow": UIColor.ohYellow, "purple": UIColor.ohPurple, "fuchsia": UIColor.ohFuchsia, - "white": UIColor.ohWhite, + "white": UIColor.white, // .ohWhite, "lime": UIColor.ohLime, "green": UIColor.ohGreen, "navy": UIColor.ohNavy, "blue": UIColor.ohBlue, "teal": UIColor.ohTeal, "aqua": UIColor.ohAqua, - "black": UIColor.ohBlack, + "black": UIColor.black, // .ohBlack, "silver": UIColor.ohSilver, "gray": UIColor.ohGray, "primary": UIColor.ohPrimary, @@ -232,4 +232,34 @@ public extension UIColor { return String(format: "%02lX%02lX%02lX", lroundf(red * 255), lroundf(green * 255), lroundf(blue * 255)) } } + + var hexString: String? { + var red: CGFloat = 0 + var green: CGFloat = 0 + var blue: CGFloat = 0 + var alpha: CGFloat = 0 + + let multiplier = CGFloat(255.999999) + + guard getRed(&red, green: &green, blue: &blue, alpha: &alpha) else { + return nil + } + + if alpha == 1.0 { + return String( + format: "%02lX%02lX%02lX", + Int(red * multiplier), + Int(green * multiplier), + Int(blue * multiplier) + ) + } else { + return String( + format: "%02lX%02lX%02lX%02lX", + Int(red * multiplier), + Int(green * multiplier), + Int(blue * multiplier), + Int(alpha * multiplier) + ) + } + } } diff --git a/openHAB/SwiftUI/IconView.swift b/openHAB/SwiftUI/IconView.swift index 08a82cbc1..eeb45e788 100644 --- a/openHAB/SwiftUI/IconView.swift +++ b/openHAB/SwiftUI/IconView.swift @@ -70,6 +70,7 @@ struct IconView: View { return widget.iconColor.isEmpty ? "black" : widget.iconColor } } + logger.debug("icon color: \(queriedIconColor)") return Endpoint.icon( rootUrl: activeConnection.configuration.url, @@ -77,7 +78,7 @@ struct IconView: View { icon: widget.icon, state: widget.iconState(), iconType: iconType, - iconColor: "black", + iconColor: queriedIconColor, staticIcon: widget.staticIcon ).url } diff --git a/openHAB/SwiftUI/Rows/ButtonGridRowView.swift b/openHAB/SwiftUI/Rows/ButtonGridRowView.swift index e32f03f97..4231d9ab8 100644 --- a/openHAB/SwiftUI/Rows/ButtonGridRowView.swift +++ b/openHAB/SwiftUI/Rows/ButtonGridRowView.swift @@ -198,6 +198,7 @@ extension OpenHABWidgetMapping { func toWidget(widgetId: String, item: OpenHABItem?) -> OpenHABWidget { let widget = OpenHABWidget() widget.widgetId = widgetId + widget.id = widgetId widget.label = label widget.command = command widget.item = item diff --git a/openHAB/SwiftUI/SitemapPageView.swift b/openHAB/SwiftUI/SitemapPageView.swift index 10bf516f3..324c4e2bd 100644 --- a/openHAB/SwiftUI/SitemapPageView.swift +++ b/openHAB/SwiftUI/SitemapPageView.swift @@ -25,7 +25,7 @@ struct SitemapPageView: View { var body: some View { NavigationStack { - List(viewModel.relevantWidgets) { widget in + List(viewModel.relevantWidgets, id: \.id) { widget in Group { if let linkedPage = widget.linkedPage { NavigationLink(destination: SitemapPageView(viewModel: SitemapPageViewModel(pageUrl: linkedPage.link, title: linkedPage.title))) { From 85a120965090bb535d180093be174f5de6d55b5c Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Thu, 31 Jul 2025 17:34:26 +0200 Subject: [PATCH 297/476] Stabilize IconView display, use id composed out of pageId and widget.id Show current image before new image is loaded Handling of none icon Signed-off-by: Tim Mueller-Seydlitz --- .../OpenHABCore/Model/OpenHABWidget.swift | 2 +- .../Sources/OpenHABCore/Util/Endpoint.swift | 4 ++-- .../OpenHABCore/Util/OpenHABImageProcessor.swift | 6 ++++-- .../OpenHABCore/Util/StringExtension.swift | 7 +++++++ .../OpenHABCoreTests/StringExtensionTests.swift | 16 ++++++++++++++++ openHAB/SitemapPageViewModel.swift | 2 +- openHAB/SwiftUI/IconView.swift | 13 ++++++++++--- 7 files changed, 41 insertions(+), 9 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift index ac8f958f2..b289e330f 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift @@ -271,6 +271,7 @@ public class OpenHABWidget: NSObject, MKAnnotation, Identifiable, ObservableObje public func iconState() -> String? { guard let item, let itemState = item.state else { return nil } + guard !itemState.isNoneIcon else { return nil } if item.isOfTypeOrGroupType(.color) { // For items that control a color item fetch the correct icon if type == .slider || (type == .switchWidget && mappings.isEmpty) { @@ -296,7 +297,6 @@ public class OpenHABWidget: NSObject, MKAnnotation, Identifiable, ObservableObje // or to "ON" to fetch the correct icon return (itemState == "0" || itemState == "OFF") ? "OFF" : "ON" } - return itemState } diff --git a/OpenHABCore/Sources/OpenHABCore/Util/Endpoint.swift b/OpenHABCore/Sources/OpenHABCore/Util/Endpoint.swift index cb026abf7..624fa35ed 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/Endpoint.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/Endpoint.swift @@ -216,8 +216,8 @@ public extension Endpoint { iconName = "none" } - if staticIcon != true { - queryItems.append(URLQueryItem(name: "state", value: state ?? "null")) + if staticIcon != true, let state { + queryItems.append(URLQueryItem(name: "state", value: state)) } queryItems.append(contentsOf: [ diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OpenHABImageProcessor.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABImageProcessor.swift index b78801a62..368d44f2e 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/OpenHABImageProcessor.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABImageProcessor.swift @@ -37,16 +37,18 @@ public struct OpenHABImageProcessor: ImageProcessor { switch data[0] { case 0x3C: // Likely SVG, since it starts with '<' - logger.info("Processing as SVG") + logger.info("Processing as SVG, data size: \(data.count)") if let image = SDImageSVGCoder.shared.decodedImage(with: data, options: nil) { let size = image.size + logger.info("SVG size: \(size.width)x\(size.height)") if size.width > 1000 || size.height > 1000 { + logger.warning("SVG too large (\(size.width)x\(size.height)), returning warning icon") return UIImage(systemSymbol: .exclamationmarkTriangle).withTintColor(.orange, renderingMode: .alwaysOriginal) } logger.info("SVG decoded successfully") return image } else { - logger.error("Failed to decode SVG") + logger.error("Failed to decode SVG, data starts with: \(String(data.prefix(20).map { String(format: "%02x", $0) }.joined()))") return UIImage(systemSymbol: .exclamationmarkTriangle).withTintColor(.orange, renderingMode: .alwaysOriginal) } default: diff --git a/OpenHABCore/Sources/OpenHABCore/Util/StringExtension.swift b/OpenHABCore/Sources/OpenHABCore/Util/StringExtension.swift index 9dd81e6f0..c53ee856f 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/StringExtension.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/StringExtension.swift @@ -187,3 +187,10 @@ public extension String? { } } } + +public extension String { + var isNoneIcon: Bool { + let pattern = #"^(oh:([a-z]+:)?)?none$"# + return range(of: pattern, options: .regularExpression) != nil + } +} diff --git a/OpenHABCore/Tests/OpenHABCoreTests/StringExtensionTests.swift b/OpenHABCore/Tests/OpenHABCoreTests/StringExtensionTests.swift index b297c7fd6..ecb6da92a 100644 --- a/OpenHABCore/Tests/OpenHABCoreTests/StringExtensionTests.swift +++ b/OpenHABCore/Tests/OpenHABCoreTests/StringExtensionTests.swift @@ -22,4 +22,20 @@ struct StringExtensionTests { #expect("///".removeTrailingSlashes() == "") #expect("".removeTrailingSlashes() == "") } + + @Test + func testIsNoneIcon() throws { + let testCases: [String: Bool] = [ + "none": true, + "oh:none": true, + "oh:classic:none": true, + "oh:foo:none": true, + "f7:none": false, + "lights": false + ] + + for (input, expected) in testCases { + #expect(input.isNoneIcon == expected, "\(input) failed") + } + } } diff --git a/openHAB/SitemapPageViewModel.swift b/openHAB/SitemapPageViewModel.swift index 6921d8389..65db32bac 100644 --- a/openHAB/SitemapPageViewModel.swift +++ b/openHAB/SitemapPageViewModel.swift @@ -44,7 +44,7 @@ class SitemapPageViewModel: ObservableObject { private var activeConnectionInfo: ConnectionInfo? private var pageHandlingTask: Task? private var defaultSitemap = "" - private var pageId = "" + @Published var pageId = "" private var isLinkedPage = false var relevantWidgets: [OpenHABWidget] { diff --git a/openHAB/SwiftUI/IconView.swift b/openHAB/SwiftUI/IconView.swift index eeb45e788..37a5b466f 100644 --- a/openHAB/SwiftUI/IconView.swift +++ b/openHAB/SwiftUI/IconView.swift @@ -44,12 +44,15 @@ struct IconView: View { @ObservedObject var widget: OpenHABWidget @ObservedObject private var networkTracker = NetworkTracker.shared @Environment(\.colorScheme) private var colorScheme + @EnvironmentObject var viewModel: SitemapPageViewModel let size: CGSize let iconType: IconType = .svg private let logger = Logger(subsystem: "org.openhab", category: "IconView") + @State private var currentImage: UIImage? + private var iconURL: URL? { guard !widget.icon.isEmpty else { return nil } @@ -97,9 +100,11 @@ struct IconView: View { .setProcessor(OpenHABImageProcessor()) .onFailure { error in logger.error("Icon loading failed for widget \(widget.label): \(error.localizedDescription)") + logger.error("Failed URL: \(iconURL.absoluteString)") } .onSuccess { result in logger.debug("Loading of icon succeeded for widget \(widget.label)") + currentImage = result.image if result.cacheType != .none { let cacheKey = iconURL.absoluteString Task { @@ -108,14 +113,16 @@ struct IconView: View { logger.debug("Icon loaded from cache: \(cacheKey)") } } - .fade(duration: 0.25) + .placeholder { _ in + // Workaround to show current image before new image is displayed. See https://github.com/onevcat/Kingfisher/issues/2028 + Image(uiImage: currentImage ?? .init()).resizable() + } .cancelOnDisappear(true) .aspectRatio(contentMode: .fit) .frame(width: size.width, height: size.height) + .id(viewModel.pageId + widget.id) } } - .onChange(of: networkTracker.activeConnection) { _ in - } } } From bdffae50fa4b995d34087a18e1e099a43d0673d6 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Thu, 31 Jul 2025 18:16:10 +0200 Subject: [PATCH 298/476] replace os_log by logger Signed-off-by: Tim Mueller-Seydlitz --- openHAB/SitemapPageViewModel.swift | 4 ++-- openHAB/WebUITableViewCell.swift | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/openHAB/SitemapPageViewModel.swift b/openHAB/SitemapPageViewModel.swift index 65db32bac..f8822fa80 100644 --- a/openHAB/SitemapPageViewModel.swift +++ b/openHAB/SitemapPageViewModel.swift @@ -329,9 +329,9 @@ class SitemapPageViewModel: ObservableObject { Task { do { try await openAPIService?.sendItemCommand(itemname: itemname, command: command) - os_log("SitemapPageViewModel: Successfully sent command %{PUBLIC}@ to %{PUBLIC}@", log: .default, type: .info, command, itemname) + logger.info("Successfully sent command \(command) to \(itemname)") } catch { - os_log("SitemapPageViewModel: Failed to send command %{PUBLIC}@ to %{PUBLIC}@ — %{PUBLIC}@", log: .default, type: .error, command, itemname, error.localizedDescription) + logger.info("Failed to send command\(command) to \(itemname): \(error.localizedDescription)") } } } diff --git a/openHAB/WebUITableViewCell.swift b/openHAB/WebUITableViewCell.swift index c1f612a0e..1ea0dace0 100644 --- a/openHAB/WebUITableViewCell.swift +++ b/openHAB/WebUITableViewCell.swift @@ -54,7 +54,7 @@ class WebUITableViewCell: GenericUITableViewCell, NoIconDisplayableCell { logger.info("webview loading url \(self.widget.url)") // swiftformat:enable redundantSelf let urlString = widget.url.lowercased().hasPrefix("http://") || widget.url.lowercased().hasPrefix("https://") ? widget.url : Preferences.currentHomePreferences.localConnectionConfig.url + widget.url - os_log("webview final URL: %{PUBLIC}@", log: .default, type: .info, urlString) + logger.info("webview final URL: \(urlString)") guard url?.absoluteString != urlString else { logger.info("webview URL has not changed, abort loading") return From ce642793b59fc5563584fb3eda7b321cb393c206 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Thu, 31 Jul 2025 20:52:06 +0200 Subject: [PATCH 299/476] id for buttongrid Signed-off-by: Tim Mueller-Seydlitz --- openHAB/SwiftUI/Rows/ButtonGridRowView.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openHAB/SwiftUI/Rows/ButtonGridRowView.swift b/openHAB/SwiftUI/Rows/ButtonGridRowView.swift index 4231d9ab8..a86d104e4 100644 --- a/openHAB/SwiftUI/Rows/ButtonGridRowView.swift +++ b/openHAB/SwiftUI/Rows/ButtonGridRowView.swift @@ -105,6 +105,7 @@ struct ButtonGridButton: View { struct ButtonGridRowView: View { @ObservedObject var widget: OpenHABWidget + @EnvironmentObject var viewModel: SitemapPageViewModel private let logger = Logger(subsystem: "org.openhab", category: "ButtonGridRowView") @@ -158,6 +159,7 @@ struct ButtonGridRowView: View { if let button, button.visibility { ButtonGridButton(widget: button) + .id(viewModel.pageId + button.widgetId) } else { // Empty cell to maintain grid structure Rectangle() From 5ceb17917ee882b57b65d696e92167a59fd17b79 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Thu, 31 Jul 2025 21:38:02 +0200 Subject: [PATCH 300/476] Handle readOnly Signed-off-by: Tim Mueller-Seydlitz --- openHAB/SwiftUI/Rows/ColorPickerRowView.swift | 1 + openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift | 1 + openHAB/SwiftUI/Rows/DatePickerInputRowView.swift | 1 + openHAB/SwiftUI/Rows/SelectionRowView.swift | 1 + openHAB/SwiftUI/Rows/SetpointRowView.swift | 2 ++ openHAB/SwiftUI/Rows/SliderRowView.swift | 3 ++- openHAB/SwiftUI/Rows/SwitchRowView.swift | 1 + openHAB/SwiftUI/Rows/TextInputRowView.swift | 1 + 8 files changed, 10 insertions(+), 1 deletion(-) diff --git a/openHAB/SwiftUI/Rows/ColorPickerRowView.swift b/openHAB/SwiftUI/Rows/ColorPickerRowView.swift index 1e7c5a3e9..430ca4e8e 100644 --- a/openHAB/SwiftUI/Rows/ColorPickerRowView.swift +++ b/openHAB/SwiftUI/Rows/ColorPickerRowView.swift @@ -38,6 +38,7 @@ struct ColorPickerRowView: View { .onChange(of: selectedColor) { newColor in sendColorCommand(newColor) } + .disabled(widget.readOnly ?? false) if let labelValue = widget.labelValue, !labelValue.isEmpty { Text(labelValue) diff --git a/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift b/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift index e6e7527d1..84064f14d 100644 --- a/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift +++ b/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift @@ -129,6 +129,7 @@ struct ColorTemperaturePickerRowView: View { sendTemperatureCommand() } .frame(height: 28) + .disabled(widget.readOnly ?? false) } // Cool indicator diff --git a/openHAB/SwiftUI/Rows/DatePickerInputRowView.swift b/openHAB/SwiftUI/Rows/DatePickerInputRowView.swift index 6ecbb506b..cc43f86f5 100644 --- a/openHAB/SwiftUI/Rows/DatePickerInputRowView.swift +++ b/openHAB/SwiftUI/Rows/DatePickerInputRowView.swift @@ -55,6 +55,7 @@ struct DatePickerInputRowView: View { .onChange(of: selectedDate) { newDate in sendDateCommand(newDate) } + .disabled(widget.readOnly ?? false) } .onAppear { if let state = widget.item?.state, !state.isEmpty { diff --git a/openHAB/SwiftUI/Rows/SelectionRowView.swift b/openHAB/SwiftUI/Rows/SelectionRowView.swift index d43a98099..39b3eca33 100644 --- a/openHAB/SwiftUI/Rows/SelectionRowView.swift +++ b/openHAB/SwiftUI/Rows/SelectionRowView.swift @@ -53,6 +53,7 @@ struct SelectionRowView: View { logger.info("Selection changed to: \(mapping.label)") viewModel.sendCommand(widget.item, commandToSend: mapping.command) } + .disabled(widget.readOnly ?? false) } } .onAppear { diff --git a/openHAB/SwiftUI/Rows/SetpointRowView.swift b/openHAB/SwiftUI/Rows/SetpointRowView.swift index d457d0cd3..5ee6efcda 100644 --- a/openHAB/SwiftUI/Rows/SetpointRowView.swift +++ b/openHAB/SwiftUI/Rows/SetpointRowView.swift @@ -64,6 +64,7 @@ struct SetpointRowView: View { .buttonStyle(.plain) .disabled(currentValue <= widget.minValue) .sensoryHeavyFeedbackIfAvailable(trigger: triggerFeedback) + .disabled(widget.readOnly ?? false) Text(formattedValue) .font(.body.monospacedDigit()) @@ -80,6 +81,7 @@ struct SetpointRowView: View { .buttonStyle(.plain) .disabled(currentValue >= widget.maxValue) .sensoryHeavyFeedbackIfAvailable(trigger: triggerFeedback) + .disabled(widget.readOnly ?? false) } } } diff --git a/openHAB/SwiftUI/Rows/SliderRowView.swift b/openHAB/SwiftUI/Rows/SliderRowView.swift index 442f9417d..585e1306c 100644 --- a/openHAB/SwiftUI/Rows/SliderRowView.swift +++ b/openHAB/SwiftUI/Rows/SliderRowView.swift @@ -52,7 +52,7 @@ struct SliderRowView: View { } .contentShape(Rectangle()) // 🔍 Make row but not slider tappable .onTapGesture { - if widget.switchSupport { + if widget.switchSupport, !(widget.readOnly ?? false) { viewModel.sendCommand(widget.item, commandToSend: sliderValue <= widget.minValue ? "ON" : "OFF") } } @@ -74,6 +74,7 @@ struct SliderRowView: View { sendSliderUpdate(sliderValue) } } + .disabled(widget.readOnly ?? false) } .onAppear { loadCurrentValue() diff --git a/openHAB/SwiftUI/Rows/SwitchRowView.swift b/openHAB/SwiftUI/Rows/SwitchRowView.swift index ea99d463f..301193edd 100644 --- a/openHAB/SwiftUI/Rows/SwitchRowView.swift +++ b/openHAB/SwiftUI/Rows/SwitchRowView.swift @@ -64,6 +64,7 @@ struct SwitchRowView: View { } )) .labelsHidden() + .disabled(widget.readOnly ?? false) } .contentShape(Rectangle()) } diff --git a/openHAB/SwiftUI/Rows/TextInputRowView.swift b/openHAB/SwiftUI/Rows/TextInputRowView.swift index ce267d66f..08fd4e7d9 100644 --- a/openHAB/SwiftUI/Rows/TextInputRowView.swift +++ b/openHAB/SwiftUI/Rows/TextInputRowView.swift @@ -42,6 +42,7 @@ struct TextInputRowView: View { .onSubmit { sendTextCommand() } + .disabled(widget.readOnly ?? false) } } .onAppear { From ee935f626746608eb409d41b9f14c0fd9be4412f Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Thu, 31 Jul 2025 22:24:00 +0200 Subject: [PATCH 301/476] Reworked hexString Signed-off-by: Tim Mueller-Seydlitz --- .../OpenHABCore/Util/UIColorExtension.swift | 28 ++++++--------- openHAB/SwiftUI/Rows/ButtonGridRowView.swift | 36 +++++++++---------- 2 files changed, 28 insertions(+), 36 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/UIColorExtension.swift b/OpenHABCore/Sources/OpenHABCore/Util/UIColorExtension.swift index a7d1e0ab8..1b870ccf6 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/UIColorExtension.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/UIColorExtension.swift @@ -239,27 +239,21 @@ public extension UIColor { var blue: CGFloat = 0 var alpha: CGFloat = 0 - let multiplier = CGFloat(255.999999) - guard getRed(&red, green: &green, blue: &blue, alpha: &alpha) else { return nil } - if alpha == 1.0 { - return String( - format: "%02lX%02lX%02lX", - Int(red * multiplier), - Int(green * multiplier), - Int(blue * multiplier) - ) - } else { - return String( - format: "%02lX%02lX%02lX%02lX", - Int(red * multiplier), - Int(green * multiplier), - Int(blue * multiplier), - Int(alpha * multiplier) - ) + let r = UInt8(round(red * 255)) + let g = UInt8(round(green * 255)) + let b = UInt8(round(blue * 255)) + let a = UInt8(round(alpha * 255)) + + func toHex(_ value: UInt8) -> String { + let hex = String(value, radix: 16, uppercase: true) + return hex.count == 1 ? "0" + hex : hex } + + let components = [r, g, b] + (a == 255 ? [] : [a]) + return components.map(toHex).joined() } } diff --git a/openHAB/SwiftUI/Rows/ButtonGridRowView.swift b/openHAB/SwiftUI/Rows/ButtonGridRowView.swift index a86d104e4..c6bd4b049 100644 --- a/openHAB/SwiftUI/Rows/ButtonGridRowView.swift +++ b/openHAB/SwiftUI/Rows/ButtonGridRowView.swift @@ -63,7 +63,7 @@ struct ButtonGridButton: View { .scaleEffect(isPressed ? 0.95 : 1.0) } .buttonStyle(PlainButtonStyle()) -// .disabled(widget.readOnly) + .disabled(widget.readOnly ?? false) .sensoryHeavyFeedbackIfAvailable(trigger: triggerFeedback) .onPressGesture( onPress: { @@ -147,24 +147,22 @@ struct ButtonGridRowView: View { Spacer() } } -// else { -// logger.debug("No label or icon") -// } - - LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: gridColumns), spacing: 8) { - ForEach(0 ..< gridRows, id: \.self) { row in - ForEach(0 ..< gridColumns, id: \.self) { column in - let button = buttonForPosition(row: row, column: column) - - if let button, - button.visibility { - ButtonGridButton(widget: button) - .id(viewModel.pageId + button.widgetId) - } else { - // Empty cell to maintain grid structure - Rectangle() - .fill(Color.clear) - .frame(height: 44) + HStack { + LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: gridColumns), spacing: 8) { + ForEach(0 ..< gridRows, id: \.self) { row in + ForEach(0 ..< gridColumns, id: \.self) { column in + let button = buttonForPosition(row: row, column: column) + + if let button, + button.visibility { + ButtonGridButton(widget: button) + .id(viewModel.pageId + button.widgetId) + } else { + // Empty cell to maintain grid structure + Rectangle() + .fill(Color.clear) + .frame(height: 44) + } } } } From 9bdf80379e515b2b36e57e0270e7d8575aea0ba5 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Fri, 1 Aug 2025 08:02:46 +0200 Subject: [PATCH 302/476] SVG view on macOS Signed-off-by: Tim Mueller-Seydlitz --- .../Util/OpenHABImageProcessor.swift | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OpenHABImageProcessor.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABImageProcessor.swift index 368d44f2e..c4f279f25 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/OpenHABImageProcessor.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABImageProcessor.swift @@ -14,6 +14,9 @@ import Kingfisher import os.log import SDWebImageSVGCoder import SFSafeSymbols +#if canImport(AppKit) +import WebKit +#endif public struct OpenHABImageProcessor: ImageProcessor { // `identifier` should be the same for processors with the same properties/functionality @@ -38,6 +41,11 @@ public struct OpenHABImageProcessor: ImageProcessor { switch data[0] { case 0x3C: // Likely SVG, since it starts with '<' logger.info("Processing as SVG, data size: \(data.count)") + #if os(macOS) + if let image = renderSVGWithWebKit(data) { + return image + } + #endif if let image = SDImageSVGCoder.shared.decodedImage(with: data, options: nil) { let size = image.size logger.info("SVG size: \(size.width)x\(size.height)") @@ -57,4 +65,24 @@ public struct OpenHABImageProcessor: ImageProcessor { } } } + + #if os(macOS) + private func renderSVGWithWebKit(_ data: Data) -> NSImage? { + guard let svgString = String(data: data, encoding: .utf8) else { return nil } + let webView = WKWebView(frame: CGRect(origin: .zero, size: CGSize(width: 256, height: 256))) + webView.loadHTMLString("\(svgString)", baseURL: nil) + + let config = WKSnapshotConfiguration() + config.rect = CGRect(origin: .zero, size: webView.bounds.size) + + var snapshotImage: NSImage? + let sema = DispatchSemaphore(value: 0) + webView.takeSnapshot(with: config) { image, _ in + snapshotImage = image + sema.signal() + } + _ = sema.wait(timeout: .now() + 2) + return snapshotImage + } + #endif } From 3d1fb8080a53bd10b16af51faed831606ec3d4d0 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Fri, 1 Aug 2025 08:39:25 +0200 Subject: [PATCH 303/476] Throttling output for Slider, Color, ColorTemperature Signed-off-by: Tim Mueller-Seydlitz --- openHAB/SwiftUI/Rows/ColorPickerRowView.swift | 17 ++++++++++++++++- .../Rows/ColorTemperaturePickerRowView.swift | 9 +++++++++ openHAB/SwiftUI/Rows/SliderRowView.swift | 7 ++++++- 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/openHAB/SwiftUI/Rows/ColorPickerRowView.swift b/openHAB/SwiftUI/Rows/ColorPickerRowView.swift index 430ca4e8e..7ba334ebd 100644 --- a/openHAB/SwiftUI/Rows/ColorPickerRowView.swift +++ b/openHAB/SwiftUI/Rows/ColorPickerRowView.swift @@ -19,6 +19,9 @@ struct ColorPickerRowView: View { @State private var selectedColor: Color = .white @EnvironmentObject var viewModel: SitemapPageViewModel + @State private var lastSendTime: Date = .distantPast + @State private var debounceTask: Task? + private let logger = Logger(subsystem: "org.openhab", category: "WidgetColorPickerView") var body: some View { @@ -36,7 +39,19 @@ struct ColorPickerRowView: View { ColorPicker("Color", selection: $selectedColor, supportsOpacity: false) .labelsHidden() .onChange(of: selectedColor) { newColor in - sendColorCommand(newColor) + let now = Date() + if now.timeIntervalSince(lastSendTime) > 0.2 { + lastSendTime = now + sendColorCommand(newColor) + } + // ColorPicker does not provide an .onEnded like Slider as it doesn’t expose the drag lifecycle. + // It only emits onChange when the color value changes. + // Therefore, we debounce final send after 0.3s of no changes + debounceTask?.cancel() + debounceTask = Task { + try? await Task.sleep(nanoseconds: 300_000_000) // 0.3s + sendColorCommand(newColor) + } } .disabled(widget.readOnly ?? false) diff --git a/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift b/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift index 84064f14d..a7e489c31 100644 --- a/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift +++ b/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift @@ -23,6 +23,8 @@ struct CustomSliderView: View { @GestureState private var dragOffset: CGSize = .zero + @State private var lastSendTime: Date = .distantPast + var body: some View { GeometryReader { geometry in let width = geometry.size.width @@ -46,6 +48,13 @@ struct CustomSliderView: View { let raw = Double(location / width) * (range.upperBound - range.lowerBound) + range.lowerBound let stepped = (raw / step).rounded() * step value = stepped.clamped(to: range) + let now = Date() + if now.timeIntervalSince(lastSendTime) > 0.2 { + lastSendTime = now + onEditingChanged() + } + } + .onEnded { _ in onEditingChanged() } ) diff --git a/openHAB/SwiftUI/Rows/SliderRowView.swift b/openHAB/SwiftUI/Rows/SliderRowView.swift index 585e1306c..b824c2145 100644 --- a/openHAB/SwiftUI/Rows/SliderRowView.swift +++ b/openHAB/SwiftUI/Rows/SliderRowView.swift @@ -23,6 +23,8 @@ struct SliderRowView: View { private let throttleInterval: TimeInterval = 0.9 // in seconds @EnvironmentObject var viewModel: SitemapPageViewModel + @State private var lastSendTime: Date = .distantPast + private var displayValue: Double { isDragging ? sliderValue : (widget.stateValueAsNumberState?.value ?? widget.minValue) } @@ -63,7 +65,10 @@ struct SliderRowView: View { set: { newValue in sliderValue = newValue if widget.shouldUseSliderUpdatesDuringMove() { - sendSliderUpdate(newValue) + let now = Date() + if now.timeIntervalSince(lastSendTime) > 0.2 { + sendSliderUpdate(newValue) + } } } ), From 9a068c7b7c27ba7c65902b95d86b2be01b703b0d Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Fri, 1 Aug 2025 11:54:46 +0200 Subject: [PATCH 304/476] ButtonGridRowView to observe widget with ObservedObject IconView to take full widget only Signed-off-by: Tim Mueller-Seydlitz --- openHAB/SwiftUI/IconView.swift | 8 +------- openHAB/SwiftUI/Rows/ButtonGridRowView.swift | 4 ++-- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/openHAB/SwiftUI/IconView.swift b/openHAB/SwiftUI/IconView.swift index 37a5b466f..e5dbcb0a4 100644 --- a/openHAB/SwiftUI/IconView.swift +++ b/openHAB/SwiftUI/IconView.swift @@ -129,18 +129,13 @@ struct IconView: View { // MARK: - Convenience Extensions extension IconView { - /// Creates a widget icon view with standard size and default styling or custom icon color + /// Creates a widget icon view with standard size init(widget: OpenHABWidget) { self.init( widget: widget, size: CGSize(width: 24, height: 24) ) } - - init(icon: String, iconColor: String = "primary") { - let widget = OpenHABWidget(icon: icon, iconColor: iconColor) - self.init(widget: widget) - } } // MARK: - Widget Type Extensions @@ -162,6 +157,5 @@ extension IconView { let widget = OpenHABWidget() widget.icon = "switch" widget.label = "Test Switch" - return IconView(widget: widget) } diff --git a/openHAB/SwiftUI/Rows/ButtonGridRowView.swift b/openHAB/SwiftUI/Rows/ButtonGridRowView.swift index c6bd4b049..dd98193b5 100644 --- a/openHAB/SwiftUI/Rows/ButtonGridRowView.swift +++ b/openHAB/SwiftUI/Rows/ButtonGridRowView.swift @@ -15,7 +15,7 @@ import os.log import SwiftUI struct ButtonGridButton: View { - let widget: OpenHABWidget + @ObservedObject var widget: OpenHABWidget @State private var isPressed = false @EnvironmentObject var viewModel: SitemapPageViewModel @@ -40,7 +40,7 @@ struct ButtonGridButton: View { } label: { HStack { if !widget.icon.isEmpty { - IconView(icon: widget.icon) + IconView(widget: widget) .frame(width: 16, height: 16) } else { Text(widget.label) From 750fd228ed96c15f915d8f6e4a21878f9dbe688f Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Sat, 2 Aug 2025 18:11:45 +0200 Subject: [PATCH 305/476] Handle refresh for images Signed-off-by: Tim Mueller-Seydlitz --- openHAB/SwiftUI/Rows/ImageRowView.swift | 49 ++++++++++++++++++++++++- 1 file changed, 47 insertions(+), 2 deletions(-) diff --git a/openHAB/SwiftUI/Rows/ImageRowView.swift b/openHAB/SwiftUI/Rows/ImageRowView.swift index dcac6b1a4..be06dec39 100644 --- a/openHAB/SwiftUI/Rows/ImageRowView.swift +++ b/openHAB/SwiftUI/Rows/ImageRowView.swift @@ -18,6 +18,8 @@ import SwiftUI struct ImageRowView: View { @ObservedObject var widget: OpenHABWidget @EnvironmentObject var viewModel: SitemapPageViewModel + @State private var refreshTimer: Timer? + @State private var forceRefreshKey = UUID() private let logger = Logger(subsystem: "org.openhab", category: "ImageRowView") @@ -26,6 +28,10 @@ struct ImageRowView: View { return URL(string: widget.url) } + private var shouldCache: Bool { + widget.refresh == 0 + } + var body: some View { VStack(alignment: .leading, spacing: 8) { if let labelText = widget.labelText, !labelText.isEmpty, widget.labelSource == .sitemapDefinition { @@ -35,11 +41,19 @@ struct ImageRowView: View { switch widget.generateImageResult(rootUrl: viewModel.openHABRootUrl ?? "") { case let .embedded(data: data): - let provider = RawImageDataProvider(data: data, cacheKey: UUID().uuidString) - KFImage(source: .provider(provider)).resizable() + let provider = RawImageDataProvider(data: data, cacheKey: shouldCache ? widget.widgetId : "\(widget.widgetId)-\(forceRefreshKey)") + KFImage(source: .provider(provider)) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxHeight: 300) + .cornerRadius(8) case let .link(url): KFImage(url) .resizable() + .cacheMemoryOnly(!shouldCache) + .forceRefresh(shouldCache ? false : true) + .cacheOriginalImage(!shouldCache ? false : true) + .id(shouldCache ? url?.absoluteString : "\(url?.absoluteString ?? "")-\(forceRefreshKey)") .aspectRatio(contentMode: .fit) .frame(maxHeight: 300) .cornerRadius(8) @@ -60,5 +74,36 @@ struct ImageRowView: View { .foregroundColor(widget.valuecolor.isEmpty ? .secondary : Color(fromString: widget.valuecolor)) } } + .onAppear { + setupRefreshTimer() + } + .onDisappear { + stopRefreshTimer() + } + .onChange(of: widget.refresh) { _ in + setupRefreshTimer() + } + } + + private func setupRefreshTimer() { + stopRefreshTimer() + + guard widget.refresh != 0 else { return } + + let refreshInterval = TimeInterval(Double(widget.refresh) / 1000) + guard refreshInterval > 0.09 else { return } + + logger.info("Scheduling image refresh every \(refreshInterval) seconds") + refreshTimer = Timer.scheduledTimer(withTimeInterval: refreshInterval, repeats: true) { _ in + Task { @MainActor in + logger.info("Refreshing image on \(refreshInterval) seconds schedule") + forceRefreshKey = UUID() + } + } + } + + private func stopRefreshTimer() { + refreshTimer?.invalidate() + refreshTimer = nil } } From c543f54fc99a1664734a20fedcf3b123b260ca60 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Sat, 2 Aug 2025 19:32:28 +0200 Subject: [PATCH 306/476] redacted view during loading Signed-off-by: Tim Mueller-Seydlitz --- NetworkTrackerViewModel 2.swift | 72 ----------------- .../OpenHABCore/Util/UIColorExtension.swift | 48 +++++------ openHAB.xcworkspace/contents.xcworkspacedata | 3 - openHAB/SwiftUI/SitemapPageView.swift | 81 ++++++++++++------- 4 files changed, 77 insertions(+), 127 deletions(-) delete mode 100644 NetworkTrackerViewModel 2.swift diff --git a/NetworkTrackerViewModel 2.swift b/NetworkTrackerViewModel 2.swift deleted file mode 100644 index fdde92f00..000000000 --- a/NetworkTrackerViewModel 2.swift +++ /dev/null @@ -1,72 +0,0 @@ -// Copyright (c) 2010-2025 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 - -@MainActor -public final class NetworkTrackerViewModel: ObservableObject { - @Published public private(set) var activeConnection: ConnectionInfo? - @Published public private(set) var status: NetworkStatus = .connecting - - private let observer: NetworkObserver - - public init(observer: NetworkObserver = .shared) async { - self.observer = observer - await observer.bind(to: self) // ✅ Now allowed - } - - public func startTracking(with configurations: [ConnectionConfiguration]) async { - await observer.startTracking(connectionConfigurations: configurations) - } - - public func send(to item: OpenHABItem, command: String) async throws { - try await observer.send(to: item.name, command: command) - } - - public func updateState(for item: OpenHABItem, state: String) async throws { - try await observer.updateState(for: item.name, state: state) - } - - public func getItems() async throws -> [OpenHABItem] { - try await observer.getItems() - } - - public func getItemByName(id: String) async throws -> OpenHABItem? { - try await observer.getItemByName(id: id) - } - - public func pollDataForPage(sitemapname: String, pageId: String = "", longPolling: Bool = false) async throws -> OpenHABPage? { - try await observer.pollDataForPage(sitemapname: sitemapname, pageId: pageId, longPolling: longPolling) - } - - public func runNow(ruleUID: String, payload: [String: String]) async throws { - try await observer.runNow(ruleUID: ruleUID, payload: payload) - } - - public func resetFailures() async { - await observer.resetFailures() - } - - // Internal API for observer updates - func updateStatus(_ status: NetworkStatus, connection: ConnectionInfo?) { - Task { @MainActor in - self.status = status - self.activeConnection = connection - } - } - - func activeConnectionStream() -> AsyncStream { - AsyncStream { continuation in - let cancellable = self.$activeConnection - .sink { continuation.yield($0) } - - continuation.onTermination = { [cancellable] _ in cancellable.cancel() } - } - } -} diff --git a/OpenHABCore/Sources/OpenHABCore/Util/UIColorExtension.swift b/OpenHABCore/Sources/OpenHABCore/Util/UIColorExtension.swift index 1b870ccf6..92594bed6 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/UIColorExtension.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/UIColorExtension.swift @@ -157,6 +157,30 @@ public extension UIColor { } public extension UIColor { + var hexString: String? { + var red: CGFloat = 0 + var green: CGFloat = 0 + var blue: CGFloat = 0 + var alpha: CGFloat = 0 + + guard getRed(&red, green: &green, blue: &blue, alpha: &alpha) else { + return nil + } + + let r = UInt8(round(red * 255)) + let g = UInt8(round(green * 255)) + let b = UInt8(round(blue * 255)) + let a = UInt8(round(alpha * 255)) + + func toHex(_ value: UInt8) -> String { + let hex = String(value, radix: 16, uppercase: true) + return hex.count == 1 ? "0" + hex : hex + } + + let components = [r, g, b] + (a == 255 ? [] : [a]) + return components.map(toHex).joined() + } + convenience init(fromString string: String) { let namedColors = [ "maroon": UIColor.ohMaroon, @@ -232,28 +256,4 @@ public extension UIColor { return String(format: "%02lX%02lX%02lX", lroundf(red * 255), lroundf(green * 255), lroundf(blue * 255)) } } - - var hexString: String? { - var red: CGFloat = 0 - var green: CGFloat = 0 - var blue: CGFloat = 0 - var alpha: CGFloat = 0 - - guard getRed(&red, green: &green, blue: &blue, alpha: &alpha) else { - return nil - } - - let r = UInt8(round(red * 255)) - let g = UInt8(round(green * 255)) - let b = UInt8(round(blue * 255)) - let a = UInt8(round(alpha * 255)) - - func toHex(_ value: UInt8) -> String { - let hex = String(value, radix: 16, uppercase: true) - return hex.count == 1 ? "0" + hex : hex - } - - let components = [r, g, b] + (a == 255 ? [] : [a]) - return components.map(toHex).joined() - } } diff --git a/openHAB.xcworkspace/contents.xcworkspacedata b/openHAB.xcworkspace/contents.xcworkspacedata index 1111aa022..59e107dc0 100644 --- a/openHAB.xcworkspace/contents.xcworkspacedata +++ b/openHAB.xcworkspace/contents.xcworkspacedata @@ -7,9 +7,6 @@ - - diff --git a/openHAB/SwiftUI/SitemapPageView.swift b/openHAB/SwiftUI/SitemapPageView.swift index 324c4e2bd..9ded51d17 100644 --- a/openHAB/SwiftUI/SitemapPageView.swift +++ b/openHAB/SwiftUI/SitemapPageView.swift @@ -25,36 +25,47 @@ struct SitemapPageView: View { var body: some View { NavigationStack { - List(viewModel.relevantWidgets, id: \.id) { widget in - Group { - if let linkedPage = widget.linkedPage { - NavigationLink(destination: SitemapPageView(viewModel: SitemapPageViewModel(pageUrl: linkedPage.link, title: linkedPage.title))) { - RowViewFactory.view(for: widget) - } - .buttonStyle(.plain) - .padding(.vertical, -6) - .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 24)) - } else if widget.type == .selection { - Button { - selectedWidget = widget - showSelectionSheet = true - } label: { - RowViewFactory.view(for: widget) - } - .buttonStyle(.plain) - } else if widget.type == .input { - Button { - selectedWidget = widget - showInputAlert = true - } label: { - RowViewFactory.view(for: widget) - } - .buttonStyle(.plain) - } else { + List { + if viewModel.isLoading, viewModel.relevantWidgets.isEmpty { + // Show skeleton/placeholder rows while loading + ForEach(placeholderWidgets, id: \.id) { widget in RowViewFactory.view(for: widget) - .onTapGesture { - viewModel.widgetTapped(widget) + .redacted(reason: .placeholder) + .disabled(true) + } + } else { + ForEach(viewModel.relevantWidgets, id: \.id) { widget in + Group { + if let linkedPage = widget.linkedPage { + NavigationLink(destination: SitemapPageView(viewModel: SitemapPageViewModel(pageUrl: linkedPage.link, title: linkedPage.title))) { + RowViewFactory.view(for: widget) + } + .buttonStyle(.plain) + .padding(.vertical, -6) + .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 24)) + } else if widget.type == .selection { + Button { + selectedWidget = widget + showSelectionSheet = true + } label: { + RowViewFactory.view(for: widget) + } + .buttonStyle(.plain) + } else if widget.type == .input { + Button { + selectedWidget = widget + showInputAlert = true + } label: { + RowViewFactory.view(for: widget) + } + .buttonStyle(.plain) + } else { + RowViewFactory.view(for: widget) + .onTapGesture { + viewModel.widgetTapped(widget) + } } + } } } } @@ -112,3 +123,17 @@ struct SitemapPageView: View { _viewModel = StateObject(wrappedValue: viewModel) } } + +extension SitemapPageView { + /// Creates placeholder widgets for skeleton loading state + private var placeholderWidgets: [OpenHABWidget] { + [ + PreviewConstants.openHABSitemapPage!.widgets[3], + PreviewConstants.openHABSitemapPage!.widgets[5], + PreviewConstants.openHABSitemapPage!.widgets[2], + PreviewConstants.openHABSitemapPage!.widgets[6], + PreviewConstants.openHABSitemapPage!.widgets[17], + PreviewConstants.openHABSitemapPage!.widgets[4] + ] + } +} From 59052df6c1a2d89a8c87d17a2062d758e6e7f820 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Sat, 2 Aug 2025 20:46:31 +0200 Subject: [PATCH 307/476] preview compiler requires Logger methods to use string interpolation syntax Included environmentObject for all RowViews where necessary Signed-off-by: Tim Mueller-Seydlitz --- openHAB/SwiftUI/Rows/ButtonGridRowView.swift | 6 +----- openHAB/SwiftUI/Rows/ColorPickerRowView.swift | 1 + openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift | 1 + openHAB/SwiftUI/Rows/DatePickerInputRowView.swift | 3 ++- openHAB/SwiftUI/Rows/FrameRowView.swift | 1 + openHAB/SwiftUI/Rows/GenericRowView.swift | 1 + openHAB/SwiftUI/Rows/RollershutterRowView.swift | 7 ++++--- openHAB/SwiftUI/Rows/SegmentedRowView.swift | 1 + openHAB/SwiftUI/Rows/SetpointRowView.swift | 1 + openHAB/SwiftUI/Rows/SliderRowView.swift | 1 + openHAB/SwiftUI/Rows/SwitchRowView.swift | 5 +++-- openHAB/SwiftUI/Rows/TextInputRowView.swift | 1 + openHAB/SwiftUI/Rows/TextRowView.swift | 1 + 13 files changed, 19 insertions(+), 11 deletions(-) diff --git a/openHAB/SwiftUI/Rows/ButtonGridRowView.swift b/openHAB/SwiftUI/Rows/ButtonGridRowView.swift index dd98193b5..b92446de1 100644 --- a/openHAB/SwiftUI/Rows/ButtonGridRowView.swift +++ b/openHAB/SwiftUI/Rows/ButtonGridRowView.swift @@ -24,11 +24,6 @@ struct ButtonGridButton: View { private let logger = Logger(subsystem: "org.openhab", category: "ButtonGridButton") private var isChecked: Bool { - if let stateless = widget.stateless { - logger.debug("button.stateless: \(stateless)") - } else { - logger.debug("button.stateless: nil") - } if let stateless = widget.stateless, stateless { return false } return widget.item?.state == widget.command } @@ -220,6 +215,7 @@ extension OpenHABWidgetMapping { .padding() Spacer() } + .environmentObject(SitemapPageViewModel()) } else { Text("No button grid widget found") } diff --git a/openHAB/SwiftUI/Rows/ColorPickerRowView.swift b/openHAB/SwiftUI/Rows/ColorPickerRowView.swift index 7ba334ebd..7d002f9b9 100644 --- a/openHAB/SwiftUI/Rows/ColorPickerRowView.swift +++ b/openHAB/SwiftUI/Rows/ColorPickerRowView.swift @@ -110,4 +110,5 @@ struct ColorPickerRowView: View { .padding() Spacer() } + .environmentObject(SitemapPageViewModel()) } diff --git a/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift b/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift index a7e489c31..412ba76fe 100644 --- a/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift +++ b/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift @@ -198,4 +198,5 @@ struct ColorTemperaturePickerRowView: View { .padding() Spacer() } + .environmentObject(SitemapPageViewModel()) } diff --git a/openHAB/SwiftUI/Rows/DatePickerInputRowView.swift b/openHAB/SwiftUI/Rows/DatePickerInputRowView.swift index cc43f86f5..3fec2e272 100644 --- a/openHAB/SwiftUI/Rows/DatePickerInputRowView.swift +++ b/openHAB/SwiftUI/Rows/DatePickerInputRowView.swift @@ -104,8 +104,9 @@ struct DatePickerInputRowView: View { #Preview { let widget = PreviewConstants.openHABSitemapPage!.widgets[13] VStack { - ColorTemperaturePickerRowView(widget: widget) + DatePickerInputRowView(widget: widget) .padding() Spacer() } + .environmentObject(SitemapPageViewModel()) } diff --git a/openHAB/SwiftUI/Rows/FrameRowView.swift b/openHAB/SwiftUI/Rows/FrameRowView.swift index 808a43704..f01fc51c0 100644 --- a/openHAB/SwiftUI/Rows/FrameRowView.swift +++ b/openHAB/SwiftUI/Rows/FrameRowView.swift @@ -33,4 +33,5 @@ struct FrameRowView: View { List([widget]) { widget in FrameRowView(widget: widget) } + .environmentObject(SitemapPageViewModel()) } diff --git a/openHAB/SwiftUI/Rows/GenericRowView.swift b/openHAB/SwiftUI/Rows/GenericRowView.swift index 3174a3e38..eab2a3941 100644 --- a/openHAB/SwiftUI/Rows/GenericRowView.swift +++ b/openHAB/SwiftUI/Rows/GenericRowView.swift @@ -37,4 +37,5 @@ struct GenericRowView: View { List([widget]) { widget in GenericRowView(widget: widget) } + .environmentObject(SitemapPageViewModel()) } diff --git a/openHAB/SwiftUI/Rows/RollershutterRowView.swift b/openHAB/SwiftUI/Rows/RollershutterRowView.swift index f97d81da6..83e93a8d9 100644 --- a/openHAB/SwiftUI/Rows/RollershutterRowView.swift +++ b/openHAB/SwiftUI/Rows/RollershutterRowView.swift @@ -47,7 +47,7 @@ struct RollershutterRowView: View { Button { triggerUpFeedback.toggle() - logger.info("up button pressed") + logger.info("\("up button pressed")") viewModel.sendCommand(widget.item, commandToSend: RollerShutterCommand.up.rawValue) } label: { Image(systemSymbol: .chevronUp) @@ -59,7 +59,7 @@ struct RollershutterRowView: View { Button { triggerStopFeedback.toggle() - logger.info("stop button pressed") + logger.info("\("stop button pressed")") viewModel.sendCommand(widget.item, commandToSend: RollerShutterCommand.stop.rawValue) } label: { Image(systemSymbol: .stop) @@ -71,7 +71,7 @@ struct RollershutterRowView: View { Button { triggerDownFeedback.toggle() - logger.info("down button pressed") + logger.info("\("down button pressed")") viewModel.sendCommand(widget.item, commandToSend: RollerShutterCommand.down.rawValue) } label: { Image(systemSymbol: .chevronDown) @@ -111,4 +111,5 @@ extension View { RollershutterRowView(widget: widget) Spacer() } + .environmentObject(SitemapPageViewModel()) } diff --git a/openHAB/SwiftUI/Rows/SegmentedRowView.swift b/openHAB/SwiftUI/Rows/SegmentedRowView.swift index d84ee4cb5..e13e4f3db 100644 --- a/openHAB/SwiftUI/Rows/SegmentedRowView.swift +++ b/openHAB/SwiftUI/Rows/SegmentedRowView.swift @@ -90,4 +90,5 @@ struct SegmentedRowView: View { SegmentedRowView(widget: widget) Spacer() } + .environmentObject(SitemapPageViewModel()) } diff --git a/openHAB/SwiftUI/Rows/SetpointRowView.swift b/openHAB/SwiftUI/Rows/SetpointRowView.swift index 5ee6efcda..4fc7be3a7 100644 --- a/openHAB/SwiftUI/Rows/SetpointRowView.swift +++ b/openHAB/SwiftUI/Rows/SetpointRowView.swift @@ -125,4 +125,5 @@ struct SetpointRowView: View { SetpointRowView(widget: widget) Spacer() } + .environmentObject(SitemapPageViewModel()) } diff --git a/openHAB/SwiftUI/Rows/SliderRowView.swift b/openHAB/SwiftUI/Rows/SliderRowView.swift index b824c2145..995c3ac1c 100644 --- a/openHAB/SwiftUI/Rows/SliderRowView.swift +++ b/openHAB/SwiftUI/Rows/SliderRowView.swift @@ -125,4 +125,5 @@ struct SliderRowView: View { SliderRowView(widget: widget) Spacer() } + .environmentObject(SitemapPageViewModel()) } diff --git a/openHAB/SwiftUI/Rows/SwitchRowView.swift b/openHAB/SwiftUI/Rows/SwitchRowView.swift index 301193edd..06eac3e52 100644 --- a/openHAB/SwiftUI/Rows/SwitchRowView.swift +++ b/openHAB/SwiftUI/Rows/SwitchRowView.swift @@ -56,9 +56,9 @@ struct SwitchRowView: View { set: { newValue in let newState = newValue ? "ON" : "OFF" if newValue { - logger.info("Switch to ON") + logger.info("\("Switch to ON")") } else { - logger.info("Switch to OFF") + logger.info("\("Switch to OFF")") } viewModel.sendCommand(widget.item, commandToSend: newState) } @@ -76,4 +76,5 @@ struct SwitchRowView: View { SwitchRowView(widget: widget) Spacer() } + .environmentObject(SitemapPageViewModel()) } diff --git a/openHAB/SwiftUI/Rows/TextInputRowView.swift b/openHAB/SwiftUI/Rows/TextInputRowView.swift index 08fd4e7d9..94de4537b 100644 --- a/openHAB/SwiftUI/Rows/TextInputRowView.swift +++ b/openHAB/SwiftUI/Rows/TextInputRowView.swift @@ -68,4 +68,5 @@ struct TextInputRowView: View { TextInputRowView(widget: widget) Spacer() } + .environmentObject(SitemapPageViewModel()) } diff --git a/openHAB/SwiftUI/Rows/TextRowView.swift b/openHAB/SwiftUI/Rows/TextRowView.swift index 7071c61f0..3d082c8fe 100644 --- a/openHAB/SwiftUI/Rows/TextRowView.swift +++ b/openHAB/SwiftUI/Rows/TextRowView.swift @@ -52,4 +52,5 @@ struct TextRowView: View { TextRowView(widget: widget) Spacer() } + .environmentObject(SitemapPageViewModel()) } From 735d325224b785bc6058a79979b20dbf5ff64ee4 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Sat, 2 Aug 2025 23:05:06 +0200 Subject: [PATCH 308/476] Support for SSE events via openapigen (#901) * Include tag 'events' in openapi generation Signed-off-by: Tim Mueller-Seydlitz * Correcting openapi.json for Content-Type sent by getEvents Signed-off-by: Tim Mueller-Seydlitz * Removed content application/json for getEvents and regenerated Client.swift and Types.swift Introduced the struct OpenHABEvent to decode getEvents data. Should be included in openapi.json Introduced helper openHABEvents(topics: ) to get an AsyncSequence Signed-off-by: Tim Mueller-Seydlitz * DateTime parsing pushed to OpenAPIService initializer Removed updateForLongPolling and pollDataForSitemap(subscriptionId) that was not used Signed-off-by: Tim Mueller-Seydlitz * Adds a complete working SSE command item impl Signed-off-by: Dan Cunningham * revert dev id Signed-off-by: Dan Cunningham * remove commented code Signed-off-by: Dan Cunningham * better screensaver commands Signed-off-by: Dan Cunningham * new device app options for notifications/sse Signed-off-by: Dan Cunningham * Adds TTS, finalizes device sytax, updates README Signed-off-by: Dan Cunningham * remove comment Signed-off-by: Dan Cunningham * Small cleanups Signed-off-by: Dan Cunningham --------- Signed-off-by: Tim Mueller-Seydlitz Signed-off-by: Dan Cunningham Co-authored-by: Dan Cunningham --- OpenHABCore/README.md | 8 + .../GeneratedSources/openapi/Client.swift | 180 +++++++ .../GeneratedSources/openapi/Types.swift | 439 ++++++++++++++++++ .../OpenHABCore/Model/OpenHABEvent.swift | 39 ++ .../OpenHABCore/Util/ItemEventStream.swift | 227 +++++++++ .../OpenHABCore/Util/OpenAPIService.swift | 59 +-- .../OpenHABCore/Util/Preferences.swift | 1 + .../openapi/openapi-generator-config.yml | 1 + .../Sources/OpenHABCore/openapi/openapi.json | 12 +- README.md | 317 +++++++++++++ openHAB.xcodeproj/.project.pbxproj.swp | Bin 16384 -> 0 bytes openHAB.xcodeproj/project.pbxproj | 5 +- openHAB/OpenHABRootViewController.swift | 110 ++++- openHAB/ScreenSaver/ScreenSaverManager.swift | 8 + .../ApplicationSettingsView.swift | 27 +- openHAB/SettingsView/ItemSelectionView.swift | 75 +++ openHAB/SettingsView/SettingsView.swift | 6 +- 17 files changed, 1464 insertions(+), 50 deletions(-) create mode 100644 OpenHABCore/Sources/OpenHABCore/Model/OpenHABEvent.swift create mode 100644 OpenHABCore/Sources/OpenHABCore/Util/ItemEventStream.swift delete mode 100644 openHAB.xcodeproj/.project.pbxproj.swp create mode 100644 openHAB/SettingsView/ItemSelectionView.swift diff --git a/OpenHABCore/README.md b/OpenHABCore/README.md index e3f615e2a..8ba7f1f57 100644 --- a/OpenHABCore/README.md +++ b/OpenHABCore/README.md @@ -1,3 +1,11 @@ # OpenHABCore This package contains code shared between the main openHAB app and its extensions. + +1. Invoke the (CLI manually)[https://swiftpackageindex.com/apple/swift-openapi-generator/1.10.1/documentation/swift-openapi-generator/manually-invoking-the-generator-cli] +This is a work around to use openAPI in a package. + +1. Clone the generator package locally with git clone https://github.com/apple/swift-openapi-generator + +1. Run ```cd swift-openapi-generator && swift run swift-openapi-generator generate --config ../Sources/OpenHABCore/openapi/openapi-generator-config.yml --output-directory ../Sources/OpenHABCore/GeneratedSources/openapi ../Sources/OpenHABCore/openapi/openapi.json``` + diff --git a/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Client.swift b/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Client.swift index ef46695f0..2703e90eb 100644 --- a/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Client.swift +++ b/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Client.swift @@ -2757,6 +2757,186 @@ public struct Client: APIProtocol { } ) } + /// Initiates a new item state tracker connection + /// + /// - Remark: HTTP `GET /events/states`. + /// - Remark: Generated from `#/paths//events/states/get(initNewStateTacker)`. + public func initNewStateTacker(_ input: Operations.initNewStateTacker.Input) async throws -> Operations.initNewStateTacker.Output { + try await client.send( + input: input, + forOperation: Operations.initNewStateTacker.id, + serializer: { input in + let path = try converter.renderedPath( + template: "/events/states", + parameters: [] + ) + var request: HTTPTypes.HTTPRequest = .init( + soar_path: path, + method: .get + ) + suppressMutabilityWarning(&request) + converter.setAcceptHeader( + in: &request.headerFields, + contentTypes: input.headers.accept + ) + return (request, nil) + }, + deserializer: { response, responseBody in + switch response.status.code { + case 200: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Operations.initNewStateTacker.Output.Ok.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "text/event-stream" + ] + ) + switch chosenContentType { + case "text/event-stream": + body = try converter.getResponseBodyAsBinary( + OpenAPIRuntime.HTTPBody.self, + from: responseBody, + transforming: { value in + .text_event_hyphen_stream(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .ok(.init(body: body)) + default: + return .undocumented( + statusCode: response.status.code, + .init( + headerFields: response.headerFields, + body: responseBody + ) + ) + } + } + ) + } + /// Get all events. + /// + /// - Remark: HTTP `GET /events`. + /// - Remark: Generated from `#/paths//events/get(getEvents)`. + public func getEvents(_ input: Operations.getEvents.Input) async throws -> Operations.getEvents.Output { + try await client.send( + input: input, + forOperation: Operations.getEvents.id, + serializer: { input in + let path = try converter.renderedPath( + template: "/events", + parameters: [] + ) + var request: HTTPTypes.HTTPRequest = .init( + soar_path: path, + method: .get + ) + suppressMutabilityWarning(&request) + try converter.setQueryItemAsURI( + in: &request, + style: .form, + explode: true, + name: "topics", + value: input.query.topics + ) + converter.setAcceptHeader( + in: &request.headerFields, + contentTypes: input.headers.accept + ) + return (request, nil) + }, + deserializer: { response, responseBody in + switch response.status.code { + case 200: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Operations.getEvents.Output.Ok.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "text/event-stream" + ] + ) + switch chosenContentType { + case "text/event-stream": + body = try converter.getResponseBodyAsBinary( + OpenAPIRuntime.HTTPBody.self, + from: responseBody, + transforming: { value in + .text_event_hyphen_stream(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .ok(.init(body: body)) + case 400: + return .badRequest(.init()) + default: + return .undocumented( + statusCode: response.status.code, + .init( + headerFields: response.headerFields, + body: responseBody + ) + ) + } + } + ) + } + /// Changes the list of items a SSE connection will receive state updates to. + /// + /// - Remark: HTTP `POST /events/states/{connectionId}`. + /// - Remark: Generated from `#/paths//events/states/{connectionId}/post(updateItemListForStateUpdates)`. + public func updateItemListForStateUpdates(_ input: Operations.updateItemListForStateUpdates.Input) async throws -> Operations.updateItemListForStateUpdates.Output { + try await client.send( + input: input, + forOperation: Operations.updateItemListForStateUpdates.id, + serializer: { input in + let path = try converter.renderedPath( + template: "/events/states/{}", + parameters: [ + input.path.connectionId + ] + ) + var request: HTTPTypes.HTTPRequest = .init( + soar_path: path, + method: .post + ) + suppressMutabilityWarning(&request) + let body: OpenAPIRuntime.HTTPBody? + switch input.body { + case .none: + body = nil + case let .json(value): + body = try converter.setOptionalRequestBodyAsJSON( + value, + headerFields: &request.headerFields, + contentType: "application/json; charset=utf-8" + ) + } + return (request, body) + }, + deserializer: { response, responseBody in + switch response.status.code { + case 200: + return .ok(.init()) + case 404: + return .notFound(.init()) + default: + return .undocumented( + statusCode: response.status.code, + .init( + headerFields: response.headerFields, + body: responseBody + ) + ) + } + } + ) + } /// Get all registered UI components in the specified namespace. /// /// - Remark: HTTP `GET /ui/components/{namespace}`. diff --git a/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Types.swift b/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Types.swift index 1641a18a3..d736e71e1 100644 --- a/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Types.swift +++ b/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Types.swift @@ -231,6 +231,21 @@ public protocol APIProtocol: Sendable { /// - Remark: HTTP `GET /sitemaps`. /// - Remark: Generated from `#/paths//sitemaps/get(getSitemaps)`. func getSitemaps(_ input: Operations.getSitemaps.Input) async throws -> Operations.getSitemaps.Output + /// Initiates a new item state tracker connection + /// + /// - Remark: HTTP `GET /events/states`. + /// - Remark: Generated from `#/paths//events/states/get(initNewStateTacker)`. + func initNewStateTacker(_ input: Operations.initNewStateTacker.Input) async throws -> Operations.initNewStateTacker.Output + /// Get all events. + /// + /// - Remark: HTTP `GET /events`. + /// - Remark: Generated from `#/paths//events/get(getEvents)`. + func getEvents(_ input: Operations.getEvents.Input) async throws -> Operations.getEvents.Output + /// Changes the list of items a SSE connection will receive state updates to. + /// + /// - Remark: HTTP `POST /events/states/{connectionId}`. + /// - Remark: Generated from `#/paths//events/states/{connectionId}/post(updateItemListForStateUpdates)`. + func updateItemListForStateUpdates(_ input: Operations.updateItemListForStateUpdates.Input) async throws -> Operations.updateItemListForStateUpdates.Output /// Get all registered UI components in the specified namespace. /// /// - Remark: HTTP `GET /ui/components/{namespace}`. @@ -767,6 +782,39 @@ extension APIProtocol { public func getSitemaps(headers: Operations.getSitemaps.Input.Headers = .init()) async throws -> Operations.getSitemaps.Output { try await getSitemaps(Operations.getSitemaps.Input(headers: headers)) } + /// Initiates a new item state tracker connection + /// + /// - Remark: HTTP `GET /events/states`. + /// - Remark: Generated from `#/paths//events/states/get(initNewStateTacker)`. + public func initNewStateTacker(headers: Operations.initNewStateTacker.Input.Headers = .init()) async throws -> Operations.initNewStateTacker.Output { + try await initNewStateTacker(Operations.initNewStateTacker.Input(headers: headers)) + } + /// Get all events. + /// + /// - Remark: HTTP `GET /events`. + /// - Remark: Generated from `#/paths//events/get(getEvents)`. + public func getEvents( + query: Operations.getEvents.Input.Query = .init(), + headers: Operations.getEvents.Input.Headers = .init() + ) async throws -> Operations.getEvents.Output { + try await getEvents(Operations.getEvents.Input( + query: query, + headers: headers + )) + } + /// Changes the list of items a SSE connection will receive state updates to. + /// + /// - Remark: HTTP `POST /events/states/{connectionId}`. + /// - Remark: Generated from `#/paths//events/states/{connectionId}/post(updateItemListForStateUpdates)`. + public func updateItemListForStateUpdates( + path: Operations.updateItemListForStateUpdates.Input.Path, + body: Operations.updateItemListForStateUpdates.Input.Body? = nil + ) async throws -> Operations.updateItemListForStateUpdates.Output { + try await updateItemListForStateUpdates(Operations.updateItemListForStateUpdates.Input( + path: path, + body: body + )) + } /// Get all registered UI components in the specified namespace. /// /// - Remark: HTTP `GET /ui/components/{namespace}`. @@ -11204,6 +11252,397 @@ public enum Operations { } } } + /// Initiates a new item state tracker connection + /// + /// - Remark: HTTP `GET /events/states`. + /// - Remark: Generated from `#/paths//events/states/get(initNewStateTacker)`. + public enum initNewStateTacker { + public static let id: Swift.String = "initNewStateTacker" + public struct Input: Sendable, Hashable { + /// - Remark: Generated from `#/paths/events/states/GET/header`. + public struct Headers: Sendable, Hashable { + public var accept: [OpenAPIRuntime.AcceptHeaderContentType] + /// Creates a new `Headers`. + /// + /// - Parameters: + /// - accept: + public init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + self.accept = accept + } + } + public var headers: Operations.initNewStateTacker.Input.Headers + /// Creates a new `Input`. + /// + /// - Parameters: + /// - headers: + public init(headers: Operations.initNewStateTacker.Input.Headers = .init()) { + self.headers = headers + } + } + @frozen public enum Output: Sendable, Hashable { + public struct Ok: Sendable, Hashable { + /// - Remark: Generated from `#/paths/events/states/GET/responses/200/content`. + @frozen public enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/events/states/GET/responses/200/content/text\/event-stream`. + case text_event_hyphen_stream(OpenAPIRuntime.HTTPBody) + /// The associated value of the enum case if `self` is `.text_event_hyphen_stream`. + /// + /// - Throws: An error if `self` is not `.text_event_hyphen_stream`. + /// - SeeAlso: `.text_event_hyphen_stream`. + public var text_event_hyphen_stream: OpenAPIRuntime.HTTPBody { + get throws { + switch self { + case let .text_event_hyphen_stream(body): + return body + } + } + } + } + /// Received HTTP response body + public var body: Operations.initNewStateTacker.Output.Ok.Body + /// Creates a new `Ok`. + /// + /// - Parameters: + /// - body: Received HTTP response body + public init(body: Operations.initNewStateTacker.Output.Ok.Body) { + self.body = body + } + } + /// OK + /// + /// - Remark: Generated from `#/paths//events/states/get(initNewStateTacker)/responses/200`. + /// + /// HTTP response code: `200 ok`. + case ok(Operations.initNewStateTacker.Output.Ok) + /// The associated value of the enum case if `self` is `.ok`. + /// + /// - Throws: An error if `self` is not `.ok`. + /// - SeeAlso: `.ok`. + public var ok: Operations.initNewStateTacker.Output.Ok { + get throws { + switch self { + case let .ok(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "ok", + response: self + ) + } + } + } + /// Undocumented response. + /// + /// A response with a code that is not documented in the OpenAPI document. + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + @frozen public enum AcceptableContentType: AcceptableProtocol { + case text_event_hyphen_stream + case other(Swift.String) + public init?(rawValue: Swift.String) { + switch rawValue.lowercased() { + case "text/event-stream": + self = .text_event_hyphen_stream + default: + self = .other(rawValue) + } + } + public var rawValue: Swift.String { + switch self { + case let .other(string): + return string + case .text_event_hyphen_stream: + return "text/event-stream" + } + } + public static var allCases: [Self] { + [ + .text_event_hyphen_stream + ] + } + } + } + /// Get all events. + /// + /// - Remark: HTTP `GET /events`. + /// - Remark: Generated from `#/paths//events/get(getEvents)`. + public enum getEvents { + public static let id: Swift.String = "getEvents" + public struct Input: Sendable, Hashable { + /// - Remark: Generated from `#/paths/events/GET/query`. + public struct Query: Sendable, Hashable { + /// topics + /// + /// - Remark: Generated from `#/paths/events/GET/query/topics`. + public var topics: Swift.String? + /// Creates a new `Query`. + /// + /// - Parameters: + /// - topics: topics + public init(topics: Swift.String? = nil) { + self.topics = topics + } + } + public var query: Operations.getEvents.Input.Query + /// - Remark: Generated from `#/paths/events/GET/header`. + public struct Headers: Sendable, Hashable { + public var accept: [OpenAPIRuntime.AcceptHeaderContentType] + /// Creates a new `Headers`. + /// + /// - Parameters: + /// - accept: + public init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + self.accept = accept + } + } + public var headers: Operations.getEvents.Input.Headers + /// Creates a new `Input`. + /// + /// - Parameters: + /// - query: + /// - headers: + public init( + query: Operations.getEvents.Input.Query = .init(), + headers: Operations.getEvents.Input.Headers = .init() + ) { + self.query = query + self.headers = headers + } + } + @frozen public enum Output: Sendable, Hashable { + public struct Ok: Sendable, Hashable { + /// - Remark: Generated from `#/paths/events/GET/responses/200/content`. + @frozen public enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/events/GET/responses/200/content/text\/event-stream`. + case text_event_hyphen_stream(OpenAPIRuntime.HTTPBody) + /// The associated value of the enum case if `self` is `.text_event_hyphen_stream`. + /// + /// - Throws: An error if `self` is not `.text_event_hyphen_stream`. + /// - SeeAlso: `.text_event_hyphen_stream`. + public var text_event_hyphen_stream: OpenAPIRuntime.HTTPBody { + get throws { + switch self { + case let .text_event_hyphen_stream(body): + return body + } + } + } + } + /// Received HTTP response body + public var body: Operations.getEvents.Output.Ok.Body + /// Creates a new `Ok`. + /// + /// - Parameters: + /// - body: Received HTTP response body + public init(body: Operations.getEvents.Output.Ok.Body) { + self.body = body + } + } + /// OK + /// + /// - Remark: Generated from `#/paths//events/get(getEvents)/responses/200`. + /// + /// HTTP response code: `200 ok`. + case ok(Operations.getEvents.Output.Ok) + /// The associated value of the enum case if `self` is `.ok`. + /// + /// - Throws: An error if `self` is not `.ok`. + /// - SeeAlso: `.ok`. + public var ok: Operations.getEvents.Output.Ok { + get throws { + switch self { + case let .ok(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "ok", + response: self + ) + } + } + } + public struct BadRequest: Sendable, Hashable { + /// Creates a new `BadRequest`. + public init() {} + } + /// Topic is empty or contains invalid characters + /// + /// - Remark: Generated from `#/paths//events/get(getEvents)/responses/400`. + /// + /// HTTP response code: `400 badRequest`. + case badRequest(Operations.getEvents.Output.BadRequest) + /// Topic is empty or contains invalid characters + /// + /// - Remark: Generated from `#/paths//events/get(getEvents)/responses/400`. + /// + /// HTTP response code: `400 badRequest`. + public static var badRequest: Self { + .badRequest(.init()) + } + /// The associated value of the enum case if `self` is `.badRequest`. + /// + /// - Throws: An error if `self` is not `.badRequest`. + /// - SeeAlso: `.badRequest`. + public var badRequest: Operations.getEvents.Output.BadRequest { + get throws { + switch self { + case let .badRequest(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "badRequest", + response: self + ) + } + } + } + /// Undocumented response. + /// + /// A response with a code that is not documented in the OpenAPI document. + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + @frozen public enum AcceptableContentType: AcceptableProtocol { + case text_event_hyphen_stream + case other(Swift.String) + public init?(rawValue: Swift.String) { + switch rawValue.lowercased() { + case "text/event-stream": + self = .text_event_hyphen_stream + default: + self = .other(rawValue) + } + } + public var rawValue: Swift.String { + switch self { + case let .other(string): + return string + case .text_event_hyphen_stream: + return "text/event-stream" + } + } + public static var allCases: [Self] { + [ + .text_event_hyphen_stream + ] + } + } + } + /// Changes the list of items a SSE connection will receive state updates to. + /// + /// - Remark: HTTP `POST /events/states/{connectionId}`. + /// - Remark: Generated from `#/paths//events/states/{connectionId}/post(updateItemListForStateUpdates)`. + public enum updateItemListForStateUpdates { + public static let id: Swift.String = "updateItemListForStateUpdates" + public struct Input: Sendable, Hashable { + /// - Remark: Generated from `#/paths/events/states/{connectionId}/POST/path`. + public struct Path: Sendable, Hashable { + /// - Remark: Generated from `#/paths/events/states/{connectionId}/POST/path/connectionId`. + public var connectionId: Swift.String + /// Creates a new `Path`. + /// + /// - Parameters: + /// - connectionId: + public init(connectionId: Swift.String) { + self.connectionId = connectionId + } + } + public var path: Operations.updateItemListForStateUpdates.Input.Path + /// - Remark: Generated from `#/paths/events/states/{connectionId}/POST/requestBody`. + @frozen public enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/events/states/{connectionId}/POST/requestBody/content/application\/json`. + case json([Swift.String]) + } + public var body: Operations.updateItemListForStateUpdates.Input.Body? + /// Creates a new `Input`. + /// + /// - Parameters: + /// - path: + /// - body: + public init( + path: Operations.updateItemListForStateUpdates.Input.Path, + body: Operations.updateItemListForStateUpdates.Input.Body? = nil + ) { + self.path = path + self.body = body + } + } + @frozen public enum Output: Sendable, Hashable { + public struct Ok: Sendable, Hashable { + /// Creates a new `Ok`. + public init() {} + } + /// OK + /// + /// - Remark: Generated from `#/paths//events/states/{connectionId}/post(updateItemListForStateUpdates)/responses/200`. + /// + /// HTTP response code: `200 ok`. + case ok(Operations.updateItemListForStateUpdates.Output.Ok) + /// OK + /// + /// - Remark: Generated from `#/paths//events/states/{connectionId}/post(updateItemListForStateUpdates)/responses/200`. + /// + /// HTTP response code: `200 ok`. + public static var ok: Self { + .ok(.init()) + } + /// The associated value of the enum case if `self` is `.ok`. + /// + /// - Throws: An error if `self` is not `.ok`. + /// - SeeAlso: `.ok`. + public var ok: Operations.updateItemListForStateUpdates.Output.Ok { + get throws { + switch self { + case let .ok(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "ok", + response: self + ) + } + } + } + public struct NotFound: Sendable, Hashable { + /// Creates a new `NotFound`. + public init() {} + } + /// Unknown connectionId + /// + /// - Remark: Generated from `#/paths//events/states/{connectionId}/post(updateItemListForStateUpdates)/responses/404`. + /// + /// HTTP response code: `404 notFound`. + case notFound(Operations.updateItemListForStateUpdates.Output.NotFound) + /// Unknown connectionId + /// + /// - Remark: Generated from `#/paths//events/states/{connectionId}/post(updateItemListForStateUpdates)/responses/404`. + /// + /// HTTP response code: `404 notFound`. + public static var notFound: Self { + .notFound(.init()) + } + /// The associated value of the enum case if `self` is `.notFound`. + /// + /// - Throws: An error if `self` is not `.notFound`. + /// - SeeAlso: `.notFound`. + public var notFound: Operations.updateItemListForStateUpdates.Output.NotFound { + get throws { + switch self { + case let .notFound(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "notFound", + response: self + ) + } + } + } + /// Undocumented response. + /// + /// A response with a code that is not documented in the OpenAPI document. + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + } /// Get all registered UI components in the specified namespace. /// /// - Remark: HTTP `GET /ui/components/{namespace}`. diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABEvent.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABEvent.swift new file mode 100644 index 000000000..d570b4a0d --- /dev/null +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABEvent.swift @@ -0,0 +1,39 @@ +// Copyright (c) 2010-2025 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 Foundation + +public struct OpenHABEvent: Decodable, Hashable, Sendable { + enum EventType: String, Decodable { + case itemStateEvent = "ItemStateEvent" + case itemStateUpdatedEvent = "ItemStateUpdatedEvent" + case itemStateChangedEvent = "ItemStateChangedEvent" + } + + struct Payload: Decodable, Equatable, Hashable, Sendable { + private enum CodingKeys: String, CodingKey { + case type, value, oldType, oldValue, lastStateUpdate, lastStateChange + } + + let type: String + let value: String + + // Optional for updated/changed events + let oldType: String? + let oldValue: String? + let lastStateUpdate: Date? + let lastStateChange: Date? + } + + let topic: String + let payload: Payload + let type: EventType +} diff --git a/OpenHABCore/Sources/OpenHABCore/Util/ItemEventStream.swift b/OpenHABCore/Sources/OpenHABCore/Util/ItemEventStream.swift new file mode 100644 index 000000000..d0fb3086f --- /dev/null +++ b/OpenHABCore/Sources/OpenHABCore/Util/ItemEventStream.swift @@ -0,0 +1,227 @@ +// Copyright (c) 2010-2025 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 Combine +import Foundation +import OpenAPIRuntime +import OpenAPIURLSession +import OSLog + +/** + Example usage: + + ``` + ItemEventStream.startMonitoringNetwork() + Task { + await ItemEventStream.trackItems(["PanelDansOffice", "F1_Kitchen"]) + } + print("Starting SSE") + streamTask = Task { [weak self] in + guard let self else { return } + for await msg in await ItemEventStream.stream() { + await MainActor.run { self.handle(msg) } + } + } + ``` + */ + +public enum StreamOutput: Sendable { + case connected + case disconnected((any Error)?) // `nil` when closed intentionally + case event(Event) +} + +public enum StateStreamMessage: Sendable, Equatable { + case ready(uuid: String, lastEventID: String?) + case state(item: String, state: String) + case alive(interval: Int) + case unknown(raw: String) +} + +public actor EventStream { + private let logger = Logger( + subsystem: Bundle.main.bundleIdentifier ?? "EventStream", + category: "SSE" + ) + private var trackedItems: Set = [] + private var continuations + = [UUID: AsyncStream>.Continuation]() + private var listenTask: Task? + private var currentConfig: ConnectionConfiguration? + private var sessionUUID: String? + private var service: OpenAPIService? + + public func stream() -> AsyncStream> { + AsyncStream { continuation in + let id = UUID() + continuations[id] = continuation + continuation.onTermination = { [weak self] _ in + guard let self else { return } + Task { [id] in + await self.cleanupContinuation(id) + } + } + } + } + + public func trackItems(_ items: [String]) async { + trackedItems = Set(items) + await sendTrackedItemsIfPossible() + } + + private func cleanupContinuation(_ id: UUID) { + continuations[id]?.finish() + continuations.removeValue(forKey: id) + } + + // NetworkManager callback + private func updateConnection(_ info: ConnectionInfo?) { + let newConfig = info?.configuration + + guard currentConfig != newConfig else { return } + + currentConfig = info?.configuration + + logger.info("Network changed – restarting SSE connection") + + listenTask?.cancel() + listenTask = nil + + guard let cfg = currentConfig else { + broadcast(.disconnected(nil)) + return + } + listenTask = Task { await listen(using: cfg) } + } + + /// Try and send the item right away, if the connection is not up, they will be sent when its ready + private func sendTrackedItemsIfPossible() async { + guard + !trackedItems.isEmpty, + let uuid = sessionUUID, + let service + else { return } + do { + try await service.updateItemListForStateUpdates( + connectionId: uuid, + items: Array(trackedItems) + ) + } catch { + logger.error("Failed to update item list: \(error.localizedDescription)") + } + } + + private func listen(using config: ConnectionConfiguration) async { + var backoff: TimeInterval = 1 + let maxBackoff: TimeInterval = 30 + + while !Task.isCancelled { + do { + let service = try OpenAPIService(connectionConfiguration: config) + let response = try await service.initNewStateTacker() + let eventStream = try response.ok.body.text_event_hyphen_stream.asDecodedServerSentEvents() + self.service = service + broadcast(.connected) + + for try await sse in eventStream { + logger.info("SSE event: \(sse.event ?? "empty")") + for rawMessage in parse(sse) { + if let message = rawMessage as? Event { + broadcast(.event(message)) + } + } + } + // if we get here we have lost the connection + throw CancellationError() + } catch is CancellationError { + // normal cleanup, just return + return + } catch { + broadcast(.disconnected(error)) + // give a little time before we try connecting again + logger.error("SSE error: \(error.localizedDescription, privacy: .public) – retrying in \(backoff, privacy: .public)s") + try? await Task.sleep(for: .seconds(backoff)) + backoff = min(backoff * 2, maxBackoff) + } + } + } + + private func broadcast(_ msg: StreamOutput) { + // the "ready" message carries the UUID string we need to store + if case let .event(raw as StateStreamMessage) = msg, + case let .ready(uuid, _) = raw { + sessionUUID = uuid + // Re‑send the item subscription when we have a fresh UUID. + Task { await self.sendTrackedItemsIfPossible() } + } + continuations.values.forEach { $0.yield(msg) } + } + + private func parse(_ sse: ServerSentEvent) -> [StateStreamMessage] { + switch sse.event ?? "" { + case "ready": + if let uuid = sse.data { + return [.ready(uuid: uuid, lastEventID: sse.id)] + } + case "alive": + if let data = sse.data!.data(using: String.Encoding.utf8), + let obj = try? JSONDecoder().decode( + Alive.self, from: data + ) { + return [.alive(interval: obj.interval)] + } + default: + // sometime message omit the `event:` field and send only `data:` with the JSON. + if let data = sse.data?.data(using: String.Encoding.utf8), + let changes = try? JSONDecoder().decode(ItemStateChanges.self, from: data) { + return changes.wrapped.map { key, value in + .state(item: key, state: value.state) + } + } + } + return [.unknown(raw: sse.data ?? "nil")] + } + + // Alive and Item State Chnage message structures + private struct Alive: Decodable { let type: String; let interval: Int } + // Multiple items can come in a single message which makes this a little more complicated + private struct ItemStateChanges: Decodable { + struct Value: Decodable { let state: String } + let wrapped: [String: Value] + init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + wrapped = try container.decode([String: Value].self) + } + + var first: (String, Value)? { wrapped.first } + } +} + +/// Helper so callers can just use `ItemEventStream` and not EventStream +public typealias ItemEventStream = EventStream + +public extension ItemEventStream { + static let shared = ItemEventStream() + /// helper function so callers can write something like: + /// `await ItemEventStream.trackItems(["KitchenLight"])`. + nonisolated static func trackItems(_ items: [String]) async { + await shared.trackItems(items) + } + + nonisolated static func startMonitoringNetwork() { + Task.detached { [weak hub = Self.shared] in + guard let hub else { return } + for await conn in NetworkTracker.shared.$activeConnection.values { + await hub.updateConnection(conn) + } + } + } +} diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift index ec821c849..5cfa267bf 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift @@ -72,8 +72,8 @@ public actor OpenAPIService { config.timeoutIntervalForRequest = 35.0 config.timeoutIntervalForResource = config.timeoutIntervalForRequest + 25 case .shortTerm: - config.timeoutIntervalForRequest = 2.0 - config.timeoutIntervalForResource = 2.0 + config.timeoutIntervalForRequest = 10.0 + config.timeoutIntervalForResource = 10.0 } let session = URLSession(configuration: config, delegate: delegate, delegateQueue: nil) let url = URL(string: connectionConfiguration.url) ?? URL(staticString: "about:blank") @@ -84,6 +84,7 @@ public actor OpenAPIService { client = Client( serverURL: serverURL, + configuration: .init(dateTranscoder: ISO8601DateTranscoder(options: [.withInternetDateTime, .withFractionalSeconds, .withTimeZone, .withColonSeparatorInTimeZone])), transport: URLSessionTransport(configuration: .init(session: session)), middlewares: [ LoggingMiddleware(), @@ -113,23 +114,6 @@ public actor OpenAPIService { // config.timeoutIntervalForResource = config.timeoutIntervalForRequest + 25 return config } - - // timeoutIntervalForRequest/timeoutIntervalForResource need to be passed through URLSessionConfiguration when URLSession is created. Therefore create a new APIClient to change values. - public func updateForLongPolling(_ newlongPolling: Bool) async { - guard newlongPolling != longPolling else { return } - longPolling = newlongPolling - - let config = prepareURLSessionConfiguration(longPolling: longPolling) - let session = URLSession(configuration: config) - client = Client( - serverURL: url!.appending(path: "/rest"), - transport: URLSessionTransport(configuration: .init(session: session)), - middlewares: [ - LoggingMiddleware(), - AuthorisationMiddleware(configuration: connectionConfiguration) - ] - ) - } } public extension OpenAPIService { @@ -204,6 +188,7 @@ public extension OpenAPIService { } public extension OpenAPIService { + // Returns subscription id or nil func openHABcreateSubscription() async throws -> String? { logger.info("Creating subscription") let result = try await client.createSitemapEventSubscription() @@ -220,6 +205,13 @@ public extension OpenAPIService { .asDecodedServerSentEventsWithJSONData(of: Components.Schemas.SitemapWidgetEvent.self) return decodedSequence.compactMap { OpenHABSitemapWidgetEvent($0.data) } } + + func openHABEvents(topics: String? = nil) async throws -> AsyncThrowingMapSequence>, ServerSentEventWithJSONData> { + let query: Operations.getEvents.Input.Query = topics == nil ? .init() : Operations.getEvents.Input.Query(topics: topics) + return try await client.getEvents(query: query) + .ok.body.text_event_hyphen_stream + .asDecodedServerSentEventsWithJSONData(of: OpenHABEvent.self) + } } public extension OpenAPIService { @@ -269,22 +261,6 @@ public extension OpenAPIService { .ok.body.json return OpenHABSitemap(result) } - - // Unused currently - // To be used when migrating to SSE - func pollDataForSitemap(sitemapname: String, longPolling: Bool, subscriptionId: String? = nil) async throws -> OpenHABSitemap? { - var headers = Operations.pollDataForSitemap.Input.Headers() - if longPolling { - logger.info("Long-polling, setting X-Atmosphere-Transport") - headers.X_hyphen_Atmosphere_hyphen_Transport = "long-polling" - } else { - headers.X_hyphen_Atmosphere_hyphen_Transport = nil - } - let query = Operations.pollDataForSitemap.Input.Query(subscriptionid: subscriptionId) - let path = Operations.pollDataForSitemap.Input.Path(sitemapname: sitemapname) - await updateForLongPolling(longPolling) - return try await pollDataForSitemap(path: path, query: query, headers: headers) - } } // Array of items @@ -296,6 +272,19 @@ public extension OpenAPIService { } } +public extension OpenAPIService { + func initNewStateTacker() async throws -> Operations.initNewStateTacker.Output { + try await client.initNewStateTacker() + } + + func updateItemListForStateUpdates(connectionId: String, items: [String]) async throws { + let path = Operations.updateItemListForStateUpdates.Input.Path(connectionId: connectionId) + let body = Operations.updateItemListForStateUpdates.Input.Body.json(.init(items)) + let response = try await client.updateItemListForStateUpdates(path: path, body: body) + _ = try response.ok + } +} + // MARK: State changes and commands public extension OpenAPIService { diff --git a/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift index 421a15ee1..cfc3b3d53 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift @@ -99,6 +99,7 @@ public struct HomePreferences: Codable, Sendable, Equatable { public var remoteConnectionConfig: ConnectionConfiguration = .remoteDefault public var sitemapForWatchLabel = "watch" public var homeName = "Home" + public var sseCommandItem = "" fileprivate init(id: UUID) { self.id = id diff --git a/OpenHABCore/Sources/OpenHABCore/openapi/openapi-generator-config.yml b/OpenHABCore/Sources/OpenHABCore/openapi/openapi-generator-config.yml index 330297b24..94a00139e 100644 --- a/OpenHABCore/Sources/OpenHABCore/openapi/openapi-generator-config.yml +++ b/OpenHABCore/Sources/OpenHABCore/openapi/openapi-generator-config.yml @@ -10,3 +10,4 @@ filter: - root - systeminfo - rules + - events diff --git a/OpenHABCore/Sources/OpenHABCore/openapi/openapi.json b/OpenHABCore/Sources/OpenHABCore/openapi/openapi.json index 7c3ab44c7..c3229a640 100644 --- a/OpenHABCore/Sources/OpenHABCore/openapi/openapi.json +++ b/OpenHABCore/Sources/OpenHABCore/openapi/openapi.json @@ -5866,7 +5866,10 @@ "operationId": "initNewStateTacker", "responses": { "200": { - "description": "OK" + "description": "OK", + "content": { + "text/event-stream": {} + } } } } @@ -5890,7 +5893,10 @@ ], "responses": { "200": { - "description": "OK" + "description": "OK", + "content": { + "text/event-stream": {} + } }, "400": { "description": "Topic is empty or contains invalid characters" @@ -5918,7 +5924,7 @@ "requestBody": { "description": "items", "content": { - "*/*": { + "application/json": { "schema": { "uniqueItems": true, "type": "array", diff --git a/README.md b/README.md index 1e4a8ae16..05dca72c7 100644 --- a/README.md +++ b/README.md @@ -36,30 +36,36 @@ This app only receives security updates and minor fixes and is not intended for Beta releases are available on [TestFlight](https://testflight.apple.com/join/563WBakc). ## Features + * Control your openHAB server directly and through a [openHAB Cloud instance](https://github.com/openhab/openhab-cloud) * [Enhanced push notification](#push-notifications) from openHAB Cloud and the openHAB cloud binding * [Apple Watch](#apple-watch-configuration) companion app * [Widgets](#widgets) (coming soon!) ## App Configuration +

Logo Logo

### Connection Settings + The app will try and connect using the Local URL as the primary connection, and if that fails or is not reachable, falls back to the local URL. ### Demo Mode + This sets up the app to use the openHAB demo server and can be used to experience the app without needing to install openHAB. #### Local URL + This is the primary connection to your openHAB instance, a fully qualified URL with a IP or host is required. Example: `https://openhab.local:8443` #### Remote URL + This is the secondary connection to your openHAB instance, a fully qualified URL with a IP or host is required. If using the openHAB cloud service, leave this as the default setting of `https://myopenhab.org`. When set to the public cloud, the app will also register for push notifications (as long as credentials are correct) @@ -68,12 +74,14 @@ Example: `https://myopenhab.org` ### Username / Password + This will be sent if the local or remote server challenges for authentication, or if "Always Send Credentials" is checked on. If using the openHAB cloud, these should be set to those login credentials. ### Application Settings ### Certificates + Allows the installation of p12 formatted certificates for use in client side authentication setups. To install a client certificate, rename the certificate with the extension `.ohp12`, then send it to your iOS device (airdrop, icloud, dropbox, etc..), then open/save and select `openHAB` from the "Open In" menu (you may need to select "More..." to see all apps). @@ -84,17 +92,77 @@ If using openssl v3 to generate certificates, make sure to add `-legacy -certpbe See https://github.com/openssl/openssl/issues/19871 for more information on V3 compatibility with Apple products. ### Idle Timeout + Useful for wall or fixed installations, will disable the Idle screen timeout. +### Screensaver + +The app includes a built-in screen saver that can be shown automatically after a period of inactivity. + +Key options (Settings → Screen Saver): + +- **Enable Screen Saver** – turns the feature on/off. +- **Appearance** – decide whether to show the time, date and/or seconds, choose 12- or 24-hour clock, and pick a custom font. +- **Idle Interval** – number of seconds of user inactivity before the screen saver appears (5 – 600 s). +- **Movement Interval** – how often the clock moves to a new random position to avoid burn-in (2 – 60 s). +- **Font Size** – independent sliders for clock and date size (relative to the screen). +- **Animation** – fade-in/out duration when the screen saver appears or disappears. +- **Brightness** – optional automatic dimming; set dim level, whether the previous brightness should be restored, and the brightness level to restore to. +- **Test Screen Saver** – instantly preview the current configuration. + +You can also control the screen saver from the [command item](#command-item) using the `device:screensaver:` syntax. See [Action Syntax](#action-syntax) for more information. + +### Command Item + +The iOS app can react to updates of a dedicated String Item so you can remotely control the device or navigate the UI from your openHAB server. + +Whenever the state of the Item changes the app interprets the new value as an [Action String](#action-syntax) and executes it immediately – exactly the same mechanism that is used for push-notification actions. + +#### Setting up the Command Item + +1. Create a String Item in openHAB, for example: + +```items +String Tablet_Command "iOS TabletCommand" +``` + + Any existing String/Text Item will also work – just remember its name. + +2. In the iOS app open **Settings → Application Settings → Command Item** and pick the Item you just created. + The list shown in the picker is populated from your openHAB instance. + +3. Send actions by updating the Item. + +Example rules: +```rules +Tablet_Command.postUpdate("device:screensaver:activate") // start the screensaver +Tablet_Command.postUpdate("device:brightness:0.3") // set brightness to 30 % +Tablet_Command.postUpdate("device:tts:Hello there!:en-US:Samantha") // speak "Hello there!" using the Samantha voice for the en-US language +Tablet_Command.postUpdate("ui:/basicui/app?w=0000&sitemap=main") // open a sitemap page +Tablet_Command.postUpdate("ui:navigate:/page/my_floorplan_page") // navigate to a page in the Main UI +Tablet_Command.postUpdate("command:KitchenLights:ON") // send ON to KitchenLights +``` + +See the [Action Syntax](#action-syntax) appendix for more information on the syntax used to send actions to the app. + +Notes: +* The app receives Item updates via Server-Sent Events, and will only receive updates if the app is running in the foreground (like kiosk applications) +* If the app is unable to connect to the server and a SSE connection is not established, it will continue to try and reconnect, but messages sent to the app during this time will be lost. + +--- + ### Crash Reporting + Sends crash reports to Google / Firebase. ### Main UI Settings #### WebRTC + Allows audio and video communications in the Main UI for views and widgets that require it. #### Default Path + Allows the user to enter a path to act as the starting point when the Main UI is loaded. Clicking the "+" button will prompt to enter the current path the of Main UI view. @@ -150,6 +218,10 @@ Push Notifications on iOS support: - Collapsible / updated notifications - Removing notifications +See [Cloud Push Notifications](https://www.openhab.org/addons/integrations/openhabcloud/#cloud-notification-actions) for more information on sending push notifications from rules. + +Also see [Action Syntax](#action-syntax) for more information on actions that can be included in push notifications. + ## Widgets Coming soon ! @@ -174,6 +246,251 @@ And also please support with the localization of openhab-ios: [![Crowdin](https://badges.crowdin.net/openhab-ios/localized.svg)](https://crowdin.com/project/openhab-ios) +## Appendix + +### Action Syntax + +The action syntax is a string containing the action type and the action payload separated by a colon. + +There are several types of actions available: + +- `command`: Sends a command to an Item by using the following syntax: `command:$itemName:$commandString` where `$itemName` is the name of the Item and `$commandString` is the command to be sent. +- `ui`: Controls the UI in two possible ways: + - `ui:$path` where `$path` is either `/basicui/app?...` for navigating sitemaps (using the native renderer) or `/some/absolute/path` for navigating (using the web view). + - `ui:$commandItemSyntax` where `$commandItemSyntax` is the same syntax as used for the [UI Command Item]({{base}}/mainui/about.html#ui-command-item). +- `http:` or `https:`: Opens the fully qualified URL in an embedded browser on the device. +- `rule` (currently only on iOS): Runs a rule by using the following syntax: `rule:$ruleId:$prop1Key=$prop1Value,$prop2Key=$prop2Value,...` where `$ruleId` is the id of the rule, and optional properties to send to the rule are in the format `name=value` separated by commas. Most rules can omit the properties. +- `app` (currently only on iOS): Launches a native app when possible using the following syntax: `app:android=$appId,ios=$appId:$path` where `$appId` on Android is a qualified app id like `com.acme.app` (see [partial list of Android ids](https://github.com/petarov/google-android-app-ids)), and on iOS is the registered URL scheme along with an optional `$path` like `acme://foo` (see [partial list of iOS ids](https://github.com/bhagyas/app-urls)). Either `android` or `ios` can be omitted if that platform is not used. +- `device`: Performs actions on the iOS device itself using the syntax `device:$operation[:$value]`. Supported operations include: + - `screensaver:activate` – activate the screensaver + - `screensaver:disable` – disables the screensaver from running (call activate to re-enable) + - `screensaver:wake` – wakes the screensaver if running + - `idletimer:enable` – allow the device to auto-lock/dim again + - `idletimer:disable` – keep the screen awake (useful for wall-mounted tablets) + - `brightness:<0-1>` – set the display brightness, e.g. `brightness:0.25` + - `tts:$text[:$language[:$voice]]` – text-to-speech; speaks `$text`. + - `$language` (optional) – BCP-47 language tag such as `en-US`. + - `$voice` (optional) – exact voice name reported by iOS (`AVSpeechSynthesisVoice`). + - See [TTS Example Voices](#tts-example-voices) for a list of available voices and languages. + +Examples: + +- `command:KitchenLights:ON` +- `command:KitchenBlinds:50` +- `ui:/basicui/app?w=0000&sitemap=main` (use Basic UI to get sitemap URL locations) +- `ui:/some/absolute/path`: Navigates to the absolut path `/some/absolute/path`. +- `ui:navigate:/page/my_floorplan_page`: Navigates Main UI to the page with the ID `my_floorplan_page`. +- `ui:popup:oh-clock-card`: Opens a popup with `oh-clock-card`. +- `https://openhab.org`: Opens an embedded browser to the site `https://openhab.org` +- `rule:02ffc3a297:prop1=foo`: Runs the rule with an id of `02ffc3a297` passing in an optional parameter named `prop1` with a value of `foo` +- `app:android=com.sonos.acr2,ios=sonos-2://`: Opens the Sonos app depending on the device type (Android or iOS) +- `device:screensaver:activate`: Activates the device screen saver +- `device:brightness:0.3`: Sets the screen brightness to 30 % +- `device:tts:Hello there!:en-US:Samantha`: Speaks “Hello there!” using the Samantha voice for the en-US language +- `device:tts:Hello there!`: Speaks “Hello there!” using the default system voice and language + +### TTS Example Voices + +This is an example (partial) list of voices available on iOS. +See [AVSpeechSynthesisVoice](https://developer.apple.com/documentation/avfaudio/avspeechsynthesisvoice) for more information. + +Additional voices, including custom and novelty voices can be downloaded in the iOS system settings. + +| Voice Name | Language | +|------------|----------| +| Majed | ar-001 | +| Daria | bg-BG | +| Montse | ca-ES | +| Zuzana | cs-CZ | +| Sara | da-DK | +| Sandy | de-DE | +| Helena | de-DE | +| Shelley | de-DE | +| Grandma | de-DE | +| Grandpa | de-DE | +| Eddy | de-DE | +| Reed | de-DE | +| Martin | de-DE | +| Anna | de-DE | +| Rocko | de-DE | +| Flo | de-DE | +| Melina | el-GR | +| Gordon | en-AU | +| Karen | en-AU | +| Catherine | en-AU | +| Rocko | en-GB | +| Shelley | en-GB | +| Martha | en-GB | +| Daniel | en-GB | +| Grandma | en-GB | +| Grandpa | en-GB | +| Flo | en-GB | +| Eddy | en-GB | +| Reed | en-GB | +| Sandy | en-GB | +| Arthur | en-GB | +| Moira | en-IE | +| Rishi | en-IN | +| Flo | en-US | +| Bahh | en-US | +| Albert | en-US | +| Fred | en-US | +| Jester | en-US | +| Organ | en-US | +| Cellos | en-US | +| Zarvox | en-US | +| Rocko | en-US | +| Shelley | en-US | +| Superstar | en-US | +| Grandma | en-US | +| Eddy | en-US | +| Bells | en-US | +| Grandpa | en-US | +| Trinoids | en-US | +| Kathy | en-US | +| Reed | en-US | +| Boing | en-US | +| Whisper | en-US | +| Good News | en-US | +| Nicky | en-US | +| Wobble | en-US | +| Bad News | en-US | +| Aaron | en-US | +| Bubbles | en-US | +| Samantha | en-US | +| Sandy | en-US | +| Junior | en-US | +| Ralph | en-US | +| Tessa | en-ZA | +| Shelley | es-ES | +| Grandma | es-ES | +| Rocko | es-ES | +| Grandpa | es-ES | +| Sandy | es-ES | +| Flo | es-ES | +| Mónica | es-ES | +| Eddy | es-ES | +| Reed | es-ES | +| Rocko | es-MX | +| Paulina | es-MX | +| Flo | es-MX | +| Sandy | es-MX | +| Eddy | es-MX | +| Shelley | es-MX | +| Grandma | es-MX | +| Reed | es-MX | +| Grandpa | es-MX | +| Shelley | fi-FI | +| Grandma | fi-FI | +| Grandpa | fi-FI | +| Sandy | fi-FI | +| Satu | fi-FI | +| Eddy | fi-FI | +| Rocko | fi-FI | +| Reed | fi-FI | +| Flo | fi-FI | +| Shelley | fr-CA | +| Grandma | fr-CA | +| Grandpa | fr-CA | +| Rocko | fr-CA | +| Eddy | fr-CA | +| Reed | fr-CA | +| Amélie | fr-CA | +| Flo | fr-CA | +| Sandy | fr-CA | +| Grandma | fr-FR | +| Flo | fr-FR | +| Rocko | fr-FR | +| Grandpa | fr-FR | +| Sandy | fr-FR | +| Eddy | fr-FR | +| Daniel | fr-FR | +| Thomas | fr-FR | +| Jacques | fr-FR | +| Marie | fr-FR | +| Shelley | fr-FR | +| Carmit | he-IL | +| Lekha | hi-IN | +| Lana | hr-HR | +| Tünde | hu-HU | +| Damayanti | id-ID | +| Eddy | it-IT | +| Sandy | it-IT | +| Reed | it-IT | +| Shelley | it-IT | +| Grandma | it-IT | +| Grandpa | it-IT | +| Flo | it-IT | +| Rocko | it-IT | +| Alice | it-IT | +| Eddy | ja-JP | +| Reed | ja-JP | +| Hattori | ja-JP | +| Shelley | ja-JP | +| Kyoko | ja-JP | +| Grandma | ja-JP | +| Rocko | ja-JP | +| Grandpa | ja-JP | +| O-ren | ja-JP | +| Sandy | ja-JP | +| Flo | ja-JP | +| Rocko | ko-KR | +| Grandma | ko-KR | +| Grandpa | ko-KR | +| Eddy | ko-KR | +| Sandy | ko-KR | +| Yuna | ko-KR | +| Reed | ko-KR | +| Flo | ko-KR | +| Shelley | ko-KR | +| Amira | ms-MY | +| Nora | nb-NO | +| Ellen | nl-BE | +| Xander | nl-NL | +| Zosia | pl-PL | +| Reed | pt-BR | +| Luciana | pt-BR | +| Shelley | pt-BR | +| Grandma | pt-BR | +| Grandpa | pt-BR | +| Rocko | pt-BR | +| Flo | pt-BR | +| Sandy | pt-BR | +| Eddy | pt-BR | +| Joana | pt-PT | +| Ioana | ro-RO | +| Milena | ru-RU | +| Laura | sk-SK | +| Tina | sl-SI | +| Alva | sv-SE | +| Vani | ta-IN | +| Kanya | th-TH | +| Yelda | tr-TR | +| Lesya | uk-UA | +| Linh | vi-VN | +| Eddy | zh-CN | +| Shelley | zh-CN | +| Grandma | zh-CN | +| Reed | zh-CN | +| Grandpa | zh-CN | +| Rocko | zh-CN | +| Yu-shu | zh-CN | +| Flo | zh-CN | +| Tingting | zh-CN | +| Li-mu | zh-CN | +| Sandy | zh-CN | +| Sinji | zh-HK | +| Shelley | zh-TW | +| Grandma | zh-TW | +| Grandpa | zh-TW | +| Sandy | zh-TW | +| Flo | zh-TW | +| Eddy | zh-TW | +| Reed | zh-TW | +| Meijia | zh-TW | +| Rocko | zh-TW | + + ## Trademark Disclaimer Product names, logos, brands and other trademarks referred to within the openHAB website are the diff --git a/openHAB.xcodeproj/.project.pbxproj.swp b/openHAB.xcodeproj/.project.pbxproj.swp deleted file mode 100644 index c5d9a576deebaf4568d01f81bbac3c133dc3531b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16384 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 U5*}rwfpk%&G+k=i`u_X$U*mgWk^lez diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index 3e3c83434..816e3f3fe 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -29,6 +29,7 @@ 657144552C1E438700C8A1F3 /* NotificationService.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 6571444E2C1E438700C8A1F3 /* NotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 657144962C30A16700C8A1F3 /* OpenHABCore in Frameworks */ = {isa = PBXBuildFile; productRef = 657144952C30A16700C8A1F3 /* OpenHABCore */; }; 65C2EF492E244C8500A0C19F /* OpenHABNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65C2EF482E244C8500A0C19F /* OpenHABNavigationController.swift */; }; + 65F055442E3D4E41004E98FE /* ItemSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65F055432E3D4E41004E98FE /* ItemSelectionView.swift */; }; 932602EE2382892B00EAD685 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DAC6608B236F6F4200F4501E /* Assets.xcassets */; }; 933D7F0722E7015100621A03 /* OpenHABUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 933D7F0622E7015000621A03 /* OpenHABUITests.swift */; }; 933D7F0F22E7030600621A03 /* SnapshotHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 933D7F0E22E7030600621A03 /* SnapshotHelper.swift */; }; @@ -313,6 +314,7 @@ 657144522C1E438700C8A1F3 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 657144972C30A3E300C8A1F3 /* NotificationService.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = NotificationService.entitlements; sourceTree = ""; }; 65C2EF482E244C8500A0C19F /* OpenHABNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenHABNavigationController.swift; sourceTree = ""; }; + 65F055432E3D4E41004E98FE /* ItemSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemSelectionView.swift; sourceTree = ""; }; 931384B324F259BC00A73AB5 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = ""; }; 931384B424F259BD00A73AB5 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/InfoPlist.strings; sourceTree = ""; }; 931384BB24F2691B00A73AB5 /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/Localizable.strings; sourceTree = ""; }; @@ -816,6 +818,7 @@ DA48001F2D837CD8009CF127 /* SettingsView */ = { isa = PBXGroup; children = ( + 65F055432E3D4E41004E98FE /* ItemSelectionView.swift */, 652B81032E2193B500648510 /* ScreenSaverSettingsView.swift */, DA4800172D837221009CF127 /* AboutSettingsView.swift */, DA48001D2D837905009CF127 /* ApplicationSettingsView.swift */, @@ -1664,6 +1667,7 @@ 653B54C2285E714900298ECD /* OpenHABViewController.swift in Sources */, 65C2EF492E244C8500A0C19F /* OpenHABNavigationController.swift in Sources */, DA2AEB6E2D92BAD800897D80 /* PageLoader.swift in Sources */, + 65F055442E3D4E41004E98FE /* ItemSelectionView.swift in Sources */, DFA16EC118898A8400EDB0BB /* SegmentedUITableViewCell.swift in Sources */, DAF0A28D2C56EF8900A14A6A /* SetpointCell.swift in Sources */, DAEAA89D21E6B06400267EA3 /* ReusableView.swift in Sources */, @@ -2246,7 +2250,6 @@ CURRENT_PROJECT_VERSION = 50; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = PBAPXHRAM9; - DEVELOPMENT_TEAM = D6A95UZXVC; "DEVELOPMENT_TEAM[sdk=watchos*]" = PBAPXHRAM9; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; diff --git a/openHAB/OpenHABRootViewController.swift b/openHAB/OpenHABRootViewController.swift index 1b0cb1244..9e1605380 100644 --- a/openHAB/OpenHABRootViewController.swift +++ b/openHAB/OpenHABRootViewController.swift @@ -9,6 +9,7 @@ // // SPDX-License-Identifier: EPL-2.0 +import AVFoundation import Combine import FirebaseCrashlytics import Foundation @@ -41,6 +42,7 @@ class OpenHABRootViewController: UIViewController { var currentView: OpenHABViewController! var isDemoMode = false var cancellables = Set() + private var streamTask: Task? private var apsRegistrationData: [AnyHashable: Any]? @@ -57,6 +59,7 @@ class OpenHABRootViewController: UIViewController { }() private var activeConnection: ConnectionInfo? + private let synthesizer = AVSpeechSynthesizer() override func viewDidLoad() { super.viewDidLoad() @@ -102,6 +105,7 @@ class OpenHABRootViewController: UIViewController { isDemoMode = Preferences.currentHomePreferences.demomode switchToSavedView() setupTracker() + startSSEListening() } override func viewWillAppear(_ animated: Bool) { @@ -115,6 +119,38 @@ class OpenHABRootViewController: UIViewController { } } + private func startSSEListening() { + ItemEventStream.startMonitoringNetwork() + print("Starting SSE") + streamTask = Task { [weak self] in + guard let self else { return } + for await msg in await ItemEventStream.shared.stream() { + await MainActor.run { self.handleSSEMessage(msg) } + } + } + } + + private func handleSSEMessage(_ msg: StreamOutput) { + switch msg { + case .connected: + print("SSE Connected") + case let .disconnected(err): + print("SSE Disconnected:", err ?? "nil") + case let .event(sm): + switch sm { + case let .state(item, state): + print("SSE Item \(item): \(state)") + handleNotificationInternal(state) + case let .ready(uuid, _): + print("SSE Session UUID:", uuid) + case let .alive(interval): + print("SSE Heartbeat interval:", interval, "s") + case let .unknown(raw): + print("SSE Unknown:", raw) + } + } + } + fileprivate func setupTracker() { let serverInfo = Preferences.$currentHomePreferences @@ -186,6 +222,7 @@ class OpenHABRootViewController: UIViewController { let localConnectionConfig = homeSettings.localConnectionConfig let remoteConnectionConfig = homeSettings.remoteConnectionConfig let demomode = homeSettings.demomode + let sseCommandItem = homeSettings.sseCommandItem Task { if demomode { @@ -202,6 +239,7 @@ class OpenHABRootViewController: UIViewController { localConnectionConfig, remoteConnectionConfig ]) + await ItemEventStream.trackItems(sseCommandItem.isEmpty ? [] : [sseCommandItem]) } } } @@ -437,9 +475,11 @@ class OpenHABRootViewController: UIViewController { case action.hasPrefix("http"): httpCommandAction(action) case action.hasPrefix("app"): - appCommandAction(action) + appCommandAction(cmd) case action.hasPrefix("rule"): - ruleCommandAction(action) + ruleCommandAction(cmd) + case action.hasPrefix("device"): + deviceAction(cmd) default: return } @@ -537,11 +577,9 @@ class OpenHABRootViewController: UIViewController { } private func appCommandAction(_ command: String) { - let content = command.dropFirst(4) // Remove "app:" - let pairs = content.split(separator: ",") + let pairs = command.split(separator: ",") for pair in pairs { let keyValue = pair.split(separator: "=", maxSplits: 1) - guard keyValue.count == 2 else { continue } if keyValue[0] == "ios" { if let url = URL(string: String(keyValue[1])) { logger.error("appCommandAction opening \(String(keyValue[0])) \(String(keyValue[1]))") @@ -552,13 +590,69 @@ class OpenHABRootViewController: UIViewController { } } + private func deviceAction(_ action: String) { + let cmdParts = action.split(separator: ":") + if cmdParts.isEmpty { return } + let command = cmdParts[0].lowercased() + let arg1 = cmdParts.count > 1 ? cmdParts[1].lowercased() : "" + switch command { + case "screensaver": + switch arg1 { + case "activate": + NotificationCenter.default.post(name: .activateScreenSaver, object: nil) + case "disable": + NotificationCenter.default.post(name: .disableScreenSaver, object: nil) + case "wake": + NotificationCenter.default.post(name: .wakeScreenSaver, object: nil) + default: + break + } + case "idletimer": + switch arg1 { + case "enable": + UIApplication.shared.isIdleTimerDisabled = false + case "disable": + UIApplication.shared.isIdleTimerDisabled = true + default: + break + } + case "brightness": + if let value = Double(arg1) { + let target = min(max(value, 0.0), 1.0) + UIScreen.main.brightness = target + } + case "tts": + func normalizeVoiceName(from input: String) -> String { + input + .lowercased() + .components(separatedBy: CharacterSet.alphanumerics.inverted) + .joined() + } + + let utterance = AVSpeechUtterance(string: arg1) + if cmdParts.count > 3 { + print("Filtering voice \(cmdParts[2]) \(cmdParts[3])") + let voice = AVSpeechSynthesisVoice.speechVoices().filter { $0.language.lowercased() == cmdParts[2].lowercased() && normalizeVoiceName(from: $0.name) == normalizeVoiceName(from: String(cmdParts[3])) } + if !voice.isEmpty { + print("setting custom voice \(voice[0].name)") + utterance.voice = voice[0] + } + } else if cmdParts.count > 2 { + utterance.voice = AVSpeechSynthesisVoice(language: String(cmdParts[2])) + } + synthesizer.speak(utterance) + default: + break + } + } + private func ruleCommandAction(_ command: String) { let components = command.split(separator: ":", maxSplits: 2) - guard components.count == 3, components[0] == "rule" else { return } + guard components.count == 2 else { return } - let uuid = String(components[1]) - let propertiesString = String(components[2]) + let uuid = String(components[0]) + let propertiesString = String(components[1]) let propertyPairs = propertiesString.split(separator: ",") var properties: [String: String] = [:] diff --git a/openHAB/ScreenSaver/ScreenSaverManager.swift b/openHAB/ScreenSaver/ScreenSaverManager.swift index 83c186066..80513ecf7 100644 --- a/openHAB/ScreenSaver/ScreenSaverManager.swift +++ b/openHAB/ScreenSaver/ScreenSaverManager.swift @@ -39,6 +39,7 @@ final class ScreenSaverManager: NSObject { super.init() NotificationCenter.default.addObserver(self, selector: #selector(handleDisableNotification), name: .disableScreenSaver, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(handleWakeNotification), name: .wakeScreenSaver, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(handleActivateNotification), name: .activateScreenSaver, object: nil) } func startMonitoring(window: UIWindow, configuration: ScreenSaverConfiguration = ScreenSaverConfiguration()) { @@ -196,12 +197,19 @@ final class ScreenSaverManager: NSObject { resetIdleTimer() dismissSaverIfNeeded() } + + @objc private func handleActivateNotification() { + logger.debug("Received activate screen saver notification") + dismissSaverIfNeeded() + showSaver() + } } /// Notifications that other parts of the app can send to control the screensaver extension Notification.Name { static let disableScreenSaver = Notification.Name("disableScreenSaver") static let wakeScreenSaver = Notification.Name("wakeScreenSaver") + static let activateScreenSaver = Notification.Name("activateScreenSaver") } extension ScreenSaverManager: UIGestureRecognizerDelegate { diff --git a/openHAB/SettingsView/ApplicationSettingsView.swift b/openHAB/SettingsView/ApplicationSettingsView.swift index 6483b722a..7b1487422 100644 --- a/openHAB/SettingsView/ApplicationSettingsView.swift +++ b/openHAB/SettingsView/ApplicationSettingsView.swift @@ -16,8 +16,10 @@ import UIKit struct ApplicationSettingsView: View { @Binding var settingsIdleOff: Bool + @Binding var settingsSSECommandItem: String private let logger = Logger(subsystem: "org.openhab.app", category: "ApplicationSettingsView") + @State private var selectedItemName: String? = nil var body: some View { Section(header: Text(LocalizedStringKey("application_settings"))) { @@ -35,6 +37,26 @@ struct ApplicationSettingsView: View { NavigationLink("Client Certificates") { ClientCertificatesView() } + + NavigationLink { + ItemSelectionView(selectedItemName: $selectedItemName) + } label: { + Text("Command Item \(selectedItemName?.isEmpty == false ? "(" + selectedItemName! + ")" : "")") + } + } + .onChange(of: selectedItemName) { newSelection in + handleItemSelectionChange(newSelection) + } + .onAppear { + selectedItemName = settingsSSECommandItem + } + } + + private func handleItemSelectionChange(_ selected: String?) { + if let selected { + settingsSSECommandItem = selected + } else { + settingsSSECommandItem = "" } } } @@ -42,11 +64,12 @@ struct ApplicationSettingsView: View { #Preview { struct PreviewWrapper: View { @State private var idleOff = false - + @State private var sseCommandItem = "" var body: some View { Form { ApplicationSettingsView( - settingsIdleOff: $idleOff + settingsIdleOff: $idleOff, + settingsSSECommandItem: $sseCommandItem ) } } diff --git a/openHAB/SettingsView/ItemSelectionView.swift b/openHAB/SettingsView/ItemSelectionView.swift new file mode 100644 index 000000000..4db02efd2 --- /dev/null +++ b/openHAB/SettingsView/ItemSelectionView.swift @@ -0,0 +1,75 @@ +// Copyright (c) 2010-2025 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 + +// +// ItemSelectionView.swift +// openHAB +// + +import OpenHABCore +import os +import SwiftUI + +struct ItemSelectionView: View { + @Binding var selectedItemName: String? + @State private var items: [OpenHABItem] = [] + + @State private var searchText = "" + @State private var isLoading = true + + private var filteredItems: [OpenHABItem] { + if searchText.isEmpty { return items } + return items.filter { item in + (item.label.localizedCaseInsensitiveContains(searchText)) || + item.name.localizedCaseInsensitiveContains(searchText) + } + } + + var body: some View { + VStack { + if isLoading { + Spacer() + ProgressView("Loading Items…") + Spacer() + } else { + TextField("Search", text: $searchText) + .textFieldStyle(.roundedBorder) + .padding(.horizontal) + + List { + ForEach(filteredItems, id: \.name) { item in + Button { + selectedItemName = (selectedItemName == item.name) ? nil : item.name + } label: { + HStack { + Text(item.name) + Spacer() + if selectedItemName == item.name { + Image(systemName: "checkmark") + } + } + } + } + } + } + } + .navigationTitle("Items") + .onAppear { + Task { + do { + items = try await NetworkTracker.shared.getItems().filter { $0.type == .stringItem } + } catch { + } + isLoading = false + } + } + } +} diff --git a/openHAB/SettingsView/SettingsView.swift b/openHAB/SettingsView/SettingsView.swift index 6460361cc..96501a70d 100644 --- a/openHAB/SettingsView/SettingsView.swift +++ b/openHAB/SettingsView/SettingsView.swift @@ -30,6 +30,7 @@ struct SettingsView: View { @State var settingsRemoteConnectionConfiguration = ConnectionConfiguration(url: "", username: "", password: "") @State var settingsHomeName = "" @State var viewAppearedOnce = false + @State var settingsSSECommandItem = "" @Environment(\.dismiss) private var dismiss @@ -44,7 +45,8 @@ struct SettingsView: View { ) ApplicationSettingsView( - settingsIdleOff: $settingsIdleOff + settingsIdleOff: $settingsIdleOff, + settingsSSECommandItem: $settingsSSECommandItem ) MainUISettingsView( @@ -129,6 +131,7 @@ struct SettingsView: View { settingsLocalConnectionConfiguration = Preferences.currentHomePreferences.localConnectionConfig settingsRemoteConnectionConfiguration = Preferences.currentHomePreferences.remoteConnectionConfig settingsHomeName = Preferences.currentHomePreferences.homeName + settingsSSECommandItem = Preferences.currentHomePreferences.sseCommandItem } func saveSettings() { @@ -142,6 +145,7 @@ struct SettingsView: View { homePreferences.sitemapForWatchLabel = sitemaps.first { $0.name == settingsSitemapForWatch }?.label ?? "unknown" homePreferences.localConnectionConfig = settingsLocalConnectionConfiguration homePreferences.remoteConnectionConfig = settingsRemoteConnectionConfiguration + homePreferences.sseCommandItem = settingsSSECommandItem } Preferences.idleOff = settingsIdleOff Preferences.sendCrashReports = settingsSendCrashReports From b0fe1217eb33156f6456302788866503d0111e15 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Sun, 3 Aug 2025 09:44:04 +0200 Subject: [PATCH 309/476] test cases for UIColor.hexString Signed-off-by: Tim Mueller-Seydlitz --- .../Tests/OpenHABCoreTests/UIColorTests.swift | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/OpenHABCore/Tests/OpenHABCoreTests/UIColorTests.swift b/OpenHABCore/Tests/OpenHABCoreTests/UIColorTests.swift index ae4614412..c57ae3767 100644 --- a/OpenHABCore/Tests/OpenHABCoreTests/UIColorTests.swift +++ b/OpenHABCore/Tests/OpenHABCoreTests/UIColorTests.swift @@ -41,4 +41,26 @@ struct UIColorTests { let fallback = UIColor(hex: "notAColor") #expect(color == fallback) } + + @Test + func hexStringForMultipleColors() { + // Array of (UIColor, expectedHexString) + let testCases: [(UIColor, String?)] = [ + (UIColor.red, "FF0000"), + (UIColor.green, "00FF00"), + (UIColor.blue, "0000FF"), + (UIColor.white, "FFFFFF"), + (UIColor.black, "000000"), + (UIColor(red: 1, green: 0, blue: 0, alpha: 0.5), "FF000080"), // 50% alpha + (UIColor(patternImage: UIImage()), nil) // Non-RGB color should return nil + ] + + for (index, testCase) in testCases.enumerated() { + let (color, expectedHex) = testCase + #expect( + color.hexString == expectedHex, + "Case \(index): Expected \(expectedHex ?? "nil"), got \(color.hexString ?? "nil")" + ) + } + } } From 11c34a222a47099a417c4881fe259430a7a34647 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Sun, 3 Aug 2025 12:40:01 +0200 Subject: [PATCH 310/476] Some linting Signed-off-by: Tim Mueller-Seydlitz --- .../Sources/OpenHABCore/Util/NetworkTracker.swift | 2 +- .../Tests/OpenHABCoreTests/ComparableTest.swift | 2 +- .../OpenHABCoreTests/NetworkTrackerTests.swift | 14 ++++---------- openHAB/ScreenSaver/ScreenSaverManager.swift | 4 ++-- openHAB/ScreenSaver/ScreenSaverView.swift | 8 ++++---- openHAB/SettingsView/ItemSelectionView.swift | 2 +- 6 files changed, 13 insertions(+), 19 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift index 7b0401b03..6ed0c5247 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift @@ -105,7 +105,7 @@ public protocol NetworkTracking: ObservableObject { var activeConnection: ConnectionInfo? { get } } -@available(*, deprecated) +/// @available(*, deprecated) public final class NetworkTracker: ObservableObject { public static let shared = NetworkTracker() diff --git a/OpenHABCore/Tests/OpenHABCoreTests/ComparableTest.swift b/OpenHABCore/Tests/OpenHABCoreTests/ComparableTest.swift index bdea5c3e6..a2c020e0f 100644 --- a/OpenHABCore/Tests/OpenHABCoreTests/ComparableTest.swift +++ b/OpenHABCore/Tests/OpenHABCoreTests/ComparableTest.swift @@ -12,7 +12,7 @@ @testable import OpenHABCore import Testing -struct ComparableTests { +struct ComparableTest { @Test func clampedWithValueBelowRange() { // Int diff --git a/OpenHABCore/Tests/OpenHABCoreTests/NetworkTrackerTests.swift b/OpenHABCore/Tests/OpenHABCoreTests/NetworkTrackerTests.swift index 7e44d1011..0688f6336 100644 --- a/OpenHABCore/Tests/OpenHABCoreTests/NetworkTrackerTests.swift +++ b/OpenHABCore/Tests/OpenHABCoreTests/NetworkTrackerTests.swift @@ -166,16 +166,10 @@ final class NetworkTrackerTests: XCTestCase { var cancellables = Set() tracker.$status - // swiftlint:disable trailing_closure - .handleEvents( - receiveSubscription: { _ in - statusSinkAttached.fulfill() - }, - receiveOutput: nil, - receiveCompletion: nil, - receiveCancel: nil - ) { _ in } - // swiftlint:enable trailing_closure + .handleEvents { _ in + statusSinkAttached.fulfill() + } receiveRequest: { _ in + } .dropFirst() .sink { status in if status == .notConnected { diff --git a/openHAB/ScreenSaver/ScreenSaverManager.swift b/openHAB/ScreenSaver/ScreenSaverManager.swift index 80513ecf7..b6568489e 100644 --- a/openHAB/ScreenSaver/ScreenSaverManager.swift +++ b/openHAB/ScreenSaver/ScreenSaverManager.swift @@ -152,9 +152,9 @@ final class ScreenSaverManager: NSObject { UIScreen.main.brightness = target } } - UIView.animate(withDuration: 0.2, animations: { + UIView.animate(withDuration: 0.2) { saver.alpha = 0 - }) { _ in + } completion: { _ in saver.removeFromSuperview() } saverView = nil diff --git a/openHAB/ScreenSaver/ScreenSaverView.swift b/openHAB/ScreenSaver/ScreenSaverView.swift index d4887b9dc..7dc7d50f3 100644 --- a/openHAB/ScreenSaver/ScreenSaverView.swift +++ b/openHAB/ScreenSaver/ScreenSaverView.swift @@ -42,10 +42,6 @@ final class ScreenSaverView: UIView { commonInit() } - deinit { - movementTimer?.invalidate() - } - @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") @@ -202,4 +198,8 @@ final class ScreenSaverView: UIView { animations() } } + + deinit { + movementTimer?.invalidate() + } } diff --git a/openHAB/SettingsView/ItemSelectionView.swift b/openHAB/SettingsView/ItemSelectionView.swift index 4db02efd2..05306d2e0 100644 --- a/openHAB/SettingsView/ItemSelectionView.swift +++ b/openHAB/SettingsView/ItemSelectionView.swift @@ -53,7 +53,7 @@ struct ItemSelectionView: View { Text(item.name) Spacer() if selectedItemName == item.name { - Image(systemName: "checkmark") + Image(systemSymbol: .checkmark) } } } From a05cc45c0ff461b10e4c9c60957f147e266e085e Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Sat, 2 Aug 2025 23:05:06 +0200 Subject: [PATCH 311/476] Support for SSE events via openapigen (#901) * Include tag 'events' in openapi generation Signed-off-by: Tim Mueller-Seydlitz * Correcting openapi.json for Content-Type sent by getEvents Signed-off-by: Tim Mueller-Seydlitz * Removed content application/json for getEvents and regenerated Client.swift and Types.swift Introduced the struct OpenHABEvent to decode getEvents data. Should be included in openapi.json Introduced helper openHABEvents(topics: ) to get an AsyncSequence Signed-off-by: Tim Mueller-Seydlitz * DateTime parsing pushed to OpenAPIService initializer Removed updateForLongPolling and pollDataForSitemap(subscriptionId) that was not used Signed-off-by: Tim Mueller-Seydlitz * Adds a complete working SSE command item impl Signed-off-by: Dan Cunningham * revert dev id Signed-off-by: Dan Cunningham * remove commented code Signed-off-by: Dan Cunningham * better screensaver commands Signed-off-by: Dan Cunningham * new device app options for notifications/sse Signed-off-by: Dan Cunningham * Adds TTS, finalizes device sytax, updates README Signed-off-by: Dan Cunningham * remove comment Signed-off-by: Dan Cunningham * Small cleanups Signed-off-by: Dan Cunningham --------- Signed-off-by: Tim Mueller-Seydlitz Signed-off-by: Dan Cunningham Co-authored-by: Dan Cunningham --- OpenHABCore/README.md | 8 + .../GeneratedSources/openapi/Client.swift | 180 +++++++ .../GeneratedSources/openapi/Types.swift | 439 ++++++++++++++++++ .../OpenHABCore/Model/OpenHABEvent.swift | 39 ++ .../OpenHABCore/Util/ItemEventStream.swift | 227 +++++++++ .../OpenHABCore/Util/OpenAPIService.swift | 59 +-- .../OpenHABCore/Util/Preferences.swift | 1 + .../openapi/openapi-generator-config.yml | 1 + .../Sources/OpenHABCore/openapi/openapi.json | 12 +- README.md | 317 +++++++++++++ openHAB.xcodeproj/.project.pbxproj.swp | Bin 16384 -> 0 bytes openHAB.xcodeproj/project.pbxproj | 5 + openHAB/OpenHABRootViewController.swift | 110 ++++- openHAB/ScreenSaver/ScreenSaverManager.swift | 8 + .../ApplicationSettingsView.swift | 27 +- openHAB/SettingsView/ItemSelectionView.swift | 75 +++ openHAB/SettingsView/SettingsView.swift | 6 +- 17 files changed, 1465 insertions(+), 49 deletions(-) create mode 100644 OpenHABCore/Sources/OpenHABCore/Model/OpenHABEvent.swift create mode 100644 OpenHABCore/Sources/OpenHABCore/Util/ItemEventStream.swift delete mode 100644 openHAB.xcodeproj/.project.pbxproj.swp create mode 100644 openHAB/SettingsView/ItemSelectionView.swift diff --git a/OpenHABCore/README.md b/OpenHABCore/README.md index e3f615e2a..8ba7f1f57 100644 --- a/OpenHABCore/README.md +++ b/OpenHABCore/README.md @@ -1,3 +1,11 @@ # OpenHABCore This package contains code shared between the main openHAB app and its extensions. + +1. Invoke the (CLI manually)[https://swiftpackageindex.com/apple/swift-openapi-generator/1.10.1/documentation/swift-openapi-generator/manually-invoking-the-generator-cli] +This is a work around to use openAPI in a package. + +1. Clone the generator package locally with git clone https://github.com/apple/swift-openapi-generator + +1. Run ```cd swift-openapi-generator && swift run swift-openapi-generator generate --config ../Sources/OpenHABCore/openapi/openapi-generator-config.yml --output-directory ../Sources/OpenHABCore/GeneratedSources/openapi ../Sources/OpenHABCore/openapi/openapi.json``` + diff --git a/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Client.swift b/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Client.swift index ef46695f0..2703e90eb 100644 --- a/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Client.swift +++ b/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Client.swift @@ -2757,6 +2757,186 @@ public struct Client: APIProtocol { } ) } + /// Initiates a new item state tracker connection + /// + /// - Remark: HTTP `GET /events/states`. + /// - Remark: Generated from `#/paths//events/states/get(initNewStateTacker)`. + public func initNewStateTacker(_ input: Operations.initNewStateTacker.Input) async throws -> Operations.initNewStateTacker.Output { + try await client.send( + input: input, + forOperation: Operations.initNewStateTacker.id, + serializer: { input in + let path = try converter.renderedPath( + template: "/events/states", + parameters: [] + ) + var request: HTTPTypes.HTTPRequest = .init( + soar_path: path, + method: .get + ) + suppressMutabilityWarning(&request) + converter.setAcceptHeader( + in: &request.headerFields, + contentTypes: input.headers.accept + ) + return (request, nil) + }, + deserializer: { response, responseBody in + switch response.status.code { + case 200: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Operations.initNewStateTacker.Output.Ok.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "text/event-stream" + ] + ) + switch chosenContentType { + case "text/event-stream": + body = try converter.getResponseBodyAsBinary( + OpenAPIRuntime.HTTPBody.self, + from: responseBody, + transforming: { value in + .text_event_hyphen_stream(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .ok(.init(body: body)) + default: + return .undocumented( + statusCode: response.status.code, + .init( + headerFields: response.headerFields, + body: responseBody + ) + ) + } + } + ) + } + /// Get all events. + /// + /// - Remark: HTTP `GET /events`. + /// - Remark: Generated from `#/paths//events/get(getEvents)`. + public func getEvents(_ input: Operations.getEvents.Input) async throws -> Operations.getEvents.Output { + try await client.send( + input: input, + forOperation: Operations.getEvents.id, + serializer: { input in + let path = try converter.renderedPath( + template: "/events", + parameters: [] + ) + var request: HTTPTypes.HTTPRequest = .init( + soar_path: path, + method: .get + ) + suppressMutabilityWarning(&request) + try converter.setQueryItemAsURI( + in: &request, + style: .form, + explode: true, + name: "topics", + value: input.query.topics + ) + converter.setAcceptHeader( + in: &request.headerFields, + contentTypes: input.headers.accept + ) + return (request, nil) + }, + deserializer: { response, responseBody in + switch response.status.code { + case 200: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Operations.getEvents.Output.Ok.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "text/event-stream" + ] + ) + switch chosenContentType { + case "text/event-stream": + body = try converter.getResponseBodyAsBinary( + OpenAPIRuntime.HTTPBody.self, + from: responseBody, + transforming: { value in + .text_event_hyphen_stream(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .ok(.init(body: body)) + case 400: + return .badRequest(.init()) + default: + return .undocumented( + statusCode: response.status.code, + .init( + headerFields: response.headerFields, + body: responseBody + ) + ) + } + } + ) + } + /// Changes the list of items a SSE connection will receive state updates to. + /// + /// - Remark: HTTP `POST /events/states/{connectionId}`. + /// - Remark: Generated from `#/paths//events/states/{connectionId}/post(updateItemListForStateUpdates)`. + public func updateItemListForStateUpdates(_ input: Operations.updateItemListForStateUpdates.Input) async throws -> Operations.updateItemListForStateUpdates.Output { + try await client.send( + input: input, + forOperation: Operations.updateItemListForStateUpdates.id, + serializer: { input in + let path = try converter.renderedPath( + template: "/events/states/{}", + parameters: [ + input.path.connectionId + ] + ) + var request: HTTPTypes.HTTPRequest = .init( + soar_path: path, + method: .post + ) + suppressMutabilityWarning(&request) + let body: OpenAPIRuntime.HTTPBody? + switch input.body { + case .none: + body = nil + case let .json(value): + body = try converter.setOptionalRequestBodyAsJSON( + value, + headerFields: &request.headerFields, + contentType: "application/json; charset=utf-8" + ) + } + return (request, body) + }, + deserializer: { response, responseBody in + switch response.status.code { + case 200: + return .ok(.init()) + case 404: + return .notFound(.init()) + default: + return .undocumented( + statusCode: response.status.code, + .init( + headerFields: response.headerFields, + body: responseBody + ) + ) + } + } + ) + } /// Get all registered UI components in the specified namespace. /// /// - Remark: HTTP `GET /ui/components/{namespace}`. diff --git a/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Types.swift b/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Types.swift index 1641a18a3..d736e71e1 100644 --- a/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Types.swift +++ b/OpenHABCore/Sources/OpenHABCore/GeneratedSources/openapi/Types.swift @@ -231,6 +231,21 @@ public protocol APIProtocol: Sendable { /// - Remark: HTTP `GET /sitemaps`. /// - Remark: Generated from `#/paths//sitemaps/get(getSitemaps)`. func getSitemaps(_ input: Operations.getSitemaps.Input) async throws -> Operations.getSitemaps.Output + /// Initiates a new item state tracker connection + /// + /// - Remark: HTTP `GET /events/states`. + /// - Remark: Generated from `#/paths//events/states/get(initNewStateTacker)`. + func initNewStateTacker(_ input: Operations.initNewStateTacker.Input) async throws -> Operations.initNewStateTacker.Output + /// Get all events. + /// + /// - Remark: HTTP `GET /events`. + /// - Remark: Generated from `#/paths//events/get(getEvents)`. + func getEvents(_ input: Operations.getEvents.Input) async throws -> Operations.getEvents.Output + /// Changes the list of items a SSE connection will receive state updates to. + /// + /// - Remark: HTTP `POST /events/states/{connectionId}`. + /// - Remark: Generated from `#/paths//events/states/{connectionId}/post(updateItemListForStateUpdates)`. + func updateItemListForStateUpdates(_ input: Operations.updateItemListForStateUpdates.Input) async throws -> Operations.updateItemListForStateUpdates.Output /// Get all registered UI components in the specified namespace. /// /// - Remark: HTTP `GET /ui/components/{namespace}`. @@ -767,6 +782,39 @@ extension APIProtocol { public func getSitemaps(headers: Operations.getSitemaps.Input.Headers = .init()) async throws -> Operations.getSitemaps.Output { try await getSitemaps(Operations.getSitemaps.Input(headers: headers)) } + /// Initiates a new item state tracker connection + /// + /// - Remark: HTTP `GET /events/states`. + /// - Remark: Generated from `#/paths//events/states/get(initNewStateTacker)`. + public func initNewStateTacker(headers: Operations.initNewStateTacker.Input.Headers = .init()) async throws -> Operations.initNewStateTacker.Output { + try await initNewStateTacker(Operations.initNewStateTacker.Input(headers: headers)) + } + /// Get all events. + /// + /// - Remark: HTTP `GET /events`. + /// - Remark: Generated from `#/paths//events/get(getEvents)`. + public func getEvents( + query: Operations.getEvents.Input.Query = .init(), + headers: Operations.getEvents.Input.Headers = .init() + ) async throws -> Operations.getEvents.Output { + try await getEvents(Operations.getEvents.Input( + query: query, + headers: headers + )) + } + /// Changes the list of items a SSE connection will receive state updates to. + /// + /// - Remark: HTTP `POST /events/states/{connectionId}`. + /// - Remark: Generated from `#/paths//events/states/{connectionId}/post(updateItemListForStateUpdates)`. + public func updateItemListForStateUpdates( + path: Operations.updateItemListForStateUpdates.Input.Path, + body: Operations.updateItemListForStateUpdates.Input.Body? = nil + ) async throws -> Operations.updateItemListForStateUpdates.Output { + try await updateItemListForStateUpdates(Operations.updateItemListForStateUpdates.Input( + path: path, + body: body + )) + } /// Get all registered UI components in the specified namespace. /// /// - Remark: HTTP `GET /ui/components/{namespace}`. @@ -11204,6 +11252,397 @@ public enum Operations { } } } + /// Initiates a new item state tracker connection + /// + /// - Remark: HTTP `GET /events/states`. + /// - Remark: Generated from `#/paths//events/states/get(initNewStateTacker)`. + public enum initNewStateTacker { + public static let id: Swift.String = "initNewStateTacker" + public struct Input: Sendable, Hashable { + /// - Remark: Generated from `#/paths/events/states/GET/header`. + public struct Headers: Sendable, Hashable { + public var accept: [OpenAPIRuntime.AcceptHeaderContentType] + /// Creates a new `Headers`. + /// + /// - Parameters: + /// - accept: + public init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + self.accept = accept + } + } + public var headers: Operations.initNewStateTacker.Input.Headers + /// Creates a new `Input`. + /// + /// - Parameters: + /// - headers: + public init(headers: Operations.initNewStateTacker.Input.Headers = .init()) { + self.headers = headers + } + } + @frozen public enum Output: Sendable, Hashable { + public struct Ok: Sendable, Hashable { + /// - Remark: Generated from `#/paths/events/states/GET/responses/200/content`. + @frozen public enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/events/states/GET/responses/200/content/text\/event-stream`. + case text_event_hyphen_stream(OpenAPIRuntime.HTTPBody) + /// The associated value of the enum case if `self` is `.text_event_hyphen_stream`. + /// + /// - Throws: An error if `self` is not `.text_event_hyphen_stream`. + /// - SeeAlso: `.text_event_hyphen_stream`. + public var text_event_hyphen_stream: OpenAPIRuntime.HTTPBody { + get throws { + switch self { + case let .text_event_hyphen_stream(body): + return body + } + } + } + } + /// Received HTTP response body + public var body: Operations.initNewStateTacker.Output.Ok.Body + /// Creates a new `Ok`. + /// + /// - Parameters: + /// - body: Received HTTP response body + public init(body: Operations.initNewStateTacker.Output.Ok.Body) { + self.body = body + } + } + /// OK + /// + /// - Remark: Generated from `#/paths//events/states/get(initNewStateTacker)/responses/200`. + /// + /// HTTP response code: `200 ok`. + case ok(Operations.initNewStateTacker.Output.Ok) + /// The associated value of the enum case if `self` is `.ok`. + /// + /// - Throws: An error if `self` is not `.ok`. + /// - SeeAlso: `.ok`. + public var ok: Operations.initNewStateTacker.Output.Ok { + get throws { + switch self { + case let .ok(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "ok", + response: self + ) + } + } + } + /// Undocumented response. + /// + /// A response with a code that is not documented in the OpenAPI document. + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + @frozen public enum AcceptableContentType: AcceptableProtocol { + case text_event_hyphen_stream + case other(Swift.String) + public init?(rawValue: Swift.String) { + switch rawValue.lowercased() { + case "text/event-stream": + self = .text_event_hyphen_stream + default: + self = .other(rawValue) + } + } + public var rawValue: Swift.String { + switch self { + case let .other(string): + return string + case .text_event_hyphen_stream: + return "text/event-stream" + } + } + public static var allCases: [Self] { + [ + .text_event_hyphen_stream + ] + } + } + } + /// Get all events. + /// + /// - Remark: HTTP `GET /events`. + /// - Remark: Generated from `#/paths//events/get(getEvents)`. + public enum getEvents { + public static let id: Swift.String = "getEvents" + public struct Input: Sendable, Hashable { + /// - Remark: Generated from `#/paths/events/GET/query`. + public struct Query: Sendable, Hashable { + /// topics + /// + /// - Remark: Generated from `#/paths/events/GET/query/topics`. + public var topics: Swift.String? + /// Creates a new `Query`. + /// + /// - Parameters: + /// - topics: topics + public init(topics: Swift.String? = nil) { + self.topics = topics + } + } + public var query: Operations.getEvents.Input.Query + /// - Remark: Generated from `#/paths/events/GET/header`. + public struct Headers: Sendable, Hashable { + public var accept: [OpenAPIRuntime.AcceptHeaderContentType] + /// Creates a new `Headers`. + /// + /// - Parameters: + /// - accept: + public init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + self.accept = accept + } + } + public var headers: Operations.getEvents.Input.Headers + /// Creates a new `Input`. + /// + /// - Parameters: + /// - query: + /// - headers: + public init( + query: Operations.getEvents.Input.Query = .init(), + headers: Operations.getEvents.Input.Headers = .init() + ) { + self.query = query + self.headers = headers + } + } + @frozen public enum Output: Sendable, Hashable { + public struct Ok: Sendable, Hashable { + /// - Remark: Generated from `#/paths/events/GET/responses/200/content`. + @frozen public enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/events/GET/responses/200/content/text\/event-stream`. + case text_event_hyphen_stream(OpenAPIRuntime.HTTPBody) + /// The associated value of the enum case if `self` is `.text_event_hyphen_stream`. + /// + /// - Throws: An error if `self` is not `.text_event_hyphen_stream`. + /// - SeeAlso: `.text_event_hyphen_stream`. + public var text_event_hyphen_stream: OpenAPIRuntime.HTTPBody { + get throws { + switch self { + case let .text_event_hyphen_stream(body): + return body + } + } + } + } + /// Received HTTP response body + public var body: Operations.getEvents.Output.Ok.Body + /// Creates a new `Ok`. + /// + /// - Parameters: + /// - body: Received HTTP response body + public init(body: Operations.getEvents.Output.Ok.Body) { + self.body = body + } + } + /// OK + /// + /// - Remark: Generated from `#/paths//events/get(getEvents)/responses/200`. + /// + /// HTTP response code: `200 ok`. + case ok(Operations.getEvents.Output.Ok) + /// The associated value of the enum case if `self` is `.ok`. + /// + /// - Throws: An error if `self` is not `.ok`. + /// - SeeAlso: `.ok`. + public var ok: Operations.getEvents.Output.Ok { + get throws { + switch self { + case let .ok(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "ok", + response: self + ) + } + } + } + public struct BadRequest: Sendable, Hashable { + /// Creates a new `BadRequest`. + public init() {} + } + /// Topic is empty or contains invalid characters + /// + /// - Remark: Generated from `#/paths//events/get(getEvents)/responses/400`. + /// + /// HTTP response code: `400 badRequest`. + case badRequest(Operations.getEvents.Output.BadRequest) + /// Topic is empty or contains invalid characters + /// + /// - Remark: Generated from `#/paths//events/get(getEvents)/responses/400`. + /// + /// HTTP response code: `400 badRequest`. + public static var badRequest: Self { + .badRequest(.init()) + } + /// The associated value of the enum case if `self` is `.badRequest`. + /// + /// - Throws: An error if `self` is not `.badRequest`. + /// - SeeAlso: `.badRequest`. + public var badRequest: Operations.getEvents.Output.BadRequest { + get throws { + switch self { + case let .badRequest(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "badRequest", + response: self + ) + } + } + } + /// Undocumented response. + /// + /// A response with a code that is not documented in the OpenAPI document. + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + @frozen public enum AcceptableContentType: AcceptableProtocol { + case text_event_hyphen_stream + case other(Swift.String) + public init?(rawValue: Swift.String) { + switch rawValue.lowercased() { + case "text/event-stream": + self = .text_event_hyphen_stream + default: + self = .other(rawValue) + } + } + public var rawValue: Swift.String { + switch self { + case let .other(string): + return string + case .text_event_hyphen_stream: + return "text/event-stream" + } + } + public static var allCases: [Self] { + [ + .text_event_hyphen_stream + ] + } + } + } + /// Changes the list of items a SSE connection will receive state updates to. + /// + /// - Remark: HTTP `POST /events/states/{connectionId}`. + /// - Remark: Generated from `#/paths//events/states/{connectionId}/post(updateItemListForStateUpdates)`. + public enum updateItemListForStateUpdates { + public static let id: Swift.String = "updateItemListForStateUpdates" + public struct Input: Sendable, Hashable { + /// - Remark: Generated from `#/paths/events/states/{connectionId}/POST/path`. + public struct Path: Sendable, Hashable { + /// - Remark: Generated from `#/paths/events/states/{connectionId}/POST/path/connectionId`. + public var connectionId: Swift.String + /// Creates a new `Path`. + /// + /// - Parameters: + /// - connectionId: + public init(connectionId: Swift.String) { + self.connectionId = connectionId + } + } + public var path: Operations.updateItemListForStateUpdates.Input.Path + /// - Remark: Generated from `#/paths/events/states/{connectionId}/POST/requestBody`. + @frozen public enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/events/states/{connectionId}/POST/requestBody/content/application\/json`. + case json([Swift.String]) + } + public var body: Operations.updateItemListForStateUpdates.Input.Body? + /// Creates a new `Input`. + /// + /// - Parameters: + /// - path: + /// - body: + public init( + path: Operations.updateItemListForStateUpdates.Input.Path, + body: Operations.updateItemListForStateUpdates.Input.Body? = nil + ) { + self.path = path + self.body = body + } + } + @frozen public enum Output: Sendable, Hashable { + public struct Ok: Sendable, Hashable { + /// Creates a new `Ok`. + public init() {} + } + /// OK + /// + /// - Remark: Generated from `#/paths//events/states/{connectionId}/post(updateItemListForStateUpdates)/responses/200`. + /// + /// HTTP response code: `200 ok`. + case ok(Operations.updateItemListForStateUpdates.Output.Ok) + /// OK + /// + /// - Remark: Generated from `#/paths//events/states/{connectionId}/post(updateItemListForStateUpdates)/responses/200`. + /// + /// HTTP response code: `200 ok`. + public static var ok: Self { + .ok(.init()) + } + /// The associated value of the enum case if `self` is `.ok`. + /// + /// - Throws: An error if `self` is not `.ok`. + /// - SeeAlso: `.ok`. + public var ok: Operations.updateItemListForStateUpdates.Output.Ok { + get throws { + switch self { + case let .ok(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "ok", + response: self + ) + } + } + } + public struct NotFound: Sendable, Hashable { + /// Creates a new `NotFound`. + public init() {} + } + /// Unknown connectionId + /// + /// - Remark: Generated from `#/paths//events/states/{connectionId}/post(updateItemListForStateUpdates)/responses/404`. + /// + /// HTTP response code: `404 notFound`. + case notFound(Operations.updateItemListForStateUpdates.Output.NotFound) + /// Unknown connectionId + /// + /// - Remark: Generated from `#/paths//events/states/{connectionId}/post(updateItemListForStateUpdates)/responses/404`. + /// + /// HTTP response code: `404 notFound`. + public static var notFound: Self { + .notFound(.init()) + } + /// The associated value of the enum case if `self` is `.notFound`. + /// + /// - Throws: An error if `self` is not `.notFound`. + /// - SeeAlso: `.notFound`. + public var notFound: Operations.updateItemListForStateUpdates.Output.NotFound { + get throws { + switch self { + case let .notFound(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "notFound", + response: self + ) + } + } + } + /// Undocumented response. + /// + /// A response with a code that is not documented in the OpenAPI document. + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + } /// Get all registered UI components in the specified namespace. /// /// - Remark: HTTP `GET /ui/components/{namespace}`. diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABEvent.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABEvent.swift new file mode 100644 index 000000000..d570b4a0d --- /dev/null +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABEvent.swift @@ -0,0 +1,39 @@ +// Copyright (c) 2010-2025 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 Foundation + +public struct OpenHABEvent: Decodable, Hashable, Sendable { + enum EventType: String, Decodable { + case itemStateEvent = "ItemStateEvent" + case itemStateUpdatedEvent = "ItemStateUpdatedEvent" + case itemStateChangedEvent = "ItemStateChangedEvent" + } + + struct Payload: Decodable, Equatable, Hashable, Sendable { + private enum CodingKeys: String, CodingKey { + case type, value, oldType, oldValue, lastStateUpdate, lastStateChange + } + + let type: String + let value: String + + // Optional for updated/changed events + let oldType: String? + let oldValue: String? + let lastStateUpdate: Date? + let lastStateChange: Date? + } + + let topic: String + let payload: Payload + let type: EventType +} diff --git a/OpenHABCore/Sources/OpenHABCore/Util/ItemEventStream.swift b/OpenHABCore/Sources/OpenHABCore/Util/ItemEventStream.swift new file mode 100644 index 000000000..d0fb3086f --- /dev/null +++ b/OpenHABCore/Sources/OpenHABCore/Util/ItemEventStream.swift @@ -0,0 +1,227 @@ +// Copyright (c) 2010-2025 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 Combine +import Foundation +import OpenAPIRuntime +import OpenAPIURLSession +import OSLog + +/** + Example usage: + + ``` + ItemEventStream.startMonitoringNetwork() + Task { + await ItemEventStream.trackItems(["PanelDansOffice", "F1_Kitchen"]) + } + print("Starting SSE") + streamTask = Task { [weak self] in + guard let self else { return } + for await msg in await ItemEventStream.stream() { + await MainActor.run { self.handle(msg) } + } + } + ``` + */ + +public enum StreamOutput: Sendable { + case connected + case disconnected((any Error)?) // `nil` when closed intentionally + case event(Event) +} + +public enum StateStreamMessage: Sendable, Equatable { + case ready(uuid: String, lastEventID: String?) + case state(item: String, state: String) + case alive(interval: Int) + case unknown(raw: String) +} + +public actor EventStream { + private let logger = Logger( + subsystem: Bundle.main.bundleIdentifier ?? "EventStream", + category: "SSE" + ) + private var trackedItems: Set = [] + private var continuations + = [UUID: AsyncStream>.Continuation]() + private var listenTask: Task? + private var currentConfig: ConnectionConfiguration? + private var sessionUUID: String? + private var service: OpenAPIService? + + public func stream() -> AsyncStream> { + AsyncStream { continuation in + let id = UUID() + continuations[id] = continuation + continuation.onTermination = { [weak self] _ in + guard let self else { return } + Task { [id] in + await self.cleanupContinuation(id) + } + } + } + } + + public func trackItems(_ items: [String]) async { + trackedItems = Set(items) + await sendTrackedItemsIfPossible() + } + + private func cleanupContinuation(_ id: UUID) { + continuations[id]?.finish() + continuations.removeValue(forKey: id) + } + + // NetworkManager callback + private func updateConnection(_ info: ConnectionInfo?) { + let newConfig = info?.configuration + + guard currentConfig != newConfig else { return } + + currentConfig = info?.configuration + + logger.info("Network changed – restarting SSE connection") + + listenTask?.cancel() + listenTask = nil + + guard let cfg = currentConfig else { + broadcast(.disconnected(nil)) + return + } + listenTask = Task { await listen(using: cfg) } + } + + /// Try and send the item right away, if the connection is not up, they will be sent when its ready + private func sendTrackedItemsIfPossible() async { + guard + !trackedItems.isEmpty, + let uuid = sessionUUID, + let service + else { return } + do { + try await service.updateItemListForStateUpdates( + connectionId: uuid, + items: Array(trackedItems) + ) + } catch { + logger.error("Failed to update item list: \(error.localizedDescription)") + } + } + + private func listen(using config: ConnectionConfiguration) async { + var backoff: TimeInterval = 1 + let maxBackoff: TimeInterval = 30 + + while !Task.isCancelled { + do { + let service = try OpenAPIService(connectionConfiguration: config) + let response = try await service.initNewStateTacker() + let eventStream = try response.ok.body.text_event_hyphen_stream.asDecodedServerSentEvents() + self.service = service + broadcast(.connected) + + for try await sse in eventStream { + logger.info("SSE event: \(sse.event ?? "empty")") + for rawMessage in parse(sse) { + if let message = rawMessage as? Event { + broadcast(.event(message)) + } + } + } + // if we get here we have lost the connection + throw CancellationError() + } catch is CancellationError { + // normal cleanup, just return + return + } catch { + broadcast(.disconnected(error)) + // give a little time before we try connecting again + logger.error("SSE error: \(error.localizedDescription, privacy: .public) – retrying in \(backoff, privacy: .public)s") + try? await Task.sleep(for: .seconds(backoff)) + backoff = min(backoff * 2, maxBackoff) + } + } + } + + private func broadcast(_ msg: StreamOutput) { + // the "ready" message carries the UUID string we need to store + if case let .event(raw as StateStreamMessage) = msg, + case let .ready(uuid, _) = raw { + sessionUUID = uuid + // Re‑send the item subscription when we have a fresh UUID. + Task { await self.sendTrackedItemsIfPossible() } + } + continuations.values.forEach { $0.yield(msg) } + } + + private func parse(_ sse: ServerSentEvent) -> [StateStreamMessage] { + switch sse.event ?? "" { + case "ready": + if let uuid = sse.data { + return [.ready(uuid: uuid, lastEventID: sse.id)] + } + case "alive": + if let data = sse.data!.data(using: String.Encoding.utf8), + let obj = try? JSONDecoder().decode( + Alive.self, from: data + ) { + return [.alive(interval: obj.interval)] + } + default: + // sometime message omit the `event:` field and send only `data:` with the JSON. + if let data = sse.data?.data(using: String.Encoding.utf8), + let changes = try? JSONDecoder().decode(ItemStateChanges.self, from: data) { + return changes.wrapped.map { key, value in + .state(item: key, state: value.state) + } + } + } + return [.unknown(raw: sse.data ?? "nil")] + } + + // Alive and Item State Chnage message structures + private struct Alive: Decodable { let type: String; let interval: Int } + // Multiple items can come in a single message which makes this a little more complicated + private struct ItemStateChanges: Decodable { + struct Value: Decodable { let state: String } + let wrapped: [String: Value] + init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + wrapped = try container.decode([String: Value].self) + } + + var first: (String, Value)? { wrapped.first } + } +} + +/// Helper so callers can just use `ItemEventStream` and not EventStream +public typealias ItemEventStream = EventStream + +public extension ItemEventStream { + static let shared = ItemEventStream() + /// helper function so callers can write something like: + /// `await ItemEventStream.trackItems(["KitchenLight"])`. + nonisolated static func trackItems(_ items: [String]) async { + await shared.trackItems(items) + } + + nonisolated static func startMonitoringNetwork() { + Task.detached { [weak hub = Self.shared] in + guard let hub else { return } + for await conn in NetworkTracker.shared.$activeConnection.values { + await hub.updateConnection(conn) + } + } + } +} diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift index ec821c849..5cfa267bf 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift @@ -72,8 +72,8 @@ public actor OpenAPIService { config.timeoutIntervalForRequest = 35.0 config.timeoutIntervalForResource = config.timeoutIntervalForRequest + 25 case .shortTerm: - config.timeoutIntervalForRequest = 2.0 - config.timeoutIntervalForResource = 2.0 + config.timeoutIntervalForRequest = 10.0 + config.timeoutIntervalForResource = 10.0 } let session = URLSession(configuration: config, delegate: delegate, delegateQueue: nil) let url = URL(string: connectionConfiguration.url) ?? URL(staticString: "about:blank") @@ -84,6 +84,7 @@ public actor OpenAPIService { client = Client( serverURL: serverURL, + configuration: .init(dateTranscoder: ISO8601DateTranscoder(options: [.withInternetDateTime, .withFractionalSeconds, .withTimeZone, .withColonSeparatorInTimeZone])), transport: URLSessionTransport(configuration: .init(session: session)), middlewares: [ LoggingMiddleware(), @@ -113,23 +114,6 @@ public actor OpenAPIService { // config.timeoutIntervalForResource = config.timeoutIntervalForRequest + 25 return config } - - // timeoutIntervalForRequest/timeoutIntervalForResource need to be passed through URLSessionConfiguration when URLSession is created. Therefore create a new APIClient to change values. - public func updateForLongPolling(_ newlongPolling: Bool) async { - guard newlongPolling != longPolling else { return } - longPolling = newlongPolling - - let config = prepareURLSessionConfiguration(longPolling: longPolling) - let session = URLSession(configuration: config) - client = Client( - serverURL: url!.appending(path: "/rest"), - transport: URLSessionTransport(configuration: .init(session: session)), - middlewares: [ - LoggingMiddleware(), - AuthorisationMiddleware(configuration: connectionConfiguration) - ] - ) - } } public extension OpenAPIService { @@ -204,6 +188,7 @@ public extension OpenAPIService { } public extension OpenAPIService { + // Returns subscription id or nil func openHABcreateSubscription() async throws -> String? { logger.info("Creating subscription") let result = try await client.createSitemapEventSubscription() @@ -220,6 +205,13 @@ public extension OpenAPIService { .asDecodedServerSentEventsWithJSONData(of: Components.Schemas.SitemapWidgetEvent.self) return decodedSequence.compactMap { OpenHABSitemapWidgetEvent($0.data) } } + + func openHABEvents(topics: String? = nil) async throws -> AsyncThrowingMapSequence>, ServerSentEventWithJSONData> { + let query: Operations.getEvents.Input.Query = topics == nil ? .init() : Operations.getEvents.Input.Query(topics: topics) + return try await client.getEvents(query: query) + .ok.body.text_event_hyphen_stream + .asDecodedServerSentEventsWithJSONData(of: OpenHABEvent.self) + } } public extension OpenAPIService { @@ -269,22 +261,6 @@ public extension OpenAPIService { .ok.body.json return OpenHABSitemap(result) } - - // Unused currently - // To be used when migrating to SSE - func pollDataForSitemap(sitemapname: String, longPolling: Bool, subscriptionId: String? = nil) async throws -> OpenHABSitemap? { - var headers = Operations.pollDataForSitemap.Input.Headers() - if longPolling { - logger.info("Long-polling, setting X-Atmosphere-Transport") - headers.X_hyphen_Atmosphere_hyphen_Transport = "long-polling" - } else { - headers.X_hyphen_Atmosphere_hyphen_Transport = nil - } - let query = Operations.pollDataForSitemap.Input.Query(subscriptionid: subscriptionId) - let path = Operations.pollDataForSitemap.Input.Path(sitemapname: sitemapname) - await updateForLongPolling(longPolling) - return try await pollDataForSitemap(path: path, query: query, headers: headers) - } } // Array of items @@ -296,6 +272,19 @@ public extension OpenAPIService { } } +public extension OpenAPIService { + func initNewStateTacker() async throws -> Operations.initNewStateTacker.Output { + try await client.initNewStateTacker() + } + + func updateItemListForStateUpdates(connectionId: String, items: [String]) async throws { + let path = Operations.updateItemListForStateUpdates.Input.Path(connectionId: connectionId) + let body = Operations.updateItemListForStateUpdates.Input.Body.json(.init(items)) + let response = try await client.updateItemListForStateUpdates(path: path, body: body) + _ = try response.ok + } +} + // MARK: State changes and commands public extension OpenAPIService { diff --git a/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift index 421a15ee1..cfc3b3d53 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift @@ -99,6 +99,7 @@ public struct HomePreferences: Codable, Sendable, Equatable { public var remoteConnectionConfig: ConnectionConfiguration = .remoteDefault public var sitemapForWatchLabel = "watch" public var homeName = "Home" + public var sseCommandItem = "" fileprivate init(id: UUID) { self.id = id diff --git a/OpenHABCore/Sources/OpenHABCore/openapi/openapi-generator-config.yml b/OpenHABCore/Sources/OpenHABCore/openapi/openapi-generator-config.yml index 330297b24..94a00139e 100644 --- a/OpenHABCore/Sources/OpenHABCore/openapi/openapi-generator-config.yml +++ b/OpenHABCore/Sources/OpenHABCore/openapi/openapi-generator-config.yml @@ -10,3 +10,4 @@ filter: - root - systeminfo - rules + - events diff --git a/OpenHABCore/Sources/OpenHABCore/openapi/openapi.json b/OpenHABCore/Sources/OpenHABCore/openapi/openapi.json index 7c3ab44c7..c3229a640 100644 --- a/OpenHABCore/Sources/OpenHABCore/openapi/openapi.json +++ b/OpenHABCore/Sources/OpenHABCore/openapi/openapi.json @@ -5866,7 +5866,10 @@ "operationId": "initNewStateTacker", "responses": { "200": { - "description": "OK" + "description": "OK", + "content": { + "text/event-stream": {} + } } } } @@ -5890,7 +5893,10 @@ ], "responses": { "200": { - "description": "OK" + "description": "OK", + "content": { + "text/event-stream": {} + } }, "400": { "description": "Topic is empty or contains invalid characters" @@ -5918,7 +5924,7 @@ "requestBody": { "description": "items", "content": { - "*/*": { + "application/json": { "schema": { "uniqueItems": true, "type": "array", diff --git a/README.md b/README.md index 1e4a8ae16..05dca72c7 100644 --- a/README.md +++ b/README.md @@ -36,30 +36,36 @@ This app only receives security updates and minor fixes and is not intended for Beta releases are available on [TestFlight](https://testflight.apple.com/join/563WBakc). ## Features + * Control your openHAB server directly and through a [openHAB Cloud instance](https://github.com/openhab/openhab-cloud) * [Enhanced push notification](#push-notifications) from openHAB Cloud and the openHAB cloud binding * [Apple Watch](#apple-watch-configuration) companion app * [Widgets](#widgets) (coming soon!) ## App Configuration +

Logo Logo

### Connection Settings + The app will try and connect using the Local URL as the primary connection, and if that fails or is not reachable, falls back to the local URL. ### Demo Mode + This sets up the app to use the openHAB demo server and can be used to experience the app without needing to install openHAB. #### Local URL + This is the primary connection to your openHAB instance, a fully qualified URL with a IP or host is required. Example: `https://openhab.local:8443` #### Remote URL + This is the secondary connection to your openHAB instance, a fully qualified URL with a IP or host is required. If using the openHAB cloud service, leave this as the default setting of `https://myopenhab.org`. When set to the public cloud, the app will also register for push notifications (as long as credentials are correct) @@ -68,12 +74,14 @@ Example: `https://myopenhab.org` ### Username / Password + This will be sent if the local or remote server challenges for authentication, or if "Always Send Credentials" is checked on. If using the openHAB cloud, these should be set to those login credentials. ### Application Settings ### Certificates + Allows the installation of p12 formatted certificates for use in client side authentication setups. To install a client certificate, rename the certificate with the extension `.ohp12`, then send it to your iOS device (airdrop, icloud, dropbox, etc..), then open/save and select `openHAB` from the "Open In" menu (you may need to select "More..." to see all apps). @@ -84,17 +92,77 @@ If using openssl v3 to generate certificates, make sure to add `-legacy -certpbe See https://github.com/openssl/openssl/issues/19871 for more information on V3 compatibility with Apple products. ### Idle Timeout + Useful for wall or fixed installations, will disable the Idle screen timeout. +### Screensaver + +The app includes a built-in screen saver that can be shown automatically after a period of inactivity. + +Key options (Settings → Screen Saver): + +- **Enable Screen Saver** – turns the feature on/off. +- **Appearance** – decide whether to show the time, date and/or seconds, choose 12- or 24-hour clock, and pick a custom font. +- **Idle Interval** – number of seconds of user inactivity before the screen saver appears (5 – 600 s). +- **Movement Interval** – how often the clock moves to a new random position to avoid burn-in (2 – 60 s). +- **Font Size** – independent sliders for clock and date size (relative to the screen). +- **Animation** – fade-in/out duration when the screen saver appears or disappears. +- **Brightness** – optional automatic dimming; set dim level, whether the previous brightness should be restored, and the brightness level to restore to. +- **Test Screen Saver** – instantly preview the current configuration. + +You can also control the screen saver from the [command item](#command-item) using the `device:screensaver:` syntax. See [Action Syntax](#action-syntax) for more information. + +### Command Item + +The iOS app can react to updates of a dedicated String Item so you can remotely control the device or navigate the UI from your openHAB server. + +Whenever the state of the Item changes the app interprets the new value as an [Action String](#action-syntax) and executes it immediately – exactly the same mechanism that is used for push-notification actions. + +#### Setting up the Command Item + +1. Create a String Item in openHAB, for example: + +```items +String Tablet_Command "iOS TabletCommand" +``` + + Any existing String/Text Item will also work – just remember its name. + +2. In the iOS app open **Settings → Application Settings → Command Item** and pick the Item you just created. + The list shown in the picker is populated from your openHAB instance. + +3. Send actions by updating the Item. + +Example rules: +```rules +Tablet_Command.postUpdate("device:screensaver:activate") // start the screensaver +Tablet_Command.postUpdate("device:brightness:0.3") // set brightness to 30 % +Tablet_Command.postUpdate("device:tts:Hello there!:en-US:Samantha") // speak "Hello there!" using the Samantha voice for the en-US language +Tablet_Command.postUpdate("ui:/basicui/app?w=0000&sitemap=main") // open a sitemap page +Tablet_Command.postUpdate("ui:navigate:/page/my_floorplan_page") // navigate to a page in the Main UI +Tablet_Command.postUpdate("command:KitchenLights:ON") // send ON to KitchenLights +``` + +See the [Action Syntax](#action-syntax) appendix for more information on the syntax used to send actions to the app. + +Notes: +* The app receives Item updates via Server-Sent Events, and will only receive updates if the app is running in the foreground (like kiosk applications) +* If the app is unable to connect to the server and a SSE connection is not established, it will continue to try and reconnect, but messages sent to the app during this time will be lost. + +--- + ### Crash Reporting + Sends crash reports to Google / Firebase. ### Main UI Settings #### WebRTC + Allows audio and video communications in the Main UI for views and widgets that require it. #### Default Path + Allows the user to enter a path to act as the starting point when the Main UI is loaded. Clicking the "+" button will prompt to enter the current path the of Main UI view. @@ -150,6 +218,10 @@ Push Notifications on iOS support: - Collapsible / updated notifications - Removing notifications +See [Cloud Push Notifications](https://www.openhab.org/addons/integrations/openhabcloud/#cloud-notification-actions) for more information on sending push notifications from rules. + +Also see [Action Syntax](#action-syntax) for more information on actions that can be included in push notifications. + ## Widgets Coming soon ! @@ -174,6 +246,251 @@ And also please support with the localization of openhab-ios: [![Crowdin](https://badges.crowdin.net/openhab-ios/localized.svg)](https://crowdin.com/project/openhab-ios) +## Appendix + +### Action Syntax + +The action syntax is a string containing the action type and the action payload separated by a colon. + +There are several types of actions available: + +- `command`: Sends a command to an Item by using the following syntax: `command:$itemName:$commandString` where `$itemName` is the name of the Item and `$commandString` is the command to be sent. +- `ui`: Controls the UI in two possible ways: + - `ui:$path` where `$path` is either `/basicui/app?...` for navigating sitemaps (using the native renderer) or `/some/absolute/path` for navigating (using the web view). + - `ui:$commandItemSyntax` where `$commandItemSyntax` is the same syntax as used for the [UI Command Item]({{base}}/mainui/about.html#ui-command-item). +- `http:` or `https:`: Opens the fully qualified URL in an embedded browser on the device. +- `rule` (currently only on iOS): Runs a rule by using the following syntax: `rule:$ruleId:$prop1Key=$prop1Value,$prop2Key=$prop2Value,...` where `$ruleId` is the id of the rule, and optional properties to send to the rule are in the format `name=value` separated by commas. Most rules can omit the properties. +- `app` (currently only on iOS): Launches a native app when possible using the following syntax: `app:android=$appId,ios=$appId:$path` where `$appId` on Android is a qualified app id like `com.acme.app` (see [partial list of Android ids](https://github.com/petarov/google-android-app-ids)), and on iOS is the registered URL scheme along with an optional `$path` like `acme://foo` (see [partial list of iOS ids](https://github.com/bhagyas/app-urls)). Either `android` or `ios` can be omitted if that platform is not used. +- `device`: Performs actions on the iOS device itself using the syntax `device:$operation[:$value]`. Supported operations include: + - `screensaver:activate` – activate the screensaver + - `screensaver:disable` – disables the screensaver from running (call activate to re-enable) + - `screensaver:wake` – wakes the screensaver if running + - `idletimer:enable` – allow the device to auto-lock/dim again + - `idletimer:disable` – keep the screen awake (useful for wall-mounted tablets) + - `brightness:<0-1>` – set the display brightness, e.g. `brightness:0.25` + - `tts:$text[:$language[:$voice]]` – text-to-speech; speaks `$text`. + - `$language` (optional) – BCP-47 language tag such as `en-US`. + - `$voice` (optional) – exact voice name reported by iOS (`AVSpeechSynthesisVoice`). + - See [TTS Example Voices](#tts-example-voices) for a list of available voices and languages. + +Examples: + +- `command:KitchenLights:ON` +- `command:KitchenBlinds:50` +- `ui:/basicui/app?w=0000&sitemap=main` (use Basic UI to get sitemap URL locations) +- `ui:/some/absolute/path`: Navigates to the absolut path `/some/absolute/path`. +- `ui:navigate:/page/my_floorplan_page`: Navigates Main UI to the page with the ID `my_floorplan_page`. +- `ui:popup:oh-clock-card`: Opens a popup with `oh-clock-card`. +- `https://openhab.org`: Opens an embedded browser to the site `https://openhab.org` +- `rule:02ffc3a297:prop1=foo`: Runs the rule with an id of `02ffc3a297` passing in an optional parameter named `prop1` with a value of `foo` +- `app:android=com.sonos.acr2,ios=sonos-2://`: Opens the Sonos app depending on the device type (Android or iOS) +- `device:screensaver:activate`: Activates the device screen saver +- `device:brightness:0.3`: Sets the screen brightness to 30 % +- `device:tts:Hello there!:en-US:Samantha`: Speaks “Hello there!” using the Samantha voice for the en-US language +- `device:tts:Hello there!`: Speaks “Hello there!” using the default system voice and language + +### TTS Example Voices + +This is an example (partial) list of voices available on iOS. +See [AVSpeechSynthesisVoice](https://developer.apple.com/documentation/avfaudio/avspeechsynthesisvoice) for more information. + +Additional voices, including custom and novelty voices can be downloaded in the iOS system settings. + +| Voice Name | Language | +|------------|----------| +| Majed | ar-001 | +| Daria | bg-BG | +| Montse | ca-ES | +| Zuzana | cs-CZ | +| Sara | da-DK | +| Sandy | de-DE | +| Helena | de-DE | +| Shelley | de-DE | +| Grandma | de-DE | +| Grandpa | de-DE | +| Eddy | de-DE | +| Reed | de-DE | +| Martin | de-DE | +| Anna | de-DE | +| Rocko | de-DE | +| Flo | de-DE | +| Melina | el-GR | +| Gordon | en-AU | +| Karen | en-AU | +| Catherine | en-AU | +| Rocko | en-GB | +| Shelley | en-GB | +| Martha | en-GB | +| Daniel | en-GB | +| Grandma | en-GB | +| Grandpa | en-GB | +| Flo | en-GB | +| Eddy | en-GB | +| Reed | en-GB | +| Sandy | en-GB | +| Arthur | en-GB | +| Moira | en-IE | +| Rishi | en-IN | +| Flo | en-US | +| Bahh | en-US | +| Albert | en-US | +| Fred | en-US | +| Jester | en-US | +| Organ | en-US | +| Cellos | en-US | +| Zarvox | en-US | +| Rocko | en-US | +| Shelley | en-US | +| Superstar | en-US | +| Grandma | en-US | +| Eddy | en-US | +| Bells | en-US | +| Grandpa | en-US | +| Trinoids | en-US | +| Kathy | en-US | +| Reed | en-US | +| Boing | en-US | +| Whisper | en-US | +| Good News | en-US | +| Nicky | en-US | +| Wobble | en-US | +| Bad News | en-US | +| Aaron | en-US | +| Bubbles | en-US | +| Samantha | en-US | +| Sandy | en-US | +| Junior | en-US | +| Ralph | en-US | +| Tessa | en-ZA | +| Shelley | es-ES | +| Grandma | es-ES | +| Rocko | es-ES | +| Grandpa | es-ES | +| Sandy | es-ES | +| Flo | es-ES | +| Mónica | es-ES | +| Eddy | es-ES | +| Reed | es-ES | +| Rocko | es-MX | +| Paulina | es-MX | +| Flo | es-MX | +| Sandy | es-MX | +| Eddy | es-MX | +| Shelley | es-MX | +| Grandma | es-MX | +| Reed | es-MX | +| Grandpa | es-MX | +| Shelley | fi-FI | +| Grandma | fi-FI | +| Grandpa | fi-FI | +| Sandy | fi-FI | +| Satu | fi-FI | +| Eddy | fi-FI | +| Rocko | fi-FI | +| Reed | fi-FI | +| Flo | fi-FI | +| Shelley | fr-CA | +| Grandma | fr-CA | +| Grandpa | fr-CA | +| Rocko | fr-CA | +| Eddy | fr-CA | +| Reed | fr-CA | +| Amélie | fr-CA | +| Flo | fr-CA | +| Sandy | fr-CA | +| Grandma | fr-FR | +| Flo | fr-FR | +| Rocko | fr-FR | +| Grandpa | fr-FR | +| Sandy | fr-FR | +| Eddy | fr-FR | +| Daniel | fr-FR | +| Thomas | fr-FR | +| Jacques | fr-FR | +| Marie | fr-FR | +| Shelley | fr-FR | +| Carmit | he-IL | +| Lekha | hi-IN | +| Lana | hr-HR | +| Tünde | hu-HU | +| Damayanti | id-ID | +| Eddy | it-IT | +| Sandy | it-IT | +| Reed | it-IT | +| Shelley | it-IT | +| Grandma | it-IT | +| Grandpa | it-IT | +| Flo | it-IT | +| Rocko | it-IT | +| Alice | it-IT | +| Eddy | ja-JP | +| Reed | ja-JP | +| Hattori | ja-JP | +| Shelley | ja-JP | +| Kyoko | ja-JP | +| Grandma | ja-JP | +| Rocko | ja-JP | +| Grandpa | ja-JP | +| O-ren | ja-JP | +| Sandy | ja-JP | +| Flo | ja-JP | +| Rocko | ko-KR | +| Grandma | ko-KR | +| Grandpa | ko-KR | +| Eddy | ko-KR | +| Sandy | ko-KR | +| Yuna | ko-KR | +| Reed | ko-KR | +| Flo | ko-KR | +| Shelley | ko-KR | +| Amira | ms-MY | +| Nora | nb-NO | +| Ellen | nl-BE | +| Xander | nl-NL | +| Zosia | pl-PL | +| Reed | pt-BR | +| Luciana | pt-BR | +| Shelley | pt-BR | +| Grandma | pt-BR | +| Grandpa | pt-BR | +| Rocko | pt-BR | +| Flo | pt-BR | +| Sandy | pt-BR | +| Eddy | pt-BR | +| Joana | pt-PT | +| Ioana | ro-RO | +| Milena | ru-RU | +| Laura | sk-SK | +| Tina | sl-SI | +| Alva | sv-SE | +| Vani | ta-IN | +| Kanya | th-TH | +| Yelda | tr-TR | +| Lesya | uk-UA | +| Linh | vi-VN | +| Eddy | zh-CN | +| Shelley | zh-CN | +| Grandma | zh-CN | +| Reed | zh-CN | +| Grandpa | zh-CN | +| Rocko | zh-CN | +| Yu-shu | zh-CN | +| Flo | zh-CN | +| Tingting | zh-CN | +| Li-mu | zh-CN | +| Sandy | zh-CN | +| Sinji | zh-HK | +| Shelley | zh-TW | +| Grandma | zh-TW | +| Grandpa | zh-TW | +| Sandy | zh-TW | +| Flo | zh-TW | +| Eddy | zh-TW | +| Reed | zh-TW | +| Meijia | zh-TW | +| Rocko | zh-TW | + + ## Trademark Disclaimer Product names, logos, brands and other trademarks referred to within the openHAB website are the diff --git a/openHAB.xcodeproj/.project.pbxproj.swp b/openHAB.xcodeproj/.project.pbxproj.swp deleted file mode 100644 index c5d9a576deebaf4568d01f81bbac3c133dc3531b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16384 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 U5*}rwfpk%&G+k=i`u_X$U*mgWk^lez diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index f1c6c29ca..9ec78983b 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -29,6 +29,7 @@ 657144552C1E438700C8A1F3 /* NotificationService.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 6571444E2C1E438700C8A1F3 /* NotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 657144962C30A16700C8A1F3 /* OpenHABCore in Frameworks */ = {isa = PBXBuildFile; productRef = 657144952C30A16700C8A1F3 /* OpenHABCore */; }; 65C2EF492E244C8500A0C19F /* OpenHABNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65C2EF482E244C8500A0C19F /* OpenHABNavigationController.swift */; }; + 65F055442E3D4E41004E98FE /* ItemSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65F055432E3D4E41004E98FE /* ItemSelectionView.swift */; }; 932602EE2382892B00EAD685 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DAC6608B236F6F4200F4501E /* Assets.xcassets */; }; 933D7F0722E7015100621A03 /* OpenHABUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 933D7F0622E7015000621A03 /* OpenHABUITests.swift */; }; 933D7F0F22E7030600621A03 /* SnapshotHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 933D7F0E22E7030600621A03 /* SnapshotHelper.swift */; }; @@ -339,6 +340,7 @@ 657144522C1E438700C8A1F3 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 657144972C30A3E300C8A1F3 /* NotificationService.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = NotificationService.entitlements; sourceTree = ""; }; 65C2EF482E244C8500A0C19F /* OpenHABNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenHABNavigationController.swift; sourceTree = ""; }; + 65F055432E3D4E41004E98FE /* ItemSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemSelectionView.swift; sourceTree = ""; }; 931384B324F259BC00A73AB5 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = ""; }; 931384B424F259BD00A73AB5 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/InfoPlist.strings; sourceTree = ""; }; 931384BB24F2691B00A73AB5 /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/Localizable.strings; sourceTree = ""; }; @@ -880,6 +882,7 @@ DA48001F2D837CD8009CF127 /* SettingsView */ = { isa = PBXGroup; children = ( + 65F055432E3D4E41004E98FE /* ItemSelectionView.swift */, 652B81032E2193B500648510 /* ScreenSaverSettingsView.swift */, DA4800172D837221009CF127 /* AboutSettingsView.swift */, DA48001D2D837905009CF127 /* ApplicationSettingsView.swift */, @@ -1779,6 +1782,7 @@ 653B54C2285E714900298ECD /* OpenHABViewController.swift in Sources */, 65C2EF492E244C8500A0C19F /* OpenHABNavigationController.swift in Sources */, DA2AEB6E2D92BAD800897D80 /* PageLoader.swift in Sources */, + 65F055442E3D4E41004E98FE /* ItemSelectionView.swift in Sources */, DFA16EC118898A8400EDB0BB /* SegmentedUITableViewCell.swift in Sources */, DAF0A28D2C56EF8900A14A6A /* SetpointCell.swift in Sources */, DAEAA89D21E6B06400267EA3 /* ReusableView.swift in Sources */, @@ -2362,6 +2366,7 @@ CURRENT_PROJECT_VERSION = 50; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = D6A95UZXVC; + DEVELOPMENT_TEAM = PBAPXHRAM9; "DEVELOPMENT_TEAM[sdk=watchos*]" = PBAPXHRAM9; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; diff --git a/openHAB/OpenHABRootViewController.swift b/openHAB/OpenHABRootViewController.swift index 2b41c0ead..9e0f774d8 100644 --- a/openHAB/OpenHABRootViewController.swift +++ b/openHAB/OpenHABRootViewController.swift @@ -9,6 +9,7 @@ // // SPDX-License-Identifier: EPL-2.0 +import AVFoundation import Combine import FirebaseCrashlytics import Foundation @@ -112,6 +113,7 @@ class OpenHABRootViewController: UIViewController { var currentView: (any UIViewController & OpenHABViewable)! var isDemoMode = false var cancellables = Set() + private var streamTask: Task? private var apsRegistrationData: [AnyHashable: Any]? @@ -124,6 +126,7 @@ class OpenHABRootViewController: UIViewController { lazy var sitemapViewController: any (UIViewController & OpenHABViewable) = HostingSitemapViewController() private var activeConnection: ConnectionInfo? + private let synthesizer = AVSpeechSynthesizer() override func viewDidLoad() { super.viewDidLoad() @@ -169,6 +172,7 @@ class OpenHABRootViewController: UIViewController { isDemoMode = Preferences.currentHomePreferences.demomode switchToSavedView() setupTracker() + startSSEListening() } override func viewWillAppear(_ animated: Bool) { @@ -182,6 +186,38 @@ class OpenHABRootViewController: UIViewController { } } + private func startSSEListening() { + ItemEventStream.startMonitoringNetwork() + print("Starting SSE") + streamTask = Task { [weak self] in + guard let self else { return } + for await msg in await ItemEventStream.shared.stream() { + await MainActor.run { self.handleSSEMessage(msg) } + } + } + } + + private func handleSSEMessage(_ msg: StreamOutput) { + switch msg { + case .connected: + print("SSE Connected") + case let .disconnected(err): + print("SSE Disconnected:", err ?? "nil") + case let .event(sm): + switch sm { + case let .state(item, state): + print("SSE Item \(item): \(state)") + handleNotificationInternal(state) + case let .ready(uuid, _): + print("SSE Session UUID:", uuid) + case let .alive(interval): + print("SSE Heartbeat interval:", interval, "s") + case let .unknown(raw): + print("SSE Unknown:", raw) + } + } + } + fileprivate func setupTracker() { let serverInfo = Preferences.$currentHomePreferences @@ -253,6 +289,7 @@ class OpenHABRootViewController: UIViewController { let localConnectionConfig = homeSettings.localConnectionConfig let remoteConnectionConfig = homeSettings.remoteConnectionConfig let demomode = homeSettings.demomode + let sseCommandItem = homeSettings.sseCommandItem Task { if demomode { @@ -269,6 +306,7 @@ class OpenHABRootViewController: UIViewController { localConnectionConfig, remoteConnectionConfig ]) + await ItemEventStream.trackItems(sseCommandItem.isEmpty ? [] : [sseCommandItem]) } } } @@ -504,9 +542,11 @@ class OpenHABRootViewController: UIViewController { case action.hasPrefix("http"): httpCommandAction(action) case action.hasPrefix("app"): - appCommandAction(action) + appCommandAction(cmd) case action.hasPrefix("rule"): - ruleCommandAction(action) + ruleCommandAction(cmd) + case action.hasPrefix("device"): + deviceAction(cmd) default: return } @@ -604,11 +644,9 @@ class OpenHABRootViewController: UIViewController { } private func appCommandAction(_ command: String) { - let content = command.dropFirst(4) // Remove "app:" - let pairs = content.split(separator: ",") + let pairs = command.split(separator: ",") for pair in pairs { let keyValue = pair.split(separator: "=", maxSplits: 1) - guard keyValue.count == 2 else { continue } if keyValue[0] == "ios" { if let url = URL(string: String(keyValue[1])) { logger.error("appCommandAction opening \(String(keyValue[0])) \(String(keyValue[1]))") @@ -619,13 +657,69 @@ class OpenHABRootViewController: UIViewController { } } + private func deviceAction(_ action: String) { + let cmdParts = action.split(separator: ":") + if cmdParts.isEmpty { return } + let command = cmdParts[0].lowercased() + let arg1 = cmdParts.count > 1 ? cmdParts[1].lowercased() : "" + switch command { + case "screensaver": + switch arg1 { + case "activate": + NotificationCenter.default.post(name: .activateScreenSaver, object: nil) + case "disable": + NotificationCenter.default.post(name: .disableScreenSaver, object: nil) + case "wake": + NotificationCenter.default.post(name: .wakeScreenSaver, object: nil) + default: + break + } + case "idletimer": + switch arg1 { + case "enable": + UIApplication.shared.isIdleTimerDisabled = false + case "disable": + UIApplication.shared.isIdleTimerDisabled = true + default: + break + } + case "brightness": + if let value = Double(arg1) { + let target = min(max(value, 0.0), 1.0) + UIScreen.main.brightness = target + } + case "tts": + func normalizeVoiceName(from input: String) -> String { + input + .lowercased() + .components(separatedBy: CharacterSet.alphanumerics.inverted) + .joined() + } + + let utterance = AVSpeechUtterance(string: arg1) + if cmdParts.count > 3 { + print("Filtering voice \(cmdParts[2]) \(cmdParts[3])") + let voice = AVSpeechSynthesisVoice.speechVoices().filter { $0.language.lowercased() == cmdParts[2].lowercased() && normalizeVoiceName(from: $0.name) == normalizeVoiceName(from: String(cmdParts[3])) } + if !voice.isEmpty { + print("setting custom voice \(voice[0].name)") + utterance.voice = voice[0] + } + } else if cmdParts.count > 2 { + utterance.voice = AVSpeechSynthesisVoice(language: String(cmdParts[2])) + } + synthesizer.speak(utterance) + default: + break + } + } + private func ruleCommandAction(_ command: String) { let components = command.split(separator: ":", maxSplits: 2) - guard components.count == 3, components[0] == "rule" else { return } + guard components.count == 2 else { return } - let uuid = String(components[1]) - let propertiesString = String(components[2]) + let uuid = String(components[0]) + let propertiesString = String(components[1]) let propertyPairs = propertiesString.split(separator: ",") var properties: [String: String] = [:] diff --git a/openHAB/ScreenSaver/ScreenSaverManager.swift b/openHAB/ScreenSaver/ScreenSaverManager.swift index 83c186066..80513ecf7 100644 --- a/openHAB/ScreenSaver/ScreenSaverManager.swift +++ b/openHAB/ScreenSaver/ScreenSaverManager.swift @@ -39,6 +39,7 @@ final class ScreenSaverManager: NSObject { super.init() NotificationCenter.default.addObserver(self, selector: #selector(handleDisableNotification), name: .disableScreenSaver, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(handleWakeNotification), name: .wakeScreenSaver, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(handleActivateNotification), name: .activateScreenSaver, object: nil) } func startMonitoring(window: UIWindow, configuration: ScreenSaverConfiguration = ScreenSaverConfiguration()) { @@ -196,12 +197,19 @@ final class ScreenSaverManager: NSObject { resetIdleTimer() dismissSaverIfNeeded() } + + @objc private func handleActivateNotification() { + logger.debug("Received activate screen saver notification") + dismissSaverIfNeeded() + showSaver() + } } /// Notifications that other parts of the app can send to control the screensaver extension Notification.Name { static let disableScreenSaver = Notification.Name("disableScreenSaver") static let wakeScreenSaver = Notification.Name("wakeScreenSaver") + static let activateScreenSaver = Notification.Name("activateScreenSaver") } extension ScreenSaverManager: UIGestureRecognizerDelegate { diff --git a/openHAB/SettingsView/ApplicationSettingsView.swift b/openHAB/SettingsView/ApplicationSettingsView.swift index 6483b722a..7b1487422 100644 --- a/openHAB/SettingsView/ApplicationSettingsView.swift +++ b/openHAB/SettingsView/ApplicationSettingsView.swift @@ -16,8 +16,10 @@ import UIKit struct ApplicationSettingsView: View { @Binding var settingsIdleOff: Bool + @Binding var settingsSSECommandItem: String private let logger = Logger(subsystem: "org.openhab.app", category: "ApplicationSettingsView") + @State private var selectedItemName: String? = nil var body: some View { Section(header: Text(LocalizedStringKey("application_settings"))) { @@ -35,6 +37,26 @@ struct ApplicationSettingsView: View { NavigationLink("Client Certificates") { ClientCertificatesView() } + + NavigationLink { + ItemSelectionView(selectedItemName: $selectedItemName) + } label: { + Text("Command Item \(selectedItemName?.isEmpty == false ? "(" + selectedItemName! + ")" : "")") + } + } + .onChange(of: selectedItemName) { newSelection in + handleItemSelectionChange(newSelection) + } + .onAppear { + selectedItemName = settingsSSECommandItem + } + } + + private func handleItemSelectionChange(_ selected: String?) { + if let selected { + settingsSSECommandItem = selected + } else { + settingsSSECommandItem = "" } } } @@ -42,11 +64,12 @@ struct ApplicationSettingsView: View { #Preview { struct PreviewWrapper: View { @State private var idleOff = false - + @State private var sseCommandItem = "" var body: some View { Form { ApplicationSettingsView( - settingsIdleOff: $idleOff + settingsIdleOff: $idleOff, + settingsSSECommandItem: $sseCommandItem ) } } diff --git a/openHAB/SettingsView/ItemSelectionView.swift b/openHAB/SettingsView/ItemSelectionView.swift new file mode 100644 index 000000000..4db02efd2 --- /dev/null +++ b/openHAB/SettingsView/ItemSelectionView.swift @@ -0,0 +1,75 @@ +// Copyright (c) 2010-2025 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 + +// +// ItemSelectionView.swift +// openHAB +// + +import OpenHABCore +import os +import SwiftUI + +struct ItemSelectionView: View { + @Binding var selectedItemName: String? + @State private var items: [OpenHABItem] = [] + + @State private var searchText = "" + @State private var isLoading = true + + private var filteredItems: [OpenHABItem] { + if searchText.isEmpty { return items } + return items.filter { item in + (item.label.localizedCaseInsensitiveContains(searchText)) || + item.name.localizedCaseInsensitiveContains(searchText) + } + } + + var body: some View { + VStack { + if isLoading { + Spacer() + ProgressView("Loading Items…") + Spacer() + } else { + TextField("Search", text: $searchText) + .textFieldStyle(.roundedBorder) + .padding(.horizontal) + + List { + ForEach(filteredItems, id: \.name) { item in + Button { + selectedItemName = (selectedItemName == item.name) ? nil : item.name + } label: { + HStack { + Text(item.name) + Spacer() + if selectedItemName == item.name { + Image(systemName: "checkmark") + } + } + } + } + } + } + } + .navigationTitle("Items") + .onAppear { + Task { + do { + items = try await NetworkTracker.shared.getItems().filter { $0.type == .stringItem } + } catch { + } + isLoading = false + } + } + } +} diff --git a/openHAB/SettingsView/SettingsView.swift b/openHAB/SettingsView/SettingsView.swift index 6460361cc..96501a70d 100644 --- a/openHAB/SettingsView/SettingsView.swift +++ b/openHAB/SettingsView/SettingsView.swift @@ -30,6 +30,7 @@ struct SettingsView: View { @State var settingsRemoteConnectionConfiguration = ConnectionConfiguration(url: "", username: "", password: "") @State var settingsHomeName = "" @State var viewAppearedOnce = false + @State var settingsSSECommandItem = "" @Environment(\.dismiss) private var dismiss @@ -44,7 +45,8 @@ struct SettingsView: View { ) ApplicationSettingsView( - settingsIdleOff: $settingsIdleOff + settingsIdleOff: $settingsIdleOff, + settingsSSECommandItem: $settingsSSECommandItem ) MainUISettingsView( @@ -129,6 +131,7 @@ struct SettingsView: View { settingsLocalConnectionConfiguration = Preferences.currentHomePreferences.localConnectionConfig settingsRemoteConnectionConfiguration = Preferences.currentHomePreferences.remoteConnectionConfig settingsHomeName = Preferences.currentHomePreferences.homeName + settingsSSECommandItem = Preferences.currentHomePreferences.sseCommandItem } func saveSettings() { @@ -142,6 +145,7 @@ struct SettingsView: View { homePreferences.sitemapForWatchLabel = sitemaps.first { $0.name == settingsSitemapForWatch }?.label ?? "unknown" homePreferences.localConnectionConfig = settingsLocalConnectionConfiguration homePreferences.remoteConnectionConfig = settingsRemoteConnectionConfiguration + homePreferences.sseCommandItem = settingsSSECommandItem } Preferences.idleOff = settingsIdleOff Preferences.sendCrashReports = settingsSendCrashReports From 7f56d9b465679ee03ea760409302fdd2b819f59c Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Sun, 3 Aug 2025 13:01:18 +0200 Subject: [PATCH 312/476] Import SFSafeSymbols into ItemSelectionView Signed-off-by: Tim Mueller-Seydlitz --- openHAB/SettingsView/ItemSelectionView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/openHAB/SettingsView/ItemSelectionView.swift b/openHAB/SettingsView/ItemSelectionView.swift index 05306d2e0..8aacb74a9 100644 --- a/openHAB/SettingsView/ItemSelectionView.swift +++ b/openHAB/SettingsView/ItemSelectionView.swift @@ -16,6 +16,7 @@ import OpenHABCore import os +import SFSafeSymbols import SwiftUI struct ItemSelectionView: View { From 44e3bb43a19178f6ed0d6ed5673cfdd88de71462 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Sun, 3 Aug 2025 13:01:38 +0200 Subject: [PATCH 313/476] Import SFSafeSymbols into ItemSelectionView Signed-off-by: Tim Mueller-Seydlitz --- openHAB/SettingsView/ItemSelectionView.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/openHAB/SettingsView/ItemSelectionView.swift b/openHAB/SettingsView/ItemSelectionView.swift index 8aacb74a9..42e42a246 100644 --- a/openHAB/SettingsView/ItemSelectionView.swift +++ b/openHAB/SettingsView/ItemSelectionView.swift @@ -67,8 +67,7 @@ struct ItemSelectionView: View { Task { do { items = try await NetworkTracker.shared.getItems().filter { $0.type == .stringItem } - } catch { - } + } catch {} isLoading = false } } From e6e3d0ff92a5ccdb2126995d81b6b90e028bf578 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Sun, 3 Aug 2025 16:37:55 +0200 Subject: [PATCH 314/476] implementing some changes suggested by Copilot review Signed-off-by: Tim Mueller-Seydlitz --- openHAB/SettingsView/ItemSelectionView.swift | 6 +++++- openHAB/SettingsView/MainUISettingsView.swift | 4 ++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/openHAB/SettingsView/ItemSelectionView.swift b/openHAB/SettingsView/ItemSelectionView.swift index 42e42a246..65b810b33 100644 --- a/openHAB/SettingsView/ItemSelectionView.swift +++ b/openHAB/SettingsView/ItemSelectionView.swift @@ -26,6 +26,8 @@ struct ItemSelectionView: View { @State private var searchText = "" @State private var isLoading = true + private let logger = Logger(subsystem: "org.openhab.app", category: "ItemSelectionView") + private var filteredItems: [OpenHABItem] { if searchText.isEmpty { return items } return items.filter { item in @@ -67,7 +69,9 @@ struct ItemSelectionView: View { Task { do { items = try await NetworkTracker.shared.getItems().filter { $0.type == .stringItem } - } catch {} + } catch { + logger.error("Failed to load items: \(error.localizedDescription)") + } isLoading = false } } diff --git a/openHAB/SettingsView/MainUISettingsView.swift b/openHAB/SettingsView/MainUISettingsView.swift index f8a845710..f34d124a9 100644 --- a/openHAB/SettingsView/MainUISettingsView.swift +++ b/openHAB/SettingsView/MainUISettingsView.swift @@ -58,9 +58,9 @@ struct MainUISettingsView: View { } Button { - let websiteDataTypes = NSSet(array: [WKWebsiteDataTypeDiskCache, WKWebsiteDataTypeMemoryCache]) + let websiteDataTypes: Set = [WKWebsiteDataTypeDiskCache, WKWebsiteDataTypeMemoryCache] let date = Date(timeIntervalSince1970: 0) - WKWebsiteDataStore.default().removeData(ofTypes: websiteDataTypes as! Set, modifiedSince: date) {} + WKWebsiteDataStore.default().removeData(ofTypes: websiteDataTypes, modifiedSince: date) {} showingCacheAlert = true } label: { NavigationLink("Clear Web Cache", destination: EmptyView()) From 79df2b4cb43c0aa931e40de2869722f1cf1da496 Mon Sep 17 00:00:00 2001 From: Dan Cunningham Date: Sun, 3 Aug 2025 09:05:19 -0700 Subject: [PATCH 315/476] Co-pilot feedback Signed-off-by: Dan Cunningham --- openHAB/OpenHABWebViewController.swift | 10 +++++----- openHAB/SettingsView/ApplicationSettingsView.swift | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/openHAB/OpenHABWebViewController.swift b/openHAB/OpenHABWebViewController.swift index a78f2e015..0f8038969 100644 --- a/openHAB/OpenHABWebViewController.swift +++ b/openHAB/OpenHABWebViewController.swift @@ -293,16 +293,16 @@ class OpenHABWebViewController: OpenHABViewController { webview.uiDelegate = self // Ensure the newly created webview resizes properly on rotation webview.autoresizingMask = [.flexibleWidth, .flexibleHeight] - webView.scrollView.bounces = false + webview.scrollView.bounces = false // support dark mode and avoid white flashing when loading - webView.isOpaque = false - webView.backgroundColor = UIColor.clear + webview.isOpaque = false + webview.backgroundColor = UIColor.clear if UIDevice.current.userInterfaceIdiom == .pad { // since ios 13 Safari sets the user agent to desktop mode on iPads so the view renders correctly with larger screens - webView.customUserAgent = "Mozilla/5.0 (iPad; CPU OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1" + webview.customUserAgent = "Mozilla/5.0 (iPad; CPU OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1" } if #available(iOS 16.4, *) { - webView.isInspectable = true + webview.isInspectable = true } if #unavailable(iOS 17) { diff --git a/openHAB/SettingsView/ApplicationSettingsView.swift b/openHAB/SettingsView/ApplicationSettingsView.swift index 7b1487422..226757c54 100644 --- a/openHAB/SettingsView/ApplicationSettingsView.swift +++ b/openHAB/SettingsView/ApplicationSettingsView.swift @@ -41,7 +41,7 @@ struct ApplicationSettingsView: View { NavigationLink { ItemSelectionView(selectedItemName: $selectedItemName) } label: { - Text("Command Item \(selectedItemName?.isEmpty == false ? "(" + selectedItemName! + ")" : "")") + Text("Command Item \(selectedItemName?.isEmpty == false ? "(\(selectedItemName ?? ""))" : "")") } } .onChange(of: selectedItemName) { newSelection in From fc7d2fe01689318831b1ba82d21403c67df23103 Mon Sep 17 00:00:00 2001 From: Dan Cunningham Date: Sun, 3 Aug 2025 09:42:10 -0700 Subject: [PATCH 316/476] Track path changed in the MainUI SPA Signed-off-by: Dan Cunningham --- openHAB/OpenHABWebViewController.swift | 64 ++++++++++++++++++-------- 1 file changed, 46 insertions(+), 18 deletions(-) diff --git a/openHAB/OpenHABWebViewController.swift b/openHAB/OpenHABWebViewController.swift index 0f8038969..aaa3cde7f 100644 --- a/openHAB/OpenHABWebViewController.swift +++ b/openHAB/OpenHABWebViewController.swift @@ -32,20 +32,45 @@ class OpenHABWebViewController: OpenHABViewController { private var myOhViews: [UUID: WKWebView] = [:] private var js = """ - window.OHApp = { - exitToApp : function(){ - window.webkit.messageHandlers.Native.postMessage('exitToApp'); - }, - goFullscreen : function(){ - window.webkit.messageHandlers.Native.postMessage('goFullscreen'); - }, - sseConnected : function(connected) { - window.webkit.messageHandlers.Native.postMessage('sseConnected-' + connected); - }, - ready : function() { - window.webkit.messageHandlers.Native.postMessage('ready'); - }, - } + (function() { + // Main UI Callbacks + window.OHApp = { + exitToApp : function(){ + window.webkit.messageHandlers.mainUi.postMessage('exitToApp'); + }, + goFullscreen : function(){ + window.webkit.messageHandlers.mainUi.postMessage('goFullscreen'); + }, + sseConnected : function(connected) { + window.webkit.messageHandlers.mainUi.postMessage('sseConnected-' + connected); + }, + ready : function() { + window.webkit.messageHandlers.mainUi.postMessage('ready'); + }, + } + + // Detect Path changes in SPA + function notifyPathChange() { + window.webkit.messageHandlers.pathChanged.postMessage(window.location.pathname); + } + + const originalPushState = history.pushState; + history.pushState = function() { + originalPushState.apply(this, arguments); + notifyPathChange(); + }; + + const originalReplaceState = history.replaceState; + history.replaceState = function() { + originalReplaceState.apply(this, arguments); + notifyPathChange(); + }; + + window.addEventListener('popstate', notifyPathChange); + + // Notify initial path on load + notifyPathChange(); + })(); """ override open var shouldAutorotate: Bool { @@ -151,10 +176,8 @@ class OpenHABWebViewController: OpenHABViewController { webView = newWebview view.addSubview(newWebview) } - // DispatchQueue.main.async { logger.info("Loading URL: \(modifiedUrl)") webView.load(request) - // } } } @@ -277,7 +300,8 @@ class OpenHABWebViewController: OpenHABViewController { config.allowsInlineMediaPlayback = true config.mediaTypesRequiringUserActionForPlayback = [] // adds: window.webkit.messageHandlers.xxxx.postMessage to JS env - config.userContentController.add(self, name: "Native") + config.userContentController.add(self, name: "mainUi") + config.userContentController.add(self, name: "pathChanged") config.userContentController.addUserScript(WKUserScript(source: js, injectionTime: .atDocumentStart, forMainFrameOnly: false)) // iOS 17 allows Sandboxed profiles, which is fantastic, iOS 16 does not and agressively caches everything @@ -320,7 +344,11 @@ extension OpenHABWebViewController: WKScriptMessageHandler { @MainActor func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { logger.info("WKScriptMessage \(message.name)") - if let callbackName = message.body as? String { + if message.name == "pathChanged", let newPath = message.body as? String { + print("path changed to: \(newPath)") + Preferences.currentWebViewPath = newPath + } + if message.name == "mainUi", let callbackName = message.body as? String { logger.info("WKScriptMessage \(callbackName)") switch callbackName { case "exitToApp": From 76bea2c09e650add51fd95ec067a9436083cafc1 Mon Sep 17 00:00:00 2001 From: Dan Cunningham Date: Sun, 3 Aug 2025 11:41:31 -0700 Subject: [PATCH 317/476] Update README Signed-off-by: Dan Cunningham --- README.md | 119 ++++++++++++++++++++++++++--------- doc/homes.png | Bin 0 -> 107743 bytes doc/screensaver-settings.png | Bin 0 -> 484183 bytes doc/settings1.jpeg | Bin 224880 -> 0 bytes doc/settings1.png | Bin 0 -> 547576 bytes doc/settings2.jpeg | Bin 430686 -> 0 bytes doc/settings2.png | Bin 0 -> 516573 bytes doc/sidemenu.jpeg | Bin 176629 -> 0 bytes doc/sidemenu.png | Bin 0 -> 218554 bytes 9 files changed, 88 insertions(+), 31 deletions(-) create mode 100644 doc/homes.png create mode 100644 doc/screensaver-settings.png delete mode 100644 doc/settings1.jpeg create mode 100644 doc/settings1.png delete mode 100644 doc/settings2.jpeg create mode 100644 doc/settings2.png delete mode 100644 doc/sidemenu.jpeg create mode 100644 doc/sidemenu.png diff --git a/README.md b/README.md index 05dca72c7..4b0cf6532 100644 --- a/README.md +++ b/README.md @@ -17,10 +17,11 @@ This is the IOS native client for openHAB. ### openHAB (Current) -This is the primary openHAB app which contains the latest features and is updated regularly. This includes Apple Watch support, enhanced notifications, widgets and more. +This is the primary openHAB app which contains the latest features and is updated regularly. This includes Apple Watch support, enhanced notifications, shortcuts and more. Requires at least iOS 16 and openHAB 2.x and later. -(Official App Store Link Coming Soon) +Download on the App Store + Beta releases are available on [TestFlight](https://testflight.apple.com/join/0uFYONeF). @@ -33,30 +34,32 @@ This app only receives security updates and minor fixes and is not intended for Download on the App Store -Beta releases are available on [TestFlight](https://testflight.apple.com/join/563WBakc). +Beta V1 releases are available on [TestFlight](https://testflight.apple.com/join/563WBakc). ## Features * Control your openHAB server directly and through a [openHAB Cloud instance](https://github.com/openhab/openhab-cloud) * [Enhanced push notification](#push-notifications) from openHAB Cloud and the openHAB cloud binding * [Apple Watch](#apple-watch-configuration) companion app -* [Widgets](#widgets) (coming soon!) +* [Shortcuts](#shortcuts) +* [Multiple Home Support](#multiple-homes) ## App Configuration

-Logo -Logo +Logo +Logo +Logo

-### Connection Settings - -The app will try and connect using the Local URL as the primary connection, and if that fails or is not reachable, falls back to the local URL. - ### Demo Mode This sets up the app to use the openHAB demo server and can be used to experience the app without needing to install openHAB. +Disable this to use the app with your own openHAB instance. + +### Connection Settings + #### Local URL This is the primary connection to your openHAB instance, a fully qualified URL with a IP or host is required. @@ -70,17 +73,19 @@ This is the secondary connection to your openHAB instance, a fully qualified URL If using the openHAB cloud service, leave this as the default setting of `https://myopenhab.org`. When set to the public cloud, the app will also register for push notifications (as long as credentials are correct) +The [Local URL](#local-url) will be used as the primary connection, and if that fails or is not reachable, falls back to the remote URL. + Example: `https://myopenhab.org` -### Username / Password +#### Username / Password This will be sent if the local or remote server challenges for authentication, or if "Always Send Credentials" is checked on. If using the openHAB cloud, these should be set to those login credentials. ### Application Settings -### Certificates +#### Certificates Allows the installation of p12 formatted certificates for use in client side authentication setups. @@ -91,11 +96,11 @@ To delete a certificate, swipe left on the certificate name in the certificate m If using openssl v3 to generate certificates, make sure to add `-legacy -certpbe pbeWithSHA1And40BitRC2-CBC` to the pk12 export command. See https://github.com/openssl/openssl/issues/19871 for more information on V3 compatibility with Apple products. -### Idle Timeout +#### Idle Timeout Useful for wall or fixed installations, will disable the Idle screen timeout. -### Screensaver +#### Screensaver The app includes a built-in screen saver that can be shown automatically after a period of inactivity. @@ -112,13 +117,13 @@ Key options (Settings → Screen Saver): You can also control the screen saver from the [command item](#command-item) using the `device:screensaver:` syntax. See [Action Syntax](#action-syntax) for more information. -### Command Item +#### Command Item The iOS app can react to updates of a dedicated String Item so you can remotely control the device or navigate the UI from your openHAB server. Whenever the state of the Item changes the app interprets the new value as an [Action String](#action-syntax) and executes it immediately – exactly the same mechanism that is used for push-notification actions. -#### Setting up the Command Item +**Setting up the Command Item** 1. Create a String Item in openHAB, for example: @@ -151,7 +156,7 @@ Notes: --- -### Crash Reporting +#### Crash Reporting Sends crash reports to Google / Firebase. @@ -167,44 +172,72 @@ Allows the user to enter a path to act as the starting point when the Main UI is Clicking the "+" button will prompt to enter the current path the of Main UI view. #### Clear Cache + Clears the Main UI web cache. ### Sitemap Settings #### Realtime Sliders + Allows sitemap sliders to send changes as the control is sliding in realtime. -### Image Cache +#### Image Cache + Clears the sitemap cached images. -### Icon Type +#### Icon Type + Select which type of icon the sitemap view will attempt to load for icons. -### Sitemap Sorting +#### Sitemap Sorting + Sort order when presenting multiple sitemaps for selection. -## Apple Watch Configuration -The Apple watch requires a sitemap with the name `watch.sitemap`. +### Apple Watch Configuration + +Select the sitemap to use for the Apple Watch companion app. + Note that some advanced sitemap features may not be supported on the Apple watch and its recommended to keep this sitemap simple and appropriate for interaction on a small display. When using the Watch app, slide left to bring up the configuration view and select "sync" to ensure the local, remote and username/password configurations are synced to the watch. +## Multiple Homes + +

+Logo +Logo +

+ +The app now supports multiple homes, allowing you to connect to multiple openHAB instances and switch between them. + +- To manage multiple homes, go to the side menu and select "Manage Homes". + +- To add, remove or rename a home, click the edit button. + +- To manage the current home settings, navigate to the settings screen and make changes. +Any non global app specific settings will be applied to the current home. + +- To switch homes, select the home to control and then navigate back. + ## Main UI and Sitemap Usage -Logo -Clicking "Home" will navigate to the Main UI from the user's openHAB system. Clicking this when the Main UI is already visible will force a reload the Main UI. +Logo + +- Clicking "Home" will navigate to the Main UI from the user's openHAB system. +Clicking this when the Main UI is already visible will force a reload of the Main UI. -Tiles are the alternative UIs installed on a user's system and will be opened in an embedded browser. +- Tiles are the alternative UIs installed on a user's system and will be opened in an embedded browser. -Sitemaps show the available sitemaps on the users system. Selecting a sitemap will present the native sitemap renderer view. +- Sitemaps show the available sitemaps on the users system. Selecting a sitemap will present the native sitemap renderer view. -Notifications is a list of push notification retrieved from the openHAB cloud (if configured). +- Notifications is a list of push notification retrieved from the openHAB cloud (if configured). -Settings opens the application settings view. +- Settings opens the application settings view. -The app will persist the last primary view opened (Main UI or Sitemaps) when the app is opened or restarted. +- The app will persist the last primary view opened (Main UI or Sitemaps) when the app is opened or restarted. ## Push Notifications + The [openHAB Cloud Connector](https://next.openhab.org/addons/integrations/openhabcloud/) allows users to send push notifications mobile devices registered with an [openHAB Cloud instance](https://github.com/openhab/openhab-cloud) such as [myopenHAB.org](https://www.myopenhab.org).

@@ -222,11 +255,35 @@ See [Cloud Push Notifications](https://www.openhab.org/addons/integrations/openh Also see [Action Syntax](#action-syntax) for more information on actions that can be included in push notifications. -## Widgets +## Shortcuts + +The app supports Shortcuts (via Apple's App Intents) which let you control your openHAB installation from the Shortcuts app, Siri voice commands, automations, widgets, or Apple Watch complications. -Coming soon ! +**Supported actions** + +- **Get Item State** – Retrieves the current state of any item. + - Returns the item’s state text so it can be used by later steps in a shortcut. + +- **Set Switch State** – Sends an `ON` or `OFF` command to a Switch item. + - Action – `ON` / `OFF` + +- **Set Dimmer or Roller Shutter Value** – Sends an integer value (0-100) to Dimmer or Rollershutter items. + - Value – 0 … 100 + +- **Set Number Item Value** – Writes a decimal value to any Number item. + - Value – decimal + +- **Set String Item Value** – Writes an arbitrary text value to a String item. + - Value – text + +- **Set Color Item Value** – Sends a color command in HSB format `Hue,Saturation,Brightness` (e.g. `240,100,100` for blue) to a Color item. + - Value – HSB string + +- **Set Contact Item State** – Sets a Contact item to `OPEN` or `CLOSED` (contacts are typically read-only however). + - State – `OPEN` / `CLOSED` ## Setting up development environment + If you want to contribute to the iOS application we are here to help you to set up development environment. openHAB iOS app is developed using Xcode and the standard iOS SDK from Apple. diff --git a/doc/homes.png b/doc/homes.png new file mode 100644 index 0000000000000000000000000000000000000000..8e0c98fe7bcd2b4fc5b112584fc4ddfb09a9d21d GIT binary patch literal 107743 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ɾ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 z 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

$~l*dbz^=;q%GYu;f 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

aaYFS1E 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

cI)+Rq58MuH@17pti*nf*OTZ?r#}gz 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�jWU!({=yr-3+f7 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 diff --git a/doc/settings1.png b/doc/settings1.png new file mode 100644 index 0000000000000000000000000000000000000000..070e1db2769ef0d9e76e8dbea15cd88b48fea058 GIT binary patch literal 547576 zcmXtgc{o(>|2`#4$d)b3BuSR+dzi|WC`);((Acs|vNIfIUne13nNTEKk}WaTk##H; zLSrAYj={_@bI#A_`d!~Yp6m6T=bV4ebv@60?)!edUMI=&jtS@KbEg>?7&uLD8d@_j zoVv`wz^=y1e7utK?S=YrU<|M}xxr99EV_1lVfgsIsoU+_46?^_RtCldPX?y{J#swG z9S;VElkXTAP98_b|E;|{@qc$uU4D1+|K{v!|9jACAdQ`Y;TnUf;q`l=j2mx9okg{) zUCZ>20^MPaN8lDREMn1+>Br!wSMq&d9yh+SmNMnO#&G_oTj{_73;?MU69C-|a|LJu z7ZE_p>tJRNfL>C9z-*1exuN4Fq!qmpI}J?;veBwghaNirib)BsZD+Qfly1+nmps*A z_>IB5W6`C*V6%!_7kp|Mz;^wFS1y5pA;zP_Rz&zv;7y9<6QcTFBZmrt9@5f;Sb`>` zX@c#S?^IP2eZ#QBqM>08Y?^;@(A;7O4dM1{m_Rd$#}CJG*lL0G^qkZC>JoC`e3h3h z=jCQ{64O$BQH8rB8>fL&;D!;5P%H9|4sum0DjET2AlRgQ#_! z)!4FJge-yAxA%716U3W1eLgXx8y}w+0v5gSTI1=gK`cMyLKBmUTh#&n217y1h;5OH z3oe`pGHfd7+jDh|327S0!ipi4H{mW2b?_TzM7yDnT>v#(LqLS*&w0^tE zTe4`NbIEPX0^@--kSs+xU@~Gs2EvmRE0*DHeC!S}J}&oDh#0GE-*dnMcy+8M4LX_y z+Bh*JMZ6xgi|2U>Z5Sc`!AXm|Z=hGy-*hewYmh?1!Dr8v82qy7pc|am&&oZU;TXuN z_4WQox6mu=>~Fji)Osqt9-tBSpH$f{1@F50N{;mKA!92S0z3XHPLdtvPgu;Gr3F=J z=(k0qirKy$?&1&E*V$&8QYHJ1zOWfjjqEePL=|V85)`GXI&wn3C{xbieYzCJ?C3eI z(hy&K<+$L&^zwoW&p}cHdn|#@1qI8?ep)ga!d&~F&<~}-2Fpl@@6&_J3k3_nJc0W_o#k^7lPfX<_tDI78c1wS*nW=s&& z0bFUG5UzXW;*UqGgd0TK7aTg-?Kk?Ms{WdH%WOvh3sLab#=-5kjrtH+O*uW_j)ABx zo*5JTu2y5`uW;%}-#oh|5e6C9!E!uxvjNpR^z)G6%2oSydQ4pt1#48QbB6L4k&02A z95{?^<&Ah*E4>Ba7a%O`Jq68Q)*$@ZXp!3nBMqKpF$(WGg5{9~AWd)z8vISs3!(wf zzyg7*(C%<~Uat1V_*Q662D_ZXmt1zqZB#y8*a9_K~vnp?+C(yxqeXfBO9_=4S+B5cU!Z#-MbUp zo3Zn^@3%VVpFlB#=F83Abe@bp4BiLqv-b%rw1Y+)l7YeH#-jFS3iAVm;#jgP z5?uiv*Lb_-5ASu{+Imc`w{3XZd@kP!+O#y`O3-#TqLG7`OZl-qus z+b=d74-cCs00JFq&JrAQu?0A6E$60++nJ}*ODk?nVkPbis7*WpQ2KCAgrjCX=6L^5 z`po)8kcRRV?7oqdMvED%BPmdlKy5tH_bEoW56<~w=Ohh=h~Y}t&wWDkTLy%hcduvd zexi9-D$=^_kB_}8wT#UjXU~3x=+Oyb}(<-*AWbUM5bHN0#w{pjw05=MdC?v=rc-~>f+ zn#Rj1G8*&uQ{%(R5lmO&7VHJo4%xN>6h@HmGzNNxTm)V$RJhDLDsAZvXY%UUuC^`z z6D^gT$J){XhyZ}wK%As;$&a*4ryQ6M{paTIvYFb-?4myorFwLm-dwuZc1!yE(G~Hx zD=GiNWRkMdp7H8-;#WJ;-JkOVmU$=C*xDyf>UWQLd`O%=Eo)#&S+>Zt#7R3=57gh} ze!J6o!@_XxMjr{Bh(6gCEegDc&6j8%^j;k4b{{jNp*H63w9MpoBxNKMfL;rK;oZ{cCwqkYzL+gp%!kny49Fgh(wsu`j*5x9KXlj*b% zTXtx;7-^A|@VzAhWZD*u!KI?9P$~}A$5-|OA(X*K9ppV|Ej`KPz{v$8BeX6TQ;kfq zyY-g}<7|E(`u(Q;HuDq3R?&Y6%IJqB0EgDx<{xuhRU$O*|~AfsF&YA3xKSVCo%fz z5Tko1sgC&TThD|o(T^U&EiYHVJuI)E3Ln7}AZ&<^Q!$Njoh$_4QlL|IJ{J_|(c2AP zxjvthN&z{L7E{&;N^4i_vmBw!8c7)9a&H`DK-&HvHMgk2OnHB2tyc`v`@037{kU}i zJNWFk0bwFQp1gXf?m-xre&GPEFR0O{QHyrle>;SXE^(&<5g_sf6+^?UK>dL430bs1 z1g-AltOjKUILv8loU_n+92V)SYN{4hPBQu3f2lB1DOJ)H4;8#mDTF-qL%|;6_5&b2 zlgKH-u^=g_)e??yL2fL3Zpce-U}C`Zm~a{D0f)3M%_NB%1BcTJ^aF5Rbsj;6yQXB8 zlox3rA_Kq-<}a;a_v`8&vSVcqE*JZHLS9NSoWF5{^Hkm(skL)Q^Nu*V$9&o7X>|YSd{1(I z2iJdbmRu9Bpa#h598-W@HskBU1WNSfNu$dj4-=ZwWZ?aP->n3SYVBm#c6nk#a~`ID zlY-aguN}X?Z#g@J@jn-WYfv2mp+qWh4rbxfYd(vGAM>?kd>m#xSuft4WISisvS1OY zZwq=Zm@@fk{B(%(dbF-sdtT!&e+x9WQq&J^rM~8hsA`-vn9hjJeW_C&SVJ$XwU)1@ zC+u1N7fR2#aB;wW8c=|0sOa0f{g7Z4R5;NafgFA6JJpI?Upv4*I)#4 z>7Br|KrE4~^%suSMW!0D2Jxo?W0{4vTIQSF@tc`}Du6kAwAi7N$1qZ5dDd0v zxsS$g#Hhw0`*$ePvfE z5Dhu$x!r$eo|S=*G5vKcjihg)e#2Z0tIhUdYna)(1iw6>c*WlQg_QNx_a!q{=CSS& z=eqOD!p1qgPqCE_^=fcp_%b}>!g*U-4XKfR@w5J=tKTi%=o>v#_2o7{BZk&#d2b14 zyS*WayO)47MPZF}&Ai;fEO3#U8SujpwJl2LCj%Q7WzU`n(uG#hDKguE(7bx;VJC-< z*|h7gYs!zTy%xQnsW-T=_a>Q<07?0D%elMWjO7))oVysW?>#dgcSg9{72kj61IR(t z3=sH7GI_p?mw&58sJWDRau9IuVtY9_cgp&%MCMrS5=p&`S+I!Xpi?xoUdz!5$GGBwQ;Rdqzbx4rCwiC87r8C||*0mAtq+eJww zN0xc?DuyVH_$X6#ARd$p^pJER$+0PEOBWy>Nhd?AryjTeRJ_*alf-1yZCZIizhI{7 z5{zP0&``*DnpWwnX@O;S@hWLwn*Z65`POUnUu?p-ak^98Szp<4ZcPRAY>T@pU&PG$ z*PXs6e;lH-!os4uyKVogRSoy@rkf~AeOH^29dbx3zv**nh_Yf(9aF8$8pfXwCe30s zaf<^x55>iT8xBu8Bh6f{MU~%kEM@($_j9a7zZwfYec)keep=^yBPiI;eKf+FICTZy z|E`2xk5)RgT0v{PQ!#SNQ|9inOvU!+=sKQ1s!y2HU{)B5xJCb@58{pVd2Yc{LT!{B2-=%1O z-8dD$mYMABa_>TxTFLq}#iS*pHr{?*&9K3T=Ur9uFnPgjwmb6QHIH}SZ1hlyj3PGc zzKB0RoR%kNF8z`nSofRv*6p|EFAD!DLhZ2Kr1;gwh56W1l9pO(Fmm8ASQSN1g1|Dq zYzTgbH9cjT)Ejx`6hCK6C{(6OV7YDlcafHv@4gK^9?@^~Vf)b&GM+GL0f3eak3ibm z+|+7!np;#L;T|OHIk)KWR^-te<4bYP5pdclyFP1-wfz2VMwnymDWL8WN{!j2f(}Y_ zmvEdLeGJ)j@jw;rbo)WGape4-`p__dt@vT{^$B{57(=0fB71XJ%ubLbC`n0O%6#*e z`!%Mzq+QGn7S`E{dP7;t-QR>IPcETtmY&%II~|iRi)UlI%mxTMjLPZjj>8)aE42ok zSGZjeL_G$8Xk&+J_anN_3*J-vCz|D^v?z{m-MZrxlyY4@Wr*{${u2U&s4;MOyo(K_KG06EU%^ zZ3;lxGJQZ(KK096S{$mL377g5mT?8^IkMP75m1>#jj_NNOQM z{u?JMHS5e+|8}Y->AF>A4;hU}CFUStEP_KhTpZG!z~s0DXpL z=KZ*El!$$ox9kO}&ry_fVa)Jq0sLDE%6LZEZQYI*{Vc9vm#Ysm4vOG*=)3URIUUu% ztATU*&=JYD_Hgz9F9&7LC8z^c5bQg|Gq*?`=fv_0Mh=sq?Eo-rQAZzQ*efN$v?JS= z-r>ox8=)(-)-HtqN1U67&MkVDLBQ=nm<~kmIlFf4cJjI1UU%)-7J^0EQ6m}74!aNn z|HQMXip$zG2$7jQnjdfsJcr@_beiv`UAJMmJdzrn3Kt%m^VDxP(f&u&vixo9aERd? z3>=9Wv{In;z#e$HQmitYreFJQK??@2U(9(zob-+H9mtuo4)SS!=A2&6dxfHz&|&U70xa zR)jlA>3U(aRQjHc&?UQ%l5nCe$3ATly?dc<3)2l>hfolifGFgvh}OdM2Cn-Bk+F@r7*aX-AX z2qqYxoU2ueRxFO{1@2uzDii&*E6+>%EqYh2pl)|Ay(-r98PHGbP_?c5x0~=tRtV!Y zAEHfx zJmgED4d@#SomuBP!y?c4^lpyH5$2a(GgQf)KhzPt?!K_tb?i$XpzEOYTcyn!)>k}f z6W~!J(&3@##h=o5u;tdh_)hd?C~@p=j28X#`Uk6BqIP9j{?Djm*%5Xi{AV`&?^DpFXYMXU zA)t;3H67Bpddwf;Q5vm0J*MF1voDPiP0-q-Es+n?^v2OViX>cLK7DJ9yz{$aRlI@*EjW> z-|TQHX6tI5+p(h@*uH+V0vo{U$Nq@Ry16b3ZUEtCr>d1ONq+MlpYQBc(8G}ai+shh zua%cHcX31^CMAGaW39U2O{BG%ox9pqR9|X0;-_v8S$O-sKuU{stZR~A-{cCw1+t@ zK>9ob&+P{k?!=r=N$f6*7?)^^9aHPnm8vLON{&|;sHq^ys{#*dDf*hy#twxf!byQ| zph1rnD2lEeUI=U%j&{^s(o3pBs$4(&Ony;Z66({tKQY-mH+^Edfw+EagA9|Af;RIb z4bC6EMBTcYP%5BVthf%-Ns;I|;Qf2slX7F4LuT@vic%ozYdZ00C*rInK@}&U{yvkQ5zN< zMwb<04yZ?160Y|i8TkAY*Y|kmFA!bK_T~3 zyglCk87G_%;AfjeZnbptaof5~bmlx#WpoyWyu!*o9~+^sT7rxd>j zFVs!PEX3>TT)&V*z4k9VA9JyO7cr@dTRFPaCcFz0&H^!kbo>n9uF=JTyxSzSxf;hlZKS9=^X(vD9)E>DDwo_R*Ub@HT4U3cWER} zNQ|_k@JtozaQF3%SmdFwWDU!x4ghDfctnMohh@_9)dEZ6-Yd!(R};&@FwpLuaxw~&{~s6R8yn-O z_glp4@aywlowa1h+k>j8!N24CLSa4)NNs;txEX&A$rSY-!lE9_L88)w_53P7h#w|i zv3Z-E!$LwZ{m~l@2A)^Jv94&B^~3`7?I5sF~{ zNXkIyw~+QvBln)5<6m0ym4mqf~2Qh6vUHRE-_4gESb<3|GQkxNRwe*Jx65E)LCITERylGEwI+^7pW4WAAB^ z$L_RLKd3i&{v15)gy5+ujeAZtmp3r=<0bZ99*6c_DI4$$(9_XADd;#V(*8%qYfo42 zlc&gN@dHuqUXpu#5g1L{Zh}HiyIxOCH0>0V)Pi}YWT^fuD@u^~*lCsEY+IjL>yhEBE647Y$996^K2{LR2Ae+7 zN;I6QEJOL&msJUCUsH=3=S0W^X>qQ+&AnnXSviK4+d;10AxDG)jnwMC@0d)&8s6>nU!1N|)(}Pk$MO7L{k`3cytCccj2I+>xf) zgN7H~r=%)w9$3)%xAW`!C^vCA43keseNnw$7w&wqay&~55@>TuXZ~hx&OD=G;Arl4 zQ%9$F@7^$_XiZkh)mgCpUo&sK*Nv15uNXOHE|a4bvi;@0>00lKbKZI?n?7hQ#c?~I z#7NkH+COr+5EoTv53?K@TKaR{WfWd-a(Ip`7GrBtMG%tDU%iYfNsyznDj?H>m+qCM zE#zE&brtJNh3(Ies|Q~BN&LXx_2q==n-{vd(ci8(G@jCl;Rw4DTc?!~v-}H`jQ!2X z`oa|ZX;$ea0A~JHS`Tlz(T-lO7!}VVP|T8QqdzeAH||J2orlg42{)xxbN^OmlW5-2 zNwF@!v6&=As%b{Dr%}~L#2bKWGIkxjOmnLs`d;a{zYb3y1lJ{~7HKZQ6Jo3__sbL> z`6o0zMwg3jysAGV=lzwVIHgxDxv-(`1q*uCbn^q|w2@KSvf38>)CVrbZrUeKwp%N3 z5q`&F%n!&8>1`;--2<~(7JG`7!LocIDI)GrHItRRsO1M4cM_bwUaJwF0{Ugedu3n5 zpP}^<5Kqwm;Qu*8w`q&--loUoKuj{V@7j)iQOKK)IKum|ZJhCfe}}YoKKs9~s36;i zvj$ZEg#%&U2e)Df%LH_;)Xr;?V9Q0Bxka{kv5~4@$M0~hQS_G*ey(fxHGvCVa;gb_ z*M*~j`B*HznNAOmkFG&{U7^J=$Ly_YD%r2Ku(%iWCccZ=6^$~v>bD}E8mGAV@jhkp zMA@y)WE^*4XCrwCt}EWs7&M+Q8naHMZ*#WMMM_4!k740)OG?>4*b$Ae=0W44_Ea`; zU!dZW3)lv~OKB`W1JT=!(9J(KlNnN1EocOu1Z(%1Y@n3Q0$n}q4 ziM277|MM`96WQER(nHvZ{=da9pEq)_+iRmM_o=C*rNm$r5kMJ2h7{u(v}D& ztv{QGjES~w!BOZ!;8Fpb+adlHu!J?+RTLsq$!(N_ajJa6#$B3ofi`tgSy!XC53*KQ zJ3^5)WTLOK$jTZ06*8thNi5YF6aW0gERM&M{-Zz6Z*MTuMEw%?`b+a2b;qC>i;0I9 z6M8M?S(d_RCLk9`fkdNxw5b3eG-uK~h@tbA4;LGLsOUtf0C0tUO^|7v$yuOIu< zPzcF$^EZrMVl`E#*wbjuTkx}^QEZnfdso;3hygVxDo?U2>3)vX)MxHLGy8V%Efhj4#4CVQ@XEEI_=uqs8p+yx-O;gArg;4|mK;0yAS zD&Zn7dX?}{HE2#+#^;19(}mUvG$~nGr1VqxMt1DZb6u@vbnXZtv`I=L3ZjuL?vngn@Wi1Sjb7j0>RZ`m54o%`7e|GoJ(VX76t zfqy=vld48p?44u3)iCIKc>PZ%qr6I^)V`&@dq)Eny=PU_Sdf7TxkZdm<_wBs1p^JXF zM9N2WB@pVf&xTmU3Rcke{-g^{9Jl?tn(jUk7H%5JB^p%pU zB2=ZO(+_28Yz)@AoTaVZIU$d0QSH&yvEs6>1FDr{H^+dRh9VKLaJgHrWMMp#==q^ zR>cZ|pI2vra&$Ahep{1#W+Q|{1FynEp~x7;);5!ZHwmk)$ zREoLDl%pwO9EEtOF3?vr!+yuy(dZHxc*THFL&0{rPTSwnUL3`f{H%jG(c7k{K9_Y1 zJm{Hgr@>(FdjWoRfM2=#e8hPG7&go(Fw_eKg?O?i{$PHQl2VzmMg)X5tjQMm$Nup;~v$PQf zhyo3VR;>$Gvhwber9LX}4)tVnRzStY2=l@LIJ9u;amp#-drn_mPlYR8GJhLI`oK9$ z+i6d!R9w9&l16I{_%WKjQ$vqs3@!iJaLQu7%dt(i@lou#n?Co46Vi=+PBJ0ML7e(0 z4s9O4R#Ll9#K%i&oavBb+%PPaU56Yy3md4F-&I&YVwOI2X|xHzpo|W{qB5%|bM)Ir ztiDE95LsWcqtPkd7I@yu8S(Kz039h%tnOZ^vNF+QFxel=o=p8M zhbrSze4TA!xXw1p``RB^gl(*N>-_n+YN{(zJO(#5r9!mSjJXD$hyB3=8KxFz5b+L}^((hL%$#eqxCso1m09ZM3D@V~=;G)7( z#Q+<6v|p>JqUKqmYkFkujK5bfhZf(`XquP>69pbtrnS?dO=rzN2!(5bWfj z6C)}~n~caXlb^eS&i-{La2jfE8 z9?`fieIMBTII$l_OY)z3{c{htm@venP+Ad(IfpqLwKWk!%d6BByY0r6w{>9wOBoXW=s=WhVY%GY=G9{_li*Z6bIUR#{YDNn0 z14M+Jz3y*B*jM>qzIJx^#CN*fJ5?zf#PjPr4-|=9`o<@rP{_=@70M#+(PEZ5agAc5 zJ??)oVTybm%AF6ceyqqZyyd|We3}28PexySywuXa#1jXmkK9TozWf#ps%`mng`K66 zh`>MKzYc@X+~od2%{*IoI)jYAtcjk!9DDb>)9%xICAi;-GFdz2Rp6xK8i!viR?zOk zWA~?Xv8eDV27Rq-lcyYfBHsH*?b-Z1FqS@{B7%5EL5ZIH%9?(-*N)QsmX9+`tNpIt z=X67!Rvh+9Xitcfzdo(z`j3k|Y>5@v-uwH$Y?LQm6jp?*_vpkEmv~=;U}N3N!j#RI zOrdiu{X#o6DC*BH8}4)0rZ1lky7?<^Q()r5<|ieeo?pXrFr=?N2ahlm6HowbZ^NG~ z0KQn5NDV0TuYIJKRtFti-}~&vq4=-PJxjw`wz|ya#AzcT=&As%!|4qw%E{Jcp%NZP6GR`?0A$UD5`Kq|Fd-;qOmBb5t=g?nDk=HsDrxSv_kV`7nti@GqBs0fJcX9 zX{?mayaDz?^N4Y9yA7HsrM%9izsJj^+{%Gy43$Q1K28I&p5MOY_*|%2Jk9FpFF^dC z{j~_!S!i68`aA0RW+BE+Zz-K<)YO3$-!K%%OIbM(@h3RmoirJB)zsIAe9kCGVgYO- zW)s^w02IGescrgSwa4ow()>ec%B+UIeVYusIQju5^aDsz9hquQ5RpBq}b--u4hl|n-q?0WoJ*7J(8^bE+avqnKq4C;X8{;?Wl;8PzZ zfE60t&`q8R?%U7BqEc<92$)&5q05=Tk2VIVP9GQ=2z2iEdPu@kj%z4FsdBAXL9Rkz z&j{o2wSv_!oXn44GDYMmXeBXs2dWA-u3lGqL^ViTd*98>QYl26K0% znaNbH8f*$q$1oTUjTlG-7mGT4IHkhGHHXGJmeoA#E`vd{tO=n`MO`$N{4W7xA1on^-6fbz%C&m#awEcQGi%n1J^s{{?cbEkwIW`Uao< zx9V|{@oQeM?GY<(viMx2%_4JTOrn@5hcsj3wRlx1q;v?z?$!}XX0AzHFUa*K5#)#MJT?)A5Y++YM3fY1? zLnS0~BE~;v?P~B|^I1!O#o_JvRsWMB1k7e(P#OL=Og%Pq&$ zwe?X26%;t4>?SIf1nm5rm>j2!KK38@58WYuXtpq|+cTt?xJN692!4^>HU87+Qir6w8fjG z?JMBb;=yLIq67<;uIayZ@0BS;7J}nYk1sN@zEuAJV_yEAMJ$Ne3B6sg2)`t#yO!U3 zr;6FbTb}(r-7O@~S{dpJ8HA_YOlry!Ey>Xp9bAG@BItrep(UE%UdBaawQA}E&rA+7 z`@eqSZ8rzzBjmr&cbYv7&57%1kB@niG#@%bxIYNe!YqQ=C zrxQ*oEE>EUmv8}ZA{n1Ez9-J=%%ynP^-su>fFqjV)skx8tn|#miuPd~!b;2jot^bk zRu?TtCVSOuFtYgZomh@LU#pfS+8D)NTyviPoub}Ru~>0vv*Yr1!pHY*lhoEYAAT2x zRu%O2+R?5Y8>{`r^3wXzj__rbLx*783>hWum(wgWeYypFflr|U&`GgJ-wLu zy>yh<`K%|$H@eZVm(ywgF+m=S(l8-g9-a^eQcO+e4Tn%}58{AnnnZR|NyqsyCZ5?n znWo3da+7l^*`}H>2}@7190Hzm;_2=GB0CYFkAN@*R zBBmOiIqF0_SsJU{ONTmf8>ZGS*UFP`(#V<*)9_8gzi!l@dQx&rDtI37kOS-IDb9+Q z&wYMgI}^SkR3|2;EIad**k$HiHzlV)gv+Gz3E!tI*P(u>=o5ij=(2D{vY%0H=B0$W z1H?@6RBIW{om*Jh_!;fmyKt));&W+S(lCBRKg+ZXxM2L`o&;yd7pX^-Gb6Yp%rk-l zSeR9MA+JPxY2jH5cwWCB^HdsgqsJY8nH?bU|IVm!ER(p-T2Xb1-L=IPuNI;>8^fCFlQsu6BGI5nc>wdWR;XQ(gZu z!~R@pyQsVx%XK%5(JGI6T%x(sF!)-8_HvsJAH8z6;=TU_*kaHEsxq?#DtDjVdcoU!#=|nwbtERSItF!VRXAw| zPN&KdbEc6oyc0Ka$zZEj4d>($*mG}BX=U{;yj`<* zV5j8vIlrCjr(nBtG1%=++b=Yec*&cBTAK}ZH1b!}eiS%E-N$ zqRcln)aq4s;nsh-o=;W{?X*@dx^~>TW+`GK(57+hab7r=P8rqO?Wm|Siecu}U4(ah zEnILn{FYm(B*09YbVPHM>Fa*n89bHU?Ra>;1a;#A6Z#IH?N9`*f0knLYzjDAQWq)$ zPw@5;-k1}2xo{egFQC~9-WxM(;^)kvZ*RbNq9o+y=s^$m`RCdT9eb@Z%0G+#VnaxptwjH7$?kn_QvC z+M1u#`Qcw)80N(%(<>NFIYS>2O$?SMh>dk z$F)Ul0*3Q};K5oy}P0vIs~V5M(k2?soRNg z9A-=|W&z@Xzb>TL5P6-g$Rq-^@H8(Qoc#kF-%(m%Uy)7mqI1_vOD^V>SV;z(6J}^-&gu{jnkd@Yd?`VG?(iJjpz1`hHb;2H4;%@ z?!Od@E`iM#Kr%=YE0@(Z1cl}v>~PFBuU{TYtNmMlwxd0Zl#m`Dc8)GKjhy| z|Nh7Ai!;-%E%=dS%~?>@E1q~eHmsaAl11FuyKWdWZx9rV?uCg=$}tYtApR98%Ql^f zN-DaRCI(ilaH}gT8$nu?OHYeI5=N{CKujCQF>}9waRv(Wi}hD`Bu`xI+r@|47l&YJ z?i_`I-gSB%399H{46BKX^jv+)xnG?;rM`Nnd;bG(pDlQPj>%>LbMD`Zg2v5J?=v!G z@p_8C*9$zp-%D#ubuprhohWJYQ2INJNli-KPoQ;)e?7?Q_tmtdr9vE6p*utt)kAB$ z7R1-+uLQIe{XT^4)%DRjtf_W+0_ET{S%eK%ilG0lJj#n}71U=e42AlMwhf;>8{Q{%1fF?v_5)19~hhZIeo+`B7l1tc`pEgjwkh73XK(BF>9Mw&p6z2Y|>uQtvvEF!T0LSp{xG*QITXN?0QxZolB#TfxZgZ3n;uAI-Ek zJUQXRrlA}{qU9aI{PjY?=l!k$njbGb{^<=?Y5l7q)O4ejHp9-lrU$P)Z9?T2w@c!o=rMSGCVtO0HIJ7+Z3S97LW8U`eDL!z;z^ah{v?;V$Nza zCMrEFZ2~U-ES2bZa*yff)0S?j3jH3{VD5J{FKBo#MLmg#LDjA2fkTS?{ObO-cJPZk zhOHg$5|PVKZXAk6%U+MXU*=TbEH75T{I?UabVYj#}k@BSUdxO=*P4ED( z`)%;^(aCGlaq|&K>oxdcc_9X;4633BhX*d zQ_)%kdQ3z-slj%HCjhD9Cp%nQ(9FK(Z_B&-6SSHuYQuy>KGJq4oVjgN4n(^_ zt-HsG;8!wv!~gd$fEHHdzkT@sqv=iDq59wW|FlrqQ+CFhin8y^C?XMQktN0wvhPc_ zqh#O8nk*yUp=?PAG4`FY?+nJ4eN33)%sD@w-}n3d1I~4w>pJIpz3%(@yq}MIP251P zq4S8D5^{uKadTR}$4q?`RNHRYx_xC$ZGDl#*kT=VAfgIqqcQU_ID32WKQUO>f0gW) zgL%^(my3B@V6>AFO-)eek1;c%qd)l6S<;oa+1Za7?UdoHv%j$W%%Tq3ak0t5GzUnF(MPi2X z5^FrTF4=gcKXRnVx8a~~X8rYdu!YSBqIw&n5b6iLsHNE7TX%Z;>T7W=LE6L*N*1!_ zzTFGNZ7i2azHUcIGrThjM!=I5_fdG{&*M{^K~Sh|%6483_3BtI4_rtX+D5u4c4box zc8paSa!+_DiEeJOm`92kS4Dv|n60sL@xv9&6R;j?fKNrFZshFiG%R$E;0n)?VGH7} z#zrb>|3$Otr<^UE-!nYXtkGD(?%ztBa=g*|94>)Z6$gdi<+K3BBpEgAE_N7g6SNt{1nu! z-SwdJ^oPQ`NH$;=Cb>%DfXw!GLJj7?$070`AF{)VFI0htemuZBa_Fcj1X?*E63~(* z55UD<;t_ZfR*{)5eO|o800}o+@@_apUFoic&u%+_vp^0Ou+U$joGWkz)fd9dI%Z}3 z|6V|k*Bl}?7a>22%UDlxCCP1taZmWJ)$DEA7F}^t96*cUFK3qY)e?FivyH*kTjuY( zTU|rnk{KIOpUvLTVtdF@D1{oFKZi2SKZz%00JObrqrHH4AZ0PoB_i@S{hfnb+NygC zd-1S-)fxqzp4_%!2zvPM#j_{BYG;NO;AD77qL&$E;{<)pgQmUxGyKR4(;;^xS|v3( zUe%mS0$bt6Rx>4=hVY$zM)S02eHclteuqvP+{D3TEC3Z3@VcMnaryVC|9;0*+E0aC zhMdO7r3na7=I}m}WLO_2=lC#7r`eKp?c*K?>fwS$_s>ExOQOU5qzeAqK|^efwLQP5 znX5VEl3p!ue$@_o^$v@67E?E7J*5-`?q!V9K;nH4xsH1}ud0e|JmN4b(zNNUHPhiM z1HjNmf12q%yn4U?I#T_{IL)C51!>y$ZlPS$hJM)OarX0z^`qrRyk>+*TDbJTdpp&o zRyB4-Hicxi?Fv}dY7qdy8p$IlEF$B*LXgZe){wdkxWf3?3lgxXoLd+449u9DsvjU@ z|Es*5lL?mq3KI{zLFtN0^#=mSSH}hWeII$P&BI}~_K*hj7W=4w8Zca^Xt!|%X*~Q( zmw5Z|3C-{puxHD5amx#u3E2e@$#6ydjoA)_$d*Tzd))oxCPnDAzY08byiEoiK%Qdd zprT1KehqGUalVX%TSl6A{7)4-4U2^nrcZDaS(ph(`K0`iYfkzVPI1(afXv!Z?cr(7 z*(@!g9Z;!=!e2YWt#_HvH^NrR+%B}fvJ%QXNVo;;SVR623);cFI%?bv_y>l!aa(rD z`EEkGttoED7#9mH=Fh0Y;u;y4drnn)CDSIewW&W5XmR>SF3y_wf>T@s93g z%zC@ie|P0*cngPOPM!t29t`;);h6vE<37ynv8c_YP2Ps3-A&;|m3f%|X5j7~f!{`M zl%WW-H`Cb4R@1M&Nr6P2Dz5ep=NfS|7`e%>QfR}L&~ILnJ`DZ$4wjjb?Z3yd2Z=-k zQJwjg53iJ2yl`tm|5G>9V~#=nl!WshNc!XjBqC^*X!cBiZnnC_eMX^rF{(X!mULC? ztM2LdC;xfE+E+YaWk`loq@-=~E{_}{1OoX9_Nh4{|LTQYik^Y*V zmwviZV*uU#cgiD50?k5d4|BiJdizYD9wI?@p*lV0;Y$g&Lwe%U?|4mlpTrM2dFjY| z@khiXU=D?LTtFTVNJ>l*b_=?OtAuRg9@1+}FazsiMnRvXsz(?@mMt>?@pp%l?{ofr zd~5=B+HGE(k}~?<=;PptCgNgjxsT(QI%t^99a|x)0<=Upv2XG6T z<5c+EeY5}UgGM1)B>TY`skdNZ_WK8rpWfI!aHw?2&%Z1CUpnn<%)L6IDink1TmCb72-eV4sM_@U%4>$rUTN$k!0Tkp9@3X$PQWa|A5TsL zwJ)IHAgDC#34L~&ni`{h5q4M+SbMf&JRezt^}tad{Mu%^cGb^E2iR-cf3WWOu$?|+ z_l?Y+Mh=pJ#7-6B+G@?ls*yiC2b-{}_4EC#YyQ$HdxYxS0{zt++SF5d<{+o@JavDl zaTotErGBuX$%)Gdyxq$F96y}MIJqFu_&1~D0^(VqrtGoy8B_3bx5DaGwm(u$o-46A zUoHUuti|@V;k66I4!13S%jk_aQ*HGV;TTXmaBmA)KwQ)Y3x<3}_PLOMtY@SazPYt5 zoEwz-xpR-Paw^RB9h0;o7jW~3%fFK9x^K>voX>CeOaavMv`C|li_>9<{3|tXi!5uZ zFb&71u9i3)YbA94FWY45gHtSt)dx@P(Vs`x@TD*Z*TAL2R_J zIG9a5%u^9ux|HO3BEB@VGs(C&1o-bu)+kl~Jp96DZkDK=r#1;#RcGB9w>h=IKsFOf z82o(Ajd;pRLcO^)%++sfr8=V7aM4qL`f-=;Q>XGP5rqZ42QK?*#WUsXP5_ZoYswes}2t zy*p#$#&i`Z@^ta%hdUDM6G}dk_2NHFk{!lJE_k`0c|0hv=Bq7i>9Bd3;Sv6~;g3ub zRwjRfBsE$ca7L9b?(Hw#Jj0(Txndjkkq^bHkICl6*Mox=Cbr^-4v}xzx2Rf}e&6O$ zJbK8ZB6`03k6RpY#@eQIcGdEVt(C1-+-pwHcx^H-7FZjSi!qdPa!t>n*F3Mk(&DF( zGk`4owD#A9kbbN&C~Zd55`%Q^Jcyhnk`v#jzxQ$18F&RhN5d&JH`JCpt>-=I48>Ia zy>q`M{($$ogFQ~e*l((H7^1Qz35K|(PNrwYlSLbMHS4wjO;y*xmP_+9ONqA^rWR!W z>R%#1A{FgDitu^%(K*B$;+VhFT7$P6(Ucs6uDHqI-QLt!yB%Tja~zo*_JjkuV6+U3 z$OhONjjq&s^&@qK(CFW=`DxGnKi?z(9;!prBrCpBd}hWPEoii&Jqs+8c!fe=(@6L! z^SNyVe5rDd(c8foKef&Y6s0$*yko~$9Hj~mIelgqlc!rf45pow6>%IRjD(qd7=c6Y zShXz%#$nC5@O7SmM`XDqzV#Pv$sHrkxz}eCClHM*(dY7_7@~Gj&k$(j*G?NFY6CI3 zdivNMS(SLn@1z~IrbJbFQZ?;;j~3&x-a7ON<^W5&t;eaoGZLyCHw|p+gA}=DV#03; z%#Dgp*9XU2?yLyTb62fX(mrduGAlD3bdqWAg&p7bo8seLLVV@zJy%@^-Hhq)(=g*Mk8eOHAwXZ2rLE(^^U zTpMa^2W~;AOqfM?Q?U&o?^1cFwyH56M`#xG3yoZGQuwQ$G=ax(7Oy^6~olIo>#vFu(66%|2AUbU+ueZ(`$0E zAkq6Ba|gq7wrlNUcM%j}SryxmE0~TroiRs2GK=8MeH6^( z*kd$i3$vXQ>&W)T8Z=%lKDHz`KT}yyr0PQ?Uk_KgK`lU+QB92Y*y2@$I*&$V zZ6W52peSLDV*f`E#{%eiLTuRj)SUqg-H zm-CG7_zURn&A!>!J3?C)XCCZkY;(Ur-}08g)`kvAUM2P#Q*n49lBSFsu2NePZEpw+ z7T$~AvocEN4L@dX?4&_-tn77}4ll1w0FsvJ^a-KUI}t)eJBr4_3%2_Fw9lvanuPKu zs;+eE4Bzj^Zm@2v+qGTbN2Wt!ZTX!q%Su)KA%&!TavUNBEero2DcBgSgvt-nRlg7( z+aET3E2kt@YF-+t9QN=)2_ZO@uzc4`FN*ZHk~Iu|o&U$O3r8vh9TBxpZ_oa>ySGPu z<%Q4-T4F;gpTqW!Zw=_j{M~RIt-0idk||zn2G^k0iygM_Mk;mM*}iJ-*4L2Q-wGcg ztU@5@>BCoyS=D3EjQ2=huYvU2gr()VmU5Ggr-Q3*Cto+8CGJS&>QoO)t9~A14c^Om zI^vZ1o8Bc%$+t~=bn^SAK?<#Q(1LU3Xl=808tkILXXxFQuAT$PYD!X*j7pT2U(x`t z6&&4W*&V?n*$2(L62wH;)R*Wc`xKP4@aZ_{-9TT6>W2RJ$(EVos?H`2@Ef<_^BWVL z#&zU@6Eq(+u=A#U+*myPwe^#tL9!M&$tQfa1|E)%4|5x9Y)hLV%Qrsy_=irBEPl4` z#^?!zdDeHN9sE{A?C?5C&J`35`4it^57H#3dyOMpb>=&fOk6n0Dv86kw3qxb)FFTO zq|yvJ_Rz9Xks;#CtVtUzW}HDqeY2)Ao7L5Wv+17yo>~VFdh#eW+*rX*IXoe^?_L}! zr!%Z) zRULgqnzR<%b(ie%y^89Ib5r{hnkjHB{rqfbNXw_)?b+RW#mu{pTx>|K7y%A4oWdONjx@!ESNgm0DVFYScv@ntD2 z(k?e;^?yySYUEgEJz`>!y}rPDI1;)_uDLcOiCc)7v&n9(4Hy(nC$8e4d@42Fq6#5VeGyw%~@3E%nIfp|)r;Pz(F>b^m^Mf!&Jp-!`Ud=d_Ty_N^^i@#HtjzRs?r z=m>P8&c-ZGk+H|E)@m6W$bXnwleAi7AHYEG5u=yT zBJzJ1SraD^(w zB)uda$b&3NOQBpv_)K}sC@tUgRwBMo>f28sh;XQRo^l)h|MW?dJ;f<~kov=cxImz2 z>(;rc%XPr}b;_>tA*L*BlD1}scf0mZLg(5cpTjwi`>vF?ZK-`wqtlil55 z=*mK74)Rs<>2S}U%Pz}FB3>ZKj{Sr-z`L^XQgDxZ>3L(JU!>ckCsEs*5Gu}>_9oaI;#u~gKnm6zjN|l@spz@7h9P@bfGnhH zB=q9>f6h<#j)<6NU-WGfK67dZ(crk_oC=)d?y$zhp~M7kXF6AS55DUzD6-97m`U0J z_B52$V3jUF!?bfDkvISC($J!E8C*Dyi@^weJi(X4E9L$ZkDpCLXFlPVnFKP9we*8; zGAm|Ag$vuI@j zY6IU@w~vlqc7zXrm7q<=iQb!iq3j!zo4Zo-dVqrO=QceY9L@0d2O2dTkPEsXJiSbNWB zos>C9kq9iVL2LiR^qBe;9_*(2m%>i)Q-S5UL!Cw1+Ln28!`f~Ytnb_a|5#=qtb_y% zxJAqpAsZxc74Hwt(5+H;^)bKPL2Q{3%fHM5?%qUVHWnB!j0Jogpdl22fTT}4dDu0$ zi$=;=2Ri+eu+D!nEgt^g3}AJ#jc!F1kJ0HBQkc^26c)@aDF-Y?mL% zuU2OVrAr$u$U}aCv$q=$?mBaTNk$X#OSGBjEW@r^`Q{%2v#N@xKS5iEe(^2Mb|a(6 zeD@$OHZ3H;JazdI2$P8AaQN80PamXcCHV$Rv?ZhhvZReuMxYIVR>F$7;iTll;sF*j4pB#SN=w0J)jly_aS8NJNa?%P@Dn64SRaQjsRXJ*;j@Q(|i`|6AVNT$|` z(FbWzVhPAUj(p5;yYl@u67sz{&Ar!*6m71bgee#Ia4Ax?&LJrgBLjQf@nlVSw?+`g z3EIULU4z4~R}u8uR**!rqT&hqw^eh(nc)hD8tPjq^(5WDxS$4ZIZ&L{^rL?*9Nk?> z7D=PUd>e7#UklkLiNMZxqQ@y%a_vtS#mar;RJF7;cL`GKbETugT7$xUwm_}+4^-Rt zd)UXbLxjI8#p4^9=Uy@vrG1k-h4tJH@`R!(+JYtVg%cQciKi;BJ!rbp>PTf|gd5Fb z9Kg$!)ArbIx@A(-sZz1WSIqv|ipCO}2}LeEC}~(;+W{ty>klzb@F_G5O_u6P zSA)rItJT(XfR*i94>cMK6F&v0{@dNb)S4+~!v?eP189iyVr=@|7H`F02og-0SO|ni zz}QVBmlqGVK)2DL(<@TS5vmt7i=f`gMQ4-rjPe=1GTUVa!a>*N=ha z^BSwQNUVJVKoCKGkg>Qr3H0qZ+*11TbXvVL$*}cPAz2(h3opWd&fD?cQaQqHU*lBZwuxp1ZhzQVKd38=vs)Uq78e9}|I?9v1r&7oH0&`aLpE{6!`s zKbLSRt)!ABEtyxJk*X6;zd$>djQH$gSC-R^1~IYpN_Qm&`dF`4*?SF6lCZQIQO&H_?VH4cs!p;`H<2$^Nxsc`n%0!LD8LS~Jf8J+}~C#SVbZ2sg4NM`&1ywa4X z({x3eaYHv;F1lDf9SrsNtwy4|K3P7>9vWCw0 z_%AXIONaP;b2>?b7Zbn-326y$9&djYamnhIpJBl{dw(RbKkD_o*|=?(9zQD#{{7m2 zum$f)g~D-yv*a51L52U_o`Wq+bXpa{EgE@>F1`DKie#{Y=*N9_i?CUu7^ zV(E95MeGir+pt06?7*tSK9anqXb_;ln8g3Guoqk_cb7OZftPP1Z|l$hTQ!(`ypR>@ zY#Rvcj8Q2*U9`R$gGQhSKeTq^zFNq@aO2w%#L|N7xZ)@}$SguC(Zw~xN50C_#I zGzYfo8~fLDtk$#^V4Ky|+)a#B81$%v?}2cNbZZ<;0$T~TUJF7*)^#6d=fc5JST$-?Az%jRK!`&G2(F^XJx`CiFYr9 zcu*Ni)|k9(=0CJxa9;!(kNe(%?1;;9{2aFX=hVZ#|G@A@qfN`_EA&y_=GbeV9qinv zq^j@c+>f-Y&Tn?B6gyNO^EsU>?SJ%aLyZZIk9<5vZ_-&lcNMx?FV332lpYa+DSm-Y zSEoft?orYXsnL>L7lV7KkmE<+35zeJt$O|wAT!~D7MqdL%4%h?TtS5iTC&3msWZRw z;k0YlXj}4&Q#64kD)rpm7YE78}Tu<(HBA){1lZO8dYr;)6} zN1QK~UwY0=@o@&|L}br!QW=~Jgu@um+R%XA_Lo{=-=M#xrOVZiE|eX1+P&lJEw5Jf=ie)S(bGI<&JFv)0)L=bzdO2CmX@ z9|3D|il!|mIWa!lKh=>+G1w~DebSTF04hYEoWtEBjW3+!AhiVg0Rn9in*%6%E;)rN;xmM31PL)w@$Q}XkXfd z4BG0C5W}x5@J`$u*S_08gkV7nKHH!@TD$131=`EOiB7j81G}3$L#A7cr=ft^rCxZ? zUCa0z{#@riX7ZWQi|p(9^3VK^KpHF`XZor;y;{}lf*Q$?IV5acC2q%a_l6^Gi75P_ z^BP^y745Q?Fs-cTejE~6#?N-02qs)kP=!`NoIG1RK68u>5z&%?hIA>2yT#gSe1)(9uJ9vmBE&VkGw^`rhbs(~5`6UwXUFUOJ)v<|lX2zFXp& zQ#!BHJ?dV^U)f{E|I*beod;-0A8z29j%;7342;mK&d>ad?D`)R$;3}H> zvJh)#qv*E?n|sdcZ$jO+bcFagVJZ!__Cb-L_Q%H`JJnvYHwS(Jifkh7;M0I#J3IuP&Q~Qg70$uS3P)X{^v1&f6#IOFJ5P2`XRKO& z)~EsOsI}=V4`28CjUC=rU~Wv$;<57erM(6&o0!z)e!Qg5`U{rq`ZaNS>T}mgkA9p_ zU)$qor-oNN@YFYRU3&QxT4-6=1K#8Y7)ayu5V|^Jt~|?Bjm>qzvm_2~A<552)HsD^ zMAziR393)s)qS)J_x_(~)uRc2v9w=!q||G{fg6u2U>UuyU-Be7`7KkK%!3Uc8>^%j z#JEP{F@I}FMc<BV-a3ZGXYI=)~-ZkkDO# zQ=ilvd=?&9B~XPcBwtBUSbH25v<6Hi5^zqNl28aNt#BdbMMy=-oiuJdW5~m;3Ku7e z!(+?!!KPV^Of>Z(p+4?DRKStL_luj$d7s8!wBO(wca`FrEN)z_dk0- z0(BcRpF<2!dpM&X(BMfSk2L-CZLIV*^khCxlmGVc-F*-+N`F30=_O)^sW z)tFS@5JegFD;lY~ugACTdrGNFTge^DSqg9 z2(gnPw{5*;8+Xq?7I+E1<&OWX$!YNG546omvZdhn`z}i%idTXrWAT+U? z^tx+-hiz-24VdgO<8qyHnELd+HM9?T{_ll*W8h}qdY}?i(R!Kb*%SfivcB|EVKM9X z*)BexeQ~Rzf@tK!oz>H^-erZ*gu!xVpx|E?up@0=oiwi(xC*S(eV{&JWR|+}cJKPf zWrvAu*l}b_@jl;4%#V#PS^KykxrbXVXWw03{c7`>Zg~8{9}@+EPBTy{tnBe2RN40Z z^8EtEUH-h2wPnnp^BOZ!`bkhHGN)~kGk6;A1jv7h?tuv*Oak?r4h?RnAw*HAXPMCq zegXYImtY;ltK}aFa@TH#V+Vs7oC2T443KsEr-D>qoGh$lH|Nh!lH45fG$fb9yOT*p1k0e_Vx}`6Dk>cDVaK0boV6ICOpS-D z9W%JIVK#U(J+*wnU8IcK<>rY`lcj7Q+IBoZ>7M6cn(E_egKLP#+fRZZn!gL(G_vUo zgkWZ>t;yoT*qv{>gO1 zpO}Tlz=uepM=ZwX?VZ0N=Nd1#taXU68jvxPF177zp0T*ULqDiq_MgaSZiHg@yhw}D zaI^%6imh0Mi(Haz6@Kv68K>a&=!6Y0?jKFHhKHX=l$BrcJ0C{eecNUD7W-RPXYzBtI&HjGufpwV zo<`%zCIgb>tz$+(gQbbDc_sogcmEx?b1R`@5>@e-X?=^a=EQ;IQ-J>GJ=If;bS{5- z8{NE?`0c5lj{89ze*-1@z}?=KatTKX+!^RP=l)4r3X1=8y|yE z7{Oc$w~-7SXN1fHR^492L|z5YY|<_nxsWUxjC@Y@p3UN8UVN?^0WySRk5ImmY+lig zpEh%4kEPzedVpFuA2c+!FOd-aDOTGMUl!mS!E(-KA)S}?Eb5}qr&WZr@&4T(0_RIk zsGxNX>}@rP8%)`jgrqUkSgMeMOIivXRY~LDU-B-Rd36fg0GeipZjXwBm>YvHg@|z- zRB*Al;dk8-2{%O-_~S$p2|g-funjP%6%%l|$q~1R^Ddk9#CfGuwHARrJknP> zqV0iU&FtwQ&+a5xM(~sC@^`!}FIvm-(XGU0?QJBxxr2uVETG#NyASMRZ-o<9LA==1 z3EWZM?P33TDck(GY9FR78QkdZRMqx{*c0+$mW+uuIQK;3^mvCS5B%Cs*YG$?PRk!c zC&`EZ3nqXk_le^6rE$bJ-{%+E%er&wRe-*QJtEeH0KVn6c@THir?;PSWC7ILSE`iE z*7P@=F%N$H2w$HAGw7dch`%QEHBQSLQ+u!$t~9Lsvr67n-P_HvsTJ4J2xm99Gh5W% zD(}YxN5)HF6uw2ob$Kb9>e41fve0%gm@ls+3hC;3*YJ+25kW~P|LLtmqKJp$Kr*|; zw+j<mLIZ)b{Z-MX75=r-V5EJ*W zvAC-G8IEUg+eg0H%-9jtrRRs2!B)`-w?0@QmE>0L2Zag7a=&*sm>`+{dedDd8T6~6 z)CxF@!sUW2n}b$!2`p!CFs0Ie%nhM37N#noioA?J_IRKbecMJt#p%kl-;dJWJ8eZ8 zRk7MMn9vSnT%wH#Xk6``wfB`9t$ryjUXAp0W&BG;Gx8WG`O81+GG|Bo`e?yP{rl^g zly;JP@m2JV8H+yE)MJ77eeqNlQRv!ArOZ3#Maryr;LWPH7y?W4)_b*b1OY80a53w-p%=+<#2oB_`fT)>xa<{&qyNhhC|>=Jxs$ zisQWb)^y1T(3}eUr3{5YH$chjrXkeRg7Xu}aX|&1?XCBdpUxJUq3eZh9C@m`n22Pi8kP@dYsv#QK5KdQt zPmvmZ7#pa9wKw?oNnfonlajlSdvnqggvTz#lD5fCI(~pQfU^7{qDRUe$sj*)`lOu87 zmuIkpGm*JH06>w=FaHrPnXGdq@Bk>y+VgQyhLuSb!1pz*39yk<;kC{Kn@}oler3=T zzaGxmBfS00W*Nm^NIqxtpBj4hoH#>=w^#isV_eh9ersm2pCJ7^ofNq6G;_HHx=EBb zT|wJ>rY^oc*o_SCz-2PD^{{rop+rgv*95P{mRAh_xT##Xc|ha7ccO7k6qwm1oO0BE z@g62f(aA;{TC|tXZjhxx1&3Di{#1Aj4TDp#aeNAP2<<$SeM8urtT1F4pRc@mpx9ni zr20uSl%=4HP^G5OmA)A$qn~Af>JZKe?(t?cr!#^dbltoCdnzq#s=nL?eMa~*c!%K$ zC8`s+DARvs=n|=NZvw@cJ^3NSotHdz<)-ESW=!6Bak}ntkKioac_x zZb^dOsH}GF@83c>2N2!9m48`QfW5*!@h3q1qT6R&f_lB$g$|PR)+IM-DzUmAsi!edUZ!LefeJRX$ z?1If6t<4F@EJ#e2H$z10G!asRB5^`x6HE5&+m{)-BKMEZC4FIqEJc7$(X&{M-@H8r zxz2*FoSGJS;Yd7SMw#i}u_v23l0F#1l3Q0~iwDZ~Q#%*RopNq|&psR3_hCBTgN~Qm z@+CLyk4i_*W_TwE+%;NQWxK|I)vhI9B&6MzoTqKg5s}ZdQldd&**#;K;g!C>H=kO1 zW|Xr%nP)}%$Ig22RlnjoFvl_gus`8nSVY4w1g<#xSC@i^CV20$?e+2YSd#S>N)WgMwE8aI@8Fm|RJj3Vc&e3ZTD zt%SC?KFSp*&y-{K#eP^<0`3eU6VGieIgK)&4Na+SUgX31p;zDbMh*U;{epF8L=6S=&AGcVD6cdO zQm^!L_V8R$)Pjv??9l$pJ*R|_sw$zX=N8N2=QDdQp*yDV<+QQbhs<6iw<4bB&&ZyGSJ{zgRmsF5=J?DkjQ z7QWAc`$Hc}T@T^mLZ#gT~8|(*_IvUEca_}L=qQD=YqEmZiUY6v#4&iaIbx9PumgoSu9NDy{jAoO+Z@TbjqgAS z`l-PS#l+}uA4|2$I);8O0XYP=)`kJ()-`SqY8}ejaIZmN ztxAuOJ*L-ozL?z7e>x-e(9uz$f$ON>X&jLl0{xbOQVmLbqkU$%lzp+taUiv){lK8W zt}mb=%3vR-0z7_C?ajYuUEj33WOrHmbZcFr_U@Oz#MK~*uqbJV2(J975{1rR>RH(F`eq7JWvB^TDA(Jc zPR-XmlYqHzc3Qr)N_U*o3k#JZkLeS9mbo%}2z6`XE3F5N?qq{T_kKz^mRGIpy7 z)9`f~fYkfDLjS&Uws=D&5at7BqWokh8px`R-@hbEaT3KDlGduv{^!!CU5GC>E?>3J z5q`z#J5n|q{9nQ35B|04j*ks8&-XPeyHUicUBs3s<(WhiM;E zZt^DaS9=|wL*S+U=Hsbov#a^+5EuWxFW3IhI29x5M^feQN4|Tk)OPvzEAxH!fI7SoPBBHNNL6t4UU(Aw@?g6x?0hTzef7AnZ5JyD zQ<94RiL3SY{)#YRZR0`Y{VlZx#T&W-N4+8N()5{x+6PUKB(AFd=7AMVy;@NGGQcYO z{c!?X)^C~d!Il&VSIbAk+{T-(VsxhOXMcou-(nmmgu5TykaY7KMad=dz?{uXqDF7+ z<|AO+O@pLk4m@&=W+J%{)3#l^+%&fD#;TOK-gzK$U%DuGR7yW1KKaqG*%~s0ZcGaP zi(3PgZLhyqgDWRBa?x|3p^iy2%R_zc{X}s1U=0LWuHaqk)RHh4u${0D4H*0J%S-zG1x3F1d>I8osk70#k@0xCLs$ zF!6-~C?J`_Mh>=Mx+WR$QA=b<$q22tcpxxP7o*{(*-`&a${}kG45UPsI=osrNMA}V zNmuBLUw(1#rmCHZOhTP+usL&((u;De?*I}Yv+ik+69$6q$CS2DSgkd-+2D~NP%WXJer1~uZxg5%X4=P zO-z(`%BC5gR_XLf2@1ZyezmK{FcuM^XBjm1^lnRF5Bg$E`w?;;LwCe%DSKxxQ&XR) zs2Rj-Kyb=rb|o)Ty;&9q$qsHQxZRAO%Rw^kZt>ODj6 zb`x;CCjp39N-PqXbZ*rh9zbb*K9k|L@;LIp+yRnX`(vTOw?ad6U@^|LGo}Tcyc&@4 zqWh(pK14PXrt5h?V{or-S{+jW)qZ0|C?2pMKXCDv)H6RHlmrw7EoasnnoLW>qEW_Ns9j-+m>=c=L zGFE+|y1R-03#Zu{j`Y)8b?HK&i2{Z|t2CPXceiA8SAVS=kZImUh)D3x3tjz6qSKzc>1d)}ef zCkmzx%YO3@f~pedt_H3?GC315u{xU|Hi5#n{>;#7hdVY=6+7cVCbB`>9C%i!hFc>~ zRQ;)k7+3rzM*|nvE-V`$DgUK^Bz6_@CvF!t8NSUwD{e@o=0jeLL~PiuXv-tU2(5`~ zT@8j*t3ig~)5NAWA3b2)k3Hu`fwacGg-8%}7Jrc1h$Wd)W zVh{0NRFQBDBY9I4i@GQ|XcTg>;~1wuDV=G1%D+@y8C}+={KBGWG0|lUA~zTt-+eoi zY;+iPc*+VRG^hM|6{HA%^5t>oz`Fq(9JwX=wvVg6hn*KrGD)5InLyAFG3({vbRlMR zXK!|(+SWFa@e)*}Wb_z9Ysnz~F@DkVB|PvmQLZ}RDV&PY%Q9#heHBrVkb8U?;%k_U#Tx-t#fb6`~0h%oO+f`VJ*- zt*ssgbT~s&d~$JI!K+B}&515uhD@?H_x=EH$!fE{>sNQEXinae?n70L&(=@o5r266 z-Z!4apz#y&4aVuJSmk67uqwO8oKEDFTc8!Z))V{iBb*?cY{;u4; zp@hR0qT2_FF`tQxSHw@ovuXm$MocHKVLaUDv?JgC;i*3RWy^B>hY#zU9F4j zue_9zOnVN}n0;xR z=0xd71u#r^kP6Q*FyG>P_eG0#8Lc9f)2tnTmmuo8kvVzkUX9Cz)z9+4#Kp@s-N=W# znLoEQ${V`sq0|aIzxVM=?w9i1qkl8=0;;_{+oD7J{auB$MC|E9ud&d-n(ijlfivk% zMd9X76aLL~wri-0s%~~*y#^EnvZn@#u&9$=s^Q7g@kFn`m(9 zG?Df8yQGUSW)-*Qm@%gY@lGhg3pBcgw}}~FPIna0hWM-#@pp5FX3`M(aus9wohTKC z2x7S$XWHMQ!vLtAdNp}7F>GkykTiF?Ev}`am*Eb$oL1`jA93q|G4kD)U%E{CQ5WUN z^;msJvuA^@%8VVN-LF#)QtR@tU|8pPk~?Iw=zCWbRvKt;>Qp*h?JjcpauzTrZYTcGq_VV@5#SntyqS6X&y{z zFN+K6XSw5QquO0HepIYpQ7$SS-+zFsDUmyOl-Ok;Y2K89+1ykbI7X5wA&Q` zcl74O{FOrM!rNiXp|&N8B?q0;n=WGPT%Nq)C6(Y=%*(y+$GzhRXNQ&~()zYKrWR{z zzw-z8i5l^9lrBl$nG}-Hs|H)q5vR0Nht;co`zpn73*(ER3bu?bFK6pNF3%SSS4GUi zossW^<=v#FMp2SVJQ^P{oYnO93jgl4_;xMazAm4H+1@+wGbthCzq(}_mxApY31KMe zFf_YAbVOtm*LE?=FUr^cMmVH;*ps>(uMS&T{}Qh4?7QOJyu)gZGw`akbx$(L3}_AJp} z*PTt2)1HRl6-tvqk74Y|al`58IzCb~`au0fNB{NGA4Hdo&dJgql*2YO1ob6)H%I}#c^}j00U(17Cwodf}2=3?!4~PGcr!$X- zDt_C)k`PfOyHU0fB6~7PkrdgYEF(hpElXjJtz^ksb|xWP_UtjC?EAiyu}}78!pt~x z&TqcY^L&4QpMU0c&U}{py080vsr}ljtjh+CP(E}<;7cc3zf}zGsXKuc+n+_>$D*aZ z#umvMsdL<;bO6)tnzk(C4nD~VD6PW}mRMU!sjn~ab!3!dy|%GO)M%%WrnBO#_Mqgq*Hc&@&yVA0KbG zzcO!O9{zZ%;5$Siw(Bb)!QC(ckVHzLiT%G5amRwr{|RYN3`5Lc5p$}(-`!@QjWP_< zIN(TTax90vYnore8X50*D=Fn?EbmcjA&# zG`TV-nsL@;@bPl-`#XL&;Rnpz0icL@_Qb=B%2QE)=mZD4Zo)R7C_Z4Ct)Ltvvj@_7 z6P@Q1rLw>e9*02#;rA7o(VB{yrj%~&H?>sm_m}dyY#RADj54>K}kk~ z<{e^hUDDDk(Su6^bD;DQ$q&c!o6IPy8t$C9e^>BohN4C=rseh1yZOz$^Us}C5ZLRV z!^ZV+|44IGf-HRNDYz+)9E5M3(3Cg)NCSvW?2jSNE57!MPa|z<3+VEDj@YC8Ydqi4 zHFM=brAu6-!VH!BZhC8Hf10h3Hx}}ZT4X;tAO2*@=l}E0PUF%5(|wWbOVLu8E41H^ zD2tM;oBkiA)!4b|-Sb}r(F;GALmlE=ToepSLFPM0lW~EKf3rOo>-2|3hGGz2lH!$$ z8|gX>>qo(g+T7>Eqji1N@=hm*^2*oWJ8(p@TDOaySd;SEw6+hvpt-`@st&Apc3>C+ z7T=RM4rf)Ss=Z&j#v5BZ2gYh2I6#ksWH(5(Nyj7Z9jMyX6c{XEoU)I}EMR*WwVcWK z{_Yh8EBWa}Aa7yP>)$=`t4sH5+IYQ-o^6jMl=km6H-_iZ4Ns35&a?w+yKvhE5eF1{t#9z4$21hO!E$h%Q_K9 zfnyeDYjNV(jlzF=1hJJE*SF=zHxWw(vQnL5yf6>Whd7U}K8Vnhx%@F>@Q)?>?RqV>3Il#3JB3e!A71Z^dyMbm%1z__T z5*C#gsW!eVCteztL(mXbNqhpt2oEbA+%v8L1d(uMKPxg2AW)8QIB=KBEb$Rs{Qt+> zEQfUv)Lnr29Z2pjP{RCuvm!}3t1F$oA*NZ+b4=dgwJ57>5%#9|m`<7@g6szJpHg1I zgEjt^Fz@K(x<$L?uQsyKuBk@n%}sf@AvM0^SI6So4qzQ{^Rt0%pxjRdNFeUc`ia_+ z1X1BQ_&e^ka(>7AKYRdJ=wKKC)4$5*SY`dfzp&e%Vd3mO!x3}m>MLm^&`tBMpTPNw z18eog2EL2XF@DFmW`j_EoI+ql z9x}(Pr+X$~&uoR{J+$N~=gF2`51`P+EZ3C-H-LuTfF_8y;>tMn{S*vmIPwRJxG5lO zKK@e&54)^Q74JT`&Upp$_snZ1gWZDE;etVi-2tK!ft8E?t1UKMQf>G=Rz)RoaQ^6t zt>eED5w*9j4Zi<~>}kW#tvJ3mB#`JoEUShTUaM_>tHo}>z!RCP)VbXA%jG-#q6Q3< zD`fqt1KIzpzCQr_K=epuM=I=6O0IJ~xT|y(bH=US%hq4wV3wr6vE#y%ca0}HSJCN0 zbvN9?Kyd5{SM9(CMn+_Ix4S-DCYA3iSv#GUFvkxOkS-J9IE5mRQ72XUmFLj2aE2Te zOXdSA)%9kemd{I;E2}v?+cYP0&kz{5^aftX`~2zZKgoOYdD64bYuUz9NrhWa^KxbL zx@>aU+AD}@h088z!Lc0Wu9)|(3!-(Rmrn7DPb88Axp8GLuO*7l^&>5H9ISrdkLx3K zZfBxSHMe$m;->l<_RBwtef@~MS)ok{DWt}IUe`L)sOay=Nq-Y_sWj!wxa#LBDbEmR zv~J(hi?4~NkrhV%{A`jVU$AC?3u8CeAy-2B))_Qp@zneEW}zb+!~B$H)hecJyx_o8 z`b)B$)$xH}PhKp)k3>O&of2ip_Ri2{kXhpJ0wzI>KNMrSn6WH0ZSg^brK!Ag^e3L7&z4(;BU z_*5IC;Z(S2UH{WESi$wy$svhy}ro zem`80AE6mdJWlNN{f~fR5Q6ijQy;-mqX$Oy-JySYBRUueC>Fl&i)y1 zF3@40QVKi?dI%+pUo@5WW-Bb7+8ganowQ=iwx_r%?xZ`% z;Y2Pt5KZn9BWb&zK#AwEwHD^`u$f0cq==QU@ZwsVGx?n$q#d+I@NXAl5)0Nrl3M{~Pd>d1e!*QY+WFuwH48!iDs^a)1o&+gdt9lR zHgly(6lr@IC3r3Wt*KW4s~6+A)`VD+jLYq3)!Z@%#r?x+-tqF;+gG*tnV-ko@bZeC zoMprgGfV%J`?3MNk1c)9=fXyNAf8-tZiYT_=Izy}& z%3qxFxnQ&wB}pgT4)7$na3H{k0QOJz22bEw3 zJu&~eh366e(d@)w{qGcn{4?Ly+nYg`C4;(iu47{bW~)h@lW(iv+n#=?krZ1yhSEOB zx@}lx13(K`R@+YEH-n0wcRY-D42xskKGU(s6^Z6iHx6LPU$5O*`Z4*GYk=>)ZOYnV z)8ZhKJB?&CkO>PpzZFvvqCVeF0H%nv%X_ph_B9tOzh8qNLn++wD~b`(7BUe0@)Tyz z^rU$DE(>p^=KZmUq)D{5p1ib<==khpR(2R%?7d2r#3J>G3*){4lp_KX3yr6q)cP@Z zFTDSWBZj2;MXGW_dgks6R=?j^1U!g^0ttWWaK|glgPqac#Vel5|IsMp3{OC|S;!!= zSaTt046`d^3di1zalR^pjdRMdEjcSy(E=uxUgKSqvk^WN1n<@c&yIeGhh<9E%^0Bz zX`AKnoGbdXpOQ*G_D#efo*3V`u`o$&I*guqSbDz#b`0q?X~N_A(N^JKjGKt%QSUW zj!=}dzzhpx&F)+)Tb*}Y6h$5BMLAc)YgZ6P2H5^55D=>s!#y%F6&HwN%jYctEU7Pbse>TG{t~<4!r=Pv&Y93Ov?h1;Oc>Yt&;!n_2mx7Od2w9d2&XJ~&9YiWh^O+T2Q_#0ZJcX%)Qo7!ot zLY3UXKaby4e^zBKbaJSf-=KLKY2*9m)4)z7lI~&Iz=MZxolCJ1gQDF4Oy*2EjsZ;N zmTtS3UJd!)O-_1wxjU5dkF5FdLR?g())Rqz3)|$5ntx?IMT$DFQ25aoxGW?dLD+e< zu5Z;Xns5ccxCcQuHmgBAgSb*ll2t{fs!Hz3EPx?zvhH1#(fhpCPJ5Gn@rtSVQd9a8 zmn}2xNK^DxENS&db0&C5J;O{Ym;R>>fT%kOX5CkRNGd=AtAOJNNpETF>;_i)OXF9s z{FU|lONYmCXT2!DJ_gVaD3kZU{&8K%TyS7;o z^<`1c>w6kww$kk|yBm=2D9b~qsBTrb^FY5_^nQKenlFVnZ}n?h;mK4~fU`%w2Flg$ zVguP=^Ex}*H4HtMWSN{|a*hq-O$^)Y~AT?m+dv>7GSr9Ex6!D4dkpN^q~c>R#sj(lg;NCrtNkNYuTJ1#^X7?LaU$l4zWwcTyQX=VxLRVu5Bh8R`W zf1%s^kR~fD^^-VK_lREfe$DynB6Gs4RQ>41c>VRb<{5I;*^o(MlRkQjIga%1OZW}& zmav&DLrw0>##o|O)>QENw*2LuPGOFs{OOs~@4L2wPE_-zC;oE)W2!&k4 zZ$VqFY~QOf|BN`FIsRZP9s4OX`mf|t{VA(`5g5mTT$=!OBegFT{`j2?8l5+;$}%x> zHum@yVe+2-0M&`}L9Mge8=b%r$_L?0*4>IX@T(5g)Tz(GStj&%v9n=Vn@eUQ;o91N z1M>tNRyjzl_&j=ln}yVqBO?>1i++qwAW81PhD6Uph^5Uy`Qi$Opx;@q`}J+_6dutT zuI^%_Sg`d#?Jpe-V%3r$NyNfXXNEhL)PYHV5Z(#>Zy!OMuJ8v2%1ilx3HpLHDuN$q zPmVXbl2m6WJrND7UY8uqsps7%ZPi0UnUcA|M6(VQFo*z7LBD@^jS~3f9!8ctUU#3w z*gZP%b1#A|0Ao@wB76Uz=LF{1a}s5E);~2m1?@I!82dJZ_Qe8bY4CUO`BAHbzGRLL zp&xfvgC5r3W5Az8(zuR=xNO6}WO%V=?0ODw&1<9Zx1reRBZF6dm(=%V3~g4;KYzU) z?jH%al5k#7nnh;bD^sX$Z)uzJc~7WCv2xi*)n@3Z;+B07w^ag?<4|#3KP^1Fjl2AK zfwNGQPu{<5$^9y!PHusEIJcQ^+A&y@)~!z;>2lrfmCC`Y1CpP|w9hz&CDiWfK3;N9 zEzH#Hm%Ex|M4zM9>J5$Lv1rA-38^IjD}JD2`K^f)@}m`nFGggXee_5o#iX0+9y#a% zAhcD$z2=5awC-uFP-osuV!k-9Qr-sGYLp>U*Yk^hntY`Nsty;WJN=$XduvSB;BO|O zlV>EWRC!syi*=5r%S~zx+|_+o`^28({>c|6pPZk^mYMFEzEC_cyL{wpPT4qo z784^aty4e>vvH(elwTKZu%i!5XFWTWxTEq6yEP1VC9ng?2Db38%B?-!Tp#mX&$hWu zUp11N9WOp!b`MShg>DRD?plE;9SODvw zDZB}Wm7lvVTIHWiE@*QAts0J*ORi8gmt3(HhzRO#trGn>qzTvwAeeBVgqsT_984;K z4$A*6yK+zBIs6aS$NMqxeo6)wedzEf(2At~vxb`1K3lMZTsxMoY=Hlw6`|y3(}y87 zKj+BRpJbc9L2ic)kiRY|$Taf^xow#DATqGiu=5C*6dvC)f8CQ86{~7O*$IN#6Ls1) z5=(#`JnLPxP8_3|+>g6nicp@;Z8U=)UKrjl%lr=MEK0ZMAF%qSzJI4Go;Rl3sRjol zNkst}Rv0XtkW@x7^B5W7D}cR8u{ zhH6JTDMF3BeBu44I|w^FNyLHQMqDX&ON&oS+(Ldk_hBvzCsF&BobT70*T)f{$K!my zjbKGZ*+Cgz&f)!lEi$=HgQT;QA1lE-DoBc53;S=gnaSspw`fQpI} z(XmrPTTI5H)3&Bs^01bJh3ls~@2Y#Q_5Bar=U>}5N29h)K8FWmuDyTid0Mhda<&kQ z=c8(La|##lW4+riKGOCxhko2jWO(7P`ErXgxLvgalGNNnPG0hP@@P}tuVSyA`;y^# z&RE*vss+=Qkv+R73;Cvo6!v#|ZIRcXdHCcsUReyoO@)LaP7!i7o`BpF^B-(dFwfbs zJW&rXDB_8{@b$WrAt|mI$UY`4p`0sxai+_CtfQ5iBYoY|(~XyFo?ggP86YH0i6gn@ zOAk``5qA}$y7BIvZF!mTOB^M9H$?H@mU&D(*>ebv6+724)&1RpT;Y zOIVjw&cxtVaPQW&XaNt3&WPx2EW8CGsok`G7}~+QwSbG!ODoUs`)tOY`HxAf!u$j} zgz&NKqs4V#q{e#=@aFvCNTojX)x#HyC$Y`G^DPFGfSZRE%*tonoIEeUZuHgTl>>=G zC9VE(78W5Q0T_N{RI#do&_fiNakq;Tf-yFSgWsVSq72$l_XOA@MmqZW2&0qdF3b&F zoz-p|8EQmR+-;kFBjI9%$`!WG+c7R>`NBQ@=h^&jODXt9VcNq8GLYb_xSz8kP}DPZ zplb%u%K5D_W-;IcNL%|(j1nvr7 z(C{NxgvaL%y#BFJ^31N2t^B^2`ss_W_IR0;r444%v9@jBrbK~$?l|P@eR0Y9)5zeN z)4;nrF#A?p@TuA8NkBO>epmAH`QgS??0J2nHgrWp{`OwLw{zy5m0Xr}wH?ZpT{N@V z*X2pv==C9OYfg&%JY@{dv3x?$Ie<3lb9C9lr3x$a1WxY z*xmAx=d8SYTFaV!VcC5(sxPFc+i^iizh1;o@9j%#n!23IHCIxu_S-c@*sL=&sC8>! z^6k@W&%YGo4iI8tG-JN^tP_A=emy&8F4Gf57~373u*0VlUeC)y_WwrleBZTu)EcKr zEWM>=G1IGF%2(h+2xC9Zp1&Ju+!9;9f3qLA5-(YPneXvJxVfBlkS#?0E* z$HQNKq*aJPE{SW8O568!21IJmeh!Y=;>b!49^%{ihw zYBx;Vvq`2a{e1Td+!vjpZ?o{+(he!ru&v+G>C2hr{&9?o0Vuk~%y(9;z3+s#lRM^FLVxnO@#Wblq*@RWahahPmz*;E-ATVfO6NE?I`8sj?2(K_B&w zMGV4Y12f^{Nt{H4;o(8Vg?@|^$_RGH9v`kU-F!j7zC_Ia$072cp0tJOiLx{lxPel_ zZMYxA9-y`*THJPUKwyI63BTgm9c0VdF-rNi-Ij+h+^c}lBiznCBvE#@7RByI^aM%f z+*sQKy*CIE7R#B(ST7%^k62HUlGpv~ec(duQ*~;Ju5t;mvLKv!^6!mL^DCYk#H7lp z{+eP)f(=NV`(21_N#Z0(n?NqvE<&~yDVJh5#A+sS!d=gouc2?p-{WC)t$hM7&i}Y$ zze%cU3V&RJV^M(J@SM)ms7@I<0=wqQ{G)l9H@m@kJDRg1{N2wTFxI;#lhUpK9BQz9 zG(9wo;f$M)|2IuWg3Hw_tkP3+#n2}QFn56z{rI!M#2s_bB03Moz7TH0t+s@a3ll2v zw^y=LGT*vu>UKwy>N1I{OPt!DRThrI6H5-v(Ko&xq-=cRe}BBpN;8RwC{}!R-Xyd) zLWHd$Nm~T1=yW2#)Um{fr+Zg}_FJssDG|Bey_^#a*2>y{kyv{|JBHkR!Appy5@mO9cca_lgK!G2+GK>zEf zrBv>%?NX9$sRo+|{`a@HIkXHveY*PP*xkpl9V4}4HJLlBtnP0O&>xX+X=Nx&0JHDa_q+(E)j71nrCG0@S?BFfJ@&uz})uE0^RS7kJ$R}@>^U| zh&(ogDg(4hS+@K7bKD!Uu>9NX-21E#%YbhPB`A%)sN@r>0=fxDzVR#%9XqeH3adh9 z1K=#3`3!VPV-+dqN?IByTCs<;n=#mz*}zH9=Ine(kO{}f@jYxp8fS{v_t*LnN5C04 zV2)Yk)?NyzB2eCo)hqNXwFLJGxTL(Lg_I&t0jNR|Zj*;o{M18;Dpsg-%+GWyd~hx* z?`VcRWsbgXQ5>v*N-UaUdQ@4-LWpY1*|vhIb;&Uj=mRmUkoH>rbQRC=m&;!N{4I{b z5KQgyI4-yBz$3Qb-IsnS__??X{~-q3mhp=w+4Hb?Z32(kwdd)^x00M8jkRlzzmG_S zN|Fr7Jkw0_7Vw3NkOzWdTbU^Xs2cr^xHI<=5h}6dHM18zp{({cN84&}p>#HU<|%I_ zwT}QjQR3ZU6;Eop&YJ1xG(Lz_TY4Sa6@{8#&KFWA?~?^+j;?xdF*6i8$Qd7iJ6|5O z?T}0UtVW(v+f+;Uwcd=kyZTOMX>JfVd{#*z97W>ov0Nxx3N%HpoHA0bC_`}9mitVM zStzf_$k03coxhzICY#3vH@rXPO^MT4aYybjhhc-xt|XIpQ9al|oy5`lY$Q)Rxc(9f zeR%URn(KM6TMCA0S^#NwSakZJ6g*#@wHnt4xCQ5;uNB-?C{)_9xoRlc_(B^3;A1>? zh1IVo=7-JM`v>k-n04_S{1Qd>Mo~&GrkB>%m$`%RRw2)jwW9KC7OVzEGqBk!zb$Xd(&2TY= z^)2XvvwOfA*rX*9DeZNn3L{eCT1aMaXZB6oUjiVX$`Iap7`_D8u9Wv>;R7;A`WlI3 zSCY5HUF?{CY>^$uc#@O&>MEG~qaPk2xZi}!djX?diC>m@ShEwZS)DgU1^E!hQ&0+{ zo@)Q=o;3*3Hl8mBC>#AKXn!)p6!9I7uz-IbL9_XX{FG$~bC4MRc!C0a&8nYt4wyK` zzIzxzA}v*dI-d>IdaIn#v<@JnmY z@Nsum`FLc;8z@^pJZhlS&D?*zOr zX-{GzJ9>3^XN(okB)Tr6dgNwSfdI-@(VyiA34e(kH=epl%-*XwRKi)AbQ3@!0HQnJ zDyZu%6g3`1#1lEsG551tz$YRko?rm88z0ou$q=}_jylgH4b6gec-ROJkAT^^Jn#oo zQc2|m++2*byB}FI_*_>!`xBG~{AgYyU;o<&?`{*s_M-Z#!$C`#aJTvk!{^ajFxsz7Z%LlBEr^KDwZiFpYcjWRIOc-!WM)Y`@z?K>N z9S9@l{9rLfLG$1>h3>nOjayTw3G(tXamBx%KySgivU5#K?N`RZU?4PB5_{&GCT0Wk z6_!LC+Zqib-t9C)SSh&7h!|OoK|0kIw3)DLn+)RkbJ&Z1IzYbOTf^=I?MV{yEKn|! zfsKpZ5[R$ezkt1DB1P&NUlaBjMX`hu>rXEXdW`9tMpy{Ez^#SDWF}Pu5rrxSF%!1To5%t!6m<>yvY%ksd3)~chbbwX; zPJ;dnn)PK{9HTi`z+eJ`A!UQr66XyMCK5 zUA-bP!SiN#gdPVcSV1rB)!P`kz=k2vR}Ek#Ns}9K$b1R20vl;nlX4O1bfKv8i14F6 z#D@Wl?;tkBjdp-bBLDs131?n}x5$XV<&~|yWY@3GgTXHXQ0(Pc<+IxAAr}faC7whp zx6-4pr6X?KOAI*i&!?Oy&da0E!0)|;|L6YxQl6lys{@#rh7_nRqIPLKOF>8N^sAKF zlbUz^__5~%GJ*a3$urjr9l(1|nZ5+%N~hJV2`P-}ipiCWJ~tsY*?mge?8PQ<#qCef zx_eJ6vpFW-oLW#}-@OLMm-Bvv?hVE;mJvE%s;tQ+v6maqh4RMSM4uF{nyO*HooD|x zkEehnB*wtf{(A0cB~Jd>nb7$=c$N8kApc1RpL5rtu6*m`ti6D%S=C;%MM#AB&Pkv5 zm_TsB?-lz!ROOW;!T>gJ_1oPo&WT$s67eN^V`b?LNe0fgB7nLz zxCxLuy=7q>+yK4IR z1xCP0TWyzCK{A={qL+BEod!p4z;Ze2M@hw<5WnB8M&n#S^f$k~HAf0gc7lLg+3UQ< zP(Vtedpc2FBlmkHCDKI{dFXqmvK`m+p`m(RA}P7z4l{CA9ebfR3EOw2JS*@kx#@yI zBGM8cRoRk(NFxw_>#+9uKpIq|%~zTja!CL$KzD`xbA{*L628zy&g?Xjt^T_{OMm_; zuu3-wzjHfS9gi499zF<3@6Mb(ka=vZVZ21qoMRywH9CNbS2ugQiW>g5I_&I~)7+%F z4iL@x*W`LV7+-I#IcHwTh*@6yb3x>LdGw{o^RX zZl2HAUPM3r65swSrD3rwTQvHmIXAlM)M)9Pjs!O5xZYj$X;b_Ka|a^Bd! zoF8o?;ll6u%6jJ)JO%2Lx-smeGOC_`SfucgZeyFAl2ckoGS9PwKGW1UliJMqCsD5T zqXXv^s%AG(pMr*l)oHkcmqehyeMR$sEA=xQriPUgeTR8XvL+tf^bECo&r>FNvL-$Y zk5c8Yu%v80mOFh8CoXjQNd_&mU+0NT#~8F2-~wt6DGuj6=W~kwN)&OHdAg&9jZO#M zO8}VvB7UyMV5`aMVkPZ;ZD%#b3s=uPuZzXDGuY2AaO<4Gw9L4KA$ZB;>D|$fL-$z% zK;^>&&gfFhBUih^T8&Ve=$WdEvRW_2z%ij<4*Wm5x6a0Ra@jJxx{6)T#H=+=03cD;)H}l_%cXr(f zy2|DJsfuEvFR_2{@^hXQK>B6$04<(v$A-A-V`#;AwiaiW(Y}OnJWjALU zP4Ea^?%U}C_Elm;`8%bMip{?JOq1+Ty(=kUdT{tBbujxw)sHWUs91Bfc|BxyMCL3H zY|x|Q*7@{1HhKv~>J~b<%C`{U(nlLXK2Ut`r{)Z${-gJ&ezLd4mlI{OO}VHwgTSlQQ4Cp?y^C?BkylEtJp<=UhHx#G*?G z8|4=HAm~1av>tX#A(l?+;ojc@i;%KTVF1l=!o9qDU#K=0bxVN#HCn?#bE7S%1p|aW z`?0E|zo+@gd})IBLEQL!=NFf-`XGqqRHbq3DFaUtDeRjLI-5mrn?iL_$K`zOj}udcWQ1eii2B^kG_w_cqENONcYuZpzyOk|JWKb9ej=Z? zS7RnD0C>0!B6v*<6};90tk0AcYK#0X6!fa>C3;(Vd;SG)rcZgk_24@w7|Ft(y@);_ z<<@iU8u{$x-j&jFHX^=Zyr4(Eac(;$%e%Nf__`M6W#*~F$j&t|@xXAHKIn?`}>Y~m<6U{jGGqrTukbJj3ybAZcT ze(ti~T%>BlGe>Am9go%9Y@6c+LZ**gz<#UnKj?GV%zu>HRq7go-@`gboT$Lz+*kj% zfT#r}x8$xn47n>&ukqOCc?v#%2bkOo;BPtKKr*aWx4q0HE`S>Dcbovb7{*uioPE za$ub#Ni#KJtI6C+h!`=s{7UW7W#JW2!^`Lf56xePX?y5O4JrO$I}pmCM*NUDv6fM| zm~V(KOwRg2?PAd>y-8dsYbUenm+cfi>y8a_iZnf~yRb9;Ryq=N7{s1xP5vY~Hh*}W z(ua&O76}?H&yKULdn57Hj0~SRPhZ17C*MH~IQ4mh@w3CKe;4&q@YO&0wD5GEu7n9d zo(P}3YRBIG?9pRYX7m7d{w!4S9J#sccz5r9a2HgTkqt`%SGk#ZV?b&@F2q?f<1ADS zW(}{qxJ*7NZk3@Kia*XsvOj$Vh!MF@)0#we{4*I&p9)^;wnsUJusw=3TN=Y-mcs?1 zv1FDC7+U@;#Vj9aeC;6L10i$WNJ8QE%50F+bMg+1to*=gM%JR-KI@%|9UlId*Gjn>ZA7PWoWagm*uFU9B}P~j(sL%P>NpP$+AmiR4M zl|jU-lU%Aa1H^u7mRD4e%o}&Q=ja*{!s5OAW0ge>LLrtO>;>n3D*=*sJndtaO>+$jmU;=_9|){do^tb z*^|4SBI6AQs3DK!-GHDv+lN2M?QA$s$6swnx7lhMzy6-&pHq5B`kbT_;Gu>B?%Z&( zJ(aN2O>r!^bHXyG`@*j_oUd>C{h?EmxG)(KXM;sL zw(}ape&t~PTe^)X#>t>Rds50b-u4=)%_aHW&C>0a0 zBfGU+XJgf8Q}%sGhvlD(LM*PUlb(K{>v_FV6G~)_>;SRVOJcvt7*Lu138rm)@#^4V z6!+W$7q})7%vpe%^gfgIF0bGrjeFzeu-@BrdiF&+1CkeN{ai=2f@Au%Rs3UgUs$bF ztcVr)%gZ%y9mjRERuN>rWCM@Rxf`5#7V{CO(q{6uQlX)9h?ld$6Ygr2nWS) ztn}<-a&^ZXonM4MpyU6nu40xz5?xqKN=09|t9PxB_xA1O!_wf$$L8l4Z%NH^iQH!- zvs={41ID{4DK3p?R({F=ptd z9)Ba$O8fTOV}>arMx*e&CEgvM1d3D-izDB8(4_B0*zGqp{$g^o-mG4TvoxQTUOk+S zMP^n$e@yzN9=7?4%FVvcR_Sg2dda4^LlMwDGj7j1hU`uuN|SM&oy8;%}Poi#b&BPa+Spy+C&){4Ss5C|gDn zp#yLo(Jt$HU;nE4D(EFnvH7X_lpM065`nGo zs@8X@BiOcoQEp;Xkv;bTy?Ov|^I|OP0boH$J2z=l% z<>+@-V8jwc`gVkCS!*7n?3@SDhJ8U~5JC;2ti&7&ZPM-+$2^fGei>B#0BS@AHp4sR z$mf5LWjd{-8QSZu-(gm0H!Nf253WyQ3#*`-8L+aOEH-@7s}_pybLPLvAZ!En%z^*X zQ4EQ#t%>%Qzyh72SrS%t&35yLDLV383g;&)VE)7d0?i5bJhM6r#nlL<`xF~q?Iuy? z8Y#zH^_!S`+$PstatHbl7Z$n{-~B=zh+QbS6JOO8pQGOl>6(s_(CT3m*az(E8jNoh}X|Qvey3c zTy_t5E>%}e5n_w2m>@4@;0KHQ=2J3eTa{O|*Dbnn8s)iMlWH)-9o%m9i8Ym#y}B2D zfY;3?=)+PLSjB5ZFYs(d=aY~Zl(V-)8cqGgI5l-=AAL%-L&WWJF z#A}mNPMREAXtVE)b0=gt3SZK!lk!nlNA+R{dq+t-seMv@mE}7Z`Eq@op#`z05jR&6 zbC9vU-L*1!YL9S)h5uOT%}M<{!SlrSib3ee+^@=8=`Q>g?8Sgq^q18SnQwZ9g}5)& zm+WAl*j(8<{jA3Ioov~kgaMGv=F8cKomz1=ck_KUo_L7CcXAclavagA|DF%hG0tCy zlr2}|qOZ4Fp4I2kJYoMnc6?i!=gvpR{W|uxM7^csy4R`85D?n3K%YTFf}#2(6Vc` z3&NJw7{Ar#X)5ZRIiqx;rn%rCBV{P-MB@+_5_?CHY)n(pj+}(6WD<{WQhAVKm1n`9 zetV?O)=vMo0%&r^0fxZI7Iisw31g}+J&_1ZxK6QaK-`i(eh(l1%^G5R7Ek5O-?bLs zeW|*je=GCdN0<@0?Y9!w`xU#HmDh2D$);`O?Z?*Nwv*nreb%R#u$_peC>4pjw9-|@ z03O5rw)sPq4o0!6VyfIG2Xa~BZX#Wh3-=`tT=b_WlB^^nI?I@uJ7+56>bG!FyA9)O^LrX~6yBC7KM|=@`x3okjYA zxqaI%GR26HOB*oMfZ^(~v!$>l|7VvM_t>UO(j-?Sw{FGhx=zyrU&Xh}`@Ck>#qCVUqQTln_e{as z%A3mH#kEeR%&ZpBWADc>^B2-Eo|=;ag4^RnE_!cVaL3a>Rhi~!Q2-W)eG?E_YFbe2 zxg4~x(rP}~6uS&WBKJESQS@HuF4dPUnTqtA^W7*A%e9ooDDm48gaV`lKwdAF^-_NL3j9EwYE%1s^> zb!!Kt$saHHUM`%}#r6GBf#ub?jlG|1yA9+8N=%+#-$})(Y)@ilo<|w+GUE@gE+H4R zOY|x|d7bDcaeYXgF?9@(ppzzR$MKkR&+Z#-r+h1Op)7laHQ?;1OonwUuRfZg;Thzi0z(I;*mikAGV7t>t|j^K6kKhDJbSkLqlmR41@Toc~bnhki1tTdQ6 zdr-bzzDSQO$=EbcyvJs;Q@J%sW26N=Aqc}(aM$gPtfM(Rgp#|Gy+w*hthTvF!4p!R ze{Jd-*^sH})C}x)klQai;H;!Cu2I#hi+W#i9a`n=I1X&PaCPrSY!4#E?pyn%>7Md| zsgX>OTWV9MT_1HX5Mz^IehC+@z?>uha2e*}P= zz&WK%>Fo3YR0L0i|7FfdJZjPW$`-Ijn%z4e>E(jBVgw(6n!OXG4N^c6HP|Z>*BNqw z)rJM@BC#@JtUe2tuMUQLx%S=g`uo6_$ieGUVBYyglzW|_@U<zSNw3+>6|>G}rCX_4HdE&bej`OEJhK zznkvNFR;sU(sE(Ai?M)Q`z-SFC6C&+qF8dm;XpeoOUMw*Y9n^CCKTs9zH~n}VK+js645oQK5DR!SU1?S!+u>o z4Vdzwk^y;z9YB#<0jMe;rN$SqEqug8^1Gi6FT_#Q?N|F5bORTUZWll2D47CqV6pk? z&lR$Rys-f9MCPmgJo(nEZ_-DeIv-c2V*kfHwL8K=)B>XSXWd%{?B-I| zJO2RnNSdVp*FV4{pVZOK(|x$W?Y{DosmeM&XDl4o{I zh|53Nxzcy)*Wo6K)>9=52QVSnDgH^Wi%HCAa?%ywL;^>^qV;#DKGAsa<)BQ=0co`I zuvh2S^7RQud$Udyx%vR1XS%ANnT1W^>vsQsO=5pH$td*F*@Dm=GvHK|XFsRsi`ixO zayH4h1SAH1(z|!(!Jc30!k}^%f&PN(Cb6i$)wKVeAJxcY;7aN@1X(UW32+zA1i?2L zMHkD_GGvIDz(2H@Yt!z}$2`<&oljla>8G!$I{%2GD?Z99AlKGZHaKpTViVj~u^>Os zos1*Ie!1*HgO1CpU|ct{V}MXjto+E~PgEtu3tAE*XP@SglQBVIwrTPlT8r`nJ*=N= z74NfZ1l7=FyDVdf%F=j@S~{jM3t(Qk0thCn_lM_B_`@eotHo4oZ;1Yv1rQVm@Lo&^R+LO z^&Ox&Rz%b539gDs-@Q499Mb%i{ZM;S8xEb_z4h~vv>jxigt$SxB8j(?s%vUU*7 zcU5I-#0U*jH^_FkBd{c9=*AP$`tepb#=EeiHByb*&>t9EZvKBvo%bV^@&EVJK+8xO zp+jcL%AS|XsDxCu963r7$H*w0D~U~#%65)%4%fM^`+Ude zzVAQa{BoW5c)g#m=j-vzLNy`N^NZw^l;gAi*_76|o?28OYh-SgX8!o!F6riaaNXZs z^vbeoo!@S41#Gb5zwhv`@!u@9<*|t6L^b?uaRWXqc%EyzfV3#&?a(wW^QH`L&c#`S z58i{-Fz3TTU@kwX!Pq7A9rGBQ!7z)n-{uim+ysbZky;W(L{l>W{TKl|^^=FO&P&|6#i6ZFs0EW(UO{}f z22f>SxTo4a9El}IJ**5Dd%**(a55qmFvk*r>cCfOi(F)ksa3td^SdprGssx&0ZSXTcpB$ z{dvXQYhi=doBPHY7|)ZA!#g{aCbn3#r_d8P;w{!nikJOqXxbsk3pWLYK8VWe?d(L} zu>b2*EcEO4VJV9INAb$Qk8jJ)`8w*UItcxp=~c{*J^*heaihcs)unUmrDEH~yE?B* zhhfzCgZLsizhSv{9N@p}d+9ge!wd3Ix6zwV>$8DtF-tCRFhsxo6E%?u($GIiBRYr{ z99qF?K_#M!rqa>f7u$Z3Q=4h;C$F}b`v=jM1wIZtR8S?7U(ZLYd{JR&xb04HYW2N| zoz!r($jv~0CI-%=k%h9B zvl6-MKcLU|$IDFOF(INwW|f^99|g5-NUD|2Ql*?yluk?nFYq_%jwM3{d+eI`!)Pb| z^EID0T6k`sYtT5QLo*TLig|%bsL?x+!zcolWKNe{YaKJ77 z&&|gY*yrTFsbyH)@Qn(Fm;&i(4TxSY2|vlk!sATqV7wB?e^(Bi?XS^3xM8NU*jt2n zjAYlpeMN4v^oa`lw^t@)Q3@CQ(jA#}G z6|ke-i)IDSf!W{Z_16741Q-AOQvN6_0N-V@{)6CG-iHBSp763iB&wl_!6L0DJ{E=0 z`NL^xN^y_9l(fo>9@-4KSPNpG0dx582A^}yF0@_e5qR0(@|D@{4@1f(pMd!A?;WvtKJ(TsA3mw-(TP~JV>!3|0uS!Ka0>am;$o`}S#flK;P=%A(OY)E z*hQOCZAuo5f2+#@<}U*U_nVrW`mZ0lENg?*84ab4<>p-Zro}R;d3V0Ckqd&<$nbsb z`|`77-95I0rvhK(q-e`9C>k2Qdhptfeg)k-8%xtrugQd-h~sBbay5&lagylI|0qk9 z_@%G!xk6!$XV zN<-f!_{%R-ff+Vg+aGrITDDbH&y!EZrP|)h;=dX!4(<*M3BRdYgtCaxM_a`^N01PB zN$vIVs)PF~mKFqs6f>!iMc4O~9J3L0bE2AZ5kx;Azrm;Ru`!gX`_Er#uCKR&}*d+U|<;T&b($o7X@zysunx zf#-cp8|O&e_Gh;HU2rph_LfvAGI2zmTnTQHRoo_)=8IG*zI|X zq7?iN-_u!PIL^sRpUiKQVTN-khC-k!@94^#`N>9C4#p77OPJ7DV3#0UE9Dbi1=(v_ zB&wktc<7e?x|CZjQ#%2&B1io}e8|{U!?y;NxeGZ1t;eI_E!c62^%Unr~d{q>u&!sh1vfxr7ozdNA&Y(orw@IObO9ZfE@hzU%;V0{ZD9N+vZB&nZWk(TV#Eo1 zY!B>je*axzs&awhrwGcvKw**`7A^7kYHd$u064(~8*V1qZxtGf0CA<(WWKIBGm5GeyugKnE@ezelV zxBSYwBeM8Y(UUMP`MD8d+rxJ=bJPvaf8+Ipbp7Pev=)8(gaS0bVb~?xvyBpz`rgUF zkdp)S-@J!AIvKm8AMM!R3NeCDe4lH7ssH{jYVC#yC^Eo)I_aeQ3ki*`Dxb2$xEs^> zyszn7Ee6j|7+m~rns%6$FPAk_P(MdJ<;yOOzFZAaec!&jes!97b*vboFz_^qK4VVU z#kUB0kkTatGJFIXg$I+ihA7-mJC^H;0nILQvhzo0J4q&Mf3^Id#dKDAhsaVfoSB!^AvQuTTJ`$vDHidD;hh;od;GiXPzNOv z4k~@+9MSX%ym%b5r-va`Lz|;<``7hCQXAMG0dykdL_yPZzIP@5LAbw_9!FV$&?b4= zl3ZG+{Bwc^axNSnu7lGFd)Fj4r-Da$1Zbq2KcshQr7}s&BKze zq=uPtOY!a@I9~cvaVTfHipHBd(|0ErwiCO91}DEmS3dg+520?5VZTyZqO!%6rroVE z@|>i<-hY6I?@vq#TqcFmic4u+>tWlZv?kUzK!RWz6Icvy+>8>~Ck(>9s- ziO+6?`}#aTZRkr2KVDkP-|vx#3V0N9TB#&nhp~>u5!-f5(V`MncU!=J+&0{dO_5v`u_RS7m9CR^YxO-U6`S%glkh8 zT~z3fdZMYzbG&C3;kvw=2E)^JQFEdRwvlqZXZlUl{qVhPsYc7; z4Db+}18%3SBzT^~P|Gxlw%0800XoYEcQGcX>{yt;a`f+}4DQ`tV7(~?xFhJdR;a-c z4fw1?$9M-bJNlKfXaIxUyDKxNP9O05baSB46oMk zvLrf|QX(=XgXc+P97$!1nEvF3TYln_YWBQzs;He0w-w;q<*+d9Tsc?sy4ksUQuk>e zhkgBdvUdhhCfrrh-+Mj)Ys6HHM=}X6RvL_1_?sKjFH$I0_ z>-x!H5BI^W;!2C`f%w`=IiPZA0I0>9!soK_3()RA3f>D|PXtB4;NLXLe-8cS;%`wX zTo4Vtb9~MO1-?=nH2>J-;A2`emYO&anzaHdh9Z$*2~clqhg*C2aW26|^40Jt#TLAH z@%a4b3*@cIcOt=J-AH9G-jCVx`N3`E%wCS^J>|jdaKkLCzn%4iLFy(&+_bvcu>TrY z5%TvzjV|%6UJlYtY8qOL@Q{ukT7E|ARZE;fWkj}ZM%6D=P2;O2s$XH^$WHL5sE|T< zxzoq%K;>G!lkXzW{L;aRAUNwz-8~PcPj#k`ueC_kz*}3XA2ceO!0&FQ>M{>{8ZrILNh-Z+Y1%FFco;Jx~_S;pZ~VeiR4JG%RguO zlFg5#-fMpSJ*s*iTUv-OdvC*@7n2_bJt!Cck%9~bamy*5qI?v6!lUCUo?1^;T3=>e zC7$o_S*wxQ_Z?VIy8ZgzeCd@+x=B2mR>LQ4nFCGJ&2w@iI(0+Ct!k z@eOdAw*F_3p~IO-M2Y71d{*s;f6##$?shzL(Dy>4*GTp{@Z)~A1EpOLtjPT9GVYkb zphW|W4G4R-b4?Q&;G&{$!>t-xsJ9;okI}b^kB6%uUD%Kh617W`c-guY!z-nU=&Vh8?5JgpPe{}CoC~72ER8lypQJS?AaT-2qO~PKxq+y z7{C2j>qiq)q;5d0hkeWMjS4?okiEJXXx;6%K5Q4(f2>aa&WygF_ zTkHUj4Sew>PzxVB*6qxZ_Rdd}Xnk~at3Qk#6~K)VNY zUq?TDSnb(T^s*V8MZxc+5W^nzDHhbEn3?PU{1C z_ zMarsDM|pdP`<*+(QWd@C&mKL6%eMr(Q4=YwWAEewfkry`Qh60}-q3-U`uvTDSy1ZF zWyM-5cR#!)X755>m4^%|K=qK{b%8b0vy zouCgPo24Y?dKB=)49Q#snur%S+BQqKIq(h$suTcFKq1MWIkriPEQ^49b`gs?iwrD1 zMeC2&7kHIvZdg^RR7k8oxdJ>v`f-*KQ;j@N+jglmN*YyzXA0VY3&@Jd+->BY?r;c?`r#eS@NlE@XC_l#Uqa%GKr2R ztyU-EpcpeDGn}s@?(ZL?y-lIhRl2J{6q+eYIDf&F4@>woG@ObG1y}HdP7mjUT&0JB z#%@4bsF6K18#$D0TyyqGac>3^31}~P537`BfCe0al&?+AiK4Ky?%$W< ztbiJ|bWn9bYgdLY+llkNKNu&@5ubisMR`_=O+j}ULu~}#1RVXO8xfTQZ6DNGc z-CX;suZ>MRX}2|#PZpfu-tZ_Xf1!eG%q8A9QD0Wqfu@WYty+)G6NHSnq(PBl#QEE1 zI4bMbjHcYdqrsZP3#MF6%=Un(KN%3G(`;g}MZ?9ml^n<~lN-P>LP%oUc&;zcLzu#4 z;^RXgT?V@^3hm(d`;D1-AOpM!v)jxtSI++tCce+PKbU#1T6vU_)`UxmLd;jvsOTdi zcu4-=`}tRQi8J;-orJ30*dgQyH5-X@)7J^?ubsaG)y_p#j4&qKKjw)r21>UC%j2=; zJp&5lycj6MBlZg~?3C>=*YS@3c-?4H_4bik>scpWFy5fDoU z-HUvSZA^<32|8OXSYY{7GG%*(vHAoNALwTGO0pTZBhxf)Hqm?khF6l^E?-U~O;R;P z&P=!qFRszB?)`I-Qm|K=w1x}DY4dnHvaAz8U16d}xYKE;xOj*6xQ*G)CC>9fbD~8Y z&=*lwIwau$=sg~7)3Ct_&od6+YwhmjV#*;qJq(hq!=oi_h45%n+56!)cPZG_J9C83 zMe<%%SuY|^k}5b}raV;8rO?r=w;1$oJAgL9=Dl`=a5KV7|Mz0M*Xq#je)EUM7h!jd zPbWI=tOCvA-BP(SvVXdEGtp6%ZCR3ETL#n~NxG}kgG}SUv0|@EXea^EGVbeOLR}&yk)Okq|y`O@p1QU1$EZHgY7?3yof;Knw#}&U->0JPAsSv&w7}`r8&07sy#Xr7 zX&vUr+rgP1u5j-*e#G0m-6NWcuUGg$#!%i-;4ja$iH`;0TB4OU0Bn60H)--jJ^^ky$s*NbAcjYZ3y9kJ+_P zEu<^{JbVX!kk@7)A^)MD5$IFr2x1b1-_8&%ZQam!B`!Qvh^c`jQ>;a<9oES{@GEcU zzR4@t%Y*JW5BNwvYlN2@R~8w<4EBkQ$=l%Pmu(Cp5V{S(M1;O#0q*pN(H^oYkN+t_ zpiP^n*b_`5NKdq*+%pT_-o%fujck?{x~fcWD)Kk`P2{})zya-~w3LlNMpPMJe!H5I z*sMP$G(sJ6^zth3@ra0?Smi(E&8YJ_t`F}d56$g$kQO9zO-TC+EQl9J#yh3@{ z=YWwP%-f8{ioZC&TXmCPkiSRQ3ztT1B#TxV*R!5_U!S&Spt*G5mhLds2fy|$7w4bc z*oJtMhb($g2|`6#P&^OrlE<=o2w8^O{j9Yhb(nnEw(X|c3E7Dri0}>nB)md+rA|96 z+gd+I!aQuFY>8M}ipg`o_*=j7ALha~>vgMdAvT)-=R2lvvCueInKzDw3z-Q!%J-wM zK=PrPu#3#21P^S`BdYSCFitlOMN)GN-Vw%Tj+nzcPK+vRgIM|W5gp#sXW<#O`4m*6 z`5udgZD_W)@Gh0Oqz)DfU<{nkfDZ?Xn>&EEOgblhmJq+^cN#Q*JxwKwZCZ~Zfjdi< zHTxZZ_V>ZW>Gw{3MO2AW&{=}1P3v2q4_nx~%*4x!U<%*W zYNg2Fl7^pW@prGuCp?(tBWD07*O<2}=wU&*V5IcN^FEctoC8NyS9|a*Ku{V%>Addj z&_NiWka8d<4P5c6%iaCoYeK+^4K`UNQBJPxY)@`dYT=5#YoKAp{F-ES;SK)R3g=4D z3f(1`OxLu{q+M)7>0&Qh9S?b-y zKY5HU-@oB9Lkbr*83M=mi%lAhOSr$wAf*@ujH?$^7) z&4Y#&;^yTJHm9ee($2PjSbt1`{eM|R40aR{rYB8akuYb37I%@g}BB#+@mt(T9DUNfTOT=S% zu;E=CF!UnIZZTxuqWEVVci43yE^y5D$E!G{`JY1LyobTgH;l{*Q@v!|Ov+7Pxh zjfPi2#}1daSG&+kd8KRZc?iiTm!92U<7@mRffbXC{BYsj36P)SWSeCCG${N-XPnmI zcg_ai_UG;6ED@IWdp{#do_QNRF+9^R|DzANEPzp{qUH~Xi|C!!nh`^Oh<@Bxs4FFm z)aF8S`}3h?JVcfNz7EkP0Q&lwo}eK!-q}uzqAL0GyF2oTau09djg7p=KYTPk5TQ0v zJ|(&k?ujB@h}?%Fn0W&Hm{zEPNWI zW08D}(PO``+MyM&&I;Jv5Q@rE{BTlU8~H5~E1#%DuGAFMXVj#E|8@dHgtXjh$araD zJpqne$ z#|U@TFDk;TI*3q~%gJZ8K^44eqpaHNZ8uiK<@}KzeC5b=tdg)#ge!19*ltggJ~;h$ z)buw7PUq#VCn3`e6#3dfZq$GFlT8>2fw#E_Nls)`32@uTQ8J__rf(AQ_Z-~}vvOIr zvrjDnlUW(>ht02{i$FjaVeXi6bw_Qc8Tpdy2}o%!wElPfA>k#GIj3pbm^%aob9mb! zMyoVZ_l`wK%h)deaLj|tML^5b+m?MAUWR1+{%ISP3t{V;;M%XUHgMGUlxo}W(@$!I z$1isg&iQ%>v=6KOZupg_hck}&UUMyU-Xp|BGUYTgo?CZBwB>n}Sh3cd$ep0a@0oGH zI0v$=57JQgmSVNd;)7kD8#t>~PXnzVK>(+`50(hG^yui_rX zJ&eFlceAJsJLOTn*5qtyI=s9UCyf-TQ~6w zMWiqC4Pxoyft|Z=L3m>Ir{-zzJ0<`2rW2EfvQP8rALNaj8_>x8`zXG|n`oICx%|;8 z@K}~CZo(k<8#$`*Ytq`2LK$=T&pF#Q>MFPPp5O@_=W*Q+82`P^)ScTlSAO=fLvZ;>0y9{OkE8_**7`;Y)z@dypG9zkhw0NVVW+ zoSD3%{rG}MU(+{$v->1U{TONATEAP#j*RF7#j+U=vZ+ zq2t>7pKjHf@5^rrbV8Fo;PsIbxBbkk?*FnLXBFBOJTRPd?X@18`MtWCm`a&iFNnvk zagaQICgXcX#J71;*HBM_*?-%*r0jVSuBU877O#i+DGl)8Oe2G^3Z+O~Q)ov|A=@Cb zy$X+kyI?NZs3WoYpO-n>E6YI+r1lD6BexPDLO+aPh0Z!rj{) zP7cVISfa@g0o&etHg{PZ@R4c~bX-+`P6B8cd6dG6S5 z>wZuxGjTsR?ACuzSY%)1`!CDC`5eeUws!npJM;?l0=7xOmln|ZLvk_s#NoJE#uS0U+ueH~qy^-&qPVXwCHk#ZCWSc(AUm4)yiOM-7K7|iH zZ9YFgA$Zz?=e0`1gnkbSc;mE|GX5F`&6nyi(h=k9xUF`Bm80|0paPfq#Y29dfXHfl zpYmH=zZ{@Bw&$90XLLgU%@$WHhFFD(*eT5w1okFA(TuhHMV>pSyY6?YJ$0NaSEb{M znilB!E3C804WuungK?k17tRI+@&P@R{e27gGH2~QSW4mui(G=FAf-)k?oA@S7s?V3 z+;W97RGj>hbmaV)%kQcwx}A?)lw;ncm=MIg;WMA}XxPUkG}6c!_~N^i;>m-~^JvO? zdo>0BLDL0L5FRpNT-twIV=BRs_b4LX@>I8!A<@Fk$cK7qOZJHU){(f~yL?OCGz)Sk zW9X!+!WU;(lA{*`S;G>*rtF4WyCWh0D|We`m?Ck*Evv}Xs9y;%7Rub|ooRd0w8(AR z*14IemS1=*;5K4cXq)x5N7o5qkzcwO0+_*%kUmXOngr$qLZ$p>B8$B*pNU- zSniznN##1e&NV!Pv`&6hhv?u54+3`~jxa8*=Te@O{w_fEx&lFztc4k6xWTlU!yFGJ z)H%K+d+NLhMe@vEQE1q>gOd$xfDI? z&pQjpCkF9w?7Bu`^EKuZIBbaf);Hez=E+`=8oe^M<_AYfh5V@zQUKUyRVZNoKbjjO zxwYln$XyAA{R~-~KbLFr^j3R!fK!OeKfd!ku4`kB4W^%SFGVMqCMSE03JGbH$J!<` zwqCOF$sP(nVHEDuZ!vmF^j*f3*ACJY#TfX6B$lg6e^bqCztrRnTcCdI9R(M0vi~9L4rNETE;WC&Y6vIE7H9 z8KR$!T$enn(Aq6!DfCM^o$(leTd`c90uzQ=WZsIa-Nbr~_~h_~PuMh%Tc5e%(Hz!i zyjjT4k#HFB=3RnP@ag6>aY3C~#v`b3Clzwsk8}wsnkB$hmi3e#mphk~V#1Wohcc4L zT1Z*U_}AQn;(81CEj2_2Q7nLS`gP!7Sh-fh?@1Tr=A_h-arl~-j3&(hBQo>R!zx|O zZ}gVQ%^KB&Mf|eQY&k1joYmeh9YVVpH_{R%vh>-uccRYWQ_y^YigbzB3Ad?!3W*Gt zPE;d!OlUlfvG4Q?+s?l9)Ne`BV2>*9qZG9AfHSc**@%qYQ`G^7GWPA|BDQfkR<{RG zk!kM7A49$VyBG3biUi^BA#eG>$aJG6c&GP;FUagY)K?X03@v=VI~1@{1lXl~h5fr- zLSrHpZT2`8EJbVyP&8Eil+9-f?^9*Q)3y;exN2p`A_3h4E)MP{@_f)A+Hp|G7fX^C z{2gceRd@S*fW|i9Vkimpw5i2#xjtHq@X7@fDZM}F!G&61vhFBXPqIlWf`>v`W7K0A zveJmWWSO)6mr664^@ua6Q&4KDOP0olkzueNXf`_t$Be+#H1cDEs=|{4?(a z18IticM=?bEPhAMBY)jQvax3pKs7yjSX~2nIm&I?Pw5~gT+BIN`PwyYI7fy;Q}mr_ z7V)l4PUiO%k>C>XuV>{5x|Hbe-WhwI8trh(Iozly7#YL)RW;oD(I6Ka>%(af6*L^x z&z-keXYi~Qvoy^St3Dp0E->nRr?7!?BB#aV2wl}{+5_k9*bDOR<8%#Fq!lzh`{ovR z{}o@%&lr!VQ`m0rS=GyE9T7Q+)(Lyzh7DB{-@7wwEs86(E7dfXJj$H{3C%^2_UB)2 z$_3*@uTm2eEYvFumiGS(dI%5gc;G%E>(Civ+A{s-QSYgP-XQ@!9n#n5FWpkP-s*DD zK{Z_b{KbkF_Tl9Ax71mYFQO9Jl94Ka(HSpfqcnx1WR-Hjg10PAR3W6i&zPOth$jt) zgAQ#iD9^VJ)UC8i#IaGrvukHH4A{kYv_2`do+D8IDCg`=ANzUyj)`l&TbC`p$o&X! z8X(W$V>BmH^P#Dy57hPqMWTc&)ihp405^`g+Q`yM&DtE0`4gtMU3kM%l$x^BAqjzr`-}n->W*V#Raaybwq}605G3f=DOQOmBR}ww|sk4F!PSGAxp_x@e3`phC_?klN;YU7!)-T2P5tG`8)HFoHOOT zej|KdQJJGiUqSuHGnc5gxt+pG0kvz*iYWD8R#*~Q4O#C~AEdXNyl{^d)>Atd7v1*Z zpIS5qVp*-(_NW;a8=NzHfj!UrVkD?%Q^tj<&01~$DUH-2I(0_~N z0*-8OMK%DhnWP=ENmEcRIdSgvWby41%E#}I?lQo<`NY1{98x9&Q`l|cA?~QDBS;4H~Iu1QqH!I{d~0teByK43>A{8 z+9nMw#Aw7nEcnCb&2t=RK>9rB@%=%km1Mtsy+didnJ|7krx$EZ(O0mw@JWC$y+QpT zLhHT9>qx}owBjT@KR&F`QO}DPy{aDZ<*hPW+Y%FZ`o&uwUHu>#p@gkj7EJsXk$=y* z-A~+l%$VhmAjEFU(irenwnJ>FSw3qPrmS9ur>G#)P995EE>xiI$VsyhJ zUjzPbN8HFg8Ah`9`yk`?Fa$gP1hin;tiuius6y87oR?)Tc!#UyvEu$iuyuc_iP*CUMa&CuBy@pbdFu>@xmT&toz1( zy9+&!cQ`*cbw{9x^V=E08w+t_&zJgjT`t!%h8h?qs0foG^e?A*?#nEMo??cTs@Om7 z6|hsoi(VM^|82yy+iWFs2pk0xihzzN|5;lwlT^0#tFZ)lR8ji|7({88fOakACH~A7 zCC>>vq=iRCeI#L1F+a4SM7E+@X}ZW{X(#C$=6vR+BLRl8zog(eR2R( zznr!R>T-Je6a%42&3CT3rWVewC2+}YPQ4QFT=xAEA?7f;3q9V2RIVO5rH3fNlFQ1v zF5d^z6o^clGe0YmL`DW>nx7~>IM*7Lhoa`7X$54zE3Gm}#?6X}oe1U2E`wggLk9A* ziK3#^x25PrS!1g+9rJ+<<;hG1f?~=GCv>w+y2$u*{0?UJ4xQ-CWqB|5MJUa}6xWf4 z3fL=`I?~rJpG91j-J=xFBeOVf;o;(`kpAZr6v0=|3@(-iYU$p{;x0FILy#+9e_?+p z9{80FHNv;%p_;)w>DBhmPT6W247Oi+_t!;6t*G{k8P^M*!CCtMZClSTUCu(9xlJDCfNYO)TSBHeVra(m;)4m8BcQ<_or~O0*GC>* zGw;(v-)euV+w94d`T}>dcm~mC3A%YMRt1j_p80W{x0X*m_3Zj(O!)mrD-(CVql094 zl6|yA{7+(zRC)Q1HuDP?K5M#Zsk)~kC*qXDc>L>wmgf#x@V!fZm$-*TSZDjqDg5Zm zyk^9lKnV}KS%2JW5iLONi%1pSty2d5r{A8R2k~sseZ;F z^x>uMtj3nouQ1sn&z|)pO#hDu#^`rDp5ybvMvzk{(qWMSKHhZutTbpx%oRe zzmUjk1kY={T8eHl_|UjWI#&d_HJp&;9#J)}!CzuHhV$f<&S_cM^Nrrh)Q~t$@ypA5-C|d)2P`{k`5vUc&}*NDInc;^dd~ zpP2CLWZS)2Hhs01Q$#PDFx_J>h^{hXCS*6XR#@|_68bmgZp4P>*FIC-dY+~=b+~+& zQ8P|G@V29^`o2L!kD?DvP=qzB%c3#HGBnghPG*PbASX~z=!w-3;|sP~;7PqIiMUV+ z+$&I3P+L3$iP)Em``a$f$Y04Ncdlc*1bL(LGck`n4XalPzciGtS6HGwvemOgT)exG zM>;njAj=F2B$FBebjL&tJL6sfBR7b~y4LeAw?4!i3}9GKyU51iU+*RCsnVq;I<*Cz z+3tM!o9p3d|G=R6-W%4Dk%m{UnZCPVYIkRfJ!;?E^$%58Xfddv@pj9>5+gqS+=oo| zIj)lsU&oX7{nM{e2~Q*jG(s5+r^YZ7jO?a|-Z$PC8~4Uy(BCkpjU29=vnq+}6wW<@gz4gWRKbHWs=cy5ejPG%q}ct45F9jQUUZ*sl2ESBxD5+?hI zsP|~U|B_pcA1dEGz9%jR%t0cmzGoO-UZ+^t^Ib?_rOwK+gN3F1#lTF?zRAze-0n;K%;g=vMTtG=HySf8eLa+n@}P z`s|^|SGdU$RQ=}i(DPRhvC1t#@6F${w>$?WPW+oF0*!8WZ9b`lS@xP^XYWj|RpdVR zjI4<6`TV#JiN5*pE6gXV&qu57$M*bis5#lqj2bPfX+a-JXOF(5|7^xLM|9hf+1@{i zEbZX)jlGNe*Cbwo-eq6xW+qR#=Us%=awIrRJn4;yb}l&6e)v*QWLMuCiJ#WIRe2OI z`oCVxD{HzZEGa5=I#cV$qRBSsETY!GJac@agIjED5Wf}5bc5EvJGyW^hPw*38)6>Q zbXEwHDTwusC!BDf5qNEtfz2gkMyiRO+ef1RzWIukxI0$410@F>mHqPAzVpOOmAA9e zAWjDn+&%l`2OX0yZ0s%=oaZw6C1)Eyb>`>F4J3+!h$2m3$8Hpjv9D;~7tgPZq){n7 zjHSP_#{96*R2*emEg?*~#qUZf zmjfrOySfPX)99IMHk1_`7uRl=<1TyX*Ori`+gh?x@Cl?k$wOfMN*0;v&+s zB0Ja}Uq+0IsUz4HV3qI#uk^%6=ptP>%g^F=DUcI8lG51~*K}R7m3@LY2l^CPZJz>q zQ~PP9YVt?28GqT$8SAqW*JSo4X6;Mh4j8l8L(7zVsl9dm+i2Ua*eA#H4DIP8YWWeB z(fYb?ADpS3Y7nNC4JYwk3;*eg&flcDOXJsY2PDQ{1|-}oJ^JuMwKwnk%ee*%FD^}d zy-kfu=f1l6<{a|()}B`uIOUdwP$+ywR|uyxA3Mnb-r{?6!i+kI$ab0H$k9#YXZkNY z2r18kI@E-HG>$)^`aLR|G7yN!n+l`~upOmlz65|l5vwXobGLT<%2A|s3P~zSY~3NP zanwD2Z8C%HA%(X9@jQGiJ;!?~5C36h3)>&JkMua}`PMb#R$`7JC zM4+!Sb&w1RKQ1M-NT=vpkC0(pJ%tUUnKN^2cql*n$svK zr=0p+61x`EGZ(l<1WOUK#aT-B9XQxC@{C!3zfv5h7nR6~#Gid|v64{L%AptK{4SJg z6UE%60=qCw4lJCrZ?|?{HIE!zrOWU;1vMP4U7oe7)(`G&ih+;&ChG+S>g@%FuJWv;+`o$sD30oFICy z)YoaC9dyy8YJBB9sDRf8m3$fE?+5yU1@vQL^ZhLS>ayn55ajyBx-iuGe^>wv0fhh? zoZPdK!G|S4>L1Q|c>6o}2amlTZW_{lmG6rda{X=!nk$%tP~Ah2?LZ$tc6pILIJ;&- zR8%r*KBvq6RK~%x^#+>)S*(G7=?AzdlgDy!?&cNM-bc-HpLz&~?O85wNhBozC+kkF z4av#QllH;Idl`#nmCv$|PA&85rM8IN7?pnE-UGzDCk#&;r7dg2cuLfrb~Ns3q~e)o zWAAwj!k7>9ZLko?lV!R15qR_Up1>*DVU-WdZC69phIX*8*ErHX+w$mM|EhII;GuzP z;?eurJj1_SU(S;)3~%C&SM@at*ck4Yc8?jaq4Qlc2)pZXe$T6`+S=V4QyOetQ0q^@ z{)`yc21DsTsj~CUX9UWB)_p8G5bJ(HPDeEmnn&H_2=J$$g5n_>WafYUV&!I{VdgEo za=KvVA3a2Vw^uB*^JQ&T5>qJf?&PV>7&fPSzvVO2}7JM`;ihNZt ze3~3|avr7U^SSmDXp}$Avs=}+(+Fv%UdZETa<F`kuw&C^G{a=Br$~6$hC-ruC_&Co@@;)-L9bDW9CGiz(wCPeabF$e_l{+m z(-QA|N%Fkt21itJnZTWZP$oDwI8{is5P5FlZ;pdiB2l0#Gx+~gE1w_oHbck+WW5}o zry^53t2e3_Esm}3Xe?UTs4;E3oJzoRULmK7!4tWQNN*e7NTU}OYMJ&rVQn%P;q6Z> zMrs(u#(_PO1Y9lhgE+eD9K&_jRbvqd=ma(cnZ%bk{jdI;&kGB;-ott25fUcLX`svX zg40hpF*Ysvw_6`!Vx@N4pLY6ezj>7HwbVl))Up>bqL3e`E)SSO(=gD0`p*D$j*Qrr zCZB{=k6!#VM?pKgbyNo8fOHRNw-+(}uHEOg))E%;)Zcne+WpJv)CnBIUAnn6x6P2V z?c}DaeTbQTj`Cv;SzHJ(*PUS@@PmYcb{4(U2-_sd?~c>nAx8%9Cz1aTRc{^-Rs8<_ zD_M#xDP)@vDoK{?Or=nWQc;$%MY5AE%&~-I8In|%Q7UP%M3%ykW$aVg*RhYCnIUsH zbIv{A$M5s~-1qPQ`R6?5y0RtbC(eLtr>yyR7r$VO^I&_?F-=$#M zT~JPhJtFLJ8x5_6W4T}b{*hyG7e>;ag&Sbs3rv|}pLu94h@E%gkbFUB^4DEmW4!sR zlg=g&?CbieqBP%*J31J9X*-6%w)d@m*>{I_)#4zNTtNHX74vQs3xM78z45`XMGef) zYIAvuDmL>VI_AurM21mynT0TGowl7?DwCu7DyXM4^Xk-Rw>L6RWV$)iBjxo)6gdZc<#?|0udH}C^Cd_|RfH9B9d+i6N@?d2{uOY3 zU;MG}iK!(qzK0fG5JW^*&V6P6Fr3;q+vdwCFod2Ej3mo`q>v5>mRJ5Uj(}5&S?*}WCBiOG? zA0^#v70PCDX(iPz67MxU#s?7&f(kzX_qj#y52RpTXYCND)eUEecWaYSvy4q^9l?cg z5tnGQk{MK>-l6KelYPfTjfrgTmUjK@lB4Qn;jfU;nZ15LSZd!40>>*R#+`LjiK_8yB6{EczY^SPdHWIFrwr@RwOvE#dReq{=f<3sng*`}bVf`hM+4azY@ zmxQi8k&RCPv$$Y1%fVxVuKG`3@lG`6ItO+Vp#2L|SG+o~=S560!5hDf8N77^sD>}W zp-h+eJ6$`-S1>VWm;dc6jY9+GgXCQ(yvl!`9J&FS!DF~KUQ{icx61(L(wIDWNM5J8 zY8frszkSQ(5%-X}Lme^|ra2*>P|4h;ZNqc}Bzf(cz!m8{?ev%@`eo8+FgpZKAZcm7 zxb~whQ3R1ibm6cUzSm111XUp=)~4N`rvj+psaQ)uy$`snHY{o zcUmld`I{`Hk7bTgL!fMc5OlP>E=vk_f!HrVOb@TV*)QnBU|Id#;}?4Gj5jwxMb6tR$knOMb=zQ0(RttM43_|oV;g$pv|j_CAJ;ZjWW)PA3-I2y|C@

2LarM08;dF~kjWZF8a;EH;uTkn@akZ_|tLXhm4x-FREUZ9s}C?y^gL5_p67m803$Lkvac4JOwGs^_!9LvJTT^~gIzg)_@ zv_6j_%SoN;RUrr{DOVWZAU{QGS*maH_Y;8EYA!;_9MXs<^Waha(fNyNcvc0gm=hHt zxVoL=0=mJkq7q1dCxr@QXOF;h$va~m3Iz8qQa9ejjc?1jt|pClPoT+Yzt_E?OS6!I z_YS_Bz(^j?0OZ?LLJ*zh^?(*XQYk8BGo(XL50TgZ(AWKM5!9rh9oKV!z$Gz?AX;t6 zPX44wrl~6VA?z!t_3eV*_li)?8;!zraT;(OL0~dMUWexZm=q6=q5s=WICW|Rhb*E9 zyM@3`;rz49Q@-1<=O9MPD$LoN91~Hn6{294w*6<$hHfgD(orY)a>fw*TTGU`Lj$=4 ztArd^g2xPt9Zw9*$ID!tT`nh1AsQrn3Dx5KwFV-IJm=ex{CAaZOU)+FLX zMBlS#=GW`}kVpfd%P#-JPC(8(Vb5DGvg0Gk9+YdmG-y5PgEE!pa$!Qw-twS{$^b$^^#cNgpkKAE_7I!jQ$rs1IJGGO+PUYA& z-O2(e7SM^Q4?o#yX=t}D!mE8jqjve4MIp4NQE)IUB2nQ~Ks!o!r6rojcIKu#armA+eVm0gn!=4oUdGkthx`h=)qNyQxNH;0BJjeKO%QEi$ zhpZG8&s^_`Ck74x<$kq}>@?}-Fg4);nBE!p`$bI0?VI5IRK=q}IK3xp9+Q|rZA0Gx z6*gKccTFR{&g~;zI94|b%CLBPvWpz7jK(Iq8%%|d6ONv*FS5k(&mv9yknX+zEdW@~ zoxz*YXy%4OFG$y3mDEk7dR({4^ZJ17$V zRGVC|`QA@H;WzzANH9FFK|&O|+R2c`r&i4u+C$UTDWY9nk=x)PBCxm_PJ*gp{CY}F z&>X`%*|tmMEW_`0l1McP)RdK(2!g8Mmy!~Dv>8fmYo*6_@5bLLbb7WFnGKJZ(d4yWHqKE#A)G`ijDF zlixDYz4xC@D%K6BmfX2pwBM;DyzJ}uEL`%vU92w2@5+5YHcz$uG?XOeyz0@H2 zcNi}2s3@QE;FCswzum*hjBejyEa@vxo_-O(B**LHG?lcKj{5sV9{7CtdK1UFY_O_M z^z0dcPT4|96ppL2J2Y%XHm_TMntBakXazZs_>)AA+u1R~m^&{~x+43%;u7s?zWq-= zfuF;<(EbaC4#RhW-yQs^uV2XIZpI{ifz%hyO{@Qvn2AM7>%W9kpr#gwvDpy5$1d}G zC*+#W*7^QIhndTVUpRw)xDlA6wN6vWyHaA+z|m`hgSJ`i+ACsNL6~`?J*F02I65)Q z#$iJipHG1pqth1mfVYtT;Fqvd9ckolWO=Jl&6z|RA$^PpfTosNX-7!}y7D8pHG7!m z_B!m*2bYE6kmBu~&aH%pU4Lf6=h32p%!C~uCdir!3b)Tg%E&HD3qu9GVU~0%-}T{t zdrY4{u>mc*NkbMtV4Kp3`C(V4zV#Go0^yM6`9~Zi-x;jqsm1_zn1RT~2COSxo;DvC zj!TtqR3!VR0 zINa(0H~5vu9tj<;C-L9uZc7f|!9>9O`3$4i{SnQ`-*3+}Z76k~s9jHrn_17}K0w?P z6$z7B>(i9TERm3%oa_F!_x5Tmw7({=WEKN=^I+#sYz>wqN^YT9IL$FyM%#SQcn5qiiP*lDUay}x@cp!Fknz}`!N zJ*0{RLNBkdS{t?)ovX)!NE0O3?wN*9Zs}84{^vu_$!vZ(>2$MEmZV5*8j?fwkXwx{bu~`d!QBf9j>W#oz$`x8w& z?5ss!5MD&L_YRg_7II5anwTp*cOkq%6fU~-(}q2Y(_QlsI^Ly(W%KcVqGa&6u@Z`V z;uC{K5!jk0M57|R_-$z&onwQH_BDOzxtob(MmMSq-%dF9)z8~xEQ8nZlQXmT)gkOl z!ilI%U9G+z+dN6(sX}@;m_gd-yT%1j$efiLxfRK=!wN`+W{+q#p(e@*r8{!L5597K zuv?|v>hqb?v&a(^hD{fVDurV?9KoVVvMNBaT)t3LvW2U#?^_D?65QU5d=I}+*Nu0o zrG6h!tUa|Kcyz_S8E2MNU~KP%xm#?jx!e<@5ZPb%-jtg&F9HMCGz@&o}U z<+3GmX0varW~1$0BjLoxgUCZ4hJOOeqLP)LBB!&FamQvJCXQw@B3>ZhDJ8M!2Aa?y z5B7m@SnwRn^s(n2j;?@?xt7XEEeEsqu`f6Jd+yD;MS;~&l{E3!U9=?Yqv>^_@JP6E zHS}WLG5VSf7)^m;e!3!BePkif*-`UF7_N8MH`b6hzRU&Rvr!Tf7}<`Su;US^rk;J$ z(o!PU?0%+O@vSXv%1i=Aya{LWGs)0YPsxp7dd!~j)?3))=}w(JoW&mHP8C{LJuzFe zGKp^U0ErB|;T0}rRhegr5{cA2R0(6UWdL-iFI3I{0b}J1x;d_GHJ)je1Xf-HsTtqa zHjZwv!{4(*OM4GvyH*=9;7_`On}$m3a8KWvQvr{YY&^89tO!keF@&hX3uRhi`;*xX zz&G8kQy+n5pOLQzB^BQ-y+GYsJuvvM9?1BxCXk@;)5~#1>(D`ntj2fC!mH)&%$70f zQRjQ_yEIMUk}WV`_2ZAleF+J#s|_#ekYL$>AH#Dl=SQ5b;R+ntrJ}2)`eqa!bm`4= z_#IiaXK=}@ZXDWN$j{6Am-6*tuXSC&SdSJ4*5g{GA{2p6@xK{kFHnVNMjA zzZrW9b@W_o5vZ$mz2NkT8VI#H=yU>5Kd#Jzge| ziaaEws5~+u;Ao$gjn`mYj>Oe5=OHu=b!YrFzo_AbuWcm{S^;kMQ|GVLW!@eitWswgnWlY1pUK8hWk(8`!#&97# zONNRZ{ViaV_-d}{2pVg$ZFo_#s-9J)=0q6iz+EY)KUJ%4NV)5|w`HPOxnhl(_{+=l z5ZC=_p=4~1+X)OFNJd9TX>zZN0a4~+3SlDx`$is*g2P2!wV&d47bhR}5XuI%56~J@ zh3f~QCO=)|oLa)8-xkXoiu@6iK4&paENLXr#nVASCRfkvh5SdDR`O}D-Dr3uc`M=G zLZJ;(jdZuJ6>Ou(uFN{x&u4DTReck#hxB#*UQ3P1*9x0;h)IfbzLt;1xA1T#!2oGi z&^#-{_Dds_`GVAZqvakm0nGu^OU!;*gQ(_@QY`vP z=$d;rzuMy%IiQty<%;{+=d*ALM3u@sNNYUv<+U+OcPksDmY_BIVO?WpzZN0R!Z4x3 za&|Z_y_d5%w{sVgJlz!a`Nd_Nz(*Rt?C*%N4quq9`FF%RgUO#8!<)*>&!F>J+3`it z2IT&!)K1|UX^od6svC*xico7Du9fFDnk#9GJRBTW(kI17>>P;I9uiVeg=0ci{F-nkdQ zg5tFqBnrQPTSf&Z?J24`hb~U$ywUJ1@J7Hl{-n%5tmcP;y2*n90T(gMNSs9y#vtwL z4f!Pehw9;5cfABEQ6Goii6q-Q29H@QSZ{g-w1@+K=f!T}8e__1(ZL(I>tVjgaU=Up zDVOu!4;0$}2*L6s0eAqMYF8k*Xn@l0pC|n8`!H`M zI5D>54#(P_W>B3pe=x@T2wojNpp$`Iy*FXTSTPq2aj;t^L(=h)q1&RbeB^KEG8fFd zXQp0)Y5y#GF!P#VM)ISXS3Aa)fe&Of=9n32xhP}rjX9ASSysgfTIpA=xC}!4SN3sh zv(aXOJ4mwC1Kqi@XT%z6DO@zgYx&tMtjN2v%_HQa8}#|r_KGCC6R{>ROyJX6m`I&< zYMT!cnMEzw!bg2;bC06LkPFGUlVT@Kybit`d7?+n@TA-}YXcY^XrGa&J!kfVN#|E^ z{b+-O_hm*v{-LTVN)BkoYVXt_Mz0d}|920u#GNcM9cVG8_>h_{w~?}?S>&>5K1ldt z4yeGhd3I7IgcCM^f;<0lEFRb?%a)56K#>XRMY(45e!+6xtoR$x9HTL%?9dhk(LhI3o4!|bKScM1+dLrmb=xwebuFNl9yN5 zbX^xWIiX48I+efk*_l%QhFN$QNcQadWWXhCF7SJyclJwew`{}xDTOm)@q+=|$CMnT8FJg5_u z0C75rE&()lEhyH!MCy;trHCV2LmwRKfeTrySXg|g%Y$e%Hj)SF7-c}@S&MyFCbaz8 z6SN4;Lo_}4U1uEj^L+lELTiS1&Wpj|?5)yvtf2&$%~DJQo+S^JQw-N*anwE*u?qoP`WYp%j}@ ziBu{I^YCL}L?q--28};QK__3*`NuEC+&l5t^qn(5pmk`99s4ew82aI}366qzZt+)6 z&b=Mpd6!9UrLy4KbRm$wvGc!%9~O!u_XxwP{xVp$ODM5NY_fm;E=&FP3#x^t!W-8Q zwulU1Xlsw!#3FyfYMQmtZOnZk5@GOEUfZrnK(q9Q|HY(hN4;mqkm~+ad9$Ir(#wZ& z;@GpLo%K#r|9WYdCk8N-WzvmS5eo=7=kNwuI=bqog|U*k;3ZO zG+VP%w+f|fm#;SA!u$Na)x0AFV~EsRBqhb=iF!Q4BfJjz>SgrX*3K(|QzTK#fkNPH zLyXe2aGkK05!)##Y3C99{VS@ib9Ch8EV0tKmQ7CJ7U0tJ~ zAmXkF&Cx(5BnyAOdpO>^bf|}dwBGb!cB4)oNINLGbqa_Z1dXBlNrJnQ4xmDb1|izv1Etr=pDn zj~L;zpznXKP0U&US}TTG*&cRoqkeVQP*57*KUwSwO-;J9d=MRY_4Gzs8Xfn8GnIj) z%7|bO<-Cpgrm%0d=j+|+0ycr?!@**HS7v^W${YQjb`rSpR4<|<`qi`#b^oVYi3@9r zrukwghjJb{7KFG9Qb)b~`tg#4$$^_9GncU$K&_g8_Dbs><4T4y8oAtmEqFSuQ$M~e zK=qtz=pS)2F`?9hBN0kJ;C)%aF`Q#g6c?e(Ug#Hu|RM_J<1j1ZsH|c6Dqc7RUx>yxph+Nd-!0R zdk_)~qWXaY*-fj^F2t~PbaFp^#B!SsjcTuB4BKn1J0XiE!H!a0HPLN zuAb#DSTDTBq1-f&?VIYSW;VpQDvzI&-zTG18B6{c%`+)VIeT=X8@vyq8}~`Enr_oWZ!3E&+=!FUbM#YvO`}t$_Zo z3f`O5vuR}6iJp`5zC<`Njg@BRlqurt#o=Pg7`4on+Fq16!@Xp_YMd?DVS=BF6(Za(VKG(EH zPCWYMn086ihB>@7YbN`GnsRh*{siIa(Wt5O*XVj2!Y?iwCzfNqk@?+?waaW62kY#8 z$4YyIRa!(Vg|8|JDF*OE`>&pjYBfJK-I~M)7M=_6xz6a7e(HnOF?^@_(M8v?Oo>mv zWw|-k&J5m16-2||3*kCJ=Ky;!u!ud-De*|jQL`B^+zMe6A^fDbojwTA7s^KnVX{KL(JZJ!NJ7}zS_)Vd590hRM34|kXkJ=4F zH-5z2`5iLOmOi_RE)OAnfsw zOEN8ZTqKXfk7j(UAi2FyI=b!|p>;Si24yV+y23xu|G#HqscKn#-ZIIe48HztvS)|> zZ)ZD@y6u3hZiP&{O}R$qeYH4UN2J0E+n+wfJLs;V-94;Zi@`zFRZTo$vgv&eg(;#X zbGZ&mN#WXk=1g4$ckOyh+x*r3!AY(OgkV;$n@W2#x=TaZ4t;=KyY*C&H1ou_)>;eQ zfR6s;dXxW;3BZ1-g+&q#$G4j)gl19J6$d~WtB;e{ZX0tmIc1-xO7{FY=i|IAF{o&S zHfm%RQl=&lNfM$+@{b8^#+>G@&HlQoV**S7yKg=4gr*YW#*AH5=uR=Rt;0IJ*a)pvi zSDt4@hBoFgQWtA4$&j*G;^shGYOcT|xTO=aHaXv=ZK6O7{nF_$SHz*?w$`*0Vg@xu%OV+MhCiv2f*J|*+IxoeHw#=f zsGGj<@m-^OKIbikFI1!3N+lzL-@p8+>PWEPwI&=|YHm`>E_Vr~af&}_6+}Jtc+pRDH=)_lAw_ zF)H-_am1(tOS3TO#H`(Yj$VyLX+!QzUr?I+!=>dy<4*R;J{0=P@Ry%NZwy$FhPQsB z45oyL6z-|*Cg@#TnNrs2YBnl)*6ROHCBzmx&-Tj{-pb`^eNFOT=Io+?ogQ_3@a!eg z+i9UrlWoBRTX}>BUR2TK>h$fN-08!gfnNpwt63e;)SZc%(4<;OrP3#**yT_-@qugC z6yYR%`2K;&0rFCbXe?+`U-?z``vdcnBMGgjs;%*5Oxo}og^*+r$ewHUK4&L3niG2_ z?CxcwYrOAlBfWjb7H>-a58i;XKn8+=MPeZCNYLvy6Si z7F(KHwoNYZ=eYY~5(E`=+^!caK0#;v_h$S2si6%10hjueMb2y`Z21lTabfZhzYy$| zf37hjAPSw>&c9ns5W@Le@9Z$SJ_C3}19lD`{;m(A?8aPJGj;ST)7V$(?I;VegNmPl z*^@_J;9m~=one!xBnT4vP*~WMn?_I88udq-c*meCUxQeKAk$R;a`}aAxHr5#HDw#a zrSWgZ1wQessVWW!hG@dOkah3W$TjW9F~AzS|Il;)iiz6>HFxMMIMzxUl7teKEvvTi zq0(Ja`D+!X{N};4jpfhk9wxOj)(1pR3_>miE22KmFICm3-n^ZzPih zB`(btauP*5)n?Xt)@=5^w6MZqbY#WuJQCfq_S)=pw{nkgx&7YT6XSol9od1TdCK}X zRe+h{V_6|M!)Hy;8VFGm?heOfLkv29Hp^?2u~%2BR}tu#bD~2bkS&4cNZ72-1Dk+U zH1%J}E6*eL^uycIXk|8zoQ;L(KV2mx;GoXa$4;B1+yG?tCENCvdowH*#Y}$aplYx8sZ z&5~!8w}`0HzROa*O4X+%u7}vYM#sF4h)3DfqF)nyNaO!k(_FmQ-seY8sZ>xj;dLkB>=2ICvlYdijQmH(mO|6501+ zI;DxSu4Q&o9ti)c=4b`{`NmGnavAVg`*XwEtfoLo3>)gVpmGY^Qobboqih{iO>gFQ zc|@V(ELt058p$v3jda*)&h9?Q!uKQW-G+W0BF9TOjmJmwByh!3#Czk&rSuU+COxi$7%EF;K7 z%~)DUh*(*U4kd-o84GUBTbwGHx~k%|!`N zE%K?xU;4AI#-s*dHb9^sV@@uKDLAi*I<`)7p_AtQHO8wN5u(v9C%jgqa-t6 ze)8*^$tS|8aQkI1qv6YP?M};yhdMb8*#vX#ZeRKl@HD}I(2!W8;N^NkLZCDOxc7AF zQy`|AXwcuU5bniv=ehqq+{5by$6^ug&rM;tk;R7B9qtg|xk#8%_*=J7+k&n?Hk;MC zNfW)}I6r_3lA za0U|&e2m4gbD#x*Qg_&pf7=s!{5R<`(dWY|OE#IC+BoYbuOkl#d_*fR_g*9asC$h* zr@QxWCDd{f{l(F{B!mM&aPHX@On|K%MzSwyb+7RIeTC#TjsKdxz1)Zn81ahDYCPTH-*f5BJS6UgA|% zT~OUfI}jP$%%A-_duJWOS75pTEqQV26V1*4G9i3B`uYE(Rj|Ih2R?YA>WkW!bLjpZ zcGa78h>JPTu);dpXC00H2LHmJ)AQGx2fUfyE9I(~H3HknQ@N%Yc;@uUD|ixS<62#j z`ZjvFhoT&wxeE7h;qRM0?ikonmVk^9`%w=~MH4&Lm}GwwI8NRcRk z>NSs59FC67t~v1<%hwmf>+bjE{79T!LOTXG?_}^n5_2W#&(g_{_-08YCHsCC+E{yU zl$U#ZCV~7dsXjhsk_Jg{Z;1Caw@tMHX>+*i;5lv}M}d%mSugnF|GSXbjhxoKnrF_8euYI_{3(OZ&=Ath=5Lryv?XpVaW|x;rte-6TqW##5u^7vaMoG@B z*WNlWZvTCugaW+Z5C-UB?U?(Hn_+Ee31tTBMZ`GNxV2Nj`&$jIn7!r?r?c&u3!&u7 zcuGh=z4YDl6ifie0XTOZ6nI;l9_-=v@sMkVRDfEN+>wnc?>GC4S=rj1&(thsH)fkI zE6GX-97xAwGkPix@80r@2#d?Hsbt8%M@zIwJ&rwMo@Q*T>wR0RR`g2abrh{7Pburc zbKyu9Z2-9pPtiY@ZS9MG@n@CK7~7j*y4t-bnG0M#o54S}n8ab|9PP}dZ(eoL-Ga}{ z!-u)rC7B8u5AU!x+1b6CQm>!!_#BoEeGc3#YJ7#{OCC8Mxyy@+;x z@=hMn+nz`W7Mg5CoowZ5)sThysTOOqk7oa;Za<#g@)HkWwBczVbM0VQ+=w%usEzPW zs{j`H47zxsKF`baQG*-#jiTR@m@D@qbXcYf76)MeCobnmf7l9fP5ZX_Is~ue;{ME; z`@*sljGx1iw{~`om;hsjr7$Z&wg_UKf!qIm_bq>FK(+t`PyQbqkZ;Ceo^A^Q%hQI~ z_7IG)19wCwJw5I!_=bMMMj+rMtgyu#qFa1S-2#@I{mkvcEnC(OEf?(KSO;(m2niLx zS$R_<7`;_Jh5V%}7(9BGGiqR!3%Uq>kgM(^0E_d7lDS#xhFwWKLB`js>>wtvF|_sG zA)4)d=ap2uM&dJpYiE37cj4$Kbo<^R$rr7!B}w5K!g*Cr*TzW{n^#D*Dk6Op^sw$3Sd-b$tNcgGHGOs#{l9mgy~ zv^#5g#d#L)I;PToVeDKFZ41aQjV%r}XP z6LbrV+Q>aE7!}Xx=`(ttC%KxvgTWKjGd9hl?-E`EPPTy~$tDjdjO5WP-pl6LBArj? ztMzAb&-`ENG+8{^oFlRFN~5ly)8%7Lpbj8gOxEY{|9Hd6XhYT5D1(WA*^;^N>ZNsf zB&JnkkdU*dR$0SbkJfobFbV5j*6uuYxgf8P3#EDHZ3R8t>?RM-zVz9qep1If1+F|$ zcSWbH1OR(~Zg}3{AzQV6n_;iuyXuvN-?N-(kCku^n=4#z{;XW1{W0Is9a^|}Q3RGy z=%pDi2hUp6ls$=r8qtiPef;kU8eO*23w&FOph8q?-z=bIc9Tlp$zo~8w#j1+9<4y@ zRbJ=UR5EmuO?s#7-|-BkO6Y7@6};;_br0SE#fhvVh>%}j03@LWbieW%{p%s)^U$xP zAoUXsvlb^0r#T)jNk-rKm!a>t6Eabu!IT6+JF;Mks{9)YXM;nEMKt);2+j172w zd632a=iz$zO?eFyLs-^S^rjbHfPcatrglufJiR`bQMZiL6w5qXg_GPX>Z}&N`Oy3) zU*euu#7Yc%k}z}FBvgLHS{_h;1PC;H>5&GWgAI4mkkd0015y54tlDU7himH*qIO1; zL`ZB>y>|0qHCNDWK^gxK@bI>wpu4B=LO9ZbMc8}M^vkJDiAI_ZGe^#;=AlVfB$qZD z$x&zbw|O%~4(GhGqxGgnMnp5HlQ?GAxy=%~p*U~#oG&y8wI?u=1xsCSKLgt>*k*m- zP5pbXSSno)Wv>D>1-NjR@pT1S6%)w zkC8vtg*Vc8E4}xZN2U5SKfoL9jXmV3Ug^7m0%wvVBdg|jBWBZ#s^|$eSHJd#Ar>Mo zsa)uq%V(+lmuuQ2kIA$oND>ymZE=^>eH2X}Sd)cX>!9=ZX1;P)@Pi863~@s-=~=Eq zR68ckS%QRqh?+&Du=Mn-L+TXPu=g}NNcG`7?#G}I_Ny6~6Ki7KgXAJ2*u3TU3c zF3^2~g=pa1W6Qo)T9=M~q0Ep2;t1N0Z^E9;Zk3wv!nV4nS}rzH{o%af=IC_Wl`~r+ zRf`n@{B)G{14TZ*#_P2ZjzZ?a21Jp69Bz_k_oONy;Vq+%tcOz+bhQY z(-?4W1Qa39u0sX;#FdMOt_!#hG8SZ}^fK8qx{*4FU2-e~04EHPgL5Jz!j^?(>JC8@ z;X6-3xQOWDIwKXg|1>GxlC9m~P_ zKPrgb^)aTzHpZS{5QI>_FQW=tBM)$`kCr?BfMwk7)y<=1*A>)TTe2kWfg0p>cqwiS z*yp?s@L>n&a0kEnx^aw!S2O6$MEQ7OHZp_=d%DXUXUD>mO6C>hqZ>8Y%F0beq^9%! ziGb9ORwkND%J17bM53`~mL$T1%ep)?6*LA6qz~n9mAK45i6njj$*BNuLutLIHTEp` zYzL;dn-C^;{At7{^>TM}e1q#XrZ-diBrseiE|N+Gx=QXmEF&&R1M>klHA#qNH)O3% zyf@7tkBFC^9{T}2Ci!vx9|Ot zB*@?Q%QnO^xs5_$e?~xGmue^dWr^51V^RrM51|?V?`-PuD8!btMcCjn_v5DB!M2<| z&Z$1Qt-L@}^)-`EQ=Vll9HX);hq8_w=Lt&;6yM$=jM!v~_1Pf?3gaXsqP+T}a7XQj zkGpP~3XkU~-g;%)((-8%v+X{0eP;P>j1AR38HF@q-MvrmoTpp|iPzJpeCW%6Zsxpu z+L7{dMEg=(7V7EZ4KwXU3)?Z37btT3yQ3}cU>Nqjcvs4^sdR&%BRDrcKYFweS8sst zY4)MDy)%s(6STtZeE4pcPu;`FHrxjZR(OK>dH)IOAo}MZom_NVZyAM*;I<86{UYZX zP3Tx-*-RMCS3X=Cdloh{G=LM1olkEbkAFws|b3Gx@h zQYerQ2i*G`dMbvTaSv=<3VwyWv~LB>D{>^c=E0?gp<+64SQ5FP3i*O0voj#k4tusp zsNv^u@!&2o^iAv4FxxqI|&|1ZDl z=>b1))}8X@Ebcbmx0^AAV-xK~6|8Hq~$b}Y<@Qs1NWafoEGjGs7&I2{F25`fu~4L}9?;coGT?hm0` zSzVaNLAi*qa!dit(%nNTd$3EUuOm`*oGvp<{B~zjr@SC>icbOF?=l905oiqHWs>?P52W${?@bm4CsQypcIq> z?O>^B_ywfu9plEmhc{)?iCM7g1~$nOWcg{`5P|0VavLO`lzZnO6wXE+1w+tC&5F&X z#B+~ti{i}gTRhD_TQ%;i5VzvO&%^Xm56KScTs11brZ0JDqA}&l(?~4GBfD7=!Bp^P zEUFjMYD_M&`6@-*`LYLNS zk+0Fzdp~EaUExILYqUV8VVc)*{j)fFNutKcy#(c?Ot#_ie$-YZ{P!!mCb^;P)s=@N zAe)eXFL`~H!F5Je+8x-)V6pg@ekPtq-$1w(R3!HYL4-U!Pme6@r(v^I^C37q4*z2; znJ{oE3*|SB_Iq$&6B%Ue>Dy$7Vg}kHKhvvPET(l$a5Q7%Zo*BV`&*Nafo}>RA528Q z42OxNGRlcp=V@yVYYdk}XOa?hzXT6G?ye-Nla|-pF-);KWfqGBoe>-(_biLP{&mN< zY#Kz$LyB#mjhO`aJTv_kMp?T5{;2+{!CGEssyZty&yD}&Zr6XZdF)@0>qmb|W|LTR zTjRULmY#X*-if)rUk(%nKEB^fm_@s8-gv=?7D_97I1BxSfS~#di>L7kYV7WuJ-iXu zj&AVdzuDaX`g@uhzf7{4YKtA?VfAi3-rS_O4iDV1P1cwB8jc4pvp}^s+@Nsm&0%2r z?CYT7?u9A%bN?|h*H06na77~LERQ}z635E>$psjz6_ErO+OPX*in!QBA>Efd$b^PT zdzMDm(UQ8avQ>vw;L z>9TD@oIPNF`vJYwHyP%?lZJJWdltZBBt*W!9`h)pI2y5H9Q7*yrPJ^}c?NQuv}RqLf2r-qgHd>G>(gO__a(fqfs*J<7=+ z{;};j6k&Ym2L0WRHdScqJ3}!egorN-J%R8_x`d`9X0OtLh~J%P)#{hXBG1p2eYeki zj(r;(e>(qJem02wpk{vs6NIW5GoAM!9P73HYm% z3NP;<1Fo*V*a6DN>SHmxW3^_CL2XIPI^#siub922_MF83TL`5FE)hHNRdPFNIxEE zCq$?crjIR>9>IZ+j}Xr4masGu@HUkoOeiOG5@PN$7IVsO6du6_khFG!Rza(}e$~!YZo2ec?;tq7ww?_1peH&0bJvGtO!k7lK-nAGw&Y8d~p(E(G36HE{mh1 z@+OLndC?v-1ICJ4`X(E~uX-ci_6#?kP_KMH0ft!L-HcWyL>igSP(=wV_+((pn)mUP zWf#8!)SO=84a*r?XsF!%`(E_Q_zN_1l;P5~CCoE&MaLfp>54G&FA|>tm%_0@o$4=B zDHBx9At7i|82Cs{xH}I_sB5ORtldB{w-hJTZ*B$y&7YRzIqz@HuDsJaBqR`v`RGE5 z7V!;#JD>bU$XGyr?MAc5C*e}R~D zxwci2YzD_mR=jHgLvsHRLspNNL{`?u*U-1zkWGXso#enbGZt&^a3au+)~3ni|DEQn&5)z&=kHrGC%24yk*F|;`s$ppj1zu&k16f`1$|oYWBJz_DB{J^T$MP zcU?;j?0mH0izoQyha4OJ$>@#RXy-zIW}A%K_tb~0_XjRswi}wY(8350K+03Ba8@>Kh zD6z}Yd=c-|cUpXFcGNBW3PW=BceuZj{U1Tu4x*Dz#&b)MRn`F?3%J6zevV%OfBd*j z+OAbVMLCi>7Y~R4{|FmeXOA9-_w9RF$}~FMQ*<1*g`WN`dutB;)qjF_V2WpaxH{Ql zp+~(MPm?b3Q^2)YeA^I|FnKa_8#0BR78ok!vX1@C7%Ziwl(h-Ml5uJX!?9(Ws57UE zjH`j$papbZ?%K|+@BC1P#K;0eDmhiW8#jKwIu|(0yF6((+C}&bl?6g`oz*?}TreE2 zg!aNIrw;mH7J=@@A*YHt?qQnmD}J{j+R<}l&#ALJgL^ZPB#}sY&x1XiG6~69siDU& zA8LDb<$+|#)teaMB}c4T!^J;xHsd?pFCP>UY~I;I+wnZKjjEyqW6-u`ogruL(29-F1UL?3+{O?r~+sw+>(Fk`=O-Zp&o zM(MQ`CDoX?Vr|T^CGo}Yj9kSXE_6uDB;)Qa6dPU>9kznkQM4hFL4{~B@d>4Y(9lmA z0)^Z^BjjR*b+U*c!5z{?c+f7EGZy{u5rR+u&RcD`Xtk0LD(%xVlcFH`nqco;~$P7ME3=Q(XO0-%n}V zYrk9OApLu^1bw%&4*s~_y2F0a(gyhkk2Wm#qq>wyC#uQI-b4!#-HH2t*j#2wO@pf6 z@72dl0aJaX0^N;L_P@y;wQ%+yS$!Bt{;QJ-PA@^a!tICt4^wC24psR6f03kQ7qSe= z5-~-xo1`qMWLIH|M8e3v&LPQeEZMSEl0DI42}Aa5*&<{x_I-?HhBN2<=DU5a-=A=< z>$%VUJoo*6zuqGkO(RH74{g??M$#7ip(0IsRQHz+l*e!FpP}-?ULcZc^1V3&5V&#e z)71gNFj^E83@O(iuB!{33E>Y?&q4^>`JQn5{N!&t+LNL}kBFo49}89?R$JNIL&nf` zpIC%O>L~yP>`_{fIrQqNA>*o)R%wNE+bmv#&9#dNDd#(F6PMj?6ZGjMS=GN40A^=7-nKta=-xusZ?-mj_F5p zKS&p;;SW6-e7kX4^p)ZmthKzfhiE@oS6~V7~rnVM&UVq+7rkjh=Vzs>9 z=SsPrd!JSHzj$Fl+`nYF4Qc;a?Ht?W*=DKI#;sx#P*kU^;cb5~7>kyYYR zNRIbM$MnieyA1m+c@jR6K%qmK9?8mE+p5%_^jQV2Oz(5zj)bnvXxPU!}nZ|#6{*sB z=5Z~2mC4us(#a66!&}{)dotx;D=TZ&8mW8qSl$3Rgp-<*$CK%vrp4qVQ20J-d5Glt z<(W>=uieEwJEeuZtmK#92*^x=4gBoe`J74!Is$^iep^a!DQ^c4(J+=bTKw{++xw

C|g{s<`LZwqNwpBz8x}JV;HeW$uqf)zyG}ipO(xNK}4CjFM?Lh$Zb) zpN#h2S4j})Rp22;B_-T{YF-*6s*|~z5=`kPr$4Kf6bAY?{gR*E(dFONl{0(EXFHky z0&$1JV@2H{_5BpFvDudP0)#kxhaojL_QlLv3B`jdYsBrwc2!#|YmEE%fkWUgV#NDR zcr(UUG+d*M9oc=Mk_uq887$$^YZ+fSIf;X<(!-KN)z7-Mif>tLCtTZX$fL=g^J;p& z=!eYcye&Ww@(;(D3Ec($x?U~&ThQl8K$LJ}{*$$x;93>j`vvg`fwVvt!$1j4F$C>&%^~uedD~fiEN-lr~uT z03o~qhfru7*PHU_E@){F80SX!BPM4cv_MMDS|o6PaDuM0eS9;{rcmxG+_>J$FIL#i z-nL?!MV;vruAPoH=_52}t}NY}k){>Rk>!NoiYC zb18G>D(F2#>{rlE6Q!3Wb(d0a+gH!NUXxDpt3C%^!2T#(Hq9D)woRkigv*%MKBX4i zluL;M>6X<2tIs_Op9*nH@WpH#>Z;nt2{=9P;Fo=Li3-cH7!@7g*4N6eNV;55FNUj+ zi?a<1E;}LyjL{RGYg}}Bp}F_9A*N*FWUCx1Wp&0M zP@os$8*xs}_-rj|%*M5A@?=+p$CsFkSvnbqEmaVBxR#c&9y_Fr77BeEXvB8oT7qcnN!T3Mk7Ih}0MN?w~2mCE$_|(j?El z$0H7u0K|h2zQJ3r3hcd8S|hN)3Fe>nh){ZP2dR#Dk}Lip@yks++0-52Gm*g8lUYgj z0nMqmNt0^}VeP7Ysx0Uh){xa3#fRc~RS&!OU@}r3J4xAY06Om6^+p9hZGNe51 z2;^t-s{m5S^SNen=uAnX{plj~=qoPgqxi11DybHrCwP5(Y~bdi-KvQ4{I~3TqoXB= z7f$3Wr{BYeX%B;=(8aw!LB*IrsY|ooi9SZ*6TzAI!x8$@BK2?l+akT(2`RklkZ4LQ(@;qwh!xyxCd7`W(_LUM> zNY&Y%s7}EogWd*gCJ~j1Y`3O8y7O2Fgp^emZGjPET2wen4X83cq9{#9g9z3AtwsVy zs7`L32pR~C$XJ!zrMEzW(i9ZgZjEbw91C{6!N_C}L`n78P$Iw+xhlKW*-V#xGD|=Z zfSS2BL{O-*RXw!aR`U*B^HY4F?kH2Rn3vflgl?jWlQJT>_Ar$$wAfUr5*Tp zU`Oz@6rULgr?BiD-rn^Yi+EmZKB4ojlJ(@&<3t%cQ#HPs5;A&w_mLnCvWmX zUtP$-?2wGb@L>q*su({by3VK=^BCyIZ^AS%JF-puuOdey5^NcDIW~3Yb^z-n)>;)^ zkBUF3O;MXbghhCG10eEbB0;CtDOcUVJnq|Ja%h`*G5@ONF_jyen&5IXYp-mPp&d8| zm!j)W24N?!Uz`ZF@O?aGUj&qXZQ*n(^CMTGwM>$ldOeH0f|lPOPsa<6_@hJyLkUgw zj4+6?D< zcD6s^=%h{Zc|g0toU;$0vCZy>&BHw9Nhov8Jo|0dGQ(74J>lUn7@a(E#r*&;aZL{r&`*ps4`EfI7o4=3u`ZM^@LB?l5eBB9D3hI3$7*!5iZvFiF|CrZHzNYlgLij&% zHw#rtz00#1-t@Qp!1q7CFN_Qw6nwiCgfsfEX{R!Z)!+TYqyObKAb=mJ_!?Oh^RKzg zUs8A}n%Dpd+Ae=z`Pcf^FYgyg3#znShMJjwH0AyKDZc8o08_xi+AQ!t-c|KAn2U~Q zYOzHBV=m-@IxZw9pJe`b{Ni7OBS8+ZENqPrA@aX`*RPjMq+l-oZxH@(5D5QoC4jB{ z|HdE;utxcEb+2Au9?WywV^PQbuSRYrpKt{Drg|&;wk~fapyjQ@>h&O{UzCxw{Es5k zFMG9$_;!=9oB6HNp#LLm4-l0@IN z|6y)!hY16VHW90K`M-V?7{FFy@B0`X{D;L1U zXNEC?A4AdQxPSZ7pQn+;CNPjr7LV}$=i^i^Sb#9X&42zcznoMTm%bAh`Ht>)6#w%P zK!{3p35Txh7sLL~%PN4K?@T1;{hyBz0S96yv22|0Z_oZY_Ku7ojU>!%{m=XU@RCjDRMB83j;B9B!$EQnY>xRti=)ZGi)R)`pI zLDo;zh<0qlbMsG&3bNAH*E`8|Hs9q4buYqmx5i&WFZ*1XG1u$KKVLDNjU};N&Cjbb!++{x)YTw-h`pg>(Q9EqiN!NtWG3+sBxok z77r&SOC^~rOIa>|;8@OSI~^u&^{|t|Rni6f6`0P~&z_ctCLu@|wxwI|bs4EO zO-#22L+<5An+bLne0K`F$>QZst|2~+fF>AAD* zz^#nvyOSP|7lnY>e$d_Gi*>bgB#n31OB~ z3il!mtA%ukj-?(Q=PEX+n?gX6enSC$u{TWyK79RE`>Kc`sWz5x@z)y(D^kC>L;t>% zPxN^uhx4TEtC=Q~;tjg8B2y5LT;Ko|vy|OkcmNQMUnx#AFmW406Tlrmf}w$Ox2uN8&SZ^Zfhc6=m%l=EE7> zfes;OXVCa_J4dqmGAQM~k_(K)QDAznrgou4xilBV$v6Ma$P{$jg=ed$q+QTQ1O5j9 z?pulh7!H4ToS>*ER?UmT0WmWHlsYFs+CxC@BC*_=o?kd4si)o1X7>BXrF7yj(bdRB zWTK)7kJND^Jdbwo^G`bakqbFz_liZ8*;$BWIJbqzj)y5!WZAm zB2Ts-QGPROy}#I3{bYpMBDF{ z4Um3Vj(mC#K$Wr4pT7A!>jXyhs%`>ijW}kFO}@L2-ZAMDT>vNR54(_^01hnuR1rHW zYDEcpa)8qlv7G|Q%^nNf$Bu-Y5dt6l5S{xEjG)_GWw_-<7icsvU$FaUe8whiOnVqv-Pof6%H9v22bZ zulW|EV*Bs@vCD58pttu;f{b_?oH0>=d@s&+xG?c>H%lDiz9+;vZXT350m8j50`G&n zB>ZL@7xNExH{>2gOm|jIq&LF z-^Q%n=?IbkVPrZ17>hT~;s7QLTj^u?LwWz*WU*k`94vHSuHDZ}^~54f9S%yd*MA?W zNItX;TIT1)UY;9*D_2|@%M(ixSNG3GTEwgiE9b7lj5*F%hKrJ|0CHBZ_C+8+Vk@0k z3Ic(ugB(szf{;rej2^YZ7QNREp^jou)%M(WqJ8k^s{?BZTm~YVcpj@b^@PY5hp{~# z?SQR?$wM(DA6}Ue3KyZ;wx!OPMAQ!4lkLjr6mz4el+5RAKTSUFG zJ_$G95u7#J-TK@be-5(R^$R8y#s|EgpT2z?QGL)G>XmLQ%9w^r`(Aw~SpTI0BruYQ zX|m)x>BA~A4g*^KEEq?AM-&96baA2*`XWd(Rg&lnp1Bfxwb~I_ zl&jjUrn_ZY%Gqw%o3zyggg)=NsdX)qmuOaJC;MkwF?w4XD3GU|o3FKC;Ar zEG;zbbjI%~lC}G0K>J&ipuu;ARp-Fy$Mq}$g*TDU-8zu^xrpo&bA3H-sg(TuJ@fKE z?%=D>m;xu!*x-z97ydMarMEz3zNATxg$X3ha+e-$wR7R?{U6YvmM4 zzt>9%PT}LAWEus|HOpycZE*~BXsr<->`i(`o@Tg|HC}A}FrjG;v}G%Bb3)ZV zi1%prXi(gnU~vRP`LIr+j2FP$g?pBHL1l{D^T@g6al_Yynj|jBTR_^i`o52578~^O zrGM39xCd#U^veDMxUFAF#AfYfn-!873k*%TO*68!0XCx;VA|vPr*z{Oi+^-&8Qtpf z&-;6f6GYMX*7&!==LbT`zmRi#om9rG8OlGDs7>-j=^%yDMzJ?BPFnCI(fc@$U z8Iv@X<9~MOTOR;S*?an`ggX;-US=vpb3DBFwU7Mei@Z!JwVi2%nmf>M}SNQ!RS*6meW@Vb@fM_-*-~B1p3~poUB4Q@SzUXnA_Z{ z!b5ygp-r4oTIw-wxd>KL%0eewS5}o_X)hDnuME$4yH>7;j|Lx-)746r14ZlCBkj9{`ttEkA2_%Y)sZ7(U+* zG7g}qF?}5M$(>!nnQR(}FuAWy-(Av~SS^cJC;+|xBZr{hvI%tEj9cPaTPwx$@dA&S z58RJ;vO42T=It8ew2hW*;URV2)36>ydzs#w{gNM^Q9Ve1se2!l{{C3`P>IQE06*oP znojs;Ieshu2&1WJRb}7VIC5oEs~%~Z8++nG+P{2LfdeCTnxtwV!#0rkyB8jC2r5bh zT-PgOxe;3NRj|WtQa&r?XpThC8-%z(iw(ZWG&$-c>jE`&v+=_Q0xvVr>inU&tH%q) zZ9dn8gF2fJ6@TIdlu$C*U~2^xFUVVv#rw(bt5!RCCZ|F<`51+;&Dc2{DNjXEA3Qnb z<3)gPj0L1Yanq-p`{w6or*bZUD^ewPAC6Rqd4lrh66mN`Q;%Kt2d8Ch0JVE7@k*|k zvB|;L=uWV9(waMg&QzDnu#v?PYU3&Bj#``6xq}zH_M?S{zX*=1_7OPU0fm~psar`~Ny$+kVZFpSali>WAL#(CBO!;&GUDdLvY^(j`ckrErtx)=-SXvK}@hky4()6l@@F366E&v6y6l8Kz zoVQe8bAQ0O-~U_SciSsYW34DpAE%AHdqMJxcqT!HRmA!a^bl?Y^jvI98$8 zv*_m}6&dQPmkWTi=SGWALX;X1htdT-aT??MpbNylSuRml#90p>#l}d3z6kiNf_GYP z2|3Hk`xeV5+fc26*|6nNJ!qKf;81%g#=Z zM~B~BzuPH(+CTh`NFT#)D1UTm%%8dvaO}hnc6^7p%oQ@zW>YF6x?cke6hj$J(Oqbi+ZvsCmYPYR%gW z1UkIuJL2byWqZOqptdO!G;m-@?L&lUri}uIxY1+%srP)rKH(0{`43U?d8bbvk?*?! z$?rMHmhp1wvY`Z$Z5D-?NQON^c-s}97PUEL&`?h}tqLr|)zV%!^xx#1Zrbsjd$5Yc z53b0Z*7@jb_B>V0=D3C_=!qJ}UyhP5*WEA{M?BvGbKJzh z`b#3jfuY?bQyYq@;H2{WdiIf>pdJFccX>F-ef*HW)(@yqysn)9 z%>|Ig$BTMoyr-e|GhcAmAy8=!gK`ng1<;AJSNlV3&LqNOZgnv4f{)bRr54UFY*3^O z4Y#K021K<;Xll=?IZ)2?N~Bl#5Caf;1_Ui&=NU-aG`zfWc3SBNcuxjFKGlq|+?%2-H-rQQuTw6-|y0dHx>!6=1>oyRy0u!w-)A)S)$K0`I4t`t58bNzs)pDfP`*J{#E1<7#{~6LBgx|G+K(}-)J)J z3tm~yIEcR{Zqh7n{3s?4B%!VOAf7VE?}Bd$e(nANC@=UL^W(R``OyiacsfBJ=mjTm z{+rOnoRGGeRnOwibJUH*haUt))R)Dw} zkm9tfI~w$s-)0yv1Q$i_sZS}_JXv}gkCErzpY1$Hb`tPjcL&V^eV8PYN9;YuIK1Zv zq34`KX}sk>*f^%1w=9GS9F9+)O}SfP3g(d8rENtOc{E=IIygtC$oj4`rm)-~fX5l( z5_VnZ0f&$qoc-z$eOKEcdy_utTN~PMs{#x2RY!o7em0I<$MAU-r>fY|a`HOg^|LPP zTMKJoW(#*t;b98Xk$-zhDaF|7L}8^6LTyj<(H&NYA6$bwTAsxNA_f?585LSb@2Gw} zHFjUgIHMUMYEGpN2YtUgZ5X2Lr$K`P*jHg74hyk+ve23vrnSMjjG^epu20Jpeq~%+ z(7&S{S81+mcZIt%)N8lCcNJxrWGjC1WG?CJR2pi#1BbIUh@<-ZG?G(a8F3ZqI=cYc zNEL}lc{wb`*0`CJtJmD4W!69hX(4TyMp}J(veOgL zBsUqUdvc0A|H7uX{hB>b6$L{oy(l@tYr17w-wmKMlt8uP?P}G*UeUQYY#)tNMvKqu zqzazyMx-w5^NQp)-K!huSJ8=9^EkC6&sb8V2BOJSl zZwax(UcY*vPK$$MwZDWqhjyErTew+_!)x3uEW8o(TqPRa2|J<@Hro>#o6nJPU`aW^ zmR}0BP`Ty963b_S6x1kxSQf%LyK?#&jNgyV4fEQ~$4e9mPEv)GfT z*b&*0j2kN!vG$krbqddw_V$xT{FSqAgE;-zVT$@UTE+XUPI86U-d+-A^6Mj-G{m7SZ~J714Rq3|hF`bgjJr8Sx-rc2lH-pdpX z>BHMW;UiI)e2KNS=ZteyH&zq&?YYT`HsE5TUL46exKx5@x>V}P*7kCVc{>5(wUTEC zE#f2oPXOXAvKO|C5bsgGnY&&PkFq=+8?G~-R-&q|0j$Gq7bijV$j9TOZp*%;8o)3p z(|LHn3FKglR@wx_GA%4mct<^klgfpf4Ep1EZMwcPSESb(`F$V5)j~84q{Cmk^yZqa z%m&aXV|%rj#D|vmiR{eAg%*EQ zBaT4aRBy_i)%7{K=*OU_<*$Nq&V{@dT6VCMh;!)ihJUKlL+fo!vcz>Bfq4uUV^3a+ z#{;vmLJ8yXJhG{91dr#zYyb`#3$knlRaRm0^r;^#)=DU3#Yl$K@<{SONNNKMvpwo!VTy#>{Jk(EuOw7m7wwg6RMXNj zYG*Pkgr%C~J4Dl{@{kK4b$Ur+UNT(2EfI^s&>4^jMJo#deeQcyuDOU>QC9f=a#c6o zX+Rk<6G)|0ZYUwTWOVw5b{nwX`^>+*g4_9hr!}`?ET}*J5$K*Nl%Sr5^}&paJ&kV4 z!2CCX(I~e_N5v^dEp;9S7<@2mtR)b#j2o{}4cI-p(hM+s*6vvL6`_4TOMAm6HcK?M zeY*$3ou~;#t=HX20Cgx6OtE-F8pggl$G;*562dOX-l>qBaMai!?bJ%R+x6H3t;juN zI~~TAXUmFxKtAqHLr69K5)oS79&IDmPN5PWw9g{e zn(`+Ro8A)Ta5 za&Ge`45l*v1wfX)F1UeK{o;pd4RSvj5W%PbVOVv+y#bAdH+Msx`B@2J=wEI)>U9!w zn*%MYw4tJ?N)tXks*YxE-sLPOF<1J%b#lC0uJp;8`_>M+4q=CW1b3u6H73&#JlF-2 z@y>9o{>6xc6N3K9@SUAQ%e*G{#c#YckS;+>ezTZ0u-Hfh0dSTJivRawld!^VDx#mU zG$VWLv}wAAJwx_h7UdP07phRk&oh7drIMGeS1KY6J(-lZ)ryzq_EdjUS_W&(4yA1xl- zy|d0P)MnC*`&?!i7vz-*xiyP9 z?0gOa`nN`G%Rv1DGA1&geEl4r*JEpGSqgdf(i@0DdXM?*mtGsQ8D9AjWA*7?-8wik z_9f3%+L;l6eZ*Ect3%o^%Lq?-m3ueQ@rF?!5+pe~^c-}%I3pteT8MFhYGH+KEnp3< z4G&%%#h{Bu;$KSQDb81Rr_qHd7{v}xg*nbB>KMtsaGK7a7;smSSiF4*#`bfmCVqR4 zC?SF10wF)6(QS*t+<~1_E1=AZ8XulYYY@XrcQNml+5c#6advXZ<+Sv}weVvpz6gBB4VG~Hjd1B zw*u-NRDuGE@+RJ+)zvv5#?x0gSew>Z3&%xR`f&9R^!1tEu!yw~cMrw?IOH!|inT}$ zd$0r(SxF~;h*0wYg45hxe2XrLo|!`?h#u#4m)`3Z0$%M{t~+wH1V`5 zY{RLN`Y+LQd6nCGm#lyYC>LO{>AT#f7CabAXK!(n?X5|Q%{*^V{TaR1WvUPByW0kJ z9{wI@JBU_N9mA;Yoec*Q+E5X$aTlT0?6ir~?o9!wN7e-VMu7$hqjB%^Q(M=ur(9wy z_Z*az3H!;X3R)6sou}nMT9wZU45ddDrTu|g`%F5fFal91z(X-D+r6-KhgC*vAH@%Qie{7bo9 z;^2xiPrQkKMd8l5J?M-Z2$?0b`eBCD$$=9*$>v0BzgKGd+U2_-UE+e#$8o_Awt2Ya z0&+U>LrF14a=15YnE@v+9-nd?kD4>SjFOk=cEVF3HqdV~yZL>qD_)#Wbp8V)$;0yo zWID)ejnYnbyVVzP*G?b}PdGB)6(!-jfNp`(TOq41ucwhfe0{10 zXl+*>AC9#x#9nBm9KW!uNXgVOKFl)UF^ZJakyMW2VFF2>_lFNPjG|YnpgYFSoP#qi z3>wTHv$%+MVBJM8K<0K1Px$?mHy6?hwikidbuHr@v}SmEjf1LEA%z2LzBY*bso zPkZisC44W1?#ob_8+!elWMYHmI0iQ?Nw9|q^*>U!r|fJgB?vT#$pzdypQmx+@>&J4 zFRRTE5I(jc=xiiNPWl1&B3m^iAM01`uRB(DggvrGvN@=*Af^BUU%}a4UWvliaVdFV zD#EMrbA{db(uDPE#eLp}kFs0+qRXSbkJkW|k*G``Qb+F!ei@EOL~*d1?9*E(T?dhcUF#7O3aVTxQomFH|Fl_b6`L0)>JO#>Ptc4X<+ zONfVWKN2?SK`OML8o^Uk$Y5cKTJC+ia4;`!SK{O@{|&KWB!B>~?1(@)ZNI5E_Krx$ zown@L%HpiuVy%ndTFb2h%3Yym>m%^_64}F3Km_FG=X^mW*7yT798d4m; z$1i-5*-VtyK=jsvrU%6>>uluWn8t3ThVt_s7uTsCLval|8rb^%3PYfYRCo)v{&}-z zG3lN#1qmQ{%QKs({Or1T5GsBqu(-N31*@qpf*U4#@(Gb;cS8X`M$55z9&1UmCu+2= z!G4;OOCVd+jpoG+?Y=zTYBF>wwoZ9$sKnf8!T7kMUNmZ3)R9y1r@ImNI!hr7?oB@6 zmd6Tb0-74_MX6P1RWtk7LTLZJX)mFNNZ90^6Cyzd8i!R)Sbo%QQ0Y|GROt0Vy~zkk z@}-~!5|enB+$I^CQ@Q;*+t+Tw{44j6V0(3V4Ss}EcpQObUShwVgUfSDsy=;VoQ%p;w{GKTd0qjW5rHYVW+?oR{WG- z4;ZzLOSAMnJrfh30!1h>jD7h9?Spxhn1`;)<-QsP z9d;FycK~!F97IwEfFz3p*u?ju`?v011hJYrI^)8_1;-Mgs=NHmLB*APW9cZ?ZQ3)G z{G~`Ue#P*|ULaC8yr^Jul$z5rUT$Fv(m9HRu9Z}o>jRQxwAFh<<6JBbg&ap9`C*Zrx5sA(&oZi%zq}IX?0+~~lIB!5=4W)E@|TNkL$<6wRSi^b z_Tjz8GxZ0}J$760&!5#mGVU z>kMwSy>QZ1L9_FP(6jft&MKq&jZ}Sz38`mM!h4(1=`O0jH;XjXS+}5HLj~5|nRqNU zq|!TB8qE$<0li2n2pY`2G}%*dzpGTucV^ zFGp6%A9+WD)KbOs$FWcRA9%0i;E{G!-zI zjPkF!4FuDn;6#CnlCNcc;1Ys*9iO6spNc1o5Z&8S&+eM^1Hv9eY$?{qdqGFrw&=a&xORbZDv7S7}W%$k>%cQKVh5Aks) z4u~pGmo_e5bb?i26-M;dpY0V+fYK0(^HKL_Lwnw3*Nw8_Sf~L3KXO)XQ{+=O_wLE| zBI|>5L+kIXH1($)G9U#6{CSsrr>eoE@Dg)RX=U+K9>KXccWoUEH}DGEV$*oX5sN!u zeHZ_}zGYGS6F|q|Wj_wu#2rhD0|tK?iSvhA>a$C;ji)e9|17zjz+|89>-?a)Uh}wn zbLTF_!w(a%!D<*fi{IzTdy?k?X zt%W(^D~c;}R7=ROrbAaMC0(aI-Flw8YTzu_({j|GjGLRK8IvB;S4{>TZ!TP~;y~#m z3_m-#tQLOw(E75v*_f<;yT};chkRxkdOhWuUBCKgkE@FDd`flu@bk~1-@l||GRghD zPXFbfT|z%Y2gOwtT|-rGy`sCt&XRKK=I9^+?W0$@8~Eyq!2IP8%Jxt2R96i*fYL|$ZAiq zH5wycx?~kv2aLCdGZx}C(xB>VrgPaXH!?5)Kq9_VBwsaRD+7iCKcp4iIoIiO1%66PFD+^>><+rDR1u#AUgj5lwOal;Wz|y z{~Q-Wbu=n}ZG&-^s3>KoJJt3`Kt9Jp%%F&E)m3B6z89F;x57?d;AB>mk?}At?&cH~ zlIS(ETgCt0fWOe~Fn(X)Z8ADQBkrUBI+?0h(e3Q)7%EHqF_>W0gM-FiLRl#&!UXYi zmslZ}x3G}JH$K$@qKYL*Qu4ybO8^4+aQd$L0mjWasye=mN59_c!o|Q)mw?rmm6soV z*Z^l3#J)j*JkdtYC_F><3zwl}tC@?Pl~ozqrWr!ecmm`#f@)5gME~(V;3g06rZmWb zhJU5rnK4kR?s_R$l9n}6LcL0)q*k>2xe2}w(`Q(^UV0aZ(=y=MV-@5_GQ!Z%BS+(( z1IW?Ov>{ zJUB9P)8hRaW0=XuE7+g`a^ieY?YCO3U=l=fT0FJimxq zvF^`iYa{#n{Qr004i;VbRm&MUGO@t)ktwgD zx}!Ky!40K{^LH|qPU)YIkgO34BHY+eg{J43NUVl7Q-|ixb`>8kZpK2XpWETDd->kF zT3X5zuN9jnT{RCo6Fr!b<^qAkEwTFtdRCrgQ&S|!RK!UL6nS5#$&0N`gBQ?~yn&@^W z@+8W-ULt+^J{BECC)?&Fh?7$gpm{gaH%L5`OQiTP-Fbee7bs(q`dw^Pd4194!NO6D_LC?P1Q$N?wp1Q?uCBiBHnQ<#5vOT8=y!S zn4P4156*+t_%-4z0%YDjkAv1N?U1-X25te}#l=P4WV-R%KF9usUiOoBfHtOWxL!;F zKp8Zg%K$BV)r9N#MlpadXrP*WQcZ4i8C~i_CYiqMoml{IV!N60nD)e;;zDCMSMn$p zhr{a^ScP&-gul;!1}kAK>{BBKlZ&!^eVJ#+Iy#HfQfJiCk~!eD074v)@6Dz8-bek7 zt{L`t-yq-<+%v99Wa1iQPBxPdO@ghto9b$FxGXtZFfn<4&t28EUQp%1Z#&_(fp1)# zeokIq^y+GRx+t^D<~QISwv*L83sFoI;vJW99^Vu4UYc~9W(7p;iWIhrH(n{a8seI) zH^^B?CS)?ac4?`9moelQPCNC(;nMV<`+^XCaBxuFz`#O`m)S2;e*~~bwB}?bSy%Wn z)iO5$uM&b(uXyML)gWmLfHpivj|gqAL2@{8 zck^M#uDdffS`Jp%hbWT^l?)meQN#>{c8khwliikBK@!EKf#ud8sfqE=#hmgAV|8sU zJu_3;Im+Rjgfe>_q^A`Dmymg_xW>=1RWXZ{9QE&@M+z>|asP8=qhVAlrR3(e;~x$k zGN(xjA?KC$MIuV0llVa~U+RM9>9w~?047eo;(=?^#&jTRjJ)9J zS|Dam_9kd)J#2DX+3XLXtKrYQgm#p=(K|BoSe%`m9gRirS&)OHi?=;F-GZ)_>b+7w^tvnUj;#CcsA_ zqrK9dX@{+9o~HF`m?(wq>!Y;R-w(GYoaK}7v54Xl-}cJ&>J#3bnN;t`_jiuVvd^H- zRYZcF5|<+=CI-KxcX!COE2j*gN47E8w<-tc{jn@B`P!3wPl?8?kBf61ONtyE1y8NEI&duj&jA5=R+Rx$l2J~xQTqQc%=w(YONHJtAET*F1iq- znSp^>oiv`ZwzhRjaXSVFYgf5?Dq6>ggbB~3Y{#kD*3ZL``8bZPo1W&Y7G~~7e2}&F zGK#Q)+vyyx^B!Yk*z&H;k+6r@s@peyzrT;fGR7Uz? z3Vi=`aj_anSSj4?n_s?y9ucRP+0(vToqsi^p?NH=6mH}P->4}sU|`Cbb{V%0DYFQ} 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

3r#G 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

OA9RcbD!o(KIDgF+(0_Rs2E&|9W+>HxeMrG~E&j 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 Vros2~>;v#iTv$e^L_p8){{fBE!9f53 literal 0 HcmV?d00001 diff --git a/doc/settings2.jpeg b/doc/settings2.jpeg deleted file mode 100644 index f6ea3f16f55f76146013a0b7b3a9aa3a53e30639..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 430686 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

O9NpRE*KLbHtxj?x0(h`JrrUDRiJAxkar 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 z z^AZgtw}R9eZ7RihpI|xyJRuwuHaX?|k8&<0s*ApeTRePwSE6$r zSM<(Ng4FTV^oGNPkGu$ZO}j*1%9Xs47IZdW3&~cs_=I|WXu%>TYdL2btwa1I%6pJk zD;VH>m84jQrs$uhiLdnnTWPL#`hqum!81WCm-bj#!u*drz(v5sSmJZb8g8XIhjtCCSHoE^Gk|t6-A`-6WyQ;PvNg+VKO+%pkz8abHS{insS9 z0u-cv0PE}OIT$+3oX>8`qjS(S@w#jnKz-ZUr z3j5h^0AV)<)wPmh-DzOw%J$v&HA?`jG2opGE&6gVW#T$qrEI=)WK|jQS*BMg=-T=%|)EdMUjgEn$WB3s2coOX5I4K zRnd63b}0oHWBkMUU$@9 z#uw++?4W^+Uai9#X(5fk(-${H*+FsHbZAC_LfdI9czbudSWz3IalXAHi5I78qu?CI zIqi*IqeZhLb=2zDgw|F7QHBb!m^cjhA|gMo8y=mETNl6qQdIDAv|xWeAUU~wd`+Gv z@zu{A)B?IDZ0pwo``rzmycXQcw*z4GEvaM~o>($ELqKNBrKULARLAF~Rhr~iyNM_P zC{GrM=Fz(l0C0(U$N8=>?Yj9OfZ-p;+S?;PR=UPXqde>_#q7RPZU2}Jlj&C46b%Vn z9Lh14{M}p%Ub+${wvj(8Tn;hN8Ca0m3^WWNAF|oPO^3)B9Oxew@@l1MuvX6XYxQ-8 zjgA&=o=736gIGO*ptk4F>!gJ+W|i}bB{+Zt+R-F@PXUK1mzf`I9}I}u!4ByDJreVD zK-+Hcg9k_qVK?>~c8ap%-OrV{`95L8d*jn1tfrpe1J+5eZ5C^x3M2ca@y)ix$K>!C zY}Wn{(g8192xt+RFYYIb8R;fS!2$14jCpC;npEnZ=aS(W27lPIQOQcO8}p9MZL4kW z&Jm_V(C1f!Szfb_yG-_*lQc}W`os;DU)pPYCyhHp{Z!;IGe1XheRZ#cE|FMqd%Ll0 zt!ezLai~Ck_I(S4)nK@wyJ?>pQxDISJin zAKnmJwKIOPb@bU|QywcUphErt?f*5l-8SK9c#)-TqIU6`2 zlxR*^%U#@iT8(QeNpkLF@3){u1G*Ptd*6W5PHBkholfm1q#*+q{-&uOxvszuf@hFx zf=ixy`YN^X3F8K*&MPZmAXpD7uHNZO#-lu-n@nWe0AgqSTzVDX0h%T{%Fw_FP0u%l zxzwNDLG4wZ!RwRzC?W-Y#7{*MeGV+`$HUUq+rfo0&r|DVRtm#c&h*W*wqw-J(aJm_>MbQxRhcBI>*~qPGM^G8L zO}-&FR||ybx&g0d#iRLXN!iz-Tp*u!lrma+vOR3*h7Yi0R}xN9iKkhMZSuh$s%ti; ziLQiD%LOvF$5D*F<@k=t28x-62JfSG%u&y2e;JV4e)1xoHQv8W_dUWxI1EJ&Y}Yb(y}BcLe4K zWUd4sZYfh!5&=)lDl5Umv@wbhm6fSaz`fr^=Ft8@y2s<#9-$$I>QJto_j0;&nnZ(CP>y&m43Uj`6^3^K7*)p;oMGtx#gJg#5T|+Q=i)R!K@Obfkaf;|xv+f1j;%hy=hXm`vBwX?L5~UxH&bZ8%Xw2tCR_je1 z@3Uc(YbWgl8HH$FnE-fC!reBr?LtR)>y-WlYMX4>xaCj;`(d8u_mz24XevR6O>fos zV~%u)1t=esiy~h+1B)eK4|_QG1vx!}5p=TS&?PVUGjqdDi2Q$vc%n5 zIz<<7k$C_^voiD8dNuW(oy{&(qThPK51X|#eR}9yx%!0>v1y&2377g4@8N9mhOkRS z6UPmX`F)&xlizQxY#TMcPG@=OcBK^(w2|LqQI_2Gjz*p_U5I&u6n>k`$hh;PU_yEK z&|Sr9FMWB#92=haruP|N1yG|`b)CBa&q@!v=;xD22qC570!k} zZvY`Ut+XC_ph|$rP<2_j$ zK|)Hp@_yy`JdM?{L-a$Hcpzp0Ia&AzR3PCbME9p7ZB$%o_y&l1d!l zQa|LDYAjze?8zl1)%2&LM}V@7OL;1h|56UuEhM!$yj-ykPtY3cc!DLP(YUSr&N0(* zr{R=M5JHU_Etk6HbdbEjCJT|yp^+l!@i{#d(^_->ERH3EI82uE)J{~EgDokbtV-Wn z-{G-RNIgPzDl;vQ0z{)K+b&%g6gIsO87kBSYWicmArv|D%WlB7&J&NCVQAKR?ha{o zR^$^ALQ!*ij&*NU*I`2F1I0~urmHZJ4%N1Q*#%G>h85;Q13MNp{idmvdL8MS@x+`AO}6*ZK8wCb0So`iPMYu#r7wkFo4ZYlpJgrqWj=(5Nh3Ei zFzj)aK3Yt4XGvE&bcBW;08l-Q`b^n+_>e&Y^GcQIY~BM$4yupW!2K|gzTL)93y<%F zGPmsUHAwn(N%-{1o{=@(I$u3XaV}~j^_#ux6*E}s5=>R8EM24{0^9{9WaCb15FHVM zx6q)Kdk~-uICJIBiIb-1q)oB{>HNx8^#{|!90s}(D|Ix3U?edU)d}t~r5O$W)~2ji zBOz;=-QJLiuZ4xPZvZ9XiIz|0upZmOA6vb<7IUxTIlhMlm>pOYYG8&OSM}SN3Ow^!qp9!GD4*@9csS0xr723S5NG`((vpb59 zXW8A>0f@ZxvN<_JgeeG0gjPM#*N`7?!T+%LS)#tIT;9kf*pbER4jG{t86@363#XH9 zWOa?tr2&nT0snS;o)IRD0x6L2%Wz0a>+p^UOTGvm+HTs{?&MIv9aiZSFHCm1q9lsC zt?QfSrsP9EZa|3u z4j93Y_Tb!>#@223to$F>A73w|_3n5ll>2SGU!z+ffG%H=nvAvL>+dB%Y?o+(tcQKJ zO_}`#CN4+Ji^Z1tty|COjZZJi>%wG9yogx!=6=sA?3$`6>vCItxmKpqw>*@S_YDnz zURKtkuo>Axzli@h-o)O)if>5E>+XCbWO8|fkx?_4$X6|eM-JUnKE_0==LP6T1vrae zJce^C!HnZkOONav=jsRnMACD>@{&VKQmoZZ*BosH;&Xf;y;-{a8NdoHa1G_t#~Xvk z??waOsC7k{$X+y$p01vKuOdJR@hHW^RKL)4=+#4N&`Wn!za)G_4x zr9T`iABGN(q6<7rB#=1(TF4lIo#S;GqlgIJ0Q*YY&U7&=6a3Y zSxqli`tvA)#mfId$|IWzEK0A(bHN>iF6O=QAg5DK57c=&@3ql!Aj&yZT6^qbk`>11 znaqnw^-U)X+`hePPtHsitljCCFMk|}1P70BH)H%DDvFm8Dmp&VCe-P)|7AaTa-q4* zbC8#xd6GX_L8OTIy?OrFgx#v2{&`YUWLM3_#zz4>#&G_Y0O+HPjqBq(qwBWpx^_PV!8lf)^y6}5V{S}rqO6_p-s)^T(R9u zX~PNsQ5!(8h-k7gA8fZ5)pobCj-I0ok_<=|2Qm!B-@+-i-=fhjW1ebLTL8Id%t4d& z+sMuZ%{I;2=kuLWZ4`{tfOM2O@O2y{Qa$KC*$ZCE_sysD-s#iIk;xj!fQV1YsLJg0 ziBDEJmuU7;iJO!+Kn_LpV3FFI_zmYCB|A|p7P*t%{a7}g(x{=SUXU>z z+t!8w)gAA>G4o;W)b*fpT^nf5A^^)U$2hQt*rIb2o#g=}iWz}V zWXgXm%qBO>WSX zN;yakf_CURkCL7CR``1k{htyz&fuqB`UFdAZFwz+x(t1MuKAk>73Ka;K zYYfy_Q$gyfYMAv*B7n9?F5qhC>*d=ek7BLSDZEmv3 zFeL-*4smN@G^xF;J8%HAdIq$t8N3ygFYGuq_4-eX@YWR*e2mo^&(W`nsT?uJg>wNqqx zE zX!u`X@5vI1I|r!X7Hx!an|(i^v;cl-x;>oFA*CarP;sU_mwC3wRN zf1T;XVmZ$=H#}3pv5Q$IUsDS@1=bT!cIZY9Jv-b{F=)Tng{IHhu^%{elohg(LXvl< zn))d|N+ebKqA}!L<3Ov7K;|Bq=c>IK_yh0OY4Y_|aJ60ZlKwqJ(W`%lTJzxczYyfj zE9VIEI(&c__Y0>+jQYOmzinDfZ&<);an)IoF3ZgGXtOP-Cr_Ocsv8a5*zx9;&YI{7 zFC&v^X<+(;NCfczSeB1*^9tspa_o^a;b_RWOItpAu`;-&NQR!5c8*Lxw!Y?198e;1dbDkw zfuTW-pg-XTsigWLGV?FerY&ti=Eq9;jU*a}ID%AuW#+P)}`} zim+XN%JK+dM0#~Rd<#tCn-bq>BzlY==&6(DUVKHjs=?nlv2LmWUQ^N9dio)i=EU94 zI}CY@{cuGeXOL;V%N@PrC|%Uk|GgF)HvSkABTIthpN5zvZ+7*EyX33E4Bi}IWNk(MiC#zK|VmE44BeP zI>KZ3h0(vB9^NUpTgZnbS2-~-)jpEiQ*4FMt7aqzWA%c9hu-&(#l>73r!9B)JvVBV zEwDZX^-j2^ORzi4EoNW3WxBODVapasYarOTckuB9ptA#P?w}$7-uG$W^*5a9?3Y(( z+Z9q-5Sfz$%!WkfI@*@VcvlV}?4Z&YH2``bXp^<15Hx~U?^|Zd;sTVLaE)X8d4Y9Q zE->~e3tLLt1fPSYc4*jXD~n*c|o-e~d43XZUo+iu`O{pmAGHz&=RV^^64WbsBarv!OC*pm;aQ%t>%s{QI z)0NWSyA}Dq%;9vdWO|_`suDccIdq;gJQZ}V_Zn^? zQXO1sTOylJ01D0BB5E~Ecq_B)NGgCX@8A{!OcjDS*S2?{u0A!HZizv4`k z22P^ol(-SC@~&y#_)t4@DH67j-R9&dv%THZbqNxc*Jt6X7tYRN-xFHJn61+>pBpz1CwMBqKi#I1hI&fjKBitdy%72ubv*OlqE<8&RkI`-{}r!Dbrpag1mcSBx87$VqQn_g#tao$;0NE* z&Z0}vDZr*$V#WfJk2!Jf?Isn4s$ydw^er`ifiXPiKOX@m7=bXc-L3;(+B`^}gYa$A zs7GYKW_9u|xu6p_a84acoGjZy$ACixv)HKXDaF2LQ&kY1ew8 z`ruP~w?)bCTvVN>2)W0vaEh0eHl{MD0_`dPf}MGe#_UA&zh*0KlJj{(W%>BQrQ6!S z*WUaFVmlu5gt0__w*AMyj2GyEbAUHyczVRm-xo*y1oDC8?FU(9QIuM|6CC#r1^e0r zWlvMo;hFZ`dF2}XOpOD@Vi?9F3;)QYo8tYApE~IFdGF=_*o#A-{zs4W*mnWP>bl@*Q+8vUPBQ4XCYd{cD zp0f6HLVrPFUHh-X%_Lyr8D8{Zr)7rUSO&xW1z>sp``P3FUomd~_xS&t{Qui}|GzzW zr?*T6T~@7rG-3WnHpCxeO7(SV#`VSW7 z(}SNz2tp}eAo|47<{WnMhlN8w`H|&+1FQimf+U=ixV9fAu>3;jKeZ%CGyg9*#Uvc? z<*Ya~RRDu(wR^{|0zNph3zc!3LHHvR!eOzqqM$uB~g2T6o~|MaH^OaBdK36zH4aGJ6UE*mcm z$=HN;|HUt#xe>;Y|AyD&L^npkOwo+tl<|HP^!HCcPTKhgYe&8-7m? zwzE>mue;y^M&y6Rn*ZEJOp5Rn(ae@#cWm0@B+!2^-KeQG-F{X=0rV$R97tzTEHTSd z3?hUobiRiw=4h;OPPo9=jK=DR^Cs?`6kf!EB|QI1dUWUkTPcR_2}Z7{=TFcoo`hG1 z#+nqESLVyxpJo=AqS5Pi7tc$)KfUJz2=PsG#Y@p<-8%P*so3up4jE%gHd_wos_%Ko zq{`B6Q6s)zU`1e5)MZD;21c0aNtcCRQqh`l}hZ6;VZP8{~_ zV%O!rQevFV9xS|R(9Wj-HHqXrxsGR(lsC*6DrZK zo=B`B3#HBB@9@SjS~z_PaQhUW^ltvT%}t80R7#YqZw}-+k{JxL&gV&hMSFJtJtab@h+Vl<0j+Wc+7g~FL%}NW>ilj)ArWF zK$MFk{x%ECPl4+5Lr=Nt;TF__R`VSqj9kP^`q|^};tleyMEc{2d;hXo&yN+GUSkQy zJK9hB_3uZ143nqOjTP`qPn z_@B<9J?c3)<=IMZAYkNj%A&|GhsigGyL=?K+QZXcMmzu(MrA4X98CtaDk`)c^b(x4~*0m(m|;`cDr; zaVi!fmOg*r@>95W8JTXN#-OBcl1fAX)T~To*o>sQYG)WQIHHdJWdfqE9BI-oeiA}9OjDnD2U?^17lFPI!@ zd`l93Pw3gl$>J^xqm8NBtQW&r?Q-%Z?%QU0s6fK~Z~mu#LH7(JB3p@f8R8xN&bli- z7$*iGoPJfmdZ&q`Kh6{2H>##TA=j3KFZ)~ssqghY`H4>6R(WH5}%yp{b( zMZHE@_=(Uy?JdsB4Kd3fHv55nd)})Ll;QVI89|op3!BUv&Y>?pqI}~0pIgsAy&HKk zs^k@z!r3P;+$3YF*dG~gq~&k-OA-t9INR`3pvl}%JV{=Ovy7MnCHKM37K-^sinAT@ zK%c};K=Tb}+AY)8V7LWqz1M5Fz&4{}pHvj6m$GKQ`C!b-o6S-u;op<4Ss=OqAmO|+ zWcE(Aix&sW*S@98gheRyCS*iYhplR;&bTG1aq#|Ky-$MpsJw8E$e&I{GJ*Y^aRgzzIm7M(cyu(6VYfVsQQy0r&W zlBhTObPCZX>ve~s)JPNne#V}j?AMn~-8em1VcQ111Y$wyk$c<64LWm#J>wI=K<-J+O%#5+ z=(t)W5{If<)(-rk@@0ZCjZF6$*uKkXB>;)u;NL)(v?lrTUd1SGyPwr!Moc{=Ee0~* zmP7?I8WA~%0Hzpv>dPHPvu%c}G%kbcu*xk&C7|=xDSG)dTm&k}zFpK|Hk;YjAI5uH zq-k1Rm-@SF08sOJCBAJMvdhR{swKS6yH{m!d^kyzV9TV_ zK)2bKDrd8aa=Pw=-8;cBE|7m508J(Rf|x}-_Yl`}fR;sgC8!&em;#~&?|{^XH1+3r zkL9I+PT?P4{~z|=GAin={~JDvA}EpqBGM&NGIWb{3kV1dATe|!ATWRe(%nNUjR;6L zC?Pd~(v7rqH|V`*^mxwy|6JEv_nYT=^{jK&SuQ<{zuCXNzxx}Xkbq2Wxl8>}=AX@y z)xSnH*@{AN0=P575>WPq=vE5HU$8nbrB6=Weg1_e${4z#iadT42r1O93LB;WFz_~j zR|%dbLpdhi)3-z~=Mi{Q==pbZe;k26fIdo59GM*?|`BIr=`1;)ah!=Mm(3jGy)7F>W zoGhrV77<~*5+m*2(0X&vAqWy(wGRy{Fy7YFlSD8m*m9}0Ke|GIC(pAS8WJWSBDR{R zycEWFI5<|H%x9;1H53T7ih&=I7TBzwAKFjyq;xkorpF7q%_VKHxca^f)6~N-{~AMJ ztf4Xylm5D%O5x_~@B)=1m2{_)tG8`tn1GeVeV`dwDZ_#)qMNL5A1~nVov+3x8S_Z)ontV28H2osTSv{( z91T#t}*PNk(Rx26+o*PO6 zYve^9jp^V->YQgt@d+w1(hm1$cO$dp5@1WTNr;oLo9y#R^QsV(eVOFPr}f7qJl>ss z?IaNHRRAsxS=hNa63c6Fk~E2SGltHBBf|+$poujH!;53HI<&{&l;~^iu7Akq>b?C< zLo0nR-zv$nUS&ADHwnwQ{bT`LF0&b-irg$QLW@L__f{sV42)zB0Dg$az^jHMowf(O zQ@dVKk7vCQr=Y*)Vgca2ENGQFyk`G+`%=?wi|aI9vUYi(y(}qWhxo!O*C2%~V@kLI zp%id@QdsX;5fEGV?9)Mz7xpROJfI}uwF>U0pN2eOz{|=K@6~9PO^3+(I{k7yi^Rl) z;%YXA!=vZt(&m9zg%9=*mkoEeJ&XynC4jjia+S@=T_<|?G)f`%1URffV)FFgQj({r zh-QTDn~&8UU5pBb7&p4@8IzSfi_@-LcPVq(z%V`9YSx(iPhc`TSMPgv;w0=PYm(N9X`=&P0^b1mbe6+hxgPdvmoa^BcCDk zO703n48q`)5OZbq9M#JOIQ=qDe~g0(?R~13;x=Qf*SDLS<}OWqH%B=&j+hnN-Wd^V z+^&CHfH&bD-HBYEu6EM6#t~eSOxuu|9Vjaf38${h}k#)+I+Gc5fn?eoO$u=eu3;w za$ZQZ=smhoO3&w7WmY2vIu0nzD$)%eslr*+Rvn??n14kL{KSM1Ular*xtC97b+Om2~u@7715y z`LRB~{VT`M3cHlB7dQB-kEvmeW|I(>g+riha$>Djy^^C#YxFt-ELM&CxNOZyAjIL| zmN;WV4}bs|HD2%vu1l@nXW?%PlC9Z2JedY}Ra-aYs|&s%!-(35BW|hAK!SmnMJwL* zpEt=MthD-*FXnq(ILADdo|bH6VhhfAofTS)Y<9>Blv+lUzysvv#!6SC_urNHA~`Kb z3h$m-)U}Ua4z1im<*3(h!D?N_L@TShK$wfw7PYB~n!e$CEj>B)ne$kw?$D;^r>kdR z5KO+}Iqx$EzBH13f+VY|D&rflG142u_Rdyh)?KA`c6bfqW{Uv4jp6dBTaW|?AK8jX z>GKsfgq(lX;@xptqEEHGWr;hl!7_xSkdHRO>y7)DcLc5>RAk2$Z;Wr3Rv&&~Dvp`v z00_f!?F!4dD2F1u`W=5w#lmVZp5~~Wwiz$)#`P?4erzt|Um8Xs)Y~EXB~{Rw*DRHX zTBzm&PJ+{G!xsPc_!VQB{%;?DwHKanVX)^S+bxQ%?u_gSD91)Y5?H?`Fvp*pAgt^3 z8^N1?T)p#|A!?qX37AcUl4;c{_jR1}=|oW0O_w%-TGdPxDDXAtXtAZk=b+Y4)Xpbs zM)D6=rq0TJuH)yXWc006TMkW?ET+835S3Z-JOZ;|rUqND-4yC%DQy3tREgKD%)LoVQO6K9aQnC3DRC7Qb&S>kX=g^hRYf~hc48iLLvqV}_ z4}2Qs!W+Q=^~^3yG8WwNwG?J`@H9U};UF&5Woz1IA!XfhUh?Ie%Z}{hcR7`k>r| zKZ#VHsI(knRLju|c(g^T78|86t%DyW&-k!co5}AIaevU2=}Jo zPHP%tIE@=Y@LJ*G>JVMi71=)Kk&{aEul)coje7uXI$dR1u9)kJc#*>K&rN^Q)gGkc z9(rouE4f4nGr1kZV&0$!mm=#__fkv|cC(aRm%ZNwI0h|6_wSGmFIW`-CnNejx{*5v zc1y^D`n?@CjuccH)>apvq+hlqI*_Au<`aw$V1AleO4)o*Qbd-JHH?m-k~ripUJHPL z#h7Q|>2EaV{Qb*Yr=^C|@(zQyHbx0+Wd`Y+>7vUYDQzN{*ws8BX~NFnEoE|nh$K!hkw$BHb7u=) zoq^hLu|aV>n@9^(JbtQoNE#y63*dckOli8GFAJt^Ieh$;kFT;|uru9XPa$0^JAH;; zI!2}1N#{Jb-OlfDZaGP^&SP*Xg|P~yK8bY$0^q66^-D=7QpD=lhx9?6!Q5!pr#>j~ zwDq`jSFOqGf^JTx@xY+^aeB8p<7Jn{ll8K%R2A8|vBP_?6UU3GdXFQb)RVfd!iD2g zcMj*&1jgo3ha%&a9rQYNMB=k`ESNSnh?!_xdUOOWFz@-Acbjy7pl0jXrXw4^h57kF zyX*N)SnYBePn7VSdCnL`8YXQRe-0(OI>&l|;;1nnm$O^SfTdEz0 zE(Va0SsnWz4TlYy6X^4=ZTg3?X-etdKWa3eyS#`Vwp#Tk-_ORCf+C&MJ{{4XTcCss zk9z4kLfCrAeifIVh94K}iMvW7x@A1r6`<^@ra#W5iX^xi%BX^|D1pyL=cK-iVE#Q& zjh)toFNhd6XUS5`uRkf;0;g2xJe-0fL_1a7y=>BX zkbivR(_AmVea*6O{dzjgY(94VV(OVsK(Zz1S~}& zhwEc8Wg}|UqNP63EGb^@GqO`KsVi*e7A=dT>8R{UmbZUGcB!CbpfsZ8pS-8{3A5kJ z4v7qeLTt^&VO@%XX7|~rdbb-^yYlzDIwEb^I$9gDKiJT8 z*_WtV?0BA~Wh+}3j2XAIdo-lN-dtbU|+ZD{D5OMUmN1b3V} z-272oEDaB!pNhOwuMkofB>< zC?0OF3N~L7tg1eYT*B!ru|vW1YcVix+YyF zD3;QHrr6G1i49?|Wlc69RF!7jalVQu=jmlP@H}unq>!0*a=fsa`C(+O@343bUpC4l zoR|8`p(3+@jb7iq6r@fPfMVFFKi7VKG9puY1_jdgs{ zKX%%yJ`XFn|9Jn@A@?YK%Z;QgY;E3?do#uQlJrD`kmxb4YL1U28A43r#$4i=;^rN0 z%jgY(aBRaxOdy=6L?}?!KhW;na+k~0y09LcO@vC@(cR5jt87z<)6Wa5hP`bdR9gEe z1frH^<@E-^RcxO&)6KCqB^)7Cl)RT%efa(B{FP+~_GI%WOj(J}g`Qbs6NY=>ln z$DDW4kLpyP?=n|(;Ux5#r9Ak(ipZDUC%X>gYe~``j0%&Do(2f;{qmRWR{9qafS<(; z7)6b1k=Ex$dd2y)QYrW7mhr2S27uyz+S6D9ns#EB_0jxc&JWpEjStzHMhs?%lC>hu z8g)s@(q|ZrAg7O%7y{m?8$0tPZ5u`+!sa-7rTZk z`d{1RAAFy#cMIWaxGVJRBXwQi=mn?o=k_JiB9#)+k>#^|7hBY3v}hl50MU-VOEHOC zyWtB+F*7p+;viaqBn#5UEpuf*0XN2Cj7(S39#kfwXR$Hc@@ck}Egpo|N!q`;`N z*fKA2r|J4$z;dwVt!&D?a~6tZh~Wm-Vl27lJL6V$bYp0o7CFfv-}-FIZoP+Sy?LG7UxV=XZ$=pEzN7AXILeIUjVtM6 zWP9TTZqVQ!roO&S02B`EI^erOjaWW|0|%)%eErt@U?089W!k%Ex#C+*pMl(*vTI!; zUO;T|A|)!k;yc~4NkbI(>p;ejpt1GN-yoFivd;lLOd3rrpdg_iS#NpTpnzHItZmNFu(A6!vfK{VEQ~ejP2O)(yz5)?Cg#IXa^t5`DMD zxcaWi$_+U-AvzJJ<^xcx2133jM<`amf32D+JyM!Ks+ByZ7cMdvm`Q8lIZii2sb1t} zvrZ|*{-b=ahfmp`=XGCPpX%q|Vu7ztJ70Fsc8^40=gmVx#8G zkkx8>Y)sI!i!oqE_2Y!pr9bHc)Eq9uM`3)^+#ZMZ8f$$TKS}jRXOT?3l#Merqj^im znGu_oK>w2vScleGj<@`g+`EzCZSpYv8iW)SNh#5Ixv^iGPBwtVZGC&I}2 z#sW-^V8prhwcBO{ktX@J8!FxFlTHV6{w;1KXHMg343&RnZk&w*w?H3B1n7ef7I7F0 zm5Sb6O9s*c%XzS_2lO+-%`1|~uRbE3tQny{yA9Gsb1fKo)`klUhNSxoq;=z=?i{Zt zWY5+uBeGgLed_g$x(_!dtcGB{DFuU0F|q;)^xlr4f$oQ#i;4RJA24*idrOEbQTDE{ znnBz4Bb?%>HY;Y?u~9t5h=9YiuaPnL!7L$Ju!3}jiKt?N=|AwFz` zu6|4Bwd2~bP1I>XM8TRFDft0|m$)gj6&qlH^5$X@rqsQ!Ho1nd*J|7v-ngPsj-oMG zH}+fUp=67LMcDJyUrbb3@v*UNVTZN25uZAZZ_cB)#_v@CExtU`QP9NE?v6UJcsj#f z1j!akwWFMUUUaP+C?Q%dE)!n*&P?P<17LiZ(o4jpzBpFV3_TcU>29XpLUJxMW*NF# z9~3)* z);WioW(M|8kT>X;IW4o7Ij_YaP9AOYCFl$G=tj!YcF8svxXT&fix(6t#A=s3GBAI% zdkm=DJ$b66FNVB#7xLfva-1xz(ku*MI#@uh2Xl`B*j_mA!wP`9rBX`M&{f|~G(rUXh(7knCZ_v>R83Jp|~9{r^isGO*s1RYIL z-9k@K*b(g90yGvv=Cab9hySWjtofi@ZXTDXh``sBBxVD)N8oyf(*BhZ`h%7^X2^23 zd_cLlWUUldB^}){s)IP^8|nEtL>X-e=7CGX)4kq(X(CazvaKM`9_;FFJMT>uvdS*o zGc#De@&_;e0E0b%wO5;C&~-KBAtS_AZXMpGe7{RE@d0z0WA8}O>QJz25X$Z+D7DFC ztaPG55>dA`?A;nIk4$o|(4ssURSb71QDxqN1ccyVm;Q7=Se@5%MZ6io?Z3e|A|P*f z+vW!qv#$I2sNu=3kmC~bUQghM%-7<+lSEgnoQLA)4+)aX>#tLgHvY_-Ekh;LqAo!B zg<3VoxLtYNIVr46>Sp@q134nC99}ayXd1?~Rj#ORY|z0k)={ohxbiUes{v!(a{+0X z#1hT*@dXgJLbJvyZFGB%#072EEb%(Oq-ALfiQZ*WNUHM@nYGVVP94e;`*~gly0ax( zQ!TJP?d5B~mqV5btOkpLjA*CBDuyVLwymmkuehdT7Z3CM!5M}wSxVsY7QJ>W4CiMr z1F}PHDq8sl5C&~sJp~OpXk?-yaW_WEyY)*GI-YCptD=mt-x9;HgFnkKxfjjRjEY zf68LXC830Gzm>#&r+8dzHzT@tFk%4OizlBu?CEPbo!5p%q9fjyXI=HklVg9QS=^}G z{kh|&hW|Ie2v=0_-As_KF`Sr9b)D`RuW(u}I@B8TBugWytXnH95TPd`6LkfdQ2lPy zd2t=Ppq1z28bIvgjE+e0CBeiK<%=HH@SdBRd`51K9ucj;J}$m|ow{e_Ih?jZsD+&o znzDCsWM{8Y!YB$l11GlS*oIcPz3>;nnY1TGU?hptn9qj)oAEThKM@FcX`k$o4nk9A@f-)l zX8mI7Q=Y7Nz7e5c7m&UD5USvyK*^LgI#8fUa249b-%&awuKj74;WBkswZUC8WFnb# z#*$M2E(Mn!)NEw^8kN`23s%sRtE$B`{6^t z4G9c%QoEKANJn%lU>S)$qIJe9vC}I3X$D}lZ=b}hi7Tk2B=cDN+MurXH(nrIeY`$9 zxZIUFtnko0R}JK4RvBv{u@FAd?e(~Rt&YB%#ZljXR(V!^)0R@)5bHc5H7ZPxSUB0}gq zs016dP=g@tlm&e`Y@~1Z%{zI-Iso0pB+Xl2>zSSchU@BJn{${)95d0U#?w`+Ug6f+ zDnrm8L~X#2pe>L;uM2u5wk8^MLi8(oi(vg!2);9_G_v-G&$`%5)!O(cRQXEKP5{bH zetVt!i7aMmxl1+9h#3K%lwYYY5WY|5J?d(S;}F?ur{|oR+^pVDYhcl&(+# zha41#Kh;Raz8*(R-qEY3D;_R&MLs$=ORqwnN)LaKYxFemMTA%*kHd-FS~9)ocpCP< zHW`@(H-QRN+z+w6P&6yOIezeT515bC;PQZ6(m^n{XvmSCi%j3~bj5SXSVgp^YOFy> z@I7N*Yi5M4Y6Lnv+Tm~o{krGR##1KVN(AIr;&BndT`B_fI!+JPxVk*6bWV6};U8+& zOTA4s$&ZoB`eht=!~{QmcGqq0dUq#qxE>xyrE#n0fLZ!uHUqD^GQ~RF_4cxW-;nMn zR2b}L_wA7(HHB{2A4H!-gMy;z*{3i|Jg0mYpiL5<5abJs9;)>RJr?Mf50q{&PT>VrBBed3juXGc5RaEPWGo;r`}V#f=@}6~Kqp{&jJYS75$$)~y z;OI-$w0n2&^f{`J%-&>|2=u-9mx~DK6k#ChQ%M(zp}DRZET)Z2A_2iE2Dl+>14C zR=%BTrmS~yxMI3!e&7&}?hdsxf=uXsm+XA1Q|}w=2tO#P`Q%aQsoLZR0&+sKoPA&n z+6F`H39U$Rnt%EBG=N|E%7?XERU9!`R^sletrS|JThIa(x#FBh@l}Q`7~{+%!9=gv zmX$Y{N{e>+xf zGttrmMEW6gghhRzPgU)pZhBDEjs${McD6N(nxLuGMwY&r7%Y}sKIO|p@Hu=B6@RY+ z%+E^-As2{UBVvYss3)1vtai2H$R!Zt*}`q@6UOupW_@YaslN5l2~diz@e7HDWuR=& zjFP3joaIU_qecgFMKE#rgn7ve!ZGI z7=juYSBA{9Rk13t${$zC08}9$Sr><b2k@V(PT*#jU0S2b5 zn|-FBh??%MVk*40GB8{HrMDdDCO`B9Q{V@hKKC9{bg>*)k5V^QcDxjc9?KM+mYMIB zBQjjCuAcYZ0{9Qst}FiRgPQT_f3vD`Cgz_5yYm}8fAgv&qqvZ-5DXLP)hEhHshuE`qxz!1EwZSNsYi#tE?ch6EZo@*&63Bu^yU=LN&iBB{Lsd zec)V;;UnvIHA?=2n2`jekRw2ZJ$Z5xnr?nUwyqHJX7p~4zr@JiO9w$R#FZ#y>5ee`oehjHwN2UvADDMDFxOJYZoc zEfBKeE?4}dF`x7FeiFhKd4bYT5P`C;L9Ub(g&H=*y?Bsbyo5bRujs$>)lGHY{gr{=+Zny`p_s4;Gi zQgUs?3_!Qlldi}=ixm8b#uq=~!%#}De3H8L56Cg1BU0DoqjabBV7W!e;7J_1bs;Q+ zhS$3897Au%MKUVKqDmJsey@2_t#Rs`u9@ONvGJ^)~=WW>9cO+`N<7rxy3*a7~Eg)=lU$# z1P~M-@4~lfqMG1ZPNjQhRM-CY8IkXM2$GPzUe|wMq5pGVxc%>SrdC=pxcd*a;#D)` z%LhZ_p_9$8nWVpgmsbKzd@v5XY&dFWvNkA-{sx4B_lRDFEUT{k+G=ctut4RA`Bd0+p{>VSNCk7&HC z4oZExuB^wgQ7$qj9c++inI^oJ0PBC>Qy#qxX#nsV2+6O131`Rck3GyL@G$;c5c-}l zMp37ikh37bg1!b=P$3@)$R|`t(YnKQ61N%HIdTFIk=D>Y+ zL>2lVhSpGeoct9{)yJ1%(G&N=_Bw&)#Xvw#M6h&H^G_eYzd5I<1MO*w-qBpsHCzp_ zUX>`YZeiadq4pn5cX(sY6g(TfKc^;7vHa@Sho?cXg!3B$eL`Ncam@W*d7vYMcqJXS zpIy>6wmy(c)c{kYM#>7&vA2fY7-bSW>$u1koe`VwRr)8WgLmjbaFz6uu72wue#oj2 zMo;ou0}Rk!?J#j}`#eyOcui(uU|}YC&HAx9=0c;|%vP$yvKb09GP(1~6}^Uykv=zG zv2o1*`%(OF;yo8!1QE)zJmDLu{687b1=!xtP z^pi9t!r5>2Z$ti2l^#<8;QISG^~5L|cF2)z5@S63%B{zGnUR4+Oa=QNpf1jbNl`AwaSW}AMnRm>c7Uc5HYo#d z^*>GfO!BPUlZqUKorO#57#J4ESMmS;Dt}?FieZ4uyQAFF`RV%ybiG>tqC(^xDPHa4 zquZd{px@9?G)t)`@K|W1Il@aL@)l#_M&~xfDD*ff@!ue6)UN5ejo*G>D+2L>X!@;$ z*+8ZY$3UH{!2qy^M$;qn31*P#G1E3B2U zr|9uj|F!8$jra^BMe1@?FNZ2CY=X8c>-f^$YnU@N9HPIge{jQtg(JrRqjd8mKD(aB ziAbp6YZuE-ykt!}K)s1Pd*oR+Lc9w{FA_7#=GyY&0`z5~>fD`o!+CthT0lleA z#1f>GG1_hUHqzA=M@>yF_zt%(x8Z+)K&WZEXS}@)VjMlYobQW#Td1|^U!X#)Rm&Z^ zOZ(+dJcgno7Xj==^=DD2=U+5RQ|wRH11|n~OTjS#$*4Ep})cz`oFSWFVSbitsnJSJh%QYKFinr`hVaWK0rSc z9@BoTc(wS=-@*D{e-YRtvL9cveKbD{gka;W8ngEQ7xNJ`4z>kb1cq<>SmICM`u8Fb zK0w6;(G11QzNfcCxbxorj~DVcbq(F0ut)FH!N>oK;(wm$)fF7n&WyMh^YM-7XWE;V z_x^SG{;(^4Elg688+1y7NTiKdt8_ z^u5auXj#qwWi20G#zdWCiCDR3kEx8K{>ygzhz_#KAZfPu{m*L|`SKDfOTdQ_-X3g! zn)P3{8@ef?DZcfZVx)4H2cP`pG6wCJj*CK_t)A@nRj8;b ziss+V`uzLG-8Ojx8a_&UON9lSBir)!hj|zo7&uSxFdWlk&Qc@tB8Rzub!jnGKkr>y zH&*Q5wxaqc3Je-xnuOm@=~{KTZ^1Ht9dot!<>b+eqLn_LUxft9l&nPx^IyRpzvUMx z)#=i;;!Rvi-d6tyk3DfZA{lG&PO)$-r zJJ$%f>BAet8(SI68&?^}8($gEn@~xL33sE>JHWnWVZ)`}Cbt=IFx#k~=Y&4_xvZ%4 zot^dHq3ZP|hleJxXu$XQS8^u(Ox&akLd}Xr)GW>JbxdiPqXT%VG|kAL7vE5??k=z_ zw?E*#Zt(+;+xivTpPQ(^V9_H@;Iky<_0>7xOt(jS$~7ie?A2Kf)&#Q_iBQ1|1ZjF(^Hug8o41(`ux}pdL)(* zdr@FJ?@%*f3VWh5P-%4O3o_8Bc+aRZqx=nU{<$LG{D9zBb*TXr8(_`nPu=lFh>-)+ zkHRodC&g|x=<$NlOAW*!AG*@5XF=P4IsSVqpGUkxXZ6TAMLaSHgu|>pRs?ddpwwdp zO9G24kb=D=Gih$oYlTj5aOBs%N)gJy;V{f^jD)ij_7Bb&JhIRoCqZWrA5Gu5?G3-X zN7thKoko4kNQNg!wV6-?fp7Xw?&a6j`$r0Alu0?B=zX@1u8vo4bS~4<+~ipQeE~({ zqA1z#uS4cSJK)qq3;XILU8IqI%spQj0Jk&=ydhJa^r*kLH((1^kVf@geU$XP-fsZi z`~p|u%LkxToK}$5>=j?ggGXiFnF!QC;6MBQ2tDPZ62Hn-KnBiGwB(>ozv0JSpY@yj z_fYV*`Ik3u%SJAD>(h1IQrIS_~36nsSdFZlGoKjMYlZ-(Woeem$w z3PYTmBU{$~>?P;Us$}?|t(g)${UQKoQ@czvf1erM7Ilk28DYsSUR`i`rj@EtMT!=BPo^JX{qKTVmIvhZs|tok z-@g{n;|esuzO&K2*NDMT)*}eD##|6xrF%G-%Z+}76b|%Gs(S%v$LY_SPf#64xcriI zz{iBfoF8sZk*fNqK^P1lJ}pay9ukPY3wp(^e26Bn^?6{TYNvm5Z?lrnd{|ruz=xHe ziyTk~KijPH&)R2-UY7KfH%3*KeJ8YC+l2AxJ{tASrj*|&;-UL(^YJGJZWiA@IuXFW zGpiF~`teM3!l-65lfa*pORb*EqdOJT3@CMg-`x_qz7T#JCEZ^;2;2!e&Vhgpu1Xk? zk_Oq2B^Z9!c`3IDOByg_sgx%62GNPmzuH(`sY@?0?jS%#`m%EN)bCfO`ik%Ynl4L& zrpw}pzMnXG8|~1@G}!G>Z7k} z(^V{QSz+&!AUq-f9Gy%EzW-Hb32>CMQ>!%%sarp)tBBZC+Rad&2rVVf ze^BpGUaU?~Wi*oK0nBR+p&=SdP9xTg|K~R;{CwuExB4Dk+kko>26qE~-fw~SVi~$k z_D-Xqw1L)TV;lz1e}z%_hKw8iHw8xi?wy z&}pl-rvT}SQqJq?29;TzSsgGnl|{Z(h$Rzk3^mkTdBHW9#T> z?5pDg(jxlMTGuT@pjYPw#``l?qOb290eErpVz(V%0LAchfuZM}bndtVt0mBM9IQ;} zh-1IlW2ydg;Y@agwVHeHXoS^$m6i1pS6Qy zo5TPWwxFVdAUr>1$2~xa)t-$zPbX-&qwvifSFov`2H;V>9aq$3-W6!P)1Lx3i%Ttd zKQ)}v0K#+b4DW^psKHPM2-jqy0CqIQC)eSdEieSj@&LwFo^x?GXdf zeJi8{o(=a_{a(=tu)o@n*C%QM>=OYB7p-|MUAT_lHN;>INKuvo>B4tyC(1{2#uKv} z+nh`ZV8Ld()oqm)VB)gvM`0?u)?lhxRG+)lz(!Nw{(N;(XJ-Zk-_riT*_{=(+3RzO9bfLH ze=a)Y#=^6exinF2qrNez?%iNR0DE5d5y(rvItPH7XMv?@kC7zMRS7<41LN$@ ztCwf?0AQ%?zQNh+fg1kzS;yW#oZi*U`X^idSn4xK)DB$8YmK?pm*%OVL@Dl-n>Nnj zAR1>vz8c+l6%6;m@Xd{#uIrX|tiwTcl>UQ(oSw&Rx9esB`)ia{*QG!qfzx%Wf>1T5 zW5c{kE*tcf7LEw@EjDC5&nE~}KhJ2|zb<@6&4(JouRlnb+XF-Dtj4W|^Ic#e0mKN- zv8otEV9^JU#!BWFu`;_8W}==do{Th!1}`{)*T~r0?z};m{$B3}42;|C(3uo!7D$kK z0*;=HEw6YhFbyEO!OYQktY-hBa09Ta6Ztu)`0umbZ1z3NCPb~2#a;FE=5pc;MZ6dz>2A7Uy4&NpmaS4QUrnoA_o&XlJ70^eClYI6*HCjJHCG~kxn2EC5`rkCd5lporxtm!>6PV8S<1 zDPjR|+sP~PFER-dP5QN|0@u00DUfw>)Iq%AJ$D7eDyiMI$(n*10F;)F_l*|>SZ&`OIvlc4~@5MML%T%WiAdLSkietZ52Jm4~yX&<}b>0ArbBEwjt0tgr+f-V+AyK{+ zV@~*=HUK>jR_n;P*6jN2)wTR(n&q;eKduc5Ca8@|cZs3`YAmyQSjq%oob?z-h}Z&{ zr!t*_bHp8<@eao=IU9suOgx)za=v;l0))H1@zWn?Bc=)dR1dyc)7eB7tZNpICjzpO z$y%N5RIT-eRuL3dRJ*#mzWoW>eg@|l+23E`LfTZZ@vV&%_vxzul%gdtd54j()qVo` zd>6OHZbXXn&4jpCX~z_#6t_u-n{-he&|x(zF8EC$cKO73l>_r^*6HvfMKU((%osJc zK8gpFnW#l`pcAlEzvq9)kWLE$(e|)6D5*O6;Jpw_u>VMmMtANmv;4iC_z<@R>%>9EAki>1I^kpy>HRA6?%$fk7O2xnNPKzGFt&h|+d> zA+VoGl|L<8n(i==QMoin_0v3_SbQ43*CU%+9*ANXubl&eQ`dyHeUK_`+LgzEPRImo zhjhd4t+_-pykSVi`pX$xx5~DVnd4qn(FCQ4a>uZ4ZyQtn&S!Fj0dDWnI~f4WOVti` zqAc=Ywx)X@IDbd~W|zh%6q-sXv`DvVvtpI3sO(}aAjTKZHkz0Rid#Ln3@H74%PCy4 zY+9O3$w1F=51>bD-lc^l=$q@+Q5Gy2jtJFr&~{gh=qKpqh*ZA(>51CaLUhB{TuBef zdijpmjp^iA_S8BLZjm+~&vma?tkW4?T@0vdGZBM^Z<&!2HF%_}4TLvGVC5E-8*yki zYyj2luIoBQr7D28oNKX+ta+CsJhJ0*j;1b801W$F{d)aaUms1o?P$;9Hi^WGpl%|N zSAK3Y%5JTbBy;;90wA@Lh~Wcu>zzdon?ChlqcITj7*zqs`G!|iVr}zRjydSR!*%DBf1Ap>%2EG?I zQuCT9sDqI<5U;9+KZ+6u$UW%rpN;m-Jq_q4o}Kr-qVN?+FBFeWtIP_|i7NDw8uFx5 z-<<+iEs8r=v1*-=&@CP9^~qItyLN(4?x?3I)DBJ!WSs)c9z*x!PL4-;3o zJrIA99I!q(xWK@5h@Cdjd$*IxQj_-k$*^{Rg^fqzeQAZI<|onhvLTUwVY~(?e*0GJ zdu@P?@q10$gCM)CG5Uj|&e^$&7q`GL8(NA6#{LR1CX4i1)n}U?@~69=Xo7CF#Q}+o zn!iV&FX1(<6gp-JLjVRAVLMBzmM4PU;T3)$G?|^_hKS(+Ixj1je;6car0S zD~^ac08`_>M)9Q1HKIrHE>rYjer}ozY%7#X$ORg&w6eIj(@rHcP}3Mm$)i_$m#DjH z&}IqAk`i9QV#lWM&pYOVy|r8#g%i`w1V5T`mG}7UIEloNFB#U~UntGk{)0&Nas#zG9*UvT8 z^t;1lT2BTT*G~Ii^&FQxk7p^62s2!in*ld)&FS4Y6=huJbT}{{$Z0?+4RDEt<3UKF z_J);|D5%eyWil=Ga78#*P<7gJZ;2&86svBL*@J>s>V=Ew(L{!tb)>uFD zZer(hRk&Ngt}KZkoGjMK&Q#B_K`ns+V0~}xpJ(*-OFJBFrX$j`BgL2C4soPrnJ9Ft z%TwhdK{Wd~!LPEnG+Q~_XvX=6!w$RZ5|ZmI8SpcNMpoLr#DEG)pk9`16iXW)6 z!DfVLtyIraBQiy&$T(0&_T{TN0K6CGrg@X-kc|%moFyB5xPy%Hj?T0pK-1|t9SrRm zwcVsrH9cIn0yF>92uIX!n<_Utn@(1T7elkRz_&DOe*G+MT-#Ipr7T*z0*I7IAP#Lv@vlB-Po z;)smajAwkqMJi=nI=4Z>Mw_D7VE{H*<3!(7__h>@&!5@`zKzVra-A>k^FT#4=C4+VmRXH76@fo^sLx zvMFz?XYtr&`n7|Qq!59`$&~ELhq1t8;Athh8r6vhip|Elu*5Vo;qB0Co6^%f)9IJu z$Tj3M*&!1_X>39`{w?vD+9V+LDhtt*lkP8Jg=1IBNg|9!K~Cf%?Vn%c^Wna8f}yzD zNLlZl$W&S0&D%XSH+%WQV?HD@?+pdgvBZdr?IF1Y3}~Cq9SsWA@kLQNFfe;0q$2`8 z3_s2)8b@4A0`(sEZEG?blk6znP+JC)G$KM(y;7ul*2ukI^x5EE?5;wmmwlsLCA;e9#+jfFCW+7msMK*J zDdU7oWonjExjsvOqfTU+p{O&d*Fsvs)tTA1qKlC^$GF10Mc%wF0*r<VR@8It(<1pkJ0A*1F#f@w)V+?ux0eL2F0W<%AU|p0*}-1HeH~V7_it04U2%eU&d2CL)MZMSez6evqi*4Zw(XkGO zlZHpSc59a{4K8@J+~kvLO|*AEVTHSHtnv};wg!=s&ki%m>J`=5dMW9ZJF&r_<7p9p zaIqcWR@xAq9Z(VLDVn1bAqM66!hz4$z(B)~@`tNmcjQy|{6@yF?F@i;3Rm=|`!Mr-(-Nw>VF4!#4k_1ZTl zLRCh7#!-@H-XeqTZ1Pp0L@*?%a00sCNWt3inqYed)~g3Nc1{ko9It=^WBB_!^p$a) zMV>xc1DfqC(-+5~e;xR8-9Y3rKu5lny+6#l>QD}2NT*J1h!f-hxxI@yud=3pX++co zlsDRU@vVr>_;cs#(IBQnZil14N*n+hWw2DCv(aU=O?9>b0{26`VIpESXAr${`AY_cY<0 zQiuRb_osDN-P)%-wsbT`1Bqa!$ZAL~K4sBIkT@rve6sS<3CDtnhT!?_-4N~Gvuz8G zvuw1w-5A^P_GpEfXw-AE8oReX22>ZRe8PtfZ1+?q*bN#V6}CVF?r|CW|9D@u7f-Xr zlsYI>c70`ZG>Tw$9yk(iq_iMFHS|5zfPd>?jW2I`rM8NgMbxH``|m>^;{b=_Xe4ka z^R@wlnj80zS)1HL_Ply{oXVQ2>%8P53KEcHv)4;6hayP{TO8_zR15af|5{)GW0NT@tNWh3!%N_BtN%>ahe-&BP`_i_qo!4cA5O z72mC`mc)h-A*$Lmibnew+^;UvjHH?_Q>!43#}`Mtw{xlWUa&~uR#3-`zaFqiZ(H$N zE37JS;hI0my!>(@s$04_THy)yD)q<-ieBCdJK3bRd7&$R*s^%)>J4P^OuQx zy${z}d>5h$_opZNlEf6QP@XI*-2n1Kh%#Y+X^f28Fmm@F)JlH_|=sv$=;I(3qg;SD%BlS+<81%Lv zVn)dDg$B@ajN&Q|g3!{hPg86t-QU={Q<-@n+_(^}K<6CgX1vgD#fD`}yU_X0pM97< z49R0`Fz-aHx&(w~_~v|1FtsBedA(F5w5_#p$YK|tl7vT}T8aZneqjDWC-JV?B3GrU z8R7a^x}@r`p`vl?mzo*|=&uAqb`=^5COnQG1)s4t0rC&+9>X#B`z(-Eknkutn66JV z7YW0dt(BfehHllEh3O>l%c$)VnvH7cL*W9FWFxC$5I$w7p6^y`{=g5Pt(FPLUwn<$yMd1}>ho%^OaXp8aY*JRyyx)beq)@J1Pq)mNWZs}wLv*FloFHr&={cB|JLNbB>Inar1W zM#8XCNfx}wd$rEi-Sd%0X?bztca1P`7TRBSt2zWRwFPs;-z&387IkaHA|~e(H3Cay zVage>R!X!K9J6-4Ky!+SfCripni?=!b+pmF@iBN$!0WuNqO0a`d@Z$iwdu!!LnUdb zA}y7hPij&g7}PWF=%(E^9mF zy_~w>Bp`_ZLTd6N zq$?J;MsCn`x?Q#CX6~x4#pu3nBz^&}JoGa+5_Yrig7X~iIHXj&kNE9HQc^?8ptgkiMYG*Vs6)|yoB6k50H^UZ;h|? z#u=1=BCEF>t}`p<=FhJ4+)469q(%rdCZy}S%%Q*nAxtt5^n2QPh)$GK-KAXOm=1D| zn`Aw36G`>IcuSsuBq{7YHEcwVk;$cpijjySV4xVl@Es2IM#TB20R`+1fmHbeyJ;U; zGJxHjYg6EI+G{Xa+e)%33R@r@+&P@cY)~`saxAI` zSH4W!6)PKyeJ9h~bdkj2_6-7)#!yfRX9!+qzxJ?8N$|O>SDW+I9z=)}y~jSkc%j|1 z^lBUM-WCzqetP05!`0Xk^Sx4MMc`^0DQbV0dvEGt%ykurML9{<#=2AZe3r)%CAg7x zO1F}bnocZxP-DOyRTa`QObEPF}LK(cQzLE zqOxUaMxk&aJpju z?y$s(A_L_QM{BV-3Mjwta1G--Uv1R;UI6!%F^$em*A>FlZ7t2%QjpwCt|N83B4M=~ z+nT1+NpH>_tp^TM{#2w4BrqRoV9KaJHuMbYq&P~NXytu&2;7a_za>3Zs~xHFOL=2^ zsJZLf^OvY865QB?%d)`|#>-oE2DqxynqNAJf%g^#T}%e<-523bYP#u8tbmzR&}c4B z`39nD9WAE)TKWcHTQKx*vMc+k8gO>-w4$8JgJN?lURlv@J_>?3ZgfbZgpIjN{VJ?> z*Wr=~_!VUWvhoj(i|+y(zzZ4XMP(q!P^6_6ns^$NTJdf5Vot|V#FjEE!C*KwVm^F8 z>$G&O!)qtS!8*j|H4s*sb36_qHek*`&PS~6Rjp57NK0M`B7*T+kKUG}8r=ktd`#1YDyF{s!Vx!zRT3Yz92~JwLsiX|PcAWvDn(puZt*N)_R@7f zJ)`#twDe{E;Z>@>RV{;?ttjbPZGq`(0unBB_6H0m#`Yg;RHYc&)upfo)DK>@WXu&y z?dGw03vo0MZLn=k=~s1Q9ReU)w7BQ#vEEPzKKh+PkM&`N<2ZsDLEI#5U9cM3E=WQ$KI5wLYkBLlBQDEDfU@BGtpF<7n3mSW#?7Or>K-n@}>$de($y%q2%+z9$g|HrGj|rXS0euc&{ZL1eq1iEwh_-($Tlu6ul5P<@ZfW>7L|DwuKE!%D!q$ss*gZGY}^2!!z6Te+b=FG}BMR%&@slH$(cSQn3B3mTDRN3f@;STF^Hei(rP6y#vgS&z>XC9F8R<|geg%UJR;GortMw>~ zZY3rqEOe>xDEM>*NOzp%35Say`d-q9-H+03FUaqht09vO z;%Qz_2oQVlnLYFB^P=3|l=!!*8m#nTg02fC3JpK$mfmkuyA|(*ay}(MJO>5HSlk&# zW;`k}9NUp(W?QelLsXTS`>*w%e-^0~41x4$cm@X0wFkXotAHv+@>~J5V?I4~?u*|XiodwAD~-%cmj zKiyp36`(|OgboRea$Y92+7#5Fy6|1X+=cpAbJp;N08NxDR(kGT$p*1nDLSlg!t3?( zC_W4sv-sP-?c)-VL(M$tg3tsIEnKFO7sAR8XHgydupNcLi8{> zPQFrihF`&keLGqO0VmJxuiDluNqorILv#e(SHgLDERg3@>%k>0ObM=XlWSlDQ7%_k zZ+yCZCqh`29gMyOSh!Z}(1jR-X8&4#!QHP_-qMwMr63u}-&wBYp64EQ)GB0QbO~Yo za{`(5=`&(r(vopWx$t($VtFLR#bYNof91q<%fHlOLyYiEIgY4WJoy#XKw z`#4P%WeLDOv@SoBPiEgSEW1`Q?$RHfx2Xbu>*oX}|LQEJ7<(;4@5Ek!N$Ja>#(=3c z4E#LCXrq}1n)E_dcWg-ACo5Jk*{Y*87QL~O_c7U6xmqGajT0CGhEI>FHV>C9G#zN5 z!QHs7>ou6D{=KT?xF-9m%z|l#@@>KlZ;{<-&4WpJpB~92+ETes`K-6ulcHA7Usv*w ze*CZ@VAVQ`XZj3~>5V)=&8EB*(b4n-7Y~=ayhyk6{TlZK3&Z)5oZZr(t@pgk!VrCC zis59%94+9|AP7gieS~?MO?>O&m3l9Sfs|KoRgX|0u6E=B5QrTnQ7{|pwP?Qmh%;0= z9=T`%(7f$N_8KM~z%4lw7H3VtXX(oO$@_w6I23CedgZnI2f6oWy58sJObHIrAe=J@FH7OY)T+ zm*S>cQRCqe{bhP(_!8z<5V5qj2JL=UjDxSy!0VwLxaBbdy4EAKb3Xh3eAh!A1*+k) zZ!AIU%y8A2TA(vD#ydAsUDqB}#A zpmby2W3tltq?$4p#=v!0vP3Ki0I%6GmiyDNGuK?9>0}=Wvpz%bK|H+?uLIv6-{E83 za3p4WFM7O2ZkHCX!Qq8#CFkcJCcCged_&LM*0{s(Fat=TdZh>$9^LC@H`(H|@3du! z!`SH}{UY;Ik{02OXQ7F{2QNN(D_wf`G9){3@bft_c7VNxGd^tDs4Kv}9uWlwm`yM1 zqCshaDZd~{lLyBx0O)0Sq91~*+PBX42(DJ|I@}`{bgl*QW0;(EI0!Q|G`9EkK^%fd zoVgxE+o*YTfJp6sy;_t6i12#1qdlQB^U_3n0)l{f} z1^+aLXW`Sl&F!?e%;7Of&^FTA@LnnIE(7l;7AuBRS9K_Ql$6zh)-cD3)g!p3m)P|y z>+?h9*5eX^uP)kdY`-_&R+E6~)Z#x88~Pu~6l?<9-x^h5GcZ5mt7`yv4qf^(v%b87*2a_@27uwblv7&(d0+X?^ zi_a(lC`Yt*#;a4$<)0MD3I#wQOjrsC$1vz!{VF+X8aAD1`19Vd>t<}V=P34|<+SFd zXXOi)jbZBeBCX%M`hLBQpdwVVH%B4e;19eQIOEeiHwg)*&+ICuCzN~uj%I>4%wn_A zveUogIrg+vu=d5bFV9EwRkLZOG3lPPkl>WAVivY3yBBI>u)Fax+xZl0(pu5pX(^Cq zel2Z`zwLf0+VWQ1g>t^Ps)jIvLq8PKb>$h7EH3W4sd?LPAQg^9^WSCTsAj4Up%9%^ z+HnVubiZ#lCc`^^8KWc>e17=LU9>9S{7#f6`-SLj%{pg3q1~^AKzYm27Xa8Ie!2GB zSl#|UIhqz(8Gxc;Js^*jb;CVllCbWZ`=nd<;o?iCSOMeQt79{zoHuu)OT7)ll)rpI zTFst=UJ{99qWbz!mQpICHdyKWSStvGR^*&4wCZJ8-I?9FhI>;6?mu7d4>S!|`Z?`x zcth*5F9*(B0KlVFshy+?w{vsXW(KOZuU}rpDOG^%Ft%zv`k?+DlN(6KoP|Oz1qN>$ zEZ1c7TW+@|)xN)nACN$E3fRU2>`?$e!suBO1(F6aD9=?D3GAE?I7Y(?C#;$QEa}5! zlJCQIQPZ36szLvlSuyJu5gWG)5%;WvA2p`V>zV%^a0%kX1k{h8?(*LMbXmf!HfFP< zb@yUzNM#+o**SQ;7u2=@AZYn`Yc&#jbE1O>n<+d?=o1hzjuzb+t&rOS&7q9^dWz3N zpzA2)B*0FG9N}4vy9I(t!;czVoT;D&!pK4|8^VsfaTpScDA{;Vkjv?kBc&pV6^602 z)Zew3erP^9Kj^ybzTAj(BVPAdqZ4aX>aTRQ8{Yy|1XQ;ZHAS01W-a_O_-+SmuK;4Wy3b+O4wpXMp@_3I*2k7;}%8p_d#$AouNEWUY6UP@| z{A!(hmG!3=<7|d=>eAKu9&=y{W4n`Ps0)^IW@#!#zlU(VLPiwNP<82C`Li51$fE|O zeBt?H|3Z~+VD zZ3S>3_G#H$(x%2bW~Xjb*})D}+G-4II()z#+~zWFdccA*$JzLm(Je4?zCC1kgg!=n z7{FEVJ)+gE24k!(g(!I)zyQdR?F!6gmh0bLs6ejh)p<-Q-P;gE2`ui=TiGLP&v#>i z#2_N8o$+v?;^VOQ=&-lzVP*EhX|jhW=wj#{;%~i*_ZO^xC13YS;GA5GSgfWF87JMx z;>vRdWSrbT=Ud9l8>k*9QIK=ZJ=`Dm_MFrAR&#Y8tuY+l^qFyA=pJ^?o{1MwrH4t&p%b zha{_3b)fOK=>-BZJ`qC(L=E;db;CMDyt|Fr-dVT(b-Tygn;Fj`1vRBZD+|#(y7W|w zMP=S@3q>{>1dG8BLO- zMf=-%E4AYqPz>;B***C~=5nGAvoG+v%<-3I&cV*lGA3j+(VtR@zRE0pHmv)pqwL%oq+RZ8Et?ugAj3np`q7-)+4Nt5^SI-t-$-Fnd9$R`e%K#EHRnBVoy&Yh;pARazE|p2 zyK7Bk_1t?+b4g)WC4FHrrCnlSc2zqpSf{ySx%@2t(g?DAI~LE@?vthUvPrNUj}hS) zNNh$qY7e-(q)4&s`hLi}lkOWgL2(~Lx}?4DktKu5a5d=au?$c$jCJsMSQFSL^}}5B35tWMMqhI^9OM+g+P~ZjQ8~G<$Tz zd~g%W{hTX2``cMkK*w;aWoSEY|1FuUu7~5G3JurGSZQdO(mGZ9jycbV$!0P-siN8@ zuO?w3K7uF3-Rn3nfRxb53&?9$+=`w(G%rk6l^!%7DBRyQXKO!>2RQRZ_jbOTy-HRs zbKAw+&3MF#mG)Y#`$jT(zCds0ri4Bzv9uo#OxhO z+Pt|EoD|`htEDqfEm9p$l zY$h4>?Sja#lgcvd;qvExi@pY__A#wjKwo7C;B}gv_5sVfgZy{rmL6ZG&%>JSHanVp z_cq3e1d7SQ-3XODCdvms>)FOoX%Eu`fF2fXx<6zXL1xN;J&z%iLIiH&cmoAw9FkR4 zeZedM+N7PH8@v|g&6IcHw>v<&fe?!_ll%B}UUr3PRa6r|74hVw-!0gih+c=~7%)Y8 zZ&r@-@Kw%X9;Ji|vj*K{pONaWS;QPuopFv5royPgIjI>h@-4a@?sns{g-7lWOEocG zZ@-^W_}VnKOm!O6-_D_9PIWiu1GHb?bjU9JeFD#Az7{ut_|4J7v-y@aS+(b~`27W^ z^U8S?Z3Mzq1Q;&_#oK{{sU2i*-v}&9W*3mF7;RAoA|Q?k6b{qpbCqcDw3y8`z8?>7 zLbd35o`Tky8<_XES$++zBitwkNSo%o!Xv(Zg`=GLam@ytAlJ_#zlNk?rUOs91Zkwl zc?hQEo~u#3ITABMSYmyLR}ipf6ry;J2@fUNoZg_bh;LwyHChhBg{|Lk_0fI$$!u^8 zln&BU-Zt8wnGHU9&%q*JhnMi2T8&YHv%11uwaJB+bG z$ME1yI?wK^I5VAmEm!9$`ytfh?mMMSu{R%aqjBtUHrHL)4}1s~bq4ky8%;k>qX^80 z3ceEBnj>aNvF;iP?Tf1J21jHLVeZ$l%xF=g{-|ouwX{V@;J-cNbb-pdy5ZyfQs*Fv z_YK2N9`rRkz4>VJUt?#x!u{qah*3#)wfhuNeJRGts)*X{3H|d0RYv|NKigKX_Z9qF z>Fpsopus*fkdGcPi)`~a&HC1REKHl~CZbe-+xa`OodZY?Di^YBFYZL@RXB|1Bc|sDbIA|bqPndB(M`t>tqM-W3!&=RIBH{BgsQYSb1>J3vhVf!OZMnr*=Nsd% zWU+T{e07dzqa7t7Zdb9iy1sYmYfm5>r;t10t&vwntq4(_#<^g{1f`hUA0xk7Bh`{h zy5|pxKe4Y@V8qWrn_Nb+1$AoEP&Yi=F4;;mc_%svG-SFb?axg%`C2B;g?X>tqTY`! zmLLkuoJT4(4BrN6HR0rxRy9~BpoAK<;gAp#M(MkCxlyh%t!C<2fx&E6Zwz9V zs`jn#k{_!Tmz~Vl!$tYy_P~VXVMz>;Mn;iL|d^n12%KlgPUj{y8fS|6;TL zHgmFSN@$94!j$I~nbMx6de#H`Jsb3dlw8^+MBq+qY{{V8;obT6sUV-Mv1nrGj_a;y z)JA2lifWPoy#+=vdULNp%{!%jL`Pa66n5TKFG%X)Jw0eI&8pDE;>7g{eqRb9Oh7ZE ztsCK8^ynQ!Rf+yZ=lL5q1|?kWaru5QENbAPO7DD<vW)P(Wivl=psSF69Yp*CyC1bbhpDl9aPRzQ8|olslWeC%O5k!dNGJ1jL@22y%~mI zfL#-u70cML;p14)DL(Ayoh1lQQdP(W&|Ao_o9=F&Gk9@$lGrXv$bknJ3=a--<+vMW zNwNym8P=Mkrb#lIn1@k)v2@KHUSQiWY;eMt}GL46-P0#!rGN1p*cl18Uk2-yk*2xn~}t z61)|a;uveT8u{DG5`HxokLy&Z?^P(*n8-+ZPQHJV@oGv@%6oP%y3nPMF3NTs$*p29 zG!)7nr6(Obk%l^!+-lEUTp|$=Cc#7W7tPKYt~wAA#}A-mYw%VdpOHtBHprmbA3pn# z>I)k;k@kL{Z>W%BIMFd`-!#AYP9|ohjC+@&9f2|@h2IsfuuNN2ONB{PqjNRP1<@93 z{D@h7&IL1c6N97qkXlg3+s^ma68k<9L>W-L9G=w4+;Q4qhqlbg3j3c8rf)GNSdQ|1 zlO*N1lhEf7frU10V(7!z(Zvj_;GIDkPHkiSNwG( zt-eY>>lyu&>e@BmgA*+zW56R(<8)jD@pZM2jZO!?_Z{CSN%iCSQabsT#bWWkv3Z2= zD$AJ`6Y?QUCW_oL_N#v>M$iX5{p%N4|CcZLI-r@@f|ubG@S@1}hm7t`h%~$8S&Z;22yE|L4ZmF6 zA8ZrWvq)HvAZ>WbQsH%hbi8|;?Ck8ldDWEn6Ms;D%&|DGV|e-+?3-Irm?%S^+#2G! zs^e}NV_xi#ky1yP-W_i6Mseq#6r;px5)$~?JRt%*0%i5P!sz13EpxPh5jGC)<4 zHQc;UEfu1$Wg?MUs_hjA?tNO77B?u;a4Op(_SY6oU>idYy7}4;!_<960l+;0EAtb`e0-@UMa@zDyxO{U&;540lY}=x;w@+sfmMKfUO8Le-NL2xA>vhY5FN33RX4q>9s$UK)Q3E-nGl& zT-a|u(f-Ng5DjyMnx|P+VyXg;E)6hid@R_9$pzfN;#+zC_%1*Ww~3d|GYdIM*Z#wY z=8rgP3r1g^euyDt%^4p_sto-REsq{GX=41e&O=T5Vt4WI(#Mb@Xj;R~{&SKc3QHyu z#L{lnIduh2fZX1+D0=rc(;b4Y`fm?THGysCASG{(Sn*h3MR!vxi;T0-y8RCClcf7` z!u6xV%jE-YlXFh7Xs>%m4&AzQTF4WNrkcR4s#~h27KulTKY5BWHR-+EUD^~zQh#I9 zK%C7&S?7kE$Z*f|OU`KPm~-M}zEdWQC8G+<Z!cM6 z%*4bs2)cBAd!8;v>;ynoC3>$*5uL$WKcagjAl z5>*d;RKKy;pyCmGblSb+wmbJ1_OQ+q6Y>1OMB3dR14?u0h#FjTC(VvCbYJWV>S#e7 zBq;TcEq)r1qINN{9^cx!-{fW^c$<8UXDgbs41|C<^E1r7M*p*6p9*m02 zDY9@nE@^w=>hr+&*9|F}a$lNoNl_9UH7~46TZE2ia6(>*@pLIJ{^#%~{R^yk&} zv!oXi+G8xG?hii-&JALA>Ce3nFzyS?T8=dWwdR)N_=VW`^(Ct|xF*0GSBTBDip zT(*p*`!0)s?l*rSnP0#lBx0_QJmn)H3n@J2s%J%R%5Y2N$iCaE;ukc*gtaqeh9o=2 zEScy|;cr3=^vN(8@XQ_*EL6;vOG0z_6Evkh>$u{>pKhEObL5j+Ov78aN#i(Z>JZI$ zxzqv2EGztn@>5+Ova<7}Ty96&H$8>zlIYK;cq_EsG|2ij$?k65bClGb@6LsS48SLS zk}a5JxySwKcPh7%k?`b8X5aT-7c)K9wx{fB#ZW>bxK6`;?OHW?19Q}+fWVG|Ey>m4 zyZevQ=Bu$Xx*)es651L#p8(2Er zhh1rapF*g7pqr|EdQ$ITa?w}Hdd!hS%6F`_W=rycV_QkHR8k+)Vf?u|9ZD0eugXVT zR%_E`;Xq7x)7qCmtWIRIpHJWaP4E29b^g6KzgrpCqdoDP{zlGkI{Mq6&o7=j{1oZI zi8=2n^o6h7->{BqCHE(K7 zHK`Vr_DN&`PC;LWDF4}bVG%SGBhB=q<5RkbEb_!+h3+sGu(sa=qqC7dzs zwe`oL|1OrH>-UmH`qhpoMO z&~dnpAJgmbh!$aE{mZwKslEp02T8vB(s%1+BdCeix2c?`AozlzOV9$hWur#W-Tj%m ztE4Yhq9%UWb+o8#tt-3vaASD$2@Tc`(d{3GNXe1n51-I_;N ztHi3@+QC@Sy8y4+RYQfo$f9G~ihkL=Vmu<7{VInMa@4HIJXic-tjj#E?eLLA6n16s zrR0y6<%1b;8Y;-3(O31NZ%idg`&Q>s4slRp1Z7^Fv`E~n^35~YC<2*zXa*i?um*cO z#qAf=p>R;-WVlT0yG(2Ni$JD^`Qbh#%c2PTPMGDMe3FnptmF=r#SDUNTj1}IO8)=FB2iprFhaGe7i5c-xExWBkM^$P( zV&WI7-*nhEx|f8$C@)ZBB#XMQaQr6m$+&yRZo1h3$lPjUi)js=#N%j)uE4~VUcFj!KfkUT51|18DnD}WNq!yVeFCOf5999wMUc=A> ze2f4B{0=*UD=C%QyKS7$*rP?oNIdzVfUnb7wOlH}sF_|C)GXb`7WNr9&d(KsH4OfT zHBLegX7UfR%7BQgoxBvgD5+CZy=Ix75Jk?3`NU3*0TQ=OJ>xX~y7>y$8_1i)!-|t; zV~}@h-X9UOgKpJe1W4&s00>AD32l~?p=Zq_mtfKRk%fKiz)j#HLa+GoZ1mZ;<)-~* z0_v-Jg3Ej59o|O5?;fA%x8|g_AP6Gq6PB8WBVv)mZlL&AUoA#>B3Qte;c#&SzA-1p zivp>O$%|??)>}^B5rak*~YmnFZ=r$JRHw-mu+3iuGEaTw!+hq-=IOVU>}-2;cy z%!>_Hd&PLjFMS%Cwn(bvuI~xc7x-b#UT{kje-80I+&uhz?xs!Ya2W3~x$^Gevf?Sq z!zD`nG#-NL3kX$gyyt>E^}*ZP7&_wvFz+@p7y#84^IEh-KzUbTRm*9xNiJN`-VSKI zvDPzxu0Wg~E73^XVTAPhG+9djzmxoV#Rl+ z2>LV8v%YEC-i(LR+vCXMtE))|)7vfLKpXm+3J(sXp#Kv(JK~KraNAUk(=bnsx@UWe z#bwa46f9vEQFh10@ftwzK+<*|Dp!hVa3ca$lEC;sORVwG&l2q#ff5`-$Zofq7WOzc ztoYIg+7eYT6WF8065m_0?Msx9mS?^cW0jb;Y``htEq#*#-=Ff|zKtDmdw6F%>CinD zth$9B@xVtfnJx@D7j-vFW@@B$r^%xz@?3Tx>AIy{xqk|u$ z%}$X2YB42P_oj((B*f#87m(rF>y-oN(|T)uP>vrSgVQpTL7kLpmp+V${*pX&%x-m7 zL9}y^&Q;lJb7G1OVR_XK%xksGH0~*1K5R1OzB*`~&&Jy=l(x9`&9L$kjqE?B4iHa4 z6;|t$BS{WR4S%?fJ#7J3RIr4YW1rP2me~5}tPaAxPTvg+-)QoyK!A@QXsd~(q$Uz6 zTyci3K3GA1?`ih|ac2+hMm=HTI+#4KU5E{)2*h>+;5Gk&X>H=+bkcz(7S_yPF%^^- z-Fbfj-ki{*l~lW1d6=IL6^fpQJFv*R8FCbEL`0XWWsUI}tQQ#p^V?zC4Ts>HYuRM$GxaeY4aPR~+ zN~;6=@m7^l&0fEa+ci+t&}-bvS9#TzYZ0LJS?eX$Q%Gb%E7X?y?wh(`9Ud_gBGQJc zI*-B24adlmhSoBd@wSz+SV)-Y*woS2=V>&N+SET&Jpbp_->89LiX10(0x??lc^RWt zFS+vsia+U^uhWNJUEg)xmTeA3OaoMwSP|WU2=r(kj>dF1&Z;p}iR3#zzY9u@!OCiq z%f;grLKAm}8@UI^#X8(=@!JfmhoJE1HXP`%QUs&LDWSCK2~AFdY$+pu8u7)Sg92$F z5W-G{X`sJC{K*uk$0(8~VA5YT^guost=2A$(l9S-LqF!?n7IKo9WS&o;kDLBg*Er0 ziCF7WRpS%1`(y9wLOAo4@n9yL9B*9(a6Hqv*%A$uCkU8(vsydYMRoOrkwrT=rlNja@<7eEHXNxN0i&cXJCAf7y)F-S)3!ECK1N(2+E=ALpG9fRI0HESu1 zcGn^v)j#vDHWiLVycgA<>;Ud6$5oC&iNWi%pN=Y(sMFM=P>dXix#elMX|iaGJ0L?`1{^`j%ji!Re(N&CIe!`N$^7lQFq(d{aKf5!++01bcC~&UR*{eEPnwU}#Pm2wga)hm1HF(q z+T}lN%a|NO*=O4pllVtc@+f*4a~jY)O@rMTPxPY@FHS#ufe#lLW`n&YI~17VE(aiKdEn=K zdYc`*&i-iX$-ajk1-`Z>-1?{;Mk)4#Sq6zw+oMn#73MDiOOT-Y9w6RPRsT7+Rxtk0 zLJW5QRGRaj1E?w_Y;}KT>u)0XFTqm=?!N~}|0TZLrSV^4xc?G@_E-LUnE9XMi4}r6 z|0UmZ6x9ERa7OZf3EclnxRX5U?}7S%iO5=J{Fg2K%hAZD{ynkuzw2nc&p^-f4OJ|q zlmA)Lz9%c5TAj!%pQw1-Z5)N(^YcBJj5_T*`2BzVz~mX`UsCyRq<>2xy@1GYB{Tha zjRP9X>ERpg3)F|S7^luRcNwsPz2{`--zEHI$+RNC!yi($W`q+)kN36x^)i2x`CE)X zne1aevtC@te3)dWZl zVE=^?TgL6#BGE7ZNQ(a^{2DL+MS`8**xh zCjD`vzlq*|u^|83M9=iZQDyEUaB=NCKSKHEL%wzm(60Z4e{NWhCaP;e?ZkOL10AnD zLK0`9`vhtXS9{^I6U7>p53|`!Hw%rWspuNzW963l4b>g`X5C z72kS=LYI;5n`@0sy-)#7goa|_ddoXitL>X3OWS`Gq>u$ZJ&J!_HTH3WPp2YjkBXkh zK-|+{d0O4K;#;eWfm3H=Bd82E$|-1yPaZHzxi|W_(#~f!j%VWqqq@{aK{$EUoyD46 zLg<*Tqz@>8=e%d^z42id5@0Hs-1^Gv)0(yEs8hE@P+W#CM#Qg$ItBJv-RMly>X5fNX_o>;&~9v6`qFd5aIpERsO>j z{?Jk-tf|*WO3(dXJFC%-LEYVNs;||I1O!+dmqEu1TY1O!7D1iNM%psBWa9j?>|lq_ zMp5*B$x47I$qpE^%MZdPp-+4BZOnRR@EM2tqdRB^$1vlOT2M^pG#njSQZ$_w7ap}A z7ris&MhG>;r558oKflwI~K+qIERq8(8CNc_{Kdaswmx3eKjI&t? ziflihgtKesV_~hX0M_Nsee=~|KT0XHE|4P-pk@&n^}>$P)^5o=Qin;07bO7yn*=5n zD#dE?%wl%xL)ei@H7PtHJYZI|d?5M2B~1D92`UVTr4FQOReI7VcU|y9X=dBiV^))D z9hMvUeKJ$w1l1_pjhDMH3qNvUV! zx~bf-^gxqjR*NiG2LY>fm{Ag_q*g|MJ-V9S8(~f4mSEkVns2FK_SvF}n(eE~tKR`} zraKgpgTEYuwkWccdp)?w(j=l=S)}JO>J9IhXT|f0tP!rpKLmH69ES@T#qEALlA+itp~fchZ1_WAt5z1(pw)XLyaFf z_ukc|J>0N6ytlW4tZh|0oHahI=m&WmZFIN@*sqUoBT=FAsjXFpL#t@7OPKgfj81P5 zsEK_1U_FW(fnrYvAg+lC$Kx0V?#vAArL9-((3-t#EdMH$?+)uQKHX`dY8w-V(}m~` z$D_OM{ibU^=RWQC-8Ci3K)TuSIhbA=zxUmF9X%wJl8-THOGifXv2qRb*yj+qO4-U;*67TOP5IRu z;@u~fFN|;wM_;#Sf6xfJ*%Pb|F7bs=ySQK9$MAGuT%67)pvsG)nG1{zP%nQg(J)VY z3J_1z=GkQ6lc|*6Q}r$T1DU6xW9Yu}i+oT^3!#lDR<|!t$LHW&J!%s3T`mVM*!F$9 zo*8|k@T2aJmW_(-i8#=;e__4~=$G4l*V`Ak^E)zX&SO;lG}y)ODjPb|m+syGz~K=A zDHcnb@)F$Z?cRZnCkCvtb1Q8Ry*G#X%Xv-5$wjIPW{IK75?Pb2Lz>0IFxT&@J+1Ep zvUZO+UIS&)ZD8iMXnO#g+vu8POGgE1u=|*JO(o}ZV1s?=0aWBD+*hPMre!#EgF0!; zta-cgIyg|?e0>8C#4c5BH~p4hab%@{jzRSKQda(a9OrL)v>lFP2$NXYTJGM}!kPJi zhPwrZrMUseCW)J&Y8r8e4w;T#oC@KSNCO8;DobmxGR6FH5TG-`C6MFvT=MPa0Jtoq zM6ap25q6@|a3NG=B*t*WwsPLc-F^9&bH_TcMjI<*>-oO2%df+|-UUe6k8Gez!wus+ z-Ql2l%w_)=u5Bam=1jb!k?_LT>P2tk<-XFI+yKhA-1Afx7aG@d^L^H>n^cFj8n|)} z4;BxN8XO)OjU3*X?h@83b{E2F0eJUr z^DNUWJ&svd54R#XTnu+Q={=K~P6MD5c9xCH-X@H>hXhN7f-w8_b~iT zTAUy512gGC=0%KEQ|;r~v*>Qp`nZyDU4G`{v#O|}hd>BEwcD&PF^sC6(E zsyiUn`SC%SQbI3{YZ#_iqG|Dcy&7TFzl2*h-nJ4H^uv(!Si|DS*>hu;j$7)cPaMkQ zo7(Gq_nLjpkhr~YJMUS2!og&lzD9WnH;l=3)B}7G(ixW_dq2p&*qvFjl+f`lcmyh$ zHKg8$`RI<#1)7tBTwGrDMnzK|=6SQ|<+ld;6XF0D0AD*hd227Dw{%DiXhlViVn>4f zTs|_Lx0a(IEz5Ss>HC=W!)2SQczX`!r6*&(tiPTKe#lO?D0P<&6-6OLHAk*pO#j`?2ZiF z4&7s>`_172pgY{b!I$5a={gOPzcD;IGZ|SU>yU7uu=(>oMDoaBES9Z4UYLsn^^#~) zM)E;5Cet*3(V9SKOQTXvFVNnx4CJ0ho&2a#_jEk)11+s0E{&)TznOQ^plfmDm-V{P zz{Wa1qh&ELKhrzy3;&S7__j7lZB^tTNoCA*0K}yY@x%r;p1U2_T6%?i#4KG~R4V7G z)0vJSNXq5I!_{9v;LMJd`oX1!Cf(8m)wffwnVr#X;gRIfcB$P|!xyja^gdV?Y=2NW zSBRDHNiMb~+)p3MXXZJLk#t{;vr|Riag=bE@^<+EQ^7|ZnfEsjFNFPasp_%o?T$Ax zs42$rBWoDp$pil|nBmyltXn}r4Ru-i_|nN{@fJRi z@4AC*6zdjtR@x^21>`alMvgnC;vJjRXAJYh8(UxeZa3@O?bn6bRh$@fA$A{!;wKFh+8oeOB6ME`?Rzwvoya&(&|t=G+n6MH zgJ~C0!=!BkLD+uB`VIWz=k*l~F$AcQ^>FNhqihs*mK4U3ckL5-r-+*SzQTNohWT?@ z6hPySD5ghVLtIp7F3e2Er1FPCi4>5w4a(usC zElHEaC`?4%$td3HrHilwQ5uZMXaoV86vB;hq%3NyG0i5cAReH67K(246N0u^ar&Cb zt;WmZ{9zP1jpcb7LxM+HZ|E(RN|jV)6Iq@+v?4gd5g z@uU!*ZT~J_^yhZG?e0yg+OySP$whvJliTvqg*cc72t;6DgmwFcn|MC-nWHP?hYRFn z+=v!uaYl<@o5{&43@4|s*F=~cJ_T188I|t=XR-XCAUuYlW;J0^wF1TfUB|0A$s{n0 znL|P$itE6gtxHy2D|yYJmA*x!kRn|5%ffxVE5;c@@rXbkQ_%wjUM$S>Gt)kDJYq>4 zn>ZI&Y1lCbu2443l&`1aMMN5y^BmSI_ctUWisUUU;pXzTHKJJVokzFHsRCMgoqwXm zW)^w_O(Rl0-6nr!mWU0+Slq52qsdpLG4=x8&k1Ln5V(tVsIqQNE2RV!!qd26Z+_>2 zCGCOShfXFsA>8#rzP#Xea^syYWRPfOmV08;Dp0mbr!qYh(?{Vivs~&y)eQ$0hFI*p z^;@W3pyOsOWsajiU+rGdxUKS>=fs4ih}1^9L;Y=zgsyH#9G0uB|%_$6fdWXEZfSjA?D=p6f_eqi1G-!ZIS8Teq zPYoaxT6L6;3TGy)9_LZBGgB0-$m0I)I`(;US+2W#q8fe@fZXs^Y~Q_P+YOHCUO%xA zn}2yrDdF`wDJ<^n<~fYqp=v`aq> zKeF7^x`OZgr}B#>Vfp#Hu*scSURoR0kuUvg-U08Hiq5v{d% z2@E4UeWlPzOaH;?H}A_GzfNppIta);*1a#JnOPyEcQOTjnYmhNaA;%%P=W1~^6ZZP z0%s%)K?t$!2I&^_H5!+yl*otIL|MPywAyO5km}YNb-{D|9AQhjb+~xA|0ru}`M}Zv z)+;I1exSTBq)y!dy3!reJ)4M%M1dz5qL5Lu%bi~o3%V9PaFzOlA`{lQCb+xF(6j1- zy&=<~UwEG3dp?I7hdK}$q#a#Gerpp1ZIZD1S|VjL*mzH?c|UFl;^=x1qEh9! zs>3XO*6VbXn01pP-1cH58V_n!e)`4!Flc8HCu)6cDPd(3y9;`HV6 zIYHmmLsRCr;jeqnTC54G>$?sRE7o?zaU1jPIy+N}cIrZ!uf_FKHSS zn3d?tCn7akQ-Tqc#C1QnWL&q|q)Ql^@IPrY=p2BzEhip2Gh)V{wPjd~AT8u?jLjtv zy*k}7SOSk1(={p`nw`?;5IVYhWLAX?6^52Z{^owIL5@B=K0NcJ7>m!=*B(A~D(@xY zjHdIEzvW&%rr0!u+5+RA8s6d4H1E~Uk1q%_h>!*fj589$#!@uQkISNl2B`0<4F}C0 z*Ond1O{M+<3~M!$+ofG?x>9ANp3e-!cW;;-B@d>|-i(s=m9IRKz9Q7XHec>89@=48qmvq5r-o$#dN5zz zKp}}yOPWl$1K+mlg9lgydU=D5;#tDM;#wn*!V<#vs_I% z<#LW1X%skXZC5W#1j@4t-2EbFmpj9KiivmbS`3u$xJpR1FMJ{go{>IQd~%08NFZd4 z;&%CpW3pl1oKX|fE;SJ^_1a)^X28;`Qq27+zQ?#vDN%p1Qw{N#N$1@@?5GSSu zrEr~gR?^wsg_uLuBXuvmL)S{aTsvXOv063if*$pGX;2bOTV)bg)9VSD9h$8I@_Yt5 z)~?){u4pB?JBK_EP-S4drB2f|nR?C508-^P?D{0dX@X6d(N8rKLiu<4*le2(jEoYX z$XN&qSFToqxu}SsLPoyxFWY-($n20{k{aDRTc&U8j(aXXj_wfXSYyF7u-SIPo%v;- z3vLcMs#76}sqHdW#m1${s>hQuFSZX_UM-4i9@>##Tp<$IvO`}$1gtG8<~~{Dzd28D z7{7gHR06&^s7BpkwV06=IW_Jdsh+sUTW}O9fYDggkNbxT50<5G*c$o#zxLibtjeql z8z03+PzMwc0b?XYS~!G+qN36wprk=a9PrSM7=WZmcPJn&-Jnv^amYg{N16lDdEi?Q z#yHOF>+kpd`@M6`HJ3BP;p}HWd#}Cry6<)0cS%mFmZ>|k$O!U1(be$`t zwN>E>hX*W52%jNivK5y604%=CsaJan3e~)kK&8!{#!-Bx*T8ByN~Gj+%)X%3`(P%L zngAan{C;G`>SMoirTXGa z6<+Ypi{n37X9h^TNp)=X9iZ`DQZKaj;AG-`nQA_%OiQXgVP$!@HLC~#XL!0d(~<~s z;knnX?9EGgBLe_Xq?`6y;MVQ9IK3~WHB9#qCKo~;Z#*Vgcac2HutvsonJ_VUaN zrdFPz(ajgDh1zSQ<+ORH^L6TRFj!-5*XuLd*!b{hUz~-z+hS?ys?Y_8A)d%9>`68I z!W74m+nh#Pe1y%ZE|F=x(X5%Tb)T^i{JTmof1D zDp;vCKXjxP2X)CSov@aJn8;lVojND8@<%LVi;_3@YDOAZ>Xd!r*peNSjbW#bMLzu8PZH829ZK zc|@*#M_oz7TIoQptP~llZz*8+ct4t%qSjj`SV4R&qtF6Pn>!^#h7^dOTY8LbzGt&s z(@{seO?34P?Z~1Ngq_;P?%tCN9y|>pulFfO4bh>f*CM37sCZvpQ<%#EsXT$u&$=@Oq8|q zz#cVqdiQoG!?gta7ZLfln2g@`)bj>n@@0Calcp`-Zl^jlO?CT{sqH7jxj$=)K5Xx% zY_?WePO}@8H(eea#^~$oGh0S=OEObLYwNpeY9+RFt+=>(Wh;n3W*nS<+CkL68|@KJ z03v+eCNO%LCTPORd~JUbR;G~Bh0en2z^YXO_hI~iOEz3*{>rw!ZZ|B{&(f}9035u# z0+=wL!ZX8twxZpbb%=2<2nF_QO)er~hQs{$qHgWMxX9%2852{YPoG8iPS^$}!H=Nb znZ`+uQ6vf}HZb<{C#)+c4m{mg)$>d+uMTD>;K0sUP%thzkvc*nB*RrpHU;14Yn&)p zn~IC*mC9`poOR1HP;5IU_}r7@X(FiEPzoAd)X)~VeROi+I{k4u_lEkAK%Z`r;>U>i z@16Yj(;cF#U5%&ayEAb523n0Y87 ze7R^iTp{A!jW4`ibAog;-i7a#8hRthUd=JJxB+;BZMS_af)(YuUk4}Nlxq8zm4fB_ zyd&4=;xlJCG>UY)myvvXesYj&@sZ=)a#!ulwOOf38GUPQNl!95)a5JEqF3>cxlin` z0cWW@kRy)M+@6ztE_8Pgu5g_hJw4}cis0_)hR*FZdbcLu-lbcbwFLM=e^Bxu z*LgSo57Na>W2c1_BON>nXB!Gta7R%CaiM)ahJhCI?_;awFWK#H49-+C37&qkkP%pX z7B0FRTihUDk>!Z4L$19B&I5oqrMZf+=Jp+U|EWYPrZEEIgmh4Dpd4e3QAZADv{PjD zbg-PvixLdYKwybqX$p~$+wT*`F*LQgg&v^J{v9ec_9fpHtX7B0_cls&?@i5^7y3JB?>kR9kC9b*Cm%2+Ya|gmS6`dxZn*h2LqFM&r921FvN_UO;OV^T7`X8o`Da1*7LcXLY5N4l)=m+ z=;6ij@m}OEqDgo_MMmFzs%36#VTT+eZo#&HRU_36kH;GZji4CER=g~WGNFK7(qyqR zA|N7Y!w!qfeDgS6i(K|ZPd4Y=K~niw-C}7w+c6dv9 z8J;Ynv>Y0Xb9ZUYaJd~Or!yhVznAv7vJ3yCV@QE4+Bem98%g8YS*Z+He&N^o8sU)I zw|E$>T+yREOh;-AO#~D*`s}C(Kz8!kiFV=RT1m}((?JD($)SSfH~%6y2;XC8T|E2F zMV#DP-h$Q~C(_%R%SC|H;YRsQE?>F4&*#`#hTb$PSQu%4`9+!eOXTV=}|CQ*SEOAWVq$RT*o>zD1T%|pllwqHwUXW3l))RVDas0 z;+s_jSxB#7j%>UjI-^?WVoz5>;IT=qtoe2hLsgek-SG-^3L9XX%I5BEf0{@?lEsc$ zxT3b>VDabp06IJ)(3QFGEMt_DcBqTwebWNaa2}CDgR^nh3U5cDCi=7=&1B=+5PC8n zy{Vicm+J&U0$;f^pJDqHcnH?$I#tp9sn~FuW6GuC#UP^%mAr2|p*4Fc_ zuK-SMF=%0syQV>S8L$j)3XJ22dpTalF=QhPn_T;N@_Tjq=0;V=aIlBH@`)>B*{n1X z%Hx7P;LqB!aN6ZEZ*8DNw(V@|O^ZSs=>^ATE_9C~GsDES60986h5ZU=b41))o^F18^^dxt|qn?gZko&_2w6jbE*{a z@;S6Ik>OSw?CJ%ltydQ-XKK7R4^_Ig47Rl3%|rD)O;DZFd`s-A*P=pKktQWk+9-0dy#Vi3hk)U`IHHby26!r@^`)65{J z?^=Kmz145(YfN$qQ`PLnF!M;s#jqD2p}h2(GO!W45pUwRG=lVG`@*C^zIkfqgj)C| zYdbsniX2x5sRrT2wP}vDk<(~^VipG^-!j%5zGz+JMt;% zK|1_u8exd`(}oF=Xb0(aFyCq`P|u{a>x#FXAlj5cEvB^nt;q!QKiVl?Q}cr<$aB!W zY;2!0x5pnE%~wsTYNIb^x9o(XCbYfXP3576Q{c8OHqo2Id~@Bp4Y!1zR1hI_B`aqeNzAG_!B z15M9qY)6CO_(Z_x6PL=RS;zFN@@THWI%!voPdKH_jDJwXJto+w1XGY;Gkeo$o?{O> zHsuG3X3$|0j0+Yt@eL(+%m59j)kLWVuyHp8^@tUbuX;6ZW{MCvuU}Hvb)(l(Qw>bJKECvop}9fFSV-R6 zz=4dYk}lq@B0+1gl(#)d!J4LbD_TlZx64z|YVKt9kJF6p5?j8(q0tBelYR_Sytp$p zSKFeeH%6Bb{j|LkWPEh}V(ry|+&_`^(tg8}T?~l|mR$i?|&5kMhm0Q;GvGl zDC<8bX_{R#Ux$%4I`AjEleaK z4y~v96Qg%d|2IV0wA~@gFVp^>k1v0cV))H4V?(e^ZCPw+$!`z#!_PM})8MJZ^|n$T|7Xbtd*@^ZlAB`+v3hHabQ~ zsf^T@useQ!?VO+})1hXn-aMLiRek-)~7DJ%9Cl|3#&#ETT0b@=WmeUH^4+B%pp16LI|WdN26eD}S1|vdw0p zU#m&2yk;^VW0Kw!t^7NZB5&!xlkx-kWbARTJhQp}x$54$rv2~qbCLhOzJDyGuVPv1 z|9gG^==l6D>h=G??n7nk%`k~fR}VlIk6!Ku0LFfojp#qm9rPFZPT;750n1;0dYS1@ zC=_UA>RK169&Pa8f!1H5no501X+kmS84!_1kZvVNvjytJSa^&y(LLDVZrf!jE9ilWm-mNEETRMZwK_KBYF>b2E|`m3qRp5IU6c$ z)j;w?upp34ye?yBeHe7<=40J#Pio zMWP;akM6^bX4t2=5PoP46WbuGizwDfU+imp6fDixW%rkt`Q~RH2!@NaKISHFJcy7H zNhbYKnCB%~a>+{AowVWrC6T*G`}L6(ygD44aojEbN-EkDeVg@nkA z&j7stQwXV+3V8pUq}oFUDZIHeGl_Oh6FJ?J6+i&GhFo1M*m8@KtSVSFZXU6nz*KoAs6kIUH~HAe|jh)GfiAm^Pg-6j)!Pa045UTD1FWC934+Y4rlb z^8(;C5&wKv&FbStinv;Tx?{Bfm8hSEAFUqrlUN@@mC}$JOtOO*MBvq0Zx~p~Ws`Le z-5)ni!ww~NbI&{Ak(zL%&M>_qrl!i^k&z=Z#cU15i+%A+fahOtI4)SSju86QfFX(w z41XsA-gg5|JLyOA)`oTiy_uxqut6OShXFS%qU9Mp!$zKUvw!5qqT`0;bR4Q!zD3Y&8J|UJYnv*4vW5zCwElu}G2ZY@)_A3NLyrF8@h<1wYqDy-woRQ9QYlGkXM6 z?!z{C=7pDJxFFdgXiZr$6(XHRnAv(1)gt&dJ-)eVNHYFxT!C@Yasx_TGB;%K7sCW7 zh`0+30*L2f`s$>+yZAm~!y?CU3*EU*FeXOf)6l$u1H0e!la`6Mru07qj-W87kw7y5 z=+a;rWVexK!ZPSPgw!NRE0G=&A4w^zHQ=qQLr2r~+BveUW`??9QOEwr)`vMF>}IO( zfY^ucY^Anj`b5v?CmeRix_ykciRW{N1_qL9lk*?750Kh+7J-Q}O43UX6cho=L!bT5 zyIFB^fipsBayljqd1S`)*(ZM7F=zIcbK#)RKuI8|%cS;Wp5GOi!r=Y%hNVO=ig!pj&Q885AMhU;Nbl9#rH6kLZ5~h7Fq|Ie(~U0@EFp zGh;y&0Xc<`SciCj=Ds=( zJ-{m7G8>}6Xkn}^TAeJ+gyKqF4~MHv#9AcT4_O+c@lnAIuxQ)xdRqsvGDz5nw$%bj zbCr$)dPT)Z;*5BEpoLPUZ`*@=&sFyE_a_3=m^p^r$sd@C7mBD~(>BWcSlQLBBCE)3 zT1lhJn6xqN?Re*%4IgY5p5=XALkn|iU$|&rn^nci#88%Z4?C-BkwVpq_1z=rYhkA0 z1h;`wn(=Czj?{G#fMQPunl}h=sLE>;>BU3__q>KCGTES;jRY ziA4*f6sUCc?4X~5?-p@UiIN%rG!=P`TVCC&@y>wT>4L(_o{h;u(FheaWnitJRGhBN zo35mF7n&+EDB|gd8eOuh5Vs=T=82Y~|HcgWMdy7pNH_)S-U5wjw+i>gROReT&uGmV>@QFe&-w@ zEujI{eZiS%0jTRJ5BOX)KyC(XF{K{01SGn?zWgfL4>dmn^7V z+IHuzPwO9+`5|77^u7Ul13UDm=?#kM+yFWAz9Hn%+&zb=E+8j~O7Z>;ZRoPD}F zRq9xAhFF;g5cyxV9zG!bc3XKcr?A-Kl%Jlmh1umJx!1s4i8jy{2`k?jrq6o2uqK@TBw4E|+;*kQVfN*b1gkE;DE?T<6B z;rtl_1z~UY%dqL#sh#r6Ul}uaj&&SUh)gU>MVgjOXxKH!rd*5)(Nm7NP2+^G_ik_@ zXb8)8KPkD81Pk-JVcthZfA6WW`+vbBN&T?`vHt`OchSg2jtEY5(e#p0X=9lHniKe$ zXc_Ga8n#V9yVlk5<@w;nqT%9dYxd%~88209G63h_NQ<`wa9Vw(A2vi(w=cq((PRS)mcAlAm+L8G@iA+<_KGMaNFzrTASmBn^6fg ztU@^LNzB5o%mTDugj{|qU$T{XqDoE*Xx1^@t_M)Ei(3|TV&GZ~!kMbRc`aJ1up6W4 zNE~t^?;r85ri`yG%Koz4fx z9I4sXia^&6uzmQrov#QZz;*@DCe=sl4+P3yq;9GI{JCr&6|LcC(GF5LC(3O~hy;|m zLhEBapxnr~J>e#<{y|^4PsTjsM;!p_Fee%N>I+A~Ou)w9OTYzPxEqeTYB|QJ^$kL= z^=mclaMXRtq%~vx#I>q~^GC&Y?H9js{$0px?PGr(yLIT@pAVDSqYU?KtugA6zpwd* z>>>B|iG#>JfaKhH96=(Q>iJ!^Ncz{l23;wle+L#lDjGz{Z5D=KYgc z-DTlO(}GCjhKzg719Q-(jXreY%&NpTAy`ZOt(9C3Ymafws7LA?LtVO&(p&f^OZ?C` z>YcgoR(VN6JMpc|B>(nQdlovY4{t}|)3wB8Ih{F+rkmZi*MHp41=u$pG%l3%EEG<4 za1AR+knPk%ijKkWfm?LqjF~ zjyByyhnosX5Qb$KHgnuW_XlB2^oZQv{a7xQz80ZGpsaO+S@<3R2G7&DsWcQ4L*?dXIlUGWaeA=Z99)D#H0mUO zlxl0D*#Xx=wGa_|B?7zDfl4q1;vnSVm!$18WzZ7QQZA3V30qIIZ#}EqP-He!9se@V zR=y`rko{rdt+($=y1f(~{m1iY75kd%SgvF2LlC}Dxb@J@Y^?7g4vpuGvJu=P=#1`I z&(jjRb_w^G2<1N_R!ELMSUmu-(q~|6DRHA>ynfbhZnV%JAuqHA-k5=KuvTP717yO3#C)lN>szB75-3vY2Mwy_$ zgBkLtD8yKSXrHm6B?R2z8qf4Tt5y zcVdhp-DFgb`fcQI%W5}p_fdoKm~B+U1(YN9pc2N#O~^Z zV%(2Kc(KAwSPRFP<@(1-BO&2G%fJ&B`~0LtZhb-aIQbisWs^s6 zWJbvDPgOUFQ%CXD4!VCifxk~r)MmvU8xSq!YzFsX8H{BdR(3;drL&6tD)fc>kAlmT z&B|Q7s4sx@Cq^OPk=aRabze12Jcpg0VE5zq!*Bjl-`@ITn*Sw_di4gxJs-w&#NUaK zyz>WI{GrUX`SB>! zFN^SB1}Q0K%d@3z68FP_xVeUYiB0T5G-a7>>`x8l`~9r^@6-pvivL+==*uA4w-D;L zT`x5F+I9Xd?R5Gj^73^~<`aU3ul=;mw(PLM{t7_(GRT5$SttrQLTc|%@8H+K#BW|4 zVy&%DSy#TQZCI30ce!#@S_`LM_~Xh)HVkLe8>A~53YkuFJHnjqw_cF`_MqQZB6Wll zwDlK3;4IRNtMu~29KO!Ntj$TIYX#kN2@F5p)j1&sLO*Pnb97youH&ognyPRCVA)>b z70qL@7k5SI@5(B&XH!u3q1eK^R~rNyUtY6EBjqMaN?P-Als>Q0-G79dD4xA_;kGx* z5nB;6}q^!Gv$~Q+%Bt(XYqi z9e&6LK)J$_h8ks5bq4BLWxF4Q+w=45l1up&I(iV| z_;oq6D_YrCUalmG{^PB@5fl0)Chi1+vD+^iyPRp@zWn5;wMYG`{WpvDuMFH@*NAOj z`gy-Cm|IbFd(G^adCbS0szrIf$IWl+%1MT&X*ya;=r7fPbMJm8L;OKf58;^KzS{cH zKi&|sR^`5ql}~`DB1>vt-bCiF@Ym~zt;@OQQ0WE_4)ni5`CyCA+wm<}y&bPGgeF|?8{x&CPzUHA zAz!jYQuW;3-3PD)Biszvw>_M~xnEbs5G1+tOMO(iN@YND_7by#&5OL5n#RS(yL?3Z z>54$V2$^`pBLUbzc^VQ->y18eQ2dzR{g~jI#GUF#M(qtSjPGungj%6z>hCwMw*%ePtK z6x>ni+08-+O2*loj9Y^z`k$Ya!~#YYy{{`!Vwbkcks3QZZGR7?+TO+LN>5rWh@tol z)def>?n|>9OnQ>^+HRSN^(Lbbwz(@fAjK4A%~99@cj25D3*4$X;hyom-;c8)vbqpn zU4{L=+qpMZ^Ue@TRzF2dN$k-UWcI86(+6rmZ0Zin2lXI_r&oNlI+3ZVl53=%Lmf&V z>A1dJ;%D0Oirt;=&a6;*^Q*4Q&fGMw6N>^-?%w5&1=k9r)a76o5cVVUJ?$n?kGGI2h`)^s*mArkdiv_o=&K2MyRYgQIYk0 z@Ltt}eYLGAU2=?ZdP4f5h1xhAKhE>p!F{jcP0U3J6#um$M_FAQh=SSj1pzd9ALO{( zWF1#{UU;Frnz}6e6ZD|JkY?VxdpBP$#Swx{Hn(3w&FU@QBUhOzuvv83xr1+Uy{owI zu8Pz>Z{F@~Jaw?9yqU+BX{ybaC>bsFB)V?8D)QOPdhrG^zU^HAt?}Bljzp}M#NJlX zlJ%8wz3G9I!cRM7?tc#daIfQ)JhQK^u*SIAlsK98r9yOF8Zn0o9rG<&jp`-?gk(YQ z%jRg)b)E3&D3N2OP9^i+YHyCee_ zqM0-=wV}j}9q?MaY<}%7lLZw0JBB$!g*RFPOV0j5$ioP=vcIFvZA#rh*)D`_!B3WM zIE}s|6%84%D8}GgElXy5&7h&nh)?5)jgn~i$}kdVuz9{rvMjtB!E~l-miQ*|;UO90 zmX{{EvY{gOmkO2!BwKhp)IyEG2Eq)A?u)%Db{7j^vaxN-j$FmV!c&q$ll%_YnZTQ= zm+92$07DG{3MGs-OhrTOB{8kZ~wP|^=H+~m=1IK^9MvK3s?c$}7vG07RrNlaru&%@kIkaDCmBo~p0D61|Y# zB^Nhgaos||rc1T!Fil#n<2HRq!k3nPFxo1D`cd8BRVi6nJpLEJ?jqsHJ>Ge?rq2%ImVO7hn~V+7Y%G%F#%oSm z`#1QR!ARycHR74ENYS~bGLkq74(QgWla@_#DCnB?*{dBgAHgQRzz>x-70!kCC%iwU znr=`b9b<)uf~3Rp3>32O@%gb7(rJ&&&%IZ*UCU$EXGk+(_?BYaigJQ(FA)&Vd)M-| zu4GllU@X^~sa=g3doEh)58Dq;n)6eV?duM6%@R9r(Vg}aSo%OiChi{X%-wAyl*4gW zCA2V1lQ`C~hwLVmyF`fQ|W{3_s}k#PZBg45O?Ri)1AD`}QRi!eZOGa#B~YQENP z>Eo3CJ)zvFjg5+3BHqFldZRYi57d0-`%GF=LKIg1{y?zjpk1l+jN|%4cp!&Wg60Z5 zP)?y-T-o_xfHwovhTk*-qdo(--(;|(UXJ6e7+sfN>8(-m*1gAks7l;`7Pi5mck8jbG`rfU&AVN!#wS*tJPMi#(&)^jt3a9;8#^zErOC7}G z3t$P8kAr!-@V!WLeh~6nmOoR@qM&Udg=i(XHDLPokGDG>Mnrb{UWq#pPA~{KzB#+n zm^pGe0z5`wI&3xX*p)x9rZ;Pvdsl7$`H}8k>e3Bv9mljA>D-ors0DR#W?J1m?~5X# zWcZ9h<`9jD#U(_m(7(`2+@32H@5d(DjKAm1T|~Bao&LHb;O=Rm>fiybCU6)YS!R{P`m#|FYoMf}Kx3>|D13uTjg z^^2OD&u`3%ZYWr}U9WQ-suMp@R~ior8&|GEAwPV; zH9h87`vzd04uprL&GgOe%_7Y*7sa=+A86cw|#h`5?{|{%WE4TaW%NWfRwnmjLS3nJ;mw_96p7S_8Dny z?>IiWrm$g|NyUt`*OMWEQU{w7DM6eC7p$Bj?|9aXOCEGyrst>2` zxWb%O52n^cc^azt=#>?Li?gmBNptJas&w3v4qp+uVW;SJB5fu>7&jWAYQ(lSQMOOC z=k=u*upirTM6(Fev;;&XXp^4^X;h3Xh=LYv?nd*D1gZ1ETOoX?S?bV5U%z|ULaam5Zx8B#4GAOqB? zw$9}@st2zwUy8p9#!cvaiNe+n^V$;9C1{Ejtqs@h+ithmdH3qa1T}Xmj%xMFmE{Vy zhFNE0$5o+64ml2jkAyk&U80Y$U_BP|2F;uITejiAVB&D9uH6C3Cl9!KFIlb}_Jn=e zynK!`$au=&yWLb2?~a@v6R8kh1DVlVI#qQ2U7r{2nX-N5;>6Df@1|hnD+-nB<5fBg zRE?TTJ<)MA$&mMP5%}qtjG=XCkuS`08<}<4l*6LjEeG z%6b~9)Zg>7htSr_>qKD22a8BsD%omhjePB)ad*)FFMCk&r8aXwnz>CxyOW*Xwi^H( zs8cLKHsicYYK38oQWkkL{l$3hg95I4o|l-*R1Hej+)rDn7k|4UIyIyAZ(xM)R!&@d zTbHG`lN`5m%#V+<#!QTvqC?DjB-&+mB`mvR;7rKb`yf-xpUphQ6t|0Co<3& z@FW}7Y^^SVl!(?LwRr|*!!geuC);d(J0xX%pTp-zb4z&deM^=gI!Yd7 z+*}ci55N%v)7PhHcyxwRRNM@P%DvJYdOnIUJe7;PIfe0)Y}T{y2WUS;r4({_$^2|L zZ25rFl=ZxxIdfU}$i~t42D)i_f4_?P9RnUuE8M;5&jbaF5TEt*4~2JvwYS^(;Jm0w z(2wO=)C^0^xc@O`4j4B4y#VPx>Kj-lVOYAw{{B*QUGmLLQ&H@$X}R;k(3Zz!xr|p~ zYz}4*(d^r9g_n`?Wt3)LlWNYYDRKZE!a<8l&{2-qGAlIKl%J2UbZ<7&9zywH2K>?> zm-_@zm0zkMJqRPmn}||uxxm+OS@_Ts?vyzVO2LY&C{r7a)}vlWP91i`<+MR~NZFWS zVA+U!@x3$3DVp})kdaicnY&@M)Wv8i`Kq+84o7HOKWLFm`-JQGLO_eAp9Pz(q7Tr-K7dN89bRfttf5d1* zTW@G8X1qxa4zv7h54{bb##ESN7-f<1jpx2W} z7phWqe;Tw(KCwg7UU-C1ijoUNd!l=BU)=E6cb)32G$**IO3n_^ag7|{2IG0PetyYS zhv6x|)$SsS?i$SD3+g)-p6J`4DkJ@M9k*2BXVCTRs@ zV?B*(cGD;G@saim0{M8Vlv)7PLfP{^yu+cr@smOru6~fZNc`O1WX)4gs_o{L_*B(| zimmo?u}14TBri7|rn5ZacEV$2pS-(|O5Dn57PlYE41KOZBP}BMeJtf}pS%heG3G-O z@*W7Q_~^cpVccfxECM4?hdAZ2RIM$OUfsc6HLuQN5_yc+VBE0)7nys^v%ZmQe-U1b zy!;2#$YzD6SJWg4vm!gwJJlY*NO3pLsLpmxIK#y!bO96 zl>`H^amze&COL8}n#M1JDj1ukO$xcT6)i5%HaLgR3-`U!)OOf)oDamQ-nBT@!Mp{o zh8FpD!}a7gqz9fDfV=%4cCtrH86IU-O0FY}$OLB$>x};yuQ=H2!)(MO-07UtD6$&6 zlfjPDQUs284!@`F%R^+KmP_)89e-xf{(M=~!XmNz@lpU{pupMu+KFd=+@cJU7f@k9HpjvJP9AiO%@UqPky$jht{kKHRLO zI@GhnvCQ55gv8F0uG_?w`D}#{H<3W98Z}Hsm8pbr8_PLj$2@$^rv_0k#smhGS7LQx zh3S#q*F^g+i!c~5^H5F!A8n?`rd;o`_|4p1rZdhlq$hG=%;+V}C2`CLp0S3n*x=r* z1~D~fy!w=in&7k19EE@}^lt4_u$n1j@`kQ7m-0W4?sE5QM;V?qj+bNPD=2z!t1VFo zHO}9Ezii*mITuYk4EiI%HPhVd^UHfnwVLbCW5kJX_eFGBrVe~$ktuUCNV#y&$G&8w zfX+HvN{Y!Ch!*iEX^v1MR(d>TO3@#uWDF8ZU$g=~|hR^uD5Z+(ngb_9C+Va}^PKFnJal-N{Z zL0+ALFj4nl-h-+S6x>UM3<>t zj+akJ1O3Rg7-jod7g0<1I4oI*nE;9NyCl*|O4NDaF4ze5i}Pq21pe)yniN8hzjv-( z0&^zzMN{CxYL+!>*B$ zdyJMpvFb>^=^J6c(1oxwV)+LdWyC|c?hZv^kt02`%g6?tD%TVUrl2+bA+BRAvOIy) zn)gUTN8&E+J6(7gNo(<7(hD7Ft8jFuS^Hl3$SFgc#DKEx8v}`jGF4k*)O%dt3ya&| zz}gxbSD{gM6*IF@F`br|EcvCyh3S3ggGcwRbtIds(_D36!5E0wj+1AMis$1WrJA00JZ-C16}NG} z`6AU~$|}cdX;_G`8&i@zkGG0ymhsSHxj5lU@5pBki-j&3-g~V)&EPBnamG$@b;*1cyRc{Fl z;V8rPpPN6-tkRJYJvByMl{K)?9J#SZ)k32i=HDPNEY31HL>WF;q~MZat?Lcx>Qf5K z1oeH4w5X|RP%@GNNh^CdtZXU>Sm2tuJcm||L90Ku`-H{SbQx`D?%ab6Ps7#xq2(a{ zaIUEFnet{%Om9%KFd|rd=5Vn^cqD&uiagqFqJq#dD}rhzxaKymPCx4~(opyg%0^j} zxKlCk++)tvs!q4pJ$z=pzPCObX+1X)^0FRr!39rJ=!=hw z;#y~Sp-bjd3`|6^kWtJFetb+}DtoLZyz?l>r6^AAtES4--Uj7s#3@Gxt(t^G7v_vz zl-AU>nJLCif$J9QNsA&^@#a2p!O8+CL3{P@XPu|%3)wi(vGq`J4%3F_keOlngeH3+_QGh9;=QKG9UFR0MF3Jl%4C|z_X-07zktPN~7nqH9=ci^VZ%`0ktIeKh5#MXwF-#w8a+oj`mcBb>p?RV@B z*m0nT1>_62S~KQD6}V3ya~{7=zq$c@}U0zk;*qP7lQ) zQJ>mPNVwtv3+7zAvZj5?t42**qQA1n6;O^sXTP9#9yhKFLStteMK$?LIr)36{fKt{ zeX{{v>>+k#WTH~z9cdiNvPB07=Ck#=b5G19cNE`M-dKi`SUERb!tE_LEEaNYzJveb zJg<8#@GWzrnfjKu-8gj?Cam?U=&HO`7-D<7rJ;l@7uUPRY<5OE%%Zue-IpFGvp@sx=*MkVFK$-BhnTek4_ng*w`4`wt`x$#mfqx#jdvm$k4< z;C!J%Em)3zpE+$a&y`wCVa`8Y?tAS>>_I9AFN>!Jl^29A+M1i7A#>gLSjNb-Hnsly zopf@O(qLw$+v;^MspO@#%|Sy`{2@iCWQ`#7cWXxzJ)l;Ch9_{hGjDsZ zJJaP_u^%2d^Z}G)#ty>(#%tQAiT@Luwm@GwBtG^pQ~ z`|#*S+I!^?p5=feD|=taDmS`{FG~vd?EB{(0hSKuqgy2Bx)1mhRe0=~5A!?em)c&b zHFe6A5G`lK(I@%}a?vjO?}mfE^BgV#9Vt#+@Y84Sv+FWRdR;OzV{J(QJxTwW)0N|* z{Q$KZCetmUqM`G3=AtNofne6-+=dyyFc)4>r!80wY|Fx|)k)iX3f=Rs$vzKBNXJjI zvY`sUCMHc+khwE1n=CT_b4f;{{ZjZRC=#AQt{^fuGlac67wSS8c*2WHvnJNQ-Ymr8 zfdMj7?sf7{I{3$byRXsHeq$$J>fgl{&yog=h%)*_Ri;zo}>Q9^l$#a(d`sh z|4IMb9E?BpE3LQ|&Zo@(`ovrTgp)JhgYoz9N)3$F7-hLRjsNKSy2a_SoZGhm{=;X= z#ZL10J72l*?X>#-BT*d?eP25LI5S@!NNoEK%HoGHi#C7WBgTIG`Ty_w>+!Xp{~PcK zPwqGsoNz|!_%}|)-#=1}2vd;#*gf=h2&XusMu0oi;VU-ILG}}m_LkVuV}Ac+A8)3F zr?}**rDAXYDgOhb^l|F`arru_N80paec z(^Z{QWWKJ*AtHX}GpG}u0GD045_IwXk_%?LUgld0JfvPavL zHPM8$#X~Z}q*@tOhZ<^LD#8SdZ67Bgc^;oS(Gb5a{s zUN^UpCL)8TQ*+8Ik+O3c@fe*#4reqvDut>c(3KqUp1C5YY=aJqi3Cw%S;epgth0C!`IYKIxKm?1$w1y`ox$RSSW-8PrFvr3&o-Covvd?% z>U~9^8DS=&%YOZiMnW34QzH%kr)T0V0M!UCP{w&pRNuI_Ii}BhE>J^H-ima7dkeEb3i&Zg=FIgcf&bYQWhg1!HKw#Vp@l$$<+&<4ZU_)KLESa53M@vo&6y6!E z@z;Zn$Cd*IQ(i#}%_4g__wO)!*NW`Jk!>VkroIsdy>Bg9HuGZ$V4iim%y|xHkk)1t zNGJLZ?|6v}Sq4#Ci{+g)w>E)t0`z>m|Ub4&HTo6>bMdeI)3w$chrpR7xF+Z^fZj?zCS+mWn>r z1MhLq3c3}g6cxo)8E{n8v#Ap5i>7F&3Fq820zS$U^}^TO>?a5M&A9dAo>;m>dgskQ zhvZNFJD|b!{@;ec-@?o&;5{zalWTB1!gH9e1`I>b79PQx!f}{vP+& zKQ)c-(6_Fu3+8#k-6Hm{&)>R5N!B_O1oQ$5HxajsQ}cf{tw@&rd~R7OBo1mcwr}KC*7@e3ewd`Uj(RwtILUzs80C6Mx`&UHSl3g(RbNR|AW}o;AsV(=GN(YDZ Wc$-rma{mE;UAZW8A@%&NhyMp_nxeG; diff --git a/doc/settings2.png b/doc/settings2.png new file mode 100644 index 0000000000000000000000000000000000000000..f956f30f44da30fd55b67371ed690fcc07b57bc6 GIT binary patch literal 516573 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

aKoSoq1o3sL8DA`U6WVf+?c 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

tYh7G?+lu#N&>;>#q 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

GQvT0xIs#3!2` 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%0D1Q zAq~<#pY%WdWfK~;?ey{-0~;(<@^B2$%+!?j@ZMTg=Bkf!+r|A3(*LdZz5vI1pvm4+ z^t8e7R@BxgbCq5z$K#PGAUA>CWTKQ{nSI4$O~vL&2SiTVzWAB1av<3!VB+%eldL!Q zH$wLHroSnHasp|RT!RnIzhZ#&a!DKd+?!=U-O_X_=6+)C39=sZm&`NgNsXI?nEEOIbF7D3IJEG0@pa~^4LQ+= ztiA>Z^)vD|m;=0g9Fu>1j)V*dCQo3EW76TgTonlxyKMo$P_&u#k@CZ}Uo@Zz<<<0k zbFcZ7$b&?xli-G@B&6-rl*xu3+0ww3SPeJc<}X9Mck1_Vys>Liq<A-qzYlaw-}8JT#UC%A2TYU-Bv)+_G}uhmSG^Oj9aXYN-X zTI#Q43fYF8rRXZH;jP}H?t?wMzwerE9lx9l+))xrK}Me5LyIa|Gu*y}2=(Wls)diY z#Aa6R@9t~w?o7xC&7n}|0#r5>4BQ zlwt(#5~YQwl{q;`D*~t15Gf+#I9jVTJ>6qBowGNcV|!b;YHu~CXCleln2!|I8r4*1 zT!{>K>RV2=}QeR4ov^+`oAO!XW%Jv_gMl#U8t6Ti~meE#6lK+~K0R6wM3m zO8YAM2P`e}JaUYcff{y`mNFFn13*p3e{#S69hGVvM1762h5JfggE-8Zgv@|pyMOw+ z)1o)W%0sDO?N|^8x>Tjw)dJr?{xfR?Jvll=0~u<5!Yt390x0(LA(LLz2l4O^(fQal zy@6}s`iFpw5;y&`W{PHmyVoAI4Ty|foxJ^^j0j~S>v;xXHmM-#-^OxL1FxtGA9(EG z==N9~0}@B2dt_o2TyLj%ehB!H^3yy)A`fhzl37P*J3gU<&0pNs6S?O`gJ1%7D2Zhc*?I9nW zfI;DFp)cib5omrfu*yKnr|kxU4{izJmFH84}M~;rULnn)pA?{28QP)|up<&BM?$XqYqWAL6HN5LepS76)(jF5e8Xs zYQEfwJ!SZD+VF-fN*{2aV+(Y6+dZ;}GoKFP(EpwZfLWP0gp1!G#Kb~*xrBg01iX9A zZ&BoHJ=C)$y+4Y3sbWNGm7?lLELpvU)3Mh7sM7#%&O{b|j!O=!3UE6yEfQKyG0YaF zv+?40GZfgrW*s=#t?lE=Qg3m|Wvti4`*;&({K^|Nr0&gHS8gcC7wm=Atd6^X{2N-W z_DRACVW(7VKloAGHb)6eFa2mPu*nI=P#f&;*G=8kjqfa^KL-3L$*IX^Uq}1?AJnl% AM*si- diff --git a/doc/sidemenu.png b/doc/sidemenu.png new file mode 100644 index 0000000000000000000000000000000000000000..c2d7e47e329aefa7b1ad646d3a5e5497cbeefc13 GIT binary patch literal 218554 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ð`lvz3}6 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

H_Hy8ns$jjl0tg%YkzNyt(fMoNgQYUHbS|_U|=f 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�J&v3c#8y|*=W9CM*3nz7ZGcN0KEQ7k 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

=OB2&W`6Y0y~Ew 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 Date: Mon, 4 Aug 2025 09:45:41 -0700 Subject: [PATCH 318/476] Switch to a home as soon as selected Signed-off-by: Dan Cunningham --- openHAB/HomeSelectionView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/openHAB/HomeSelectionView.swift b/openHAB/HomeSelectionView.swift index 741c6710b..70a9dd855 100644 --- a/openHAB/HomeSelectionView.swift +++ b/openHAB/HomeSelectionView.swift @@ -177,6 +177,7 @@ struct HomeSelectionView: View { private func select(home: UUID) { Preferences.switchActiveHome(to: home) + dismiss() } private func loadHomesList() { From 93a6fd5beb68827e34fc1f6901394eb1f72d0c34 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Mon, 4 Aug 2025 19:05:54 +0200 Subject: [PATCH 319/476] Screenshots for Release (#902) Reworked Snapfile to match existing simulators / and then transform to get 5.5inch screenshots Reworked UITests to enable snapshots Refactored DrawerView to handle accessibilityLabel Eliminating swift 6 warnings in UITests Signed-off-by: Tim Mueller-Seydlitz --- .../OpenHABCore/Util/SetPointService.swift | 4 +- fastlane/Fastfile | 5 + fastlane/Snapfile | 4 +- openHAB/DrawerView.swift | 243 ++++++++---------- .../SettingsView/ConnectionSettingsView.swift | 1 + openHABUITests/OpenHABUITests.swift | 125 ++++----- 6 files changed, 177 insertions(+), 205 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/SetPointService.swift b/OpenHABCore/Sources/OpenHABCore/Util/SetPointService.swift index 48ef83006..2ab31a715 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/SetPointService.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/SetPointService.swift @@ -12,8 +12,6 @@ import Foundation public struct SetPointService { - public init() {} - /// Calculates a new value for a setpoint /// - Parameters: /// - currentValue: The current value of the setpoint @@ -30,4 +28,6 @@ public struct SetPointService { let newValue = isDecreasing ? currentValue - step : currentValue + step return newValue.clamped(to: minValue ... maxValue) } + + public init() {} } diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 7047aa7b3..1de84ec47 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -48,6 +48,11 @@ platform :ios do capture_screenshots(workspace: 'openHAB.xcworkspace', scheme: 'openHABUITests', dark_mode: true) + # Optional: Convert iPhone 15 screenshots to 5.5-inch + Dir["./fastlane/screenshots/en-US/*iPhone_15*portrait*.png"].each do |file| + output_file = file.sub("iPhone_15", "iPhone_8_Plus") + sh "sips -Z 2208 #{file} --out #{output_file}" # resize to height 2208 px + end end desc 'Run unit tests.' diff --git a/fastlane/Snapfile b/fastlane/Snapfile index 45128f725..cabdd5ff3 100644 --- a/fastlane/Snapfile +++ b/fastlane/Snapfile @@ -2,8 +2,8 @@ #A list of devices you want to take the screenshots from devices([ - "iPhone 15 Pro Max", - "iPhone 8 Plus", + "iPhone 15 Pro Max", # 6.7-inch required + "iPhone 15", # used to generate 5.5-inch screenshots "iPad Pro 13-inch (M4)", "iPad Pro (12.9-inch) (6th generation)" ]) diff --git a/openHAB/DrawerView.swift b/openHAB/DrawerView.swift index efd80500b..4dffa7fb6 100644 --- a/openHAB/DrawerView.swift +++ b/openHAB/DrawerView.swift @@ -81,168 +81,79 @@ struct ConnectionView: View { } struct DrawerView: View { - struct MainSectionView: View { - var menuEntry: (Image, Text, TargetController) -> MenuEntry + @State private var sitemaps: [OpenHABSitemap] = [] + @State private var uiTiles: [OpenHABUiTile] = [] + @State private var selectedSection: Int? + @State private var connectedUrl = "Not connected" // Default label text - var body: some View { - Section(header: Text("Main")) { - menuEntry( - Image("openHABIcon"), - Text("Home"), - .webview - ) - } - } - } + @EnvironmentObject private var networkTracker: NetworkTracker - struct TilesSectionView: View { - var uiTiles: [OpenHABUiTile] - var tilesIconwidth: CGFloat - var onDismiss: (TargetController) -> Void - var dismiss: DismissAction + var onDismiss: (TargetController) -> Void + @Environment(\.dismiss) private var dismiss - var body: some View { - Section(header: Text("Tiles")) { - ForEach(uiTiles, id: \.url) { tile in - HStack { - ImageView(url: tile.imageUrl) - .aspectRatio(contentMode: .fit) - .frame(width: tilesIconwidth) - Text(tile.name) - } - .onTapGesture { - dismiss() - onDismiss(.tile(tile.url)) - } - } + @ScaledMetric var iconWidth = 20.0 + + @State private var sitemapForWatch: String? + + var mainSection: some View { + Section(header: Text("Main")) { + menuEntry(image: Image("openHABIcon").resizable(), goTo: .webview) { + Text("Home").accessibilityIdentifier("Home") } } } - // Handle double-tap gesture for selecting or deselecting the sitemap for the watch - struct SitemapsSectionView: View { - var sitemaps: [OpenHABSitemap] - var sitemapIconwidth: CGFloat - @Binding var sitemapForWatch: String? - var onDismiss: (TargetController) -> Void - var dismiss: DismissAction - - var body: some View { - Section(header: Text("Sitemaps")) { - ForEach(sitemaps, id: \.name) { sitemap in - SitemapRowView( - sitemap: sitemap, - sitemapIconwidth: sitemapIconwidth, - isWatchSitemap: sitemap.name == sitemapForWatch, - onDismiss: onDismiss, - dismiss: dismiss - ) - .onTapGesture(count: 2) { - Preferences.modifyActiveHome { homePreferences in - if sitemap.name == sitemapForWatch { - sitemapForWatch = nil - homePreferences.sitemapForWatch = "" - homePreferences.sitemapForWatchLabel = "" - } else { - sitemapForWatch = sitemap.name - homePreferences.sitemapForWatch = sitemap.name - homePreferences.sitemapForWatchLabel = sitemap.label - } - } - } + var tilesSection: some View { + Section(header: Text("Tiles")) { + ForEach(uiTiles, id: \.url) { tile in + menuEntry( + image: ImageView(url: tile.imageUrl), + goTo: .tile(tile.url) + ) { + Text(tile.name) } } } } - struct SitemapRowView: View { - @EnvironmentObject var networkTracker: NetworkTracker - var sitemap: OpenHABSitemap - var sitemapIconwidth: CGFloat - var isWatchSitemap: Bool - var onDismiss: (TargetController) -> Void - var dismiss: DismissAction - - var body: some View { - HStack { - if sitemap.icon.isEmpty { - Image("openHABIcon") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: sitemapIconwidth) - } else { - let url = Endpoint.iconForDrawer( - rootUrl: networkTracker.activeConnection?.configuration.url ?? "", - icon: sitemap.icon - ).url - KFImage(url).placeholder { Image("openHABIcon").resizable() } - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: sitemapIconwidth) - } - Text(sitemap.label) - if isWatchSitemap { - Spacer() - Image(systemSymbol: .applewatchWatchface) + var sitemapsSection: some View { + Section(header: Text("Sitemaps")) { + ForEach(sitemaps, id: \.name) { sitemap in + menuEntry( + image: sitemapIcon(for: sitemap), + goTo: .sitemap(sitemap.name) + ) { + HStack { + Text(sitemap.label) + if sitemap.name == sitemapForWatch { + Spacer() + Image(systemSymbol: .applewatchWatchface) + } + } } - } - .onTapGesture { - dismiss() - onDismiss(.sitemap(sitemap.name)) + .onTapGesture(count: 2) { toggleWatchSitemap(sitemap) } } } } - struct SystemSectionView: View { - var menuEntry: (Image, Text, TargetController) -> MenuEntry - - var body: some View { - Section(header: Text("System")) { - settingsMenuEntry(image: .gear, text: "settings", goTo: .settings) - - if Preferences.getNotificationConnection() != nil, !Preferences.currentHomePreferences.demomode { - settingsMenuEntry(image: .bell, text: "notifications", goTo: .notifications) - } - - settingsMenuEntry(image: .house, text: "Manage Homes", goTo: .homeSelection) + var systemSection: some View { + Section(header: Text("System")) { + systemMenuEntry(image: .gear, text: "settings", goTo: .settings) + if Preferences.getNotificationConnection() != nil, + !Preferences.currentHomePreferences.demomode { + systemMenuEntry(image: .bell, text: "notifications", goTo: .notifications) } - } - - private func settingsMenuEntry(image: SFSymbol, text: String, goTo target: TargetController) -> MenuEntry { - menuEntry( - Image(systemSymbol: image), - Text(LocalizedStringKey(text)), - target - ) + systemMenuEntry(image: .house, text: "Manage Homes", goTo: .homeSelection) } } - @State private var sitemaps: [OpenHABSitemap] = [] - @State private var uiTiles: [OpenHABUiTile] = [] - @State private var selectedSection: Int? - @State private var connectedUrl = "Not connected" // Default label text - - @EnvironmentObject private var networkTracker: NetworkTracker - - var onDismiss: (TargetController) -> Void - @Environment(\.dismiss) private var dismiss - - @ScaledMetric var openHABIconwidth = 20.0 - @ScaledMetric var tilesIconwidth = 20.0 - @ScaledMetric var sitemapIconwidth = 20.0 - - @State private var sitemapForWatch: String? - var body: some View { VStack { List { - MainSectionView(menuEntry: menuEntry) - - TilesSectionView(uiTiles: uiTiles, tilesIconwidth: tilesIconwidth, onDismiss: onDismiss, dismiss: dismiss) - - SitemapsSectionView(sitemaps: sitemaps, sitemapIconwidth: sitemapIconwidth, sitemapForWatch: $sitemapForWatch, onDismiss: onDismiss, dismiss: dismiss) - - SystemSectionView(menuEntry: menuEntry) + mainSection + tilesSection + sitemapsSection + systemSection } .listStyle(.inset) @@ -268,7 +179,7 @@ struct DrawerView: View { image .resizable() .aspectRatio(contentMode: .fit) - .frame(width: openHABIconwidth) + .frame(width: iconWidth, height: iconWidth) text } .onTapGesture { @@ -277,6 +188,60 @@ struct DrawerView: View { } } + private func menuEntry(image: some View, + goTo target: TargetController, + @ViewBuilder label: () -> some View) -> some View { + HStack { + image + .aspectRatio(contentMode: .fit) + .frame(width: iconWidth, height: iconWidth) + label() + } + .contentShape(Rectangle()) // entire row tappable + .onTapGesture { + dismiss() + onDismiss(target) + } + } + + func systemMenuEntry(image: SFSymbol, text: String, goTo target: TargetController) -> some View { + menuEntry(image: Image(systemSymbol: image), goTo: target) { + Text(LocalizedStringKey(text)) + .accessibilityLabel(text) + } + } + + func sitemapIcon(for sitemap: OpenHABSitemap) -> some View { + Group { + if sitemap.icon.isEmpty { + Image("openHABIcon").resizable() + } else { + let url = Endpoint.iconForDrawer( + rootUrl: networkTracker.activeConnection?.configuration.url ?? "", + icon: sitemap.icon + ).url + KFImage(url) + .placeholder { Image("openHABIcon").resizable() } + .resizable() + } + } + .aspectRatio(contentMode: .fit) + } + + func toggleWatchSitemap(_ sitemap: OpenHABSitemap) { + Preferences.modifyActiveHome { prefs in + if sitemap.name == sitemapForWatch { + sitemapForWatch = nil + prefs.sitemapForWatch = "" + prefs.sitemapForWatchLabel = "" + } else { + sitemapForWatch = sitemap.name + prefs.sitemapForWatch = sitemap.name + prefs.sitemapForWatchLabel = sitemap.label + } + } + } + private func updateSitemapsAndUITiles(activeConnection: ConnectionInfo?) async { guard let activeConnection else { return } diff --git a/openHAB/SettingsView/ConnectionSettingsView.swift b/openHAB/SettingsView/ConnectionSettingsView.swift index f6096e5e3..01b7d7a4e 100644 --- a/openHAB/SettingsView/ConnectionSettingsView.swift +++ b/openHAB/SettingsView/ConnectionSettingsView.swift @@ -20,6 +20,7 @@ struct ConnectionSettingsView: View { var body: some View { Toggle("Demo Mode", isOn: $settingsDemomode) + .accessibilityIdentifier("Demo Mode") if !settingsDemomode { SingleConnectionSettingsView(headerText: "Local server", connectionConfig: $localConnectionConfiguration, showNotificationToggle: false) diff --git a/openHABUITests/OpenHABUITests.swift b/openHABUITests/OpenHABUITests.swift index abcfc786b..6415f852e 100644 --- a/openHABUITests/OpenHABUITests.swift +++ b/openHABUITests/OpenHABUITests.swift @@ -13,16 +13,19 @@ import os.log import XCTest class OpenHABUITests: XCTestCase { - @MainActor - override func setUp() { - let app = XCUIApplication() - app.launchEnvironment = ["UITest": "1"] - setupSnapshot(app) + let runWebViewAndSitemap = true // To accelerate testing of settings set to false + override func setUp() async throws { + try await super.setUp() + + let app = await XCUIApplication() + await MainActor.run { + app.launchEnvironment = ["UITest": "1"] + } continueAfterFailure = false - app.launch() + await setupSnapshot(app) + await app.launch() } - @MainActor override func tearDown() { // Put teardown code here. This method is called after the invocation of each test method in the class. } @@ -30,77 +33,75 @@ class OpenHABUITests: XCTestCase { @MainActor func testShots() { let app = XCUIApplication() + app.activate() + let hamburgerButton = app.navigationBars.buttons["HamburgerButton"] hamburgerButton.tap() sleep(3) - let tablesQuery = app.tables - tablesQuery.staticTexts["Home"].tap() - sleep(10) - snapshot("0_MainUI") + if runWebViewAndSitemap { + app.staticTexts["Home"].tap() + sleep(10) + snapshot("0_MainUI") - // Locations Tab - let webViewsQuery = app.webViews.webViews.webViews - webViewsQuery.links["placemark_fill Locations"].tap() - sleep(2) - snapshot("1_Locations") + // Locations Tab + let webViewsQuery = app.webViews.webViews.webViews + webViewsQuery.links["placemark_fill Locations"].tap() + sleep(2) + snapshot("1_Locations") - webViewsQuery.staticTexts["Living Room"].tap() - sleep(2) - snapshot("2_LivingRoom") - // Close button on Living Room view - webViewsQuery.otherElements["openHAB"].children(matching: .link).matching(identifier: "multiply_circle_fill").element(boundBy: 0).staticTexts["multiply_circle_fill"].tap() - sleep(2) + webViewsQuery.staticTexts["Living Room"].tap() + sleep(2) + snapshot("2_LivingRoom") + // Close button on Living Room view + webViewsQuery.otherElements["openHAB"].children(matching: .link).matching(identifier: "multiply_circle_fill").element(boundBy: 0).staticTexts["multiply_circle_fill"].tap() + sleep(2) + + var menuStaticText: XCUIElement? + // if we have a left side menu, then use it (large screens like 12.9 inch iPad will not) + if !webViewsQuery.staticTexts["Floorplans"].exists { + // Left side menu in webUI + menuStaticText = webViewsQuery.staticTexts["menu"] + sleep(2) + menuStaticText?.tap() + sleep(1) + } + + app.staticTexts["Floorplans"].tap() + sleep(10) + snapshot("3_Floorplans") - var menuStaticText: XCUIElement? - // if we have a left side menu, then use it (large screens like 12.9 inch iPad will not) - if !webViewsQuery.staticTexts["Floorplans"].exists { - // Left side menu in webUI - menuStaticText = webViewsQuery.staticTexts["menu"] menuStaticText?.tap() sleep(1) - } - webViewsQuery.staticTexts["Floorplans"].tap() - sleep(2) - snapshot("3_Floorplans") + // openHAB logo in left menu + webViewsQuery.links.allElementsBoundByIndex[1].tap() + sleep(2) - menuStaticText?.tap() - sleep(1) - // openHAB logo in left menu - webViewsQuery.links.allElementsBoundByIndex[1].tap() - sleep(2) + app.webViews.staticTexts["square_arrow_right"].tap() - // right menu in webUI - webViewsQuery.staticTexts["square_arrow_right"].tap() - tablesQuery.staticTexts["Main Menu"].tap() - sleep(5) - snapshot("4_MainSitemap") + app.staticTexts["Main Menu"].tap() + app.cells.containing(.staticText, identifier: "Widget Overview").firstMatch.tap() + sleep(10) + snapshot("4_MainSitemap") - let widgetTable = app.tables["OpenHABSitemapViewControllerWidgetTableView"] - - widgetTable.staticTexts["Widget Overview"].tap() - sleep(3) - widgetTable.staticTexts["BINARY WIDGETS"].swipeDown() - sleep(6) - snapshot("5_WidgetOverview") + app.staticTexts["BINARY WIDGETS"].swipeUp() + sleep(6) + snapshot("5_WidgetOverview") - app.navigationBars.buttons.element(boundBy: 0).tap() - sleep(2) - widgetTable.staticTexts["Ground Floor"].tap() - sleep(5) - widgetTable.staticTexts["Kitchen"].tap() - sleep(5) - snapshot("6_Kitchen") + app.navigationBars.buttons.element(boundBy: 0).tap() + sleep(2) - app.navigationBars.buttons.element(boundBy: 0).tap() - sleep(2) - app.navigationBars.buttons.element(boundBy: 0).tap() + hamburgerButton.tap() + sleep(3) + } + app.staticTexts["settings"].tap() sleep(2) + snapshot("7_Settings_Demo") - hamburgerButton.tap() - sleep(2) - tablesQuery.staticTexts["Settings"].tap() +// let switch1 = app.switches["Demo Mode"] + let switch1 = app.switches["1"] + switch1.tap() sleep(2) - snapshot("7_Settings") + snapshot("8_Settings_Server") } } From f491470ffdff7cfd7883b5ee36d8c3c283a42a6c Mon Sep 17 00:00:00 2001 From: Dan Cunningham Date: Mon, 4 Aug 2025 12:50:06 -0700 Subject: [PATCH 320/476] readme update Signed-off-by: Dan Cunningham --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4b0cf6532..21d7db38c 100644 --- a/README.md +++ b/README.md @@ -257,7 +257,7 @@ Also see [Action Syntax](#action-syntax) for more information on actions that ca ## Shortcuts -The app supports Shortcuts (via Apple's App Intents) which let you control your openHAB installation from the Shortcuts app, Siri voice commands, automations, widgets, or Apple Watch complications. +The app supports exposes several actions that let you control your openHAB installation from the Shortcuts app. **Supported actions** From a26d0ac8c736b35ea54b2b9a763ae2ad54f58f9d Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Tue, 5 Aug 2025 08:51:16 +0200 Subject: [PATCH 321/476] Minor change Signed-off-by: Tim Mueller-Seydlitz --- openHAB/SwiftUI/SitemapPageView.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/openHAB/SwiftUI/SitemapPageView.swift b/openHAB/SwiftUI/SitemapPageView.swift index 61015c1e8..dd54fa96e 100644 --- a/openHAB/SwiftUI/SitemapPageView.swift +++ b/openHAB/SwiftUI/SitemapPageView.swift @@ -42,7 +42,6 @@ struct SitemapPageView: View { } .buttonStyle(.plain) .padding(.vertical, -6) -// .listRowInsets(EdgeInsets(top: 0, leading: 4, bottom: 0, trailing: 24)) } else if widget.type == .selection { Button { selectedWidget = widget From 8a957236f56dc2a3bd1f86aa639ba439afe2c0a5 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Tue, 5 Aug 2025 09:39:54 +0200 Subject: [PATCH 322/476] Improve entry of URL for server Signed-off-by: Tim Mueller-Seydlitz --- .../SettingsView/ConnectionSettingsView.swift | 6 ++++-- .../SingleConnectionSettingsView.swift | 16 +++++++--------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/openHAB/SettingsView/ConnectionSettingsView.swift b/openHAB/SettingsView/ConnectionSettingsView.swift index 01b7d7a4e..65a5b1a69 100644 --- a/openHAB/SettingsView/ConnectionSettingsView.swift +++ b/openHAB/SettingsView/ConnectionSettingsView.swift @@ -23,8 +23,10 @@ struct ConnectionSettingsView: View { .accessibilityIdentifier("Demo Mode") if !settingsDemomode { - SingleConnectionSettingsView(headerText: "Local server", connectionConfig: $localConnectionConfiguration, showNotificationToggle: false) - SingleConnectionSettingsView(headerText: "Remote server", connectionConfig: $remoteConnectionConfiguration, showNotificationToggle: true) + Group { + SingleConnectionSettingsView(headerText: "Local server", connectionConfig: $localConnectionConfiguration, showNotificationToggle: false) + SingleConnectionSettingsView(headerText: "Remote server", connectionConfig: $remoteConnectionConfiguration, showNotificationToggle: true) + } } } } diff --git a/openHAB/SettingsView/SingleConnectionSettingsView.swift b/openHAB/SettingsView/SingleConnectionSettingsView.swift index e800089bc..1cca8d2e5 100644 --- a/openHAB/SettingsView/SingleConnectionSettingsView.swift +++ b/openHAB/SettingsView/SingleConnectionSettingsView.swift @@ -45,15 +45,13 @@ struct SingleConnectionSettingsView: View { VStack(alignment: .leading) { LabeledContent { Spacer() - TextField( - "URL", - text: $connectionConfig.url - ) - .fixedSize() - .keyboardType(.URL) - .textInputAutocapitalization(.never) - .disableAutocorrection(true) - .font(.system(.caption)) + TextField("URL", text: $connectionConfig.url) + .textContentType(.URL) // Helps iOS identify it as a URL field + .keyboardType(.URL) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + .fixedSize() + .font(.system(.caption)) } label: { HStack { Text("URL") From c84fe66d1b164550f7a171cb42ff8930fb05c654 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Tue, 5 Aug 2025 16:15:33 +0200 Subject: [PATCH 323/476] Addressing issues brought up by gemini review Signed-off-by: Tim Mueller-Seydlitz --- .gitignore | 1 - BuildTools/.swiftlint.yml | 3 +++ OpenHABCore/Package.swift | 2 +- OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift | 4 ++-- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index 67161d5aa..d1f30c796 100644 --- a/.gitignore +++ b/.gitignore @@ -18,7 +18,6 @@ build/ BuildTools/.build OpenHABCore/Package.resolved -#OpenHABCore/Sources/OpenHABCore/GeneratedSources swift-openapi-generator/ OpenHABCore/swift-openapi-generator/ vendor/ diff --git a/BuildTools/.swiftlint.yml b/BuildTools/.swiftlint.yml index 9f7fccd77..f690f9e02 100644 --- a/BuildTools/.swiftlint.yml +++ b/BuildTools/.swiftlint.yml @@ -19,6 +19,9 @@ opt_in_rules: - empty_enum_arguments - prefer_zero_over_explicit_init +analyzer_rules: + - unused_import + disabled_rules: # rule identifiers to exclude from running - force_cast - todo diff --git a/OpenHABCore/Package.swift b/OpenHABCore/Package.swift index 2711948b0..3201c0889 100644 --- a/OpenHABCore/Package.swift +++ b/OpenHABCore/Package.swift @@ -47,7 +47,7 @@ let package = Package( .enableUpcomingFeature("ImplicitOpenExistentials"), .enableUpcomingFeature("ImportObjcForwardDeclarations"), .enableUpcomingFeature("InferSendableFromCaptures"), -// .enableUpcomingFeature("InternalImportsByDefault"), + .enableUpcomingFeature("InternalImportsByDefault"), .enableUpcomingFeature("IsolatedDefaultValues"), .enableUpcomingFeature("MemberImportVisibility"), .enableUpcomingFeature("NonfrozenEnumExhaustivity"), diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift index 2dfd8bf76..18c286612 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift @@ -446,7 +446,7 @@ extension OpenHABWidget { maxValue: widget.maxValue, step: widget.step, refresh: widget.refresh.map(Int.init), - height: 50, // TODO: + height: widget.height.map(Double.init), isLeaf: true, iconColor: widget.iconcolor, labelColor: widget.labelcolor, @@ -455,7 +455,7 @@ extension OpenHABWidget { state: widget.state, text: "", legend: widget.legend, - inputHint: InputHint(rawValue: widget.inputHint ?? ""), + inputHint: InputHint(rawValue: widget.inputHint ?? "unknown") ?? .unknown, encoding: widget.encoding, item: OpenHABItem(widget.item), linkedPage: OpenHABPage(widget.linkedPage), From 9cc6814cc8b0addfb21c7b19b9f0160001244e6f Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Tue, 5 Aug 2025 16:42:54 +0200 Subject: [PATCH 324/476] Revert change InternalImportsByDefault Handle height in Map and Webview Signed-off-by: Tim Mueller-Seydlitz --- OpenHABCore/Package.swift | 2 +- .../OpenHABCore/Model/OpenHABWidget.swift | 19 +++++++++++++++++-- openHAB/SwiftUI/Rows/MapRowView.swift | 4 ++-- openHAB/SwiftUI/Rows/WebRowView.swift | 2 +- 4 files changed, 21 insertions(+), 6 deletions(-) diff --git a/OpenHABCore/Package.swift b/OpenHABCore/Package.swift index 3201c0889..2711948b0 100644 --- a/OpenHABCore/Package.swift +++ b/OpenHABCore/Package.swift @@ -47,7 +47,7 @@ let package = Package( .enableUpcomingFeature("ImplicitOpenExistentials"), .enableUpcomingFeature("ImportObjcForwardDeclarations"), .enableUpcomingFeature("InferSendableFromCaptures"), - .enableUpcomingFeature("InternalImportsByDefault"), +// .enableUpcomingFeature("InternalImportsByDefault"), .enableUpcomingFeature("IsolatedDefaultValues"), .enableUpcomingFeature("MemberImportVisibility"), .enableUpcomingFeature("NonfrozenEnumExhaustivity"), diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift index 503bedf31..709f5e099 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift @@ -9,9 +9,9 @@ // // SPDX-License-Identifier: EPL-2.0 -import Combine +@_exported import Combine import Foundation -import MapKit +@_exported import MapKit import os.log public enum WidgetTypeEnum { @@ -541,6 +541,21 @@ public extension [OpenHABWidget] { } } +public extension OpenHABWidget { + var preferredRowHeight: CGFloat? { + switch type { + case .frame: + label.isEmpty ? 0 : 35.0 + case .image, .chart, .video: + nil // Automatic sizing + case .webview, .mapview: + 44.0 * CGFloat(height ?? 8) + default: + 44.0 + } + } +} + extension OpenHABWidget { convenience init(_ widget: Components.Schemas.WidgetDTO) { self.init( diff --git a/openHAB/SwiftUI/Rows/MapRowView.swift b/openHAB/SwiftUI/Rows/MapRowView.swift index 22db2a1c2..1ac1bf4e0 100644 --- a/openHAB/SwiftUI/Rows/MapRowView.swift +++ b/openHAB/SwiftUI/Rows/MapRowView.swift @@ -37,7 +37,7 @@ struct MapRowViewLegacy: View { Map(coordinateRegion: .constant(region), annotationItems: CLLocationCoordinate2DIsValid(widget.coordinate) ? [widget.coordinate] : []) { location in MapMarker(coordinate: location, tint: .red) } - .frame(height: 200) + .frame(height: widget.preferredRowHeight) .cornerRadius(8) } } @@ -61,7 +61,7 @@ private struct MapRowViewNew: View { Map(position: $cameraPosition) { Marker("", coordinate: widget.coordinate) } - .frame(height: 200) + .frame(height: widget.preferredRowHeight) .onAppear { cameraPosition = .region( MKCoordinateRegion( diff --git a/openHAB/SwiftUI/Rows/WebRowView.swift b/openHAB/SwiftUI/Rows/WebRowView.swift index 9369ea948..df34696df 100644 --- a/openHAB/SwiftUI/Rows/WebRowView.swift +++ b/openHAB/SwiftUI/Rows/WebRowView.swift @@ -25,7 +25,7 @@ struct WidgetWebViewContainer: View { } WebRowView(widget: widget) - .frame(height: 300) + .frame(height: widget.preferredRowHeight) .cornerRadius(8) if let labelValue = widget.labelValue, !labelValue.isEmpty { From bdc329488fd7d78b3881133b4efc4aebaccc1ddb Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Tue, 5 Aug 2025 22:36:17 +0200 Subject: [PATCH 325/476] Created SitemapNavigationView as navigation container SitemapPageView focuses on content rendering Simplified HostingSitemapViewController Signed-off-by: Tim Mueller-Seydlitz --- BuildTools/.swiftlint.yml | 4 +- openHAB.xcodeproj/project.pbxproj | 4 + openHAB/OpenHABRootViewController.swift | 64 ++++--------- openHAB/SwiftUI/SitemapNavigationView.swift | 58 +++++++++++ openHAB/SwiftUI/SitemapPageView.swift | 101 ++++++++++---------- 5 files changed, 131 insertions(+), 100 deletions(-) create mode 100644 openHAB/SwiftUI/SitemapNavigationView.swift diff --git a/BuildTools/.swiftlint.yml b/BuildTools/.swiftlint.yml index d2afd071d..5056dde1c 100644 --- a/BuildTools/.swiftlint.yml +++ b/BuildTools/.swiftlint.yml @@ -19,9 +19,6 @@ opt_in_rules: - empty_enum_arguments - prefer_zero_over_explicit_init -analyzer_rules: - - unused_import - disabled_rules: # rule identifiers to exclude from running - force_cast - todo @@ -40,6 +37,7 @@ excluded: - ../openHAB/Intents/Generated - ../openHABWatch/Intents/Generated - ../OsLogRewriter/.build + - ../build nesting: type_level: 2 diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index 9ec78983b..82facc441 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -123,6 +123,7 @@ DA6454DE2E204B95006005E8 /* PreviewConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA7224D123828D3300712D20 /* PreviewConstants.swift */; }; DA64ACA62DBEAD5600294F60 /* SitemapPageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA64ACA52DBEAD5600294F60 /* SitemapPageViewModel.swift */; }; DA64ACA82DBEAD8300294F60 /* SitemapPageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA64ACA72DBEAD8300294F60 /* SitemapPageView.swift */; }; + DA64ACAA2DBEAD9000294F60 /* SitemapNavigationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA64ACA92DBEAD9000294F60 /* SitemapNavigationView.swift */; }; DA65871F236F83CE007E2E7F /* UserDefaultsExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA65871E236F83CD007E2E7F /* UserDefaultsExtension.swift */; }; DA6B2EEF2C861BC900DF77CF /* DrawerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA6B2EEE2C861BC900DF77CF /* DrawerView.swift */; }; DA6B2EF12C87B59000DF77CF /* NotificationsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA6B2EF02C87B59000DF77CF /* NotificationsView.swift */; }; @@ -470,6 +471,7 @@ DA5ED9BD2C850955004875E0 /* ClientCertificatesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientCertificatesViewModel.swift; sourceTree = ""; }; DA64ACA52DBEAD5600294F60 /* SitemapPageViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SitemapPageViewModel.swift; sourceTree = ""; }; DA64ACA72DBEAD8300294F60 /* SitemapPageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SitemapPageView.swift; sourceTree = ""; }; + DA64ACA92DBEAD9000294F60 /* SitemapNavigationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SitemapNavigationView.swift; sourceTree = ""; }; DA65871E236F83CD007E2E7F /* UserDefaultsExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaultsExtension.swift; sourceTree = ""; }; DA6B2EEE2C861BC900DF77CF /* DrawerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DrawerView.swift; sourceTree = ""; }; DA6B2EF02C87B59000DF77CF /* NotificationsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsView.swift; sourceTree = ""; }; @@ -873,6 +875,7 @@ DA35E2CC2E1F96CA003987BB /* IconView.swift */, DAE280092E35F5590028EE24 /* IconURLView.swift */, DA64ACA72DBEAD8300294F60 /* SitemapPageView.swift */, + DA64ACA92DBEAD9000294F60 /* SitemapNavigationView.swift */, DAEA21D72DBF472D00D54342 /* RowViewFactory.swift */, DAC949FF2E21A473007E67B7 /* Rows */, ); @@ -1739,6 +1742,7 @@ 2F55E7BD2DEE44A800EC8350 /* ClientCertificatesView.swift in Sources */, DA6B2EF72C8B92E800DF77CF /* SelectionView.swift in Sources */, DA64ACA82DBEAD8300294F60 /* SitemapPageView.swift in Sources */, + DA64ACAA2DBEAD9000294F60 /* SitemapNavigationView.swift in Sources */, DA4800212D839D3A009CF127 /* AnimatedSecureTextField.swift in Sources */, DAC131112DA3213100075AE2 /* SetSwitchStateIntentHandlerTests.swift in Sources */, DA35E2BD2E1EEA9D003987BB /* MapRowView.swift in Sources */, diff --git a/openHAB/OpenHABRootViewController.swift b/openHAB/OpenHABRootViewController.swift index 9e0f774d8..ae940975c 100644 --- a/openHAB/OpenHABRootViewController.swift +++ b/openHAB/OpenHABRootViewController.swift @@ -37,14 +37,23 @@ protocol ModalHandler: AnyObject { private let logger = Logger(subsystem: "org.openhab.UI", category: "OpenHABRootViewController") -class HostingSitemapViewController: UIHostingController, OpenHABViewable { +class HostingSitemapViewController: UIHostingController, OpenHABViewable { private let viewModel: SitemapPageViewModel - private let searchController = UISearchController(searchResultsController: nil) + + private weak var rootViewController: OpenHABRootViewController? init() { let viewModel = SitemapPageViewModel() self.viewModel = viewModel - super.init(rootView: SitemapPageView(viewModel: viewModel)) + super.init(rootView: SitemapNavigationView(viewModel: viewModel, onShowSideMenu: {})) + } + + func setRootViewController(_ rootViewController: OpenHABRootViewController) { + self.rootViewController = rootViewController + // Update the closure after initialization + rootView = SitemapNavigationView(viewModel: viewModel) { [weak self] in + self?.rootViewController?.showSideMenu() + } } @available(*, unavailable) @@ -54,37 +63,8 @@ class HostingSitemapViewController: UIHostingController, OpenHA override func viewDidLoad() { super.viewDidLoad() - // Keep UIKit navigation bar visible for hamburger menu - navigationController?.setNavigationBarHidden(false, animated: false) - setupSearchController() - } - - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - - // Ensure hamburger menu is preserved when search controller is set up - if parent?.navigationItem.searchController !== searchController { - let existingRightBarButtonItem = parent?.navigationItem.rightBarButtonItem - parent?.navigationItem.searchController = searchController - parent?.navigationItem.hidesSearchBarWhenScrolling = true - if let rightButton = existingRightBarButtonItem { - parent?.navigationItem.rightBarButtonItem = rightButton - } - } - } - - private func setupSearchController() { - searchController.searchResultsUpdater = self - searchController.obscuresBackgroundDuringPresentation = false - searchController.searchBar.autocapitalizationType = .none - searchController.searchBar.delegate = self - searchController.delegate = self - searchController.searchBar.placeholder = NSLocalizedString("search_items", comment: "") - definesPresentationContext = true - - // Assign to navigation item - navigationItem.searchController = searchController - navigationItem.hidesSearchBarWhenScrolling = false + // Hide UIKit navigation bar since SwiftUI now handles navigation + navigationController?.setNavigationBarHidden(true, animated: false) } func getSitemapTitle() -> String { @@ -123,7 +103,11 @@ class OpenHABRootViewController: UIViewController { return viewController }() - lazy var sitemapViewController: any (UIViewController & OpenHABViewable) = HostingSitemapViewController() + lazy var sitemapViewController: any (UIViewController & OpenHABViewable) = { + let controller = HostingSitemapViewController() + controller.setRootViewController(self) + return controller + }() private var activeConnection: ConnectionInfo? private let synthesizer = AVSpeechSynthesizer() @@ -915,13 +899,3 @@ extension OpenHABRootViewController: ModalHandler { } } } - -extension HostingSitemapViewController: UISearchResultsUpdating, UISearchBarDelegate, UISearchControllerDelegate { - func updateSearchResults(for searchController: UISearchController) { - viewModel.searchText = searchController.searchBar.text ?? "" - } - - func searchBarCancelButtonClicked(_ searchBar: UISearchBar) { - viewModel.searchText = "" - } -} diff --git a/openHAB/SwiftUI/SitemapNavigationView.swift b/openHAB/SwiftUI/SitemapNavigationView.swift new file mode 100644 index 000000000..110179e81 --- /dev/null +++ b/openHAB/SwiftUI/SitemapNavigationView.swift @@ -0,0 +1,58 @@ +// Copyright (c) 2010-2025 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 OpenHABCore +import SFSafeSymbols +import SwiftUI + +struct SitemapNavigationView: View { + @StateObject public var viewModel = SitemapPageViewModel() + let onShowSideMenu: () -> Void + + var body: some View { + NavigationStack { + SitemapPageView(viewModel: viewModel) + .navigationTitle(viewModel.pageTitle) + .navigationBarTitleDisplayMode(.inline) + .searchable(text: $viewModel.searchText, prompt: Text(NSLocalizedString("search_items", comment: ""))) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button { + onShowSideMenu() + } label: { + Image(systemSymbol: .line3Horizontal) + .font(.title) + } + } + } + } + } + + init(viewModel: SitemapPageViewModel, onShowSideMenu: @escaping () -> Void) { + _viewModel = StateObject(wrappedValue: viewModel) + self.onShowSideMenu = onShowSideMenu + } + + init(onShowSideMenu: @escaping () -> Void = {}) { + _viewModel = StateObject(wrappedValue: SitemapPageViewModel()) + self.onShowSideMenu = onShowSideMenu + } +} + +#Preview { + let previewViewModel = SitemapPageViewModel( + pageUrl: PreviewConstants.openHABSitemapPage?.link ?? "", + title: PreviewConstants.openHABSitemapPage?.title ?? "Preview Page" + ) + SitemapNavigationView(viewModel: previewViewModel) { + print("Show side menu tapped") + } +} diff --git a/openHAB/SwiftUI/SitemapPageView.swift b/openHAB/SwiftUI/SitemapPageView.swift index dd54fa96e..8f343634a 100644 --- a/openHAB/SwiftUI/SitemapPageView.swift +++ b/openHAB/SwiftUI/SitemapPageView.swift @@ -24,64 +24,61 @@ struct SitemapPageView: View { } var body: some View { - NavigationStack { - List { - if viewModel.isLoading, viewModel.relevantWidgets.isEmpty { - // Show skeleton/placeholder rows while loading - ForEach(placeholderWidgets, id: \.id) { widget in - RowViewFactory.view(for: widget) - .redacted(reason: .placeholder) - .disabled(true) - } - } else { - ForEach(viewModel.relevantWidgets, id: \.id) { widget in - Group { - if let linkedPage = widget.linkedPage { - NavigationLink(destination: SitemapPageView(viewModel: SitemapPageViewModel(pageUrl: linkedPage.link, title: linkedPage.title))) { - RowViewFactory.view(for: widget) - } - .buttonStyle(.plain) - .padding(.vertical, -6) - } else if widget.type == .selection { - Button { - selectedWidget = widget - showSelectionSheet = true - } label: { - RowViewFactory.view(for: widget) - } - .buttonStyle(.plain) - } else if widget.type == .input { - Button { - selectedWidget = widget - showInputAlert = true - } label: { - RowViewFactory.view(for: widget) - } - .buttonStyle(.plain) - } else { + List { + if viewModel.isLoading, viewModel.relevantWidgets.isEmpty { + // Show skeleton/placeholder rows while loading + ForEach(placeholderWidgets, id: \.id) { widget in + RowViewFactory.view(for: widget) + .redacted(reason: .placeholder) + .disabled(true) + } + } else { + ForEach(viewModel.relevantWidgets, id: \.id) { widget in + Group { + if let linkedPage = widget.linkedPage { + NavigationLink(destination: SitemapPageView(viewModel: SitemapPageViewModel(pageUrl: linkedPage.link, title: linkedPage.title))) { + RowViewFactory.view(for: widget) + } + .buttonStyle(.plain) + .padding(.vertical, -6) + } else if widget.type == .selection { + Button { + selectedWidget = widget + showSelectionSheet = true + } label: { + RowViewFactory.view(for: widget) + } + .buttonStyle(.plain) + } else if widget.type == .input { + Button { + selectedWidget = widget + showInputAlert = true + } label: { RowViewFactory.view(for: widget) - .onTapGesture { - viewModel.widgetTapped(widget) - } } + .buttonStyle(.plain) + } else { + RowViewFactory.view(for: widget) + .onTapGesture { + viewModel.widgetTapped(widget) + } } } } } - .environmentObject(viewModel) - .listStyle(.plain) - .navigationBarHidden(!isLinkedPage) - .navigationTitle(isLinkedPage ? viewModel.pageTitle : "") - .navigationBarTitleDisplayMode(.inline) - .refreshable { - await viewModel.reload() - } - .task { - viewModel.startPageHandling() - } - .onChange(of: viewModel.networkTracker.activeConnection) { activeConnection in - viewModel.handleActiveConnectionChange(activeConnection) - } + } + .environmentObject(viewModel) + .listStyle(.plain) + .navigationTitle(viewModel.pageTitle) + .navigationBarTitleDisplayMode(.inline) + .refreshable { + await viewModel.reload() + } + .task { + viewModel.startPageHandling() + } + .onChange(of: viewModel.networkTracker.activeConnection) { activeConnection in + viewModel.handleActiveConnectionChange(activeConnection) } .sheet(isPresented: $showSelectionSheet) { if let widget = selectedWidget { From 62e056be48062a0c2160aaf8ab8e92d44c1940ec Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Wed, 6 Aug 2025 18:08:06 +0200 Subject: [PATCH 326/476] Merge develop Signed-off-by: Tim Mueller-Seydlitz --- .github/workflows/publish.yml | 2 +- BuildTools/.swiftformat | 1 + BuildTools/.swiftlint.yml | 3 + .../Sources/CommonUI/PreviewConstants.swift | 581 ++++++++++++++++++ .../OpenHABCore/Util/ItemEventStream.swift | 1 - .../OpenHABCore/Util/Preferences.swift | 1 - .../NetworkTrackerTests.swift | 82 +-- openHAB.xcodeproj/project.pbxproj | 14 +- openHAB/NoIconDisplayableCell.swift | 2 + openHAB/OpenHABRootViewController.swift | 4 +- openHAB/SwiftUI/Rows/FrameRowView.swift | 1 + openHAB/SwiftUI/Rows/GenericRowView.swift | 1 + openHAB/SwiftUI/SitemapNavigationView.swift | 1 + openHAB/SwiftUI/SitemapPageView.swift | 1 + openHABWatch/Domain/UserData.swift | 3 +- openHABWatch/Views/Rows/ImageRow.swift | 1 + openHABWatch/Views/Rows/SwitchRow.swift | 1 + 17 files changed, 645 insertions(+), 55 deletions(-) create mode 100644 CommonUI/Sources/CommonUI/PreviewConstants.swift diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 326f55fb2..7e940b15c 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -38,7 +38,7 @@ jobs: - name: Setup Xcode uses: maxim-lobanov/setup-xcode@v1 with: - xcode-version: '16.3.0' + xcode-version: '16.4.0' - name: Checkout code uses: actions/checkout@v4 diff --git a/BuildTools/.swiftformat b/BuildTools/.swiftformat index 9680ec683..67bedb415 100644 --- a/BuildTools/.swiftformat +++ b/BuildTools/.swiftformat @@ -4,6 +4,7 @@ --exclude ../OpenHABCore/swift-openapi-generator --exclude ../OpenHABCore/Sources/OpenHABCore/GeneratedSources --exclude ../OsLogRewriter/.build +--exclude ../build --symlinks ignore # disabled rules diff --git a/BuildTools/.swiftlint.yml b/BuildTools/.swiftlint.yml index 5056dde1c..2d7c038f0 100644 --- a/BuildTools/.swiftlint.yml +++ b/BuildTools/.swiftlint.yml @@ -19,6 +19,9 @@ opt_in_rules: - empty_enum_arguments - prefer_zero_over_explicit_init +analyzer_rules: + - unused_import + disabled_rules: # rule identifiers to exclude from running - force_cast - todo diff --git a/CommonUI/Sources/CommonUI/PreviewConstants.swift b/CommonUI/Sources/CommonUI/PreviewConstants.swift new file mode 100644 index 000000000..055450c69 --- /dev/null +++ b/CommonUI/Sources/CommonUI/PreviewConstants.swift @@ -0,0 +1,581 @@ +// Copyright (c) 2010-2025 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 Foundation +import OpenHABCore +import os.log + +// swiftlint:disable type_body_length +public enum PreviewConstants { + public static let logger = Logger(subsystem: "org.openhab", category: "PreviewConstants") + + public static let remoteURLString = "http://192.168.2.10:8080" + + public static var openHABSitemapPage: OpenHABPage? { + let data = sitemapJson + do { + let sitemapPage = try data.decoded(as: Components.Schemas.PageDTO.self) + let openHABSitemapPage = OpenHABPage(sitemapPage) + return openHABSitemapPage + } catch { + logger.error("Should not throw \(error.localizedDescription)") + return nil + } + } + + public static let sitemapJson = Data(""" + { + "id": "watch", + "title": "watch", + "link": "http://192.168.2.10:8080/rest/sitemaps/watch/watch", + "leaf": true, + "timeout": false, + "widgets": [ + { + "widgetId": "00", + "type": "Switch", + "visibility": true, + "label": "Licht Keller WC Decke", + "icon": "switch", + "mappings": [], + "item": { + "link": "http://192.168.2.15:8081/rest/items/lcnLightSwitch6_1", + "state": "OFF", + "type": "Switch", + "name": "lcnLightSwitch6_1", + "label": "Licht Keller WC Decke", + "tags": [ + "Lighting" + ], + "groupNames": [ + "gKellerLicht", + "gLcn" + ] + }, + "widgets": [] + }, + { + "widgetId": "01", + "type": "Switch", + "visibility": true, + "label": "Licht Oberlicht", + "icon": "switch", + "mappings": [], + "item": { + "link": "http://192.168.2.15:8081/rest/items/lcnLightSwitch14_1", + "state": "OFF", + "type": "Switch", + "name": "lcnLightSwitch14_1", + "label": "Licht Oberlicht", + "tags": [ + "Lighting" + ], + "groupNames": [ + "gEGLicht", + "G_PresenceSimulation", + "gLcn" + ] + }, + "widgets": [] + }, + { + "widgetId": "02", + "type": "Switch", + "visibility": true, + "label": "Licht Esstisch [Test]", + "icon": "switch", + "mappings": [], + "item": { + "link": "http://192.168.2.15:8081/rest/items/lcnLightSwitch20_1", + "state": "ON", + "type": "Switch", + "name": "lcnLightSwitch20_1", + "label": "Licht Esstisch", + "tags": [], + "groupNames": [ + "gEGLicht", + "G_PresenceSimulation", + "gLcn", + "gStateON" + ] + }, + "widgets": [] + }, + { + "widgetId": "03", + "type": "Slider", + "visibility": true, + "label": "Esstisch [100]", + "icon": "slider", + "mappings": [], + "switchSupport": false, + "sendFrequency": 0, + "item": { + "link": "http://192.168.2.10:8080/rest/items/lcnLightDimmer", + "state": "95", + "stateDescription": { + "pattern": "%s", + "readOnly": false, + "options": [] + }, + "type": "Dimmer", + "name": "lcnLightDimmer", + "label": "Esstisch", + "tags": [ + "Lighting" + ], + "groupNames": [ + "gEGLicht", + "gLcn" + ] + }, + "widgets": [] + }, + { + "widgetId": "04", + "type": "Switch", + "visibility": true, + "label": "Fernsteuerung", + "icon": "switch", + "mappings": [ + { + "command": "0", + "label": "Overwrite" + }, + { + "command": "1", + "label": "Kalender" + }, + { + "command": "2", + "label": "Automatik" + } + ], + "item": { + "link": "http://192.168.2.15:8081/rest/items/Automatik", + "state": "2", + "type": "String", + "name": "Automatik", + "tags": [], + "groupNames": [] + }, + "widgets": [] + }, + { + "widgetId": "05", + "type": "Switch", + "visibility": true, + "label": "Jalousie WZ Süd links", + "icon": "rollershutter", + "mappings": [], + "item": { + "link": "http://192.168.2.15:8081/rest/items/lcnJalousieWZSuedLinks", + "state": "0", + "type": "Rollershutter", + "name": "lcnJalousieWZSuedLinks", + "label": "Jalousie WZ Süd links", + "tags": [], + "groupNames": [ + "gWZ", + "gEGJalousien", + "gHausJalousie", + "gJalousienSued", + "gEGJalousienSued", + "gLcn" + ] + }, + "widgets": [] + }, + { + "widgetId": "06", + "type": "Setpoint", + "visibility": true, + "label": "Setpoint Temperature [21.0 °C]", + "icon": "temperature", + "mappings": [], + "minValue": 8, + "maxValue": 25, + "step": 0.5, + "item": { + "link": "http://192.168.2.15:8081/rest/items/ZimmerPaul_SetpointTemperature", + "state": "21.0 °C", + "stateDescription": { + "pattern": "%.1f %unit%", + "readOnly": false, + "options": [] + }, + "type": "Number:Temperature", + "name": "ZimmerPaul_SetpointTemperature", + "label": "Setpoint Temperature [%.1f °C]", + "category": "Temperature", + "tags": [], + "groupNames": [] + }, + "widgets": [] + }, + { + "widgetId": "07", + "type": "Text", + "visibility": true, + "label": "Aussentemperatur [11.5 °C]", + "icon": "temperature", + "mappings": [], + "item": { + "link": "http://192.168.2.15:8081/rest/items/TempAussen", + "state": "11.5", + "stateDescription": { + "pattern": "%.1f °C", + "readOnly": false, + "options": [] + }, + "type": "Number", + "name": "TempAussen", + "label": "Aussentemperatur", + "category": "temperature", + "tags": [], + "groupNames": [ + "gOnewire" + ] + }, + "widgets": [] + }, + { + "widgetId": "08", + "type": "Image", + "visibility": true, + "label": "", + "icon": "image", + "mappings": [], + "url": "http://192.168.2.15:8081/proxy?sitemap=watch.sitemap&widgetId=08", + "widgets": [] + }, + { + "widgetId": "09", + "type": "Mapview", + "visibility": true, + "label": "Location [-°N -°E -m]", + "icon": "mapview", + "mappings": [], + "height": 5, + "item": { + "link": "http://192.168.2.15:8081/rest/items/GPSTrackerTi_Location", + "state":"52.5200066,13.4049540", + "stateDescription": { + "pattern": "%2$s°N %3$s°E %1$sm", + "readOnly": true, + "options": [] + }, + "type": "Location", + "name": "GPSTrackerTi_Location", + "label": "Location", + "tags": [], + "groupNames": [] + }, + "widgets": [] + }, + { + "widgetId": "10", + "type": "Colorpicker", + "visibility": true, + "label": "Color", + "icon": "colorlight", + "mappings": [], + "item": { + "link": "http://192.168.2.160:8080/rest/items/LEDVANCECLA60RGBWZ3_Color", + "state": "UNDEF", + "type": "Color", + "name": "LEDVANCECLA60RGBWZ3_Color", + "label": "Color", + "category": "ColorLight", + "tags": [], + "groupNames": [ + "dg_AllItems" + ] + }, + "widgets": [] + }, + { + "widgetId": "11", + "type": "Setpoint", + "visibility": true, + "label": "item in seconds [2400.0 s]", + "labelSource": "SITEMAP_WIDGET", + "icon": "", + "staticIcon": false, + "pattern": "%.1f s", + "unit": "s", + "mappings": [], + "minValue": 300, + "maxValue": 3600, + "step": 60, + "item": { + "link": "http://192.168.2.10:8080/rest/items/testTime", + "state": "2400 s", + "stateDescription": { + "pattern": "%.0f %unit%", + "readOnly": false, + "options": [] + }, + "unitSymbol": "s", + "type": "Number:Time", + "name": "testTime", + "label": "", + "category": "", + "tags": [], + "groupNames": [] + }, + "widgets": [] + }, + { + "widgetId": "12", + "type": "Setpoint", + "visibility": true, + "label": "item in minutes [40.0 min]", + "labelSource": "SITEMAP_WIDGET", + "icon": "", + "staticIcon": false, + "pattern": "%.1f min", + "unit": "min", + "mappings": [], + "minValue": 5, + "maxValue": 60, + "step": 5, + "state": "40 min", + "item": { + "link": "http://192.168.2.10:8080/rest/items/testTime", + "state": "2400 s", + "stateDescription": { + "pattern": "%.0f %unit%", + "readOnly": false, + "options": [] + }, + "unitSymbol": "s", + "type": "Number:Time", + "name": "testTime", + "label": "", + "category": "", + "tags": [], + "groupNames": [] + }, + "widgets": [] + }, + { + "widgetId": "13", + "type": "Colortemperaturepicker", + "visibility": true, + "label": "Color Temperature [2700 K]", + "labelSource": "SITEMAP_WIDGET", + "icon": "colorwheel", + "staticIcon": true, + "pattern": "%.0f %unit%", + "unit": "K", + "mappings": [], + "item": { + "link": "http://192.168.2.10:8080/rest/items/test_LEDLight_ColorTemp", + "state": "2700.0 K", + "stateDescription": { + "pattern": "%.0f %unit%", + "readOnly": false, + "options": [] + }, + "unitSymbol": "K", + "type": "Number:Temperature", + "name": "test_LEDLight_ColorTemp", + "label": "", + "category": "", + "tags": [], + "groupNames": [] + }, + "widgets": [] + }, + { + "widgetId": "14", + "type": "Slider", + "visibility": true, + "label": "Brightness", + "labelSource": "SITEMAP_WIDGET", + "icon": "", + "staticIcon": false, + "unit": "", + "mappings": [], + "switchSupport": false, + "releaseOnly": false, + "item": { + "link": "http://192.168.2.10:8080/rest/items/test_LEDLight_Brightness", + "state": "NULL", + "type": "Dimmer", + "name": "test_LEDLight_Brightness", + "label": "", + "category": "", + "tags": [], + "groupNames": [] + }, + "widgets": [] + }, + { + "widgetId": "15", + "type": "Colorpicker", + "visibility": true, + "label": "Color", + "labelSource": "SITEMAP_WIDGET", + "icon": "colorwheel", + "staticIcon": false, + "unit": "", + "mappings": [], + "item": { + "link": "http://192.168.2.10:8080/rest/items/test_LEDLight_color", + "state": "0,0,73", + "type": "Color", + "name": "test_LEDLight_color", + "label": "test_LEDLight_color", + "category": "", + "tags": [], + "groupNames": [] + }, + "widgets": [] + }, + { + "widgetId": "16", + "type": "Buttongrid", + "visibility": true, + "label": "Remote Control [-]", + "labelSource": "SITEMAP_WIDGET", + "icon": "screen", + "staticIcon": true, + "pattern": "%s", + "unit": "", + "mappings": [ + { + "row": 1, + "column": 1, + "command": "POWER", + "label": "Power", + "icon": "switch-off" + }, + { + "row": 1, + "column": 2, + "command": "MENU", + "label": "Menu" + }, + { + "row": 1, + "column": 3, + "command": "EXIT", + "label": "Exit" + }, + { + "row": 2, + "column": 2, + "command": "UP", + "label": "Up", + "icon": "f7:arrowtriangle_up" + }, + { + "row": 2, + "column": 4, + "command": "VOL_PLUS", + "label": "Volume +" + }, + { + "row": 3, + "column": 1, + "command": "LEFT", + "label": "Left", + "icon": "f7:arrowtriangle_left" + }, + { + "row": 3, + "column": 2, + "command": "OK", + "label": "Ok" + }, + { + "row": 3, + "column": 3, + "command": "RIGHT", + "label": "Right", + "icon": "f7:arrowtriangle_right" + }, + { + "row": 3, + "column": 4, + "command": "MUTE", + "label": "Mute", + "icon": "soundvolume_mute" + }, + { + "row": 4, + "column": 2, + "command": "DOWN", + "label": "Down", + "icon": "f7:arrowtriangle_down" + }, + { + "row": 4, + "column": 4, + "command": "VOL_MINUS", + "label": "Volume -" + } + ], + "item": { + "link": "http://192.168.2.10:8080/rest/items/test_RemoteControl", + "state": "NULL", + "stateDescription": { + "pattern": "%s", + "readOnly": false, + "options": [] + }, + "type": "String", + "name": "test_RemoteControl", + "label": "test_RemoteControl", + "category": "", + "tags": [], + "groupNames": [] + }, + "widgets": [] + }, + { + "widgetId": "17", + "type": "Input", + "visibility": true, + "label": "Meter [166000]", + "labelSource": "SITEMAP_WIDGET", + "icon": "energy", + "staticIcon": true, + "pattern": "%.0f %unit%", + "unit": "", + "mappings": [], + "inputHint": "number", + "item": { + "link": "http://192.168.2.10:8080/rest/items/Test_Meter_Reading", + "state": "166000.0", + "stateDescription": { + "pattern": "%.0f", + "readOnly": false, + "options": [] + }, + "type": "Number", + "name": "Test_Meter_Reading", + "label": "Test_Meter_Reading", + "category": "", + "tags": [], + "groupNames": [] + }, + "widgets": [] + }, + + ] + } + """.utf8) +} + +// swiftlint:enable type_body_length diff --git a/OpenHABCore/Sources/OpenHABCore/Util/ItemEventStream.swift b/OpenHABCore/Sources/OpenHABCore/Util/ItemEventStream.swift index d0fb3086f..3c68203f2 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/ItemEventStream.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/ItemEventStream.swift @@ -132,7 +132,6 @@ public actor EventStream { broadcast(.connected) for try await sse in eventStream { - logger.info("SSE event: \(sse.event ?? "empty")") for rawMessage in parse(sse) { if let message = rawMessage as? Event { broadcast(.event(message)) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift index cfc3b3d53..e4cc4b3f0 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift @@ -193,7 +193,6 @@ extension Preferences { fileprivate static func getPreference(key: String, defaultValue: T, encoder: (T) -> (some Sendable)?, decoder: (Any?) -> T?) -> T { let preferenceValue = sharedDefaults.object(forKey: key) if let preferenceConverted = decoder(preferenceValue) { - logger.debug("Preference value \(key) is \(String(describing: preferenceConverted))") return preferenceConverted } else { if let preferenceValue { diff --git a/OpenHABCore/Tests/OpenHABCoreTests/NetworkTrackerTests.swift b/OpenHABCore/Tests/OpenHABCoreTests/NetworkTrackerTests.swift index 0688f6336..5c73a717b 100644 --- a/OpenHABCore/Tests/OpenHABCoreTests/NetworkTrackerTests.swift +++ b/OpenHABCore/Tests/OpenHABCoreTests/NetworkTrackerTests.swift @@ -150,45 +150,45 @@ final class NetworkTrackerTests: XCTestCase { await fulfillment(of: [expectation], timeout: 2.0) } - @MainActor - func testTrackerGoesOfflineOnNetworkLoss() async { - let statusSinkAttached = XCTestExpectation(description: "Combine sink attached") - let becameNotConnected = XCTestExpectation(description: "Status becomes .notConnected") - let monitorStarted = XCTestExpectation(description: "Path monitor started") - - let mockMonitor = MockPathMonitor { monitorStarted.fulfill() } // ⬅️ Hold on to this - let tracker = NetworkTracker( - monitor: mockMonitor, - connectionPool: ConnectionPool { _ in MockOpenAPIService() }, - failureTracker: ConnectionFailureTracker() - ) - - var cancellables = Set() - - tracker.$status - .handleEvents { _ in - statusSinkAttached.fulfill() - } receiveRequest: { _ in - } - .dropFirst() - .sink { status in - if status == .notConnected { - becameNotConnected.fulfill() - } - } - .store(in: &cancellables) - - // Start tracking first to initialize properly - await tracker.startTracking(connectionConfigurations: [ - ConnectionConfiguration(url: "http://mock", username: "", password: "", priority: 0) - ]) - - // 🚦 Wait until Combine and monitoring are ready before triggering anything - await fulfillment(of: [statusSinkAttached, monitorStarted], timeout: 2.0) - - // Simulate loss of network - mockMonitor.simulateConnection(isConnected: false) // ✅ use directly - - await fulfillment(of: [becameNotConnected], timeout: 4.0) - } +// @MainActor +// func testTrackerGoesOfflineOnNetworkLoss() async { +// let statusSinkAttached = XCTestExpectation(description: "Combine sink attached") +// let becameNotConnected = XCTestExpectation(description: "Status becomes .notConnected") +// let monitorStarted = XCTestExpectation(description: "Path monitor started") +// +// let mockMonitor = MockPathMonitor { monitorStarted.fulfill() } // ⬅️ Hold on to this +// let tracker = NetworkTracker( +// monitor: mockMonitor, +// connectionPool: ConnectionPool { _ in MockOpenAPIService() }, +// failureTracker: ConnectionFailureTracker() +// ) +// +// var cancellables = Set() +// +// tracker.$status +// .handleEvents { _ in +// statusSinkAttached.fulfill() +// } receiveRequest: { _ in +// } +// .dropFirst() +// .sink { status in +// if status == .notConnected { +// becameNotConnected.fulfill() +// } +// } +// .store(in: &cancellables) +// +// // Start tracking first to initialize properly +// await tracker.startTracking(connectionConfigurations: [ +// ConnectionConfiguration(url: "http://mock", username: "", password: "", priority: 0) +// ]) +// +// // 🚦 Wait until Combine and monitoring are ready before triggering anything +// await fulfillment(of: [statusSinkAttached, monitorStarted], timeout: 2.0) +// +// // Simulate loss of network +// mockMonitor.simulateConnection(isConnected: false) // ✅ use directly +// +// await fulfillment(of: [becameNotConnected], timeout: 4.0) +// } } diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index 82facc441..35dea7992 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -120,7 +120,6 @@ DA50C7BD2B0A51BD0009F716 /* SliderWithSwitchSupportRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA50C7BC2B0A51BD0009F716 /* SliderWithSwitchSupportRow.swift */; }; DA50C7BF2B0A65300009F716 /* SliderWithSwitchSupportUITableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA50C7BE2B0A652F0009F716 /* SliderWithSwitchSupportUITableViewCell.swift */; }; DA5ED9BE2C850955004875E0 /* ClientCertificatesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5ED9BD2C850955004875E0 /* ClientCertificatesViewModel.swift */; }; - DA6454DE2E204B95006005E8 /* PreviewConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA7224D123828D3300712D20 /* PreviewConstants.swift */; }; DA64ACA62DBEAD5600294F60 /* SitemapPageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA64ACA52DBEAD5600294F60 /* SitemapPageViewModel.swift */; }; DA64ACA82DBEAD8300294F60 /* SitemapPageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA64ACA72DBEAD8300294F60 /* SitemapPageView.swift */; }; DA64ACAA2DBEAD9000294F60 /* SitemapNavigationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA64ACA92DBEAD9000294F60 /* SitemapNavigationView.swift */; }; @@ -129,7 +128,6 @@ DA6B2EF12C87B59000DF77CF /* NotificationsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA6B2EF02C87B59000DF77CF /* NotificationsView.swift */; }; DA6B2EF52C89F8F200DF77CF /* ColorPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA6B2EF42C89F8F200DF77CF /* ColorPickerView.swift */; }; DA6B2EF72C8B92E800DF77CF /* SelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA6B2EF62C8B92E800DF77CF /* SelectionView.swift */; }; - DA7224D223828D3400712D20 /* PreviewConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA7224D123828D3300712D20 /* PreviewConstants.swift */; }; DA72E1B8236DEA0900B8EF3A /* AppMessageService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA72E1B5236DEA0900B8EF3A /* AppMessageService.swift */; }; DA77E19B2D886D9B007CFF0F /* SingleConnectionSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA77E19A2D886D9B007CFF0F /* SingleConnectionSettingsView.swift */; }; DA7ACD5F2DC3DB130055CFC7 /* SFSafeSymbols in Frameworks */ = {isa = PBXBuildFile; productRef = DA7ACD5E2DC3DB130055CFC7 /* SFSafeSymbols */; settings = {ATTRIBUTES = (Required, ); }; }; @@ -356,6 +354,7 @@ 933D7F0622E7015000621A03 /* OpenHABUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenHABUITests.swift; sourceTree = ""; }; 933D7F0822E7015100621A03 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 933D7F0E22E7030600621A03 /* SnapshotHelper.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = SnapshotHelper.swift; path = fastlane/SnapshotHelper.swift; sourceTree = SOURCE_ROOT; }; + 934B610B2348D2F9009112D5 /* Color+Extension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Color+Extension.swift"; sourceTree = ""; }; 935B484525342B8E00E44CF0 /* URL+Static.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+Static.swift"; sourceTree = ""; }; 935D3412257B7E2F0020A404 /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = Resources/nl.lproj/Intents.strings; sourceTree = ""; }; 935D3419257B7E820020A404 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.intentdefinition; name = Base; path = Resources/Base.lproj/Intents.intentdefinition; sourceTree = ""; }; @@ -1021,6 +1020,8 @@ DAF457A723DBA2C40018B495 /* Utils */ = { isa = PBXGroup; children = ( + DA7224D123828D3300712D20 /* PreviewConstants.swift */, + 934B610B2348D2F9009112D5 /* Color+Extension.swift */, DAF4578123D630C70018B495 /* IconView.swift */, DAF4578623D798A50018B495 /* TextLabelView.swift */, DAF4578823D79AA50018B495 /* DetailTextLabelView.swift */, @@ -1195,7 +1196,6 @@ DFFD8FCE18EDBD30003B502A /* Util */ = { isa = PBXGroup; children = ( - DA7224D123828D3300712D20 /* PreviewConstants.swift */, 938EDCE022C4FEB800661CA1 /* ScaleAspectFitImageView.swift */, DFFD8FD018EDBD4F003B502A /* UICircleButton.swift */, 938BF9C524EFCC0700E6B52F /* UILabel+Localization.swift */, @@ -1650,7 +1650,6 @@ DA162BEC2CD3B53E0040DAE5 /* LogsViewer.swift in Sources */, DAF4578723D798A50018B495 /* TextLabelView.swift in Sources */, DA0749DE23E0B5950057FA83 /* ColorPickerRow.swift in Sources */, - DA7224D223828D3400712D20 /* PreviewConstants.swift in Sources */, DAF4581E23DC60020018B495 /* ImageRawRow.swift in Sources */, DAD0858B2AE56F0E001D36BE /* OpenHABWatch.swift in Sources */, DAF4578223D630C70018B495 /* IconView.swift in Sources */, @@ -1730,6 +1729,7 @@ DA35E2B02E1EDB86003987BB /* SetpointRowView.swift in Sources */, DA35E2CB2E1F93AD003987BB /* ImageView.swift in Sources */, DAEE35072E224F6000B4A7F1 /* ColorTemperaturePickerRowView.swift in Sources */, + DA77E19B2D886D9B007CFF0F /* SingleConnectionSettingsView.swift in Sources */, DA6B2EF52C89F8F200DF77CF /* ColorPickerView.swift in Sources */, DA4800142D836892009CF127 /* ConnectionSettingsView.swift in Sources */, 2F6412EE2CE494A80039FB28 /* DatePickerUITableViewCell.swift in Sources */, @@ -1757,6 +1757,8 @@ DA35E2C62E1EEA9D003987BB /* SegmentedRowView.swift in Sources */, DA35E2C72E1EEA9D003987BB /* VideoRowView.swift in Sources */, DA35E2CD2E1F96CA003987BB /* IconView.swift in Sources */, + DA4800212D839D3A009CF127 /* AnimatedSecureTextField.swift in Sources */, + DAC131112DA3213100075AE2 /* SetSwitchStateIntentHandlerTests.swift in Sources */, DAC65FC7236EDF3900F4501E /* SpinnerViewController.swift in Sources */, DA9F81872C85020F00B47B72 /* RTFTextView.swift in Sources */, DA6B2EF12C87B59000DF77CF /* NotificationsView.swift in Sources */, @@ -1794,7 +1796,6 @@ DFA16EBB18883DE500EDB0BB /* SliderUITableViewCell.swift in Sources */, DFA13CB418872EBD006355C3 /* SwitchUITableViewCell.swift in Sources */, DFFD8FD118EDBD4F003B502A /* UICircleButton.swift in Sources */, - DA6454DE2E204B95006005E8 /* PreviewConstants.swift in Sources */, DA48001C2D837556009CF127 /* SitemapSettingsView.swift in Sources */, 938BF9C624EFCC0700E6B52F /* UILabel+Localization.swift in Sources */, ); @@ -2369,7 +2370,6 @@ CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 50; DEBUG_INFORMATION_FORMAT = dwarf; - DEVELOPMENT_TEAM = D6A95UZXVC; DEVELOPMENT_TEAM = PBAPXHRAM9; "DEVELOPMENT_TEAM[sdk=watchos*]" = PBAPXHRAM9; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -2894,7 +2894,7 @@ repositoryURL = "https://github.com/onevcat/Kingfisher.git"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 8.5.0; + minimumVersion = 8.0.0; }; }; 93F8063327AE6C620035A6B0 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */ = { diff --git a/openHAB/NoIconDisplayableCell.swift b/openHAB/NoIconDisplayableCell.swift index 0692c7ee2..d19193c67 100644 --- a/openHAB/NoIconDisplayableCell.swift +++ b/openHAB/NoIconDisplayableCell.swift @@ -10,5 +10,7 @@ // SPDX-License-Identifier: EPL-2.0 // No icon will be displazed for cells that conform to NoIconDisplayableCell protocol +import OpenHABCore +import SwiftUI protocol NoIconDisplayableCell {} diff --git a/openHAB/OpenHABRootViewController.swift b/openHAB/OpenHABRootViewController.swift index ae940975c..55c581134 100644 --- a/openHAB/OpenHABRootViewController.swift +++ b/openHAB/OpenHABRootViewController.swift @@ -194,10 +194,10 @@ class OpenHABRootViewController: UIViewController { handleNotificationInternal(state) case let .ready(uuid, _): print("SSE Session UUID:", uuid) - case let .alive(interval): - print("SSE Heartbeat interval:", interval, "s") case let .unknown(raw): print("SSE Unknown:", raw) + default: + break } } } diff --git a/openHAB/SwiftUI/Rows/FrameRowView.swift b/openHAB/SwiftUI/Rows/FrameRowView.swift index f01fc51c0..611aa60d7 100644 --- a/openHAB/SwiftUI/Rows/FrameRowView.swift +++ b/openHAB/SwiftUI/Rows/FrameRowView.swift @@ -9,6 +9,7 @@ // // SPDX-License-Identifier: EPL-2.0 +import CommonUI import OpenHABCore import SwiftUI diff --git a/openHAB/SwiftUI/Rows/GenericRowView.swift b/openHAB/SwiftUI/Rows/GenericRowView.swift index a7cbe13a5..12a0c2c84 100644 --- a/openHAB/SwiftUI/Rows/GenericRowView.swift +++ b/openHAB/SwiftUI/Rows/GenericRowView.swift @@ -9,6 +9,7 @@ // // SPDX-License-Identifier: EPL-2.0 +import CommonUI import OpenHABCore import SwiftUI diff --git a/openHAB/SwiftUI/SitemapNavigationView.swift b/openHAB/SwiftUI/SitemapNavigationView.swift index 110179e81..2915b05f1 100644 --- a/openHAB/SwiftUI/SitemapNavigationView.swift +++ b/openHAB/SwiftUI/SitemapNavigationView.swift @@ -9,6 +9,7 @@ // // SPDX-License-Identifier: EPL-2.0 +import CommonUI import OpenHABCore import SFSafeSymbols import SwiftUI diff --git a/openHAB/SwiftUI/SitemapPageView.swift b/openHAB/SwiftUI/SitemapPageView.swift index 8f343634a..63ee24657 100644 --- a/openHAB/SwiftUI/SitemapPageView.swift +++ b/openHAB/SwiftUI/SitemapPageView.swift @@ -9,6 +9,7 @@ // // SPDX-License-Identifier: EPL-2.0 +import CommonUI import OpenHABCore import SwiftUI diff --git a/openHABWatch/Domain/UserData.swift b/openHABWatch/Domain/UserData.swift index fd01f95f5..ffcfbe166 100644 --- a/openHABWatch/Domain/UserData.swift +++ b/openHABWatch/Domain/UserData.swift @@ -10,6 +10,7 @@ // SPDX-License-Identifier: EPL-2.0 import Combine +import CommonUI import Foundation import OpenHABCore import os.log @@ -36,7 +37,6 @@ final class UserData: ObservableObject { private var cancellables = Set() - #if DEBUG init(preview: Bool = false) { let data = PreviewConstants.sitemapJson do { @@ -50,7 +50,6 @@ final class UserData: ObservableObject { logger.error("Should not throw \(error.localizedDescription)") } } - #endif init() { NotificationCenter.default.addObserver( diff --git a/openHABWatch/Views/Rows/ImageRow.swift b/openHABWatch/Views/Rows/ImageRow.swift index fe8ec65d1..072c4cd4c 100644 --- a/openHABWatch/Views/Rows/ImageRow.swift +++ b/openHABWatch/Views/Rows/ImageRow.swift @@ -9,6 +9,7 @@ // // SPDX-License-Identifier: EPL-2.0 +import CommonUI import Kingfisher import OpenHABCore import os.log diff --git a/openHABWatch/Views/Rows/SwitchRow.swift b/openHABWatch/Views/Rows/SwitchRow.swift index 54d874e48..ab67cec5a 100644 --- a/openHABWatch/Views/Rows/SwitchRow.swift +++ b/openHABWatch/Views/Rows/SwitchRow.swift @@ -9,6 +9,7 @@ // // SPDX-License-Identifier: EPL-2.0 +import CommonUI import OpenHABCore import os.log import SwiftUI From 5b7358ccb4549c4f1133f4a6d97fcbfd496653bb Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Wed, 6 Aug 2025 21:29:32 +0200 Subject: [PATCH 327/476] Presenting Settings with Cancel and Save again Address some overlooked lint remarks Signed-off-by: Tim Mueller-Seydlitz --- .../OpenHABCore/Model/OpenHABWidget.swift | 2 +- .../OpenHABCoreTests/ComparableTests.swift | 2 +- openHAB.xcodeproj/project.pbxproj | 45 ++++++------------- .../xcshareddata/xcschemes/openHAB.xcscheme | 2 +- .../xcschemes/openHABIntents.xcscheme | 2 +- .../xcschemes/openHABTestsSwift.xcscheme | 2 +- .../xcschemes/openHABUITests.xcscheme | 2 +- .../openHABWatch (Notification).xcscheme | 2 +- .../xcschemes/openHABWatch.xcscheme | 2 +- .../openHABWatchSwift (Complication).xcscheme | 2 +- openHAB/OpenHABRootViewController.swift | 13 +++--- 11 files changed, 29 insertions(+), 47 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift index 1aab924fd..9d17ca496 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift @@ -579,7 +579,7 @@ extension OpenHABWidget { state: widget.state, text: "", legend: widget.legend, - inputHint: InputHint(rawValue: widget.inputHint ?? "unknown") ?? .unknown, + inputHint: InputHint(rawValue: widget.inputHint ?? "unknown"), encoding: widget.encoding, item: OpenHABItem(widget.item), linkedPage: OpenHABPage(widget.linkedPage), diff --git a/OpenHABCore/Tests/OpenHABCoreTests/ComparableTests.swift b/OpenHABCore/Tests/OpenHABCoreTests/ComparableTests.swift index a2c020e0f..bdea5c3e6 100644 --- a/OpenHABCore/Tests/OpenHABCoreTests/ComparableTests.swift +++ b/OpenHABCore/Tests/OpenHABCoreTests/ComparableTests.swift @@ -12,7 +12,7 @@ @testable import OpenHABCore import Testing -struct ComparableTest { +struct ComparableTests { @Test func clampedWithValueBelowRange() { // Int diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index 196a89d88..fe15f3652 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -354,7 +354,6 @@ 933D7F0622E7015000621A03 /* OpenHABUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenHABUITests.swift; sourceTree = ""; }; 933D7F0822E7015100621A03 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 933D7F0E22E7030600621A03 /* SnapshotHelper.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = SnapshotHelper.swift; path = fastlane/SnapshotHelper.swift; sourceTree = SOURCE_ROOT; }; - 934B610B2348D2F9009112D5 /* Color+Extension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Color+Extension.swift"; sourceTree = ""; }; 935B484525342B8E00E44CF0 /* URL+Static.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+Static.swift"; sourceTree = ""; }; 935D3412257B7E2F0020A404 /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = Resources/nl.lproj/Intents.strings; sourceTree = ""; }; 935D3419257B7E820020A404 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.intentdefinition; name = Base; path = Resources/Base.lproj/Intents.intentdefinition; sourceTree = ""; }; @@ -476,7 +475,6 @@ DA6B2EF02C87B59000DF77CF /* NotificationsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsView.swift; sourceTree = ""; }; DA6B2EF42C89F8F200DF77CF /* ColorPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorPickerView.swift; sourceTree = ""; }; DA6B2EF62C8B92E800DF77CF /* SelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectionView.swift; sourceTree = ""; }; - DA7224D123828D3300712D20 /* PreviewConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewConstants.swift; sourceTree = ""; }; DA72E1B5236DEA0900B8EF3A /* AppMessageService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppMessageService.swift; sourceTree = ""; }; DA77E19A2D886D9B007CFF0F /* SingleConnectionSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SingleConnectionSettingsView.swift; sourceTree = ""; }; DA7E1E47222EB00B002AEFD8 /* PlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerView.swift; sourceTree = ""; }; @@ -562,17 +560,7 @@ /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ - DA2AEB752D92D32000897D80 /* Cells */ = { - isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - ); - explicitFileTypes = { - }; - explicitFolders = ( - ); - path = Cells; - sourceTree = ""; - }; + DA2AEB752D92D32000897D80 /* Cells */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Cells; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -1030,8 +1018,6 @@ DAF457A723DBA2C40018B495 /* Utils */ = { isa = PBXGroup; children = ( - DA7224D123828D3300712D20 /* PreviewConstants.swift */, - 934B610B2348D2F9009112D5 /* Color+Extension.swift */, DAF4578123D630C70018B495 /* IconView.swift */, DAF4578623D798A50018B495 /* TextLabelView.swift */, DAF4578823D79AA50018B495 /* DetailTextLabelView.swift */, @@ -1410,7 +1396,7 @@ BuildIndependentTargetsInParallel = YES; CLASSPREFIX = OpenHAB; LastSwiftUpdateCheck = 1540; - LastUpgradeCheck = 1620; + LastUpgradeCheck = 1640; ORGANIZATIONNAME = "openHAB e.V."; TargetAttributes = { 4D6470D22561F935007B03FC = { @@ -1739,7 +1725,6 @@ DA35E2B02E1EDB86003987BB /* SetpointRowView.swift in Sources */, DA35E2CB2E1F93AD003987BB /* ImageView.swift in Sources */, DAEE35072E224F6000B4A7F1 /* ColorTemperaturePickerRowView.swift in Sources */, - DA77E19B2D886D9B007CFF0F /* SingleConnectionSettingsView.swift in Sources */, DA6B2EF52C89F8F200DF77CF /* ColorPickerView.swift in Sources */, DA4800142D836892009CF127 /* ConnectionSettingsView.swift in Sources */, 2F6412EE2CE494A80039FB28 /* DatePickerUITableViewCell.swift in Sources */, @@ -1767,8 +1752,6 @@ DA35E2C62E1EEA9D003987BB /* SegmentedRowView.swift in Sources */, DA35E2C72E1EEA9D003987BB /* VideoRowView.swift in Sources */, DA35E2CD2E1F96CA003987BB /* IconView.swift in Sources */, - DA4800212D839D3A009CF127 /* AnimatedSecureTextField.swift in Sources */, - DAC131112DA3213100075AE2 /* SetSwitchStateIntentHandlerTests.swift in Sources */, DAC65FC7236EDF3900F4501E /* SpinnerViewController.swift in Sources */, DA9F81872C85020F00B47B72 /* RTFTextView.swift in Sources */, DA6B2EF12C87B59000DF77CF /* NotificationsView.swift in Sources */, @@ -1959,8 +1942,8 @@ CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_ENTITLEMENTS = openHABIntents/openHABIntents.entitlements; - CODE_SIGN_IDENTITY = "Apple Distribution"; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "Apple Distribution"; + CODE_SIGN_IDENTITY = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 52; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; @@ -1981,7 +1964,7 @@ MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = org.openhab.app.openHABIntents; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = "match AppStore org.openhab.app.openHABIntents"; + PROVISIONING_PROFILE_SPECIFIER = ""; "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "match AppStore org.openhab.app.openHABIntents"; SKIP_INSTALL = YES; SWIFT_OPTIMIZATION_LEVEL = "-O"; @@ -2050,8 +2033,8 @@ CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements; - CODE_SIGN_IDENTITY = "Apple Distribution"; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "Apple Distribution"; + CODE_SIGN_IDENTITY = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 52; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; @@ -2077,7 +2060,7 @@ MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = org.openhab.app.NotificationService; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = "match AppStore org.openhab.app.NotificationService"; + PROVISIONING_PROFILE_SPECIFIER = ""; "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "match AppStore org.openhab.app.NotificationService"; SKIP_INSTALL = YES; SWIFT_EMIT_LOC_STRINGS = YES; @@ -2241,8 +2224,8 @@ CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_ENTITLEMENTS = "openHABWatch Extension/openHABWatch Extension.entitlements"; - CODE_SIGN_IDENTITY = "Apple Distribution"; - "CODE_SIGN_IDENTITY[sdk=watchos*]" = "Apple Distribution"; + CODE_SIGN_IDENTITY = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=watchos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; COPY_PHASE_STRIP = NO; CURRENT_PROJECT_VERSION = 52; @@ -2269,7 +2252,7 @@ MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = org.openhab.app.watchkitapp; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = "match AppStore org.openhab.app.watchkitapp"; + PROVISIONING_PROFILE_SPECIFIER = ""; "PROVISIONING_PROFILE_SPECIFIER[sdk=watchos*]" = "match AppStore org.openhab.app.watchkitapp"; SDKROOT = watchos; SKIP_INSTALL = YES; @@ -2746,8 +2729,8 @@ CLANG_CXX_LANGUAGE_STANDARD = "$(inherited)"; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = openHAB/openHAB.entitlements; - CODE_SIGN_IDENTITY = "Apple Distribution"; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "Apple Distribution"; + CODE_SIGN_IDENTITY = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 52; DEVELOPMENT_TEAM = ""; @@ -2773,7 +2756,7 @@ PRODUCT_BUNDLE_IDENTIFIER = org.openhab.app; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE = ""; - PROVISIONING_PROFILE_SPECIFIER = "match AppStore org.openhab.app"; + PROVISIONING_PROFILE_SPECIFIER = ""; "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "match AppStore org.openhab.app"; SUPPORTS_MACCATALYST = NO; SWIFT_COMPILATION_MODE = wholemodule; diff --git a/openHAB.xcodeproj/xcshareddata/xcschemes/openHAB.xcscheme b/openHAB.xcodeproj/xcshareddata/xcschemes/openHAB.xcscheme index 5e3f3815d..fc352091c 100644 --- a/openHAB.xcodeproj/xcshareddata/xcschemes/openHAB.xcscheme +++ b/openHAB.xcodeproj/xcshareddata/xcschemes/openHAB.xcscheme @@ -1,6 +1,6 @@ , func viewName() -> String { "sitemap" } - func reloadView() { - // Maybe call viewModel.reload() if you wire a viewModel refresh - Task { - await rootView.viewModel.reload() + nonisolated func reloadView() { + Task { @MainActor in + await viewModel.reload() } } @@ -761,7 +760,7 @@ class OpenHABRootViewController: UIViewController { } private func switchView(target: TargetController) { - let targetView: (UIViewController & OpenHABViewable) + let targetView: any (UIViewController & OpenHABViewable) switch target { case .sitemap: @@ -882,8 +881,8 @@ extension OpenHABRootViewController: ModalHandler { switchView(target: to) await (sitemapViewController as? HostingSitemapViewController)?.pushSitemap(name: sitemapName, path: nil) case .settings: - let hostingController = UIHostingController(rootView: SettingsView()) - navigationController?.pushViewController(hostingController, animated: true) + let hostingController = UIHostingController(rootView: NavigationView { SettingsView() }) + present(hostingController, animated: true) case .notifications: let hostingController = UIHostingController(rootView: NotificationsView()) navigationController?.pushViewController(hostingController, animated: true) From 8d0bb5298dc07a4aa9b8806a12424e8ab9c7346c Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Thu, 14 Aug 2025 21:44:04 +0200 Subject: [PATCH 328/476] Safer icon display Signed-off-by: Tim Mueller-Seydlitz --- openHAB.xcodeproj/project.pbxproj | 24 ++++++++++++------------ openHAB/SwiftUI/SitemapPageView.swift | 2 +- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index 481fe938b..087ba3ec2 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -1899,8 +1899,8 @@ CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_ENTITLEMENTS = openHABIntents/openHABIntents.entitlements; - CODE_SIGN_IDENTITY = "Apple Distribution"; - CODE_SIGN_STYLE = Manual; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 57; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = PBAPXHRAM9; @@ -1920,7 +1920,7 @@ MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = org.openhab.app.openHABIntents; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = "match AppStore org.openhab.app.openHABIntents"; + PROVISIONING_PROFILE_SPECIFIER = ""; SKIP_INSTALL = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; @@ -1985,8 +1985,8 @@ CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements; - CODE_SIGN_IDENTITY = "Apple Distribution"; - CODE_SIGN_STYLE = Manual; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 57; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = PBAPXHRAM9; @@ -2011,7 +2011,7 @@ MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = org.openhab.app.NotificationService; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = "match AppStore org.openhab.app.NotificationService"; + PROVISIONING_PROFILE_SPECIFIER = ""; SKIP_INSTALL = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -2172,8 +2172,8 @@ CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_ENTITLEMENTS = "openHABWatch Extension/openHABWatch Extension.entitlements"; - CODE_SIGN_IDENTITY = "Apple Distribution"; - CODE_SIGN_STYLE = Manual; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 57; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = PBAPXHRAM9; @@ -2198,7 +2198,7 @@ MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = org.openhab.app.watchkitapp; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = "match AppStore org.openhab.app.watchkitapp"; + PROVISIONING_PROFILE_SPECIFIER = ""; SDKROOT = watchos; SKIP_INSTALL = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; @@ -2666,8 +2666,8 @@ CLANG_CXX_LANGUAGE_STANDARD = "$(inherited)"; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = openHAB/openHAB.entitlements; - CODE_SIGN_IDENTITY = "Apple Distribution"; - CODE_SIGN_STYLE = Manual; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 57; DEVELOPMENT_TEAM = PBAPXHRAM9; ENABLE_DEBUG_DYLIB = YES; @@ -2692,7 +2692,7 @@ PRODUCT_BUNDLE_IDENTIFIER = org.openhab.app; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE = ""; - PROVISIONING_PROFILE_SPECIFIER = "match AppStore org.openhab.app"; + PROVISIONING_PROFILE_SPECIFIER = ""; SUPPORTS_MACCATALYST = NO; SWIFT_INSTALL_OBJC_HEADER = NO; SWIFT_OBJC_BRIDGING_HEADER = ""; diff --git a/openHAB/SwiftUI/SitemapPageView.swift b/openHAB/SwiftUI/SitemapPageView.swift index 63ee24657..1393cfce2 100644 --- a/openHAB/SwiftUI/SitemapPageView.swift +++ b/openHAB/SwiftUI/SitemapPageView.swift @@ -34,7 +34,7 @@ struct SitemapPageView: View { .disabled(true) } } else { - ForEach(viewModel.relevantWidgets, id: \.id) { widget in + ForEach(viewModel.relevantWidgets) { widget in Group { if let linkedPage = widget.linkedPage { NavigationLink(destination: SitemapPageView(viewModel: SitemapPageViewModel(pageUrl: linkedPage.link, title: linkedPage.title))) { From e5ea803cf2e475d4d6af81c57e2627cc10e4768a Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Fri, 15 Aug 2025 13:59:29 +0200 Subject: [PATCH 329/476] prepare for Xcode 26 Signed-off-by: Tim Mueller-Seydlitz --- .../Sources/OpenHABCore/Util/HTTPClient.swift | 4 +- openHAB.xcodeproj/project.pbxproj | 23 ++++++ .../xcshareddata/swiftpm/Package.resolved | 4 +- openHAB/OpenHABRootViewController.swift | 18 ++--- openHAB/SitemapPageViewModel.swift | 11 +-- openHAB/SwiftUI/EmbeddingRowView.swift | 79 +++++++++++++++++++ openHAB/SwiftUI/SitemapPageView.swift | 67 +--------------- 7 files changed, 121 insertions(+), 85 deletions(-) create mode 100644 openHAB/SwiftUI/EmbeddingRowView.swift diff --git a/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift b/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift index ca449e190..243beb2e1 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift @@ -12,8 +12,6 @@ @preconcurrency import Foundation import os -private let logger = Logger(subsystem: "org.openhab", category: "HTTPClient") - public enum HTTPClientError: Error { case serverTrustEvaluationFailed(reason: String) case noDataforItem @@ -57,6 +55,8 @@ public enum CertificateEvaluateResult: Sendable { } actor CertificateStore { + private let logger = Logger(subsystem: "org.openhab", category: "CertificateStore") + private var trustedCertificates: [String: Data] = [:] private func getPersistencePath() -> URL { diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index 8b78754af..194170096 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -190,6 +190,8 @@ DAF4581823DC4A050018B495 /* ImageRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF4581723DC4A050018B495 /* ImageRow.swift */; }; DAF4581E23DC60020018B495 /* ImageRawRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF4581D23DC60020018B495 /* ImageRawRow.swift */; }; DAF4F6C0222734D300C24876 /* NewImageUITableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF4F6BF222734D200C24876 /* NewImageUITableViewCell.swift */; }; + DAF5AA682E4F3A39004F18D7 /* EmbeddingRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF5AA672E4F3A38004F18D7 /* EmbeddingRowView.swift */; }; + DAFF80982E4F47830084513E /* SDWebImage in Frameworks */ = {isa = PBXBuildFile; productRef = DAFF80972E4F47830084513E /* SDWebImage */; }; DF05FF231896BD2D00FF2F9B /* SelectionUITableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF05FF221896BD2D00FF2F9B /* SelectionUITableViewCell.swift */; }; DF06F1FC18FEC2020011E7B9 /* ColorPickerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF06F1FB18FEC2020011E7B9 /* ColorPickerViewController.swift */; }; DF4B84131886DAC400F34902 /* FrameUITableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF4B84121886DAC400F34902 /* FrameUITableViewCell.swift */; }; @@ -536,6 +538,7 @@ DAF4581723DC4A050018B495 /* ImageRow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageRow.swift; sourceTree = ""; }; DAF4581D23DC60020018B495 /* ImageRawRow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageRawRow.swift; sourceTree = ""; }; DAF4F6BF222734D200C24876 /* NewImageUITableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewImageUITableViewCell.swift; sourceTree = ""; }; + DAF5AA672E4F3A38004F18D7 /* EmbeddingRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmbeddingRowView.swift; sourceTree = ""; }; DAF6F4112C67E83B0083883E /* openapiCorrected.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = openapiCorrected.json; sourceTree = ""; }; DAFD2FE62E0D96700059A1EB /* OsLogRewriter */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = OsLogRewriter; sourceTree = ""; }; DF05FF221896BD2D00FF2F9B /* SelectionUITableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SelectionUITableViewCell.swift; sourceTree = ""; }; @@ -636,6 +639,7 @@ 937E4485270B379900A98C26 /* DeviceKit in Frameworks */, DABB5E332D98972F009A4B8A /* SDWebImageSVGCoder in Frameworks */, DFB2622F18830A3600D3244D /* UIKit.framework in Frameworks */, + DAFF80982E4F47830084513E /* SDWebImage in Frameworks */, DACE664A2C63B0760069E514 /* OpenAPIURLSession in Frameworks */, 93F8064A27AE7A2E0035A6B0 /* FlexColorPicker in Frameworks */, DA28C362225241DE00AB409C /* WebKit.framework in Frameworks */, @@ -872,6 +876,7 @@ DA35E2CC2E1F96CA003987BB /* IconView.swift */, DAE280092E35F5590028EE24 /* IconURLView.swift */, DA64ACA72DBEAD8300294F60 /* SitemapPageView.swift */, + DAF5AA672E4F3A38004F18D7 /* EmbeddingRowView.swift */, DA64ACA92DBEAD9000294F60 /* SitemapNavigationView.swift */, DAEA21D72DBF472D00D54342 /* RowViewFactory.swift */, DAC949FF2E21A473007E67B7 /* Rows */, @@ -1382,6 +1387,7 @@ DA9A7EFE2D66915900824156 /* SFSafeSymbols */, DABB5E322D98972F009A4B8A /* SDWebImageSVGCoder */, DAC949F92E219F0D007E67B7 /* CommonUI */, + DAFF80972E4F47830084513E /* SDWebImage */, ); productName = openHAB; productReference = DFB2622718830A3600D3244D /* openHAB.app */; @@ -1475,6 +1481,7 @@ DACE664B2C63B0840069E514 /* XCRemoteSwiftPackageReference "swift-openapi-runtime" */, DA3B75AC2C59729200E219AB /* XCRemoteSwiftPackageReference "SFSafeSymbols" */, DA2C4FD32B4F573300D1C533 /* XCRemoteSwiftPackageReference "SDWebImageSVGCoder" */, + DAFF80962E4F47830084513E /* XCRemoteSwiftPackageReference "SDWebImage" */, ); productRefGroup = DFB2622818830A3600D3244D /* Products */; projectDirPath = ""; @@ -1787,6 +1794,7 @@ DAEAA89D21E6B06400267EA3 /* ReusableView.swift in Sources */, DF05FF231896BD2D00FF2F9B /* SelectionUITableViewCell.swift in Sources */, DFA16EBB18883DE500EDB0BB /* SliderUITableViewCell.swift in Sources */, + DAF5AA682E4F3A39004F18D7 /* EmbeddingRowView.swift in Sources */, DFA13CB418872EBD006355C3 /* SwitchUITableViewCell.swift in Sources */, DFFD8FD118EDBD4F003B502A /* UICircleButton.swift in Sources */, DA48001C2D837556009CF127 /* SitemapSettingsView.swift in Sources */, @@ -2694,6 +2702,7 @@ PROVISIONING_PROFILE = ""; PROVISIONING_PROFILE_SPECIFIER = ""; SUPPORTS_MACCATALYST = NO; + SWIFT_DEFAULT_ACTOR_ISOLATION = nonisolated; SWIFT_INSTALL_OBJC_HEADER = NO; SWIFT_OBJC_BRIDGING_HEADER = ""; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; @@ -2760,6 +2769,7 @@ "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "match AppStore org.openhab.app"; SUPPORTS_MACCATALYST = NO; SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_DEFAULT_ACTOR_ISOLATION = nonisolated; SWIFT_INSTALL_OBJC_HEADER = NO; SWIFT_OBJC_BRIDGING_HEADER = ""; SWIFT_PRECOMPILE_BRIDGING_HEADER = NO; @@ -2954,6 +2964,14 @@ minimumVersion = 1.5.0; }; }; + DAFF80962E4F47830084513E /* XCRemoteSwiftPackageReference "SDWebImage" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/SDWebImage/SDWebImage"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 5.21.1; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -3085,6 +3103,11 @@ package = DACE664B2C63B0840069E514 /* XCRemoteSwiftPackageReference "swift-openapi-runtime" */; productName = OpenAPIRuntime; }; + DAFF80972E4F47830084513E /* SDWebImage */ = { + isa = XCSwiftPackageProductDependency; + package = DAFF80962E4F47830084513E /* XCRemoteSwiftPackageReference "SDWebImage" */; + productName = SDWebImage; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = DFB2621F18830A3600D3244D /* Project object */; diff --git a/openHAB.xcworkspace/xcshareddata/swiftpm/Package.resolved b/openHAB.xcworkspace/xcshareddata/swiftpm/Package.resolved index 879cc6ee6..00d57ab2b 100644 --- a/openHAB.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/openHAB.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -149,8 +149,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/SDWebImage/SDWebImage.git", "state" : { - "revision" : "e7d3256c497af9330a0df866ee38f544ddd87c49", - "version" : "5.20.1" + "revision" : "b62cb63bf4ed1f04c961a56c9c6c9d5ab8524ec6", + "version" : "5.21.1" } }, { diff --git a/openHAB/OpenHABRootViewController.swift b/openHAB/OpenHABRootViewController.swift index 2957c8a3e..1cd0b0136 100644 --- a/openHAB/OpenHABRootViewController.swift +++ b/openHAB/OpenHABRootViewController.swift @@ -45,15 +45,7 @@ class HostingSitemapViewController: UIHostingController, init() { let viewModel = SitemapPageViewModel() self.viewModel = viewModel - super.init(rootView: SitemapNavigationView(viewModel: viewModel, onShowSideMenu: {})) - } - - func setRootViewController(_ rootViewController: OpenHABRootViewController) { - self.rootViewController = rootViewController - // Update the closure after initialization - rootView = SitemapNavigationView(viewModel: viewModel) { [weak self] in - self?.rootViewController?.showSideMenu() - } + super.init(rootView: SitemapNavigationView(viewModel: viewModel) {}) } @available(*, unavailable) @@ -67,6 +59,14 @@ class HostingSitemapViewController: UIHostingController, navigationController?.setNavigationBarHidden(true, animated: false) } + func setRootViewController(_ rootViewController: OpenHABRootViewController) { + self.rootViewController = rootViewController + // Update the closure after initialization + rootView = SitemapNavigationView(viewModel: viewModel) { [weak self] in + self?.rootViewController?.showSideMenu() + } + } + func getSitemapTitle() -> String { viewModel.pageTitle } diff --git a/openHAB/SitemapPageViewModel.swift b/openHAB/SitemapPageViewModel.swift index 223b9d453..0689190cd 100644 --- a/openHAB/SitemapPageViewModel.swift +++ b/openHAB/SitemapPageViewModel.swift @@ -9,7 +9,7 @@ // // SPDX-License-Identifier: EPL-2.0 -import Combine +@preconcurrency import Combine import OpenAPIRuntime import OpenHABCore import os.log @@ -239,13 +239,6 @@ class SitemapPageViewModel: ObservableObject { } } - func widgetTapped(_ widget: OpenHABWidget) { - if let linkedPage = widget.linkedPage { - // Push a new view (handled in the SwiftUI view) - } - // handle other widget types - } - @MainActor func pushSitemap(name: String, path: String?) async { defaultSitemap = name @@ -369,7 +362,7 @@ class SitemapPageViewModel: ObservableObject { } } -extension Published.Publisher { +extension Published.Publisher where Output: Sendable { func stream() -> AsyncStream { AsyncStream { continuation in let cancellable = self.sink { value in diff --git a/openHAB/SwiftUI/EmbeddingRowView.swift b/openHAB/SwiftUI/EmbeddingRowView.swift new file mode 100644 index 000000000..5ea4a32e5 --- /dev/null +++ b/openHAB/SwiftUI/EmbeddingRowView.swift @@ -0,0 +1,79 @@ +// Copyright (c) 2010-2025 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 OpenHABCore +import SwiftUI + +struct EmbeddingRowView: View { + @ObservedObject var widget: OpenHABWidget + @EnvironmentObject var viewModel: SitemapPageViewModel + @State private var selectedWidget: OpenHABWidget? + @State private var showSelectionSheet = false + @State private var showInputAlert = false + @State private var inputText = "" + + var body: some View { + Group { + if let linkedPage = widget.linkedPage { + NavigationLink(destination: SitemapPageView(viewModel: SitemapPageViewModel(pageUrl: linkedPage.link, title: linkedPage.title))) { + RowViewFactory.view(for: widget) + } + .buttonStyle(.plain) + .padding(.vertical, -6) + } else if widget.type == .selection { + Button { + selectedWidget = widget + showSelectionSheet = true + } label: { + RowViewFactory.view(for: widget) + } + .buttonStyle(.plain) + } else if widget.type == .input { + Button { + selectedWidget = widget + showInputAlert = true + } label: { + RowViewFactory.view(for: widget) + } + .buttonStyle(.plain) + } else { + RowViewFactory.view(for: widget) + } + } + + .alert("Input", isPresented: $showInputAlert) { + if let widget = selectedWidget { + TextField("Enter value", text: $inputText) + Button("Cancel", role: .cancel) {} + Button("OK") { + // Handle input submission + showInputAlert = false + if let item = widget.item { + viewModel.sendCommand(item, commandToSend: inputText) + } + } + } + } + .sheet(isPresented: $showSelectionSheet) { + if let widget = selectedWidget { + SelectionView( + labelText: widget.labelText, + mappings: widget.mappingsOrItemOptions, + selectionItemState: widget.item?.state + ) { selectedMappingIndex in + let selectedMapping = widget.mappingsOrItemOptions[selectedMappingIndex] + viewModel.sendCommand(widget.item, commandToSend: selectedMapping.command) + showSelectionSheet = false + } + } + } + } +} diff --git a/openHAB/SwiftUI/SitemapPageView.swift b/openHAB/SwiftUI/SitemapPageView.swift index 1393cfce2..e8b493299 100644 --- a/openHAB/SwiftUI/SitemapPageView.swift +++ b/openHAB/SwiftUI/SitemapPageView.swift @@ -15,56 +15,23 @@ import SwiftUI struct SitemapPageView: View { @StateObject public var viewModel = SitemapPageViewModel() - @State private var showSelectionSheet = false - @State private var showInputAlert = false - @State private var selectedWidget: OpenHABWidget? - @State private var inputText = "" private var isLinkedPage: Bool { viewModel.isLinked } var body: some View { - List { + Group { if viewModel.isLoading, viewModel.relevantWidgets.isEmpty { // Show skeleton/placeholder rows while loading - ForEach(placeholderWidgets, id: \.id) { widget in + List(placeholderWidgets, id: \.id) { widget in RowViewFactory.view(for: widget) .redacted(reason: .placeholder) .disabled(true) } } else { - ForEach(viewModel.relevantWidgets) { widget in - Group { - if let linkedPage = widget.linkedPage { - NavigationLink(destination: SitemapPageView(viewModel: SitemapPageViewModel(pageUrl: linkedPage.link, title: linkedPage.title))) { - RowViewFactory.view(for: widget) - } - .buttonStyle(.plain) - .padding(.vertical, -6) - } else if widget.type == .selection { - Button { - selectedWidget = widget - showSelectionSheet = true - } label: { - RowViewFactory.view(for: widget) - } - .buttonStyle(.plain) - } else if widget.type == .input { - Button { - selectedWidget = widget - showInputAlert = true - } label: { - RowViewFactory.view(for: widget) - } - .buttonStyle(.plain) - } else { - RowViewFactory.view(for: widget) - .onTapGesture { - viewModel.widgetTapped(widget) - } - } - } + List(viewModel.relevantWidgets) { widget in + EmbeddingRowView(widget: widget) } } } @@ -81,32 +48,6 @@ struct SitemapPageView: View { .onChange(of: viewModel.networkTracker.activeConnection) { activeConnection in viewModel.handleActiveConnectionChange(activeConnection) } - .sheet(isPresented: $showSelectionSheet) { - if let widget = selectedWidget { - SelectionView( - labelText: widget.labelText, - mappings: widget.mappingsOrItemOptions, - selectionItemState: widget.item?.state - ) { selectedMappingIndex in - let selectedMapping = widget.mappingsOrItemOptions[selectedMappingIndex] - viewModel.sendCommand(widget.item, commandToSend: selectedMapping.command) - showSelectionSheet = false - } - } - } - .alert("Input", isPresented: $showInputAlert) { - if let widget = selectedWidget { - TextField("Enter value", text: $inputText) - Button("Cancel", role: .cancel) {} - Button("OK") { - // Handle input submission - showInputAlert = false - if let item = widget.item { - viewModel.sendCommand(item, commandToSend: inputText) - } - } - } - } .alert("Error", isPresented: .constant(viewModel.error != nil), actions: { Button("OK", role: .cancel) {} }, message: { From ec148a55fc865b80032900eaf6b327c0edaad0ab Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Tue, 19 Aug 2025 14:49:51 +0200 Subject: [PATCH 330/476] Cleaning up ImageView duplication Signed-off-by: Tim Mueller-Seydlitz --- openHAB/DrawerView.swift | 27 --------------------------- openHAB/SwiftUI/ImageView.swift | 2 +- 2 files changed, 1 insertion(+), 28 deletions(-) diff --git a/openHAB/DrawerView.swift b/openHAB/DrawerView.swift index d1b99a903..47f98e094 100644 --- a/openHAB/DrawerView.swift +++ b/openHAB/DrawerView.swift @@ -30,33 +30,6 @@ enum DrawerViewError: Error, CustomDebugStringConvertible { } } -struct ImageView: View { - let url: String - - @EnvironmentObject var networkTracker: MainActorNetworkTracker - - var body: some View { - if !url.isEmpty { - switch url { - case _ where url.hasPrefix("data:image"): - let provider = Base64ImageDataProvider(base64String: url.deletingPrefix("data:image/png;base64,"), cacheKey: UUID().uuidString) - return KFImage(source: .provider(provider)).resizable() - case _ where url.hasPrefix("http"): - return KFImage(URL(string: url)).resizable() - default: - let builtURL = Endpoint.resource( - openHABRootUrl: networkTracker.activeConnection?.configuration.url ?? "", - path: url.prepare() - ).url - return KFImage(builtURL).resizable() - } - } else { - // This will always fallback to placeholder - return KFImage(URL(string: "bundle://openHABIcon")).placeholder { Image("openHABIcon").resizable() } - } - } -} - // Display the connected URL struct ConnectionView: View { @StateObject private var networkTracker = MainActorNetworkTracker.shared diff --git a/openHAB/SwiftUI/ImageView.swift b/openHAB/SwiftUI/ImageView.swift index 489fc60bb..74637c44f 100644 --- a/openHAB/SwiftUI/ImageView.swift +++ b/openHAB/SwiftUI/ImageView.swift @@ -17,7 +17,7 @@ import SafariServices import SFSafeSymbols import SwiftUI -struct ImageView2: View { +struct ImageView: View { let url: String @EnvironmentObject var networkTracker: MainActorNetworkTracker From d36dc62aaa16eecb7775a10dd647d447aa0f901e Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Fri, 22 Aug 2025 09:23:35 +0200 Subject: [PATCH 331/476] Update openHAB/NoIconDisplayableCell.swift Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Tim Bert <5411131+timbms@users.noreply.github.com> --- openHAB/NoIconDisplayableCell.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/openHAB/NoIconDisplayableCell.swift b/openHAB/NoIconDisplayableCell.swift index 711207f66..6a4a9b460 100644 --- a/openHAB/NoIconDisplayableCell.swift +++ b/openHAB/NoIconDisplayableCell.swift @@ -13,6 +13,10 @@ import OpenHABCore import SwiftUI -// No icon will be displazed for cells that conform to NoIconDisplayableCell protocol +// No icon will be displayed for cells that conform to NoIconDisplayableCell protocol +import OpenHABCore +import SwiftUI + +// No icon will be displayed for cells that conform to NoIconDisplayableCell protocol protocol NoIconDisplayableCell {} From 3d8a1da69f99cb67563d2eaa0c535307a224d87e Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Wed, 17 Sep 2025 08:35:54 +0200 Subject: [PATCH 332/476] Fix compiler bugs with Xcode 26 Signed-off-by: Tim Mueller-Seydlitz --- .../xcshareddata/xcschemes/NotificationService.xcscheme | 1 + .../xcshareddata/xcschemes/openHABIntents.xcscheme | 1 + openHAB/OpenHABRootViewController.swift | 2 +- openHAB/OpenHABViewController.swift | 2 +- 4 files changed, 4 insertions(+), 2 deletions(-) diff --git a/openHAB.xcodeproj/xcshareddata/xcschemes/NotificationService.xcscheme b/openHAB.xcodeproj/xcshareddata/xcschemes/NotificationService.xcscheme index 0a4b77d03..1adbba228 100644 --- a/openHAB.xcodeproj/xcshareddata/xcschemes/NotificationService.xcscheme +++ b/openHAB.xcodeproj/xcshareddata/xcschemes/NotificationService.xcscheme @@ -75,6 +75,7 @@ savedToolIdentifier = "" useCustomWorkingDirectory = "NO" debugDocumentVersioning = "YES" + askForAppToLaunch = "Yes" launchAutomaticallySubstyle = "2"> diff --git a/openHAB.xcodeproj/xcshareddata/xcschemes/openHABIntents.xcscheme b/openHAB.xcodeproj/xcshareddata/xcschemes/openHABIntents.xcscheme index c71b7fac6..6ca9ff665 100644 --- a/openHAB.xcodeproj/xcshareddata/xcschemes/openHABIntents.xcscheme +++ b/openHAB.xcodeproj/xcshareddata/xcschemes/openHABIntents.xcscheme @@ -78,6 +78,7 @@ savedToolIdentifier = "" useCustomWorkingDirectory = "NO" debugDocumentVersioning = "YES" + askForAppToLaunch = "Yes" launchAutomaticallySubstyle = "2"> diff --git a/openHAB/OpenHABRootViewController.swift b/openHAB/OpenHABRootViewController.swift index 8ab3cd284..aeb0309b0 100644 --- a/openHAB/OpenHABRootViewController.swift +++ b/openHAB/OpenHABRootViewController.swift @@ -37,7 +37,7 @@ protocol ModalHandler: AnyObject { private let logger = Logger(subsystem: "org.openhab.UI", category: "OpenHABRootViewController") -class HostingSitemapViewController: UIHostingController, OpenHABViewable { +class HostingSitemapViewController: UIHostingController, @MainActor OpenHABViewable { private let viewModel: SitemapPageViewModel private weak var rootViewController: OpenHABRootViewController? diff --git a/openHAB/OpenHABViewController.swift b/openHAB/OpenHABViewController.swift index fb2114da0..1195d3053 100644 --- a/openHAB/OpenHABViewController.swift +++ b/openHAB/OpenHABViewController.swift @@ -22,7 +22,7 @@ protocol OpenHABViewable: AnyObject { func pushSitemap(name: String, path: String?) async } -class OpenHABViewController: UIViewController, OpenHABViewable { +class OpenHABViewController: UIViewController, @MainActor OpenHABViewable { private let logger = Logger(subsystem: "org.openhab.UI", category: "OpenHABViewController") var trackerCancellables = Set() From adc9dde627d46bad20bfc52bd931b57cdaf4c098 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Wed, 17 Sep 2025 08:39:36 +0200 Subject: [PATCH 333/476] Remove double import Signed-off-by: Tim Mueller-Seydlitz --- openHAB/NoIconDisplayableCell.swift | 6 ------ 1 file changed, 6 deletions(-) diff --git a/openHAB/NoIconDisplayableCell.swift b/openHAB/NoIconDisplayableCell.swift index 6a4a9b460..d19193c67 100644 --- a/openHAB/NoIconDisplayableCell.swift +++ b/openHAB/NoIconDisplayableCell.swift @@ -13,10 +13,4 @@ import OpenHABCore import SwiftUI -// No icon will be displayed for cells that conform to NoIconDisplayableCell protocol -import OpenHABCore -import SwiftUI - -// No icon will be displayed for cells that conform to NoIconDisplayableCell protocol - protocol NoIconDisplayableCell {} From 873b854307588bfd1a527ccdfda845b1a3cd23ff Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Wed, 17 Sep 2025 11:02:24 +0200 Subject: [PATCH 334/476] Set @MainActor on protocol OpenHABViewable Signed-off-by: Tim Mueller-Seydlitz --- openHAB/OpenHABRootViewController.swift | 2 +- openHAB/OpenHABViewController.swift | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/openHAB/OpenHABRootViewController.swift b/openHAB/OpenHABRootViewController.swift index aeb0309b0..8ab3cd284 100644 --- a/openHAB/OpenHABRootViewController.swift +++ b/openHAB/OpenHABRootViewController.swift @@ -37,7 +37,7 @@ protocol ModalHandler: AnyObject { private let logger = Logger(subsystem: "org.openhab.UI", category: "OpenHABRootViewController") -class HostingSitemapViewController: UIHostingController, @MainActor OpenHABViewable { +class HostingSitemapViewController: UIHostingController, OpenHABViewable { private let viewModel: SitemapPageViewModel private weak var rootViewController: OpenHABRootViewController? diff --git a/openHAB/OpenHABViewController.swift b/openHAB/OpenHABViewController.swift index 1195d3053..1cf9b7a42 100644 --- a/openHAB/OpenHABViewController.swift +++ b/openHAB/OpenHABViewController.swift @@ -16,13 +16,14 @@ import SideMenu import SwiftMessages import UIKit +@MainActor protocol OpenHABViewable: AnyObject { func reloadView() func viewName() -> String func pushSitemap(name: String, path: String?) async } -class OpenHABViewController: UIViewController, @MainActor OpenHABViewable { +class OpenHABViewController: UIViewController, OpenHABViewable { private let logger = Logger(subsystem: "org.openhab.UI", category: "OpenHABViewController") var trackerCancellables = Set() From 920a41eb7a819ce9e1726debfae863ff7adff790 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Mon, 20 Oct 2025 16:11:52 +0200 Subject: [PATCH 335/476] Properly using SF Symbols Signed-off-by: Tim Mueller-Seydlitz --- .../Sources/OpenHABCore/Util/OpenHABImageProcessor.swift | 2 +- .../Sources/OpenHABCore/Util/UIColorExtension.swift | 7 +------ openHABWatch/Views/Rows/SegmentRow.swift | 2 +- openHABWatch/Views/Rows/SegmentSelectionView.swift | 6 +++--- 4 files changed, 6 insertions(+), 11 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OpenHABImageProcessor.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABImageProcessor.swift index 291a66509..de6843a3d 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/OpenHABImageProcessor.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABImageProcessor.swift @@ -83,7 +83,7 @@ public struct OpenHABImageProcessor: ImageProcessor { } #endif - // Limit SVG decode size (to prevent memory issues + // Limit SVG decode size (to prevent memory issues) if let image = decodeSVGOnMain(data, targetSize: maxSize, preserveAspectRatio: true) { return image } else { diff --git a/OpenHABCore/Sources/OpenHABCore/Util/UIColorExtension.swift b/OpenHABCore/Sources/OpenHABCore/Util/UIColorExtension.swift index 2185923b1..912131d47 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/UIColorExtension.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/UIColorExtension.swift @@ -217,12 +217,7 @@ public extension UIColor { } // Try hex let hexColor = UIColor(hex: string) - // If hexColor is gray, input was invalid - if hexColor.toHex() == UIColor.gray.toHex() { - self.init(cgColor: UIColor.gray.cgColor) - } else { - self.init(cgColor: hexColor.cgColor) - } + self.init(cgColor: hexColor.cgColor) } convenience init(hex: String) { diff --git a/openHABWatch/Views/Rows/SegmentRow.swift b/openHABWatch/Views/Rows/SegmentRow.swift index 6a1a62e20..04a97fdb5 100644 --- a/openHABWatch/Views/Rows/SegmentRow.swift +++ b/openHABWatch/Views/Rows/SegmentRow.swift @@ -58,7 +58,7 @@ struct SegmentRow: View { Text(widget.mappingsOrItemOptions[selectedIndex].label) .foregroundColor(.secondary) } - Image(systemName: "chevron.right") + Image(systemSymbol: .chevronRight) .foregroundColor(.secondary) .font(.caption) } diff --git a/openHABWatch/Views/Rows/SegmentSelectionView.swift b/openHABWatch/Views/Rows/SegmentSelectionView.swift index e5f7f7c12..d5c5e978f 100644 --- a/openHABWatch/Views/Rows/SegmentSelectionView.swift +++ b/openHABWatch/Views/Rows/SegmentSelectionView.swift @@ -22,16 +22,16 @@ struct SegmentSelectionView: View { ScrollView { LazyVStack(spacing: 12) { ForEach(0 ..< widget.mappingsOrItemOptions.count, id: \.self) { index in - Button(action: { + Button { selectOption(at: index) - }) { + } label: { HStack { Text(widget.mappingsOrItemOptions[index].label) .foregroundColor(.primary) .multilineTextAlignment(.leading) Spacer() if isSelected(index: index) { - Image(systemName: "checkmark") + Image(systemSymbol: .checkmark) .foregroundColor(.accentColor) .font(.caption.weight(.bold)) } From fe52ccdc6bffe9491a69273b8ce4d6c10d5ce6f2 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Mon, 20 Oct 2025 20:33:12 +0200 Subject: [PATCH 336/476] Cleaning up remainder of watch extension Signed-off-by: Tim Mueller-Seydlitz --- openHAB.xcodeproj/project.pbxproj | 4 +- .../Views/Rows/FrameRow.swift | 39 ---- .../Views/Rows/SegmentRow.swift | 63 ------ .../Views/Rows/SliderRow.swift | 67 ------- .../Views/Rows/SwitchRow.swift | 61 ------ .../Views/Utils/IconView.swift | 61 ------ .../openHABWatch Extension/UserData.swift | 188 ------------------ .../openHABWatch.entitlements | 0 8 files changed, 2 insertions(+), 481 deletions(-) delete mode 100644 openHABWatch Extension/Views/Rows/FrameRow.swift delete mode 100644 openHABWatch Extension/Views/Rows/SegmentRow.swift delete mode 100644 openHABWatch Extension/Views/Rows/SliderRow.swift delete mode 100644 openHABWatch Extension/Views/Rows/SwitchRow.swift delete mode 100644 openHABWatch Extension/Views/Utils/IconView.swift delete mode 100644 openHABWatch Extension/openHABWatch Extension/UserData.swift rename openHABWatch Extension/openHABWatch Extension.entitlements => openHABWatch/openHABWatch.entitlements (100%) diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index 97aa6c764..076788103 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -2144,7 +2144,7 @@ CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CODE_SIGN_ENTITLEMENTS = "openHABWatch Extension/openHABWatch Extension.entitlements"; + CODE_SIGN_ENTITLEMENTS = openHABWatch/openHABWatch.entitlements; CURRENT_PROJECT_VERSION = 82; DEBUG_INFORMATION_FORMAT = dwarf; GCC_C_LANGUAGE_STANDARD = "compiler-default"; @@ -2188,7 +2188,7 @@ CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CODE_SIGN_ENTITLEMENTS = "openHABWatch Extension/openHABWatch Extension.entitlements"; + CODE_SIGN_ENTITLEMENTS = openHABWatch/openHABWatch.entitlements; CODE_SIGN_IDENTITY = "Apple Distribution"; CODE_SIGN_STYLE = Manual; COPY_PHASE_STRIP = NO; diff --git a/openHABWatch Extension/Views/Rows/FrameRow.swift b/openHABWatch Extension/Views/Rows/FrameRow.swift deleted file mode 100644 index 601c22495..000000000 --- a/openHABWatch Extension/Views/Rows/FrameRow.swift +++ /dev/null @@ -1,39 +0,0 @@ -// Copyright (c) 2010-2025 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 OpenHABCore -import SwiftUI - -struct FrameRow: View { - @ObservedObject var widget: OpenHABWidget - @ObservedObject var settings = ObservableOpenHABDataObject.shared - var body: some View { - let gray = Color(UIColor.darkGray) - return VStack { - HStack { - Text(widget.labelText?.uppercased() ?? "") - .font(.callout) - .lineLimit(1) - Spacer() - } - } - .background(gray.edgesIgnoringSafeArea(.all)) - // .background(SwiftUI.Color.yellow.edgesIgnoringSafeArea(.all)) - // .background( Color(color:.systemGray)) - } -} - -struct FrameRow_Previews: PreviewProvider { - static var previews: some View { - let widget = UserData().widgets[6] - return FrameRow(widget: widget) - } -} diff --git a/openHABWatch Extension/Views/Rows/SegmentRow.swift b/openHABWatch Extension/Views/Rows/SegmentRow.swift deleted file mode 100644 index 1592c3c67..000000000 --- a/openHABWatch Extension/Views/Rows/SegmentRow.swift +++ /dev/null @@ -1,63 +0,0 @@ -// Copyright (c) 2010-2025 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 OpenHABCore -import os.log -import SwiftUI - -struct SegmentRow: View { - @ObservedObject var widget: OpenHABWidget - @ObservedObject var settings = ObservableOpenHABDataObject.shared - - @State private var favoriteColor = 0 - var body: some View { - let valueBinding = Binding( - get: { - guard case let .segmented(value) = widget.stateEnumBinding else { return 0 } - return value - }, - set: { - os_log("Slider new value = %g", log: .default, type: .info, $0) - // self.widget.sendCommand($0) - widget.stateEnumBinding = .segmented($0) - } - ) - return - VStack { - HStack { - IconView(widget: widget, settings: settings) - TextLabelView(widget: widget) - Spacer() - DetailTextLabelView(widget: widget) - } - Picker("Picker", selection: valueBinding) { - ForEach(0 ..< widget.mappingsOrItemOptions.count, id: \.self) { - Text(widget.mappingsOrItemOptions[$0].label).tag($0) - } - } - .labelsHidden() - .frame(height: 100) - .padding(.top, 0) - } - } -} - -struct SegmentRow_Previews: PreviewProvider { - static var previews: some View { - let widget = UserData().widgets[4] - return Group { - SegmentRow(widget: widget) - .previewLayout(.fixed(width: 300, height: 70)) - SegmentRow(widget: widget) - .previewDevice("Apple Watch Series 4 - 44mm") - } - } -} diff --git a/openHABWatch Extension/Views/Rows/SliderRow.swift b/openHABWatch Extension/Views/Rows/SliderRow.swift deleted file mode 100644 index 55d25932a..000000000 --- a/openHABWatch Extension/Views/Rows/SliderRow.swift +++ /dev/null @@ -1,67 +0,0 @@ -// Copyright (c) 2010-2025 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 OpenHABCore -import os.log -import SwiftUI - -struct SliderRow: View { - @ObservedObject var widget: OpenHABWidget - @ObservedObject var settings = ObservableOpenHABDataObject.shared - - var body: some View { - let valueBinding = Binding( - get: { - widget.adjustedValue - }, - set: { - os_log("Slider new value = %g", log: .default, type: .info, $0) - widget.sendCommand($0.valueText(step: widget.step)) - - // self.widget.stateEnumBinding = .slider($0) - } - ) - - return - VStack(spacing: 3) { - HStack { - IconView(widget: widget, settings: settings) - TextLabelView(widget: widget) - Spacer() - DetailTextLabelView(widget: widget) - }.padding(.top, 8) - - Slider(value: valueBinding, in: widget.minValue ... widget.maxValue, step: widget.step) - .labelsHidden() - .focusable(true) - .digitalCrownRotation( - valueBinding, - from: widget.minValue, - through: widget.maxValue, - by: widget.step, - sensitivity: .medium, - isHapticFeedbackEnabled: true - ) - } - } -} - -struct SliderRow_Previews: PreviewProvider { - static var previews: some View { - let widget = UserData().widgets[3] - return Group { - SliderRow(widget: widget) - .previewLayout(.fixed(width: 300, height: 70)) - SliderRow(widget: widget) - .previewDevice("Apple Watch Series 4 - 44mm") - } - } -} diff --git a/openHABWatch Extension/Views/Rows/SwitchRow.swift b/openHABWatch Extension/Views/Rows/SwitchRow.swift deleted file mode 100644 index 4e6025acc..000000000 --- a/openHABWatch Extension/Views/Rows/SwitchRow.swift +++ /dev/null @@ -1,61 +0,0 @@ -// Copyright (c) 2010-2025 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 Kingfisher -import OpenHABCore -import os.log -import SwiftUI - -struct SwitchRow: View { - @ObservedObject var widget: OpenHABWidget - @ObservedObject var settings = ObservableOpenHABDataObject.shared - - var body: some View { - // https://stackoverflow.com/questions/59395501/do-something-when-toggle-state-changes - let stateBinding = Binding( - get: { - widget.stateEnumBinding.boolState - }, - set: { - if $0 { - os_log("Switch to ON", log: .viewCycle, type: .info) - widget.sendCommand("ON") - } else { - os_log("Switch to OFF", log: .viewCycle, type: .info) - widget.sendCommand("OFF") - } - widget.stateEnumBinding = .switcher($0) - } - ) - - return - Toggle(isOn: stateBinding) { - HStack { - IconView(widget: widget, settings: settings) - VStack { - TextLabelView(widget: widget) - DetailTextLabelView(widget: widget) - } - } - } - .focusable(true) - .padding(.trailing) - .cornerRadius(5) - } -} - -struct SwitchRow_Previews: PreviewProvider { - static var previews: some View { - let widget = UserData().widgets[2] - return SwitchRow(widget: widget) - .previewLayout(.fixed(width: 300, height: 70)) - } -} diff --git a/openHABWatch Extension/Views/Utils/IconView.swift b/openHABWatch Extension/Views/Utils/IconView.swift deleted file mode 100644 index cd2bb95a4..000000000 --- a/openHABWatch Extension/Views/Utils/IconView.swift +++ /dev/null @@ -1,61 +0,0 @@ -// Copyright (c) 2010-2025 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 Kingfisher -import OpenHABCore -import os.log -import SwiftUI - -struct IconView: View { - @ObservedObject var widget: OpenHABWidget - @ObservedObject var settings = ObservableOpenHABDataObject.shared - - var iconURL: URL? { - Endpoint.icon( - rootUrl: settings.openHABRootUrl, - version: 2, - icon: widget.icon, - state: widget.item?.state ?? "", - iconType: .png, - iconColor: "" - ).url - } - - var body: some View { - let image = iconURL != nil ? KFImage(source: .network(KF.ImageResource( - downloadURL: iconURL!, - cacheKey: iconURL!.path + (iconURL!.query ?? "") - ))) : KFImage(iconURL) - return image - .onSuccess { retrieveImageResult in - os_log("Success loading icon: %{PUBLIC}s", log: .notifications, type: .debug, "\(retrieveImageResult)") - } - .onFailure { kingfisherError in - os_log("Failure loading icon: %{PUBLIC}s", log: .notifications, type: .debug, kingfisherError.localizedDescription) - } - .placeholder { - Image(systemSymbol: .arrowTriangle2CirclepathCircle) - .font(.callout) - .opacity(0.3) - } - .cancelOnDisappear(true) - .resizable() - .scaledToFit() - .frame(width: 20.0, height: 20.0) - } -} - -struct IconView_Previews: PreviewProvider { - static var previews: some View { - let widget = UserData().widgets[3] - return IconView(widget: widget, settings: ObservableOpenHABDataObject(openHABRootUrl: PreviewConstants.remoteURLString)) - } -} diff --git a/openHABWatch Extension/openHABWatch Extension/UserData.swift b/openHABWatch Extension/openHABWatch Extension/UserData.swift deleted file mode 100644 index a6d1f6d12..000000000 --- a/openHABWatch Extension/openHABWatch Extension/UserData.swift +++ /dev/null @@ -1,188 +0,0 @@ -// Copyright (c) 2010-2025 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 SwiftUI - -final class UserData: ObservableObject { - @Published var widgets: [OpenHABWidget] = [] - @Published var showAlert = false - @Published var errorDescription = "" - @Published var showCertificateAlert = false - @Published var certificateErrorDescription = "" - - let decoder = JSONDecoder() - - var openHABSitemapPage: OpenHABPage? - - private var commandOperation: Alamofire.Request? - private var currentPageOperation: Alamofire.Request? - private var tracker: OpenHABWatchTracker? - private var dataObjectCancellable: AnyCancellable? - - // Demo - init() { - decoder.dateDecodingStrategy = .formatted(DateFormatter.iso8601Full) - - let data = PreviewConstants.sitemapJson - - do { - // Self-executing closure - // Inspired by https://www.swiftbysundell.com/posts/inline-types-and-functions-in-swift - openHABSitemapPage = try { - let sitemapPageCodingData = try data.decoded(as: OpenHABPage.CodingData.self) - return sitemapPageCodingData.openHABSitemapPage - }() - } catch { - os_log("Should not throw %{PUBLIC}@", log: OSLog.remoteAccess, type: .error, error.localizedDescription) - } - - widgets = openHABSitemapPage?.widgets ?? [] - - openHABSitemapPage?.sendCommand = { [weak self] item, command in - self?.sendCommand(item, commandToSend: command) - } - } - - init(url: URL?, refresh: Bool = true) { - loadPage( - url: url, - longPolling: true, - refresh: refresh - ) - } - - init(url: URL?) { - tracker = OpenHABWatchTracker() - tracker?.delegate = self - tracker?.trackedUrl(url) - -// dataObjectCancellable = ObservableOpenHABDataObject.shared.objectRefreshed.sink { _ in -// // New settings updates from the phone app to start a reconnect -// os_log("Settings update received, starting reconnect", log: .remoteAccess, type: .info) -// self.refreshUrl() -// } -// refreshUrl() - } - - init(sitemapName: String = "watch") { - tracker = OpenHABWatchTracker() - tracker?.delegate = self - tracker?.start() - - dataObjectCancellable = ObservableOpenHABDataObject.shared.objectRefreshed.sink { _ in - // New settings updates from the phone app to start a reconnect - os_log("Settings update received, starting reconnect", log: .remoteAccess, type: .info) - self.refreshUrl() - } - refreshUrl() - } - - func loadPage(url: URL?, - longPolling: Bool, - refresh: Bool) { - if currentPageOperation != nil { - currentPageOperation?.cancel() - currentPageOperation = nil - } - - currentPageOperation = NetworkConnection.page( - url: url, - longPolling: longPolling - ) { [weak self] response in - guard let self else { return } - - switch response.result { - case let .success(data): - os_log("Page loaded with success", log: OSLog.remoteAccess, type: .info) - do { - // Self-executing closure - // Inspired by https://www.swiftbysundell.com/posts/inline-types-and-functions-in-swift - openHABSitemapPage = try { - let sitemapPageCodingData = try data.decoded(as: OpenHABPage.CodingData.self) - return sitemapPageCodingData.openHABSitemapPage - }() - } catch { - os_log("Should not throw %{PUBLIC}@", log: OSLog.remoteAccess, type: .error, error.localizedDescription) - } - - openHABSitemapPage?.sendCommand = { [weak self] item, command in - self?.sendCommand(item, commandToSend: command) - } - - widgets = openHABSitemapPage?.widgets ?? [] - - showAlert = widgets.isEmpty ? true : false - if refresh { loadPage( - url: url, - longPolling: true, - refresh: true - ) } - - case let .failure(error): - os_log("On LoadPage %{PUBLIC}@ code: %d ", log: .remoteAccess, type: .error, error.localizedDescription, response.response?.statusCode ?? 0) - errorDescription = error.localizedDescription - widgets = [] - showAlert = true - } - } - currentPageOperation?.resume() - } - - func sendCommand(_ item: OpenHABItem?, commandToSend command: String?) { - if commandOperation != nil { - commandOperation?.cancel() - commandOperation = nil - } - if let item, let command { - commandOperation = NetworkConnection.sendCommand(item: item, commandToSend: command) - commandOperation?.resume() - } - } - - func refreshUrl() { - if ObservableOpenHABDataObject.shared.haveReceivedAppContext { - showAlert = false - tracker?.selectUrl() - } - } -} - -extension UserData: OpenHABWatchTrackerDelegate { - func openHABTracked(_ openHABUrl: URL?) { - guard let urlString = openHABUrl?.absoluteString else { return } - os_log("openHABTracked: %{PUBLIC}@", log: .remoteAccess, type: .error, urlString) - - if !ObservableOpenHABDataObject.shared.haveReceivedAppContext { - AppMessageService.singleton.requestApplicationContext() - errorDescription = NSLocalizedString("settings_not_received", comment: "") - showAlert = true - return - } - - ObservableOpenHABDataObject.shared.openHABRootUrl = urlString - - let url = Endpoint.watchSitemap(openHABRootUrl: urlString, sitemapName: ObservableOpenHABDataObject.shared.sitemapName).url - loadPage(url: url, longPolling: false, refresh: true) - } - - func openHABTrackingProgress(_ message: String?) { - os_log("openHABTrackingProgress: %{PUBLIC}@", log: .remoteAccess, type: .error, message ?? "") - } - - func openHABTrackingError(_ error: Error) { - os_log("openHABTrackingError: %{PUBLIC}@", log: .remoteAccess, type: .error, error.localizedDescription) - } -} diff --git a/openHABWatch Extension/openHABWatch Extension.entitlements b/openHABWatch/openHABWatch.entitlements similarity index 100% rename from openHABWatch Extension/openHABWatch Extension.entitlements rename to openHABWatch/openHABWatch.entitlements From eb5a768da1fe077f920d2d1267f0db1ad56d1d8e Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Sun, 26 Oct 2025 16:19:51 +0100 Subject: [PATCH 337/476] Unblock after merging develop Signed-off-by: Tim Mueller-Seydlitz --- openHAB/OpenHABRootViewController.swift | 39 +++++++++++++++++++++++-- openHAB/OpenHABViewController.swift | 14 ++++++++- 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/openHAB/OpenHABRootViewController.swift b/openHAB/OpenHABRootViewController.swift index 78ae0afa5..29921ed34 100644 --- a/openHAB/OpenHABRootViewController.swift +++ b/openHAB/OpenHABRootViewController.swift @@ -84,6 +84,17 @@ class HostingSitemapViewController: UIHostingController, // Implement pushing logic into SitemapPageViewModel await viewModel.pushSitemap(name: name, path: path) } + + // swiftlint:disable:next function_parameter_count + func showPopupMessage(seconds: Double, + title: String, + message: String, + theme: Theme, + viewTapAction: (() -> Void)?, + buttonTitle: String, + buttonAction: (() -> Void)?) {} + + func hidePopupMessages() {} } // MARK: - Search Controller Delegates @@ -218,19 +229,41 @@ class OpenHABRootViewController: UIViewController { let retryButtonTitle: String = NSLocalizedString("retry", comment: "retry connection") switch status { case .started: - currentView.showPopupMessage(seconds: -1, title: NSLocalizedString("no_connection_will_reconnect", comment: ""), message: "", theme: .warning, buttonTitle: retryButtonTitle) { + currentView.showPopupMessage( + seconds: -1, + title: NSLocalizedString("no_connection_will_reconnect", comment: ""), + message: "", + theme: .warning, + viewTapAction: nil, + buttonTitle: retryButtonTitle + ) { Task { await NetworkTracker.shared.restartTracking() } } case .connecting: - currentView.showPopupMessage(seconds: 60, title: NSLocalizedString("connecting", comment: ""), message: "", theme: .info) + currentView.showPopupMessage( + seconds: 60, + title: NSLocalizedString("connecting", comment: ""), + message: "", + theme: .info, + viewTapAction: nil, + buttonTitle: "", + buttonAction: nil + ) case .connected: currentView.hidePopupMessages() case .stopped: let error: String = NSLocalizedString("error", comment: "") let no_network: String = NSLocalizedString("network_not_available", comment: "") - currentView.showPopupMessage(seconds: -1, title: error, message: no_network, theme: .error, buttonTitle: retryButtonTitle) { + currentView.showPopupMessage( + seconds: -1, + title: error, + message: no_network, + theme: .error, + viewTapAction: nil, + buttonTitle: retryButtonTitle + ) { Task { await NetworkTracker.shared.restartTracking() } diff --git a/openHAB/OpenHABViewController.swift b/openHAB/OpenHABViewController.swift index 3e5e8e790..b167dc939 100644 --- a/openHAB/OpenHABViewController.swift +++ b/openHAB/OpenHABViewController.swift @@ -20,6 +20,15 @@ import UIKit protocol OpenHABViewable: AnyObject { func reloadView() func viewName() -> String + // swiftlint:disable:next function_parameter_count + func showPopupMessage(seconds: Double, + title: String, + message: String, + theme: Theme, + viewTapAction: (() -> Void)?, + buttonTitle: String, + buttonAction: (() -> Void)?) + func hidePopupMessages() } class OpenHABViewController: UIViewController, OpenHABViewable { @@ -37,7 +46,10 @@ class OpenHABViewController: UIViewController, OpenHABViewable { CertificateManagers.serverCertificateManager.delegate = self } - func showPopupMessage(seconds: Double, title: String, message: String, theme: Theme, + func showPopupMessage(seconds: Double, + title: String, + message: String, + theme: Theme, viewTapAction: (() -> Void)? = nil, buttonTitle: String = NSLocalizedString("dismiss", comment: ""), buttonAction: (() -> Void)? = nil) { From 86ef5c31d32c4c266db7293a700af9e01a86bee8 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Mon, 27 Oct 2025 17:59:57 +0100 Subject: [PATCH 338/476] Reduce row spacing in SitemapPageView Move LogsViewer to CommonUI package Signed-off-by: Tim Mueller-Seydlitz --- .../Views => CommonUI/Sources/CommonUI}/LogsViewer.swift | 6 ++++-- openHAB.xcodeproj/project.pbxproj | 4 ---- openHAB/SwiftUI/SitemapPageView.swift | 1 + openHABWatch/OpenHABWatch.swift | 1 + 4 files changed, 6 insertions(+), 6 deletions(-) rename {openHABWatch/Views => CommonUI/Sources/CommonUI}/LogsViewer.swift (97%) diff --git a/openHABWatch/Views/LogsViewer.swift b/CommonUI/Sources/CommonUI/LogsViewer.swift similarity index 97% rename from openHABWatch/Views/LogsViewer.swift rename to CommonUI/Sources/CommonUI/LogsViewer.swift index 6c8d56176..d18f8ac99 100644 --- a/openHABWatch/Views/LogsViewer.swift +++ b/CommonUI/Sources/CommonUI/LogsViewer.swift @@ -15,17 +15,19 @@ import SwiftUI // Thanks to https://useyourloaf.com/blog/fetching-oslog-messages-in-swift/ -struct LogsViewer: View { +public struct LogsViewer: View { private static let template = NSPredicate(format: "(subsystem BEGINSWITH $PREFIX)") @State private var text = "Loading..." + public init() {} + let myFont = Font .system(size: 10) .monospaced() - var body: some View { + public var body: some View { ScrollView { Text(text) .font(myFont) diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index 4bd9afac7..5623c9e6b 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -84,7 +84,6 @@ DA0F37D023D4ACC7007EAB48 /* SliderRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA0F37CF23D4ACC7007EAB48 /* SliderRow.swift */; }; DA10161B2DC7BAE500552D14 /* SFSafeSymbols in Frameworks */ = {isa = PBXBuildFile; productRef = DA10161A2DC7BAE500552D14 /* SFSafeSymbols */; }; DA15BFBD23C6726400BD8ADA /* AppSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA15BFBC23C6726400BD8ADA /* AppSettings.swift */; }; - DA162BEC2CD3B53E0040DAE5 /* LogsViewer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA162BEB2CD3B53E0040DAE5 /* LogsViewer.swift */; }; DA19E25B22FD801D002F8F2F /* OpenHABGeneralTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA19E25A22FD801D002F8F2F /* OpenHABGeneralTests.swift */; }; DA21EAE22339621C001AB415 /* Throttler.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA21EAE12339621C001AB415 /* Throttler.swift */; }; DA2741002EA62F1F002FE576 /* SitemapPageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2740FF2EA62F1F002FE576 /* SitemapPageView.swift */; }; @@ -397,7 +396,6 @@ DA0DA9E12E0C9B74000C5D0A /* BuildTools */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = BuildTools; sourceTree = ""; }; DA0F37CF23D4ACC7007EAB48 /* SliderRow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SliderRow.swift; sourceTree = ""; }; DA15BFBC23C6726400BD8ADA /* AppSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettings.swift; sourceTree = ""; }; - DA162BEB2CD3B53E0040DAE5 /* LogsViewer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogsViewer.swift; sourceTree = ""; }; DA19E25A22FD801D002F8F2F /* OpenHABGeneralTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenHABGeneralTests.swift; sourceTree = ""; }; DA1C2E4B230DC28F00FACFB0 /* Appfile */ = {isa = PBXFileReference; lastKnownFileType = text; path = Appfile; sourceTree = ""; }; DA1C2E4C230DC28F00FACFB0 /* SnapshotHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnapshotHelper.swift; sourceTree = ""; }; @@ -913,7 +911,6 @@ isa = PBXGroup; children = ( DA2740FF2EA62F1F002FE576 /* SitemapPageView.swift */, - DA162BEB2CD3B53E0040DAE5 /* LogsViewer.swift */, DAC6608C236F771600F4501E /* PreferencesSwiftUIView.swift */, DAF457A323DB7A820018B495 /* Rows */, DAF457A723DBA2C40018B495 /* Utils */, @@ -1655,7 +1652,6 @@ DA32D1B42C8C98C40018D974 /* IconWithAction.swift in Sources */, DA07764A234683BC0086C685 /* SwitchRow.swift in Sources */, DA2E0AA423DC96E9009B0A99 /* ImageWithAction.swift in Sources */, - DA162BEC2CD3B53E0040DAE5 /* LogsViewer.swift in Sources */, DAF4578723D798A50018B495 /* TextLabelView.swift in Sources */, DA0749DE23E0B5950057FA83 /* ColorPickerRow.swift in Sources */, DAF4581E23DC60020018B495 /* ImageRawRow.swift in Sources */, diff --git a/openHAB/SwiftUI/SitemapPageView.swift b/openHAB/SwiftUI/SitemapPageView.swift index e8b493299..b5e0e2408 100644 --- a/openHAB/SwiftUI/SitemapPageView.swift +++ b/openHAB/SwiftUI/SitemapPageView.swift @@ -37,6 +37,7 @@ struct SitemapPageView: View { } .environmentObject(viewModel) .listStyle(.plain) + .listRowSpacing(-8) .navigationTitle(viewModel.pageTitle) .navigationBarTitleDisplayMode(.inline) .refreshable { diff --git a/openHABWatch/OpenHABWatch.swift b/openHABWatch/OpenHABWatch.swift index a1b5f3ab4..a7e0dd886 100644 --- a/openHABWatch/OpenHABWatch.swift +++ b/openHABWatch/OpenHABWatch.swift @@ -9,6 +9,7 @@ // // SPDX-License-Identifier: EPL-2.0 +import CommonUI import OpenHABCore import SFSafeSymbols import SwiftUI From 0fa43b7e99c94fe1d84e871a386bc82500fd2e4b Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Wed, 5 Nov 2025 17:15:37 +0100 Subject: [PATCH 339/476] Cleanup Signed-off-by: Tim Mueller-Seydlitz --- CommonUI/Sources/CommonUI/LogsViewer.swift | 4 ++-- .../Tests/OpenHABCoreTests/CertificateStoreTests.swift | 2 +- openHAB.xcodeproj/project.pbxproj | 5 +---- .../xcshareddata/xcschemes/NotificationService.xcscheme | 2 +- openHAB.xcodeproj/xcshareddata/xcschemes/openHAB.xcscheme | 2 +- .../xcshareddata/xcschemes/openHABIntents.xcscheme | 2 +- .../xcshareddata/xcschemes/openHABTestsSwift.xcscheme | 2 +- .../xcshareddata/xcschemes/openHABUITests.xcscheme | 2 +- .../xcschemes/openHABWatch (Notification).xcscheme | 2 +- .../xcshareddata/xcschemes/openHABWatch.xcscheme | 2 +- .../xcschemes/openHABWatchSwift (Complication).xcscheme | 2 +- 11 files changed, 12 insertions(+), 15 deletions(-) diff --git a/CommonUI/Sources/CommonUI/LogsViewer.swift b/CommonUI/Sources/CommonUI/LogsViewer.swift index d18f8ac99..367c4284c 100644 --- a/CommonUI/Sources/CommonUI/LogsViewer.swift +++ b/CommonUI/Sources/CommonUI/LogsViewer.swift @@ -21,8 +21,6 @@ public struct LogsViewer: View { @State private var text = "Loading..." - public init() {} - let myFont = Font .system(size: 10) .monospaced() @@ -38,6 +36,8 @@ public struct LogsViewer: View { } } + public init() {} + private func fetchLogs() async -> String { let calendar = Calendar.current guard let dayAgo = calendar.date( diff --git a/OpenHABCore/Tests/OpenHABCoreTests/CertificateStoreTests.swift b/OpenHABCore/Tests/OpenHABCoreTests/CertificateStoreTests.swift index 79bffdb08..7cc3897b0 100644 --- a/OpenHABCore/Tests/OpenHABCoreTests/CertificateStoreTests.swift +++ b/OpenHABCore/Tests/OpenHABCoreTests/CertificateStoreTests.swift @@ -122,7 +122,7 @@ struct CertificateStoreTests { await store.removeCertificate(forDomain: domainB) } - /// TODO Find solution + //// TODO Find solution // @Test("Persistence across instances") // func persistenceAcrossInstances() async throws { // let domain = "persistence-test.openhab.org" diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index d0baebd8e..bc4bee702 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -651,9 +651,6 @@ 93F8064A27AE7A2E0035A6B0 /* FlexColorPicker in Frameworks */, DA28C362225241DE00AB409C /* WebKit.framework in Frameworks */, DAC949FA2E219F0D007E67B7 /* CommonUI in Frameworks */, - DA0245AF2E79E10A000B7883 /* SDWebImage in Frameworks */, - 93F8064A27AE7A2E0035A6B0 /* FlexColorPicker in Frameworks */, - DA28C362225241DE00AB409C /* WebKit.framework in Frameworks */, 93F8065027AE7A830035A6B0 /* SideMenu in Frameworks */, DFE10414197415F900D94943 /* Security.framework in Frameworks */, 93F8064727AE7A050035A6B0 /* SwiftMessages in Frameworks */, @@ -1412,7 +1409,7 @@ BuildIndependentTargetsInParallel = YES; CLASSPREFIX = OpenHAB; LastSwiftUpdateCheck = 1540; - LastUpgradeCheck = 2600; + LastUpgradeCheck = 2610; ORGANIZATIONNAME = "openHAB e.V."; TargetAttributes = { 4D6470D22561F935007B03FC = { diff --git a/openHAB.xcodeproj/xcshareddata/xcschemes/NotificationService.xcscheme b/openHAB.xcodeproj/xcshareddata/xcschemes/NotificationService.xcscheme index 669ac71d2..75fa51411 100644 --- a/openHAB.xcodeproj/xcshareddata/xcschemes/NotificationService.xcscheme +++ b/openHAB.xcodeproj/xcshareddata/xcschemes/NotificationService.xcscheme @@ -1,6 +1,6 @@ Date: Sun, 9 Nov 2025 08:46:22 +0100 Subject: [PATCH 340/476] Enable self-signed certificates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tim Müller-Seydlitz --- openHAB/OpenHABRootViewController.swift | 17 +++++++++++++++++ openHAB/SwiftUI/Rows/VideoRowView.swift | 2 +- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/openHAB/OpenHABRootViewController.swift b/openHAB/OpenHABRootViewController.swift index 39bbdd2d2..a5aeaf32f 100644 --- a/openHAB/OpenHABRootViewController.swift +++ b/openHAB/OpenHABRootViewController.swift @@ -13,6 +13,7 @@ import AVFoundation import Combine import FirebaseCrashlytics import Foundation +import Kingfisher import OpenHABCore import os.log import SafariServices @@ -180,6 +181,7 @@ class OpenHABRootViewController: UIViewController { switchToSavedView() isDemoMode = Preferences.shared.currentHomePreferences.demomode } + ImageDownloader.default.authenticationChallengeResponder = self } private func startSSEListening() { @@ -991,3 +993,18 @@ extension OpenHABRootViewController: ModalHandler { } } } + +// MARK: Kingfisher authentication with URLCredential + +extension OpenHABRootViewController: AuthenticationChallengeResponsible { + func downloader(_ downloader: ImageDownloader, + didReceive challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { + await onReceiveSessionChallenge(with: challenge) + } + + func downloader(_ downloader: ImageDownloader, + task: URLSessionTask, + didReceive challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { + await onReceiveSessionTaskChallenge(with: challenge) + } +} diff --git a/openHAB/SwiftUI/Rows/VideoRowView.swift b/openHAB/SwiftUI/Rows/VideoRowView.swift index 88f08ce96..f57257209 100644 --- a/openHAB/SwiftUI/Rows/VideoRowView.swift +++ b/openHAB/SwiftUI/Rows/VideoRowView.swift @@ -36,7 +36,7 @@ struct VideoRowView: View { .frame(height: 200) .cornerRadius(8) .onAppear { - player = AVPlayer(url: videoURL) + player = AVPlayer(url: videoURL) } .onDisappear { player?.pause() From 484ae20b3efe6cb19e1926b90de691a9ede43351 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20M=C3=BCller-Seydlitz?= Date: Sun, 9 Nov 2025 21:59:52 +0100 Subject: [PATCH 341/476] SegmentedRow: add widget.labelValue, adjust row heigh SelectionRow: Align Picker of style .menu with label EmbeddingRowView: Align row height to UIKit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tim Müller-Seydlitz --- openHAB/SwiftUI/EmbeddingRowView.swift | 3 +- openHAB/SwiftUI/Rows/SegmentedRowView.swift | 33 ++++++++++++++------- openHAB/SwiftUI/Rows/SelectionRowView.swift | 1 + openHAB/SwiftUI/Rows/VideoRowView.swift | 2 +- openHAB/SwiftUI/SitemapPageView.swift | 5 ++-- 5 files changed, 28 insertions(+), 16 deletions(-) diff --git a/openHAB/SwiftUI/EmbeddingRowView.swift b/openHAB/SwiftUI/EmbeddingRowView.swift index 5ea4a32e5..466ce81e8 100644 --- a/openHAB/SwiftUI/EmbeddingRowView.swift +++ b/openHAB/SwiftUI/EmbeddingRowView.swift @@ -27,7 +27,6 @@ struct EmbeddingRowView: View { RowViewFactory.view(for: widget) } .buttonStyle(.plain) - .padding(.vertical, -6) } else if widget.type == .selection { Button { selectedWidget = widget @@ -48,7 +47,7 @@ struct EmbeddingRowView: View { RowViewFactory.view(for: widget) } } - + .padding(.vertical, -8) .alert("Input", isPresented: $showInputAlert) { if let widget = selectedWidget { TextField("Enter value", text: $inputText) diff --git a/openHAB/SwiftUI/Rows/SegmentedRowView.swift b/openHAB/SwiftUI/Rows/SegmentedRowView.swift index e13e4f3db..1ce4947c3 100644 --- a/openHAB/SwiftUI/Rows/SegmentedRowView.swift +++ b/openHAB/SwiftUI/Rows/SegmentedRowView.swift @@ -42,6 +42,12 @@ struct SegmentedRowView: View { } Spacer() + if let detailTextLabel = widget.labelValue, !detailTextLabel.isEmpty { + Text(detailTextLabel) + .foregroundColor(widget.valuecolor.isEmpty ? Color(uiColor: UIColor.ohSecondaryLabel) : Color(fromString: widget.valuecolor)) + .lineLimit(1) + } + if !mappings.isEmpty { if isMomentary { HStack { @@ -56,20 +62,25 @@ struct SegmentedRowView: View { } } } else { - Picker("", selection: Binding( - get: { selectedIndex ?? -1 }, - set: { newIndex in - selectedIndex = newIndex - if let mapping = mappings[safe: newIndex] { - viewModel.sendCommand(widget.item, commandToSend: mapping.command) + HStack { + Picker("", selection: Binding( + get: { selectedIndex ?? -1 }, + set: { newIndex in + selectedIndex = newIndex + if let mapping = mappings[safe: newIndex] { + viewModel.sendCommand(widget.item, commandToSend: mapping.command) + } + } + )) { + ForEach(mappings.indices, id: \.self) { index in + Text(mappings[index].label).tag(index) } } - )) { - ForEach(mappings.indices, id: \.self) { index in - Text(mappings[index].label).tag(index) - } + .padding(.bottom, -8) + .padding(.top, -8) + .pickerStyle(.segmented) + .controlSize(.small) } - .pickerStyle(.segmented) } } } diff --git a/openHAB/SwiftUI/Rows/SelectionRowView.swift b/openHAB/SwiftUI/Rows/SelectionRowView.swift index 39b3eca33..c3d1004d5 100644 --- a/openHAB/SwiftUI/Rows/SelectionRowView.swift +++ b/openHAB/SwiftUI/Rows/SelectionRowView.swift @@ -47,6 +47,7 @@ struct SelectionRowView: View { } } .pickerStyle(.menu) + .padding(.bottom, -4) .frame(height: pickerHeight) // 👈 Restrict height of the Picker .onChange(of: selectedIndex) { newIndex in guard let mapping = mappings[safe: newIndex] else { return } diff --git a/openHAB/SwiftUI/Rows/VideoRowView.swift b/openHAB/SwiftUI/Rows/VideoRowView.swift index f57257209..88f08ce96 100644 --- a/openHAB/SwiftUI/Rows/VideoRowView.swift +++ b/openHAB/SwiftUI/Rows/VideoRowView.swift @@ -36,7 +36,7 @@ struct VideoRowView: View { .frame(height: 200) .cornerRadius(8) .onAppear { - player = AVPlayer(url: videoURL) + player = AVPlayer(url: videoURL) } .onDisappear { player?.pause() diff --git a/openHAB/SwiftUI/SitemapPageView.swift b/openHAB/SwiftUI/SitemapPageView.swift index b5e0e2408..77f1bd0ff 100644 --- a/openHAB/SwiftUI/SitemapPageView.swift +++ b/openHAB/SwiftUI/SitemapPageView.swift @@ -29,15 +29,16 @@ struct SitemapPageView: View { .redacted(reason: .placeholder) .disabled(true) } + .environment(\.defaultMinListRowHeight, 30) } else { List(viewModel.relevantWidgets) { widget in EmbeddingRowView(widget: widget) } + .environment(\.defaultMinListRowHeight, 30) } } .environmentObject(viewModel) - .listStyle(.plain) - .listRowSpacing(-8) + .listStyle(.inset) .navigationTitle(viewModel.pageTitle) .navigationBarTitleDisplayMode(.inline) .refreshable { From e3f0f73deb2ebc133c18659d09e68d99a3431da4 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Sun, 16 Nov 2025 17:21:56 +0100 Subject: [PATCH 342/476] Port mjpeg handling to SwiftUI Signed-off-by: Tim Mueller-Seydlitz --- .../xcshareddata/swiftpm/Package.resolved | 8 +- openHAB/SettingsView/DebugSettingsView.swift | 3 +- openHAB/SwiftUI/Rows/VideoRowView.swift | 175 +++++++++++++++++- 3 files changed, 174 insertions(+), 12 deletions(-) diff --git a/openHAB.xcworkspace/xcshareddata/swiftpm/Package.resolved b/openHAB.xcworkspace/xcshareddata/swiftpm/Package.resolved index 2793c4df6..1ff56a806 100644 --- a/openHAB.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/openHAB.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -32,8 +32,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/firebase/firebase-ios-sdk.git", "state" : { - "revision" : "793b67f4652e1a39d03fab6650033768afe6d15e", - "version" : "12.5.0" + "revision" : "087bb95235f676c1a37e928769a5b6645dcbd325", + "version" : "12.6.0" } }, { @@ -203,8 +203,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-numerics", "state" : { - "revision" : "e0ec0f5f3af6f3e4d5e7a19d2af26b481acb6ba8", - "version" : "1.0.3" + "revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2", + "version" : "1.1.1" } }, { diff --git a/openHAB/SettingsView/DebugSettingsView.swift b/openHAB/SettingsView/DebugSettingsView.swift index 8deb79691..3377ea440 100644 --- a/openHAB/SettingsView/DebugSettingsView.swift +++ b/openHAB/SettingsView/DebugSettingsView.swift @@ -10,6 +10,7 @@ // SPDX-License-Identifier: EPL-2.0 import Combine +import CommonUI import OpenHABCore import os.log import SafariServices @@ -57,7 +58,7 @@ struct DebugSettingsView: View { } Section(header: Text(LocalizedStringKey("debug"))) { NavigationLink { - LoggerView() + LogsViewer() } label: { Text("Logs") } diff --git a/openHAB/SwiftUI/Rows/VideoRowView.swift b/openHAB/SwiftUI/Rows/VideoRowView.swift index 88f08ce96..4b3381ae3 100644 --- a/openHAB/SwiftUI/Rows/VideoRowView.swift +++ b/openHAB/SwiftUI/Rows/VideoRowView.swift @@ -13,10 +13,18 @@ import AVKit import CommonUI import OpenHABCore import SwiftUI +import UIKit struct VideoRowView: View { @ObservedObject var widget: OpenHABWidget @State private var player: AVPlayer? + @State private var mjpegPlayer: SimpleMJPEGPlayer? + @State private var mjpegImage: UIImage? + @State private var aspectRatio: CGFloat = 16.0 / 9.0 + @State private var isLoading = false + @State private var currentStreamUrl: URL? + @State private var imageObservationTimer: Timer? + @State private var playerObserver: NSKeyValueObservation? @EnvironmentObject var viewModel: SitemapPageViewModel private var videoURL: URL? { @@ -24,6 +32,10 @@ struct VideoRowView: View { return URL(string: widget.url) } + private var isMJPEG: Bool { + widget.encoding.lowercased() == VideoEncoding.mjpeg.rawValue + } + var body: some View { VStack(alignment: .leading, spacing: 8) { if let labelText = widget.labelText, !labelText.isEmpty, widget.labelSource == .sitemapDefinition { @@ -32,15 +44,49 @@ struct VideoRowView: View { } if let videoURL { - VideoPlayer(player: player) - .frame(height: 200) - .cornerRadius(8) - .onAppear { - player = AVPlayer(url: videoURL) + ZStack { + if isMJPEG { + // MJPEG display using UIImageView + if let mjpegImage { + Image(uiImage: mjpegImage) + .resizable() + .aspectRatio(aspectRatio, contentMode: .fit) + .frame(height: 200) + .cornerRadius(8) + } else { + Rectangle() + .fill(Color.gray.opacity(0.3)) + .frame(height: 200) + .aspectRatio(aspectRatio, contentMode: .fit) + .cornerRadius(8) + } + } else { + // HLS/other video formats using VideoPlayer + VideoPlayer(player: player) + .frame(height: 200) + .aspectRatio(aspectRatio, contentMode: .fit) + .cornerRadius(8) } - .onDisappear { - player?.pause() + + if isLoading { + ProgressView() + .scaleEffect(1.2) + .progressViewStyle(CircularProgressViewStyle()) } + } + .onAppear { + setupVideo(url: videoURL) + } + .onDisappear { + cleanup() + } + .onChange(of: widget.url) { newValue in + if !newValue.isEmpty, let newURL = URL(string: newValue) { + setupVideo(url: newURL) + } else { + cleanup() + } + } } else { Rectangle() .fill(Color.gray.opacity(0.3)) @@ -59,4 +105,119 @@ struct VideoRowView: View { } } } + + @MainActor + private func setupVideo(url: URL) { + // Avoid redundant setup if URL hasn't changed + if currentStreamUrl?.absoluteString == url.absoluteString { + return + } + + // Clean up previous setup + cleanup() + currentStreamUrl = url + isLoading = true + + if isMJPEG { + setupMJPEG(url: url) + } else { + setupHLS(url: url) + } + } + + @MainActor + private func setupMJPEG(url: URL) { + // Create a dummy UIImageView for the SimpleMJPEGPlayer + let imageView = UIImageView() + imageView.contentMode = .scaleAspectFit + + mjpegPlayer = VideoStreamManager.shared.getOrCreateStream( + for: url, + imageView: imageView, + onFirstFrame: { [aspectRatio = aspectRatio] newAspectRatio in + Task { @MainActor in + self.aspectRatio = newAspectRatio + isLoading = false + } + }, + onError: { error in + Task { @MainActor in + print("MJPEG stream error: \(error.localizedDescription)") + isLoading = false + } + } + ) + + // Observe image changes on the UIImageView + startImageObservation(imageView: imageView) + } + + @MainActor + private func startImageObservation(imageView: UIImageView) { + imageObservationTimer = Timer.scheduledTimer(withTimeInterval: 1.0 / 30.0, repeats: true) { _ in + DispatchQueue.main.async { + if mjpegPlayer == nil { + imageObservationTimer?.invalidate() + imageObservationTimer = nil + return + } + + if let image = imageView.image { + mjpegImage = image + if isLoading { + isLoading = false + } + } + } + } + } + + private func setupHLS(url: URL) { + let playerItem = AVPlayerItem(url: url) + player = AVPlayer(playerItem: playerItem) + + // Observe player readiness + playerObserver = playerItem.observe(\.status, options: [.new, .old]) { item, _ in + Task { @MainActor in + switch item.status { + case .readyToPlay: + isLoading = false + if item.presentationSize != .zero { + let newAspectRatio = item.presentationSize.width / item.presentationSize.height + aspectRatio = newAspectRatio + } + // Auto-play when ready + player?.play() + case .failed: + isLoading = false + print("HLS player failed: \(item.error?.localizedDescription ?? "Unknown error")") + default: + break + } + } + } + } + + private func cleanup() { + // Clean up timer + imageObservationTimer?.invalidate() + imageObservationTimer = nil + + // Clean up HLS observer + playerObserver = nil + + // Release MJPEG stream + if let currentStreamUrl, isMJPEG { + VideoStreamManager.shared.releaseStream(for: currentStreamUrl) + } + + // Clean up HLS player + player?.pause() + player = nil + + mjpegPlayer = nil + mjpegImage = nil + currentStreamUrl = nil + isLoading = false + } } From 13c34a34a2ef67dc884b181d0106528f3f9b0e7d Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Fri, 19 Dec 2025 18:34:41 +0100 Subject: [PATCH 343/476] Port functionality to SwiftUI: Search functionality, Title cleanup (removes bracket content), Network status change detection, Improved loading state management Signed-off-by: Tim Mueller-Seydlitz --- .../xcshareddata/swiftpm/Package.resolved | 2 +- openHAB/SitemapPageViewModel.swift | 56 ++++++++++++++++++- openHAB/SwiftUI/SitemapPageView.swift | 17 ++++++ openHABWatch/Domain/UserData.swift | 2 +- 4 files changed, 74 insertions(+), 3 deletions(-) diff --git a/openHAB.xcworkspace/xcshareddata/swiftpm/Package.resolved b/openHAB.xcworkspace/xcshareddata/swiftpm/Package.resolved index 1ff56a806..881a604c6 100644 --- a/openHAB.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/openHAB.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -192,7 +192,7 @@ { "identity" : "swift-http-types", "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-http-types", + "location" : "https://github.com/apple/swift-http-types.git", "state" : { "revision" : "45eb0224913ea070ec4fba17291b9e7ecf4749ca", "version" : "1.5.1" diff --git a/openHAB/SitemapPageViewModel.swift b/openHAB/SitemapPageViewModel.swift index c847c2393..389b7b730 100644 --- a/openHAB/SitemapPageViewModel.swift +++ b/openHAB/SitemapPageViewModel.swift @@ -37,7 +37,9 @@ class SitemapPageViewModel: ObservableObject { @Published var searchText = "" @Published var error: (any LocalizedError)? @Published var isLoading = false + @Published var isUpdating = false @Published var openHABRootUrl: String? + @Published var showSearchField = false @ObservedObject var networkTracker = MainActorNetworkTracker.shared private var openAPIService: OpenAPIService? @@ -46,6 +48,8 @@ class SitemapPageViewModel: ObservableObject { private var defaultSitemap = "" @Published var pageId = "" private var isLinkedPage = false + private var pageNetworkStatus: NetworkStatus? + private var pageNetworkStatusAvailable = false var relevantWidgets: [OpenHABWidget] { var flattenedWidgets = [OpenHABWidget]() @@ -59,7 +63,15 @@ class SitemapPageViewModel: ObservableObject { } var pageTitle: String { - currentPage?.title ?? (defaultSitemap.isEmpty ? "Sitemap" : defaultSitemap) + // Strip bracket content from title (e.g., "Living Room[2]" becomes "Living Room") + let title = currentPage?.title.components(separatedBy: "[")[0] ?? "" + if !title.isEmpty { + return title + } else if !defaultSitemap.isEmpty { + return defaultSitemap + } else { + return "Sitemap" + } } var isLinked: Bool { @@ -105,6 +117,7 @@ class SitemapPageViewModel: ObservableObject { func loadSettings() { defaultSitemap = Preferences.shared.currentHomePreferences.defaultSitemap + showSearchField = Preferences.shared.applicationPreferences.showSearchField } func startPageHandling() { @@ -134,6 +147,7 @@ class SitemapPageViewModel: ObservableObject { } // 1. Initial page load (longPolling: false) + isLoading = true let initialPage = try await openAPIService?.pollDataForPage( sitemapname: defaultSitemap, pageId: pageId, @@ -145,14 +159,17 @@ class SitemapPageViewModel: ObservableObject { if let page = initialPage { updateUI(with: page) } + isLoading = false // 2. Start long polling loop while !Task.isCancelled { + isUpdating = true let page = try await openAPIService?.pollDataForPage( sitemapname: defaultSitemap, pageId: pageId, longPolling: true ) + isUpdating = false try Task.checkCancellation() if let page { @@ -162,10 +179,14 @@ class SitemapPageViewModel: ObservableObject { } catch is CancellationError { logger.info("🔁 pageHandlingTask was cancelled") + isLoading = false + isUpdating = false } catch let error as DecodingError { logger.error("Decoding error: \(error.localizedDescription)") await MainActor.run { self.error = SitemapPageError.serviceUnavailable + self.isLoading = false + self.isUpdating = false } } catch let error as ClientError { if let urlError = error.underlyingError as? URLError, urlError.code == .cancelled { @@ -178,12 +199,18 @@ class SitemapPageViewModel: ObservableObject { self.error = SitemapPageError.serviceUnavailable } } + isLoading = false + isUpdating = false } catch let openAPIError as OpenAPIServiceError { logger.error("OpenAPIServiceError: \(openAPIError.localizedDescription)") + isLoading = false + isUpdating = false } catch { logger.error("❌ Unhandled pageHandlingTask error: \(error.localizedDescription)") await MainActor.run { self.error = SitemapPageError.serviceUnavailable + self.isLoading = false + self.isUpdating = false } } } @@ -300,11 +327,38 @@ class SitemapPageViewModel: ObservableObject { logger.info("SitemapPageViewModel tracker URL \(activeConnection.configuration.url)") + // Check if network status changed + if pageNetworkStatusChanged() { + logger.info("Network status changed, restarting page handling") + pageHandlingTask?.cancel() + } + Task { await handleActiveConnection(activeConnection) } } + @discardableResult + private func pageNetworkStatusChanged() -> Bool { + logger.info("SitemapPageViewModel pageNetworkStatusChange") + + let currentStatus = MainActorNetworkTracker.shared.status + + // First run + if !pageNetworkStatusAvailable { + pageNetworkStatus = currentStatus + pageNetworkStatusAvailable = true + return false + } + + if pageNetworkStatus == currentStatus { + return false + } else { + pageNetworkStatus = currentStatus + return true + } + } + private func handleActiveConnection(_ connection: ConnectionInfo) async { // Save the active connection information activeConnectionInfo = connection diff --git a/openHAB/SwiftUI/SitemapPageView.swift b/openHAB/SwiftUI/SitemapPageView.swift index 77f1bd0ff..c17527d2d 100644 --- a/openHAB/SwiftUI/SitemapPageView.swift +++ b/openHAB/SwiftUI/SitemapPageView.swift @@ -15,6 +15,7 @@ import SwiftUI struct SitemapPageView: View { @StateObject public var viewModel = SitemapPageViewModel() + @State private var idleTimerDisabled = false private var isLinkedPage: Bool { viewModel.isLinked @@ -41,12 +42,28 @@ struct SitemapPageView: View { .listStyle(.inset) .navigationTitle(viewModel.pageTitle) .navigationBarTitleDisplayMode(.inline) + .searchable(text: $viewModel.searchText, prompt: "Search items") + .autocorrectionDisabled() + .textInputAutocapitalization(.never) .refreshable { await viewModel.reload() } .task { viewModel.startPageHandling() } + .onAppear { + // Disable idle timer if configured in settings + if Preferences.shared.idleOff { + UIApplication.shared.isIdleTimerDisabled = true + idleTimerDisabled = true + } + } + .onDisappear { + // Re-enable idle timer when leaving the view + if idleTimerDisabled { + UIApplication.shared.isIdleTimerDisabled = false + } + } .onChange(of: viewModel.networkTracker.activeConnection) { activeConnection in viewModel.handleActiveConnectionChange(activeConnection) } diff --git a/openHABWatch/Domain/UserData.swift b/openHABWatch/Domain/UserData.swift index 4f5393c60..8ef7a9e87 100644 --- a/openHABWatch/Domain/UserData.swift +++ b/openHABWatch/Domain/UserData.swift @@ -198,7 +198,7 @@ final class UserData: ObservableObject { currentlyLoadingSitemap = sitemapName pageHandlingTask = Task { - let taskSitemapName = sitemapName // Capture the sitemap name for this specific task + let taskSitemapName = sitemapName // Capture the sitemap name for this specific task defer { // Only clear references if this task is still the current one Task { @MainActor in From 4d83867aecc9a04d88c55f3d02e3f9c02d54c966 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Sat, 27 Dec 2025 09:06:45 +0100 Subject: [PATCH 344/476] Fix sitemap row spacing, duplicate toolbar, and transition errors Signed-off-by: Tim Mueller-Seydlitz --- openHAB/OpenHABRootViewController.swift | 6 +++++ openHAB/SitemapPageViewModel.swift | 23 +++++++++++++++++++ .../SwiftUI/Rows/RollershutterRowView.swift | 4 ++-- openHAB/SwiftUI/SitemapNavigationView.swift | 4 +++- openHAB/SwiftUI/SitemapPageView.swift | 16 +++++-------- 5 files changed, 40 insertions(+), 13 deletions(-) diff --git a/openHAB/OpenHABRootViewController.swift b/openHAB/OpenHABRootViewController.swift index a17c1dd7b..f6caa668f 100644 --- a/openHAB/OpenHABRootViewController.swift +++ b/openHAB/OpenHABRootViewController.swift @@ -59,6 +59,12 @@ class HostingSitemapViewController: UIHostingController, navigationController?.setNavigationBarHidden(true, animated: false) } + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + // Ensure UIKit navigation bar stays hidden when transitioning from other views + navigationController?.setNavigationBarHidden(true, animated: animated) + } + func setRootViewController(_ rootViewController: OpenHABRootViewController) { self.rootViewController = rootViewController // Update the closure after initialization diff --git a/openHAB/SitemapPageViewModel.swift b/openHAB/SitemapPageViewModel.swift index 389b7b730..23da79950 100644 --- a/openHAB/SitemapPageViewModel.swift +++ b/openHAB/SitemapPageViewModel.swift @@ -122,6 +122,7 @@ class SitemapPageViewModel: ObservableObject { func startPageHandling() { pageHandlingTask?.cancel() + error = nil // Clear any previous errors when starting a new page handling session logger.info("🚀 Starting page load and long polling flow...") @@ -182,6 +183,13 @@ class SitemapPageViewModel: ObservableObject { isLoading = false isUpdating = false } catch let error as DecodingError { + // Don't set error if task was cancelled + guard !Task.isCancelled else { + logger.info("Task cancelled, ignoring DecodingError") + isLoading = false + isUpdating = false + return + } logger.error("Decoding error: \(error.localizedDescription)") await MainActor.run { self.error = SitemapPageError.serviceUnavailable @@ -194,6 +202,13 @@ class SitemapPageViewModel: ObservableObject { } else if let urlError = error.underlyingError as? URLError, urlError.code == .timedOut { logger.info("Task timed out (URLError: timedOut)") } else { + // Don't set error if task was cancelled + guard !Task.isCancelled else { + logger.info("Task cancelled, ignoring ClientError") + isLoading = false + isUpdating = false + return + } logger.error("ClientError: \(error.localizedDescription)") await MainActor.run { self.error = SitemapPageError.serviceUnavailable @@ -206,6 +221,13 @@ class SitemapPageViewModel: ObservableObject { isLoading = false isUpdating = false } catch { + // Don't set error if task was cancelled + guard !Task.isCancelled else { + logger.info("Task cancelled, ignoring error") + isLoading = false + isUpdating = false + return + } logger.error("❌ Unhandled pageHandlingTask error: \(error.localizedDescription)") await MainActor.run { self.error = SitemapPageError.serviceUnavailable @@ -270,6 +292,7 @@ class SitemapPageViewModel: ObservableObject { func pushSitemap(name: String, path: String?) async { defaultSitemap = name pageId = path ?? "" + error = nil // Clear any previous errors when switching sitemaps startPageHandling() } diff --git a/openHAB/SwiftUI/Rows/RollershutterRowView.swift b/openHAB/SwiftUI/Rows/RollershutterRowView.swift index 83e93a8d9..03f99c70a 100644 --- a/openHAB/SwiftUI/Rows/RollershutterRowView.swift +++ b/openHAB/SwiftUI/Rows/RollershutterRowView.swift @@ -89,7 +89,7 @@ extension View { @ViewBuilder func sensoryHeavyFeedbackIfAvailable(trigger: Bool) -> some View { if #available(iOS 17.0, *) { - self.sensoryFeedback(.impact(weight: .heavy, intensity: 0.9), trigger: trigger) + sensoryFeedback(.impact(weight: .heavy, intensity: 0.9), trigger: trigger) } else { self } @@ -98,7 +98,7 @@ extension View { @ViewBuilder func sensoryStopFeedbackIfAvailable(trigger: Bool) -> some View { if #available(iOS 17.0, *) { - self.sensoryFeedback(.impact(flexibility: .rigid), trigger: trigger) + sensoryFeedback(.impact(flexibility: .rigid), trigger: trigger) } else { self } diff --git a/openHAB/SwiftUI/SitemapNavigationView.swift b/openHAB/SwiftUI/SitemapNavigationView.swift index 2915b05f1..de1715b9b 100644 --- a/openHAB/SwiftUI/SitemapNavigationView.swift +++ b/openHAB/SwiftUI/SitemapNavigationView.swift @@ -15,7 +15,7 @@ import SFSafeSymbols import SwiftUI struct SitemapNavigationView: View { - @StateObject public var viewModel = SitemapPageViewModel() + @StateObject var viewModel = SitemapPageViewModel() let onShowSideMenu: () -> Void var body: some View { @@ -24,6 +24,8 @@ struct SitemapNavigationView: View { .navigationTitle(viewModel.pageTitle) .navigationBarTitleDisplayMode(.inline) .searchable(text: $viewModel.searchText, prompt: Text(NSLocalizedString("search_items", comment: ""))) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) .toolbar { ToolbarItem(placement: .navigationBarTrailing) { Button { diff --git a/openHAB/SwiftUI/SitemapPageView.swift b/openHAB/SwiftUI/SitemapPageView.swift index c17527d2d..4e4a04f20 100644 --- a/openHAB/SwiftUI/SitemapPageView.swift +++ b/openHAB/SwiftUI/SitemapPageView.swift @@ -14,7 +14,7 @@ import OpenHABCore import SwiftUI struct SitemapPageView: View { - @StateObject public var viewModel = SitemapPageViewModel() + @StateObject var viewModel = SitemapPageViewModel() @State private var idleTimerDisabled = false private var isLinkedPage: Bool { @@ -30,21 +30,14 @@ struct SitemapPageView: View { .redacted(reason: .placeholder) .disabled(true) } - .environment(\.defaultMinListRowHeight, 30) } else { List(viewModel.relevantWidgets) { widget in EmbeddingRowView(widget: widget) } - .environment(\.defaultMinListRowHeight, 30) } } .environmentObject(viewModel) .listStyle(.inset) - .navigationTitle(viewModel.pageTitle) - .navigationBarTitleDisplayMode(.inline) - .searchable(text: $viewModel.searchText, prompt: "Search items") - .autocorrectionDisabled() - .textInputAutocapitalization(.never) .refreshable { await viewModel.reload() } @@ -67,7 +60,10 @@ struct SitemapPageView: View { .onChange(of: viewModel.networkTracker.activeConnection) { activeConnection in viewModel.handleActiveConnectionChange(activeConnection) } - .alert("Error", isPresented: .constant(viewModel.error != nil), actions: { + .alert("Error", isPresented: Binding( + get: { viewModel.error != nil }, + set: { if !$0 { viewModel.error = nil } } + ), actions: { Button("OK", role: .cancel) {} }, message: { if let error = viewModel.error { @@ -83,7 +79,7 @@ struct SitemapPageView: View { extension SitemapPageView { /// Creates placeholder widgets for skeleton loading state - public var placeholderWidgets: [OpenHABWidget] { + var placeholderWidgets: [OpenHABWidget] { [ PreviewConstants.openHABSitemapPage!.widgets[3], PreviewConstants.openHABSitemapPage!.widgets[5], From a7bd869e8718f12a45c19d3cf32c8876b17f904b Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Sat, 27 Dec 2025 10:20:30 +0100 Subject: [PATCH 345/476] Reduce logging Signed-off-by: Tim Mueller-Seydlitz --- OpenHABCore/Sources/OpenHABCore/Util/Endpoint.swift | 2 -- openHAB/SwiftUI/IconView.swift | 3 --- 2 files changed, 5 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/Endpoint.swift b/OpenHABCore/Sources/OpenHABCore/Util/Endpoint.swift index a2cf17c31..111986965 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/Endpoint.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/Endpoint.swift @@ -197,9 +197,7 @@ public extension Endpoint { queryItems = [URLQueryItem(name: "height", value: "64")] if !iconColor.isEmpty { let uiColor = UIColor(fromString: iconColor) - Logger.endpoint.info("\(uiColor.rgbaDescription)") let colorString = uiColor.hexString - Logger.endpoint.debug("color : \(colorString ?? "No proper color")") if let colorString { queryItems.append(URLQueryItem(name: "color", value: "#\(colorString)")) } diff --git a/openHAB/SwiftUI/IconView.swift b/openHAB/SwiftUI/IconView.swift index ed7cacdb6..541836252 100644 --- a/openHAB/SwiftUI/IconView.swift +++ b/openHAB/SwiftUI/IconView.swift @@ -73,7 +73,6 @@ struct IconView: View { return widget.iconColor.isEmpty ? "black" : widget.iconColor } } - logger.debug("icon color: \(queriedIconColor)") return Endpoint.icon( rootUrl: activeConnection.configuration.url, @@ -103,14 +102,12 @@ struct IconView: View { logger.error("Failed URL: \(iconURL.absoluteString)") } .onSuccess { result in - logger.debug("Loading of icon succeeded for widget \(widget.label)") currentImage = result.image if result.cacheType != .none { let cacheKey = iconURL.absoluteString Task { await IconCacheTracker.shared.addCacheKey(cacheKey) } - logger.debug("Icon loaded from cache: \(cacheKey)") } } .placeholder { _ in From b460afa4467ca0c9259539b8591a235dc64b603b Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Sat, 27 Dec 2025 10:27:33 +0100 Subject: [PATCH 346/476] Display sitemap labels instead of technical names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fetch and display sitemap label on startup and when switching sitemaps - Add direct network connection observation to restart page handling - Fix blank page on startup by restarting when network becomes available 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- openHAB/SitemapPageViewModel.swift | 51 ++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/openHAB/SitemapPageViewModel.swift b/openHAB/SitemapPageViewModel.swift index 23da79950..a72d31a52 100644 --- a/openHAB/SitemapPageViewModel.swift +++ b/openHAB/SitemapPageViewModel.swift @@ -46,6 +46,7 @@ class SitemapPageViewModel: ObservableObject { private var activeConnectionInfo: ConnectionInfo? private var pageHandlingTask: Task? private var defaultSitemap = "" + private var defaultSitemapLabel = "" @Published var pageId = "" private var isLinkedPage = false private var pageNetworkStatus: NetworkStatus? @@ -67,6 +68,8 @@ class SitemapPageViewModel: ObservableObject { let title = currentPage?.title.components(separatedBy: "[")[0] ?? "" if !title.isEmpty { return title + } else if !defaultSitemapLabel.isEmpty { + return defaultSitemapLabel } else if !defaultSitemap.isEmpty { return defaultSitemap } else { @@ -81,6 +84,13 @@ class SitemapPageViewModel: ObservableObject { init() { loadSettings() setupActiveConnectionObserver() + + // Observe network connection changes directly + Task { @MainActor in + for await connection in networkTracker.$activeConnection.values { + handleActiveConnectionChange(connection) + } + } } init(pageUrl: String, title: String, pageId: String = "") { @@ -134,11 +144,13 @@ class SitemapPageViewModel: ObservableObject { guard !defaultSitemap.isEmpty else { logger.error("startPageHandling: Cannot run with empty sitemap after discovery") + isLoading = false return } do { guard let activeConnection = await NetworkTracker.shared.waitForActiveConnection() else { logger.error("Failed to establish connection within timeout") + isLoading = false return } let configuration = activeConnection.configuration @@ -147,6 +159,11 @@ class SitemapPageViewModel: ObservableObject { openAPIService = try OpenAPIService(connectionConfiguration: configuration) } + // Fetch sitemap label if we loaded from preferences (not from discovery) + if defaultSitemapLabel.isEmpty { + await fetchSitemapLabel() + } + // 1. Initial page load (longPolling: false) isLoading = true let initialPage = try await openAPIService?.pollDataForPage( @@ -248,6 +265,10 @@ class SitemapPageViewModel: ObservableObject { do { isLoading = true try await setupConnection() + // Fetch sitemap label if we don't have it yet + if defaultSitemapLabel.isEmpty { + await fetchSitemapLabel() + } try await loadCurrentPage() } catch { self.error = error as? any LocalizedError @@ -291,11 +312,36 @@ class SitemapPageViewModel: ObservableObject { @MainActor func pushSitemap(name: String, path: String?) async { defaultSitemap = name + defaultSitemapLabel = "" // Clear old label so it gets fetched for the new sitemap pageId = path ?? "" error = nil // Clear any previous errors when switching sitemaps startPageHandling() } + private func fetchSitemapLabel() async { + guard let service = openAPIService else { + logger.error("OpenAPI service not available for fetching sitemap label") + return + } + + do { + let sitemaps = try await service.openHABSitemaps() + + // Find the sitemap matching our defaultSitemap name and get its label + if let sitemap = sitemaps.first(where: { $0.name == defaultSitemap }) { + defaultSitemapLabel = sitemap.label + // swiftformat:disable:next redundantSelf + logger.info("Found label '\(self.defaultSitemapLabel)' for sitemap '\(self.defaultSitemap)'") + } else { + // swiftformat:disable:next redundantSelf + logger.warning("Could not find sitemap '\(self.defaultSitemap)' in available sitemaps") + } + } catch { + logger.warning("Failed to fetch sitemap label: \(error)") + // Don't set error here as this is not critical - we can continue without the label + } + } + private func discoverAndSelectSitemap() async { do { try await setupConnection() @@ -313,6 +359,7 @@ class SitemapPageViewModel: ObservableObject { case 1: // Auto-select the only available sitemap defaultSitemap = filteredSitemaps[0].name + defaultSitemapLabel = filteredSitemaps[0].label // swiftformat:disable:next redundantSelf logger.info("Auto-selected single sitemap: \(self.defaultSitemap)") @@ -323,6 +370,7 @@ class SitemapPageViewModel: ObservableObject { case 2...: // Multiple sitemaps available - select the first one defaultSitemap = filteredSitemaps[0].name + defaultSitemapLabel = filteredSitemaps[0].label // swiftformat:disable:next redundantSelf logger.info("Auto-selected first sitemap from \(filteredSitemaps.count) available: \(self.defaultSitemap)") @@ -354,6 +402,9 @@ class SitemapPageViewModel: ObservableObject { if pageNetworkStatusChanged() { logger.info("Network status changed, restarting page handling") pageHandlingTask?.cancel() + // Restart page handling to establish long-polling + startPageHandling() + return } Task { From 8f90905b561cefcada691977e60f7178a783d6c6 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Sat, 27 Dec 2025 10:29:00 +0100 Subject: [PATCH 347/476] Update Package.resolved Signed-off-by: Tim Mueller-Seydlitz --- .../xcshareddata/swiftpm/Package.resolved | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/openHAB.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/openHAB.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 6d5944dee..626e199d7 100644 --- a/openHAB.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/openHAB.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,4 +1,5 @@ { + "originHash" : "510fece68ecb3b20f88be6a202204737d9bb1b0c487b784869bdd79b26798df4", "pins" : [ { "identity" : "abseil-cpp-binary", @@ -203,8 +204,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/weakfl/SwiftFormatPlugin", "state" : { - "revision" : "daf7c48b2264b11cc8535aa3649b2d8486bb3b08", - "version" : "0.56.1" + "revision" : "c41997c642ffc937c6e83b23dadbd7e626485cfa", + "version" : "0.58.7" } }, { @@ -212,8 +213,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/weakfl/SwiftLintPlugin.git", "state" : { - "revision" : "3e10a982ff8f62ba6a19401380280a26e4c56bef", - "version" : "0.59.1" + "revision" : "47d1da3a8e30e0ada5543899e8c47f54664073ac", + "version" : "0.62.2" } }, { @@ -226,5 +227,5 @@ } } ], - "version" : 2 + "version" : 3 } From 3b927ac75b60825c5eeac5ea48f3fa26b17e7b7f Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Mon, 5 Jan 2026 09:31:23 +0100 Subject: [PATCH 348/476] Increased tolerance from 5s to 30s in #expect(info Signed-off-by: Tim Mueller-Seydlitz --- OpenHABCore/Tests/OpenHABCoreTests/CertificateStoreTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/OpenHABCore/Tests/OpenHABCoreTests/CertificateStoreTests.swift b/OpenHABCore/Tests/OpenHABCoreTests/CertificateStoreTests.swift index b14ee473c..54be5e5f0 100644 --- a/OpenHABCore/Tests/OpenHABCoreTests/CertificateStoreTests.swift +++ b/OpenHABCore/Tests/OpenHABCoreTests/CertificateStoreTests.swift @@ -42,7 +42,7 @@ struct CertificateStoreTests { let info = await store.getCertificateInfo(forDomain: domain) #expect(info != nil) #expect(info?.data == data) - #expect(info!.dateAccepted.timeIntervalSinceNow > -5) // stored just now + #expect(info!.dateAccepted.timeIntervalSinceNow > -30) // stored recently (allow 30s for slow CI) await store.removeCertificate(forDomain: domain) } From ce4e9160871a688c736b6feba044d8aef4d1898a Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Fri, 16 Jan 2026 08:43:08 +0100 Subject: [PATCH 349/476] Improved sspacing Bug fix for updates not working UX improvement : Optimistic response for Switch Signed-off-by: Tim Mueller-Seydlitz --- openHAB/SitemapPageViewModel.swift | 6 +++--- openHAB/SwiftUI/EmbeddingRowView.swift | 3 ++- openHAB/SwiftUI/Rows/SwitchRowView.swift | 8 +++++++- openHAB/SwiftUI/SitemapPageView.swift | 4 +++- 4 files changed, 15 insertions(+), 6 deletions(-) diff --git a/openHAB/SitemapPageViewModel.swift b/openHAB/SitemapPageViewModel.swift index ffabd9d49..87cb7af59 100644 --- a/openHAB/SitemapPageViewModel.swift +++ b/openHAB/SitemapPageViewModel.swift @@ -441,15 +441,15 @@ class SitemapPageViewModel: ObservableObject { do { // Setup the OpenAPI service based on the new connection openAPIService = try OpenAPIService(connectionConfiguration: connection.configuration) - // Reload the sitemap data - await selectSitemap() + // Start page handling which includes initial load and long polling + startPageHandling() } catch { self.error = error as? any LocalizedError } } func selectSitemap() async { - await reload() + startPageHandling() } // MARK: - Command Sending diff --git a/openHAB/SwiftUI/EmbeddingRowView.swift b/openHAB/SwiftUI/EmbeddingRowView.swift index e9c4e0fb6..7eeef9bb5 100644 --- a/openHAB/SwiftUI/EmbeddingRowView.swift +++ b/openHAB/SwiftUI/EmbeddingRowView.swift @@ -47,7 +47,8 @@ struct EmbeddingRowView: View { RowViewFactory.view(for: widget) } } - .padding(.vertical, -8) + .contentShape(Rectangle()) + .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) .alert("Input", isPresented: $showInputAlert) { if let widget = selectedWidget { TextField("Enter value", text: $inputText) diff --git a/openHAB/SwiftUI/Rows/SwitchRowView.swift b/openHAB/SwiftUI/Rows/SwitchRowView.swift index 52b4bfdd7..d9d959030 100644 --- a/openHAB/SwiftUI/Rows/SwitchRowView.swift +++ b/openHAB/SwiftUI/Rows/SwitchRowView.swift @@ -17,6 +17,7 @@ import SwiftUI struct SwitchRowView: View { @ObservedObject var widget: OpenHABWidget @EnvironmentObject var viewModel: SitemapPageViewModel + @State private var localIsOn: Bool? private let logger = Logger(subsystem: "org.openhab", category: "WidgetSwitchView") @@ -30,7 +31,7 @@ struct SwitchRowView: View { } private var isOn: Bool { - effectiveState.parseAsBool() + localIsOn ?? effectiveState.parseAsBool() } var body: some View { @@ -54,6 +55,7 @@ struct SwitchRowView: View { Toggle("", isOn: Binding( get: { isOn }, set: { newValue in + localIsOn = newValue let newState = newValue ? "ON" : "OFF" if newValue { logger.info("\("Switch to ON")") @@ -67,6 +69,10 @@ struct SwitchRowView: View { .disabled(widget.readOnly ?? false) } .contentShape(Rectangle()) + .onChange(of: effectiveState) { _ in + // Sync local state when server state changes + localIsOn = nil + } } } diff --git a/openHAB/SwiftUI/SitemapPageView.swift b/openHAB/SwiftUI/SitemapPageView.swift index feabf6c2d..62a7c1404 100644 --- a/openHAB/SwiftUI/SitemapPageView.swift +++ b/openHAB/SwiftUI/SitemapPageView.swift @@ -37,7 +37,9 @@ struct SitemapPageView: View { } } .environmentObject(viewModel) - .listStyle(.inset) + .listStyle(.plain) + .listRowSpacing(0) + .environment(\.defaultMinListRowHeight, 44) .refreshable { await viewModel.reload() } From 7fd3a63bbb4e9dab16a9143dc56d522c5090fd7e Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Tue, 27 Jan 2026 23:06:35 +0100 Subject: [PATCH 350/476] Resolve merge mess in openHAB.xcodeproj/project.pbxproj Signed-off-by: Tim Mueller-Seydlitz --- openHAB.xcodeproj/project.pbxproj | 25 +------- .../xcshareddata/swiftpm/Package.resolved | 57 ++++++++----------- openHAB/BonjourDiscoveryViewModel.swift | 6 +- 3 files changed, 29 insertions(+), 59 deletions(-) diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index 1a0dca852..87180754a 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -168,6 +168,7 @@ DAD0857B2AE4782F001D36BE /* OpenHABWatchUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAD0857A2AE4782F001D36BE /* OpenHABWatchUITests.swift */; }; DAD0857D2AE4782F001D36BE /* OpenHABWatchLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAD0857C2AE4782F001D36BE /* OpenHABWatchLaunchTests.swift */; }; DAD0858B2AE56F0E001D36BE /* OpenHABWatch.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAD0855F2AE47824001D36BE /* OpenHABWatch.swift */; }; + DADC420A2E7AB899004E866F /* (null) in Frameworks */ = {isa = PBXBuildFile; }; DAE2800A2E35F5590028EE24 /* IconURLView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAE280092E35F5590028EE24 /* IconURLView.swift */; }; DAE7B4A72E26927C00B9FE99 /* ButtonGridRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAE7B4A62E26927C00B9FE99 /* ButtonGridRowView.swift */; }; DAEA21D82DBF472D00D54342 /* RowViewFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAEA21D72DBF472D00D54342 /* RowViewFactory.swift */; }; @@ -175,7 +176,6 @@ DAEA21DC2DBF47DA00D54342 /* SliderRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAEA21DB2DBF47DA00D54342 /* SliderRowView.swift */; }; DAEA21DE2DBF481300D54342 /* TextRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAEA21DD2DBF481300D54342 /* TextRowView.swift */; }; DAEA21E02DBF483E00D54342 /* GenericRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAEA21DF2DBF483E00D54342 /* GenericRowView.swift */; }; - DADC420A2E7AB899004E866F /* SDWebImage in Frameworks */ = {isa = PBXBuildFile; productRef = DADC42092E7AB899004E866F /* SDWebImage */; }; DAEAA89D21E6B06400267EA3 /* ReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAEAA89C21E6B06300267EA3 /* ReusableView.swift */; }; DAEAA89F21E6B16600267EA3 /* UITableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAEAA89E21E6B16600267EA3 /* UITableView.swift */; }; DAEE35072E224F6000B4A7F1 /* ColorTemperaturePickerRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAEE35062E224F6000B4A7F1 /* ColorTemperaturePickerRowView.swift */; }; @@ -504,7 +504,6 @@ DAA42BA921DC983B00244B2A /* VideoUITableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VideoUITableViewCell.swift; sourceTree = ""; }; DAA42BAB21DC984A00244B2A /* WebUITableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WebUITableViewCell.swift; sourceTree = ""; }; DAA599B72EAC0FE7003A8726 /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = AppIcon.icon; sourceTree = ""; }; - DAAAB2822EA3874400F1B05D /* SegmentSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SegmentSelectionView.swift; sourceTree = ""; }; DABED17A2E451694000B92EF /* BonjourDiscoverySheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BonjourDiscoverySheet.swift; sourceTree = ""; }; DABED17C2E4516B4000B92EF /* BonjourDiscoveryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BonjourDiscoveryViewModel.swift; sourceTree = ""; }; DAC131102DA3213100075AE2 /* SetSwitchStateIntentHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetSwitchStateIntentHandlerTests.swift; sourceTree = ""; }; @@ -659,8 +658,7 @@ DABB5E332D98972F009A4B8A /* SDWebImageSVGCoder in Frameworks */, DFB2622F18830A3600D3244D /* UIKit.framework in Frameworks */, DAFF80982E4F47830084513E /* SDWebImage in Frameworks */, - DA0245AF2E79E10A000B7883 /* SDWebImage in Frameworks */, - DADC420A2E7AB899004E866F /* SDWebImage in Frameworks */, + DADC420A2E7AB899004E866F /* (null) in Frameworks */, 93F8064A27AE7A2E0035A6B0 /* FlexColorPicker in Frameworks */, DA28C362225241DE00AB409C /* WebKit.framework in Frameworks */, DAC949FA2E219F0D007E67B7 /* CommonUI in Frameworks */, @@ -1064,7 +1062,6 @@ 652B81082E2193DA00648510 /* ScreenSaver */, DA2AEB752D92D32000897D80 /* Cells */, DABED17C2E4516B4000B92EF /* BonjourDiscoveryViewModel.swift */, - DA7125362EC892BB0067D7B2 /* LoggerView.swift */, 653B54C1285E714900298ECD /* OpenHABViewController.swift */, DA95F3322E0F2B1700FE4474 /* OpenHABRootViewController.swift */, DA94AF432EC8DE41003BB3C8 /* VideoStreamManager.swift */, @@ -1414,9 +1411,6 @@ 6557AF912C039D140094D0C8 /* FirebaseMessaging */, DA9A7EFE2D66915900824156 /* SFSafeSymbols */, DABB5E322D98972F009A4B8A /* SDWebImageSVGCoder */, - DA0245AE2E79E10A000B7883 /* SDWebImage */, - DADC42092E7AB899004E866F /* SDWebImage */, - DAAAB2802EA3843100F1B05D /* Kingfisher */, ); productName = openHAB; productReference = DFB2622718830A3600D3244D /* openHAB.app */; @@ -1508,9 +1502,7 @@ 93F8064E27AE7A820035A6B0 /* XCRemoteSwiftPackageReference "SideMenu" */, DA3B75AC2C59729200E219AB /* XCRemoteSwiftPackageReference "SFSafeSymbols" */, DA2C4FD32B4F573300D1C533 /* XCRemoteSwiftPackageReference "SDWebImageSVGCoder" */, - DA0245AD2E79E10A000B7883 /* XCRemoteSwiftPackageReference "SDWebImage" */, DADC42082E7AB899004E866F /* XCRemoteSwiftPackageReference "SDWebImage" */, - DAF9D9072EA4F95700416F22 /* XCRemoteSwiftPackageReference "Kingfisher" */, ); productRefGroup = DFB2622818830A3600D3244D /* Products */; projectDirPath = ""; @@ -2863,7 +2855,6 @@ minimumVersion = 7.0.0; }; }; - DAFF80962E4F47830084513E /* XCRemoteSwiftPackageReference "SDWebImage" */ = { DADC42082E7AB899004E866F /* XCRemoteSwiftPackageReference "SDWebImage" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/SDWebImage/SDWebImage"; @@ -2872,14 +2863,6 @@ minimumVersion = 5.21.2; }; }; - DAF9D9072EA4F95700416F22 /* XCRemoteSwiftPackageReference "Kingfisher" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/SDWebImage/SDWebImage"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 8.6.1; - }; - }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -3003,10 +2986,6 @@ }; DAFF80972E4F47830084513E /* SDWebImage */ = { isa = XCSwiftPackageProductDependency; - package = DAFF80962E4F47830084513E /* XCRemoteSwiftPackageReference "SDWebImage" */; - DADC42092E7AB899004E866F /* SDWebImage */ = { - isa = XCSwiftPackageProductDependency; - package = DADC42082E7AB899004E866F /* XCRemoteSwiftPackageReference "SDWebImage" */; productName = SDWebImage; }; /* End XCSwiftPackageProductDependency section */ diff --git a/openHAB.xcworkspace/xcshareddata/swiftpm/Package.resolved b/openHAB.xcworkspace/xcshareddata/swiftpm/Package.resolved index fde5b8d0e..c733407c2 100644 --- a/openHAB.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/openHAB.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -33,8 +33,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/firebase/firebase-ios-sdk.git", "state" : { - "revision" : "087bb95235f676c1a37e928769a5b6645dcbd325", - "version" : "12.6.0" + "revision" : "674d9a7ee9858207181a3dd0b42c77298c6fb71b", + "version" : "12.8.0" } }, { @@ -60,8 +60,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/google/GoogleAppMeasurement.git", "state" : { - "revision" : "c2d59acf17a8ba7ed80a763593c67c9c7c006ad1", - "version" : "12.5.0" + "revision" : "2ffd220823f3716904733162e9ae685545c276d1", + "version" : "12.8.0" } }, { @@ -87,8 +87,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/google/grpc-binary.git", "state" : { - "revision" : "cc0001a0cf963aa40501d9c2b181e7fc9fd8ec71", - "version" : "1.69.0" + "revision" : "75b31c842f664a0f46a2e590a570e370249fd8f6", + "version" : "1.69.1" } }, { @@ -96,8 +96,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/google/gtm-session-fetcher.git", "state" : { - "revision" : "a2ab612cb980066ee56d90d60d8462992c07f24b", - "version" : "3.5.0" + "revision" : "fb7f2740b1570d2f7599c6bb9531bf4fad6974b7", + "version" : "5.0.0" } }, { @@ -112,10 +112,10 @@ { "identity" : "kingfisher", "kind" : "remoteSourceControl", - "location" : "https://github.com/onevcat/Kingfisher", + "location" : "https://github.com/onevcat/Kingfisher.git", "state" : { - "revision" : "4d75de347da985a70c63af4d799ed482021f6733", - "version" : "8.6.1" + "revision" : "d30a5fad881137e2267f96a8e3fc35c58999bb94", + "version" : "8.6.2" } }, { @@ -148,10 +148,10 @@ { "identity" : "sdwebimage", "kind" : "remoteSourceControl", - "location" : "https://github.com/SDWebImage/SDWebImage.git", + "location" : "https://github.com/SDWebImage/SDWebImage", "state" : { - "revision" : "34cf2423a2c4088d06a3b08655603b5bc3eeeb3a", - "version" : "5.21.2" + "revision" : "36e79ba485e9bb4d3cd4e3318908866dac5e7b51", + "version" : "5.21.5" } }, { @@ -186,8 +186,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections", "state" : { - "revision" : "671108c96644956dddcd89dd59c203dcdb36cec7", - "version" : "1.1.4" + "revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e", + "version" : "1.3.0" } }, { @@ -213,8 +213,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-openapi-runtime", "state" : { - "revision" : "8f33cc5dfe81169fb167da73584b9c72c3e8bc23", - "version" : "1.8.2" + "revision" : "7cdf33371bf89b23b9cf4fd3ce8d3c825c28fbe8", + "version" : "1.9.0" } }, { @@ -222,17 +222,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-openapi-urlsession", "state" : { - "revision" : "6fac6f7c428d5feea2639b5f5c8b06ddfb79434b", - "version" : "1.1.0" - } - }, - { - "identity" : "swift-protobuf", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-protobuf.git", - "state" : { - "revision" : "ebc7251dd5b37f627c93698e4374084d98409633", - "version" : "1.28.2" + "revision" : "279aa6b77be6aa842a4bf3c45fa79fa15edf3e07", + "version" : "1.2.0" } }, { @@ -249,8 +240,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swhitty/swift-timeout.git", "state" : { - "revision" : "89f44421c61476b4aa290c573d427a3f1831492f", - "version" : "0.4.0" + "revision" : "4efb73b593d5553b90766d531db701ecf2306237", + "version" : "0.4.1" } }, { @@ -276,8 +267,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/SwiftKickMobile/SwiftMessages.git", "state" : { - "revision" : "c544df6ce316422b9d65a571f932e800213dd09c", - "version" : "10.0.1" + "revision" : "c0ff6c65bfc00e6a707957cb7069988c5cde2a30", + "version" : "10.0.2" } } ], diff --git a/openHAB/BonjourDiscoveryViewModel.swift b/openHAB/BonjourDiscoveryViewModel.swift index 534457908..d9bbde02a 100644 --- a/openHAB/BonjourDiscoveryViewModel.swift +++ b/openHAB/BonjourDiscoveryViewModel.swift @@ -21,8 +21,8 @@ final class BonjourDiscoveryViewModel: ObservableObject { @Published var discoveredURLs: [String] = [] @Published var isDiscovering = false - private var bonjourService: BonjourServiceProtocol? - private let serviceFactory: @Sendable () -> BonjourServiceProtocol + private var bonjourService: (any BonjourServiceProtocol)? + private let serviceFactory: @Sendable () -> any BonjourServiceProtocol private let logger = Logger(subsystem: "org.openhab", category: "BonjourDiscovery") /// Number of discovery cycles (more cycles help find multi-homed servers) @@ -31,7 +31,7 @@ final class BonjourDiscoveryViewModel: ObservableObject { /// Duration of each discovery cycle in seconds var cycleDuration: TimeInterval = 5 - init(serviceFactory: @escaping @Sendable () -> BonjourServiceProtocol = { BonjourService() }) { + init(serviceFactory: @escaping @Sendable () -> any BonjourServiceProtocol = { BonjourService() }) { self.serviceFactory = serviceFactory } From 796a9ecb92700102813795ef35f9682be020ecde Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Wed, 28 Jan 2026 20:30:01 +0100 Subject: [PATCH 351/476] Changes Made: 1. Replaced foregroundColor() with foregroundStyle() (35 occurrences across 25+ files) - All SwiftUI row views (SliderRowView, SwitchRowView, TextRowView, etc.) - Settings views (SettingsView, ServerCertificatesView, MainUISettingsView, etc.) - Other views (NotificationsView, LoggerView, ScreenSaverView, etc.) 2. Replaced cornerRadius() with clipShape(.rect(cornerRadius:)) (8 files) - VideoRowView.swift (4 occurrences) - WebRowView.swift - ImageRowView.swift (3 occurrences) - MapRowView.swift - NotificationsView.swift - ColorTemperaturePickerRowView.swift 3. Fixed non-private @State properties in SettingsView.swift - Made 17 @State properties private - Simplified preview to use default SettingsView() initializer 4. Fixed duplicate state variable in SliderRowView.swift - Removed unused lastSentTime variable - Kept lastSendTime and fixed throttling logic to update it after sending 5. Fixed bug: lastSendTime was never updated after sending - In SliderRowView.swift, added lastSendTime = now after sendSliderUpdate(newValue) to make throttling work correctly Signed-off-by: Tim Mueller-Seydlitz --- openHAB/HomeSelectionView.swift | 2 +- openHAB/LoggerView.swift | 4 +- openHAB/NotificationsView.swift | 4 +- openHAB/ScreenSaver/ScreenSaverView.swift | 4 +- openHAB/SelectionView.swift | 2 +- .../AnimatedSecureTextField.swift | 2 +- .../SettingsView/BonjourDiscoverySheet.swift | 2 +- openHAB/SettingsView/MainUISettingsView.swift | 2 +- .../SettingsView/ServerCertificatesView.swift | 6 +- openHAB/SettingsView/SettingsView.swift | 100 ++++-------------- .../SingleConnectionSettingsView.swift | 6 +- .../SettingsView/SitemapSettingsView.swift | 4 +- openHAB/SwiftUI/Rows/ButtonGridRowView.swift | 4 +- openHAB/SwiftUI/Rows/ColorPickerRowView.swift | 6 +- .../Rows/ColorTemperaturePickerRowView.swift | 16 +-- .../SwiftUI/Rows/DatePickerInputRowView.swift | 2 +- openHAB/SwiftUI/Rows/FrameRowView.swift | 2 +- openHAB/SwiftUI/Rows/GenericRowView.swift | 2 +- openHAB/SwiftUI/Rows/ImageRowView.swift | 12 +-- openHAB/SwiftUI/Rows/MapRowView.swift | 4 +- .../SwiftUI/Rows/RollershutterRowView.swift | 8 +- openHAB/SwiftUI/Rows/SegmentedRowView.swift | 4 +- openHAB/SwiftUI/Rows/SelectionRowView.swift | 2 +- openHAB/SwiftUI/Rows/SetpointRowView.swift | 8 +- openHAB/SwiftUI/Rows/SliderRowView.swift | 8 +- openHAB/SwiftUI/Rows/SwitchRowView.swift | 4 +- openHAB/SwiftUI/Rows/TextInputRowView.swift | 2 +- openHAB/SwiftUI/Rows/TextRowView.swift | 4 +- openHAB/SwiftUI/Rows/VideoRowView.swift | 14 +-- openHAB/SwiftUI/Rows/WebRowView.swift | 6 +- 30 files changed, 92 insertions(+), 154 deletions(-) diff --git a/openHAB/HomeSelectionView.swift b/openHAB/HomeSelectionView.swift index 2bd3ce6a6..5d14aaecb 100644 --- a/openHAB/HomeSelectionView.swift +++ b/openHAB/HomeSelectionView.swift @@ -48,7 +48,7 @@ struct HomeSelectionView: View { if Preferences.shared.currentHomePreferences.id == home, !showEditOptions { Spacer() Image(systemSymbol: .checkmark) - .foregroundColor(.blue) + .foregroundStyle(.blue) } else if !showEditOptions { Spacer() // make more of the cell clickable } diff --git a/openHAB/LoggerView.swift b/openHAB/LoggerView.swift index a31ed8e7b..1b0263610 100644 --- a/openHAB/LoggerView.swift +++ b/openHAB/LoggerView.swift @@ -32,14 +32,14 @@ struct LoggerView: View { .padding() } else if logs.isEmpty { Text("No logs found") - .foregroundColor(.gray) + .foregroundStyle(.gray) .padding() } else { List(logs, id: \.id) { log in VStack(alignment: .leading, spacing: 1) { Text(formattedDate(log.timestamp)) .font(.caption.monospacedDigit()) - .foregroundColor(.gray) + .foregroundStyle(.gray) Text(log.message) .font(.body) diff --git a/openHAB/NotificationsView.swift b/openHAB/NotificationsView.swift index e99d45192..6d16ac0cb 100644 --- a/openHAB/NotificationsView.swift +++ b/openHAB/NotificationsView.swift @@ -29,14 +29,14 @@ struct NotificationRow: View { } .resizable() .frame(width: 40, height: 40) - .cornerRadius(8) + .clipShape(.rect(cornerRadius: 8)) VStack(alignment: .leading) { Text(notification.message ?? "") .font(.body) if let timeStamp = notification.created { Text(dateString(from: timeStamp)) .font(.caption) - .foregroundColor(.gray) + .foregroundStyle(.gray) } } } diff --git a/openHAB/ScreenSaver/ScreenSaverView.swift b/openHAB/ScreenSaver/ScreenSaverView.swift index 8ebff0d25..6935e243e 100644 --- a/openHAB/ScreenSaver/ScreenSaverView.swift +++ b/openHAB/ScreenSaver/ScreenSaverView.swift @@ -42,14 +42,14 @@ struct ScreenSaverView: View { Text(dateString(for: context.date)) .font(dateFont(for: geometry.size)) .monospacedDigit() - .foregroundColor(.white.opacity(0.85 * alphaFactor)) + .foregroundStyle(.white.opacity(0.85 * alphaFactor)) } if configuration.showsTime { Text(timeString(for: context.date)) .font(timeFont(for: geometry.size)) .monospacedDigit() - .foregroundColor(.white.opacity(alphaFactor)) + .foregroundStyle(.white.opacity(alphaFactor)) } } .opacity(fadeOpacity) diff --git a/openHAB/SelectionView.swift b/openHAB/SelectionView.swift index d5216d78c..4b7760c84 100644 --- a/openHAB/SelectionView.swift +++ b/openHAB/SelectionView.swift @@ -29,7 +29,7 @@ struct SelectionView: View { Spacer() if selectionItemState == mapping.command { Image(systemSymbol: .checkmark) - .foregroundColor(.blue) + .foregroundStyle(.blue) } } .contentShape(.interaction, Rectangle()) // Ensures entire row is tappable diff --git a/openHAB/SettingsView/AnimatedSecureTextField.swift b/openHAB/SettingsView/AnimatedSecureTextField.swift index 2becc48e8..4d7086553 100644 --- a/openHAB/SettingsView/AnimatedSecureTextField.swift +++ b/openHAB/SettingsView/AnimatedSecureTextField.swift @@ -22,7 +22,7 @@ struct AnimatedSecureTextField: View { isSecure = !isSecure } label: { Image(systemSymbol: isSecure ? .eyeSlash : .eyeFill) - .foregroundColor(.gray) + .foregroundStyle(.gray) } Spacer() HStack { diff --git a/openHAB/SettingsView/BonjourDiscoverySheet.swift b/openHAB/SettingsView/BonjourDiscoverySheet.swift index 9a8a9f000..1cfbc262f 100644 --- a/openHAB/SettingsView/BonjourDiscoverySheet.swift +++ b/openHAB/SettingsView/BonjourDiscoverySheet.swift @@ -39,7 +39,7 @@ struct BonjourDiscoverySheet: View { HStack { Spacer() Text("no_servers_found") - .foregroundColor(.secondary) + .foregroundStyle(.secondary) .padding(.vertical, 8) Spacer() } diff --git a/openHAB/SettingsView/MainUISettingsView.swift b/openHAB/SettingsView/MainUISettingsView.swift index ac6d6fe07..ea6a42ac5 100644 --- a/openHAB/SettingsView/MainUISettingsView.swift +++ b/openHAB/SettingsView/MainUISettingsView.swift @@ -67,7 +67,7 @@ struct MainUISettingsView: View { } label: { NavigationLink("Clear Web Cache", destination: EmptyView()) } - .foregroundColor(Color(uiColor: .label)) + .foregroundStyle(Color(uiColor: .label)) .alert("cache_cleared", isPresented: $showingCacheAlert) { Button("OK", role: .cancel) {} } diff --git a/openHAB/SettingsView/ServerCertificatesView.swift b/openHAB/SettingsView/ServerCertificatesView.swift index 1c61209c2..6d2d98c18 100644 --- a/openHAB/SettingsView/ServerCertificatesView.swift +++ b/openHAB/SettingsView/ServerCertificatesView.swift @@ -77,7 +77,7 @@ struct ServerCertificatesView: View { List { if viewModel.certificates.isEmpty { Text("No accepted server certificates") - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } else { ForEach(viewModel.certificates, id: \.domain) { certificate in VStack(alignment: .leading, spacing: 4) { @@ -86,11 +86,11 @@ struct ServerCertificatesView: View { if let summary = certificate.summary { Text(summary) .font(.caption) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } Text("Added: \(certificate.dateAdded, formatter: viewModel.dateFormatter)") .font(.caption2) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } .padding(.vertical, 2) } diff --git a/openHAB/SettingsView/SettingsView.swift b/openHAB/SettingsView/SettingsView.swift index 70317ba35..2c606fb4e 100644 --- a/openHAB/SettingsView/SettingsView.swift +++ b/openHAB/SettingsView/SettingsView.swift @@ -15,23 +15,23 @@ import os import SwiftUI struct SettingsView: View { - @State var settingsDemomode = false - @State var settingsIdleOff = true - @State var settingsRealTimeSliders = true - @State var settingsShowSearchField = true - @State var settingsSendCrashReports = false - @State var settingsIconType: IconType = .svg - @State var settingsSortSitemapsBy: SortSitemapsOrder = .label - @State var settingsDefaultMainUIPath = "" - @State var settingsAlwaysAllowWebRTC = true - @State var settingsSitemapForWatch = "" - - @State var sitemaps: [OpenHABSitemap] = [] - @State var settingsLocalConnectionConfiguration = ConnectionConfiguration(url: "", username: "", password: "") - @State var settingsRemoteConnectionConfiguration = ConnectionConfiguration(url: "", username: "", password: "") - @State var settingsHomeName = "" - @State var viewAppearedOnce = false - @State var settingsSSECommandItem = "" + @State private var settingsDemomode = false + @State private var settingsIdleOff = true + @State private var settingsRealTimeSliders = true + @State private var settingsShowSearchField = true + @State private var settingsSendCrashReports = false + @State private var settingsIconType: IconType = .svg + @State private var settingsSortSitemapsBy: SortSitemapsOrder = .label + @State private var settingsDefaultMainUIPath = "" + @State private var settingsAlwaysAllowWebRTC = true + @State private var settingsSitemapForWatch = "" + + @State private var sitemaps: [OpenHABSitemap] = [] + @State private var settingsLocalConnectionConfiguration = ConnectionConfiguration(url: "", username: "", password: "") + @State private var settingsRemoteConnectionConfiguration = ConnectionConfiguration(url: "", username: "", password: "") + @State private var settingsHomeName = "" + @State private var viewAppearedOnce = false + @State private var settingsSSECommandItem = "" @Environment(\.dismiss) private var dismiss @@ -175,69 +175,7 @@ extension UIApplication { } #Preview { - struct PreviewWrapper: View { - @State var settingsDemomode = false - @State var settingsIdleOff = true - @State var settingsRealTimeSliders = true - @State var settingsShowSearchField = true - @State var settingsSendCrashReports = false - @State var settingsIconType: IconType = .svg - @State var settingsSortSitemapsBy: SortSitemapsOrder = .label - @State var settingsDefaultMainUIPath = "/overview/" - @State var settingsAlwaysAllowWebRTC = true - @State var settingsSitemapForWatch = "home" - @State var sitemaps: [OpenHABSitemap] = [ - OpenHABSitemap( - name: "home", - icon: "", - label: "Home", - link: "http://192.168.1.100/rest/sitemaps/home", - page: nil - ), - OpenHABSitemap( - name: "office", - icon: "", - label: "Office", - link: "http://192.168.1.100/rest/sitemaps/office", - page: nil - ) - ] - @State var localConnectionConfiguration = ConnectionConfiguration( - url: "http://192.168.2.1", - username: "user", - password: "password123" - ) - @State var remoteConnectionConfiguration = ConnectionConfiguration( - url: "http://192.168.2.1", - username: "user", - password: "password123" - ) - - var body: some View { - NavigationStack { - SettingsView( - settingsDemomode: settingsDemomode, - settingsIdleOff: settingsIdleOff, - settingsRealTimeSliders: settingsRealTimeSliders, - settingsShowSearchField: settingsShowSearchField, - settingsSendCrashReports: settingsSendCrashReports, - settingsIconType: settingsIconType, - settingsSortSitemapsBy: settingsSortSitemapsBy, - settingsDefaultMainUIPath: settingsDefaultMainUIPath, - settingsAlwaysAllowWebRTC: settingsAlwaysAllowWebRTC, - settingsSitemapForWatch: settingsSitemapForWatch, - sitemaps: sitemaps, - settingsLocalConnectionConfiguration: localConnectionConfiguration, - settingsRemoteConnectionConfiguration: remoteConnectionConfiguration - ) - } - .onAppear { - // Mock behavior of updateSitemaps - if settingsSitemapForWatch.isEmpty, let first = sitemaps.first { - settingsSitemapForWatch = first.name - } - } - } + NavigationStack { + SettingsView() } - return PreviewWrapper() } diff --git a/openHAB/SettingsView/SingleConnectionSettingsView.swift b/openHAB/SettingsView/SingleConnectionSettingsView.swift index e17b4b453..cae47e877 100644 --- a/openHAB/SettingsView/SingleConnectionSettingsView.swift +++ b/openHAB/SettingsView/SingleConnectionSettingsView.swift @@ -81,7 +81,7 @@ struct SingleConnectionSettingsView: View { Image(systemSymbol: .wifiCircle) } .buttonStyle(.plain) - .foregroundColor(.accentColor) + .foregroundStyle(Color.accentColor) .disabled(connectionConfig.url.isEmpty) .help("Test Connection") } @@ -97,9 +97,9 @@ struct SingleConnectionSettingsView: View { if let message = connectionTestMessage, let success = connectionTestSuccess { HStack(spacing: 4) { Image(systemSymbol: success ? .checkmarkCircle : .xmarkOctagon) - .foregroundColor(success ? .green : .red) + .foregroundStyle(success ? .green : .red) Text(message) - .foregroundColor(success ? .green : .red) + .foregroundStyle(success ? .green : .red) .font(.caption2) } .transition(.opacity) diff --git a/openHAB/SettingsView/SitemapSettingsView.swift b/openHAB/SettingsView/SitemapSettingsView.swift index eb3c6d0c6..ca64b4469 100644 --- a/openHAB/SettingsView/SitemapSettingsView.swift +++ b/openHAB/SettingsView/SitemapSettingsView.swift @@ -45,7 +45,7 @@ struct SitemapSettingsView: View { } label: { NavigationLink("Check & Clear Image Cache", destination: EmptyView()) } - .foregroundColor(Color(uiColor: .label)) + .foregroundStyle(Color(uiColor: .label)) .alert( "Image Cache", isPresented: $showingCacheAlert, @@ -88,7 +88,7 @@ struct SitemapSettingsView: View { Picker("Sitemap For Apple Watch", selection: $settingsSitemapForWatch) { if sitemaps.isEmpty { - Text("No sitemaps available").tag("").foregroundColor(.secondary) + Text("No sitemaps available").tag("").foregroundStyle(.secondary) } else { ForEach(sitemaps, id: \.name) { sitemap in Text(sitemap.label).tag(sitemap.name) diff --git a/openHAB/SwiftUI/Rows/ButtonGridRowView.swift b/openHAB/SwiftUI/Rows/ButtonGridRowView.swift index 2f14cae23..01ace0544 100644 --- a/openHAB/SwiftUI/Rows/ButtonGridRowView.swift +++ b/openHAB/SwiftUI/Rows/ButtonGridRowView.swift @@ -40,7 +40,7 @@ struct ButtonGridButton: View { } else { Text(widget.label) .font(.caption) - .foregroundColor(.primary) + .foregroundStyle(.primary) .lineLimit(1) .truncationMode(.tail) } @@ -136,7 +136,7 @@ struct ButtonGridRowView: View { if let labelText = widget.labelText, !labelText.isEmpty { Text(labelText) - .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) + .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) } Spacer() diff --git a/openHAB/SwiftUI/Rows/ColorPickerRowView.swift b/openHAB/SwiftUI/Rows/ColorPickerRowView.swift index f852350fc..26451398e 100644 --- a/openHAB/SwiftUI/Rows/ColorPickerRowView.swift +++ b/openHAB/SwiftUI/Rows/ColorPickerRowView.swift @@ -31,7 +31,7 @@ struct ColorPickerRowView: View { if let labelText = widget.labelText, !labelText.isEmpty { Text(labelText) - .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) + .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) } Spacer() @@ -44,7 +44,7 @@ struct ColorPickerRowView: View { lastSendTime = now sendColorCommand(newColor) } - // ColorPicker does not provide an .onEnded like Slider as it doesn’t expose the drag lifecycle. + // ColorPicker does not provide an .onEnded like Slider as it doesn't expose the drag lifecycle. // It only emits onChange when the color value changes. // Therefore, we debounce final send after 0.3s of no changes debounceTask?.cancel() @@ -58,7 +58,7 @@ struct ColorPickerRowView: View { if let labelValue = widget.labelValue, !labelValue.isEmpty { Text(labelValue) .font(.caption) - .foregroundColor(widget.valuecolor.isEmpty ? .secondary : Color(fromString: widget.valuecolor)) + .foregroundStyle(widget.valuecolor.isEmpty ? .secondary : Color(fromString: widget.valuecolor)) } } .onAppear { diff --git a/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift b/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift index 2aa11e7a4..2160c9841 100644 --- a/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift +++ b/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift @@ -37,7 +37,7 @@ struct CustomSliderView: View { Circle() .frame(width: 20, height: 20) - .foregroundColor(.white) + .foregroundStyle(.white) .shadow(radius: 1) .overlay(Circle().stroke(Color.gray.opacity(0.6), lineWidth: 1)) .position(x: xPos, y: height / 2) @@ -88,7 +88,7 @@ struct ColorTemperaturePickerRowView: View { HStack { if let labelText = widget.labelText, !labelText.isEmpty { Text(labelText) - .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) + .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) } Spacer() @@ -97,16 +97,16 @@ struct ColorTemperaturePickerRowView: View { HStack { Text("\(Int(selectedTemperature))K") .font(.caption) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) Text(" - ") .font(.caption) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) // Temperature description Text(temperatureDescription) .font(.caption2) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } } @@ -114,7 +114,7 @@ struct ColorTemperaturePickerRowView: View { HStack { // Warm indicator Image(systemSymbol: .sunMinFill) - .foregroundColor(.orange) + .foregroundStyle(.orange) .font(.caption) // Slider with custom gradient track @@ -127,7 +127,7 @@ struct ColorTemperaturePickerRowView: View { endPoint: .trailing ) .frame(height: 10) - .cornerRadius(3) + .clipShape(.rect(cornerRadius: 3)) // Actual slider CustomSliderView( @@ -143,7 +143,7 @@ struct ColorTemperaturePickerRowView: View { // Cool indicator Image(systemSymbol: .snowflake) - .foregroundColor(.blue) + .foregroundStyle(.blue) .font(.caption) } } diff --git a/openHAB/SwiftUI/Rows/DatePickerInputRowView.swift b/openHAB/SwiftUI/Rows/DatePickerInputRowView.swift index b85f12683..929d9c1ca 100644 --- a/openHAB/SwiftUI/Rows/DatePickerInputRowView.swift +++ b/openHAB/SwiftUI/Rows/DatePickerInputRowView.swift @@ -41,7 +41,7 @@ struct DatePickerInputRowView: View { if let labelText = widget.labelText, !labelText.isEmpty { Text(labelText) - .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) + .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) } Spacer() diff --git a/openHAB/SwiftUI/Rows/FrameRowView.swift b/openHAB/SwiftUI/Rows/FrameRowView.swift index 00d8c345d..c536a5de7 100644 --- a/openHAB/SwiftUI/Rows/FrameRowView.swift +++ b/openHAB/SwiftUI/Rows/FrameRowView.swift @@ -21,7 +21,7 @@ struct FrameRowView: View { HStack { Text(widget.labelText?.uppercased() ?? "") .font(.callout) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) .lineLimit(1) Spacer() } diff --git a/openHAB/SwiftUI/Rows/GenericRowView.swift b/openHAB/SwiftUI/Rows/GenericRowView.swift index ed19bfb70..c5ee4741e 100644 --- a/openHAB/SwiftUI/Rows/GenericRowView.swift +++ b/openHAB/SwiftUI/Rows/GenericRowView.swift @@ -26,7 +26,7 @@ struct GenericRowView: View { Spacer() if let value = widget.labelValue { Text(value) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } } } diff --git a/openHAB/SwiftUI/Rows/ImageRowView.swift b/openHAB/SwiftUI/Rows/ImageRowView.swift index dc298ac16..6fec47d4f 100644 --- a/openHAB/SwiftUI/Rows/ImageRowView.swift +++ b/openHAB/SwiftUI/Rows/ImageRowView.swift @@ -36,7 +36,7 @@ struct ImageRowView: View { VStack(alignment: .leading, spacing: 8) { if let labelText = widget.labelText, !labelText.isEmpty, widget.labelSource == .sitemapDefinition { Text(labelText) - .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) + .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) } switch widget.generateImageResult(rootUrl: viewModel.openHABRootUrl ?? "") { case let .embedded(data: data): @@ -45,7 +45,7 @@ struct ImageRowView: View { .resizable() .aspectRatio(contentMode: .fit) .frame(maxHeight: 300) - .cornerRadius(8) + .clipShape(.rect(cornerRadius: 8)) case let .link(url): KFImage(url) .resizable() @@ -55,22 +55,22 @@ struct ImageRowView: View { .id(shouldCache ? url?.absoluteString : "\(url?.absoluteString ?? "")-\(forceRefreshKey)") .aspectRatio(contentMode: .fit) .frame(maxHeight: 300) - .cornerRadius(8) + .clipShape(.rect(cornerRadius: 8)) case .empty: Rectangle() .fill(Color.gray.opacity(0.3)) .frame(height: 200) .overlay( Text("No Image URL") - .foregroundColor(.secondary) + .foregroundStyle(.secondary) ) - .cornerRadius(8) + .clipShape(.rect(cornerRadius: 8)) } if let labelValue = widget.labelValue, !labelValue.isEmpty { Text(labelValue) .font(.caption) - .foregroundColor(widget.valuecolor.isEmpty ? .secondary : Color(fromString: widget.valuecolor)) + .foregroundStyle(widget.valuecolor.isEmpty ? .secondary : Color(fromString: widget.valuecolor)) } } .onAppear { diff --git a/openHAB/SwiftUI/Rows/MapRowView.swift b/openHAB/SwiftUI/Rows/MapRowView.swift index 5f0c5d212..6804fcb66 100644 --- a/openHAB/SwiftUI/Rows/MapRowView.swift +++ b/openHAB/SwiftUI/Rows/MapRowView.swift @@ -31,14 +31,14 @@ struct MapRowViewLegacy: View { VStack(alignment: .leading, spacing: 8) { if let labelText = widget.labelText, !labelText.isEmpty, widget.labelSource == .sitemapDefinition { Text(labelText) - .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) + .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) } Map(coordinateRegion: .constant(region), annotationItems: CLLocationCoordinate2DIsValid(widget.coordinate) ? [widget.coordinate] : []) { location in MapMarker(coordinate: location, tint: .red) } .frame(height: widget.preferredRowHeight) - .cornerRadius(8) + .clipShape(.rect(cornerRadius: 8)) } } } diff --git a/openHAB/SwiftUI/Rows/RollershutterRowView.swift b/openHAB/SwiftUI/Rows/RollershutterRowView.swift index 80190c690..0b69b44c5 100644 --- a/openHAB/SwiftUI/Rows/RollershutterRowView.swift +++ b/openHAB/SwiftUI/Rows/RollershutterRowView.swift @@ -39,7 +39,7 @@ struct RollershutterRowView: View { VStack(alignment: .leading, spacing: 2) { if let labelText = widget.labelText, !labelText.isEmpty { Text(labelText) - .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) + .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) } } @@ -52,7 +52,7 @@ struct RollershutterRowView: View { } label: { Image(systemSymbol: .chevronUp) .font(.title2) - .foregroundColor(Color(UIColor.systemBlue)) + .foregroundStyle(Color(UIColor.systemBlue)) } .buttonStyle(.plain) .sensoryHeavyFeedbackIfAvailable(trigger: triggerUpFeedback) @@ -64,7 +64,7 @@ struct RollershutterRowView: View { } label: { Image(systemSymbol: .stop) .font(.title2) - .foregroundColor(Color(UIColor.systemBlue)) + .foregroundStyle(Color(UIColor.systemBlue)) } .buttonStyle(.plain) .sensoryHeavyFeedbackIfAvailable(trigger: triggerStopFeedback) @@ -76,7 +76,7 @@ struct RollershutterRowView: View { } label: { Image(systemSymbol: .chevronDown) .font(.title2) - .foregroundColor(Color(UIColor.systemBlue)) + .foregroundStyle(Color(UIColor.systemBlue)) } .buttonStyle(.plain) .sensoryHeavyFeedbackIfAvailable(trigger: triggerDownFeedback) diff --git a/openHAB/SwiftUI/Rows/SegmentedRowView.swift b/openHAB/SwiftUI/Rows/SegmentedRowView.swift index 193c3b08e..54bad9a62 100644 --- a/openHAB/SwiftUI/Rows/SegmentedRowView.swift +++ b/openHAB/SwiftUI/Rows/SegmentedRowView.swift @@ -38,13 +38,13 @@ struct SegmentedRowView: View { if let labelText = widget.labelText, !labelText.isEmpty { Text(labelText) - .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) + .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) } Spacer() if let detailTextLabel = widget.labelValue, !detailTextLabel.isEmpty { Text(detailTextLabel) - .foregroundColor(widget.valuecolor.isEmpty ? Color(uiColor: UIColor.ohSecondaryLabel) : Color(fromString: widget.valuecolor)) + .foregroundStyle(widget.valuecolor.isEmpty ? Color(uiColor: UIColor.ohSecondaryLabel) : Color(fromString: widget.valuecolor)) .lineLimit(1) } diff --git a/openHAB/SwiftUI/Rows/SelectionRowView.swift b/openHAB/SwiftUI/Rows/SelectionRowView.swift index 7449ab050..d646dcdaa 100644 --- a/openHAB/SwiftUI/Rows/SelectionRowView.swift +++ b/openHAB/SwiftUI/Rows/SelectionRowView.swift @@ -34,7 +34,7 @@ struct SelectionRowView: View { if let labelText = widget.labelText, !labelText.isEmpty { Text(labelText) - .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) + .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) } Spacer() diff --git a/openHAB/SwiftUI/Rows/SetpointRowView.swift b/openHAB/SwiftUI/Rows/SetpointRowView.swift index 932e2a3e5..29d26bfda 100644 --- a/openHAB/SwiftUI/Rows/SetpointRowView.swift +++ b/openHAB/SwiftUI/Rows/SetpointRowView.swift @@ -47,7 +47,7 @@ struct SetpointRowView: View { if let labelText = widget.labelText, !labelText.isEmpty { Text(labelText) - .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) + .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) } Spacer() @@ -59,7 +59,7 @@ struct SetpointRowView: View { } label: { Image(systemSymbol: .chevronDown) .font(.body) - .foregroundColor(currentValue <= widget.minValue ? Color(.systemGray2) : Color(UIColor.systemBlue)) + .foregroundStyle(currentValue <= widget.minValue ? Color(.systemGray2) : Color(UIColor.systemBlue)) } .buttonStyle(.plain) .disabled(currentValue <= widget.minValue) @@ -68,7 +68,7 @@ struct SetpointRowView: View { Text(formattedValue) .font(.body.monospacedDigit()) - .foregroundColor(widget.valuecolor.isEmpty ? .secondary : Color(fromString: widget.valuecolor)) + .foregroundStyle(widget.valuecolor.isEmpty ? .secondary : Color(fromString: widget.valuecolor)) Button { triggerFeedback.toggle() @@ -76,7 +76,7 @@ struct SetpointRowView: View { } label: { Image(systemSymbol: .chevronUp) .font(.body) - .foregroundColor(currentValue >= widget.maxValue ? Color(.systemGray2) : Color(UIColor.systemBlue)) + .foregroundStyle(currentValue >= widget.maxValue ? Color(.systemGray2) : Color(UIColor.systemBlue)) } .buttonStyle(.plain) .disabled(currentValue >= widget.maxValue) diff --git a/openHAB/SwiftUI/Rows/SliderRowView.swift b/openHAB/SwiftUI/Rows/SliderRowView.swift index 6ad504000..27c6a89fa 100644 --- a/openHAB/SwiftUI/Rows/SliderRowView.swift +++ b/openHAB/SwiftUI/Rows/SliderRowView.swift @@ -19,7 +19,6 @@ struct SliderRowView: View { @State private var isDragging = false @State private var updateTask: Task? - @State private var lastSentTime = Date.distantPast private let throttleInterval: TimeInterval = 0.9 // in seconds @EnvironmentObject var viewModel: SitemapPageViewModel @@ -41,7 +40,7 @@ struct SliderRowView: View { if let labelText = widget.labelText, !labelText.isEmpty { Text(labelText) - .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) + .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) } Spacer() @@ -49,7 +48,7 @@ struct SliderRowView: View { if let value = widget.labelValue { Text(value) .font(.caption) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } } .contentShape(Rectangle()) // 🔍 Make row but not slider tappable @@ -68,6 +67,7 @@ struct SliderRowView: View { let now = Date() if now.timeIntervalSince(lastSendTime) > 0.2 { sendSliderUpdate(newValue) + lastSendTime = now } } } @@ -113,7 +113,7 @@ struct SliderRowView: View { guard !Task.isCancelled else { return } await MainActor.run { sendSliderUpdate(sliderValue) - lastSentTime = Date() + lastSendTime = Date() } } } diff --git a/openHAB/SwiftUI/Rows/SwitchRowView.swift b/openHAB/SwiftUI/Rows/SwitchRowView.swift index d9d959030..18030d42a 100644 --- a/openHAB/SwiftUI/Rows/SwitchRowView.swift +++ b/openHAB/SwiftUI/Rows/SwitchRowView.swift @@ -41,7 +41,7 @@ struct SwitchRowView: View { if let labelText = widget.labelText, !labelText.isEmpty { Text(labelText) - .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) + .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) } Spacer() @@ -49,7 +49,7 @@ struct SwitchRowView: View { if let labelValue = widget.labelValue, !labelValue.isEmpty { Text(labelValue) .font(.caption) - .foregroundColor(widget.valuecolor.isEmpty ? .secondary : Color(fromString: widget.valuecolor)) + .foregroundStyle(widget.valuecolor.isEmpty ? .secondary : Color(fromString: widget.valuecolor)) } Toggle("", isOn: Binding( diff --git a/openHAB/SwiftUI/Rows/TextInputRowView.swift b/openHAB/SwiftUI/Rows/TextInputRowView.swift index 7ae5e96dd..380f5a28a 100644 --- a/openHAB/SwiftUI/Rows/TextInputRowView.swift +++ b/openHAB/SwiftUI/Rows/TextInputRowView.swift @@ -29,7 +29,7 @@ struct TextInputRowView: View { if let labelText = widget.labelText, !labelText.isEmpty { Text(labelText) - .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) + .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) } Spacer() diff --git a/openHAB/SwiftUI/Rows/TextRowView.swift b/openHAB/SwiftUI/Rows/TextRowView.swift index 5dba3eb60..c50f5d554 100644 --- a/openHAB/SwiftUI/Rows/TextRowView.swift +++ b/openHAB/SwiftUI/Rows/TextRowView.swift @@ -24,14 +24,14 @@ struct TextRowView: View { .frame(width: 24, height: 24) Text(widget.labelText ?? "") - .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) + .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) Spacer() if let value = widget.labelValue { Text(value) .font(.body) - .foregroundColor(widget.valuecolor.isEmpty ? .secondary : Color(fromString: widget.valuecolor)) + .foregroundStyle(widget.valuecolor.isEmpty ? .secondary : Color(fromString: widget.valuecolor)) } } .contextMenu { diff --git a/openHAB/SwiftUI/Rows/VideoRowView.swift b/openHAB/SwiftUI/Rows/VideoRowView.swift index 9fdf10f41..fb4fe7ba9 100644 --- a/openHAB/SwiftUI/Rows/VideoRowView.swift +++ b/openHAB/SwiftUI/Rows/VideoRowView.swift @@ -40,7 +40,7 @@ struct VideoRowView: View { VStack(alignment: .leading, spacing: 8) { if let labelText = widget.labelText, !labelText.isEmpty, widget.labelSource == .sitemapDefinition { Text(labelText) - .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) + .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) } if let videoURL { @@ -52,20 +52,20 @@ struct VideoRowView: View { .resizable() .aspectRatio(aspectRatio, contentMode: .fit) .frame(height: 200) - .cornerRadius(8) + .clipShape(.rect(cornerRadius: 8)) } else { Rectangle() .fill(Color.gray.opacity(0.3)) .frame(height: 200) .aspectRatio(aspectRatio, contentMode: .fit) - .cornerRadius(8) + .clipShape(.rect(cornerRadius: 8)) } } else { // HLS/other video formats using VideoPlayer VideoPlayer(player: player) .frame(height: 200) .aspectRatio(aspectRatio, contentMode: .fit) - .cornerRadius(8) + .clipShape(.rect(cornerRadius: 8)) } if isLoading { @@ -93,15 +93,15 @@ struct VideoRowView: View { .frame(height: 200) .overlay( Text("No Video URL") - .foregroundColor(.secondary) + .foregroundStyle(.secondary) ) - .cornerRadius(8) + .clipShape(.rect(cornerRadius: 8)) } if let labelValue = widget.labelValue, !labelValue.isEmpty { Text(labelValue) .font(.caption) - .foregroundColor(widget.valuecolor.isEmpty ? .secondary : Color(fromString: widget.valuecolor)) + .foregroundStyle(widget.valuecolor.isEmpty ? .secondary : Color(fromString: widget.valuecolor)) } } } diff --git a/openHAB/SwiftUI/Rows/WebRowView.swift b/openHAB/SwiftUI/Rows/WebRowView.swift index 23f68adef..86038ac6f 100644 --- a/openHAB/SwiftUI/Rows/WebRowView.swift +++ b/openHAB/SwiftUI/Rows/WebRowView.swift @@ -21,17 +21,17 @@ struct WidgetWebViewContainer: View { VStack(alignment: .leading, spacing: 8) { if let labelText = widget.labelText, !labelText.isEmpty, widget.labelSource == .sitemapDefinition { Text(labelText) - .foregroundColor(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) + .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) } WebRowView(widget: widget) .frame(height: widget.preferredRowHeight) - .cornerRadius(8) + .clipShape(.rect(cornerRadius: 8)) if let labelValue = widget.labelValue, !labelValue.isEmpty { Text(labelValue) .font(.caption) - .foregroundColor(widget.valuecolor.isEmpty ? .secondary : Color(fromString: widget.valuecolor)) + .foregroundStyle(widget.valuecolor.isEmpty ? .secondary : Color(fromString: widget.valuecolor)) } } } From 9be604cdd8ad52c37b96d802c3154dd4aff1b07f Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Wed, 28 Jan 2026 21:03:54 +0100 Subject: [PATCH 352/476] Replace onTapGesture with Button where appropriate DrawerView and SelectionView Kept on onTapGesture: ScreenSaverView, SliderRowView, and HomeSelectionView Signed-off-by: Tim Mueller-Seydlitz --- openHAB/DrawerView.swift | 36 ++++++++++++++++++++---------------- openHAB/SelectionView.swift | 24 ++++++++++++------------ 2 files changed, 32 insertions(+), 28 deletions(-) diff --git a/openHAB/DrawerView.swift b/openHAB/DrawerView.swift index 40af99d05..ced561bbc 100644 --- a/openHAB/DrawerView.swift +++ b/openHAB/DrawerView.swift @@ -146,33 +146,37 @@ struct DrawerView: View { } private func menuEntry(image: Image, text: Text, goTo target: TargetController) -> some View { - HStack { - image - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: iconWidth, height: iconWidth) - text - } - .onTapGesture { + Button { dismiss() onDismiss(target) + } label: { + HStack { + image + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: iconWidth, height: iconWidth) + text + } } + .buttonStyle(.plain) } private func menuEntry(image: some View, goTo target: TargetController, @ViewBuilder label: () -> some View) -> some View { - HStack { - image - .aspectRatio(contentMode: .fit) - .frame(width: iconWidth, height: iconWidth) - label() - } - .contentShape(Rectangle()) // entire row tappable - .onTapGesture { + Button { dismiss() onDismiss(target) + } label: { + HStack { + image + .aspectRatio(contentMode: .fit) + .frame(width: iconWidth, height: iconWidth) + label() + } + .contentShape(Rectangle()) // entire row tappable } + .buttonStyle(.plain) } func systemMenuEntry(image: SFSymbol, text: String, goTo target: TargetController) -> some View { diff --git a/openHAB/SelectionView.swift b/openHAB/SelectionView.swift index 4b7760c84..675db12f8 100644 --- a/openHAB/SelectionView.swift +++ b/openHAB/SelectionView.swift @@ -24,23 +24,23 @@ struct SelectionView: View { var body: some View { List(0 ..< mappings.count, id: \.self) { index in let mapping = mappings[index] - HStack { - Text(mapping.label) - Spacer() - if selectionItemState == mapping.command { - Image(systemSymbol: .checkmark) - .foregroundStyle(.blue) - } - } - .contentShape(.interaction, Rectangle()) // Ensures entire row is tappable - .onTapGesture { + Button { selectionItemState = mappings[index].command Logger.selectionView.info("Selected mapping \(index)") onSelection(index) onDismiss?() + } label: { + HStack { + Text(mapping.label) + Spacer() + if selectionItemState == mapping.command { + Image(systemSymbol: .checkmark) + .foregroundStyle(.blue) + } + } + .contentShape(Rectangle()) } - .accessibilityElement(children: .combine) - .accessibilityAddTraits(.isButton) + .buttonStyle(.plain) } .navigationTitle(labelText ?? "Select Mapping") // Navigation title } From ddc2abc45d69876e96a7dedb5108edd61a3deecd Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Fri, 30 Jan 2026 07:46:11 +0100 Subject: [PATCH 353/476] Implement the press-and-release mappings for switches Signed-off-by: Tim Mueller-Seydlitz --- .../OpenHABCore/Model/OpenHABWidget.swift | 5 + .../Model/OpenHABWidgetMapping.swift | 7 ++ .../OpenHABCore/Util/BonjourService.swift | 2 +- .../CertificateStoreTests.swift | 12 +-- openHAB/SwiftUI/Rows/SegmentedRowView.swift | 47 +++++++- .../Views/Rows/SegmentSelectionView.swift | 101 +++++++++++++----- 6 files changed, 142 insertions(+), 32 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift index d7f42c4ec..0cbd3121c 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift @@ -164,6 +164,11 @@ public class OpenHABWidget: NSObject, MKAnnotation, Identifiable, ObservableObje } } + /// Returns true if any mapping has press-and-release behavior + public var hasPressReleaseMappings: Bool { + mappingsOrItemOptions.contains { $0.hasPressReleaseBehavior } + } + public var stateValueAsBool: Bool? { item?.state?.parseAsBool() } diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidgetMapping.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidgetMapping.swift index 132b53868..e7531f55a 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidgetMapping.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidgetMapping.swift @@ -19,12 +19,19 @@ public struct OpenHABWidgetMapping: Decodable, Sendable { public var icon: String? public var releaseCommand: String? + /// Returns true if this mapping has press-and-release behavior + public var hasPressReleaseBehavior: Bool { + guard let releaseCommand else { return false } + return !releaseCommand.isEmpty + } + public init(command: String?, label: String?, row: Int? = nil, column: Int? = nil, icon: String? = nil, releaseCommand: String? = nil) { self.command = command.orEmpty self.label = label.orEmpty self.row = row self.column = column self.icon = icon + self.releaseCommand = releaseCommand } } diff --git a/OpenHABCore/Sources/OpenHABCore/Util/BonjourService.swift b/OpenHABCore/Sources/OpenHABCore/Util/BonjourService.swift index 995cf1150..b701f8c10 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/BonjourService.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/BonjourService.swift @@ -356,7 +356,7 @@ public final class BonjourService: NSObject, BonjourServiceProtocol, NetServiceB logger.info("Found service: \(service.name, privacy: .public) type: \(service.type, privacy: .public)") let alreadyExists = state.withLockUnchecked { state in - state.discoveredServices.contains(where: { $0 === service }) + state.discoveredServices.contains { $0 === service } } if alreadyExists { diff --git a/OpenHABCore/Tests/OpenHABCoreTests/CertificateStoreTests.swift b/OpenHABCore/Tests/OpenHABCoreTests/CertificateStoreTests.swift index 20f079e67..021f76ff3 100644 --- a/OpenHABCore/Tests/OpenHABCoreTests/CertificateStoreTests.swift +++ b/OpenHABCore/Tests/OpenHABCoreTests/CertificateStoreTests.swift @@ -14,6 +14,12 @@ import Foundation import os.log import Testing +// MARK: - Test Errors + +enum CertificateStoreTestError: Error { + case resourceNotFound(String) +} + @Suite("CertificateStore Tests", .serialized) struct CertificateStoreTests { // Helper to load the bundled test certificate data @@ -148,9 +154,3 @@ struct CertificateStoreTests { try? FileManager.default.removeItem(at: tempPath) } } - -// MARK: - Test Errors - -enum CertificateStoreTestError: Error { - case resourceNotFound(String) -} diff --git a/openHAB/SwiftUI/Rows/SegmentedRowView.swift b/openHAB/SwiftUI/Rows/SegmentedRowView.swift index 54bad9a62..0209ae64d 100644 --- a/openHAB/SwiftUI/Rows/SegmentedRowView.swift +++ b/openHAB/SwiftUI/Rows/SegmentedRowView.swift @@ -25,6 +25,7 @@ struct SegmentedRowView: View { } @State private var selectedIndex: Int? + @State private var pressedIndex: Int? private var isMomentary: Bool { mappings.count == 1 || widget.item?.state == "NULL" @@ -49,7 +50,10 @@ struct SegmentedRowView: View { } if !mappings.isEmpty { - if isMomentary { + if widget.hasPressReleaseMappings { + // Press-release buttons for mappings with releaseCommand + pressReleaseButtons + } else if isMomentary { HStack { ForEach(mappings.indices, id: \.self) { index in Button { @@ -93,6 +97,47 @@ struct SegmentedRowView: View { selectedIndex = widget.mapCommandtoIndex(with: newState) } } + + @ViewBuilder + private var pressReleaseButtons: some View { + HStack { + ForEach(mappings.indices, id: \.self) { index in + let mapping = mappings[index] + pressReleaseButton(for: mapping, at: index) + } + } + } + + @ViewBuilder + private func pressReleaseButton(for mapping: OpenHABWidgetMapping, at index: Int) -> some View { + Text(mapping.label) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(pressedIndex == index ? Color.accentColor.opacity(0.3) : Color(uiColor: .systemGray5)) + ) + .foregroundStyle(pressedIndex == index ? Color.accentColor : .primary) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { _ in + if pressedIndex != index { + pressedIndex = index + // Send command on press + logger.info("Sending press command: \(mapping.command)") + viewModel.sendCommand(widget.item, commandToSend: mapping.command) + } + } + .onEnded { _ in + pressedIndex = nil + // Send release command on release + if let releaseCommand = mapping.releaseCommand, !releaseCommand.isEmpty { + logger.info("Sending release command: \(releaseCommand)") + viewModel.sendCommand(widget.item, commandToSend: releaseCommand) + } + } + ) + } } #Preview { diff --git a/openHABWatch/Views/Rows/SegmentSelectionView.swift b/openHABWatch/Views/Rows/SegmentSelectionView.swift index cba2bd75b..23d0b7a5a 100644 --- a/openHABWatch/Views/Rows/SegmentSelectionView.swift +++ b/openHABWatch/Views/Rows/SegmentSelectionView.swift @@ -17,36 +17,21 @@ struct SegmentSelectionView: View { @ObservedObject var widget: OpenHABWidget @Environment(\.dismiss) private var dismiss @State private var pendingValue: String? + @State private var pressedIndex: Int? var body: some View { ScrollView { LazyVStack(spacing: 12) { ForEach(0 ..< widget.mappingsOrItemOptions.count, id: \.self) { index in - Button { - selectOption(at: index) - } label: { - HStack { - Text(widget.mappingsOrItemOptions[index].label) - .foregroundColor(.primary) - .multilineTextAlignment(.leading) - Spacer() - if isSelected(index: index) { - Image(systemSymbol: .checkmark) - .foregroundColor(.accentColor) - .font(.caption.weight(.bold)) - } - } - .padding() - .background( - RoundedRectangle(cornerRadius: 8) - .fill(isSelected(index: index) ? Color.accentColor.opacity(0.2) : Color.clear) - ) - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke(Color.secondary.opacity(0.3), lineWidth: 1) - ) + let mapping = widget.mappingsOrItemOptions[index] + + if widget.hasPressReleaseMappings { + // Press-release button for mappings with releaseCommand + pressReleaseButton(for: mapping, at: index) + } else { + // Standard button for regular mappings + standardButton(for: mapping, at: index) } - .buttonStyle(PlainButtonStyle()) } } .padding() @@ -55,6 +40,74 @@ struct SegmentSelectionView: View { .navigationBarTitleDisplayMode(.inline) } + @ViewBuilder + private func standardButton(for mapping: OpenHABWidgetMapping, at index: Int) -> some View { + Button { + selectOption(at: index) + } label: { + optionLabel(for: mapping, at: index, isPressed: false) + } + .buttonStyle(PlainButtonStyle()) + } + + @ViewBuilder + private func pressReleaseButton(for mapping: OpenHABWidgetMapping, at index: Int) -> some View { + optionLabel(for: mapping, at: index, isPressed: pressedIndex == index) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { _ in + if pressedIndex != index { + pressedIndex = index + // Send command on press + Logger.rowViews.info("Sending press command: \(mapping.command)") + widget.sendCommand(mapping.command) + } + } + .onEnded { _ in + pressedIndex = nil + // Send release command on release + if let releaseCommand = mapping.releaseCommand, !releaseCommand.isEmpty { + Logger.rowViews.info("Sending release command: \(releaseCommand)") + widget.sendCommand(releaseCommand) + } + } + ) + } + + @ViewBuilder + private func optionLabel(for mapping: OpenHABWidgetMapping, at index: Int, isPressed: Bool) -> some View { + HStack { + Text(mapping.label) + .foregroundColor(.primary) + .multilineTextAlignment(.leading) + Spacer() + if isSelected(index: index), !widget.hasPressReleaseMappings { + Image(systemSymbol: .checkmark) + .foregroundColor(.accentColor) + .font(.caption.weight(.bold)) + } + } + .padding() + .background( + RoundedRectangle(cornerRadius: 8) + .fill(backgroundColor(for: index, isPressed: isPressed)) + ) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.secondary.opacity(0.3), lineWidth: 1) + ) + } + + private func backgroundColor(for index: Int, isPressed: Bool) -> Color { + if isPressed { + Color.accentColor.opacity(0.4) + } else if isSelected(index: index) { + Color.accentColor.opacity(0.2) + } else { + Color.clear + } + } + private func isSelected(index: Int) -> Bool { guard case let .segmented(value) = widget.stateEnumBinding else { return false } return value == index From 4019242f409519cc5a7c63f35e72d47c905f00e1 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Fri, 30 Jan 2026 13:59:31 +0100 Subject: [PATCH 354/476] =?UTF-8?q?Some=20improvements=20for=20current=20t?= =?UTF-8?q?arget=20iOS=2016=20foregroundColor()=20=E2=86=92=20foregroundSt?= =?UTF-8?q?yle()=20(Watch=20App)=20=20@ObservedObject=20Inside=20Observabl?= =?UTF-8?q?eObject=20Class=20Redundant=20MainActor.run=20in=20@MainActor?= =?UTF-8?q?=20Class=20onTapGesture=20=E2=86=92=20Button=20for=20Accessibil?= =?UTF-8?q?ity=20=20=20for=20SliderRowView=20Force=20Unwraps=20in=20Previe?= =?UTF-8?q?w=20Code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tim Mueller-Seydlitz --- openHAB/SitemapPageViewModel.swift | 22 ++++------- openHAB/SwiftUI/Rows/SliderRowView.swift | 39 ++++++++++--------- openHAB/SwiftUI/SitemapPageView.swift | 17 ++++---- .../Views/PreferencesSwiftUIView.swift | 2 +- openHABWatch/Views/Rows/SegmentRow.swift | 4 +- .../Views/Rows/SegmentSelectionView.swift | 4 +- openHABWatch/Views/Utils/ColorSelection.swift | 2 +- .../Views/Utils/DetailTextLabelView.swift | 2 +- openHABWatch/Views/Utils/TextLabelView.swift | 2 +- 9 files changed, 45 insertions(+), 49 deletions(-) diff --git a/openHAB/SitemapPageViewModel.swift b/openHAB/SitemapPageViewModel.swift index 87cb7af59..d5154790d 100644 --- a/openHAB/SitemapPageViewModel.swift +++ b/openHAB/SitemapPageViewModel.swift @@ -41,7 +41,7 @@ class SitemapPageViewModel: ObservableObject { @Published var openHABRootUrl: String? @Published var showSearchField = false - @ObservedObject var networkTracker = MainActorNetworkTracker.shared + let networkTracker = MainActorNetworkTracker.shared private var openAPIService: OpenAPIService? private var activeConnectionInfo: ConnectionInfo? private var pageHandlingTask: Task? @@ -208,11 +208,9 @@ class SitemapPageViewModel: ObservableObject { return } logger.error("Decoding error: \(error.localizedDescription)") - await MainActor.run { - self.error = SitemapPageError.serviceUnavailable - self.isLoading = false - self.isUpdating = false - } + self.error = SitemapPageError.serviceUnavailable + isLoading = false + isUpdating = false } catch let error as ClientError { if let urlError = error.underlyingError as? URLError, urlError.code == .cancelled { logger.info("Task cancelled (URLError: cancelled)") @@ -227,9 +225,7 @@ class SitemapPageViewModel: ObservableObject { return } logger.error("ClientError: \(error.localizedDescription)") - await MainActor.run { - self.error = SitemapPageError.serviceUnavailable - } + self.error = SitemapPageError.serviceUnavailable } isLoading = false isUpdating = false @@ -246,11 +242,9 @@ class SitemapPageViewModel: ObservableObject { return } logger.error("❌ Unhandled pageHandlingTask error: \(error.localizedDescription)") - await MainActor.run { - self.error = SitemapPageError.serviceUnavailable - self.isLoading = false - self.isUpdating = false - } + self.error = SitemapPageError.serviceUnavailable + isLoading = false + isUpdating = false } } } diff --git a/openHAB/SwiftUI/Rows/SliderRowView.swift b/openHAB/SwiftUI/Rows/SliderRowView.swift index 27c6a89fa..ed38ef216 100644 --- a/openHAB/SwiftUI/Rows/SliderRowView.swift +++ b/openHAB/SwiftUI/Rows/SliderRowView.swift @@ -34,29 +34,30 @@ struct SliderRowView: View { var body: some View { HStack { - HStack { - IconView(widget: widget) - .frame(width: 24, height: 24) - - if let labelText = widget.labelText, !labelText.isEmpty { - Text(labelText) - .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) - } + Button { + viewModel.sendCommand(widget.item, commandToSend: sliderValue <= widget.minValue ? "ON" : "OFF") + } label: { + HStack { + IconView(widget: widget) + .frame(width: 24, height: 24) + + if let labelText = widget.labelText, !labelText.isEmpty { + Text(labelText) + .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) + } - Spacer() + Spacer() - if let value = widget.labelValue { - Text(value) - .font(.caption) - .foregroundStyle(.secondary) - } - } - .contentShape(Rectangle()) // 🔍 Make row but not slider tappable - .onTapGesture { - if widget.switchSupport, !(widget.readOnly ?? false) { - viewModel.sendCommand(widget.item, commandToSend: sliderValue <= widget.minValue ? "ON" : "OFF") + if let value = widget.labelValue { + Text(value) + .font(.caption) + .foregroundStyle(.secondary) + } } + .contentShape(Rectangle()) } + .buttonStyle(.plain) + .disabled(!widget.switchSupport || (widget.readOnly ?? false)) Slider( value: Binding( diff --git a/openHAB/SwiftUI/SitemapPageView.swift b/openHAB/SwiftUI/SitemapPageView.swift index 62a7c1404..f49a22d18 100644 --- a/openHAB/SwiftUI/SitemapPageView.swift +++ b/openHAB/SwiftUI/SitemapPageView.swift @@ -82,14 +82,15 @@ struct SitemapPageView: View { extension SitemapPageView { /// Creates placeholder widgets for skeleton loading state var placeholderWidgets: [OpenHABWidget] { - [ - PreviewConstants.openHABSitemapPage!.widgets[3], - PreviewConstants.openHABSitemapPage!.widgets[5], - PreviewConstants.openHABSitemapPage!.widgets[2], - PreviewConstants.openHABSitemapPage!.widgets[6], - PreviewConstants.openHABSitemapPage!.widgets[17], - PreviewConstants.openHABSitemapPage!.widgets[4] - ] + guard let page = PreviewConstants.openHABSitemapPage else { return [] } + return [ + page.widgets[safe: 3], + page.widgets[safe: 5], + page.widgets[safe: 2], + page.widgets[safe: 6], + page.widgets[safe: 17], + page.widgets[safe: 4] + ].compactMap(\.self) } } diff --git a/openHABWatch/Views/PreferencesSwiftUIView.swift b/openHABWatch/Views/PreferencesSwiftUIView.swift index 0b2cd41fb..b19319db8 100644 --- a/openHABWatch/Views/PreferencesSwiftUIView.swift +++ b/openHABWatch/Views/PreferencesSwiftUIView.swift @@ -39,7 +39,7 @@ struct CompactLabeledContentStyle: LabeledContentStyle { Spacer() configuration.content .font(.footnote) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } // .padding(.vertical, 4) // Reduces vertical space } diff --git a/openHABWatch/Views/Rows/SegmentRow.swift b/openHABWatch/Views/Rows/SegmentRow.swift index a8a144946..ee48ec22f 100644 --- a/openHABWatch/Views/Rows/SegmentRow.swift +++ b/openHABWatch/Views/Rows/SegmentRow.swift @@ -56,11 +56,11 @@ struct SegmentRow: View { return value == index }) { Text(widget.mappingsOrItemOptions[selectedIndex].label) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) .lineLimit(1) } Image(systemSymbol: .chevronRight) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) .font(.caption) } .padding(.horizontal, 8) diff --git a/openHABWatch/Views/Rows/SegmentSelectionView.swift b/openHABWatch/Views/Rows/SegmentSelectionView.swift index 23d0b7a5a..6e7d18f35 100644 --- a/openHABWatch/Views/Rows/SegmentSelectionView.swift +++ b/openHABWatch/Views/Rows/SegmentSelectionView.swift @@ -78,12 +78,12 @@ struct SegmentSelectionView: View { private func optionLabel(for mapping: OpenHABWidgetMapping, at index: Int, isPressed: Bool) -> some View { HStack { Text(mapping.label) - .foregroundColor(.primary) + .foregroundStyle(.primary) .multilineTextAlignment(.leading) Spacer() if isSelected(index: index), !widget.hasPressReleaseMappings { Image(systemSymbol: .checkmark) - .foregroundColor(.accentColor) + .foregroundStyle(Color.accentColor) .font(.caption.weight(.bold)) } } diff --git a/openHABWatch/Views/Utils/ColorSelection.swift b/openHABWatch/Views/Utils/ColorSelection.swift index d5c6cb97f..b0175fc52 100644 --- a/openHABWatch/Views/Utils/ColorSelection.swift +++ b/openHABWatch/Views/Utils/ColorSelection.swift @@ -114,7 +114,7 @@ struct ColorSelection: View { // Add the gestures and visuals to the handle return Circle() .overlay(thumb.isDragging ? Circle().stroke(Color.white, lineWidth: 2) : nil) - .foregroundColor(.white) + .foregroundStyle(.white) .frame(width: 25, height: 25, alignment: .center) .position(limitCircle(CGPoint(x: xpos, y: ypos), geometry.size, thumb.translation)) .animation(.interactiveSpring(), value: thumb.isDragging) diff --git a/openHABWatch/Views/Utils/DetailTextLabelView.swift b/openHABWatch/Views/Utils/DetailTextLabelView.swift index 83f264527..02064c228 100644 --- a/openHABWatch/Views/Utils/DetailTextLabelView.swift +++ b/openHABWatch/Views/Utils/DetailTextLabelView.swift @@ -20,7 +20,7 @@ struct DetailTextLabelView: View { Text(label) .font(.footnote) .lineLimit(1) - .foregroundColor(!widget.valuecolor.isEmpty ? Color(widget.valuecolor) : .secondary) + .foregroundStyle(!widget.valuecolor.isEmpty ? Color(widget.valuecolor) : .secondary) } } } diff --git a/openHABWatch/Views/Utils/TextLabelView.swift b/openHABWatch/Views/Utils/TextLabelView.swift index 4ca78109d..3c4aa4efc 100644 --- a/openHABWatch/Views/Utils/TextLabelView.swift +++ b/openHABWatch/Views/Utils/TextLabelView.swift @@ -21,7 +21,7 @@ struct TextLabelView: View { Text(widget.labelText ?? "") .font(.caption) .lineLimit(lineLimit) - .foregroundColor(!widget.labelcolor.isEmpty ? Color(fromString: widget.labelcolor) : .primary) + .foregroundStyle(!widget.labelcolor.isEmpty ? Color(fromString: widget.labelcolor) : .primary) } } From b898bfb0f9d87adb45880a33ed06559b39ab5ebe Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Fri, 30 Jan 2026 18:43:18 +0100 Subject: [PATCH 355/476] Fix valuecolor support and match UIKit styling in SwiftUI views - Add valuecolor support to SelectionView and SelectionRowView - Selection popup checkmark now respects widget.valuecolor - Replace Picker with styled Text to show selected value with correct color - Pass valuecolor from EmbeddingRowView and OpenHABSitemapViewController - Fix valuecolor for group widgets in GenericRowView - Apply widget.valuecolor to value text (was hardcoded to .secondary) - Apply widget.labelcolor to label text for consistency - Increase icon size from 24x24 to 32x32 in all row views - Matches UIKit cell icon size for visual consistency - Updated: Generic, Text, Switch, Selection, Slider, Setpoint, Segmented, Rollershutter, ColorPicker, ColorTemperaturePicker, DatePickerInput, TextInput, and ButtonGrid row views - Make Frame headers more compact - Reduce vertical padding from 8pt to 4pt for frames with labels - Use 0pt padding for empty frame labels (matching UIKit behavior) Signed-off-by: Tim Mueller-Seydlitz --- openHAB/OpenHABSitemapViewController.swift | 3 +- openHAB/SelectionView.swift | 22 +++++++- openHAB/SwiftUI/EmbeddingRowView.swift | 17 ++++++- openHAB/SwiftUI/Rows/ButtonGridRowView.swift | 2 +- openHAB/SwiftUI/Rows/ColorPickerRowView.swift | 2 +- .../Rows/ColorTemperaturePickerRowView.swift | 2 +- .../SwiftUI/Rows/DatePickerInputRowView.swift | 2 +- openHAB/SwiftUI/Rows/GenericRowView.swift | 7 ++- .../SwiftUI/Rows/RollershutterRowView.swift | 2 +- openHAB/SwiftUI/Rows/SegmentedRowView.swift | 2 +- openHAB/SwiftUI/Rows/SelectionRowView.swift | 50 +++++++------------ openHAB/SwiftUI/Rows/SetpointRowView.swift | 2 +- openHAB/SwiftUI/Rows/SliderRowView.swift | 2 +- openHAB/SwiftUI/Rows/SwitchRowView.swift | 2 +- openHAB/SwiftUI/Rows/TextInputRowView.swift | 2 +- openHAB/SwiftUI/Rows/TextRowView.swift | 2 +- 16 files changed, 72 insertions(+), 49 deletions(-) diff --git a/openHAB/OpenHABSitemapViewController.swift b/openHAB/OpenHABSitemapViewController.swift index f1419204a..88a2652fd 100644 --- a/openHAB/OpenHABSitemapViewController.swift +++ b/openHAB/OpenHABSitemapViewController.swift @@ -812,7 +812,8 @@ extension OpenHABSitemapViewController: UITableViewDelegate, UITableViewDataSour rootView: SelectionView( labelText: widget.labelText, mappings: widget.mappingsOrItemOptions, - selectionItemState: selectionItemState + selectionItemState: selectionItemState, + valuecolor: widget.valuecolor ) { selectedMappingIndex in let selectedMapping: OpenHABWidgetMapping = widget.mappingsOrItemOptions[selectedMappingIndex] self.sendCommand(widget.item, commandToSend: selectedMapping.command) diff --git a/openHAB/SelectionView.swift b/openHAB/SelectionView.swift index 675db12f8..213b9d7ac 100644 --- a/openHAB/SelectionView.swift +++ b/openHAB/SelectionView.swift @@ -9,6 +9,7 @@ // // SPDX-License-Identifier: EPL-2.0 +import CommonUI import OpenHABCore import os.log import SFSafeSymbols @@ -18,6 +19,7 @@ struct SelectionView: View { let labelText: String? var mappings: [OpenHABWidgetMapping] // List of mappings (instead of AnyHashable, we use a concrete type) @State var selectionItemState: String? // To track the selected item state + var valuecolor = "" // Color for the selected value indicator var onSelection: (Int) -> Void // Closure to handle selection var onDismiss: (() -> Void)? // Closure to handle dismissal after selection @@ -35,7 +37,7 @@ struct SelectionView: View { Spacer() if selectionItemState == mapping.command { Image(systemSymbol: .checkmark) - .foregroundStyle(.blue) + .foregroundStyle(valuecolor.isEmpty ? .blue : Color(fromString: valuecolor)) } } .contentShape(Rectangle()) @@ -46,7 +48,7 @@ struct SelectionView: View { } } -#Preview { +#Preview("Default") { SelectionView( labelText: "Test Label", mappings: [ @@ -60,3 +62,19 @@ struct SelectionView: View { print("Dismissing selection view") } } + +#Preview("With Valuecolor") { + SelectionView( + labelText: "Test Label", + mappings: [ + OpenHABWidgetMapping(command: "ON", label: "On"), + OpenHABWidgetMapping(command: "OFF", label: "Off") + ], + selectionItemState: "OFF", + valuecolor: "red" + ) { selectedMappingIndex in + print("Selected mapping at index \(selectedMappingIndex)") + } onDismiss: { + print("Dismissing selection view") + } +} diff --git a/openHAB/SwiftUI/EmbeddingRowView.swift b/openHAB/SwiftUI/EmbeddingRowView.swift index 7eeef9bb5..0931355ae 100644 --- a/openHAB/SwiftUI/EmbeddingRowView.swift +++ b/openHAB/SwiftUI/EmbeddingRowView.swift @@ -20,6 +20,18 @@ struct EmbeddingRowView: View { @State private var showInputAlert = false @State private var inputText = "" + /// Insets for different widget types - Frame headers are more compact + private var frameInsets: EdgeInsets { + if widget.type == .frame { + // Empty frame labels should be minimal, matching UIKit's 0pt height + let hasLabel = !(widget.label.isEmpty) + return hasLabel + ? EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16) + : EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16) + } + return EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16) + } + var body: some View { Group { if let linkedPage = widget.linkedPage { @@ -48,7 +60,7 @@ struct EmbeddingRowView: View { } } .contentShape(Rectangle()) - .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) + .listRowInsets(frameInsets) .alert("Input", isPresented: $showInputAlert) { if let widget = selectedWidget { TextField("Enter value", text: $inputText) @@ -67,7 +79,8 @@ struct EmbeddingRowView: View { SelectionView( labelText: widget.labelText, mappings: widget.mappingsOrItemOptions, - selectionItemState: widget.item?.state + selectionItemState: widget.item?.state, + valuecolor: widget.valuecolor ) { selectedMappingIndex in let selectedMapping = widget.mappingsOrItemOptions[selectedMappingIndex] viewModel.sendCommand(widget.item, commandToSend: selectedMapping.command) diff --git a/openHAB/SwiftUI/Rows/ButtonGridRowView.swift b/openHAB/SwiftUI/Rows/ButtonGridRowView.swift index 01ace0544..a565e451b 100644 --- a/openHAB/SwiftUI/Rows/ButtonGridRowView.swift +++ b/openHAB/SwiftUI/Rows/ButtonGridRowView.swift @@ -132,7 +132,7 @@ struct ButtonGridRowView: View { if showLabelAndIcon { HStack { IconView(widget: widget) - .frame(width: 24, height: 24) + .frame(width: 32, height: 32) if let labelText = widget.labelText, !labelText.isEmpty { Text(labelText) diff --git a/openHAB/SwiftUI/Rows/ColorPickerRowView.swift b/openHAB/SwiftUI/Rows/ColorPickerRowView.swift index 26451398e..c801cfc01 100644 --- a/openHAB/SwiftUI/Rows/ColorPickerRowView.swift +++ b/openHAB/SwiftUI/Rows/ColorPickerRowView.swift @@ -27,7 +27,7 @@ struct ColorPickerRowView: View { var body: some View { HStack { IconView(widget: widget) - .frame(width: 24, height: 24) + .frame(width: 32, height: 32) if let labelText = widget.labelText, !labelText.isEmpty { Text(labelText) diff --git a/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift b/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift index 2160c9841..24cb24f3c 100644 --- a/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift +++ b/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift @@ -82,7 +82,7 @@ struct ColorTemperaturePickerRowView: View { var body: some View { HStack(alignment: .top) { IconView(widget: widget) - .frame(width: 24, height: 24) + .frame(width: 32, height: 32) VStack(spacing: 8) { HStack { diff --git a/openHAB/SwiftUI/Rows/DatePickerInputRowView.swift b/openHAB/SwiftUI/Rows/DatePickerInputRowView.swift index 929d9c1ca..6a1893a5a 100644 --- a/openHAB/SwiftUI/Rows/DatePickerInputRowView.swift +++ b/openHAB/SwiftUI/Rows/DatePickerInputRowView.swift @@ -37,7 +37,7 @@ struct DatePickerInputRowView: View { var body: some View { HStack { IconView(widget: widget) - .frame(width: 24, height: 24) + .frame(width: 32, height: 32) if let labelText = widget.labelText, !labelText.isEmpty { Text(labelText) diff --git a/openHAB/SwiftUI/Rows/GenericRowView.swift b/openHAB/SwiftUI/Rows/GenericRowView.swift index c5ee4741e..1ac0913bc 100644 --- a/openHAB/SwiftUI/Rows/GenericRowView.swift +++ b/openHAB/SwiftUI/Rows/GenericRowView.swift @@ -20,13 +20,16 @@ struct GenericRowView: View { var body: some View { HStack { IconView(widget: widget) - .frame(width: 24, height: 24) + .frame(width: 32, height: 32) Text(widget.labelText ?? "") + .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) + Spacer() + if let value = widget.labelValue { Text(value) - .foregroundStyle(.secondary) + .foregroundStyle(widget.valuecolor.isEmpty ? .secondary : Color(fromString: widget.valuecolor)) } } } diff --git a/openHAB/SwiftUI/Rows/RollershutterRowView.swift b/openHAB/SwiftUI/Rows/RollershutterRowView.swift index 0b69b44c5..4eb20e0a1 100644 --- a/openHAB/SwiftUI/Rows/RollershutterRowView.swift +++ b/openHAB/SwiftUI/Rows/RollershutterRowView.swift @@ -34,7 +34,7 @@ struct RollershutterRowView: View { VStack(alignment: .leading, spacing: 8) { HStack { IconView(widget: widget) - .frame(width: 24, height: 24) + .frame(width: 32, height: 32) VStack(alignment: .leading, spacing: 2) { if let labelText = widget.labelText, !labelText.isEmpty { diff --git a/openHAB/SwiftUI/Rows/SegmentedRowView.swift b/openHAB/SwiftUI/Rows/SegmentedRowView.swift index 0209ae64d..7df652177 100644 --- a/openHAB/SwiftUI/Rows/SegmentedRowView.swift +++ b/openHAB/SwiftUI/Rows/SegmentedRowView.swift @@ -34,7 +34,7 @@ struct SegmentedRowView: View { var body: some View { HStack { IconView(widget: widget) - .frame(width: 24, height: 24) + .frame(width: 32, height: 32) .padding(.top, 4) // Align with text if let labelText = widget.labelText, !labelText.isEmpty { diff --git a/openHAB/SwiftUI/Rows/SelectionRowView.swift b/openHAB/SwiftUI/Rows/SelectionRowView.swift index d646dcdaa..beded8c35 100644 --- a/openHAB/SwiftUI/Rows/SelectionRowView.swift +++ b/openHAB/SwiftUI/Rows/SelectionRowView.swift @@ -11,26 +11,29 @@ import CommonUI import OpenHABCore -import os.log +import SFSafeSymbols import SwiftUI struct SelectionRowView: View { @ObservedObject var widget: OpenHABWidget - @State private var selectedIndex = 0 - @ScaledMetric(relativeTo: .body) private var pickerHeight: CGFloat = 24 - @EnvironmentObject var viewModel: SitemapPageViewModel - - private let logger = Logger(subsystem: "org.openhab", category: "WidgetSelectionView") private var mappings: [OpenHABWidgetMapping] { widget.mappingsOrItemOptions } + /// Returns the label of the currently selected mapping, or the widget's labelValue as fallback + private var selectedValueText: String? { + if let state = widget.item?.state, + let selectedMapping = mappings.first(where: { $0.command == state }) { + return selectedMapping.label + } + return widget.labelValue + } + var body: some View { HStack { IconView(widget: widget) - .frame(width: 24, height: 24) - .padding(.top, 4) // Align with text + .frame(width: 32, height: 32) if let labelText = widget.labelText, !labelText.isEmpty { Text(labelText) @@ -39,30 +42,15 @@ struct SelectionRowView: View { Spacer() - if !mappings.isEmpty { - Picker("", selection: $selectedIndex) { - ForEach(mappings.indices, id: \.self) { index in - Text(mappings[index].label) - .tag(index) - } - } - .pickerStyle(.menu) - .padding(.bottom, -4) - .frame(height: pickerHeight) // 👈 Restrict height of the Picker - .onChange(of: selectedIndex) { newIndex in - guard let mapping = mappings[safe: newIndex] else { return } - logger.info("Selection changed to: \(mapping.label)") - viewModel.sendCommand(widget.item, commandToSend: mapping.command) - } - .disabled(widget.readOnly ?? false) + if let valueText = selectedValueText, !valueText.isEmpty { + Text(valueText) + .foregroundStyle(widget.valuecolor.isEmpty ? .secondary : Color(fromString: widget.valuecolor)) } - } - .onAppear { - selectedIndex = widget.mapCommandtoIndex(with: widget.item?.state) - } - .onChange(of: widget.item?.state ?? "") { newState in - guard !newState.isEmpty else { return } - selectedIndex = widget.mapCommandtoIndex(with: newState) + + // Show disclosure indicator to indicate tappable selection + Image(systemSymbol: .chevronUpChevronDown) + .font(.caption) + .foregroundStyle(.secondary) } } } diff --git a/openHAB/SwiftUI/Rows/SetpointRowView.swift b/openHAB/SwiftUI/Rows/SetpointRowView.swift index 29d26bfda..070cac246 100644 --- a/openHAB/SwiftUI/Rows/SetpointRowView.swift +++ b/openHAB/SwiftUI/Rows/SetpointRowView.swift @@ -43,7 +43,7 @@ struct SetpointRowView: View { var body: some View { HStack { IconView(widget: widget) - .frame(width: 24, height: 24) + .frame(width: 32, height: 32) if let labelText = widget.labelText, !labelText.isEmpty { Text(labelText) diff --git a/openHAB/SwiftUI/Rows/SliderRowView.swift b/openHAB/SwiftUI/Rows/SliderRowView.swift index ed38ef216..eee804ae7 100644 --- a/openHAB/SwiftUI/Rows/SliderRowView.swift +++ b/openHAB/SwiftUI/Rows/SliderRowView.swift @@ -39,7 +39,7 @@ struct SliderRowView: View { } label: { HStack { IconView(widget: widget) - .frame(width: 24, height: 24) + .frame(width: 32, height: 32) if let labelText = widget.labelText, !labelText.isEmpty { Text(labelText) diff --git a/openHAB/SwiftUI/Rows/SwitchRowView.swift b/openHAB/SwiftUI/Rows/SwitchRowView.swift index 18030d42a..81594d28a 100644 --- a/openHAB/SwiftUI/Rows/SwitchRowView.swift +++ b/openHAB/SwiftUI/Rows/SwitchRowView.swift @@ -37,7 +37,7 @@ struct SwitchRowView: View { var body: some View { HStack { IconView(widget: widget) - .frame(width: 24, height: 24) + .frame(width: 32, height: 32) if let labelText = widget.labelText, !labelText.isEmpty { Text(labelText) diff --git a/openHAB/SwiftUI/Rows/TextInputRowView.swift b/openHAB/SwiftUI/Rows/TextInputRowView.swift index 380f5a28a..b1521e135 100644 --- a/openHAB/SwiftUI/Rows/TextInputRowView.swift +++ b/openHAB/SwiftUI/Rows/TextInputRowView.swift @@ -25,7 +25,7 @@ struct TextInputRowView: View { var body: some View { HStack { IconView(widget: widget) - .frame(width: 24, height: 24) + .frame(width: 32, height: 32) if let labelText = widget.labelText, !labelText.isEmpty { Text(labelText) diff --git a/openHAB/SwiftUI/Rows/TextRowView.swift b/openHAB/SwiftUI/Rows/TextRowView.swift index c50f5d554..32f77433d 100644 --- a/openHAB/SwiftUI/Rows/TextRowView.swift +++ b/openHAB/SwiftUI/Rows/TextRowView.swift @@ -21,7 +21,7 @@ struct TextRowView: View { var body: some View { HStack { IconView(widget: widget) - .frame(width: 24, height: 24) + .frame(width: 32, height: 32) Text(widget.labelText ?? "") .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) From 0dd0119257928dab2b317a5a7d57e59aa740704f Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Fri, 30 Jan 2026 18:54:58 +0100 Subject: [PATCH 356/476] Pass iconColor to OpenHABImageProcessor in SwiftUI IconView Enable SVG color preprocessing for non-iconify icons in the SwiftUI implementation, matching the behavior of the Watch IconView and UIKit cells. - Add processorIconColor(for:) helper to determine when to apply color - Skip color preprocessing for iconify icons (they handle their own colors) - Pass widget.iconColor to processor for other SVG icons Signed-off-by: Tim Mueller-Seydlitz --- openHAB/SwiftUI/IconView.swift | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/openHAB/SwiftUI/IconView.swift b/openHAB/SwiftUI/IconView.swift index 0d73ca159..d01cf8cb3 100644 --- a/openHAB/SwiftUI/IconView.swift +++ b/openHAB/SwiftUI/IconView.swift @@ -96,7 +96,7 @@ struct IconView: View { KFImage(iconURL) .retry(maxCount: 3, interval: .seconds(5)) .resizable() - .setProcessor(OpenHABImageProcessor()) + .setProcessor(OpenHABImageProcessor(iconColor: processorIconColor(for: iconURL))) .onFailure { error in logger.error("Icon loading failed for widget \(widget.label): \(error.localizedDescription)") logger.error("Failed URL: \(iconURL.absoluteString)") @@ -121,6 +121,13 @@ struct IconView: View { } } } + + /// Returns the icon color for SVG preprocessing, or nil for iconify icons (they handle their own colors) + private func processorIconColor(for url: URL) -> String? { + // Don't apply color preprocessing for iconify icons + guard url.host != "api.iconify.design" else { return nil } + return widget.iconColor.isEmpty ? nil : widget.iconColor + } } // MARK: - Convenience Extensions From bdeb9c265d132fea24d0543d974e8b650413c5a2 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Fri, 30 Jan 2026 19:05:04 +0100 Subject: [PATCH 357/476] Replace Picker with button-based segmented control in SegmentedRowView SwiftUI's Picker with .segmented style doesn't fire selection callbacks when tapping an already-selected segment. This breaks use cases requiring repeated command sends (e.g., volume control, scene triggers). - Replace Picker-based implementation with individual Button views - Buttons always respond to taps, even on selected segments - Maintain visual selection feedback via background highlighting - Remove isMomentary distinction - unified button-based approach This matches the behavior of Android app and BasicUI (issue #530). Signed-off-by: Tim Mueller-Seydlitz --- openHAB/SwiftUI/Rows/SegmentedRowView.swift | 90 ++++++++++++--------- 1 file changed, 52 insertions(+), 38 deletions(-) diff --git a/openHAB/SwiftUI/Rows/SegmentedRowView.swift b/openHAB/SwiftUI/Rows/SegmentedRowView.swift index 7df652177..594c14432 100644 --- a/openHAB/SwiftUI/Rows/SegmentedRowView.swift +++ b/openHAB/SwiftUI/Rows/SegmentedRowView.swift @@ -27,10 +27,6 @@ struct SegmentedRowView: View { @State private var selectedIndex: Int? @State private var pressedIndex: Int? - private var isMomentary: Bool { - mappings.count == 1 || widget.item?.state == "NULL" - } - var body: some View { HStack { IconView(widget: widget) @@ -53,51 +49,46 @@ struct SegmentedRowView: View { if widget.hasPressReleaseMappings { // Press-release buttons for mappings with releaseCommand pressReleaseButtons - } else if isMomentary { - HStack { - ForEach(mappings.indices, id: \.self) { index in - Button { - viewModel.sendCommand(widget.item, commandToSend: mappings[index].command) - } label: { - Text(mappings[index].label) - .padding(.horizontal, 6) - } - .buttonStyle(.bordered) - } - } } else { - HStack { - Picker("", selection: Binding( - get: { selectedIndex ?? -1 }, - set: { newIndex in - selectedIndex = newIndex - if let mapping = mappings[safe: newIndex] { - viewModel.sendCommand(widget.item, commandToSend: mapping.command) - } - } - )) { - ForEach(mappings.indices, id: \.self) { index in - Text(mappings[index].label).tag(index) - } - } - .padding(.bottom, -8) - .padding(.top, -8) - .pickerStyle(.segmented) - .controlSize(.small) - } + // Button-based segmented control that allows repeated clicks on same segment + // This matches Android app and BasicUI behavior (issue #530) + segmentedButtons } } } .onAppear { - if !isMomentary { - selectedIndex = widget.mapCommandtoIndex(with: widget.item?.state) - } + selectedIndex = widget.mapCommandtoIndex(with: widget.item?.state) } .onChange(of: widget.item?.state) { newState in selectedIndex = widget.mapCommandtoIndex(with: newState) } } + /// Button-based segmented control that always responds to taps, even on selected segment + @ViewBuilder + private var segmentedButtons: some View { + HStack(spacing: 0) { + ForEach(0 ..< mappings.count, id: \.self) { index in + segmentButton(at: index) + + // Add divider between segments + if index < mappings.count - 1 { + Divider() + .frame(height: 20) + } + } + } + .background( + RoundedRectangle(cornerRadius: 7) + .fill(Color(uiColor: .systemGray6)) + ) + .overlay( + RoundedRectangle(cornerRadius: 7) + .stroke(Color.secondary.opacity(0.3), lineWidth: 0.5) + ) + .fixedSize(horizontal: true, vertical: true) + } + @ViewBuilder private var pressReleaseButtons: some View { HStack { @@ -108,6 +99,29 @@ struct SegmentedRowView: View { } } + // MARK: - Helper Methods + + @ViewBuilder + private func segmentButton(at index: Int) -> some View { + let isSelected = selectedIndex == index + let mapping = mappings[index] + + Button { + logger.info("Segment tapped: \(index), command: \(mapping.command)") + selectedIndex = index + viewModel.sendCommand(widget.item, commandToSend: mapping.command) + } label: { + Text(mapping.label) + .font(.subheadline) + .padding(.horizontal, 8) + .padding(.vertical, 6) + .frame(maxWidth: .infinity) + .background(isSelected ? Color.accentColor.opacity(0.2) : Color.clear) + .foregroundStyle(isSelected ? Color.accentColor : .primary) + } + .buttonStyle(.plain) + } + @ViewBuilder private func pressReleaseButton(for mapping: OpenHABWidgetMapping, at index: Int) -> some View { Text(mapping.label) From 0ca8a4bd5314cfbcd47b39de030894362e7e8c3c Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Fri, 30 Jan 2026 19:47:11 +0100 Subject: [PATCH 358/476] Fix icon size, row heights, and background colors in SwiftUI views Icon size: - Update IconView default size from 24x24 to 32x32 to match UIKit cells Segmented control: - Add text truncation with lineLimit(1) and truncationMode(.tail) - Constrain button width (minWidth: 40, maxWidth: 120) to prevent row explosion Row heights and density: - Set defaultMinListRowHeight to 32 to allow compact Frame rows - Reduce regular row vertical padding from 8pt to 4pt for UIKit-like density - Frame rows use 6pt vertical padding for ~35pt total height Background colors (matching UIKit): - Frame rows: systemGroupedBackground (lighter gray) - Other rows: secondarySystemGroupedBackground (white/off-white) - Centralize background handling in EmbeddingRowView Signed-off-by: Tim Mueller-Seydlitz --- openHAB/SwiftUI/EmbeddingRowView.swift | 20 +++++++++++++++----- openHAB/SwiftUI/IconView.swift | 4 ++-- openHAB/SwiftUI/Rows/FrameRowView.swift | 1 - openHAB/SwiftUI/Rows/SegmentedRowView.swift | 6 ++++-- openHAB/SwiftUI/SitemapPageView.swift | 2 +- 5 files changed, 22 insertions(+), 11 deletions(-) diff --git a/openHAB/SwiftUI/EmbeddingRowView.swift b/openHAB/SwiftUI/EmbeddingRowView.swift index 0931355ae..7d9006b0b 100644 --- a/openHAB/SwiftUI/EmbeddingRowView.swift +++ b/openHAB/SwiftUI/EmbeddingRowView.swift @@ -21,15 +21,24 @@ struct EmbeddingRowView: View { @State private var inputText = "" /// Insets for different widget types - Frame headers are more compact - private var frameInsets: EdgeInsets { + private var rowInsets: EdgeInsets { if widget.type == .frame { - // Empty frame labels should be minimal, matching UIKit's 0pt height + // Frame headers: ~35pt total in UIKit let hasLabel = !(widget.label.isEmpty) return hasLabel - ? EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16) + ? EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16) : EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16) } - return EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16) + // Regular rows: use smaller vertical padding to match UIKit density + return EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16) + } + + /// Background color matching UIKit: Frame rows use systemGroupedBackground, others use secondarySystemGroupedBackground + private var rowBackground: Color { + if widget.type == .frame { + return Color(UIColor.ohSystemGroupedBackground) + } + return Color(UIColor.ohSecondarySystemGroupedBackground) } var body: some View { @@ -60,7 +69,8 @@ struct EmbeddingRowView: View { } } .contentShape(Rectangle()) - .listRowInsets(frameInsets) + .listRowInsets(rowInsets) + .listRowBackground(rowBackground) .alert("Input", isPresented: $showInputAlert) { if let widget = selectedWidget { TextField("Enter value", text: $inputText) diff --git a/openHAB/SwiftUI/IconView.swift b/openHAB/SwiftUI/IconView.swift index d01cf8cb3..b5024415f 100644 --- a/openHAB/SwiftUI/IconView.swift +++ b/openHAB/SwiftUI/IconView.swift @@ -133,11 +133,11 @@ struct IconView: View { // MARK: - Convenience Extensions extension IconView { - /// Creates a widget icon view with standard size + /// Creates a widget icon view with standard size (32x32, matching UIKit cells) init(widget: OpenHABWidget) { self.init( widget: widget, - size: CGSize(width: 24, height: 24) + size: CGSize(width: 32, height: 32) ) } } diff --git a/openHAB/SwiftUI/Rows/FrameRowView.swift b/openHAB/SwiftUI/Rows/FrameRowView.swift index c536a5de7..455906925 100644 --- a/openHAB/SwiftUI/Rows/FrameRowView.swift +++ b/openHAB/SwiftUI/Rows/FrameRowView.swift @@ -25,7 +25,6 @@ struct FrameRowView: View { .lineLimit(1) Spacer() } - .listRowBackground(Color(UIColor.systemGroupedBackground)) } } diff --git a/openHAB/SwiftUI/Rows/SegmentedRowView.swift b/openHAB/SwiftUI/Rows/SegmentedRowView.swift index 594c14432..7e279777c 100644 --- a/openHAB/SwiftUI/Rows/SegmentedRowView.swift +++ b/openHAB/SwiftUI/Rows/SegmentedRowView.swift @@ -86,7 +86,7 @@ struct SegmentedRowView: View { RoundedRectangle(cornerRadius: 7) .stroke(Color.secondary.opacity(0.3), lineWidth: 0.5) ) - .fixedSize(horizontal: true, vertical: true) + .fixedSize(horizontal: false, vertical: true) } @ViewBuilder @@ -113,9 +113,11 @@ struct SegmentedRowView: View { } label: { Text(mapping.label) .font(.subheadline) + .lineLimit(1) + .truncationMode(.tail) .padding(.horizontal, 8) .padding(.vertical, 6) - .frame(maxWidth: .infinity) + .frame(minWidth: 40, maxWidth: 120) .background(isSelected ? Color.accentColor.opacity(0.2) : Color.clear) .foregroundStyle(isSelected ? Color.accentColor : .primary) } diff --git a/openHAB/SwiftUI/SitemapPageView.swift b/openHAB/SwiftUI/SitemapPageView.swift index f49a22d18..0cfae7742 100644 --- a/openHAB/SwiftUI/SitemapPageView.swift +++ b/openHAB/SwiftUI/SitemapPageView.swift @@ -39,7 +39,7 @@ struct SitemapPageView: View { .environmentObject(viewModel) .listStyle(.plain) .listRowSpacing(0) - .environment(\.defaultMinListRowHeight, 44) + .environment(\.defaultMinListRowHeight, 32) .refreshable { await viewModel.reload() } From c2c4ededd7443ab9ff5da909fc0f2fa8df67d351 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Fri, 30 Jan 2026 20:19:15 +0100 Subject: [PATCH 359/476] Fix dark mode icons and improve SegmentedRowView layout IconView dark mode fix: - Use UIColor.ohBlack as default icon color (adapts to light/dark mode) - Apply semanticColorToHex() for proper color conversion - Include colorScheme in view id to force refresh on mode change - Matches UIKit develop branch implementation SegmentedRowView improvements: - Add layout priorities for text truncation order - detailTextLabel truncates first (priority 0) - labelText truncates second (priority 1) - Use smaller font (.footnote) for segment buttons - Remove excessive layoutPriority from buttons to prevent space hogging - Add fixedSize to pressReleaseButtons for proper sizing Signed-off-by: Tim Mueller-Seydlitz --- openHAB/SwiftUI/IconView.swift | 23 ++++++++------------- openHAB/SwiftUI/Rows/SegmentedRowView.swift | 14 +++++++++---- 2 files changed, 19 insertions(+), 18 deletions(-) diff --git a/openHAB/SwiftUI/IconView.swift b/openHAB/SwiftUI/IconView.swift index b5024415f..3cadc8d80 100644 --- a/openHAB/SwiftUI/IconView.swift +++ b/openHAB/SwiftUI/IconView.swift @@ -53,6 +53,12 @@ struct IconView: View { @State private var currentImage: UIImage? + /// Icon color converted to hex, using ohBlack as default (adapts to light/dark mode) + private var iconColorHex: String { + let logicColor = !widget.iconColor.isEmpty ? UIColor(fromString: widget.iconColor) : .ohBlack + return logicColor.semanticColorToHex() ?? "#000000" + } + private var iconURL: URL? { guard !widget.icon.isEmpty else { return nil } @@ -63,24 +69,13 @@ struct IconView: View { return nil } - var queriedIconColor: String { - switch colorScheme { - case .light: - return widget.iconColor.isEmpty ? "black" : widget.iconColor - case .dark: - return widget.iconColor.isEmpty ? "white" : widget.iconColor - @unknown default: - return widget.iconColor.isEmpty ? "black" : widget.iconColor - } - } - return Endpoint.icon( rootUrl: activeConnection.configuration.url, version: activeConnection.version, icon: widget.icon, state: widget.iconState(), iconType: iconType, - iconColor: queriedIconColor, + iconColor: iconColorHex, staticIcon: widget.staticIcon )?.url } @@ -117,7 +112,7 @@ struct IconView: View { .cancelOnDisappear(true) .aspectRatio(contentMode: .fit) .frame(width: size.width, height: size.height) - .id(viewModel.pageId + widget.id) + .id("\(viewModel.pageId)-\(widget.id)-\(colorScheme)") } } } @@ -126,7 +121,7 @@ struct IconView: View { private func processorIconColor(for url: URL) -> String? { // Don't apply color preprocessing for iconify icons guard url.host != "api.iconify.design" else { return nil } - return widget.iconColor.isEmpty ? nil : widget.iconColor + return iconColorHex } } diff --git a/openHAB/SwiftUI/Rows/SegmentedRowView.swift b/openHAB/SwiftUI/Rows/SegmentedRowView.swift index 7e279777c..c36095a13 100644 --- a/openHAB/SwiftUI/Rows/SegmentedRowView.swift +++ b/openHAB/SwiftUI/Rows/SegmentedRowView.swift @@ -31,24 +31,30 @@ struct SegmentedRowView: View { HStack { IconView(widget: widget) .frame(width: 32, height: 32) - .padding(.top, 4) // Align with text if let labelText = widget.labelText, !labelText.isEmpty { Text(labelText) .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) + .lineLimit(1) + .truncationMode(.tail) + .layoutPriority(1) // Truncates second } - Spacer() + + Spacer(minLength: 4) if let detailTextLabel = widget.labelValue, !detailTextLabel.isEmpty { Text(detailTextLabel) .foregroundStyle(widget.valuecolor.isEmpty ? Color(uiColor: UIColor.ohSecondaryLabel) : Color(fromString: widget.valuecolor)) .lineLimit(1) + .truncationMode(.tail) + .layoutPriority(0) // Truncates first } if !mappings.isEmpty { if widget.hasPressReleaseMappings { // Press-release buttons for mappings with releaseCommand pressReleaseButtons + .fixedSize(horizontal: true, vertical: false) } else { // Button-based segmented control that allows repeated clicks on same segment // This matches Android app and BasicUI behavior (issue #530) @@ -112,11 +118,11 @@ struct SegmentedRowView: View { viewModel.sendCommand(widget.item, commandToSend: mapping.command) } label: { Text(mapping.label) - .font(.subheadline) + .font(.footnote) .lineLimit(1) .truncationMode(.tail) .padding(.horizontal, 8) - .padding(.vertical, 6) + .padding(.vertical, 5) .frame(minWidth: 40, maxWidth: 120) .background(isSelected ? Color.accentColor.opacity(0.2) : Color.clear) .foregroundStyle(isSelected ? Color.accentColor : .primary) From 3b129e3795889967b3c3f1bfa8a9296af7093f0f Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Fri, 30 Jan 2026 20:28:55 +0100 Subject: [PATCH 360/476] Add lineLimit(1) to labelText and labelValue in all row views Prevent text wrapping to multiple lines by adding lineLimit(1) to: - labelText in all 16 row views - labelValue in 9 row views that display it This matches UIKit behavior where labels are truncated with ellipsis instead of expanding to multiple lines. Signed-off-by: Tim Mueller-Seydlitz --- openHAB/SwiftUI/Rows/ButtonGridRowView.swift | 1 + openHAB/SwiftUI/Rows/ColorPickerRowView.swift | 2 ++ openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift | 1 + openHAB/SwiftUI/Rows/DatePickerInputRowView.swift | 1 + openHAB/SwiftUI/Rows/GenericRowView.swift | 2 ++ openHAB/SwiftUI/Rows/ImageRowView.swift | 2 ++ openHAB/SwiftUI/Rows/MapRowView.swift | 1 + openHAB/SwiftUI/Rows/RollershutterRowView.swift | 1 + openHAB/SwiftUI/Rows/SelectionRowView.swift | 2 ++ openHAB/SwiftUI/Rows/SetpointRowView.swift | 1 + openHAB/SwiftUI/Rows/SliderRowView.swift | 2 ++ openHAB/SwiftUI/Rows/SwitchRowView.swift | 2 ++ openHAB/SwiftUI/Rows/TextInputRowView.swift | 1 + openHAB/SwiftUI/Rows/TextRowView.swift | 2 ++ openHAB/SwiftUI/Rows/VideoRowView.swift | 2 ++ openHAB/SwiftUI/Rows/WebRowView.swift | 2 ++ 16 files changed, 25 insertions(+) diff --git a/openHAB/SwiftUI/Rows/ButtonGridRowView.swift b/openHAB/SwiftUI/Rows/ButtonGridRowView.swift index a565e451b..04541214f 100644 --- a/openHAB/SwiftUI/Rows/ButtonGridRowView.swift +++ b/openHAB/SwiftUI/Rows/ButtonGridRowView.swift @@ -137,6 +137,7 @@ struct ButtonGridRowView: View { if let labelText = widget.labelText, !labelText.isEmpty { Text(labelText) .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) + .lineLimit(1) } Spacer() diff --git a/openHAB/SwiftUI/Rows/ColorPickerRowView.swift b/openHAB/SwiftUI/Rows/ColorPickerRowView.swift index c801cfc01..d9c509e1b 100644 --- a/openHAB/SwiftUI/Rows/ColorPickerRowView.swift +++ b/openHAB/SwiftUI/Rows/ColorPickerRowView.swift @@ -32,6 +32,7 @@ struct ColorPickerRowView: View { if let labelText = widget.labelText, !labelText.isEmpty { Text(labelText) .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) + .lineLimit(1) } Spacer() @@ -59,6 +60,7 @@ struct ColorPickerRowView: View { Text(labelValue) .font(.caption) .foregroundStyle(widget.valuecolor.isEmpty ? .secondary : Color(fromString: widget.valuecolor)) + .lineLimit(1) } } .onAppear { diff --git a/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift b/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift index 24cb24f3c..7e4938297 100644 --- a/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift +++ b/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift @@ -89,6 +89,7 @@ struct ColorTemperaturePickerRowView: View { if let labelText = widget.labelText, !labelText.isEmpty { Text(labelText) .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) + .lineLimit(1) } Spacer() diff --git a/openHAB/SwiftUI/Rows/DatePickerInputRowView.swift b/openHAB/SwiftUI/Rows/DatePickerInputRowView.swift index 6a1893a5a..cf7aded12 100644 --- a/openHAB/SwiftUI/Rows/DatePickerInputRowView.swift +++ b/openHAB/SwiftUI/Rows/DatePickerInputRowView.swift @@ -42,6 +42,7 @@ struct DatePickerInputRowView: View { if let labelText = widget.labelText, !labelText.isEmpty { Text(labelText) .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) + .lineLimit(1) } Spacer() diff --git a/openHAB/SwiftUI/Rows/GenericRowView.swift b/openHAB/SwiftUI/Rows/GenericRowView.swift index 1ac0913bc..8a0a17e1b 100644 --- a/openHAB/SwiftUI/Rows/GenericRowView.swift +++ b/openHAB/SwiftUI/Rows/GenericRowView.swift @@ -24,12 +24,14 @@ struct GenericRowView: View { Text(widget.labelText ?? "") .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) + .lineLimit(1) Spacer() if let value = widget.labelValue { Text(value) .foregroundStyle(widget.valuecolor.isEmpty ? .secondary : Color(fromString: widget.valuecolor)) + .lineLimit(1) } } } diff --git a/openHAB/SwiftUI/Rows/ImageRowView.swift b/openHAB/SwiftUI/Rows/ImageRowView.swift index 6fec47d4f..5542c137d 100644 --- a/openHAB/SwiftUI/Rows/ImageRowView.swift +++ b/openHAB/SwiftUI/Rows/ImageRowView.swift @@ -37,6 +37,7 @@ struct ImageRowView: View { if let labelText = widget.labelText, !labelText.isEmpty, widget.labelSource == .sitemapDefinition { Text(labelText) .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) + .lineLimit(1) } switch widget.generateImageResult(rootUrl: viewModel.openHABRootUrl ?? "") { case let .embedded(data: data): @@ -71,6 +72,7 @@ struct ImageRowView: View { Text(labelValue) .font(.caption) .foregroundStyle(widget.valuecolor.isEmpty ? .secondary : Color(fromString: widget.valuecolor)) + .lineLimit(1) } } .onAppear { diff --git a/openHAB/SwiftUI/Rows/MapRowView.swift b/openHAB/SwiftUI/Rows/MapRowView.swift index 6804fcb66..bf3251381 100644 --- a/openHAB/SwiftUI/Rows/MapRowView.swift +++ b/openHAB/SwiftUI/Rows/MapRowView.swift @@ -32,6 +32,7 @@ struct MapRowViewLegacy: View { if let labelText = widget.labelText, !labelText.isEmpty, widget.labelSource == .sitemapDefinition { Text(labelText) .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) + .lineLimit(1) } Map(coordinateRegion: .constant(region), annotationItems: CLLocationCoordinate2DIsValid(widget.coordinate) ? [widget.coordinate] : []) { location in diff --git a/openHAB/SwiftUI/Rows/RollershutterRowView.swift b/openHAB/SwiftUI/Rows/RollershutterRowView.swift index 4eb20e0a1..ef97b3fff 100644 --- a/openHAB/SwiftUI/Rows/RollershutterRowView.swift +++ b/openHAB/SwiftUI/Rows/RollershutterRowView.swift @@ -40,6 +40,7 @@ struct RollershutterRowView: View { if let labelText = widget.labelText, !labelText.isEmpty { Text(labelText) .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) + .lineLimit(1) } } diff --git a/openHAB/SwiftUI/Rows/SelectionRowView.swift b/openHAB/SwiftUI/Rows/SelectionRowView.swift index beded8c35..ce4dd1c3f 100644 --- a/openHAB/SwiftUI/Rows/SelectionRowView.swift +++ b/openHAB/SwiftUI/Rows/SelectionRowView.swift @@ -38,6 +38,7 @@ struct SelectionRowView: View { if let labelText = widget.labelText, !labelText.isEmpty { Text(labelText) .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) + .lineLimit(1) } Spacer() @@ -45,6 +46,7 @@ struct SelectionRowView: View { if let valueText = selectedValueText, !valueText.isEmpty { Text(valueText) .foregroundStyle(widget.valuecolor.isEmpty ? .secondary : Color(fromString: widget.valuecolor)) + .lineLimit(1) } // Show disclosure indicator to indicate tappable selection diff --git a/openHAB/SwiftUI/Rows/SetpointRowView.swift b/openHAB/SwiftUI/Rows/SetpointRowView.swift index 070cac246..75c12b594 100644 --- a/openHAB/SwiftUI/Rows/SetpointRowView.swift +++ b/openHAB/SwiftUI/Rows/SetpointRowView.swift @@ -48,6 +48,7 @@ struct SetpointRowView: View { if let labelText = widget.labelText, !labelText.isEmpty { Text(labelText) .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) + .lineLimit(1) } Spacer() diff --git a/openHAB/SwiftUI/Rows/SliderRowView.swift b/openHAB/SwiftUI/Rows/SliderRowView.swift index eee804ae7..301093a44 100644 --- a/openHAB/SwiftUI/Rows/SliderRowView.swift +++ b/openHAB/SwiftUI/Rows/SliderRowView.swift @@ -44,6 +44,7 @@ struct SliderRowView: View { if let labelText = widget.labelText, !labelText.isEmpty { Text(labelText) .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) + .lineLimit(1) } Spacer() @@ -52,6 +53,7 @@ struct SliderRowView: View { Text(value) .font(.caption) .foregroundStyle(.secondary) + .lineLimit(1) } } .contentShape(Rectangle()) diff --git a/openHAB/SwiftUI/Rows/SwitchRowView.swift b/openHAB/SwiftUI/Rows/SwitchRowView.swift index 81594d28a..43c46b5df 100644 --- a/openHAB/SwiftUI/Rows/SwitchRowView.swift +++ b/openHAB/SwiftUI/Rows/SwitchRowView.swift @@ -42,6 +42,7 @@ struct SwitchRowView: View { if let labelText = widget.labelText, !labelText.isEmpty { Text(labelText) .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) + .lineLimit(1) } Spacer() @@ -50,6 +51,7 @@ struct SwitchRowView: View { Text(labelValue) .font(.caption) .foregroundStyle(widget.valuecolor.isEmpty ? .secondary : Color(fromString: widget.valuecolor)) + .lineLimit(1) } Toggle("", isOn: Binding( diff --git a/openHAB/SwiftUI/Rows/TextInputRowView.swift b/openHAB/SwiftUI/Rows/TextInputRowView.swift index b1521e135..35d042706 100644 --- a/openHAB/SwiftUI/Rows/TextInputRowView.swift +++ b/openHAB/SwiftUI/Rows/TextInputRowView.swift @@ -30,6 +30,7 @@ struct TextInputRowView: View { if let labelText = widget.labelText, !labelText.isEmpty { Text(labelText) .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) + .lineLimit(1) } Spacer() diff --git a/openHAB/SwiftUI/Rows/TextRowView.swift b/openHAB/SwiftUI/Rows/TextRowView.swift index 32f77433d..88513b8bb 100644 --- a/openHAB/SwiftUI/Rows/TextRowView.swift +++ b/openHAB/SwiftUI/Rows/TextRowView.swift @@ -25,6 +25,7 @@ struct TextRowView: View { Text(widget.labelText ?? "") .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) + .lineLimit(1) Spacer() @@ -32,6 +33,7 @@ struct TextRowView: View { Text(value) .font(.body) .foregroundStyle(widget.valuecolor.isEmpty ? .secondary : Color(fromString: widget.valuecolor)) + .lineLimit(1) } } .contextMenu { diff --git a/openHAB/SwiftUI/Rows/VideoRowView.swift b/openHAB/SwiftUI/Rows/VideoRowView.swift index 811e4494d..ab7b1acf0 100644 --- a/openHAB/SwiftUI/Rows/VideoRowView.swift +++ b/openHAB/SwiftUI/Rows/VideoRowView.swift @@ -41,6 +41,7 @@ struct VideoRowView: View { if let labelText = widget.labelText, !labelText.isEmpty, widget.labelSource == .sitemapDefinition { Text(labelText) .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) + .lineLimit(1) } if let videoURL { @@ -102,6 +103,7 @@ struct VideoRowView: View { Text(labelValue) .font(.caption) .foregroundStyle(widget.valuecolor.isEmpty ? .secondary : Color(fromString: widget.valuecolor)) + .lineLimit(1) } } } diff --git a/openHAB/SwiftUI/Rows/WebRowView.swift b/openHAB/SwiftUI/Rows/WebRowView.swift index 86038ac6f..b0d1aa987 100644 --- a/openHAB/SwiftUI/Rows/WebRowView.swift +++ b/openHAB/SwiftUI/Rows/WebRowView.swift @@ -22,6 +22,7 @@ struct WidgetWebViewContainer: View { if let labelText = widget.labelText, !labelText.isEmpty, widget.labelSource == .sitemapDefinition { Text(labelText) .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) + .lineLimit(1) } WebRowView(widget: widget) @@ -32,6 +33,7 @@ struct WidgetWebViewContainer: View { Text(labelValue) .font(.caption) .foregroundStyle(widget.valuecolor.isEmpty ? .secondary : Color(fromString: widget.valuecolor)) + .lineLimit(1) } } } From 876afce84ebc0074e53fae48079a0444b1183e50 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Fri, 30 Jan 2026 20:44:14 +0100 Subject: [PATCH 361/476] Add navigation titles to SelectionView sheet and SitemapPageView Wrap SelectionView in NavigationStack when presented as sheet to display the navigation title header with Cancel button. Add navigationTitle and large title display mode to SitemapPageView for collapsing title on scroll. Co-Authored-By: Claude Opus 4.5 --- openHAB/SwiftUI/EmbeddingRowView.swift | 27 +++++++++++++++++--------- openHAB/SwiftUI/SitemapPageView.swift | 2 ++ 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/openHAB/SwiftUI/EmbeddingRowView.swift b/openHAB/SwiftUI/EmbeddingRowView.swift index 7d9006b0b..4e5b55ac7 100644 --- a/openHAB/SwiftUI/EmbeddingRowView.swift +++ b/openHAB/SwiftUI/EmbeddingRowView.swift @@ -86,15 +86,24 @@ struct EmbeddingRowView: View { } .sheet(isPresented: $showSelectionSheet) { if let widget = selectedWidget { - SelectionView( - labelText: widget.labelText, - mappings: widget.mappingsOrItemOptions, - selectionItemState: widget.item?.state, - valuecolor: widget.valuecolor - ) { selectedMappingIndex in - let selectedMapping = widget.mappingsOrItemOptions[selectedMappingIndex] - viewModel.sendCommand(widget.item, commandToSend: selectedMapping.command) - showSelectionSheet = false + NavigationStack { + SelectionView( + labelText: widget.labelText, + mappings: widget.mappingsOrItemOptions, + selectionItemState: widget.item?.state, + valuecolor: widget.valuecolor + ) { selectedMappingIndex in + let selectedMapping = widget.mappingsOrItemOptions[selectedMappingIndex] + viewModel.sendCommand(widget.item, commandToSend: selectedMapping.command) + showSelectionSheet = false + } + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + showSelectionSheet = false + } + } + } } } } diff --git a/openHAB/SwiftUI/SitemapPageView.swift b/openHAB/SwiftUI/SitemapPageView.swift index 0cfae7742..047203224 100644 --- a/openHAB/SwiftUI/SitemapPageView.swift +++ b/openHAB/SwiftUI/SitemapPageView.swift @@ -62,6 +62,8 @@ struct SitemapPageView: View { .onChange(of: viewModel.networkTracker.activeConnection) { activeConnection in viewModel.handleActiveConnectionChange(activeConnection) } + .navigationTitle(viewModel.pageTitle) + .navigationBarTitleDisplayMode(.large) .alert("Error", isPresented: Binding( get: { viewModel.error != nil }, set: { if !$0 { viewModel.error = nil } } From 7777ddd91d7ada2c0fa43d08a2d088bdc7df3b81 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Sat, 31 Jan 2026 07:06:41 +0100 Subject: [PATCH 362/476] Replace SelectionRowView sheet with Menu popover Use SwiftUI Menu to show selection options in a native popover instead of a full-screen sheet. Maintains valuecolor support for selected value and shows checkmark next to current selection. --- openHAB/SwiftUI/EmbeddingRowView.swift | 38 ++----------- openHAB/SwiftUI/Rows/SelectionRowView.swift | 60 ++++++++++++++------- 2 files changed, 45 insertions(+), 53 deletions(-) diff --git a/openHAB/SwiftUI/EmbeddingRowView.swift b/openHAB/SwiftUI/EmbeddingRowView.swift index 4e5b55ac7..416fdbf85 100644 --- a/openHAB/SwiftUI/EmbeddingRowView.swift +++ b/openHAB/SwiftUI/EmbeddingRowView.swift @@ -15,9 +15,8 @@ import SwiftUI struct EmbeddingRowView: View { @ObservedObject var widget: OpenHABWidget @EnvironmentObject var viewModel: SitemapPageViewModel - @State private var selectedWidget: OpenHABWidget? - @State private var showSelectionSheet = false @State private var showInputAlert = false + @State private var inputWidget: OpenHABWidget? @State private var inputText = "" /// Insets for different widget types - Frame headers are more compact @@ -48,17 +47,9 @@ struct EmbeddingRowView: View { RowViewFactory.view(for: widget) } .buttonStyle(.plain) - } else if widget.type == .selection { - Button { - selectedWidget = widget - showSelectionSheet = true - } label: { - RowViewFactory.view(for: widget) - } - .buttonStyle(.plain) } else if widget.type == .input { Button { - selectedWidget = widget + inputWidget = widget showInputAlert = true } label: { RowViewFactory.view(for: widget) @@ -72,7 +63,7 @@ struct EmbeddingRowView: View { .listRowInsets(rowInsets) .listRowBackground(rowBackground) .alert("Input", isPresented: $showInputAlert) { - if let widget = selectedWidget { + if let widget = inputWidget { TextField("Enter value", text: $inputText) Button("Cancel", role: .cancel) {} Button("OK") { @@ -84,28 +75,5 @@ struct EmbeddingRowView: View { } } } - .sheet(isPresented: $showSelectionSheet) { - if let widget = selectedWidget { - NavigationStack { - SelectionView( - labelText: widget.labelText, - mappings: widget.mappingsOrItemOptions, - selectionItemState: widget.item?.state, - valuecolor: widget.valuecolor - ) { selectedMappingIndex in - let selectedMapping = widget.mappingsOrItemOptions[selectedMappingIndex] - viewModel.sendCommand(widget.item, commandToSend: selectedMapping.command) - showSelectionSheet = false - } - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { - showSelectionSheet = false - } - } - } - } - } - } } } diff --git a/openHAB/SwiftUI/Rows/SelectionRowView.swift b/openHAB/SwiftUI/Rows/SelectionRowView.swift index ce4dd1c3f..0fc2cd3ac 100644 --- a/openHAB/SwiftUI/Rows/SelectionRowView.swift +++ b/openHAB/SwiftUI/Rows/SelectionRowView.swift @@ -11,11 +11,15 @@ import CommonUI import OpenHABCore +import os.log import SFSafeSymbols import SwiftUI struct SelectionRowView: View { @ObservedObject var widget: OpenHABWidget + @EnvironmentObject var viewModel: SitemapPageViewModel + + private let logger = Logger(subsystem: "org.openhab", category: "SelectionRowView") private var mappings: [OpenHABWidgetMapping] { widget.mappingsOrItemOptions @@ -31,28 +35,48 @@ struct SelectionRowView: View { } var body: some View { - HStack { - IconView(widget: widget) - .frame(width: 32, height: 32) - - if let labelText = widget.labelText, !labelText.isEmpty { - Text(labelText) - .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) - .lineLimit(1) + Menu { + ForEach(mappings.indices, id: \.self) { index in + let mapping = mappings[index] + let isSelected = widget.item?.state == mapping.command + Button { + logger.info("Selection changed to: \(mapping.label)") + viewModel.sendCommand(widget.item, commandToSend: mapping.command) + } label: { + if isSelected { + Label(mapping.label, systemSymbol: .checkmark) + } else { + Text(mapping.label) + } + } } + } label: { + HStack { + IconView(widget: widget) + .frame(width: 32, height: 32) - Spacer() + if let labelText = widget.labelText, !labelText.isEmpty { + Text(labelText) + .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) + .lineLimit(1) + } - if let valueText = selectedValueText, !valueText.isEmpty { - Text(valueText) - .foregroundStyle(widget.valuecolor.isEmpty ? .secondary : Color(fromString: widget.valuecolor)) - .lineLimit(1) - } + Spacer() - // Show disclosure indicator to indicate tappable selection - Image(systemSymbol: .chevronUpChevronDown) - .font(.caption) - .foregroundStyle(.secondary) + if let valueText = selectedValueText, !valueText.isEmpty { + Text(valueText) + .foregroundStyle(widget.valuecolor.isEmpty ? .secondary : Color(fromString: widget.valuecolor)) + .lineLimit(1) + } + + // Show disclosure indicator to indicate tappable selection + Image(systemSymbol: .chevronUpChevronDown) + .font(.caption) + .foregroundStyle(.secondary) + } + .contentShape(Rectangle()) } + .buttonStyle(.plain) + .disabled(widget.readOnly ?? false) } } From e3305fc418ee5500ce727f062bbe92503558dcb6 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Sat, 31 Jan 2026 09:13:48 +0100 Subject: [PATCH 363/476] Improve SwiftUI views: charts, segmented control animation, and linked pages - Fix charts not displaying in linked pages by setting openHABRootUrl from active connection in SitemapPageViewModel init - Hide labelValue for chart widgets (only show for images) - Add animated sliding selection indicator to SegmentedRowView - Improve segmented control contrast with systemGray5/systemGray3 colors Signed-off-by: Tim Mueller-Seydlitz --- openHAB/SitemapPageViewModel.swift | 3 ++ openHAB/SwiftUI/Rows/ImageRowView.swift | 3 +- openHAB/SwiftUI/Rows/SegmentedRowView.swift | 40 ++++++++++++--------- 3 files changed, 28 insertions(+), 18 deletions(-) diff --git a/openHAB/SitemapPageViewModel.swift b/openHAB/SitemapPageViewModel.swift index d5154790d..6429eb4da 100644 --- a/openHAB/SitemapPageViewModel.swift +++ b/openHAB/SitemapPageViewModel.swift @@ -98,6 +98,9 @@ class SitemapPageViewModel: ObservableObject { setupActiveConnectionObserver() isLinkedPage = true + // Set openHABRootUrl from current active connection for charts/images + openHABRootUrl = networkTracker.activeConnection?.configuration.url + // Extract pageId from URL if not provided if pageId.isEmpty { if let urlComponents = URLComponents(string: pageUrl), diff --git a/openHAB/SwiftUI/Rows/ImageRowView.swift b/openHAB/SwiftUI/Rows/ImageRowView.swift index 5542c137d..092cb1d2c 100644 --- a/openHAB/SwiftUI/Rows/ImageRowView.swift +++ b/openHAB/SwiftUI/Rows/ImageRowView.swift @@ -68,7 +68,8 @@ struct ImageRowView: View { .clipShape(.rect(cornerRadius: 8)) } - if let labelValue = widget.labelValue, !labelValue.isEmpty { + // Only show labelValue for image widgets, not charts + if widget.type == .image, let labelValue = widget.labelValue, !labelValue.isEmpty { Text(labelValue) .font(.caption) .foregroundStyle(widget.valuecolor.isEmpty ? .secondary : Color(fromString: widget.valuecolor)) diff --git a/openHAB/SwiftUI/Rows/SegmentedRowView.swift b/openHAB/SwiftUI/Rows/SegmentedRowView.swift index c36095a13..65841d2e7 100644 --- a/openHAB/SwiftUI/Rows/SegmentedRowView.swift +++ b/openHAB/SwiftUI/Rows/SegmentedRowView.swift @@ -56,8 +56,7 @@ struct SegmentedRowView: View { pressReleaseButtons .fixedSize(horizontal: true, vertical: false) } else { - // Button-based segmented control that allows repeated clicks on same segment - // This matches Android app and BasicUI behavior (issue #530) + // Button-based segmented control with animated selection indicator segmentedButtons } } @@ -70,23 +69,30 @@ struct SegmentedRowView: View { } } - /// Button-based segmented control that always responds to taps, even on selected segment + /// Button-based segmented control with animated selection indicator @ViewBuilder private var segmentedButtons: some View { HStack(spacing: 0) { ForEach(0 ..< mappings.count, id: \.self) { index in segmentButton(at: index) - - // Add divider between segments - if index < mappings.count - 1 { - Divider() - .frame(height: 20) - } } } .background( - RoundedRectangle(cornerRadius: 7) - .fill(Color(uiColor: .systemGray6)) + GeometryReader { geometry in + // Layer 1: Dark gray background + RoundedRectangle(cornerRadius: 7) + .fill(Color(uiColor: .systemGray5)) + + // Layer 2: Selection indicator (lighter, more visible) + if let selectedIndex, !mappings.isEmpty { + let segmentWidth = geometry.size.width / CGFloat(mappings.count) + RoundedRectangle(cornerRadius: 6) + .fill(Color(uiColor: .systemGray3)) + .frame(width: segmentWidth - 4, height: geometry.size.height - 4) + .offset(x: CGFloat(selectedIndex) * segmentWidth + 2, y: 2) + .animation(.spring(response: 0.3, dampingFraction: 0.7), value: selectedIndex) + } + } ) .overlay( RoundedRectangle(cornerRadius: 7) @@ -109,12 +115,13 @@ struct SegmentedRowView: View { @ViewBuilder private func segmentButton(at index: Int) -> some View { - let isSelected = selectedIndex == index let mapping = mappings[index] Button { logger.info("Segment tapped: \(index), command: \(mapping.command)") - selectedIndex = index + withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { + selectedIndex = index + } viewModel.sendCommand(widget.item, commandToSend: mapping.command) } label: { Text(mapping.label) @@ -124,8 +131,7 @@ struct SegmentedRowView: View { .padding(.horizontal, 8) .padding(.vertical, 5) .frame(minWidth: 40, maxWidth: 120) - .background(isSelected ? Color.accentColor.opacity(0.2) : Color.clear) - .foregroundStyle(isSelected ? Color.accentColor : .primary) + .foregroundStyle(.primary) } .buttonStyle(.plain) } @@ -137,9 +143,9 @@ struct SegmentedRowView: View { .padding(.vertical, 6) .background( RoundedRectangle(cornerRadius: 8) - .fill(pressedIndex == index ? Color.accentColor.opacity(0.3) : Color(uiColor: .systemGray5)) + .fill(pressedIndex == index ? Color(uiColor: .systemGray3) : Color(uiColor: .systemGray5)) ) - .foregroundStyle(pressedIndex == index ? Color.accentColor : .primary) + .foregroundStyle(.primary) .gesture( DragGesture(minimumDistance: 0) .onChanged { _ in From 65e21323b9883eaa1cc93bedeeda92b38baa74d5 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Sat, 31 Jan 2026 09:39:27 +0100 Subject: [PATCH 364/476] Fix format string crash with unescaped percent sign Escape trailing % in format strings that aren't already escaped. Server-side patterns like "%.0f %" should become "%.0f %%" to properly display a literal percent sign in String(format:). Signed-off-by: Tim Mueller-Seydlitz --- OpenHABCore/Sources/OpenHABCore/Model/NumberState.swift | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Model/NumberState.swift b/OpenHABCore/Sources/OpenHABCore/Model/NumberState.swift index 917dc72fb..b26390cf4 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/NumberState.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/NumberState.swift @@ -37,11 +37,18 @@ public struct NumberState: CustomStringConvertible, Equatable { public func toString(locale: Locale?) -> String { if let format, !format.isEmpty { - let actualFormat = format + var actualFormat = format .replacingOccurrences(of: "%unit%", with: unit ?? "") // %s in Java is for Strings, but does not work in Swift, see // https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/Strings/Articles/formatSpecifiers.html) .replacingOccurrences(of: "%s", with: "%@") + + // Escape trailing % that isn't already escaped (e.g., "%.0f %" should become "%.0f %%") + // This handles server-side format patterns that forgot to escape the percent sign + if actualFormat.hasSuffix(" %"), !actualFormat.hasSuffix(" %%") { + actualFormat = String(actualFormat.dropLast()) + "%%" + } + let formatValue: any CVarArg = if format.contains("%d") { intValue } else if format.contains("%s") { From 1fe6cc871fd38637c6b884c391947a15e19a8f5f Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Sat, 31 Jan 2026 14:30:03 +0100 Subject: [PATCH 365/476] Change of colors Signed-off-by: Tim Mueller-Seydlitz --- openHAB/SwiftUI/Rows/SegmentedRowView.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openHAB/SwiftUI/Rows/SegmentedRowView.swift b/openHAB/SwiftUI/Rows/SegmentedRowView.swift index 65841d2e7..1920030cc 100644 --- a/openHAB/SwiftUI/Rows/SegmentedRowView.swift +++ b/openHAB/SwiftUI/Rows/SegmentedRowView.swift @@ -87,7 +87,7 @@ struct SegmentedRowView: View { if let selectedIndex, !mappings.isEmpty { let segmentWidth = geometry.size.width / CGFloat(mappings.count) RoundedRectangle(cornerRadius: 6) - .fill(Color(uiColor: .systemGray3)) + .fill(Color(uiColor: .white)) .frame(width: segmentWidth - 4, height: geometry.size.height - 4) .offset(x: CGFloat(selectedIndex) * segmentWidth + 2, y: 2) .animation(.spring(response: 0.3, dampingFraction: 0.7), value: selectedIndex) @@ -126,6 +126,7 @@ struct SegmentedRowView: View { } label: { Text(mapping.label) .font(.footnote) + .bold() .lineLimit(1) .truncationMode(.tail) .padding(.horizontal, 8) From a5dede4dff2f3465e0ee1f0021a2d46c6922c8b2 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Sat, 31 Jan 2026 16:22:50 +0100 Subject: [PATCH 366/476] Distinguish colors for light and dark mode Signed-off-by: Tim Mueller-Seydlitz --- openHAB/SwiftUI/Rows/SegmentedRowView.swift | 253 +++++++++++++++++++- 1 file changed, 247 insertions(+), 6 deletions(-) diff --git a/openHAB/SwiftUI/Rows/SegmentedRowView.swift b/openHAB/SwiftUI/Rows/SegmentedRowView.swift index 1920030cc..84f832790 100644 --- a/openHAB/SwiftUI/Rows/SegmentedRowView.swift +++ b/openHAB/SwiftUI/Rows/SegmentedRowView.swift @@ -17,6 +17,7 @@ import SwiftUI struct SegmentedRowView: View { @ObservedObject var widget: OpenHABWidget @EnvironmentObject var viewModel: SitemapPageViewModel + @Environment(\.colorScheme) var colorScheme private let logger = Logger(subsystem: "org.openhab", category: "WidgetSegmentedView") @@ -28,7 +29,7 @@ struct SegmentedRowView: View { @State private var pressedIndex: Int? var body: some View { - HStack { + HStack(spacing: 0) { IconView(widget: widget) .frame(width: 32, height: 32) @@ -40,7 +41,7 @@ struct SegmentedRowView: View { .layoutPriority(1) // Truncates second } - Spacer(minLength: 4) + Spacer(minLength: 8) if let detailTextLabel = widget.labelValue, !detailTextLabel.isEmpty { Text(detailTextLabel) @@ -54,10 +55,13 @@ struct SegmentedRowView: View { if widget.hasPressReleaseMappings { // Press-release buttons for mappings with releaseCommand pressReleaseButtons + .padding(.leading, 8) .fixedSize(horizontal: true, vertical: false) } else { // Button-based segmented control with animated selection indicator segmentedButtons + .padding(.leading, 8) + .frame(minWidth: 75) } } } @@ -81,13 +85,20 @@ struct SegmentedRowView: View { GeometryReader { geometry in // Layer 1: Dark gray background RoundedRectangle(cornerRadius: 7) - .fill(Color(uiColor: .systemGray5)) - + .fill( + colorScheme == .dark + ? Color(uiColor: .tertiarySystemBackground) + : Color(uiColor: .secondarySystemBackground) + ) // Layer 2: Selection indicator (lighter, more visible) if let selectedIndex, !mappings.isEmpty { let segmentWidth = geometry.size.width / CGFloat(mappings.count) RoundedRectangle(cornerRadius: 6) - .fill(Color(uiColor: .white)) + .fill( + colorScheme == .dark + ? Color(uiColor: .systemGray2) + : Color(uiColor: .systemBackground) + ) .frame(width: segmentWidth - 4, height: geometry.size.height - 4) .offset(x: CGFloat(selectedIndex) * segmentWidth + 2, y: 2) .animation(.spring(response: 0.3, dampingFraction: 0.7), value: selectedIndex) @@ -131,7 +142,7 @@ struct SegmentedRowView: View { .truncationMode(.tail) .padding(.horizontal, 8) .padding(.vertical, 5) - .frame(minWidth: 40, maxWidth: 120) + .frame(minWidth: 30, maxWidth: 120) .foregroundStyle(.primary) } .buttonStyle(.plain) @@ -169,6 +180,236 @@ struct SegmentedRowView: View { } } +// MARK: - Preview Helpers + +private extension SegmentedRowView { + static func createPreviewWidget(label: String, + detailLabel: String? = nil, + mappings: [OpenHABWidgetMapping], + selectedState: String? = nil) -> OpenHABWidget { + let widget = OpenHABWidget() + widget.widgetId = UUID().uuidString + widget.label = label + widget.type = .switchWidget + widget.mappings = mappings + + if let detailLabel { + let item = OpenHABItem( + name: "", + type: "String", + state: selectedState ?? mappings.first?.command ?? "", + link: "", + label: detailLabel, + groupType: nil, + stateDescription: nil, + commandDescription: nil, + members: [], + category: nil, + options: nil + ) + widget.item = item + } + + return widget + } +} + +// MARK: - Previews + +#Preview("Short Labels") { + let widget = SegmentedRowView.createPreviewWidget( + label: "Light Switch", + detailLabel: "Status", + mappings: [ + OpenHABWidgetMapping(command: "ON", label: "ON"), + OpenHABWidgetMapping(command: "OFF", label: "OFF") + ], + selectedState: "ON" + ) + + VStack(spacing: 20) { + SegmentedRowView(widget: widget) + Spacer() + } + .environmentObject(SitemapPageViewModel()) +} + +#Preview("Long Labels") { + let widget = SegmentedRowView.createPreviewWidget( + label: "Temperature Control Mode", + detailLabel: "Current Mode", + mappings: [ + OpenHABWidgetMapping(command: "manual", label: "Manual Override"), + OpenHABWidgetMapping(command: "calendar", label: "Calendar Based"), + OpenHABWidgetMapping(command: "automatic", label: "Fully Automatic") + ], + selectedState: "automatic" + ) + + VStack(spacing: 20) { + SegmentedRowView(widget: widget) + Spacer() + } + .environmentObject(SitemapPageViewModel()) +} + +#Preview("Multiple Segments (4)") { + let widget = SegmentedRowView.createPreviewWidget( + label: "Fan Speed", + detailLabel: "Level 3", + mappings: [ + OpenHABWidgetMapping(command: "0", label: "Off"), + OpenHABWidgetMapping(command: "1", label: "Low"), + OpenHABWidgetMapping(command: "2", label: "Med"), + OpenHABWidgetMapping(command: "3", label: "High") + ], + selectedState: "3" + ) + + VStack(spacing: 20) { + SegmentedRowView(widget: widget) + Spacer() + } + .environmentObject(SitemapPageViewModel()) +} + +#Preview("Narrow Labels (2 segments)") { + let widget = SegmentedRowView.createPreviewWidget( + label: "Door Lock", + mappings: [ + OpenHABWidgetMapping(command: "lock", label: "🔒"), + OpenHABWidgetMapping(command: "unlock", label: "🔓") + ], + selectedState: "lock" + ) + + VStack(spacing: 20) { + SegmentedRowView(widget: widget) + Spacer() + } + .environmentObject(SitemapPageViewModel()) +} + +#Preview("Press-Release Buttons") { + let widget = SegmentedRowView.createPreviewWidget( + label: "Blinds Control", + detailLabel: "Position", + mappings: [ + OpenHABWidgetMapping(command: "UP", label: "Up", releaseCommand: "STOP"), + OpenHABWidgetMapping(command: "DOWN", label: "Down", releaseCommand: "STOP") + ] + ) + + VStack(spacing: 20) { + SegmentedRowView(widget: widget) + Text("Press and hold buttons to move blinds") + .font(.caption) + .foregroundColor(.secondary) + Spacer() + } + .environmentObject(SitemapPageViewModel()) +} + +#Preview("Press-Release (Multiple)") { + let widget = SegmentedRowView.createPreviewWidget( + label: "Garage Door", + mappings: [ + OpenHABWidgetMapping(command: "OPEN", label: "Open", releaseCommand: "STOP"), + OpenHABWidgetMapping(command: "CLOSE", label: "Close", releaseCommand: "STOP"), + OpenHABWidgetMapping(command: "PARTIAL", label: "Partial", releaseCommand: "STOP") + ] + ) + + VStack(spacing: 20) { + SegmentedRowView(widget: widget) + Text("Hold to perform action, release to stop") + .font(.caption) + .foregroundColor(.secondary) + Spacer() + } + .environmentObject(SitemapPageViewModel()) +} + +#Preview("Truncation Test") { + let widget = SegmentedRowView.createPreviewWidget( + label: "Very Long Label That Should Truncate Nicely", + detailLabel: "Also A Very Long Detail Text Here", + mappings: [ + OpenHABWidgetMapping(command: "option1", label: "First"), + OpenHABWidgetMapping(command: "option2", label: "Second") + ], + selectedState: "option1" + ) + + VStack(spacing: 20) { + SegmentedRowView(widget: widget) + Text("Tests label truncation behavior") + .font(.caption) + .foregroundColor(.secondary) + Spacer() + } + .environmentObject(SitemapPageViewModel()) +} + +#Preview("All Scenarios") { + ScrollView { + VStack(spacing: 16) { + // Short labels + SegmentedRowView(widget: SegmentedRowView.createPreviewWidget( + label: "Light", + mappings: [ + OpenHABWidgetMapping(command: "ON", label: "ON"), + OpenHABWidgetMapping(command: "OFF", label: "OFF") + ], + selectedState: "ON" + )) + + Divider() + + // Long labels + SegmentedRowView(widget: SegmentedRowView.createPreviewWidget( + label: "Climate Mode", + detailLabel: "Auto", + mappings: [ + OpenHABWidgetMapping(command: "m", label: "Manual"), + OpenHABWidgetMapping(command: "a", label: "Automatic"), + OpenHABWidgetMapping(command: "s", label: "Schedule") + ], + selectedState: "a" + )) + + Divider() + + // Press-release + SegmentedRowView(widget: SegmentedRowView.createPreviewWidget( + label: "Shutter", + mappings: [ + OpenHABWidgetMapping(command: "UP", label: "↑", releaseCommand: "STOP"), + OpenHABWidgetMapping(command: "DOWN", label: "↓", releaseCommand: "STOP") + ] + )) + + Divider() + + // Multiple segments + SegmentedRowView(widget: SegmentedRowView.createPreviewWidget( + label: "Speed", + mappings: [ + OpenHABWidgetMapping(command: "0", label: "Off"), + OpenHABWidgetMapping(command: "1", label: "Low"), + OpenHABWidgetMapping(command: "2", label: "Mid"), + OpenHABWidgetMapping(command: "3", label: "High") + ], + selectedState: "2" + )) + + Spacer() + } + .padding() + } + .environmentObject(SitemapPageViewModel()) +} + #Preview { let widget = PreviewConstants.openHABSitemapPage!.widgets[4] VStack { From 9ef61924f535d12d40205c8001bdbe2e29c4f8af Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Sat, 31 Jan 2026 17:08:51 +0100 Subject: [PATCH 367/476] Refactored the SliderRowView to conditionally wrap the label content in a Button based on the switchSupport property Signed-off-by: Tim Mueller-Seydlitz --- openHAB/SwiftUI/Rows/SliderRowView.swift | 59 ++++++++++++++---------- 1 file changed, 35 insertions(+), 24 deletions(-) diff --git a/openHAB/SwiftUI/Rows/SliderRowView.swift b/openHAB/SwiftUI/Rows/SliderRowView.swift index 301093a44..a86fcac84 100644 --- a/openHAB/SwiftUI/Rows/SliderRowView.swift +++ b/openHAB/SwiftUI/Rows/SliderRowView.swift @@ -34,32 +34,17 @@ struct SliderRowView: View { var body: some View { HStack { - Button { - viewModel.sendCommand(widget.item, commandToSend: sliderValue <= widget.minValue ? "ON" : "OFF") - } label: { - HStack { - IconView(widget: widget) - .frame(width: 32, height: 32) - - if let labelText = widget.labelText, !labelText.isEmpty { - Text(labelText) - .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) - .lineLimit(1) - } - - Spacer() - - if let value = widget.labelValue { - Text(value) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - } + if widget.switchSupport { + Button { + viewModel.sendCommand(widget.item, commandToSend: sliderValue <= widget.minValue ? "ON" : "OFF") + } label: { + labelContent } - .contentShape(Rectangle()) + .buttonStyle(.plain) + .disabled(widget.readOnly ?? false) + } else { + labelContent } - .buttonStyle(.plain) - .disabled(!widget.switchSupport || (widget.readOnly ?? false)) Slider( value: Binding( @@ -97,6 +82,32 @@ struct SliderRowView: View { } } + @ViewBuilder + private var labelContent: some View { + HStack { + IconView(widget: widget) + .frame(width: 32, height: 32) + + if let labelText = widget.labelText, !labelText.isEmpty { + Text(labelText) + .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) + .lineLimit(1) + .truncationMode(.tail) + } + + Spacer() + + if let detailTextLabel = widget.labelValue, !detailTextLabel.isEmpty { + Text(detailTextLabel) + .font(.callout) + .foregroundStyle(widget.valuecolor.isEmpty ? Color(uiColor: UIColor.ohSecondaryLabel) : Color(fromString: widget.valuecolor)) + .lineLimit(1) + } + + } + .contentShape(Rectangle()) + } + private func loadCurrentValue() { sliderValue = widget.stateValueAsNumberState?.value ?? widget.minValue } From 1a97edd48a9f14825bf8461cdc5b1d0484fdb6ed Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Sat, 31 Jan 2026 20:16:16 +0100 Subject: [PATCH 368/476] Fix regression on watchOS app Signed-off-by: Tim Mueller-Seydlitz --- openHABWatch/Domain/UserData.swift | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/openHABWatch/Domain/UserData.swift b/openHABWatch/Domain/UserData.swift index 5a4a1036d..572b01190 100644 --- a/openHABWatch/Domain/UserData.swift +++ b/openHABWatch/Domain/UserData.swift @@ -44,9 +44,7 @@ final class UserData: ObservableObject { do { let sitemapPage = try data.decoded(as: Components.Schemas.PageDTO.self) openHABSitemapPage = OpenHABPage(sitemapPage) - var flattenedWidgets = [OpenHABWidget]() - flattenedWidgets.flatten(openHABSitemapPage?.widgets ?? []) - widgets = flattenedWidgets + widgets = openHABSitemapPage?.widgets ?? [] openHABSitemapPage?.sendCommand = { [weak self] item, command in Task { await self?.sendCommand(item, command: command) } } @@ -294,8 +292,7 @@ final class UserData: ObservableObject { Task { await self?.sendCommand(item, command: command) } } self.openHABSitemapPage = initialPage - var newWidgets = [OpenHABWidget]() - newWidgets.flatten(initialPage?.widgets ?? []) + let newWidgets = initialPage?.widgets ?? [] self.updateWidgets(with: newWidgets) if !newWidgets.isEmpty { self.cachedWidgets = newWidgets @@ -321,8 +318,7 @@ final class UserData: ObservableObject { } } self.openHABSitemapPage = page - var newWidgets = [OpenHABWidget]() - newWidgets.flatten(page?.widgets ?? []) + let newWidgets = page?.widgets ?? [] self.updateWidgets(with: newWidgets) if !newWidgets.isEmpty { self.cachedWidgets = newWidgets From 2c884598bd2aa0f3ca2745da4dc9df10a3e3d754 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Sat, 31 Jan 2026 20:47:18 +0100 Subject: [PATCH 369/476] Fix for slider appearing Signed-off-by: Tim Mueller-Seydlitz --- openHAB/SwiftUI/Rows/SliderRowView.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/openHAB/SwiftUI/Rows/SliderRowView.swift b/openHAB/SwiftUI/Rows/SliderRowView.swift index a86fcac84..6c981a132 100644 --- a/openHAB/SwiftUI/Rows/SliderRowView.swift +++ b/openHAB/SwiftUI/Rows/SliderRowView.swift @@ -109,7 +109,10 @@ struct SliderRowView: View { } private func loadCurrentValue() { - sliderValue = widget.stateValueAsNumberState?.value ?? widget.minValue + // Avoid snapping to minValue while state is still loading. + if let value = widget.stateValueAsNumberState?.value { + sliderValue = value + } } private func sendSliderUpdate(_ newValue: Double) { From a897059f5499067584d1411f059d706a2ccad647 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Sun, 1 Feb 2026 08:20:23 +0100 Subject: [PATCH 370/476] Dark mode for charts Alignment with develop Signed-off-by: Tim Mueller-Seydlitz --- .../OpenHABCore/Model/OpenHABPage.swift | 21 +++++++++++-------- openHAB/SwiftUI/Rows/ImageRowView.swift | 7 ++++++- openHAB/SwiftUI/Rows/SliderRowView.swift | 3 +-- 3 files changed, 19 insertions(+), 12 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABPage.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABPage.swift index 3e907efad..52e5a24da 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABPage.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABPage.swift @@ -21,23 +21,26 @@ public class OpenHABPage: NSObject, @unchecked Sendable { public var leaf = false public var icon = "" - public init(pageId: String, title: String, link: String, leaf: Bool, widgets tempWidgets: [OpenHABWidget], icon: String) { + public init(pageId: String, title: String, link: String, leaf: Bool, widgets: [OpenHABWidget], icon: String) { super.init() self.pageId = pageId self.title = title self.link = link self.leaf = leaf - decorateWithSendCommand(tempWidgets) - widgets = tempWidgets self.icon = icon + var flattened = [OpenHABWidget]() + flattened.flatten(widgets) + self.widgets = flattened - func decorateWithSendCommand(_ widgets: [OpenHABWidget]) { - for widget in widgets { - widget.sendCommand = { [weak self] item, command in - self?.sendCommand(item, commandToSend: command) - } - decorateWithSendCommand(widget.widgets) + decorateWithSendCommand(self.widgets) + } + + private func decorateWithSendCommand(_ widgets: [OpenHABWidget]) { + for widget in widgets { + widget.sendCommand = { [weak self] item, command in + self?.sendCommand(item, commandToSend: command) } + decorateWithSendCommand(widget.widgets) } } diff --git a/openHAB/SwiftUI/Rows/ImageRowView.swift b/openHAB/SwiftUI/Rows/ImageRowView.swift index 092cb1d2c..d3c7568bb 100644 --- a/openHAB/SwiftUI/Rows/ImageRowView.swift +++ b/openHAB/SwiftUI/Rows/ImageRowView.swift @@ -18,6 +18,7 @@ import SwiftUI struct ImageRowView: View { @ObservedObject var widget: OpenHABWidget @EnvironmentObject var viewModel: SitemapPageViewModel + @Environment(\.colorScheme) var colorScheme @State private var refreshTimer: Timer? @State private var forceRefreshKey = UUID() @@ -32,6 +33,10 @@ struct ImageRowView: View { widget.refresh == 0 } + private var chartStyle: ChartStyle { + colorScheme == .light ? .light : .dark + } + var body: some View { VStack(alignment: .leading, spacing: 8) { if let labelText = widget.labelText, !labelText.isEmpty, widget.labelSource == .sitemapDefinition { @@ -39,7 +44,7 @@ struct ImageRowView: View { .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) .lineLimit(1) } - switch widget.generateImageResult(rootUrl: viewModel.openHABRootUrl ?? "") { + switch widget.generateImageResult(rootUrl: viewModel.openHABRootUrl ?? "", chartStyle: chartStyle) { case let .embedded(data: data): let provider = RawImageDataProvider(data: data, cacheKey: shouldCache ? widget.widgetId : "\(widget.widgetId)-\(forceRefreshKey)") KFImage(source: .provider(provider)) diff --git a/openHAB/SwiftUI/Rows/SliderRowView.swift b/openHAB/SwiftUI/Rows/SliderRowView.swift index 6c981a132..875aa0e42 100644 --- a/openHAB/SwiftUI/Rows/SliderRowView.swift +++ b/openHAB/SwiftUI/Rows/SliderRowView.swift @@ -96,14 +96,13 @@ struct SliderRowView: View { } Spacer() - + if let detailTextLabel = widget.labelValue, !detailTextLabel.isEmpty { Text(detailTextLabel) .font(.callout) .foregroundStyle(widget.valuecolor.isEmpty ? Color(uiColor: UIColor.ohSecondaryLabel) : Color(fromString: widget.valuecolor)) .lineLimit(1) } - } .contentShape(Rectangle()) } From 3aa5a999ee2e618bdf3e6c3c2f06d52b6acfbbbe Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Sun, 1 Feb 2026 08:58:08 +0100 Subject: [PATCH 371/476] Fix for singleMappingButton Signed-off-by: Tim Mueller-Seydlitz --- openHAB/SwiftUI/Rows/SegmentedRowView.swift | 54 +++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/openHAB/SwiftUI/Rows/SegmentedRowView.swift b/openHAB/SwiftUI/Rows/SegmentedRowView.swift index 84f832790..b0664a9c9 100644 --- a/openHAB/SwiftUI/Rows/SegmentedRowView.swift +++ b/openHAB/SwiftUI/Rows/SegmentedRowView.swift @@ -57,6 +57,10 @@ struct SegmentedRowView: View { pressReleaseButtons .padding(.leading, 8) .fixedSize(horizontal: true, vertical: false) + } else if mappings.count == 1 { + singleMappingButton + .padding(.leading, 8) + .fixedSize(horizontal: true, vertical: false) } else { // Button-based segmented control with animated selection indicator segmentedButtons @@ -122,6 +126,39 @@ struct SegmentedRowView: View { } } + @ViewBuilder + private var singleMappingButton: some View { + let mapping = mappings[0] + let isSelected = selectedIndex == 0 + + Button { + logger.info("Segment tapped: 0, command: \(mapping.command)") + withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { + selectedIndex = 0 + } + viewModel.sendCommand(widget.item, commandToSend: mapping.command) + } label: { + Text(mapping.label) + .font(.footnote) + .bold() + .lineLimit(1) + .truncationMode(.tail) + .padding(.horizontal, 8) + .padding(.vertical, 5) + .frame(minWidth: 30, maxWidth: 120) + .foregroundStyle(.primary) + } + .background( + RoundedRectangle(cornerRadius: 7) + .fill(Color(uiColor: isSelected ? .systemGray3 : .systemGray5)) + ) + .overlay( + RoundedRectangle(cornerRadius: 7) + .stroke(Color.secondary.opacity(isSelected ? 0.5 : 0.35), lineWidth: 0.75) + ) + .buttonStyle(.plain) + } + // MARK: - Helper Methods @ViewBuilder @@ -290,6 +327,23 @@ private extension SegmentedRowView { .environmentObject(SitemapPageViewModel()) } +#Preview("Single Segment") { + let widget = SegmentedRowView.createPreviewWidget( + label: "Scene", + detailLabel: "Active", + mappings: [ + OpenHABWidgetMapping(command: "PLAY", label: "Run") + ], + selectedState: "PLAY" + ) + + VStack(spacing: 20) { + SegmentedRowView(widget: widget) + Spacer() + } + .environmentObject(SitemapPageViewModel()) +} + #Preview("Press-Release Buttons") { let widget = SegmentedRowView.createPreviewWidget( label: "Blinds Control", From b8fc979512a20d9264084d38312b293413b8638e Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Sun, 1 Feb 2026 10:53:29 +0100 Subject: [PATCH 372/476] Improvement on layout of SegmentedRowView Signed-off-by: Tim Mueller-Seydlitz --- .../Sources/OpenHABCore/Model/OpenHABWidget.swift | 2 -- openHAB/SwiftUI/Rows/SegmentedRowView.swift | 15 ++++++++++++--- openHAB/SwiftUI/Rows/VideoRowView.swift | 7 +++++-- openHAB/SwiftUI/Rows/WebRowView.swift | 5 ++++- 4 files changed, 21 insertions(+), 8 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift index 0cbd3121c..4693a62d2 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift @@ -314,8 +314,6 @@ public class OpenHABWidget: NSObject, MKAnnotation, Identifiable, ObservableObje public func generateImageResult(rootUrl: String, chartStyle: ChartStyle = .light) -> ImagePayload { - print("widget yAxisDecimalPattern: \(yAxisDecimalPattern ?? "")") - switch type { case .chart: guard let url = Endpoint.chart( diff --git a/openHAB/SwiftUI/Rows/SegmentedRowView.swift b/openHAB/SwiftUI/Rows/SegmentedRowView.swift index b0664a9c9..374078caf 100644 --- a/openHAB/SwiftUI/Rows/SegmentedRowView.swift +++ b/openHAB/SwiftUI/Rows/SegmentedRowView.swift @@ -48,6 +48,8 @@ struct SegmentedRowView: View { .foregroundStyle(widget.valuecolor.isEmpty ? Color(uiColor: UIColor.ohSecondaryLabel) : Color(fromString: widget.valuecolor)) .lineLimit(1) .truncationMode(.tail) +// .minimumScaleFactor(0.25) // shrinks text instead of vanishing +// .allowsTightening(true) .layoutPriority(0) // Truncates first } @@ -57,6 +59,7 @@ struct SegmentedRowView: View { pressReleaseButtons .padding(.leading, 8) .fixedSize(horizontal: true, vertical: false) + .layoutPriority(1) } else if mappings.count == 1 { singleMappingButton .padding(.leading, 8) @@ -66,6 +69,8 @@ struct SegmentedRowView: View { segmentedButtons .padding(.leading, 8) .frame(minWidth: 75) + .fixedSize(horizontal: true, vertical: false) + .layoutPriority(1) } } } @@ -150,11 +155,11 @@ struct SegmentedRowView: View { } .background( RoundedRectangle(cornerRadius: 7) - .fill(Color(uiColor: isSelected ? .systemGray3 : .systemGray5)) + .fill(Color(uiColor: isSelected ? .systemBackground : .secondarySystemBackground)) ) .overlay( RoundedRectangle(cornerRadius: 7) - .stroke(Color.secondary.opacity(isSelected ? 0.5 : 0.35), lineWidth: 0.75) + .stroke(Color(uiColor: .separator).opacity(0.6), lineWidth: 0.75) ) .buttonStyle(.plain) } @@ -226,7 +231,11 @@ private extension SegmentedRowView { selectedState: String? = nil) -> OpenHABWidget { let widget = OpenHABWidget() widget.widgetId = UUID().uuidString - widget.label = label + if let detailLabel, !detailLabel.isEmpty { + widget.label = "\(label) [\(detailLabel)]" + } else { + widget.label = label + } widget.type = .switchWidget widget.mappings = mappings diff --git a/openHAB/SwiftUI/Rows/VideoRowView.swift b/openHAB/SwiftUI/Rows/VideoRowView.swift index ab7b1acf0..ce16f3d26 100644 --- a/openHAB/SwiftUI/Rows/VideoRowView.swift +++ b/openHAB/SwiftUI/Rows/VideoRowView.swift @@ -12,6 +12,7 @@ import AVKit import CommonUI import OpenHABCore +import os.log import SwiftUI import UIKit @@ -27,6 +28,8 @@ struct VideoRowView: View { @State private var playerObserver: NSKeyValueObservation? @EnvironmentObject var viewModel: SitemapPageViewModel + private let logger = Logger(subsystem: "org.openhab", category: "VideoRowView") + private var videoURL: URL? { guard !widget.url.isEmpty else { return nil } return URL(string: widget.url) @@ -144,7 +147,7 @@ struct VideoRowView: View { }, onError: { error in Task { @MainActor in - print("MJPEG stream error: \(error.localizedDescription)") + logger.debug("MJPEG stream error: \(error.localizedDescription)") isLoading = false } } @@ -192,7 +195,7 @@ struct VideoRowView: View { player?.play() case .failed: isLoading = false - print("HLS player failed: \(item.error?.localizedDescription ?? "Unknown error")") + logger.debug("HLS player failed: \(item.error?.localizedDescription ?? "Unknown error")") default: break } diff --git a/openHAB/SwiftUI/Rows/WebRowView.swift b/openHAB/SwiftUI/Rows/WebRowView.swift index b0d1aa987..a6235e411 100644 --- a/openHAB/SwiftUI/Rows/WebRowView.swift +++ b/openHAB/SwiftUI/Rows/WebRowView.swift @@ -11,6 +11,7 @@ import CommonUI import OpenHABCore +import os.log import SwiftUI import WebKit @@ -41,8 +42,10 @@ struct WidgetWebViewContainer: View { struct WebRowView: UIViewRepresentable { class Coordinator: NSObject, WKNavigationDelegate { + private let logger = Logger(subsystem: "org.openhab", category: "WebRowViewCoordinator") + func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: any Error) { - print("WebView failed to load: \(error.localizedDescription)") + logger.debug("WebView failed to load: \(error.localizedDescription)") } } From c1df46cacb43c70045b9451ae1815db3c8dd6975 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Sun, 1 Feb 2026 11:24:57 +0100 Subject: [PATCH 373/476] Removing litter Signed-off-by: Tim Mueller-Seydlitz --- .../Sources/OpenHABCore/Util/Endpoint.swift | 8 - PR_1052_CODE_FLOW.md | 248 ------------------ PR_1052_EVALUATION.md | 147 ----------- PR_1052_INDEX.md | 145 ---------- PR_1052_SUMMARY.md | 58 ---- PR_1052_VISUAL.md | 217 --------------- 6 files changed, 823 deletions(-) delete mode 100644 PR_1052_CODE_FLOW.md delete mode 100644 PR_1052_EVALUATION.md delete mode 100644 PR_1052_INDEX.md delete mode 100644 PR_1052_SUMMARY.md delete mode 100644 PR_1052_VISUAL.md diff --git a/OpenHABCore/Sources/OpenHABCore/Util/Endpoint.swift b/OpenHABCore/Sources/OpenHABCore/Util/Endpoint.swift index 709e43706..a4f454eaf 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/Endpoint.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/Endpoint.swift @@ -54,14 +54,6 @@ public struct Endpoint: Equatable { var queryItems: [URLQueryItem] } -extension UIColor { - var rgbaDescription: String { - var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 - getRed(&r, green: &g, blue: &b, alpha: &a) - return String(format: "r: %.2f, g: %.2f, b: %.2f, a: %.2f", r, g, b, a) - } -} - public extension Endpoint { var url: URL? { var components = URLComponents(string: baseURL) diff --git a/PR_1052_CODE_FLOW.md b/PR_1052_CODE_FLOW.md deleted file mode 100644 index 365b5f77a..000000000 --- a/PR_1052_CODE_FLOW.md +++ /dev/null @@ -1,248 +0,0 @@ -# Code Flow Analysis: How PR #1052 Fixes Issue #987 - -## Problem Flow (Before PR #1052) - -``` -┌─────────────────────────────────────────────────────────────────────┐ -│ User enables "Ignore SSL Certificate" in settings │ -└─────────────────────────┬───────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────┐ -│ NetworkTracker stores ignoreSSL=true in ConnectionConfiguration │ -└─────────────────────────┬───────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────┐ -│ Watch app tries to load icon via Kingfisher │ -└─────────────────────────┬───────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────┐ -│ HTTPS connection with self-signed certificate │ -└─────────────────────────┬───────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────┐ -│ Kingfisher calls AuthenticationChallengeResponsible delegate │ -│ → OpenHABWatchAppDelegate.downloader(_:didReceive:) │ -└─────────────────────────┬───────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────┐ -│ onReceiveSessionChallenge() is called │ -│ Switch case: NSURLAuthenticationMethodServerTrust │ -└─────────────────────────┬───────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────┐ -│ ❌ PROBLEM: Has "// TODO:" comment │ -│ ❌ Does NOT check ignoreSSL setting │ -│ ❌ Always calls CertificateManagers.serverCertificateManager │ -└─────────────────────────┬───────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────┐ -│ ServerCertificateManager.evaluateTrust() validates certificate │ -│ Certificate is invalid (self-signed) │ -└─────────────────────────┬───────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────┐ -│ ❌ Prompts user to accept certificate (but Watch has no UI) │ -│ ❌ OR returns .cancelAuthenticationChallenge │ -└─────────────────────────┬───────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────┐ -│ ❌ RESULT: Icon fails to load (white circle shown instead) │ -└─────────────────────────────────────────────────────────────────────┘ -``` - -## Solution Flow (After PR #1052) - -``` -┌─────────────────────────────────────────────────────────────────────┐ -│ User enables "Ignore SSL Certificate" in settings │ -└─────────────────────────┬───────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────┐ -│ NetworkTracker stores ignoreSSL=true in ConnectionConfiguration │ -└─────────────────────────┬───────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────┐ -│ Watch app tries to load icon via Kingfisher │ -└─────────────────────────┬───────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────┐ -│ HTTPS connection with self-signed certificate │ -└─────────────────────────┬───────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────┐ -│ Kingfisher calls AuthenticationChallengeResponsible delegate │ -│ → OpenHABWatchAppDelegate.downloader(_:didReceive:) │ -└─────────────────────────┬───────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────┐ -│ onReceiveSessionChallenge() is called │ -│ Switch case: NSURLAuthenticationMethodServerTrust │ -└─────────────────────────┬───────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────┐ -│ ✅ NEW CODE: Check if ignoreSSL is enabled │ -│ │ -│ if let activeConnection = await NetworkTracker.shared │ -│ .activeConnection, │ -│ activeConnection.configuration.ignoreSSL, │ -│ let serverTrust = challenge.protectionSpace.serverTrust { │ -│ return (.useCredential, URLCredential(trust: serverTrust)) │ -│ } │ -└─────────────────────────┬───────────────────────────────────────────┘ - │ - YES ignoreSSL enabled? - │ - ┌───────────┴───────────┐ - │ │ - YES NO - │ │ - ▼ ▼ -┌──────────────────────────┐ ┌──────────────────────────────┐ -│ ✅ Bypass validation │ │ Normal validation path │ -│ Return .useCredential │ │ → ServerCertificateManager │ -│ with server trust │ │ .evaluateTrust() │ -└─────────┬────────────────┘ └──────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────┐ -│ ✅ RESULT: Icon loads successfully! │ -└─────────────────────────────────────────────────────────────────────┘ -``` - -## Key Components - -### 1. NetworkTracker (line 479) -```swift -if let connection { - KingfisherManager.shared.defaultOptions = [ - .requestModifier(OpenHABAccessTokenAdapter( - connectionConfiguration: connection.configuration - )) - ] -} -``` -- Configures Kingfisher with connection settings -- BUT: Does not configure authentication challenge handling -- Challenge handling comes from the delegate pattern - -### 2. OpenHABWatchAppDelegate (lines 84-88) -```swift -extension OpenHABWatchAppDelegate: AuthenticationChallengeResponsible { - func downloader(_ downloader: ImageDownloader, - didReceive challenge: URLAuthenticationChallenge) async - -> (URLSession.AuthChallengeDisposition, URLCredential?) { - await onReceiveSessionChallenge(with: challenge) - } -} -``` -- Watch app implements Kingfisher's AuthenticationChallengeResponsible -- Delegates SSL challenges to onReceiveSessionChallenge() - -### 3. SessionChallengeHandler - BEFORE -```swift -case NSURLAuthenticationMethodServerTrust: - // TODO: ← Problem: not implemented! - return await CertificateManagers.serverCertificateManager - .evaluateTrust(with: challenge) -``` - -### 4. SessionChallengeHandler - AFTER (PR #1052) -```swift -case NSURLAuthenticationMethodServerTrust: - // Check if the active connection has ignoreSSL enabled - if let activeConnection = await NetworkTracker.shared.activeConnection, - activeConnection.configuration.ignoreSSL, - let serverTrust = challenge.protectionSpace.serverTrust { - Logger.sessionChallenge.info("Ignoring SSL certificate validation") - return (.useCredential, URLCredential(trust: serverTrust)) - } - return await CertificateManagers.serverCertificateManager - .evaluateTrust(with: challenge) -``` - -## Comparison with Similar Code - -### HTTPClientDelegate (lines 86-88) -Used by OpenAPI HTTP client (not Kingfisher): -```swift -if result.isAny(of: .unspecified, .proceed) || - connectionConfiguration.ignoreSSL { - return (.useCredential, URLCredential(trust: serverTrust)) -} -``` -✅ Has ignoreSSL check - -### ServerCertificateManager (line 87) -Used for general certificate evaluation: -```swift -if evaluateResult.isAny(of: .unspecified, .proceed) || ignoreSSL { - return -} -``` -✅ Has ignoreSSL check - -### onReceiveSessionChallenge - BEFORE -Used by Kingfisher for icon loading: -```swift -// TODO: -return await CertificateManagers.serverCertificateManager - .evaluateTrust(with: challenge) -``` -❌ Missing ignoreSSL check ← **This is the bug!** - -### onReceiveSessionChallenge - AFTER (PR #1052) -```swift -if let activeConnection = await NetworkTracker.shared.activeConnection, - activeConnection.configuration.ignoreSSL, - let serverTrust = challenge.protectionSpace.serverTrust { - return (.useCredential, URLCredential(trust: serverTrust)) -} -return await CertificateManagers.serverCertificateManager - .evaluateTrust(with: challenge) -``` -✅ Now has ignoreSSL check ← **This fixes the bug!** - -## Why This Matters for Apple Watch - -1. **No UI for certificate prompts**: Watch has limited UI, can't show certificate acceptance dialogs -2. **Relies on iPhone settings**: Watch uses connection settings synced from iPhone -3. **Icons loaded separately**: Each icon is a separate HTTPS request through Kingfisher -4. **Self-signed certs common**: Home automation users often use self-signed certificates for local networks -5. **ignoreSSL setting exists**: iOS app has this setting specifically for this use case - -## Testing Scenarios - -| Scenario | ignoreSSL | Certificate | Expected Result | Before PR | After PR | -|----------|-----------|-------------|-----------------|-----------|----------| -| Local HTTPS | ✅ ON | Self-signed | ✅ Icons load | ❌ Fail | ✅ Pass | -| Local HTTPS | ❌ OFF | Self-signed | Prompt user | ❌ Fail* | ⚠️ Prompt* | -| Local HTTPS | ❌ OFF | Valid cert | ✅ Icons load | ✅ Pass | ✅ Pass | -| Local HTTP | N/A | N/A | ✅ Icons load | ✅ Pass | ✅ Pass | -| myopenhab.org | N/A | Valid cert | ✅ Icons load | ✅ Pass | ✅ Pass | - -*Watch can't show prompts, so would need certificate pre-accepted on iPhone - -## Conclusion - -PR #1052 adds the missing ignoreSSL check that exists in similar code paths (HTTPClientDelegate, ServerCertificateManager) but was missing in the Kingfisher authentication challenge handler. This is a targeted, safe fix that: - -1. ✅ Only affects icon loading via Kingfisher -2. ✅ Only activates when user explicitly enables ignoreSSL -3. ✅ Follows established patterns in the codebase -4. ✅ Fixes the exact reported issue -5. ✅ No side effects on other functionality diff --git a/PR_1052_EVALUATION.md b/PR_1052_EVALUATION.md deleted file mode 100644 index d690c696b..000000000 --- a/PR_1052_EVALUATION.md +++ /dev/null @@ -1,147 +0,0 @@ -# Evaluation of PR #1052: Fix for Issue #987 - -## Executive Summary - -**✅ RECOMMENDATION: APPROVE AND MERGE** - -PR #1052 correctly addresses issue #987 (Icons not showing on Apple Watch with SSL connection using self-signed certificate) by implementing the missing `ignoreSSL` check in the `onReceiveSessionChallenge()` function. - -## Issue Analysis - -### Problem Statement (Issue #987) -- **Symptom**: Icons don't display on Apple Watch when using HTTPS with self-signed certificates -- **Works**: HTTP connections and myopenhab.org cloud connections show icons correctly -- **Fails**: Local HTTPS connections with self-signed certificates, even with "Ignore SSL Certificate" setting enabled -- **Related**: Issue #944 (icons not showing on watch - fixed for non-SSL scenarios) - -### Root Cause -The `onReceiveSessionChallenge()` function in `SessionChallengeHandler.swift` had a `// TODO:` comment at line 46 and did not check the `ignoreSSL` configuration setting before validating SSL certificates. This caused Kingfisher (the image loading library) to always enforce certificate validation, even when users explicitly disabled SSL verification. - -## PR Solution Analysis - -### Changes Made -The PR adds 6 lines of code to `OpenHABCore/Sources/OpenHABCore/Util/SessionChallengeHandler.swift`: - -```swift -// Check if the active connection has ignoreSSL enabled -if let activeConnection = await NetworkTracker.shared.activeConnection, - activeConnection.configuration.ignoreSSL, - let serverTrust = challenge.protectionSpace.serverTrust { - Logger.sessionChallenge.info("Ignoring SSL certificate validation (ignoreSSL enabled)") - return (.useCredential, URLCredential(trust: serverTrust)) -} -``` - -### Why This Fix Works - -1. **Architecture Context**: - - Kingfisher uses `AuthenticationChallengeResponsible` protocol for SSL challenges - - Watch app implements this in `OpenHABWatchAppDelegate.swift` (lines 84-95) - - When loading icons, Kingfisher calls `onReceiveSessionChallenge()` for SSL validation - - NetworkTracker maintains the active connection configuration (line 479 in NetworkTracker.swift) - -2. **Correct Pattern Usage**: - - Mirrors the established pattern in `HTTPClientDelegate.handleServerTrust()` (line 86): - ```swift - if result.isAny(of: .unspecified, .proceed) || connectionConfiguration.ignoreSSL - ``` - - Follows the same pattern used in `ServerCertificateManager.evaluate()` (line 87): - ```swift - if evaluateResult.isAny(of: .unspecified, .proceed) || ignoreSSL - ``` - -3. **Safe Implementation**: - - Uses optional chaining to safely access `activeConnection` - - Guards against nil `serverTrust` - - Falls through to normal certificate evaluation if ignoreSSL is not enabled - -4. **Proper Order of Operations**: - - Check ignoreSSL **before** calling `CertificateManagers.serverCertificateManager.evaluateTrust()` - - This prevents unnecessary prompts to the user when SSL validation is disabled - -## Code Quality Assessment - -### ✅ Strengths -- **Minimal change**: Only touches the exact location needed -- **Safe**: Uses proper Swift optional handling -- **Async/await**: Correctly uses `await` for actor-isolated property access -- **Logging**: Adds informative log message for debugging -- **Consistency**: Follows existing code patterns in the project -- **No breaking changes**: Backward compatible, only affects users with ignoreSSL enabled - -### ⚠️ Observations -- No test coverage exists for `SessionChallengeHandler` functions - - This is consistent with current codebase state - - Testing would require complex URLAuthenticationChallenge mocking - - ServerCertificateManager and HTTPClientDelegate have test coverage but not the session challenge handlers -- Warning log at line 40 says "not implemented fully" - this PR partially addresses that - -## Testing Recommendations - -### Manual Testing Checklist -- [ ] Test with HTTPS and self-signed certificate with ignoreSSL **enabled** → icons should load -- [ ] Test with HTTPS and self-signed certificate with ignoreSSL **disabled** → should prompt for certificate acceptance -- [ ] Test with HTTPS and valid certificate → icons should load (no ignoreSSL needed) -- [ ] Test with HTTP connection → icons should load (no SSL involved) -- [ ] Test with myopenhab.org cloud → icons should load -- [ ] Verify iOS app still works correctly (not just Watch) - -### Automated Testing -Consider adding unit tests for `onReceiveSessionChallenge()` in future work: -- Mock NetworkTracker with test connection configurations -- Mock URLAuthenticationChallenge with test server trust -- Verify correct disposition and credential returned for various scenarios - -## Related Code Review - -### Similar Implementations in Codebase - -1. **HTTPClientDelegate.swift** (lines 71-89): - - Handles server trust challenges for OpenAPI HTTP client - - Checks `connectionConfiguration.ignoreSSL` at line 86 - - Provides user prompts for certificate acceptance - -2. **ServerCertificateManager.swift** (lines 83-89): - - Evaluates server trust for certificate validation - - Checks `ignoreSSL` property at line 87 - - Manages certificate storage and user decisions - -3. **SessionChallengeHandler.swift** (lines 107-131): - - Class-based session challenge handler (alternative implementation) - - Uses evaluator closures for server trust - - Not currently used by Kingfisher - -## Security Considerations - -### ✅ Secure by Default -- SSL validation is enabled by default -- Users must explicitly enable ignoreSSL setting -- Only bypasses validation when user has consciously disabled it - -### ⚠️ User Responsibility -- Users enabling ignoreSSL should understand security implications -- App should clearly warn users about SSL certificate risks (handled in settings UI) -- Only recommended for testing or isolated home networks - -## Conclusion - -**This PR should be merged.** It correctly implements the missing functionality that prevents icons from loading on Apple Watch when using SSL with self-signed certificates and the ignoreSSL setting enabled. The implementation: - -1. ✅ Solves the reported issue -2. ✅ Follows established patterns in the codebase -3. ✅ Is safe and minimal -4. ✅ Maintains backward compatibility -5. ✅ Includes appropriate logging - -### Recommended Actions -1. **Merge** PR #1052 -2. **Manual test** on Apple Watch with SSL configuration -3. **Consider** adding automated tests in future work -4. **Update** the warning message at line 40 since this TODO is now addressed -5. **Document** in release notes that SSL icon loading is now fixed for Watch - ---- - -**Evaluated by**: GitHub Copilot Agent -**Date**: 2026-01-29 -**Evaluation Status**: ✅ APPROVED diff --git a/PR_1052_INDEX.md b/PR_1052_INDEX.md deleted file mode 100644 index d1a5ab5a4..000000000 --- a/PR_1052_INDEX.md +++ /dev/null @@ -1,145 +0,0 @@ -# PR #1052 Evaluation - Index - -This directory contains a comprehensive evaluation of [PR #1052](https://github.com/openhab/openhab-ios/pull/1052), which fixes [Issue #987](https://github.com/openhab/openhab-ios/issues/987) (Icons not showing on Apple Watch when using HTTPS with self-signed certificates). - -## 🎯 Quick Answer - -**✅ YES - PR #1052 WILL FIX THE ISSUE** - -The PR adds the missing `ignoreSSL` check in `onReceiveSessionChallenge()`, allowing icons to load on Apple Watch when SSL certificate validation is disabled by the user. - -## 📚 Documentation Files - -### 1. [PR_1052_SUMMARY.md](PR_1052_SUMMARY.md) (2.4 KB) -**Start here** - Quick verdict, TL;DR, and 30-second overview -- Executive summary -- The fix (6 lines of code) -- Why it works -- Final recommendation - -### 2. [PR_1052_VISUAL.md](PR_1052_VISUAL.md) (8.5 KB) -**Visual learner?** - Before/after comparison with diagrams -- Side-by-side code comparison -- Visual impact diagrams -- Test scenario matrix -- Comparison with similar implementations -- The 6 lines that fix everything - -### 3. [PR_1052_EVALUATION.md](PR_1052_EVALUATION.md) (6.6 KB) -**Deep dive** - Comprehensive analysis and assessment -- Issue and root cause analysis -- PR solution analysis -- Code quality assessment -- Security considerations -- Testing recommendations -- Related code review - -### 4. [PR_1052_CODE_FLOW.md](PR_1052_CODE_FLOW.md) (17 KB) -**Technical details** - Architecture and data flow -- Problem flow (before fix) -- Solution flow (after fix) -- Key component analysis -- Comparison with similar code paths -- Testing scenarios - -## 🔍 What Was Evaluated - -### The Problem (Issue #987) -- Icons don't display on Apple Watch when: - - Using HTTPS connection - - With self-signed certificate - - Even when "Ignore SSL Certificate" setting is enabled - -### The Root Cause -The `onReceiveSessionChallenge()` function in `SessionChallengeHandler.swift`: -- Had a `// TODO:` comment at line 46 -- Did not check the `ignoreSSL` configuration setting -- Always enforced certificate validation, even when users disabled it -- Used by Kingfisher (icon loading library) for SSL challenges - -### The Solution (PR #1052) -Adds 6 lines of code to check the active connection's `ignoreSSL` setting: -```swift -if let activeConnection = await NetworkTracker.shared.activeConnection, - activeConnection.configuration.ignoreSSL, - let serverTrust = challenge.protectionSpace.serverTrust { - Logger.sessionChallenge.info("Ignoring SSL certificate validation (ignoreSSL enabled)") - return (.useCredential, URLCredential(trust: serverTrust)) -} -``` - -## ✅ Evaluation Results - -### Correctness -- ✅ Addresses the exact root cause -- ✅ Matches established patterns (HTTPClientDelegate, ServerCertificateManager) -- ✅ Proper use of NetworkTracker.shared.activeConnection -- ✅ Correct async/await usage - -### Safety -- ✅ Secure by default (SSL validation enabled unless user disables) -- ✅ Proper optional chaining -- ✅ No force unwrapping -- ✅ Safe nil handling - -### Code Quality -- ✅ Minimal change (only 6 lines) -- ✅ Follows project code style -- ✅ Includes logging -- ✅ No breaking changes -- ✅ Backward compatible - -### Testing -- ⚠️ No existing test coverage for SessionChallengeHandler (consistent with codebase) -- ✅ Manual testing checklist provided -- ✅ Test scenarios documented - -### Documentation -- ✅ Clear PR description -- ✅ Comprehensive evaluation provided (4 documents, 34.5 KB total) -- ✅ Code flow diagrams -- ✅ Testing recommendations - -## 📊 Impact Analysis - -| Scenario | Before PR | After PR | Impact | -|----------|-----------|----------|--------| -| Watch + HTTPS + Self-signed + ignoreSSL=ON | ❌ Fail | ✅ Pass | **Fixed** | -| Watch + HTTPS + Self-signed + ignoreSSL=OFF | Prompt | Prompt | Same | -| Watch + HTTPS + Valid cert | ✅ Pass | ✅ Pass | Same | -| Watch + HTTP | ✅ Pass | ✅ Pass | Same | -| iOS + Any SSL scenario | ✅ Pass | ✅ Pass | Same | - -**Net Impact**: Fixes the exact reported issue, no side effects - -## 🚀 Recommendation - -``` -╔══════════════════════════════════════════════╗ -║ ✅ APPROVE AND MERGE PR #1052 ║ -║ ║ -║ • Correctly fixes issue #987 ║ -║ • Safe, minimal, and well-tested ║ -║ • Follows established patterns ║ -║ • No breaking changes ║ -║ • Ready to merge ║ -╚══════════════════════════════════════════════╝ -``` - -## 🔗 Links - -- **PR**: https://github.com/openhab/openhab-ios/pull/1052 -- **Issue**: https://github.com/openhab/openhab-ios/issues/987 -- **Related Issue**: https://github.com/openhab/openhab-ios/issues/944 - -## 📝 Notes - -- Evaluation completed: 2026-01-29 -- Evaluator: GitHub Copilot Agent -- Total documentation: 4 files, ~34.5 KB -- Code review: Passed with no issues -- Recommendation: **APPROVE AND MERGE** - ---- - -**Next Steps**: Merge PR #1052, perform manual testing on Apple Watch with SSL configuration, update release notes. diff --git a/PR_1052_SUMMARY.md b/PR_1052_SUMMARY.md deleted file mode 100644 index ca7006722..000000000 --- a/PR_1052_SUMMARY.md +++ /dev/null @@ -1,58 +0,0 @@ -# PR #1052 Evaluation Summary - -## Quick Answer - -**✅ YES - PR #1052 WILL ADDRESS ISSUE #987** - -## TL;DR - -**Problem**: Watch app icons don't load when using HTTPS with self-signed certificates, even with "Ignore SSL Certificate" enabled. - -**Root Cause**: `onReceiveSessionChallenge()` function (used by Kingfisher image loading) didn't check the `ignoreSSL` setting before validating SSL certificates. - -**Solution**: PR #1052 adds 6 lines of code to check `ignoreSSL` before certificate validation, matching the pattern used in similar code (HTTPClientDelegate, ServerCertificateManager). - -**Verdict**: Safe, minimal, correct fix that follows established patterns. Recommend APPROVE and MERGE. - -## The Fix (6 lines) - -```swift -// Check if the active connection has ignoreSSL enabled -if let activeConnection = await NetworkTracker.shared.activeConnection, - activeConnection.configuration.ignoreSSL, - let serverTrust = challenge.protectionSpace.serverTrust { - Logger.sessionChallenge.info("Ignoring SSL certificate validation (ignoreSSL enabled)") - return (.useCredential, URLCredential(trust: serverTrust)) -} -``` - -## Why It Works - -1. **Kingfisher** (icon loading library) calls `onReceiveSessionChallenge()` for SSL challenges -2. **Before**: Always validated certificates, ignored user's `ignoreSSL` setting → icons failed to load -3. **After**: Checks `ignoreSSL` setting first, bypasses validation if enabled → icons load successfully -4. **Pattern**: Same approach used in `HTTPClientDelegate` and `ServerCertificateManager` - -## Validation - -✅ **Code Review**: Matches established patterns -✅ **Architecture**: Correct use of NetworkTracker.shared.activeConnection -✅ **Safety**: Proper optional chaining and async/await -✅ **Security**: Secure by default, only bypasses when user enables -✅ **Minimal**: Only 6 lines, no side effects -✅ **Backward Compatible**: Doesn't break existing functionality - -## Recommendation - -**APPROVE AND MERGE** - This PR correctly implements the missing functionality that prevents Watch icons from loading with self-signed certificates and ignoreSSL enabled. - -## Documentation - -See detailed analysis in: -- **PR_1052_EVALUATION.md** - Full evaluation with testing recommendations -- **PR_1052_CODE_FLOW.md** - Visual code flow and component diagrams - ---- - -**Evaluated**: 2026-01-29 -**Status**: ✅ APPROVED FOR MERGE diff --git a/PR_1052_VISUAL.md b/PR_1052_VISUAL.md deleted file mode 100644 index 2b2679974..000000000 --- a/PR_1052_VISUAL.md +++ /dev/null @@ -1,217 +0,0 @@ -# PR #1052 Evaluation - Visual Summary - -## The Change (Before → After) - -### BEFORE (Current Code - Has Bug) -```swift -@MainActor -public func onReceiveSessionChallenge(with challenge: URLAuthenticationChallenge) async - -> (URLSession.AuthChallengeDisposition, URLCredential?) { - - Logger.sessionChallenge.warning("onReceiveSessionChallenge is not implemented fully (see TODOs)") - Logger.sessionChallenge.info("onReceiveSessionChallenge host: \(String(describing: challenge.protectionSpace.host))") - var disposition: URLSession.AuthChallengeDisposition = .performDefaultHandling - - switch challenge.protectionSpace.authenticationMethod { - case NSURLAuthenticationMethodServerTrust: - // TODO: ← ❌ PROBLEM: Not checking ignoreSSL! - return await CertificateManagers.serverCertificateManager.evaluateTrust(with: challenge) - // ↑ - // Always validates certificate, even when ignoreSSL is enabled - // → Icons fail to load on Watch with self-signed certificates -``` - -### AFTER (PR #1052 - Fixed) -```swift -@MainActor -public func onReceiveSessionChallenge(with challenge: URLAuthenticationChallenge) async - -> (URLSession.AuthChallengeDisposition, URLCredential?) { - - Logger.sessionChallenge.warning("onReceiveSessionChallenge is not implemented fully (see TODOs)") - Logger.sessionChallenge.info("onReceiveSessionChallenge host: \(String(describing: challenge.protectionSpace.host))") - var disposition: URLSession.AuthChallengeDisposition = .performDefaultHandling - - switch challenge.protectionSpace.authenticationMethod { - case NSURLAuthenticationMethodServerTrust: - // ✅ NEW: Check if the active connection has ignoreSSL enabled - if let activeConnection = await NetworkTracker.shared.activeConnection, - activeConnection.configuration.ignoreSSL, - let serverTrust = challenge.protectionSpace.serverTrust { - Logger.sessionChallenge.info("Ignoring SSL certificate validation (ignoreSSL enabled)") - return (.useCredential, URLCredential(trust: serverTrust)) - // ↑ - // Bypasses validation when ignoreSSL is enabled - // → Icons now load successfully! ✅ - } - return await CertificateManagers.serverCertificateManager.evaluateTrust(with: challenge) -``` - -## Visual Impact - -### Current Behavior (Bug) -``` -Watch App with HTTPS + Self-Signed Cert + ignoreSSL=ON - -[Settings] ignoreSSL: ☑️ ENABLED - ↓ -[Icon Request] → HTTPS → SSL Challenge - ↓ -[onReceiveSessionChallenge] - ↓ - ❌ TODO - doesn't check ignoreSSL - ↓ -[CertificateManager] → VALIDATE - ↓ - ❌ Certificate Invalid - ↓ -[Result] Icon fails to load - White circle displayed ⚪ -``` - -### Fixed Behavior (PR #1052) -``` -Watch App with HTTPS + Self-Signed Cert + ignoreSSL=ON - -[Settings] ignoreSSL: ☑️ ENABLED - ↓ -[Icon Request] → HTTPS → SSL Challenge - ↓ -[onReceiveSessionChallenge] - ↓ - ✅ Check: ignoreSSL enabled? - ↓ - YES ✅ - ↓ - Return .useCredential - ↓ -[Result] Icon loads successfully - Icon displayed 🏠 ✅ -``` - -## Side-by-Side Comparison - -| Aspect | Before PR #1052 | After PR #1052 | -|--------|----------------|----------------| -| **ignoreSSL Check** | ❌ No | ✅ Yes | -| **Watch Icons with SSL** | ❌ Fail | ✅ Work | -| **Code Pattern** | ❌ Inconsistent | ✅ Matches others | -| **User Experience** | ❌ White circles | ✅ Icons shown | -| **Lines Changed** | 0 | 6 | -| **Breaking Changes** | N/A | ✅ None | -| **Security** | N/A | ✅ Secure by default | - -## Comparison with Similar Code - -All three SSL challenge handlers should check ignoreSSL: - -### 1. HTTPClientDelegate (OpenAPI HTTP Client) -```swift -// Line 86 - HTTPClientDelegate.swift -if result.isAny(of: .unspecified, .proceed) || - connectionConfiguration.ignoreSSL { // ✅ HAS CHECK - return (.useCredential, URLCredential(trust: serverTrust)) -} -``` -Status: ✅ **Already has ignoreSSL check** - -### 2. ServerCertificateManager (Certificate Evaluation) -```swift -// Line 87 - ServerCertificateManager.swift -if evaluateResult.isAny(of: .unspecified, .proceed) || - ignoreSSL { // ✅ HAS CHECK - return -} -``` -Status: ✅ **Already has ignoreSSL check** - -### 3. onReceiveSessionChallenge (Kingfisher Icon Loading) -**BEFORE:** -```swift -// Line 46 - SessionChallengeHandler.swift -case NSURLAuthenticationMethodServerTrust: - // TODO: ← ❌ MISSING CHECK - return await CertificateManagers.serverCertificateManager - .evaluateTrust(with: challenge) -``` -Status: ❌ **Missing ignoreSSL check** ← THIS IS THE BUG! - -**AFTER (PR #1052):** -```swift -// Line 46 - SessionChallengeHandler.swift -case NSURLAuthenticationMethodServerTrust: - if let activeConnection = await NetworkTracker.shared.activeConnection, - activeConnection.configuration.ignoreSSL, // ✅ NOW HAS CHECK - let serverTrust = challenge.protectionSpace.serverTrust { - return (.useCredential, URLCredential(trust: serverTrust)) - } - return await CertificateManagers.serverCertificateManager - .evaluateTrust(with: challenge) -``` -Status: ✅ **Now has ignoreSSL check** ← THIS FIXES THE BUG! - -## Test Matrix - -| Test Scenario | Connection | Certificate | ignoreSSL | Before PR | After PR | Status | -|--------------|------------|-------------|-----------|-----------|----------|--------| -| 1 | Local HTTPS | Self-signed | ✅ ON | ❌ Fail | ✅ Pass | **Fixed** | -| 2 | Local HTTPS | Self-signed | ❌ OFF | ⚠️ Prompt | ⚠️ Prompt | Same | -| 3 | Local HTTPS | Valid | N/A | ✅ Pass | ✅ Pass | Same | -| 4 | Local HTTP | N/A | N/A | ✅ Pass | ✅ Pass | Same | -| 5 | myopenhab | Valid | N/A | ✅ Pass | ✅ Pass | Same | -| 6 | Local HTTPS | Valid | ✅ ON | ✅ Pass | ✅ Pass | Same | - -**Key Finding**: Only Scenario 1 (the reported issue) changes from ❌ to ✅ - -## The 6 Lines That Fix Everything - -```diff - case NSURLAuthenticationMethodServerTrust: -- // TODO: -+ // Check if the active connection has ignoreSSL enabled -+ if let activeConnection = await NetworkTracker.shared.activeConnection, -+ activeConnection.configuration.ignoreSSL, -+ let serverTrust = challenge.protectionSpace.serverTrust { -+ Logger.sessionChallenge.info("Ignoring SSL certificate validation (ignoreSSL enabled)") -+ return (.useCredential, URLCredential(trust: serverTrust)) -+ } - return await CertificateManagers.serverCertificateManager.evaluateTrust(with: challenge) -``` - -**Lines added**: 6 -**Lines removed**: 1 (the TODO comment) -**Net change**: +7 lines -**Impact**: Fixes Watch icon loading with SSL + self-signed certificates - -## Final Verdict - -``` -╔════════════════════════════════════════════════════════════════╗ -║ ║ -║ ✅ APPROVE AND MERGE PR #1052 ║ -║ ║ -║ The PR correctly implements the missing ignoreSSL check ║ -║ that prevents Watch icons from loading with self-signed ║ -║ certificates. The fix is: ║ -║ ║ -║ • Minimal (6 lines) ║ -║ • Correct (matches established patterns) ║ -║ • Safe (proper optionals and async/await) ║ -║ • Secure (secure by default) ║ -║ • Complete (addresses exact root cause) ║ -║ ║ -║ No additional changes needed. ║ -║ ║ -╚════════════════════════════════════════════════════════════════╝ -``` - -## Documentation - -- 📄 **PR_1052_SUMMARY.md** - Quick answer (this file) -- 📋 **PR_1052_EVALUATION.md** - Comprehensive analysis (6.6 KB) -- 📊 **PR_1052_CODE_FLOW.md** - Visual diagrams (17 KB) - ---- - -**Evaluation Date**: 2026-01-29 -**Evaluator**: GitHub Copilot Agent -**Status**: ✅ COMPLETE - READY TO MERGE From 131184aa70e208018e8a3c270ad41637ecfb40de Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Sun, 1 Feb 2026 12:33:36 +0100 Subject: [PATCH 374/476] Fix for path and filename for openHABWatch/openHABWatch.entitlements Signed-off-by: Tim Mueller-Seydlitz --- openHAB.xcodeproj/project.pbxproj | 2 -- 1 file changed, 2 deletions(-) diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index 52486a474..bb5ddd6b6 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -2169,8 +2169,6 @@ CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_ENTITLEMENTS = openHABWatch/openHABWatch.entitlements; - CURRENT_PROJECT_VERSION = 103; - CODE_SIGN_ENTITLEMENTS = "openHABWatch Extension/openHABWatch Extension.entitlements"; CURRENT_PROJECT_VERSION = 104; DEBUG_INFORMATION_FORMAT = dwarf; GCC_C_LANGUAGE_STANDARD = "compiler-default"; From bd9d3f828d259d0fccdd6326c409722378dc167b Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Sun, 1 Feb 2026 15:58:39 +0100 Subject: [PATCH 375/476] Improving color selection Signed-off-by: Tim Mueller-Seydlitz --- openHABWatch/Views/Rows/ColorPickerRow.swift | 10 +- openHABWatch/Views/Utils/ColorSelection.swift | 174 ++++++++++++++---- 2 files changed, 148 insertions(+), 36 deletions(-) diff --git a/openHABWatch/Views/Rows/ColorPickerRow.swift b/openHABWatch/Views/Rows/ColorPickerRow.swift index b4d6aa0c3..35cf57f36 100644 --- a/openHABWatch/Views/Rows/ColorPickerRow.swift +++ b/openHABWatch/Views/Rows/ColorPickerRow.swift @@ -36,10 +36,16 @@ struct ColorPickerRow: View { Spacer() - NavigationLink(destination: ColorSelection()) { + NavigationLink(destination: ColorSelection(widget: widget)) { Circle() - .fill(Color(uiColor!)) + .fill(uiColor.map { Color($0) } ?? Color.gray.opacity(0.4)) .frame(width: 35, height: 35) + .overlay { + if uiColor == nil { + Circle() + .stroke(Color.white.opacity(0.4), lineWidth: 1) + } + } } Spacer() diff --git a/openHABWatch/Views/Utils/ColorSelection.swift b/openHABWatch/Views/Utils/ColorSelection.swift index b0175fc52..53465fec6 100644 --- a/openHABWatch/Views/Utils/ColorSelection.swift +++ b/openHABWatch/Views/Utils/ColorSelection.swift @@ -50,22 +50,49 @@ enum DragState { } struct ColorSelection: View { + @ObservedObject var widget: OpenHABWidget @GestureState var thumb: DragState = .inactive - @State var hue = 0.5 - @State var xpos: Double = 100 - @State var ypos: Double = 100 + @State private var hue = 0.0 + @State private var saturation = 1.0 + @State private var brightness = 1.0 + @State private var xpos: Double = 100 + @State private var ypos: Double = 100 + @State private var dragStart: CGPoint? + @State private var lastSendTime: Date = .distantPast + + private let handleRadius: Double = 12.5 var body: some View { - let spectrum = Gradient(colors: [.red, .yellow, .green, .blue, .purple, .red]) + // Use a clockwise spectrum to match the handle's hue direction. + let spectrum = Gradient(colors: [.red, .purple, .blue, .green, .yellow, .red]) - let conic = AngularGradient(gradient: spectrum, center: .center, angle: .degrees(0)) + // Rotate so hue = 0 aligns with top (matching the handle math). + let conic = AngularGradient(gradient: spectrum, center: .center, angle: .degrees(-90)) return GeometryReader { (geometry: GeometryProxy) in - Circle() - .size(geometry.size) - .fill(conic) - .overlay(generateHandle(geometry: geometry)) + ZStack(alignment: .topLeading) { + Circle() + .size(geometry.size) + .fill(conic) + .overlay(generateHandle(geometry: geometry)) + + Circle() + .fill(Color(hue: hue, saturation: saturation, brightness: brightness)) + .frame(width: 16, height: 16) + .overlay( + Circle() + .stroke(Color.white.opacity(0.6), lineWidth: 1) + ) + .padding(6) + } + .onAppear { + initializeFromState(geometry.size) + } + .onChange(of: widget.item?.state ?? "") { _, newState in + guard !newState.isEmpty else { return } + updateFromState(newState, geometry.size) + } } } @@ -75,38 +102,52 @@ struct ColorSelection: View { } /// Prevent the draggable element from going beyond the circle - func limitCircle(_ point: CGPoint, _ geometry: CGSize, _ state: CGSize) -> (CGPoint) { - let x1 = point.x + state.width - geometry.width / 2 - let y1 = point.y + state.height - geometry.height / 2 + func limitCircle(_ point: CGPoint, _ geometry: CGSize) -> CGPoint { + let center = CGPoint(x: geometry.width / 2, y: geometry.height / 2) + let x1 = Double(point.x - center.x) + let y1 = Double(point.y - center.y) let theta = atan2(x1, y1) // Circle limit.width = limit.height - let radius = min(sqrt(x1 * x1 + y1 * y1), geometry.width / 2) - return CGPoint(x: sin(theta) * radius + geometry.width / 2, y: cos(theta) * radius + geometry.width / 2) + let maxRadius = max(0.0, Double(min(geometry.width, geometry.height) / 2)) + let radius = min(sqrt(x1 * x1 + y1 * y1), maxRadius) + return CGPoint( + x: CGFloat(sin(theta) * radius) + center.x, + y: CGFloat(cos(theta) * radius) + center.y + ) } /// Creates the `Handle` and adds the drag gesture to it. func generateHandle(geometry: GeometryProxy) -> some View { - /// [Reference]: https://developer.apple.com/documentation/swiftui/gestures/composing_swiftui_gestures "Composing SwiftUI Gestures " - let longPressDrag = LongPressGesture(minimumDuration: 0.05) - .sequenced(before: DragGesture()) + let drag = DragGesture(minimumDistance: 0) .updating($thumb) { value, state, _ in - switch value { - // Long press begins. - case .first(true): - state = .pressing - // Long press confirmed, dragging may begin. - case .second(true, let drag): - state = .dragging(translation: drag?.translation ?? .zero) - // Dragging ended or the long press cancelled. - default: - state = .inactive + state = .dragging(translation: value.translation) + } + .onChanged { value in + let start = dragStart ?? CGPoint(x: xpos, y: ypos) + if dragStart == nil { + dragStart = start } + let proposed = CGPoint( + x: start.x + value.translation.width, + y: start.y + value.translation.height + ) + let limited = limitCircle(proposed, geometry.size) + xpos = Double(limited.x) + ypos = Double(limited.y) + updateColorFromPosition(in: geometry.size, send: true) } .onEnded { value in - guard case .second(true, let drag?) = value else { return } - Logger.rowViews.info("Translation x y = \(drag.translation.width), \(drag.translation.height)") - xpos += Double(drag.translation.width) - ypos += Double(drag.translation.height) + Logger.rowViews.info("Translation x y = \(value.translation.width), \(value.translation.height)") + let start = dragStart ?? CGPoint(x: xpos, y: ypos) + let proposed = CGPoint( + x: start.x + value.translation.width, + y: start.y + value.translation.height + ) + let limited = limitCircle(proposed, geometry.size) + xpos = Double(limited.x) + ypos = Double(limited.y) + dragStart = nil + updateColorFromPosition(in: geometry.size, send: true, force: true) } // MARK: Customize Handle Here @@ -116,12 +157,77 @@ struct ColorSelection: View { .overlay(thumb.isDragging ? Circle().stroke(Color.white, lineWidth: 2) : nil) .foregroundStyle(.white) .frame(width: 25, height: 25, alignment: .center) - .position(limitCircle(CGPoint(x: xpos, y: ypos), geometry.size, thumb.translation)) + .position(limitCircle(CGPoint(x: xpos, y: ypos), geometry.size)) .animation(.interactiveSpring(), value: thumb.isDragging) - .gesture(longPressDrag) + .gesture(drag) + } + + private func initializeFromState(_ size: CGSize) { + if let state = widget.item?.state, !state.isEmpty { + updateFromState(state, size) + } else { + updateHandlePosition(in: size) + } + } + + private func updateFromState(_ state: String, _ size: CGSize) { + let components = state.split(separator: ",") + guard components.count >= 3, + let hueValue = Double(components[0]), + let saturationValue = Double(components[1]), + let brightnessValue = Double(components[2]) else { + return + } + hue = hueValue / 360.0 + saturation = saturationValue / 100.0 + brightness = brightnessValue / 100.0 + updateHandlePosition(in: size) + } + + private func updateHandlePosition(in size: CGSize) { + let radius = min(size.width, size.height) / 2 + let usableRadius = max(0.0, Double(radius)) + // Reverse the 180° offset when placing the handle. + let angle = (hue - 0.5) * 2.0 * Double.pi + let dist = max(0.0, min(1.0, saturation)) * usableRadius + let center = CGPoint(x: size.width / 2, y: size.height / 2) + // Match limitCircle's angle convention (x = sin, y = cos). + xpos = Double(center.x) + sin(angle) * dist + ypos = Double(center.y) + cos(angle) * dist + } + + private func updateColorFromPosition(in size: CGSize, send: Bool, force: Bool = false) { + let center = CGPoint(x: size.width / 2, y: size.height / 2) + let dx = Double(xpos) - Double(center.x) + let dy = Double(ypos) - Double(center.y) + // Match limitCircle's angle convention (atan2(x, y)). + let angle = atan2(dx, dy) + let normalizedAngle = angle >= 0 ? angle : (2.0 * Double.pi + angle) + hue = normalizedAngle / (2.0 * Double.pi) + // Align with wheel orientation (current visual mapping is 180° offset). + hue = (hue + 0.5).truncatingRemainder(dividingBy: 1.0) + let radius = sqrt(dx * dx + dy * dy) + let maxRadius = max(1.0, Double(min(size.width, size.height) / 2)) + saturation = max(0.0, min(1.0, radius / maxRadius)) + if send { + let now = Date() + if force || now.timeIntervalSince(lastSendTime) > 0.2 { + lastSendTime = now + sendColorCommand() + } + } + } + + private func sendColorCommand() { + let hueValue = Int(hue * 360.0) + let saturationValue = Int(saturation * 100.0) + let brightnessValue = Int(brightness * 100.0) + let command = "\(hueValue),\(saturationValue),\(brightnessValue)" + Logger.rowViews.info("Sending color command: \(command)") + widget.sendCommand(command) } } #Preview { - ColorSelection() + ColorSelection(widget: UserData(preview: true).widgets[10]) } From f6f5bc5636b55cb943862bf844fe7f0aea456424 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Sun, 1 Feb 2026 18:00:07 +0100 Subject: [PATCH 376/476] Fix for duplicate rows Signed-off-by: Tim Mueller-Seydlitz --- openHAB/SitemapPageViewModel.swift | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/openHAB/SitemapPageViewModel.swift b/openHAB/SitemapPageViewModel.swift index 6429eb4da..6158b66d1 100644 --- a/openHAB/SitemapPageViewModel.swift +++ b/openHAB/SitemapPageViewModel.swift @@ -53,12 +53,9 @@ class SitemapPageViewModel: ObservableObject { private var pageNetworkStatusAvailable = false var relevantWidgets: [OpenHABWidget] { - var flattenedWidgets = [OpenHABWidget]() - flattenedWidgets.flatten(currentPage?.widgets ?? []) - - guard !searchText.isEmpty else { return flattenedWidgets } - - return flattenedWidgets.filter { + let widgets = currentPage?.widgets ?? [] + guard !searchText.isEmpty else { return widgets } + return widgets.filter { $0.label.lowercased().contains(searchText.lowercased()) && $0.type != .frame } } From aeeda62ef00b2a75e72382e6cf232b62e7f6e238 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Sun, 1 Feb 2026 18:59:22 +0100 Subject: [PATCH 377/476] Partially reverting b8fc979 Improvement on layout of SegmentedRowView to obtain wider segmented buttons Signed-off-by: Tim Mueller-Seydlitz --- openHAB/SwiftUI/Rows/SegmentedRowView.swift | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/openHAB/SwiftUI/Rows/SegmentedRowView.swift b/openHAB/SwiftUI/Rows/SegmentedRowView.swift index 374078caf..f73cbb5b3 100644 --- a/openHAB/SwiftUI/Rows/SegmentedRowView.swift +++ b/openHAB/SwiftUI/Rows/SegmentedRowView.swift @@ -48,8 +48,6 @@ struct SegmentedRowView: View { .foregroundStyle(widget.valuecolor.isEmpty ? Color(uiColor: UIColor.ohSecondaryLabel) : Color(fromString: widget.valuecolor)) .lineLimit(1) .truncationMode(.tail) -// .minimumScaleFactor(0.25) // shrinks text instead of vanishing -// .allowsTightening(true) .layoutPriority(0) // Truncates first } @@ -59,7 +57,6 @@ struct SegmentedRowView: View { pressReleaseButtons .padding(.leading, 8) .fixedSize(horizontal: true, vertical: false) - .layoutPriority(1) } else if mappings.count == 1 { singleMappingButton .padding(.leading, 8) @@ -69,9 +66,8 @@ struct SegmentedRowView: View { segmentedButtons .padding(.leading, 8) .frame(minWidth: 75) - .fixedSize(horizontal: true, vertical: false) .layoutPriority(1) - } + } } } .onAppear { From 307555d52ff63b8d55ea950661f86a486f7fcdcd Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Sun, 1 Feb 2026 19:21:01 +0100 Subject: [PATCH 378/476] Align labelText of SegmentedRowView with other RowViews Signed-off-by: Tim Mueller-Seydlitz --- openHAB/SwiftUI/Rows/SegmentedRowView.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openHAB/SwiftUI/Rows/SegmentedRowView.swift b/openHAB/SwiftUI/Rows/SegmentedRowView.swift index f73cbb5b3..715dc0345 100644 --- a/openHAB/SwiftUI/Rows/SegmentedRowView.swift +++ b/openHAB/SwiftUI/Rows/SegmentedRowView.swift @@ -38,6 +38,7 @@ struct SegmentedRowView: View { .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) .lineLimit(1) .truncationMode(.tail) + .padding(.leading, 8) .layoutPriority(1) // Truncates second } @@ -67,7 +68,7 @@ struct SegmentedRowView: View { .padding(.leading, 8) .frame(minWidth: 75) .layoutPriority(1) - } + } } } .onAppear { From 5e916d777d8ab1b950cd05c0c3c97a14c66ce896 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Sun, 1 Feb 2026 20:17:26 +0100 Subject: [PATCH 379/476] Reworked widget update logic Update logic skips refreshing the widget's type, leading to stale types showing as switches instead of charts; adding type assignment during updates should fix the display inconsistency. Signed-off-by: Tim Mueller-Seydlitz --- openHABWatch/Domain/UserData.swift | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/openHABWatch/Domain/UserData.swift b/openHABWatch/Domain/UserData.swift index 572b01190..d130f0314 100644 --- a/openHABWatch/Domain/UserData.swift +++ b/openHABWatch/Domain/UserData.swift @@ -397,6 +397,7 @@ final class UserData: ObservableObject { if let existingWidget = existingWidgetsMap[newWidget.widgetId] { // Update existing widget's properties to preserve the instance existingWidget.label = newWidget.label + existingWidget.type = newWidget.type existingWidget.icon = newWidget.icon existingWidget.state = newWidget.state existingWidget.item = newWidget.item @@ -404,6 +405,15 @@ final class UserData: ObservableObject { existingWidget.iconColor = newWidget.iconColor existingWidget.labelcolor = newWidget.labelcolor existingWidget.valuecolor = newWidget.valuecolor + existingWidget.url = newWidget.url + existingWidget.period = newWidget.period + existingWidget.service = newWidget.service + existingWidget.legend = newWidget.legend + existingWidget.refresh = newWidget.refresh + existingWidget.height = newWidget.height + existingWidget.forceAsItem = newWidget.forceAsItem + existingWidget.mappings = newWidget.mappings + existingWidget.widgets = newWidget.widgets // Add other properties as needed updatedWidgets.append(existingWidget) existingWidgetsMap.removeValue(forKey: newWidget.widgetId) From b937ba1eb1e9c7798d035e59daa766b001c27d95 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Sun, 1 Feb 2026 21:04:43 +0100 Subject: [PATCH 380/476] Flip switchSupport logic adjust the slider binding to reflect the live widget value when not dragging, and sync the internal state when editing starts. Signed-off-by: Tim Mueller-Seydlitz --- openHAB/SwiftUI/Rows/SliderRowView.swift | 7 ++++++- openHABWatch/Views/SitemapPageView.swift | 4 ++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/openHAB/SwiftUI/Rows/SliderRowView.swift b/openHAB/SwiftUI/Rows/SliderRowView.swift index 875aa0e42..8405226ef 100644 --- a/openHAB/SwiftUI/Rows/SliderRowView.swift +++ b/openHAB/SwiftUI/Rows/SliderRowView.swift @@ -48,7 +48,9 @@ struct SliderRowView: View { Slider( value: Binding( - get: { sliderValue }, + get: { + isDragging ? sliderValue : (widget.stateValueAsNumberState?.value ?? widget.minValue) + }, set: { newValue in sliderValue = newValue if widget.shouldUseSliderUpdatesDuringMove() { @@ -63,6 +65,9 @@ struct SliderRowView: View { in: sliderRange ) { editing in isDragging = editing + if editing { + sliderValue = widget.stateValueAsNumberState?.value ?? widget.minValue + } if !editing, !widget.shouldUseSliderUpdatesDuringMove() { sendSliderUpdate(sliderValue) } diff --git a/openHABWatch/Views/SitemapPageView.swift b/openHABWatch/Views/SitemapPageView.swift index 9516786df..f244b6a3f 100644 --- a/openHABWatch/Views/SitemapPageView.swift +++ b/openHABWatch/Views/SitemapPageView.swift @@ -35,9 +35,9 @@ struct WidgetRowView: View { SwitchRow(widget: widget) case .slider: if widget.switchSupport { - SliderRow(widget: widget) - } else { SliderWithSwitchSupportRow(widget: widget) + } else { + SliderRow(widget: widget) } case .segmented: SegmentRow(widget: widget) From b6bdb092444aac696592e0f02e10fb44fce4b1a1 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Sun, 1 Feb 2026 22:43:21 +0100 Subject: [PATCH 381/476] SelectionRowView adjustment keep the row visible by separating the visible content from the Menu label, and overlaying a clear hit target Signed-off-by: Tim Mueller-Seydlitz --- openHAB/SwiftUI/Rows/SelectionRowView.swift | 81 ++++++++++++--------- 1 file changed, 47 insertions(+), 34 deletions(-) diff --git a/openHAB/SwiftUI/Rows/SelectionRowView.swift b/openHAB/SwiftUI/Rows/SelectionRowView.swift index 0fc2cd3ac..c74e5c50b 100644 --- a/openHAB/SwiftUI/Rows/SelectionRowView.swift +++ b/openHAB/SwiftUI/Rows/SelectionRowView.swift @@ -35,48 +35,61 @@ struct SelectionRowView: View { } var body: some View { - Menu { - ForEach(mappings.indices, id: \.self) { index in - let mapping = mappings[index] - let isSelected = widget.item?.state == mapping.command - Button { - logger.info("Selection changed to: \(mapping.label)") - viewModel.sendCommand(widget.item, commandToSend: mapping.command) - } label: { - if isSelected { - Label(mapping.label, systemSymbol: .checkmark) - } else { - Text(mapping.label) + ZStack { + rowContent + .frame(maxWidth: .infinity, alignment: .leading) + .animation(nil, value: widget.item?.state) + + Menu { + ForEach(mappings.indices, id: \.self) { index in + let mapping = mappings[index] + let isSelected = widget.item?.state == mapping.command + Button { + logger.info("Selection changed to: \(mapping.label)") + viewModel.sendCommand(widget.item, commandToSend: mapping.command) + } label: { + if isSelected { + Label(mapping.label, systemSymbol: .checkmark) + } else { + Text(mapping.label) + } } } + } label: { + Color.clear + .frame(maxWidth: .infinity, maxHeight: .infinity) + .contentShape(Rectangle()) } - } label: { - HStack { - IconView(widget: widget) - .frame(width: 32, height: 32) + .buttonStyle(.plain) + .disabled(widget.readOnly ?? false) + } + } - if let labelText = widget.labelText, !labelText.isEmpty { - Text(labelText) - .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) - .lineLimit(1) - } + @ViewBuilder + private var rowContent: some View { + HStack { + IconView(widget: widget) + .frame(width: 32, height: 32) - Spacer() + if let labelText = widget.labelText, !labelText.isEmpty { + Text(labelText) + .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) + .lineLimit(1) + } - if let valueText = selectedValueText, !valueText.isEmpty { - Text(valueText) - .foregroundStyle(widget.valuecolor.isEmpty ? .secondary : Color(fromString: widget.valuecolor)) - .lineLimit(1) - } + Spacer() - // Show disclosure indicator to indicate tappable selection - Image(systemSymbol: .chevronUpChevronDown) - .font(.caption) - .foregroundStyle(.secondary) + if let valueText = selectedValueText, !valueText.isEmpty { + Text(valueText) + .foregroundStyle(widget.valuecolor.isEmpty ? .secondary : Color(fromString: widget.valuecolor)) + .lineLimit(1) } - .contentShape(Rectangle()) + + // Show disclosure indicator to indicate tappable selection + Image(systemSymbol: .chevronUpChevronDown) + .font(.caption) + .foregroundStyle(.secondary) } - .buttonStyle(.plain) - .disabled(widget.readOnly ?? false) + .contentShape(Rectangle()) } } From 115707915d0dae80e4e1782b5f752d61d18fad16 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Mon, 2 Feb 2026 08:34:21 +0100 Subject: [PATCH 382/476] Add TextRow to watchOS and remove legacy WidgetTypeEnum Refactor watchOS row views to use widget.type directly (matching iOS pattern) instead of the legacy stateEnum. Add TextRow for text widget support. - Add TextRow.swift for watchOS text widget display - Refactor SwitchRow to use local @State for toggle state - Refactor SegmentRow/SegmentSelectionView to use local @State for selection - Update SitemapPageView.rowWidget to switch on widget.type - Remove WidgetTypeEnum, stateEnum, and stateEnumBinding from OpenHABWidget Signed-off-by: Tim Mueller-Seydlitz --- .../OpenHABCore/Model/OpenHABWidget.swift | 66 ------------------- openHAB.xcodeproj/project.pbxproj | 4 ++ openHABWatch/Domain/UserData.swift | 1 - openHABWatch/Views/Rows/SegmentRow.swift | 41 ++++-------- .../Views/Rows/SegmentSelectionView.swift | 13 ++-- openHABWatch/Views/Rows/SwitchRow.swift | 35 ++++++---- openHABWatch/Views/Rows/TextRow.swift | 34 ++++++++++ openHABWatch/Views/SitemapPageView.swift | 28 +++++--- 8 files changed, 101 insertions(+), 121 deletions(-) create mode 100644 openHABWatch/Views/Rows/TextRow.swift diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift index 4693a62d2..8e02d107f 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift @@ -14,28 +14,6 @@ import Foundation @_exported import MapKit import os.log -public enum WidgetTypeEnum { - case switcher(Bool) - case slider // - case segmented(Int) - case unassigned - case rollershutter - case frame - case setpoint - case selection - case colorpicker - case chart - case image - case video - case webview - case mapview - - public var boolState: Bool { - guard case let .switcher(value) = self else { return false } - return value - } -} - public class OpenHABWidget: NSObject, MKAnnotation, Identifiable, ObservableObject { public enum WidgetType: String, Decodable { case chart = "Chart" @@ -135,8 +113,6 @@ public class OpenHABWidget: NSObject, MKAnnotation, Identifiable, ObservableObje public var yAxisDecimalPattern: String? - @Published public var stateEnumBinding: WidgetTypeEnum = .unassigned - // Text prior to "[" public var labelText: String? { let array = label.components(separatedBy: "[") @@ -197,46 +173,6 @@ public class OpenHABWidget: NSObject, MKAnnotation, Identifiable, ObservableObje } } - public var stateEnum: WidgetTypeEnum { - switch type { - case .frame: - .frame - case .switchWidget: - // Reflecting the discussion held in https://github.com/openhab/openhab-core/issues/952 - if !mappings.isEmpty { - .segmented(Int(mappingIndex(byCommand: item?.state) ?? -1)) - } else if item?.isOfTypeOrGroupType(.switchItem) ?? false { - .switcher(item?.state == "ON" ? true : false) - } else if item?.isOfTypeOrGroupType(.rollershutter) ?? false { - .rollershutter - } else if !mappingsOrItemOptions.isEmpty { - .segmented(Int(mappingIndex(byCommand: item?.state) ?? -1)) - } else { - .switcher(item?.state == "ON" ? true : false) - } - case .setpoint: - .setpoint - case .slider: - .slider - case .selection: - .selection - case .colorpicker: - .colorpicker - case .chart: - .chart - case .image: - .image - case .video: - .video - case .webview: - .webview - case .mapview: - .mapview - default: - .unassigned - } - } - public func sendItemUpdate(state: NumberState?) { guard let item, let state else { Logger.restAPI.info("ItemUpdate for Item or State = nil") @@ -440,8 +376,6 @@ public extension OpenHABWidget { self.pattern = pattern ?? "" self.staticIcon = staticIcon ?? false self.labelSource = labelSource - stateEnumBinding = stateEnum - self.labelSource = labelSource self.releaseOnly = releaseOnly self.row = row self.column = column diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index bb5ddd6b6..6a8edd510 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -195,6 +195,7 @@ DAF457A623DB9CE00018B495 /* SetpointRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF457A523DB9CE00018B495 /* SetpointRow.swift */; }; DAF457A923DBA4990018B495 /* FrameRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF457A823DBA4990018B495 /* FrameRow.swift */; }; DAF4581623DC48400018B495 /* GenericRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF4581523DC483F0018B495 /* GenericRow.swift */; }; + 10472F7AF99C4940A6144817 /* TextRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 399449C421544C61AD83450C /* TextRow.swift */; }; DAF4581823DC4A050018B495 /* ImageRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF4581723DC4A050018B495 /* ImageRow.swift */; }; DAF4581E23DC60020018B495 /* ImageRawRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF4581D23DC60020018B495 /* ImageRawRow.swift */; }; DAF4F6C0222734D300C24876 /* NewImageUITableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF4F6BF222734D200C24876 /* NewImageUITableViewCell.swift */; }; @@ -553,6 +554,7 @@ DAF457A523DB9CE00018B495 /* SetpointRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetpointRow.swift; sourceTree = ""; }; DAF457A823DBA4990018B495 /* FrameRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrameRow.swift; sourceTree = ""; }; DAF4581523DC483F0018B495 /* GenericRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GenericRow.swift; sourceTree = ""; }; + 399449C421544C61AD83450C /* TextRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextRow.swift; sourceTree = ""; }; DAF4581723DC4A050018B495 /* ImageRow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageRow.swift; sourceTree = ""; }; DAF4581D23DC60020018B495 /* ImageRawRow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageRawRow.swift; sourceTree = ""; }; DAF4F6BF222734D200C24876 /* NewImageUITableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewImageUITableViewCell.swift; sourceTree = ""; }; @@ -1033,6 +1035,7 @@ DAF457A523DB9CE00018B495 /* SetpointRow.swift */, DAF457A823DBA4990018B495 /* FrameRow.swift */, DAF4581523DC483F0018B495 /* GenericRow.swift */, + 399449C421544C61AD83450C /* TextRow.swift */, DAF4581723DC4A050018B495 /* ImageRow.swift */, DAF4581D23DC60020018B495 /* ImageRawRow.swift */, DA2E0B0F23DCC439009B0A99 /* MapViewRow.swift */, @@ -1686,6 +1689,7 @@ DA72E1B8236DEA0900B8EF3A /* AppMessageService.swift in Sources */, DA07752B2346705F0086C685 /* OpenHABWatchAppDelegate.swift in Sources */, DAF4581623DC48400018B495 /* GenericRow.swift in Sources */, + 10472F7AF99C4940A6144817 /* TextRow.swift in Sources */, DAF457A023DA3E1C0018B495 /* SegmentRow.swift in Sources */, DAF4578923D79AA50018B495 /* DetailTextLabelView.swift in Sources */, DAC9AF4924F966FA006DAE93 /* LazyView.swift in Sources */, diff --git a/openHABWatch/Domain/UserData.swift b/openHABWatch/Domain/UserData.swift index d130f0314..189eda70f 100644 --- a/openHABWatch/Domain/UserData.swift +++ b/openHABWatch/Domain/UserData.swift @@ -401,7 +401,6 @@ final class UserData: ObservableObject { existingWidget.icon = newWidget.icon existingWidget.state = newWidget.state existingWidget.item = newWidget.item - existingWidget.stateEnumBinding = newWidget.stateEnumBinding existingWidget.iconColor = newWidget.iconColor existingWidget.labelcolor = newWidget.labelcolor existingWidget.valuecolor = newWidget.valuecolor diff --git a/openHABWatch/Views/Rows/SegmentRow.swift b/openHABWatch/Views/Rows/SegmentRow.swift index ee48ec22f..3319789be 100644 --- a/openHABWatch/Views/Rows/SegmentRow.swift +++ b/openHABWatch/Views/Rows/SegmentRow.swift @@ -16,30 +16,10 @@ import SwiftUI struct SegmentRow: View { @ObservedObject var widget: OpenHABWidget @EnvironmentObject var settings: AppSettings - @State private var pendingValue: String? + @State private var selectedIndex: Int? - var valueBinding: Binding { - .init( - get: { - guard case let .segmented(value) = widget.stateEnumBinding else { return 0 } - return value - }, - set: { newValue in - Logger.rowViews.debug("Picker new value = \(newValue)") - widget.stateEnumBinding = .segmented(newValue) - if let selectedCommand = widget.mappingsOrItemOptions[safe: newValue]?.command { - pendingValue = selectedCommand - Logger.rowViews.debug("Selected command: \(selectedCommand)") - Task { @MainActor in - try? await Task.sleep(for: .milliseconds(500)) - if pendingValue == selectedCommand { // Ensure no new updates came in - widget.sendCommand(selectedCommand) - pendingValue = nil - } - } - } - } - ) + private var currentIndex: Int { + selectedIndex ?? widget.mappingIndex(byCommand: widget.item?.state).map { Int($0) } ?? 0 } var body: some View { @@ -49,13 +29,10 @@ struct SegmentRow: View { TextLabelView(widget: widget, lineLimit: 1) Spacer() } - NavigationLink(destination: LazyView(SegmentSelectionView(widget: widget))) { + NavigationLink(destination: LazyView(SegmentSelectionView(widget: widget, selectedIndex: $selectedIndex))) { HStack { - if let selectedIndex = widget.mappingsOrItemOptions.indices.first(where: { index in - guard case let .segmented(value) = widget.stateEnumBinding else { return false } - return value == index - }) { - Text(widget.mappingsOrItemOptions[selectedIndex].label) + if currentIndex >= 0, currentIndex < widget.mappingsOrItemOptions.count { + Text(widget.mappingsOrItemOptions[currentIndex].label) .foregroundStyle(.secondary) .lineLimit(1) } @@ -69,6 +46,12 @@ struct SegmentRow: View { } .buttonStyle(.plain) } + .onAppear { + selectedIndex = widget.mappingIndex(byCommand: widget.item?.state).map { Int($0) } + } + .onChange(of: widget.item?.state) { newState in + selectedIndex = widget.mappingIndex(byCommand: newState).map { Int($0) } + } } } diff --git a/openHABWatch/Views/Rows/SegmentSelectionView.swift b/openHABWatch/Views/Rows/SegmentSelectionView.swift index 6e7d18f35..06fa3d9ab 100644 --- a/openHABWatch/Views/Rows/SegmentSelectionView.swift +++ b/openHABWatch/Views/Rows/SegmentSelectionView.swift @@ -15,10 +15,15 @@ import SwiftUI struct SegmentSelectionView: View { @ObservedObject var widget: OpenHABWidget + @Binding var selectedIndex: Int? @Environment(\.dismiss) private var dismiss @State private var pendingValue: String? @State private var pressedIndex: Int? + private var currentIndex: Int { + selectedIndex ?? widget.mappingIndex(byCommand: widget.item?.state).map { Int($0) } ?? 0 + } + var body: some View { ScrollView { LazyVStack(spacing: 12) { @@ -109,12 +114,11 @@ struct SegmentSelectionView: View { } private func isSelected(index: Int) -> Bool { - guard case let .segmented(value) = widget.stateEnumBinding else { return false } - return value == index + currentIndex == index } private func selectOption(at index: Int) { - widget.stateEnumBinding = .segmented(index) + selectedIndex = index if let selectedCommand = widget.mappingsOrItemOptions[safe: index]?.command { pendingValue = selectedCommand Task { @MainActor in @@ -130,9 +134,10 @@ struct SegmentSelectionView: View { } #Preview { + @Previewable @State var selectedIndex: Int? = 0 let widget = UserData(preview: true).widgets[4] return NavigationStack { - SegmentSelectionView(widget: widget) + SegmentSelectionView(widget: widget, selectedIndex: $selectedIndex) } .environmentObject(AppSettings()) } diff --git a/openHABWatch/Views/Rows/SwitchRow.swift b/openHABWatch/Views/Rows/SwitchRow.swift index ae5931225..63731f5fc 100644 --- a/openHABWatch/Views/Rows/SwitchRow.swift +++ b/openHABWatch/Views/Rows/SwitchRow.swift @@ -17,26 +17,34 @@ import SwiftUI struct SwitchRow: View { @ObservedObject var widget: OpenHABWidget @EnvironmentObject var settings: AppSettings + @State private var localIsOn: Bool? - // https://stackoverflow.com/questions/59395501/do-something-when-toggle-state-changes - var stateBinding: Binding { - .init( - get: { widget.stateEnumBinding.boolState }, - set: { - if $0 { + private var effectiveState: String { + var state = widget.state + if state.isEmpty { + state = widget.item?.state ?? "" + } + return state + } + + private var isOn: Bool { + localIsOn ?? effectiveState.parseAsBool() + } + + var body: some View { + Toggle(isOn: Binding( + get: { isOn }, + set: { newValue in + localIsOn = newValue + if newValue { Logger.rowViews.info("Switch to ON") widget.sendCommand("ON") } else { Logger.rowViews.info("Switch to OFF") widget.sendCommand("OFF") } - widget.stateEnumBinding = .switcher($0) } - ) - } - - var body: some View { - Toggle(isOn: stateBinding) { + )) { HStack { IconView(widget: widget, settings: settings) VStack { @@ -47,6 +55,9 @@ struct SwitchRow: View { } .padding(.trailing) .cornerRadius(5) + .onChange(of: effectiveState) { _ in + localIsOn = nil + } } } diff --git a/openHABWatch/Views/Rows/TextRow.swift b/openHABWatch/Views/Rows/TextRow.swift new file mode 100644 index 000000000..9243ac967 --- /dev/null +++ b/openHABWatch/Views/Rows/TextRow.swift @@ -0,0 +1,34 @@ +// Copyright (c) 2010-2026 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 CommonUI +import OpenHABCore +import SwiftUI + +struct TextRow: View { + @ObservedObject var widget: OpenHABWidget + @EnvironmentObject var settings: AppSettings + + var body: some View { + HStack { + IconView(widget: widget, settings: settings) + TextLabelView(widget: widget) + Spacer() + DetailTextLabelView(widget: widget) + } + } +} + +#Preview { + let widget = UserData(preview: true).widgets[3] + TextRow(widget: widget) + .environmentObject(AppSettings()) +} diff --git a/openHABWatch/Views/SitemapPageView.swift b/openHABWatch/Views/SitemapPageView.swift index f244b6a3f..3a8f967a5 100644 --- a/openHABWatch/Views/SitemapPageView.swift +++ b/openHABWatch/Views/SitemapPageView.swift @@ -30,25 +30,32 @@ struct WidgetRowView: View { } @ViewBuilder func rowWidget(widget: OpenHABWidget) -> some View { - switch widget.stateEnum { - case .switcher: - SwitchRow(widget: widget) + switch widget.type { + case .switchWidget: + if !widget.mappings.isEmpty { + SegmentRow(widget: widget) + } else if widget.item?.isOfTypeOrGroupType(.switchItem) ?? false { + SwitchRow(widget: widget) + } else if widget.item?.isOfTypeOrGroupType(.rollershutter) ?? false { + RollershutterRow(widget: widget) + } else if !widget.mappingsOrItemOptions.isEmpty { + SegmentRow(widget: widget) + } else { + SwitchRow(widget: widget) + } case .slider: if widget.switchSupport { SliderWithSwitchSupportRow(widget: widget) } else { SliderRow(widget: widget) } - case .segmented: - SegmentRow(widget: widget) - case .rollershutter: - RollershutterRow(widget: widget) case .setpoint: SetpointRow(widget: widget) case .frame: FrameRow(widget: widget) + case .text: + TextRow(widget: widget) case .image: - // Encoded image if widget.item != nil { ImageRawRow(widget: widget) } else { @@ -70,7 +77,10 @@ struct WidgetRowView: View { MapViewRow(widget: widget) case .colorpicker: ColorPickerRow(widget: widget) - default: + case .selection, .video, .webview, .input, .colortemperaturepicker, .buttongrid: + // Not yet implemented for watchOS + GenericRow(widget: widget) + case .group, .defaultWidget, .button, .unknown: GenericRow(widget: widget) } } From 2847818b964f14e29cd2540458c59bd70d68a703 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Mon, 2 Feb 2026 12:31:45 +0100 Subject: [PATCH 383/476] Add SelectionRow to watchOS and fix TextRow disclosure indicator - Add SelectionRow.swift with SelectionListView for selection widget support - Add disclosure indicator (chevron) to TextRow when widget has linkedPage - Use chevronUpChevronDown icon for SelectionRow matching iOS style Signed-off-by: Tim Mueller-Seydlitz --- openHAB.xcodeproj/project.pbxproj | 4 + openHABWatch/Views/Rows/SelectionRow.swift | 143 +++++++++++++++++++++ openHABWatch/Views/Rows/TextRow.swift | 6 + openHABWatch/Views/SitemapPageView.swift | 4 +- 4 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 openHABWatch/Views/Rows/SelectionRow.swift diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index 6a8edd510..2fc5d5392 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -191,6 +191,7 @@ DAF4578723D798A50018B495 /* TextLabelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF4578623D798A50018B495 /* TextLabelView.swift */; }; DAF4578923D79AA50018B495 /* DetailTextLabelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF4578823D79AA50018B495 /* DetailTextLabelView.swift */; }; DAF457A023DA3E1C0018B495 /* SegmentRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF4579F23DA3E1C0018B495 /* SegmentRow.swift */; }; + C4377202F7D642B5A8349008 /* SelectionRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = D86D8BF295C448039B2B85EB /* SelectionRow.swift */; }; DAF457A223DB6C640018B495 /* RollershutterRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF457A123DB6C640018B495 /* RollershutterRow.swift */; }; DAF457A623DB9CE00018B495 /* SetpointRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF457A523DB9CE00018B495 /* SetpointRow.swift */; }; DAF457A923DBA4990018B495 /* FrameRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF457A823DBA4990018B495 /* FrameRow.swift */; }; @@ -550,6 +551,7 @@ DAF4578623D798A50018B495 /* TextLabelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextLabelView.swift; sourceTree = ""; }; DAF4578823D79AA50018B495 /* DetailTextLabelView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DetailTextLabelView.swift; sourceTree = ""; }; DAF4579F23DA3E1C0018B495 /* SegmentRow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SegmentRow.swift; sourceTree = ""; }; + D86D8BF295C448039B2B85EB /* SelectionRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectionRow.swift; sourceTree = ""; }; DAF457A123DB6C640018B495 /* RollershutterRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RollershutterRow.swift; sourceTree = ""; }; DAF457A523DB9CE00018B495 /* SetpointRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetpointRow.swift; sourceTree = ""; }; DAF457A823DBA4990018B495 /* FrameRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrameRow.swift; sourceTree = ""; }; @@ -1031,6 +1033,7 @@ DA50C7BC2B0A51BD0009F716 /* SliderWithSwitchSupportRow.swift */, DAF457A123DB6C640018B495 /* RollershutterRow.swift */, DAF4579F23DA3E1C0018B495 /* SegmentRow.swift */, + D86D8BF295C448039B2B85EB /* SelectionRow.swift */, DA2741012EA62FA3002FE576 /* SegmentSelectionView.swift */, DAF457A523DB9CE00018B495 /* SetpointRow.swift */, DAF457A823DBA4990018B495 /* FrameRow.swift */, @@ -1691,6 +1694,7 @@ DAF4581623DC48400018B495 /* GenericRow.swift in Sources */, 10472F7AF99C4940A6144817 /* TextRow.swift in Sources */, DAF457A023DA3E1C0018B495 /* SegmentRow.swift in Sources */, + C4377202F7D642B5A8349008 /* SelectionRow.swift in Sources */, DAF4578923D79AA50018B495 /* DetailTextLabelView.swift in Sources */, DAC9AF4924F966FA006DAE93 /* LazyView.swift in Sources */, DA0776F0234788010086C685 /* UserData.swift in Sources */, diff --git a/openHABWatch/Views/Rows/SelectionRow.swift b/openHABWatch/Views/Rows/SelectionRow.swift new file mode 100644 index 000000000..3823bbcdc --- /dev/null +++ b/openHABWatch/Views/Rows/SelectionRow.swift @@ -0,0 +1,143 @@ +// Copyright (c) 2010-2026 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 OpenHABCore +import os.log +import SFSafeSymbols +import SwiftUI + +struct SelectionRow: View { + @ObservedObject var widget: OpenHABWidget + @EnvironmentObject var settings: AppSettings + @State private var selectedIndex: Int? + + private var mappings: [OpenHABWidgetMapping] { + widget.mappingsOrItemOptions + } + + private var currentIndex: Int? { + selectedIndex ?? widget.mappingIndex(byCommand: widget.item?.state).map { Int($0) } + } + + /// Returns the label of the currently selected mapping + private var selectedValueText: String? { + if let index = currentIndex, index >= 0, index < mappings.count { + return mappings[index].label + } + return widget.labelValue + } + + var body: some View { + HStack { + HStack { + IconView(widget: widget, settings: settings) + TextLabelView(widget: widget, lineLimit: 1) + Spacer() + } + NavigationLink(destination: LazyView(SelectionListView(widget: widget, selectedIndex: $selectedIndex))) { + HStack(spacing: 4) { + if let valueText = selectedValueText { + Text(valueText) + .foregroundStyle(.secondary) + .lineLimit(1) + } + Image(systemSymbol: .chevronUpChevronDown) + .foregroundStyle(.secondary) + .font(.caption2) + } + } + .buttonStyle(.plain) + } + .onAppear { + selectedIndex = widget.mappingIndex(byCommand: widget.item?.state).map { Int($0) } + } + .onChange(of: widget.item?.state) { newState in + selectedIndex = widget.mappingIndex(byCommand: newState).map { Int($0) } + } + } +} + +/// Selection list view for picking from available options +struct SelectionListView: View { + @ObservedObject var widget: OpenHABWidget + @Binding var selectedIndex: Int? + @Environment(\.dismiss) private var dismiss + + private var mappings: [OpenHABWidgetMapping] { + widget.mappingsOrItemOptions + } + + private var currentIndex: Int? { + selectedIndex ?? widget.mappingIndex(byCommand: widget.item?.state).map { Int($0) } + } + + var body: some View { + ScrollView { + LazyVStack(spacing: 12) { + ForEach(0 ..< mappings.count, id: \.self) { index in + let mapping = mappings[index] + Button { + selectOption(at: index) + } label: { + HStack { + Text(mapping.label) + .foregroundStyle(.primary) + .multilineTextAlignment(.leading) + Spacer() + if currentIndex == index { + Image(systemSymbol: .checkmark) + .foregroundStyle(Color.accentColor) + .font(.caption.weight(.bold)) + } + } + .padding() + .background( + RoundedRectangle(cornerRadius: 8) + .fill(currentIndex == index ? Color.accentColor.opacity(0.2) : Color.clear) + ) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.secondary.opacity(0.3), lineWidth: 1) + ) + } + .buttonStyle(PlainButtonStyle()) + } + } + .padding() + } + .navigationTitle(widget.labelText ?? "Select") + .navigationBarTitleDisplayMode(.inline) + } + + private func selectOption(at index: Int) { + selectedIndex = index + if let selectedCommand = mappings[safe: index]?.command { + Logger.rowViews.info("Selection changed to: \(selectedCommand)") + widget.sendCommand(selectedCommand) + dismiss() + } + } +} + +#Preview { + let widget = UserData(preview: true).widgets[4] + SelectionRow(widget: widget) + .environmentObject(AppSettings()) +} + +#Preview("Selection List") { + @Previewable @State var selectedIndex: Int? = 0 + let widget = UserData(preview: true).widgets[4] + NavigationStack { + SelectionListView(widget: widget, selectedIndex: $selectedIndex) + } + .environmentObject(AppSettings()) +} diff --git a/openHABWatch/Views/Rows/TextRow.swift b/openHABWatch/Views/Rows/TextRow.swift index 9243ac967..029c007b9 100644 --- a/openHABWatch/Views/Rows/TextRow.swift +++ b/openHABWatch/Views/Rows/TextRow.swift @@ -11,6 +11,7 @@ import CommonUI import OpenHABCore +import SFSafeSymbols import SwiftUI struct TextRow: View { @@ -23,6 +24,11 @@ struct TextRow: View { TextLabelView(widget: widget) Spacer() DetailTextLabelView(widget: widget) + if widget.linkedPage != nil { + Image(systemSymbol: .chevronRight) + .foregroundStyle(.secondary) + .font(.caption) + } } } } diff --git a/openHABWatch/Views/SitemapPageView.swift b/openHABWatch/Views/SitemapPageView.swift index 3a8f967a5..f17fc337a 100644 --- a/openHABWatch/Views/SitemapPageView.swift +++ b/openHABWatch/Views/SitemapPageView.swift @@ -77,7 +77,9 @@ struct WidgetRowView: View { MapViewRow(widget: widget) case .colorpicker: ColorPickerRow(widget: widget) - case .selection, .video, .webview, .input, .colortemperaturepicker, .buttongrid: + case .selection: + SelectionRow(widget: widget) + case .video, .webview, .input, .colortemperaturepicker, .buttongrid: // Not yet implemented for watchOS GenericRow(widget: widget) case .group, .defaultWidget, .button, .unknown: From f361ad2a9f3fba5a8f67f9e711207ddda8457576 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Mon, 2 Feb 2026 16:37:53 +0100 Subject: [PATCH 384/476] Fix Setpoint buttons on watchOS and improve unit handling - Change IconWithAction to use Button instead of onTapGesture for reliable tap handling in List rows - Add disabled state and grayed out styling to watchOS SetpointRow buttons at min/max values - Use widget.unit as fallback when creating NumberState for dimension items - Include unit in log messages for better debugging Signed-off-by: Tim Mueller-Seydlitz --- openHAB/SetpointCell.swift | 3 +- openHAB/SwiftUI/Rows/SetpointRowView.swift | 5 +-- openHABWatch/Views/Rows/SetpointRow.swift | 31 +++++++++++++------ openHABWatch/Views/Utils/IconWithAction.swift | 14 ++++----- 4 files changed, 34 insertions(+), 19 deletions(-) diff --git a/openHAB/SetpointCell.swift b/openHAB/SetpointCell.swift index 49b69d822..6c34bf552 100644 --- a/openHAB/SetpointCell.swift +++ b/openHAB/SetpointCell.swift @@ -69,7 +69,8 @@ class SetpointCell: GenericUITableViewCell { if numberState != nil { numberState?.value = limitedNewValue } else { - numberState = NumberState(value: limitedNewValue) + // Use widget's unit as fallback when creating NumberState + numberState = NumberState(value: limitedNewValue, unit: widget.unit) } widget.sendItemUpdate(state: numberState) diff --git a/openHAB/SwiftUI/Rows/SetpointRowView.swift b/openHAB/SwiftUI/Rows/SetpointRowView.swift index 75c12b594..5af3423cd 100644 --- a/openHAB/SwiftUI/Rows/SetpointRowView.swift +++ b/openHAB/SwiftUI/Rows/SetpointRowView.swift @@ -112,10 +112,11 @@ struct SetpointRowView: View { return } - numberState = numberState ?? NumberState(value: limitedNewValue) + // Use widget's unit as fallback when creating NumberState + numberState = numberState ?? NumberState(value: limitedNewValue, unit: widget.unit) numberState?.value = limitedNewValue - logger.info("Setpoint \(isDecreasing ? "decreased" : "increased") to \(limitedNewValue)") + logger.info("Setpoint \(isDecreasing ? "decreased" : "increased") to \(numberState?.description ?? String(limitedNewValue))") viewModel.sendToUpdate(item: widget.item, state: numberState) } } diff --git a/openHABWatch/Views/Rows/SetpointRow.swift b/openHABWatch/Views/Rows/SetpointRow.swift index 56b1c225f..99106a122 100644 --- a/openHABWatch/Views/Rows/SetpointRow.swift +++ b/openHABWatch/Views/Rows/SetpointRow.swift @@ -18,6 +18,7 @@ struct SetpointRow: View { @ObservedObject var widget: OpenHABWidget @EnvironmentObject var settings: AppSettings private let setpointService = SetPointService() + private let logger = Logger(subsystem: "org.openhab.watch", category: "SetpointRow") private var isIntStep: Bool { widget.step.truncatingRemainder(dividingBy: 1) == 0 @@ -27,6 +28,10 @@ struct SetpointRow: View { isIntStep ? "%ld" : "%.01f" } + private var currentValue: Double { + widget.stateValueAsNumberState?.value ?? widget.minValue + } + var body: some View { VStack(spacing: 5) { HStack { @@ -37,10 +42,13 @@ struct SetpointRow: View { HStack { Spacer() - IconWithAction( - systemSymbol: .chevronDownCircleFill, - action: decreaseValue - ) + Button(action: decreaseValue) { + Image(systemSymbol: .chevronDownCircleFill) + .font(.system(size: 25)) + .foregroundStyle(currentValue <= widget.minValue ? Color.gray : Color.blue) + } + .buttonStyle(.plain) + .disabled(currentValue <= widget.minValue) Spacer() @@ -49,10 +57,13 @@ struct SetpointRow: View { Spacer() - IconWithAction( - systemSymbol: .chevronUpCircleFill, - action: increaseValue - ) + Button(action: increaseValue) { + Image(systemSymbol: .chevronUpCircleFill) + .font(.system(size: 25)) + .foregroundStyle(currentValue >= widget.maxValue ? Color.gray : Color.blue) + } + .buttonStyle(.plain) + .disabled(currentValue >= widget.maxValue) Spacer() } @@ -76,9 +87,11 @@ struct SetpointRow: View { return } - numberState = numberState ?? NumberState(value: limitedNewValue) + // Use widget's unit as fallback when creating NumberState + numberState = numberState ?? NumberState(value: limitedNewValue, unit: widget.unit) numberState?.value = limitedNewValue + logger.info("Setpoint \(isDecreasing ? "decreased" : "increased") to \(numberState?.description ?? String(limitedNewValue))") widget.sendItemUpdate(state: numberState) } diff --git a/openHABWatch/Views/Utils/IconWithAction.swift b/openHABWatch/Views/Utils/IconWithAction.swift index 6444a1582..ab7a23710 100644 --- a/openHABWatch/Views/Utils/IconWithAction.swift +++ b/openHABWatch/Views/Utils/IconWithAction.swift @@ -16,13 +16,13 @@ struct IconWithAction: View { var systemSymbol: SFSymbol var action: () -> Void var body: some View { - Image(systemSymbol: systemSymbol) - .font(.system(size: 25)) - .colorMultiply(.blue) - .saturation(0.8) - .onTapGesture { - action() - } + Button(action: action) { + Image(systemSymbol: systemSymbol) + .font(.system(size: 25)) + .colorMultiply(.blue) + .saturation(0.8) + } + .buttonStyle(.plain) } } From cd145490088f91ef40a0544ac9859ae9e0b786ed Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Mon, 2 Feb 2026 20:01:58 +0100 Subject: [PATCH 385/476] Move TextLabelView to CommonUI for reuse across iOS and watchOS Make TextLabelView configurable with optional font and lineLimit parameters so it can be shared between platforms. iOS defaults to body font with lineLimit 1, while watchOS explicitly uses caption font with lineLimit 2 to preserve existing behavior. Signed-off-by: Tim Mueller-Seydlitz --- BuildTools/.swiftformat | 1 + BuildTools/.swiftlint.yml | 1 + .../Sources/CommonUI}/TextLabelView.swift | 21 ++-- openHAB.xcodeproj/project.pbxproj | 4 - openHABWatch/Views/Rows/ColorPickerRow.swift | 3 +- openHABWatch/Views/Rows/GenericRow.swift | 3 +- .../Views/Rows/RollershutterRow.swift | 3 +- openHABWatch/Views/Rows/SegmentRow.swift | 8 +- openHABWatch/Views/Rows/SelectionRow.swift | 103 +++++++++--------- openHABWatch/Views/Rows/SetpointRow.swift | 3 +- openHABWatch/Views/Rows/SliderRow.swift | 3 +- .../Rows/SliderWithSwitchSupportRow.swift | 3 +- openHABWatch/Views/Rows/SwitchRow.swift | 4 +- openHABWatch/Views/Rows/TextRow.swift | 2 +- 14 files changed, 85 insertions(+), 77 deletions(-) rename {openHABWatch/Views/Utils => CommonUI/Sources/CommonUI}/TextLabelView.swift (67%) diff --git a/BuildTools/.swiftformat b/BuildTools/.swiftformat index 67bedb415..aed3c3735 100644 --- a/BuildTools/.swiftformat +++ b/BuildTools/.swiftformat @@ -5,6 +5,7 @@ --exclude ../OpenHABCore/Sources/OpenHABCore/GeneratedSources --exclude ../OsLogRewriter/.build --exclude ../build +--exclude ../CommonUI/.build --symlinks ignore # disabled rules diff --git a/BuildTools/.swiftlint.yml b/BuildTools/.swiftlint.yml index fcd8e2087..470ed8b72 100644 --- a/BuildTools/.swiftlint.yml +++ b/BuildTools/.swiftlint.yml @@ -42,6 +42,7 @@ excluded: - ../OsLogRewriter/.build - ../build - ../BonjourDiscoveryTool + - ../CommonUI/.build nesting: type_level: 2 diff --git a/openHABWatch/Views/Utils/TextLabelView.swift b/CommonUI/Sources/CommonUI/TextLabelView.swift similarity index 67% rename from openHABWatch/Views/Utils/TextLabelView.swift rename to CommonUI/Sources/CommonUI/TextLabelView.swift index 3c4aa4efc..c03d12966 100644 --- a/openHABWatch/Views/Utils/TextLabelView.swift +++ b/CommonUI/Sources/CommonUI/TextLabelView.swift @@ -9,23 +9,24 @@ // // SPDX-License-Identifier: EPL-2.0 -import CommonUI import OpenHABCore import SwiftUI -struct TextLabelView: View { +public struct TextLabelView: View { @ObservedObject var widget: OpenHABWidget - var lineLimit = 2 - - var body: some View { + var font: Font? + var lineLimit: Int + + public var body: some View { Text(widget.labelText ?? "") - .font(.caption) + .font(font) .lineLimit(lineLimit) .foregroundStyle(!widget.labelcolor.isEmpty ? Color(fromString: widget.labelcolor) : .primary) } -} -#Preview { - let widget = UserData(preview: true).widgets[2] - TextLabelView(widget: widget) + public init(widget: OpenHABWidget, font: Font? = nil, lineLimit: Int = 1) { + self.widget = widget + self.font = font + self.lineLimit = lineLimit + } } diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index 2fc5d5392..3859f3bc6 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -188,7 +188,6 @@ DAF231DB27BB828000AB916C /* pantryUseTagPoints2NonExistentElement.svg in Resources */ = {isa = PBXBuildFile; fileRef = DAF231DA27BB828000AB916C /* pantryUseTagPoints2NonExistentElement.svg */; }; DAF231E327BBD1A000AB916C /* embeddedpng_valid.svg in Resources */ = {isa = PBXBuildFile; fileRef = DAF231E227BBD1A000AB916C /* embeddedpng_valid.svg */; }; DAF4578223D630C70018B495 /* IconView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF4578123D630C70018B495 /* IconView.swift */; }; - DAF4578723D798A50018B495 /* TextLabelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF4578623D798A50018B495 /* TextLabelView.swift */; }; DAF4578923D79AA50018B495 /* DetailTextLabelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF4578823D79AA50018B495 /* DetailTextLabelView.swift */; }; DAF457A023DA3E1C0018B495 /* SegmentRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF4579F23DA3E1C0018B495 /* SegmentRow.swift */; }; C4377202F7D642B5A8349008 /* SelectionRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = D86D8BF295C448039B2B85EB /* SelectionRow.swift */; }; @@ -548,7 +547,6 @@ DAF231DA27BB828000AB916C /* pantryUseTagPoints2NonExistentElement.svg */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xml; path = pantryUseTagPoints2NonExistentElement.svg; sourceTree = ""; }; DAF231E227BBD1A000AB916C /* embeddedpng_valid.svg */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xml; path = embeddedpng_valid.svg; sourceTree = ""; }; DAF4578123D630C70018B495 /* IconView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconView.swift; sourceTree = ""; }; - DAF4578623D798A50018B495 /* TextLabelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextLabelView.swift; sourceTree = ""; }; DAF4578823D79AA50018B495 /* DetailTextLabelView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DetailTextLabelView.swift; sourceTree = ""; }; DAF4579F23DA3E1C0018B495 /* SegmentRow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SegmentRow.swift; sourceTree = ""; }; D86D8BF295C448039B2B85EB /* SelectionRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectionRow.swift; sourceTree = ""; }; @@ -1051,7 +1049,6 @@ isa = PBXGroup; children = ( DAF4578123D630C70018B495 /* IconView.swift */, - DAF4578623D798A50018B495 /* TextLabelView.swift */, DAF4578823D79AA50018B495 /* DetailTextLabelView.swift */, DA2E0AA323DC96E9009B0A99 /* ImageWithAction.swift */, DA2E0B0D23DCC152009B0A99 /* MapView.swift */, @@ -1680,7 +1677,6 @@ DA32D1B42C8C98C40018D974 /* IconWithAction.swift in Sources */, DA07764A234683BC0086C685 /* SwitchRow.swift in Sources */, DA2E0AA423DC96E9009B0A99 /* ImageWithAction.swift in Sources */, - DAF4578723D798A50018B495 /* TextLabelView.swift in Sources */, DA0749DE23E0B5950057FA83 /* ColorPickerRow.swift in Sources */, DAF4581E23DC60020018B495 /* ImageRawRow.swift in Sources */, DAD0858B2AE56F0E001D36BE /* OpenHABWatch.swift in Sources */, diff --git a/openHABWatch/Views/Rows/ColorPickerRow.swift b/openHABWatch/Views/Rows/ColorPickerRow.swift index 35cf57f36..7a1a4513b 100644 --- a/openHABWatch/Views/Rows/ColorPickerRow.swift +++ b/openHABWatch/Views/Rows/ColorPickerRow.swift @@ -9,6 +9,7 @@ // // SPDX-License-Identifier: EPL-2.0 +import CommonUI import OpenHABCore import os.log import SFSafeSymbols @@ -24,7 +25,7 @@ struct ColorPickerRow: View { VStack(spacing: 0) { HStack { IconView(widget: widget, settings: settings) - TextLabelView(widget: widget) + TextLabelView(widget: widget, font: .caption, lineLimit: 2) Spacer() } HStack { diff --git a/openHABWatch/Views/Rows/GenericRow.swift b/openHABWatch/Views/Rows/GenericRow.swift index a17349464..dcf4b48b4 100644 --- a/openHABWatch/Views/Rows/GenericRow.swift +++ b/openHABWatch/Views/Rows/GenericRow.swift @@ -9,6 +9,7 @@ // // SPDX-License-Identifier: EPL-2.0 +import CommonUI import OpenHABCore import os.log import SwiftUI @@ -20,7 +21,7 @@ struct GenericRow: View { var body: some View { HStack { IconView(widget: widget, settings: settings) - TextLabelView(widget: widget) + TextLabelView(widget: widget, font: .caption, lineLimit: 2) Spacer() DetailTextLabelView(widget: widget) widget.makeView(settings: settings) diff --git a/openHABWatch/Views/Rows/RollershutterRow.swift b/openHABWatch/Views/Rows/RollershutterRow.swift index c83c30f14..f88bef76c 100644 --- a/openHABWatch/Views/Rows/RollershutterRow.swift +++ b/openHABWatch/Views/Rows/RollershutterRow.swift @@ -9,6 +9,7 @@ // // SPDX-License-Identifier: EPL-2.0 +import CommonUI import OpenHABCore import SFSafeSymbols import SwiftUI @@ -21,7 +22,7 @@ struct RollershutterRow: View { VStack(spacing: -5) { HStack { IconView(widget: widget, settings: settings) - TextLabelView(widget: widget) + TextLabelView(widget: widget, font: .caption, lineLimit: 2) Spacer() } HStack { diff --git a/openHABWatch/Views/Rows/SegmentRow.swift b/openHABWatch/Views/Rows/SegmentRow.swift index 3319789be..7eb9c16e6 100644 --- a/openHABWatch/Views/Rows/SegmentRow.swift +++ b/openHABWatch/Views/Rows/SegmentRow.swift @@ -9,6 +9,7 @@ // // SPDX-License-Identifier: EPL-2.0 +import CommonUI import OpenHABCore import os.log import SwiftUI @@ -26,7 +27,7 @@ struct SegmentRow: View { HStack { HStack { IconView(widget: widget, settings: settings) - TextLabelView(widget: widget, lineLimit: 1) + TextLabelView(widget: widget, font: .caption) Spacer() } NavigationLink(destination: LazyView(SegmentSelectionView(widget: widget, selectedIndex: $selectedIndex))) { @@ -49,8 +50,8 @@ struct SegmentRow: View { .onAppear { selectedIndex = widget.mappingIndex(byCommand: widget.item?.state).map { Int($0) } } - .onChange(of: widget.item?.state) { newState in - selectedIndex = widget.mappingIndex(byCommand: newState).map { Int($0) } + .onChange(of: widget.item?.state, initial: false) { oldValue, newValue in + selectedIndex = widget.mappingIndex(byCommand: newValue).map { Int($0) } } } } @@ -63,3 +64,4 @@ struct SegmentRow: View { } .environmentObject(AppSettings()) } + diff --git a/openHABWatch/Views/Rows/SelectionRow.swift b/openHABWatch/Views/Rows/SelectionRow.swift index 3823bbcdc..a6c88e4a4 100644 --- a/openHABWatch/Views/Rows/SelectionRow.swift +++ b/openHABWatch/Views/Rows/SelectionRow.swift @@ -9,62 +9,12 @@ // // SPDX-License-Identifier: EPL-2.0 +import CommonUI import OpenHABCore import os.log import SFSafeSymbols import SwiftUI -struct SelectionRow: View { - @ObservedObject var widget: OpenHABWidget - @EnvironmentObject var settings: AppSettings - @State private var selectedIndex: Int? - - private var mappings: [OpenHABWidgetMapping] { - widget.mappingsOrItemOptions - } - - private var currentIndex: Int? { - selectedIndex ?? widget.mappingIndex(byCommand: widget.item?.state).map { Int($0) } - } - - /// Returns the label of the currently selected mapping - private var selectedValueText: String? { - if let index = currentIndex, index >= 0, index < mappings.count { - return mappings[index].label - } - return widget.labelValue - } - - var body: some View { - HStack { - HStack { - IconView(widget: widget, settings: settings) - TextLabelView(widget: widget, lineLimit: 1) - Spacer() - } - NavigationLink(destination: LazyView(SelectionListView(widget: widget, selectedIndex: $selectedIndex))) { - HStack(spacing: 4) { - if let valueText = selectedValueText { - Text(valueText) - .foregroundStyle(.secondary) - .lineLimit(1) - } - Image(systemSymbol: .chevronUpChevronDown) - .foregroundStyle(.secondary) - .font(.caption2) - } - } - .buttonStyle(.plain) - } - .onAppear { - selectedIndex = widget.mappingIndex(byCommand: widget.item?.state).map { Int($0) } - } - .onChange(of: widget.item?.state) { newState in - selectedIndex = widget.mappingIndex(byCommand: newState).map { Int($0) } - } - } -} - /// Selection list view for picking from available options struct SelectionListView: View { @ObservedObject var widget: OpenHABWidget @@ -127,6 +77,57 @@ struct SelectionListView: View { } } +struct SelectionRow: View { + @ObservedObject var widget: OpenHABWidget + @EnvironmentObject var settings: AppSettings + @State private var selectedIndex: Int? + + private var mappings: [OpenHABWidgetMapping] { + widget.mappingsOrItemOptions + } + + private var currentIndex: Int? { + selectedIndex ?? widget.mappingIndex(byCommand: widget.item?.state).map { Int($0) } + } + + /// Returns the label of the currently selected mapping + private var selectedValueText: String? { + if let index = currentIndex, index >= 0, index < mappings.count { + return mappings[index].label + } + return widget.labelValue + } + + var body: some View { + HStack { + HStack { + IconView(widget: widget, settings: settings) + TextLabelView(widget: widget, font: .caption) + Spacer() + } + NavigationLink(destination: LazyView(SelectionListView(widget: widget, selectedIndex: $selectedIndex))) { + HStack(spacing: 4) { + if let valueText = selectedValueText { + Text(valueText) + .foregroundStyle(.secondary) + .lineLimit(1) + } + Image(systemSymbol: .chevronUpChevronDown) + .foregroundStyle(.secondary) + .font(.caption2) + } + } + .buttonStyle(.plain) + } + .onAppear { + selectedIndex = widget.mappingIndex(byCommand: widget.item?.state).map { Int($0) } + } + .onChange(of: widget.item?.state) { _, newState in + selectedIndex = widget.mappingIndex(byCommand: newState).map { Int($0) } + } + } +} + #Preview { let widget = UserData(preview: true).widgets[4] SelectionRow(widget: widget) diff --git a/openHABWatch/Views/Rows/SetpointRow.swift b/openHABWatch/Views/Rows/SetpointRow.swift index 99106a122..cd593c34e 100644 --- a/openHABWatch/Views/Rows/SetpointRow.swift +++ b/openHABWatch/Views/Rows/SetpointRow.swift @@ -9,6 +9,7 @@ // // SPDX-License-Identifier: EPL-2.0 +import CommonUI import OpenHABCore import os.log import SFSafeSymbols @@ -36,7 +37,7 @@ struct SetpointRow: View { VStack(spacing: 5) { HStack { IconView(widget: widget, settings: settings) - TextLabelView(widget: widget) + TextLabelView(widget: widget, font: .caption, lineLimit: 2) Spacer() } HStack { diff --git a/openHABWatch/Views/Rows/SliderRow.swift b/openHABWatch/Views/Rows/SliderRow.swift index 7a6304c80..4396cd76c 100644 --- a/openHABWatch/Views/Rows/SliderRow.swift +++ b/openHABWatch/Views/Rows/SliderRow.swift @@ -9,6 +9,7 @@ // // SPDX-License-Identifier: EPL-2.0 +import CommonUI import OpenHABCore import os.log import SwiftUI @@ -40,7 +41,7 @@ struct SliderRow: View { VStack(spacing: 3) { HStack { IconView(widget: widget, settings: settings) - TextLabelView(widget: widget) + TextLabelView(widget: widget, font: .caption, lineLimit: 2) Spacer() DetailTextLabelView(widget: widget) }.padding(.top, 8) diff --git a/openHABWatch/Views/Rows/SliderWithSwitchSupportRow.swift b/openHABWatch/Views/Rows/SliderWithSwitchSupportRow.swift index 61ac7a5d3..b96e4e983 100644 --- a/openHABWatch/Views/Rows/SliderWithSwitchSupportRow.swift +++ b/openHABWatch/Views/Rows/SliderWithSwitchSupportRow.swift @@ -9,6 +9,7 @@ // // SPDX-License-Identifier: EPL-2.0 +import CommonUI import OpenHABCore import os.log import SwiftUI @@ -61,7 +62,7 @@ struct SliderWithSwitchSupportRow: View { HStack { IconView(widget: widget, settings: settings) VStack { - TextLabelView(widget: widget) + TextLabelView(widget: widget, font: .caption, lineLimit: 2) DetailTextLabelView(widget: widget) } } diff --git a/openHABWatch/Views/Rows/SwitchRow.swift b/openHABWatch/Views/Rows/SwitchRow.swift index 63731f5fc..5d8ba59b2 100644 --- a/openHABWatch/Views/Rows/SwitchRow.swift +++ b/openHABWatch/Views/Rows/SwitchRow.swift @@ -48,14 +48,14 @@ struct SwitchRow: View { HStack { IconView(widget: widget, settings: settings) VStack { - TextLabelView(widget: widget, lineLimit: 1) + TextLabelView(widget: widget, font: .caption) DetailTextLabelView(widget: widget) } } } .padding(.trailing) .cornerRadius(5) - .onChange(of: effectiveState) { _ in + .onChange(of: effectiveState) { localIsOn = nil } } diff --git a/openHABWatch/Views/Rows/TextRow.swift b/openHABWatch/Views/Rows/TextRow.swift index 029c007b9..4b2f2adef 100644 --- a/openHABWatch/Views/Rows/TextRow.swift +++ b/openHABWatch/Views/Rows/TextRow.swift @@ -21,7 +21,7 @@ struct TextRow: View { var body: some View { HStack { IconView(widget: widget, settings: settings) - TextLabelView(widget: widget) + TextLabelView(widget: widget, font: .caption, lineLimit: 2) Spacer() DetailTextLabelView(widget: widget) if widget.linkedPage != nil { From d7736be2cfd2164a3367a9fec5a025e629c1be7b Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Mon, 2 Feb 2026 20:02:58 +0100 Subject: [PATCH 386/476] Minor layout and style cleanups - SegmentedRowView: Move spacer inside conditional to avoid gap when there's no value label - ColorSelection: Remove redundant type annotation - Add CommonUI Package.resolved Signed-off-by: Tim Mueller-Seydlitz --- CommonUI/Package.resolved | 96 +++++++++++++++++++ openHAB/SwiftUI/Rows/SegmentedRowView.swift | 7 +- openHABWatch/Views/Utils/ColorSelection.swift | 2 +- 3 files changed, 100 insertions(+), 5 deletions(-) create mode 100644 CommonUI/Package.resolved diff --git a/CommonUI/Package.resolved b/CommonUI/Package.resolved new file mode 100644 index 000000000..6ac18d527 --- /dev/null +++ b/CommonUI/Package.resolved @@ -0,0 +1,96 @@ +{ + "originHash" : "822542af313faf8fe83f30c3acf880bf33b4214d7b137016615f52409ce94c8c", + "pins" : [ + { + "identity" : "kingfisher", + "kind" : "remoteSourceControl", + "location" : "https://github.com/onevcat/Kingfisher.git", + "state" : { + "revision" : "d30a5fad881137e2267f96a8e3fc35c58999bb94", + "version" : "8.6.2" + } + }, + { + "identity" : "sdwebimage", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SDWebImage/SDWebImage.git", + "state" : { + "revision" : "36e79ba485e9bb4d3cd4e3318908866dac5e7b51", + "version" : "5.21.5" + } + }, + { + "identity" : "sdwebimagesvgcoder", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SDWebImage/SDWebImageSVGCoder.git", + "state" : { + "revision" : "85b5d58ad02c207c496fa34426dc6560d6ae32f0", + "version" : "1.8.0" + } + }, + { + "identity" : "sfsafesymbols", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SFSafeSymbols/SFSafeSymbols.git", + "state" : { + "revision" : "e01b3d4f861412f8dcee8d93c417d2c2b0cdfd77", + "version" : "7.0.0" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections", + "state" : { + "revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-http-types", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-types.git", + "state" : { + "revision" : "45eb0224913ea070ec4fba17291b9e7ecf4749ca", + "version" : "1.5.1" + } + }, + { + "identity" : "swift-numerics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-numerics", + "state" : { + "revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2", + "version" : "1.1.1" + } + }, + { + "identity" : "swift-openapi-runtime", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-openapi-runtime", + "state" : { + "revision" : "7cdf33371bf89b23b9cf4fd3ce8d3c825c28fbe8", + "version" : "1.9.0" + } + }, + { + "identity" : "swift-openapi-urlsession", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-openapi-urlsession", + "state" : { + "revision" : "279aa6b77be6aa842a4bf3c45fa79fa15edf3e07", + "version" : "1.2.0" + } + }, + { + "identity" : "swift-timeout", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swhitty/swift-timeout.git", + "state" : { + "revision" : "4efb73b593d5553b90766d531db701ecf2306237", + "version" : "0.4.1" + } + } + ], + "version" : 3 +} diff --git a/openHAB/SwiftUI/Rows/SegmentedRowView.swift b/openHAB/SwiftUI/Rows/SegmentedRowView.swift index 715dc0345..f18f94d91 100644 --- a/openHAB/SwiftUI/Rows/SegmentedRowView.swift +++ b/openHAB/SwiftUI/Rows/SegmentedRowView.swift @@ -39,17 +39,16 @@ struct SegmentedRowView: View { .lineLimit(1) .truncationMode(.tail) .padding(.leading, 8) - .layoutPriority(1) // Truncates second + .layoutPriority(1) } - Spacer(minLength: 8) - if let detailTextLabel = widget.labelValue, !detailTextLabel.isEmpty { + Spacer(minLength: 8) Text(detailTextLabel) .foregroundStyle(widget.valuecolor.isEmpty ? Color(uiColor: UIColor.ohSecondaryLabel) : Color(fromString: widget.valuecolor)) .lineLimit(1) .truncationMode(.tail) - .layoutPriority(0) // Truncates first + .layoutPriority(1) } if !mappings.isEmpty { diff --git a/openHABWatch/Views/Utils/ColorSelection.swift b/openHABWatch/Views/Utils/ColorSelection.swift index 53465fec6..de024d8f4 100644 --- a/openHABWatch/Views/Utils/ColorSelection.swift +++ b/openHABWatch/Views/Utils/ColorSelection.swift @@ -61,7 +61,7 @@ struct ColorSelection: View { @State private var dragStart: CGPoint? @State private var lastSendTime: Date = .distantPast - private let handleRadius: Double = 12.5 + private let handleRadius = 12.5 var body: some View { // Use a clockwise spectrum to match the handle's hue direction. From e21a5778d81733a2d9724ed4cf27c9fbc5a5b772 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Mon, 2 Feb 2026 20:23:27 +0100 Subject: [PATCH 387/476] Fix valuecolor parsing in watchOS DetailTextLabelView Use Color(fromString:) from CommonUI to properly parse hex color strings like "#00AA00". The previous code used Color(widget.valuecolor) which doesn't handle hex strings, causing values with custom colors to not display. Signed-off-by: Tim Mueller-Seydlitz --- openHABWatch/Views/Utils/DetailTextLabelView.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openHABWatch/Views/Utils/DetailTextLabelView.swift b/openHABWatch/Views/Utils/DetailTextLabelView.swift index 02064c228..76b34744b 100644 --- a/openHABWatch/Views/Utils/DetailTextLabelView.swift +++ b/openHABWatch/Views/Utils/DetailTextLabelView.swift @@ -9,6 +9,7 @@ // // SPDX-License-Identifier: EPL-2.0 +import CommonUI import OpenHABCore import SwiftUI @@ -20,7 +21,7 @@ struct DetailTextLabelView: View { Text(label) .font(.footnote) .lineLimit(1) - .foregroundStyle(!widget.valuecolor.isEmpty ? Color(widget.valuecolor) : .secondary) + .foregroundStyle(!widget.valuecolor.isEmpty ? Color(fromString: widget.valuecolor) : .secondary) } } } From 91712b6acd6cadcd459ae85847fc45d989b71437 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Tue, 3 Feb 2026 09:35:47 +0100 Subject: [PATCH 388/476] Improve SegmentedRowView previews and add fallback icon support - Add fallbackSymbol parameter to IconView for SF Symbol fallback when network icons are unavailable (useful for previews) - Add fallbackSymbol parameter to SegmentedRowView, forwarded to IconView - Create PreviewList helper to reduce boilerplate in previews - Update previews to use List with proper insets matching SitemapPageView - Scale fallback symbol to 75% for better visual match with network icons Signed-off-by: Tim Mueller-Seydlitz --- openHAB/SwiftUI/IconView.swift | 27 +- openHAB/SwiftUI/Rows/SegmentedRowView.swift | 344 +++++++++----------- 2 files changed, 169 insertions(+), 202 deletions(-) diff --git a/openHAB/SwiftUI/IconView.swift b/openHAB/SwiftUI/IconView.swift index 3cadc8d80..a3372a857 100644 --- a/openHAB/SwiftUI/IconView.swift +++ b/openHAB/SwiftUI/IconView.swift @@ -13,6 +13,7 @@ import Combine import Kingfisher import OpenHABCore import os.log +import SFSafeSymbols import SwiftUI /// Thread-safe actor for tracking cached icon keys @@ -48,6 +49,8 @@ struct IconView: View { let size: CGSize let iconType: IconType = .svg + /// Optional SF Symbol to show as fallback when network icon is unavailable (useful for previews) + let fallbackSymbol: SFSymbol? private let logger = Logger(subsystem: "org.openhab", category: "IconView") @@ -82,10 +85,18 @@ struct IconView: View { var body: some View { ZStack { - // No icon or failed to load - show empty space - Rectangle() - .fill(Color.clear) - .frame(width: size.width, height: size.height) + // No icon URL - show fallback symbol if available + if let fallbackSymbol { + Image(systemSymbol: fallbackSymbol) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: size.width * 0.75, height: size.height * 0.75) + .foregroundStyle(.primary) + } else { + Rectangle() + .fill(Color.clear) + .frame(width: size.width, height: size.height) + } if let iconURL { KFImage(iconURL) @@ -129,10 +140,11 @@ struct IconView: View { extension IconView { /// Creates a widget icon view with standard size (32x32, matching UIKit cells) - init(widget: OpenHABWidget) { + init(widget: OpenHABWidget, fallbackSymbol: SFSymbol? = nil) { self.init( widget: widget, - size: CGSize(width: 32, height: 32) + size: CGSize(width: 32, height: 32), + fallbackSymbol: fallbackSymbol ) } } @@ -156,5 +168,6 @@ extension IconView { let widget = OpenHABWidget() widget.icon = "switch" widget.label = "Test Switch" - return IconView(widget: widget) + return IconView(widget: widget, fallbackSymbol: .switch2) + .environmentObject(SitemapPageViewModel()) } diff --git a/openHAB/SwiftUI/Rows/SegmentedRowView.swift b/openHAB/SwiftUI/Rows/SegmentedRowView.swift index f18f94d91..ac0d308a8 100644 --- a/openHAB/SwiftUI/Rows/SegmentedRowView.swift +++ b/openHAB/SwiftUI/Rows/SegmentedRowView.swift @@ -12,13 +12,18 @@ import CommonUI import OpenHABCore import os.log +import SFSafeSymbols import SwiftUI +// swiftlint:disable:next file_types_order struct SegmentedRowView: View { @ObservedObject var widget: OpenHABWidget @EnvironmentObject var viewModel: SitemapPageViewModel @Environment(\.colorScheme) var colorScheme + /// Optional SF Symbol fallback for IconView (useful for previews) + var fallbackSymbol: SFSymbol? + private let logger = Logger(subsystem: "org.openhab", category: "WidgetSegmentedView") private var mappings: [OpenHABWidgetMapping] { @@ -30,7 +35,7 @@ struct SegmentedRowView: View { var body: some View { HStack(spacing: 0) { - IconView(widget: widget) + IconView(widget: widget, fallbackSymbol: fallbackSymbol) .frame(width: 32, height: 32) if let labelText = widget.labelText, !labelText.isEmpty { @@ -220,9 +225,27 @@ struct SegmentedRowView: View { // MARK: - Preview Helpers +#if DEBUG +/// Wrapper for consistent preview list styling matching SitemapPageView +private struct PreviewList: View { + @ViewBuilder let content: () -> Content + + var body: some View { + List { + content() + .listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16)) + } + .listStyle(.plain) + .listRowSpacing(0) + .environment(\.defaultMinListRowHeight, 32) + .environmentObject(SitemapPageViewModel()) + } +} + private extension SegmentedRowView { static func createPreviewWidget(label: String, detailLabel: String? = nil, + icon: String = "switch", mappings: [OpenHABWidgetMapping], selectedState: String? = nil) -> OpenHABWidget { let widget = OpenHABWidget() @@ -233,247 +256,178 @@ private extension SegmentedRowView { widget.label = label } widget.type = .switchWidget + widget.icon = icon widget.mappings = mappings - if let detailLabel { - let item = OpenHABItem( - name: "", - type: "String", - state: selectedState ?? mappings.first?.command ?? "", - link: "", - label: detailLabel, - groupType: nil, - stateDescription: nil, - commandDescription: nil, - members: [], - category: nil, - options: nil - ) - widget.item = item - } + let item = OpenHABItem( + name: "Preview_\(label.replacingOccurrences(of: " ", with: "_"))", + type: "String", + state: selectedState ?? mappings.first?.command ?? "", + link: "", + label: detailLabel ?? label, + groupType: nil, + stateDescription: nil, + commandDescription: nil, + members: [], + category: nil, + options: nil + ) + widget.item = item return widget } } +#endif // MARK: - Previews #Preview("Short Labels") { - let widget = SegmentedRowView.createPreviewWidget( - label: "Light Switch", - detailLabel: "Status", - mappings: [ - OpenHABWidgetMapping(command: "ON", label: "ON"), - OpenHABWidgetMapping(command: "OFF", label: "OFF") - ], - selectedState: "ON" - ) - - VStack(spacing: 20) { - SegmentedRowView(widget: widget) - Spacer() - } - .environmentObject(SitemapPageViewModel()) -} - -#Preview("Long Labels") { - let widget = SegmentedRowView.createPreviewWidget( - label: "Temperature Control Mode", - detailLabel: "Current Mode", - mappings: [ - OpenHABWidgetMapping(command: "manual", label: "Manual Override"), - OpenHABWidgetMapping(command: "calendar", label: "Calendar Based"), - OpenHABWidgetMapping(command: "automatic", label: "Fully Automatic") - ], - selectedState: "automatic" - ) - - VStack(spacing: 20) { - SegmentedRowView(widget: widget) - Spacer() - } - .environmentObject(SitemapPageViewModel()) -} - -#Preview("Multiple Segments (4)") { - let widget = SegmentedRowView.createPreviewWidget( - label: "Fan Speed", - detailLabel: "Level 3", - mappings: [ - OpenHABWidgetMapping(command: "0", label: "Off"), - OpenHABWidgetMapping(command: "1", label: "Low"), - OpenHABWidgetMapping(command: "2", label: "Med"), - OpenHABWidgetMapping(command: "3", label: "High") - ], - selectedState: "3" - ) - - VStack(spacing: 20) { - SegmentedRowView(widget: widget) - Spacer() - } - .environmentObject(SitemapPageViewModel()) -} - -#Preview("Narrow Labels (2 segments)") { - let widget = SegmentedRowView.createPreviewWidget( - label: "Door Lock", - mappings: [ - OpenHABWidgetMapping(command: "lock", label: "🔒"), - OpenHABWidgetMapping(command: "unlock", label: "🔓") - ], - selectedState: "lock" - ) - - VStack(spacing: 20) { - SegmentedRowView(widget: widget) - Spacer() + PreviewList { + SegmentedRowView( + widget: SegmentedRowView.createPreviewWidget( + label: "Light Switch", + detailLabel: "ON", + mappings: [ + OpenHABWidgetMapping(command: "ON", label: "ON"), + OpenHABWidgetMapping(command: "OFF", label: "OFF") + ], + selectedState: "ON" + ), + fallbackSymbol: .switch2 + ) } - .environmentObject(SitemapPageViewModel()) } -#Preview("Single Segment") { - let widget = SegmentedRowView.createPreviewWidget( - label: "Scene", - detailLabel: "Active", - mappings: [ - OpenHABWidgetMapping(command: "PLAY", label: "Run") - ], - selectedState: "PLAY" - ) - - VStack(spacing: 20) { - SegmentedRowView(widget: widget) - Spacer() +#Preview("Charts Period") { + PreviewList { + SegmentedRowView( + widget: SegmentedRowView.createPreviewWidget( + label: "Charts Period", + mappings: [ + OpenHABWidgetMapping(command: "D", label: "Day"), + OpenHABWidgetMapping(command: "W", label: "Week"), + OpenHABWidgetMapping(command: "M", label: "M"), + OpenHABWidgetMapping(command: "4h", label: "4h") + ], + selectedState: "D" + ), + fallbackSymbol: .chartBarFill + ) } - .environmentObject(SitemapPageViewModel()) } -#Preview("Press-Release Buttons") { - let widget = SegmentedRowView.createPreviewWidget( - label: "Blinds Control", - detailLabel: "Position", - mappings: [ - OpenHABWidgetMapping(command: "UP", label: "Up", releaseCommand: "STOP"), - OpenHABWidgetMapping(command: "DOWN", label: "Down", releaseCommand: "STOP") - ] - ) - - VStack(spacing: 20) { - SegmentedRowView(widget: widget) - Text("Press and hold buttons to move blinds") - .font(.caption) - .foregroundColor(.secondary) - Spacer() +#Preview("Long Labels") { + PreviewList { + SegmentedRowView( + widget: SegmentedRowView.createPreviewWidget( + label: "Temperature Control", + detailLabel: "Automatic", + mappings: [ + OpenHABWidgetMapping(command: "manual", label: "Manual"), + OpenHABWidgetMapping(command: "calendar", label: "Calendar"), + OpenHABWidgetMapping(command: "automatic", label: "Automatic") + ], + selectedState: "automatic" + ), + fallbackSymbol: .thermometerMedium + ) } - .environmentObject(SitemapPageViewModel()) } -#Preview("Press-Release (Multiple)") { - let widget = SegmentedRowView.createPreviewWidget( - label: "Garage Door", - mappings: [ - OpenHABWidgetMapping(command: "OPEN", label: "Open", releaseCommand: "STOP"), - OpenHABWidgetMapping(command: "CLOSE", label: "Close", releaseCommand: "STOP"), - OpenHABWidgetMapping(command: "PARTIAL", label: "Partial", releaseCommand: "STOP") - ] - ) - - VStack(spacing: 20) { - SegmentedRowView(widget: widget) - Text("Hold to perform action, release to stop") - .font(.caption) - .foregroundColor(.secondary) - Spacer() +#Preview("Multiple Segments (4)") { + PreviewList { + SegmentedRowView( + widget: SegmentedRowView.createPreviewWidget( + label: "Fan Speed", + detailLabel: "High", + mappings: [ + OpenHABWidgetMapping(command: "0", label: "Off"), + OpenHABWidgetMapping(command: "1", label: "Low"), + OpenHABWidgetMapping(command: "2", label: "Med"), + OpenHABWidgetMapping(command: "3", label: "High") + ], + selectedState: "3" + ), + fallbackSymbol: .fanOscillation + ) } - .environmentObject(SitemapPageViewModel()) } -#Preview("Truncation Test") { - let widget = SegmentedRowView.createPreviewWidget( - label: "Very Long Label That Should Truncate Nicely", - detailLabel: "Also A Very Long Detail Text Here", - mappings: [ - OpenHABWidgetMapping(command: "option1", label: "First"), - OpenHABWidgetMapping(command: "option2", label: "Second") - ], - selectedState: "option1" - ) - - VStack(spacing: 20) { - SegmentedRowView(widget: widget) - Text("Tests label truncation behavior") - .font(.caption) - .foregroundColor(.secondary) - Spacer() +#Preview("Single Mapping") { + PreviewList { + SegmentedRowView( + widget: SegmentedRowView.createPreviewWidget( + label: "Scene", + detailLabel: "Movie Night", + mappings: [ + OpenHABWidgetMapping(command: "PLAY", label: "Run") + ], + selectedState: "PLAY" + ), + fallbackSymbol: .theatermasksFill + ) } - .environmentObject(SitemapPageViewModel()) } #Preview("All Scenarios") { - ScrollView { - VStack(spacing: 16) { - // Short labels - SegmentedRowView(widget: SegmentedRowView.createPreviewWidget( + PreviewList { + SegmentedRowView( + widget: SegmentedRowView.createPreviewWidget( label: "Light", + detailLabel: "ON", mappings: [ OpenHABWidgetMapping(command: "ON", label: "ON"), OpenHABWidgetMapping(command: "OFF", label: "OFF") ], selectedState: "ON" - )) - - Divider() - - // Long labels - SegmentedRowView(widget: SegmentedRowView.createPreviewWidget( + ), + fallbackSymbol: .lightbulbFill + ) + SegmentedRowView( + widget: SegmentedRowView.createPreviewWidget( label: "Climate Mode", detailLabel: "Auto", mappings: [ OpenHABWidgetMapping(command: "m", label: "Manual"), - OpenHABWidgetMapping(command: "a", label: "Automatic"), + OpenHABWidgetMapping(command: "a", label: "Auto"), OpenHABWidgetMapping(command: "s", label: "Schedule") ], selectedState: "a" - )) - - Divider() - - // Press-release - SegmentedRowView(widget: SegmentedRowView.createPreviewWidget( - label: "Shutter", - mappings: [ - OpenHABWidgetMapping(command: "UP", label: "↑", releaseCommand: "STOP"), - OpenHABWidgetMapping(command: "DOWN", label: "↓", releaseCommand: "STOP") - ] - )) - - Divider() - - // Multiple segments - SegmentedRowView(widget: SegmentedRowView.createPreviewWidget( - label: "Speed", + ), + fallbackSymbol: .thermometerMedium + ) + SegmentedRowView( + widget: SegmentedRowView.createPreviewWidget( + label: "Fan Speed", + detailLabel: "Medium", mappings: [ OpenHABWidgetMapping(command: "0", label: "Off"), OpenHABWidgetMapping(command: "1", label: "Low"), - OpenHABWidgetMapping(command: "2", label: "Mid"), + OpenHABWidgetMapping(command: "2", label: "Med"), OpenHABWidgetMapping(command: "3", label: "High") ], selectedState: "2" - )) - - Spacer() - } - .padding() + ), + fallbackSymbol: .fanOscillation + ) + SegmentedRowView( + widget: SegmentedRowView.createPreviewWidget( + label: "Scene", + mappings: [ + OpenHABWidgetMapping(command: "RUN", label: "Run") + ], + selectedState: "RUN" + ), + fallbackSymbol: .theatermasksFill + ) } - .environmentObject(SitemapPageViewModel()) } -#Preview { - let widget = PreviewConstants.openHABSitemapPage!.widgets[4] - VStack { - SegmentedRowView(widget: widget) - Spacer() +#Preview("From PreviewConstants") { + PreviewList { + SegmentedRowView( + widget: PreviewConstants.openHABSitemapPage!.widgets[4], + fallbackSymbol: .switch2 + ) } - .environmentObject(SitemapPageViewModel()) } From d970489bf7546aa389ee6cb97059768f7f163781 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Tue, 3 Feb 2026 09:36:16 +0100 Subject: [PATCH 389/476] Fix linter warnings Signed-off-by: Tim Mueller-Seydlitz --- CommonUI/Sources/CommonUI/TextLabelView.swift | 2 +- openHABWatch/Views/Rows/SegmentRow.swift | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/CommonUI/Sources/CommonUI/TextLabelView.swift b/CommonUI/Sources/CommonUI/TextLabelView.swift index c03d12966..d8051cdc3 100644 --- a/CommonUI/Sources/CommonUI/TextLabelView.swift +++ b/CommonUI/Sources/CommonUI/TextLabelView.swift @@ -16,7 +16,7 @@ public struct TextLabelView: View { @ObservedObject var widget: OpenHABWidget var font: Font? var lineLimit: Int - + public var body: some View { Text(widget.labelText ?? "") .font(font) diff --git a/openHABWatch/Views/Rows/SegmentRow.swift b/openHABWatch/Views/Rows/SegmentRow.swift index 7eb9c16e6..9af0ce900 100644 --- a/openHABWatch/Views/Rows/SegmentRow.swift +++ b/openHABWatch/Views/Rows/SegmentRow.swift @@ -50,7 +50,7 @@ struct SegmentRow: View { .onAppear { selectedIndex = widget.mappingIndex(byCommand: widget.item?.state).map { Int($0) } } - .onChange(of: widget.item?.state, initial: false) { oldValue, newValue in + .onChange(of: widget.item?.state, initial: false) { _, newValue in selectedIndex = widget.mappingIndex(byCommand: newValue).map { Int($0) } } } @@ -64,4 +64,3 @@ struct SegmentRow: View { } .environmentObject(AppSettings()) } - From c28c263d6bc2202029035ad3d0c7e1f0624ce4e9 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Tue, 3 Feb 2026 12:06:59 +0100 Subject: [PATCH 390/476] Fix iOS slider external updates and merge watchOS slider rows - Fix iOS SliderRowView not reacting to external updates by using debounce pattern (matching watchOS approach) - Merge SliderWithSwitchSupportRow into SliderRow on watchOS - Add fallbackSymbol support to watchOS IconView for previews - Improve slider previews with minValue/maxValue/step support Signed-off-by: Tim Mueller-Seydlitz --- openHAB.xcodeproj/project.pbxproj | 4 - openHAB/SwiftUI/Rows/SliderRowView.swift | 249 +++++++++++++----- openHABWatch/Views/Rows/SliderRow.swift | 170 +++++++++++- .../Rows/SliderWithSwitchSupportRow.swift | 86 ------ openHABWatch/Views/SitemapPageView.swift | 6 +- openHABWatch/Views/Utils/IconView.swift | 8 + 6 files changed, 347 insertions(+), 176 deletions(-) delete mode 100644 openHABWatch/Views/Rows/SliderWithSwitchSupportRow.swift diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index 3859f3bc6..041e243ad 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -119,7 +119,6 @@ DA48001E2D837905009CF127 /* ApplicationSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA48001D2D837905009CF127 /* ApplicationSettingsView.swift */; }; DA4800212D839D3A009CF127 /* AnimatedSecureTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4800202D839D39009CF127 /* AnimatedSecureTextField.swift */; }; DA4D4DB5233F9ACB00B37E37 /* README.md in Resources */ = {isa = PBXBuildFile; fileRef = DA4D4DB4233F9ACB00B37E37 /* README.md */; }; - DA50C7BD2B0A51BD0009F716 /* SliderWithSwitchSupportRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA50C7BC2B0A51BD0009F716 /* SliderWithSwitchSupportRow.swift */; }; DA50C7BF2B0A65300009F716 /* SliderWithSwitchSupportUITableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA50C7BE2B0A652F0009F716 /* SliderWithSwitchSupportUITableViewCell.swift */; }; DA5ED9BE2C850955004875E0 /* ClientCertificatesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5ED9BD2C850955004875E0 /* ClientCertificatesViewModel.swift */; }; DA64ACA62DBEAD5600294F60 /* SitemapPageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA64ACA52DBEAD5600294F60 /* SitemapPageViewModel.swift */; }; @@ -476,7 +475,6 @@ DA4800202D839D39009CF127 /* AnimatedSecureTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnimatedSecureTextField.swift; sourceTree = ""; }; DA4D4DB4233F9ACB00B37E37 /* README.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; DA4D4E0E2340A00200B37E37 /* Changes.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = Changes.md; sourceTree = ""; }; - DA50C7BC2B0A51BD0009F716 /* SliderWithSwitchSupportRow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SliderWithSwitchSupportRow.swift; sourceTree = ""; }; DA50C7BE2B0A652F0009F716 /* SliderWithSwitchSupportUITableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SliderWithSwitchSupportUITableViewCell.swift; sourceTree = ""; }; DA5ED9BD2C850955004875E0 /* ClientCertificatesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientCertificatesViewModel.swift; sourceTree = ""; }; DA64ACA52DBEAD5600294F60 /* SitemapPageViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SitemapPageViewModel.swift; sourceTree = ""; }; @@ -1028,7 +1026,6 @@ children = ( DA077649234683BC0086C685 /* SwitchRow.swift */, DA0F37CF23D4ACC7007EAB48 /* SliderRow.swift */, - DA50C7BC2B0A51BD0009F716 /* SliderWithSwitchSupportRow.swift */, DAF457A123DB6C640018B495 /* RollershutterRow.swift */, DAF4579F23DA3E1C0018B495 /* SegmentRow.swift */, D86D8BF295C448039B2B85EB /* SelectionRow.swift */, @@ -1695,7 +1692,6 @@ DAC9AF4924F966FA006DAE93 /* LazyView.swift in Sources */, DA0776F0234788010086C685 /* UserData.swift in Sources */, DAC6608D236F771600F4501E /* PreferencesSwiftUIView.swift in Sources */, - DA50C7BD2B0A51BD0009F716 /* SliderWithSwitchSupportRow.swift in Sources */, DAF457A623DB9CE00018B495 /* SetpointRow.swift in Sources */, DAF4581823DC4A050018B495 /* ImageRow.swift in Sources */, DA2741002EA62F1F002FE576 /* SitemapPageView.swift in Sources */, diff --git a/openHAB/SwiftUI/Rows/SliderRowView.swift b/openHAB/SwiftUI/Rows/SliderRowView.swift index 8405226ef..caf264af6 100644 --- a/openHAB/SwiftUI/Rows/SliderRowView.swift +++ b/openHAB/SwiftUI/Rows/SliderRowView.swift @@ -11,32 +11,54 @@ import CommonUI import OpenHABCore +import SFSafeSymbols import SwiftUI struct SliderRowView: View { @ObservedObject var widget: OpenHABWidget - @State private var sliderValue = 0.0 - @State private var isDragging = false + var fallbackSymbol: SFSymbol? - @State private var updateTask: Task? - private let throttleInterval: TimeInterval = 0.9 // in seconds @EnvironmentObject var viewModel: SitemapPageViewModel + /// Pending value while user is dragging; nil when not actively changing + @State private var pendingValue: Double? @State private var lastSendTime: Date = .distantPast - private var displayValue: Double { - isDragging ? sliderValue : (widget.stateValueAsNumberState?.value ?? widget.minValue) - } - private var sliderRange: ClosedRange { widget.minValue ... widget.maxValue } + private var valueBinding: Binding { + Binding( + get: { pendingValue ?? widget.adjustedValue }, + set: { newValue in + pendingValue = newValue + if widget.shouldUseSliderUpdatesDuringMove() { + let now = Date() + if now.timeIntervalSince(lastSendTime) > 0.2 { + sendSliderUpdate(newValue) + lastSendTime = now + } + } + // Debounce: clear pending value after delay if no new updates + Task { @MainActor in + try? await Task.sleep(for: .milliseconds(500)) + if pendingValue == newValue { + if !widget.shouldUseSliderUpdatesDuringMove() { + sendSliderUpdate(newValue) + } + pendingValue = nil + } + } + } + ) + } + var body: some View { HStack { if widget.switchSupport { Button { - viewModel.sendCommand(widget.item, commandToSend: sliderValue <= widget.minValue ? "ON" : "OFF") + viewModel.sendCommand(widget.item, commandToSend: (pendingValue ?? widget.adjustedValue) <= widget.minValue ? "ON" : "OFF") } label: { labelContent } @@ -46,51 +68,15 @@ struct SliderRowView: View { labelContent } - Slider( - value: Binding( - get: { - isDragging ? sliderValue : (widget.stateValueAsNumberState?.value ?? widget.minValue) - }, - set: { newValue in - sliderValue = newValue - if widget.shouldUseSliderUpdatesDuringMove() { - let now = Date() - if now.timeIntervalSince(lastSendTime) > 0.2 { - sendSliderUpdate(newValue) - lastSendTime = now - } - } - } - ), - in: sliderRange - ) { editing in - isDragging = editing - if editing { - sliderValue = widget.stateValueAsNumberState?.value ?? widget.minValue - } - if !editing, !widget.shouldUseSliderUpdatesDuringMove() { - sendSliderUpdate(sliderValue) - } - } - .disabled(widget.readOnly ?? false) - } - .onAppear { - loadCurrentValue() - } - .onChange(of: widget.stateValueAsNumberState?.value) { newValue in - if !isDragging, let newValue { - sliderValue = newValue - } - } - .onDisappear { - updateTask?.cancel() + Slider(value: valueBinding, in: sliderRange) + .disabled(widget.readOnly ?? false) } } @ViewBuilder private var labelContent: some View { HStack { - IconView(widget: widget) + IconView(widget: widget, fallbackSymbol: fallbackSymbol) .frame(width: 32, height: 32) if let labelText = widget.labelText, !labelText.isEmpty { @@ -112,39 +98,162 @@ struct SliderRowView: View { .contentShape(Rectangle()) } - private func loadCurrentValue() { - // Avoid snapping to minValue while state is still loading. - if let value = widget.stateValueAsNumberState?.value { - sliderValue = value - } - } - private func sendSliderUpdate(_ newValue: Double) { var numberState = widget.stateValueAsNumberState numberState = numberState ?? NumberState(value: newValue) numberState?.value = newValue viewModel.sendToUpdate(item: widget.item, state: numberState) } +} - private func throttledSendSliderUpdate(_ newValue: Double) { - updateTask?.cancel() +// MARK: - Preview Helpers - updateTask = Task { - try? await Task.sleep(nanoseconds: UInt64(throttleInterval * 1_000_000_000)) - guard !Task.isCancelled else { return } - await MainActor.run { - sendSliderUpdate(sliderValue) - lastSendTime = Date() - } +#if DEBUG +private struct PreviewList: View { + @ViewBuilder let content: () -> Content + + var body: some View { + List { + content() + .listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16)) + } + .listStyle(.plain) + .listRowSpacing(0) + .environment(\.defaultMinListRowHeight, 32) + .environmentObject(SitemapPageViewModel()) + } +} + +private extension SliderRowView { + static func createPreviewWidget(label: String, + value: Double? = nil, + minValue: Double = 0.0, + maxValue: Double = 100.0, + step: Double = 1.0, + icon: String = "slider", + switchSupport: Bool = false) -> OpenHABWidget { + let widget = OpenHABWidget() + widget.widgetId = UUID().uuidString + widget.type = .slider + widget.icon = icon + widget.minValue = minValue + widget.maxValue = maxValue + widget.step = step + widget.switchSupport = switchSupport + + if let value { + widget.label = "\(label) [\(Int(value))]" + } else { + widget.label = label } + + let item = OpenHABItem( + name: "Preview_\(label.replacingOccurrences(of: " ", with: "_"))", + type: "Dimmer", + state: value.map { String($0) } ?? "NULL", + link: "", + label: label, + groupType: nil, + stateDescription: nil, + commandDescription: nil, + members: [], + category: nil, + options: nil + ) + widget.item = item + + return widget + } +} +#endif + +// MARK: - Previews + +#Preview("Default Range (0-100)") { + PreviewList { + SliderRowView( + widget: SliderRowView.createPreviewWidget( + label: "Brightness", + value: 75 + ), + fallbackSymbol: .sliderHorizontal3 + ) + } +} + +#Preview("Custom Range (minValue)") { + PreviewList { + SliderRowView( + widget: SliderRowView.createPreviewWidget( + label: "Temperature", + value: 21, + minValue: 16, + maxValue: 28, + step: 0.5 + ), + fallbackSymbol: .thermometerMedium + ) + } +} + +#Preview("With Switch Support") { + PreviewList { + SliderRowView( + widget: SliderRowView.createPreviewWidget( + label: "Dimmer", + value: 50, + switchSupport: true + ), + fallbackSymbol: .lightbulbFill + ) + } +} + +#Preview("All Scenarios") { + PreviewList { + SliderRowView( + widget: SliderRowView.createPreviewWidget( + label: "Brightness", + value: 75 + ), + fallbackSymbol: .sliderHorizontal3 + ) + SliderRowView( + widget: SliderRowView.createPreviewWidget( + label: "Temperature", + value: 21, + minValue: 16, + maxValue: 28, + step: 0.5 + ), + fallbackSymbol: .thermometerMedium + ) + SliderRowView( + widget: SliderRowView.createPreviewWidget( + label: "Volume", + value: 30, + minValue: 0, + maxValue: 100, + icon: "soundvolume" + ), + fallbackSymbol: .speakerWave2Fill + ) + SliderRowView( + widget: SliderRowView.createPreviewWidget( + label: "Dimmer", + value: 50, + switchSupport: true + ), + fallbackSymbol: .lightbulbFill + ) } } -#Preview { - let widget = PreviewConstants.openHABSitemapPage!.widgets[3] - VStack { - SliderRowView(widget: widget) - Spacer() +#Preview("From PreviewConstants") { + PreviewList { + SliderRowView( + widget: PreviewConstants.openHABSitemapPage!.widgets[3], + fallbackSymbol: .sliderHorizontal3 + ) } - .environmentObject(SitemapPageViewModel()) } diff --git a/openHABWatch/Views/Rows/SliderRow.swift b/openHABWatch/Views/Rows/SliderRow.swift index 4396cd76c..5f3d1e32e 100644 --- a/openHABWatch/Views/Rows/SliderRow.swift +++ b/openHABWatch/Views/Rows/SliderRow.swift @@ -12,11 +12,13 @@ import CommonUI import OpenHABCore import os.log +import SFSafeSymbols import SwiftUI struct SliderRow: View { @ObservedObject var widget: OpenHABWidget @EnvironmentObject var settings: AppSettings + var fallbackSymbol: SFSymbol? @State private var pendingValue: Double? var valueBinding: Binding { .init( @@ -37,14 +39,45 @@ struct SliderRow: View { ) } + private var stateBinding: Binding { + Binding( + get: { + widget.adjustedValue > widget.minValue + }, + set: { newValue in + if newValue { + Logger.rowViews.info("SliderRow switch to ON") + widget.sendCommand(widget.maxValue.valueText(step: widget.step)) + } else { + Logger.rowViews.info("SliderRow switch to OFF") + widget.sendCommand(widget.minValue.valueText(step: widget.step)) + } + } + ) + } + var body: some View { VStack(spacing: 3) { - HStack { - IconView(widget: widget, settings: settings) - TextLabelView(widget: widget, font: .caption, lineLimit: 2) - Spacer() - DetailTextLabelView(widget: widget) - }.padding(.top, 8) + if widget.switchSupport { + Toggle(isOn: stateBinding) { + HStack { + IconView(widget: widget, settings: settings, fallbackSymbol: fallbackSymbol) + VStack(alignment: .leading) { + TextLabelView(widget: widget, font: .caption, lineLimit: 2) + DetailTextLabelView(widget: widget) + } + } + } + .padding(.trailing) + .cornerRadius(5) + } else { + HStack { + IconView(widget: widget, settings: settings, fallbackSymbol: fallbackSymbol) + TextLabelView(widget: widget, font: .caption, lineLimit: 2) + Spacer() + DetailTextLabelView(widget: widget) + }.padding(.top, 8) + } Slider(value: valueBinding, in: widget.minValue ... widget.maxValue, step: widget.step) .labelsHidden() @@ -52,11 +85,126 @@ struct SliderRow: View { } } -#Preview { - let widget = UserData(preview: true).widgets[3] - return Group { - SliderRow(widget: widget) - SliderRow(widget: widget) +// MARK: - Preview Helpers + +#if DEBUG +private extension SliderRow { + static func createPreviewWidget(label: String, + value: Double? = nil, + minValue: Double = 0.0, + maxValue: Double = 100.0, + step: Double = 1.0, + icon: String = "slider", + switchSupport: Bool = false) -> OpenHABWidget { + let widget = OpenHABWidget() + widget.widgetId = UUID().uuidString + widget.type = .slider + widget.icon = icon + widget.minValue = minValue + widget.maxValue = maxValue + widget.step = step + widget.switchSupport = switchSupport + + if let value { + widget.label = "\(label) [\(Int(value))]" + } else { + widget.label = label + } + + let item = OpenHABItem( + name: "Preview_\(label.replacingOccurrences(of: " ", with: "_"))", + type: "Dimmer", + state: value.map { String($0) } ?? "NULL", + link: "", + label: label, + groupType: nil, + stateDescription: nil, + commandDescription: nil, + members: [], + category: nil, + options: nil + ) + widget.item = item + + return widget + } +} +#endif + +// MARK: - Previews + +#Preview("Default Range") { + SliderRow( + widget: SliderRow.createPreviewWidget( + label: "Brightness", + value: 75 + ), + fallbackSymbol: .sliderHorizontal3 + ) + .environmentObject(AppSettings()) +} + +#Preview("Custom Range (minValue)") { + SliderRow( + widget: SliderRow.createPreviewWidget( + label: "Temperature", + value: 16, + minValue: 16, + maxValue: 28, + step: 0.5 + ), + fallbackSymbol: .thermometerMedium + ) + .environmentObject(AppSettings()) +} + +#Preview("With Switch Support") { + SliderRow( + widget: SliderRow.createPreviewWidget( + label: "Dimmer", + value: 50, + switchSupport: true + ), + fallbackSymbol: .lightbulbFill + ) + .environmentObject(AppSettings()) +} + +#Preview("All Scenarios") { + List { + SliderRow( + widget: SliderRow.createPreviewWidget( + label: "Brightness", + value: 75 + ), + fallbackSymbol: .sliderHorizontal3 + ) + SliderRow( + widget: SliderRow.createPreviewWidget( + label: "Temperature", + value: 21, + minValue: 16, + maxValue: 28, + step: 0.5 + ), + fallbackSymbol: .thermometerMedium + ) + SliderRow( + widget: SliderRow.createPreviewWidget( + label: "Dimmer", + value: 50, + switchSupport: true + ), + fallbackSymbol: .lightbulbFill + ) } .environmentObject(AppSettings()) } + +#Preview("From UserData") { + SliderRow( + widget: UserData(preview: true).widgets[3], + fallbackSymbol: .sliderHorizontal3 + ) + .environmentObject(AppSettings()) +} diff --git a/openHABWatch/Views/Rows/SliderWithSwitchSupportRow.swift b/openHABWatch/Views/Rows/SliderWithSwitchSupportRow.swift deleted file mode 100644 index b96e4e983..000000000 --- a/openHABWatch/Views/Rows/SliderWithSwitchSupportRow.swift +++ /dev/null @@ -1,86 +0,0 @@ -// Copyright (c) 2010-2026 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 CommonUI -import OpenHABCore -import os.log -import SwiftUI - -struct SliderWithSwitchSupportRow: View { - @ObservedObject var widget: OpenHABWidget - @EnvironmentObject var settings: AppSettings - @State private var pendingValue: Double? - - var body: some View { - let valueBinding = Binding( - get: { - pendingValue ?? widget.adjustedValue - }, - set: { newValue in - Logger.rowViews.info("SliderWithSwitchSupportRow new value = \(newValue)") - pendingValue = newValue - Task { @MainActor in - try? await Task.sleep(for: .milliseconds(500)) - if pendingValue == newValue { // Ensure no new updates came in - widget.sendCommand(newValue.valueText(step: widget.step)) - pendingValue = nil - } - } - } - ) - - let stateBinding = Binding( - get: { - if widget.adjustedValue > widget.minValue { - true - } else { - false - } - }, - set: { - if $0 { - Logger.rowViews.info("Switch to ON") - widget.sendCommand(widget.maxValue.valueText(step: widget.step)) - } else { - Logger.rowViews.info("Switch to OFF") - widget.sendCommand(widget.minValue.valueText(step: widget.step)) - } - } - ) - - return - VStack(spacing: 3) { - Toggle(isOn: stateBinding) { - HStack { - IconView(widget: widget, settings: settings) - VStack { - TextLabelView(widget: widget, font: .caption, lineLimit: 2) - DetailTextLabelView(widget: widget) - } - } - } - .padding(.trailing) - .cornerRadius(5) - - Slider(value: valueBinding, in: widget.minValue ... widget.maxValue, step: widget.step) - .labelsHidden() - } - } -} - -#Preview { - let widget = UserData(preview: true).widgets[3] - return Group { - SliderRow(widget: widget) - SliderRow(widget: widget) - } - .environmentObject(AppSettings()) -} diff --git a/openHABWatch/Views/SitemapPageView.swift b/openHABWatch/Views/SitemapPageView.swift index f17fc337a..0f6f3a7fd 100644 --- a/openHABWatch/Views/SitemapPageView.swift +++ b/openHABWatch/Views/SitemapPageView.swift @@ -44,11 +44,7 @@ struct WidgetRowView: View { SwitchRow(widget: widget) } case .slider: - if widget.switchSupport { - SliderWithSwitchSupportRow(widget: widget) - } else { - SliderRow(widget: widget) - } + SliderRow(widget: widget) case .setpoint: SetpointRow(widget: widget) case .frame: diff --git a/openHABWatch/Views/Utils/IconView.swift b/openHABWatch/Views/Utils/IconView.swift index 028d36e0b..2e924908b 100644 --- a/openHABWatch/Views/Utils/IconView.swift +++ b/openHABWatch/Views/Utils/IconView.swift @@ -19,6 +19,8 @@ struct IconView: View { @ObservedObject var widget: OpenHABWidget @ObservedObject var settings = AppSettings.shared @ObservedObject private var networkTracker = MainActorNetworkTracker.shared + /// Optional SF Symbol to show as fallback when network icon is unavailable (useful for previews) + var fallbackSymbol: SFSymbol? @State private var imageLoadingFailed = false @State private var retryCount = 0 @@ -70,6 +72,12 @@ struct IconView: View { .aspectRatio(contentMode: .fit) .frame(width: 20, height: 20) .id("\(iconURL.absoluteString)-\(widget.item?.state ?? "")-\(widget.iconColor)") + } else if let fallbackSymbol { + Image(systemSymbol: fallbackSymbol) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 15, height: 15) + .foregroundStyle(.primary) } else { Rectangle() .foregroundStyle(.background) From d91560354453a02066d4eae7bda6dfd24b724683 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Tue, 3 Feb 2026 20:48:51 +0100 Subject: [PATCH 391/476] Improve slider behavior and show current value while dragging - Use onEditingChanged for true release-only behavior on iOS - Show current slider value while dragging on iOS and watchOS - Fix external update handling with proper isEditing tracking - Keep pendingValue until server responds to avoid visual jump Signed-off-by: Tim Mueller-Seydlitz --- openHAB/SwiftUI/Rows/SliderRowView.swift | 65 +++++++++++++++++------- openHABWatch/Views/Rows/SliderRow.swift | 25 ++++++++- 2 files changed, 69 insertions(+), 21 deletions(-) diff --git a/openHAB/SwiftUI/Rows/SliderRowView.swift b/openHAB/SwiftUI/Rows/SliderRowView.swift index caf264af6..aaafd539e 100644 --- a/openHAB/SwiftUI/Rows/SliderRowView.swift +++ b/openHAB/SwiftUI/Rows/SliderRowView.swift @@ -14,6 +14,7 @@ import OpenHABCore import SFSafeSymbols import SwiftUI +// swiftlint:disable:next file_types_order struct SliderRowView: View { @ObservedObject var widget: OpenHABWidget var fallbackSymbol: SFSymbol? @@ -22,17 +23,28 @@ struct SliderRowView: View { /// Pending value while user is dragging; nil when not actively changing @State private var pendingValue: Double? + @State private var isEditing = false @State private var lastSendTime: Date = .distantPast private var sliderRange: ClosedRange { widget.minValue ... widget.maxValue } + private var currentValue: Double { + pendingValue ?? widget.adjustedValue + } + + private var currentValueText: String { + currentValue.valueText(step: widget.step) + } + private var valueBinding: Binding { Binding( get: { pendingValue ?? widget.adjustedValue }, set: { newValue in pendingValue = newValue + + // Send updates during drag if enabled (throttled) if widget.shouldUseSliderUpdatesDuringMove() { let now = Date() if now.timeIntervalSince(lastSendTime) > 0.2 { @@ -40,16 +52,6 @@ struct SliderRowView: View { lastSendTime = now } } - // Debounce: clear pending value after delay if no new updates - Task { @MainActor in - try? await Task.sleep(for: .milliseconds(500)) - if pendingValue == newValue { - if !widget.shouldUseSliderUpdatesDuringMove() { - sendSliderUpdate(newValue) - } - pendingValue = nil - } - } } ) } @@ -58,7 +60,7 @@ struct SliderRowView: View { HStack { if widget.switchSupport { Button { - viewModel.sendCommand(widget.item, commandToSend: (pendingValue ?? widget.adjustedValue) <= widget.minValue ? "ON" : "OFF") + viewModel.sendCommand(widget.item, commandToSend: currentValue <= widget.minValue ? "ON" : "OFF") } label: { labelContent } @@ -68,8 +70,34 @@ struct SliderRowView: View { labelContent } - Slider(value: valueBinding, in: sliderRange) - .disabled(widget.readOnly ?? false) + Slider(value: valueBinding, in: sliderRange) { editing in + isEditing = editing + if !editing { + // User released slider - send final value for release-only mode + if !widget.shouldUseSliderUpdatesDuringMove(), let value = pendingValue { + sendSliderUpdate(value) + } + // Keep pendingValue set until server responds to avoid visual jump + // Fallback: clear after delay if server doesn't respond + Task { @MainActor in + try? await Task.sleep(for: .milliseconds(2000)) + if !isEditing, pendingValue != nil { + pendingValue = nil + } + } + } + } + .disabled(widget.readOnly ?? false) + } + .onChange(of: widget.adjustedValue) { _ in + // Clear pending value when server responds (and user is not editing) + if !isEditing { + pendingValue = nil + } + } + .onAppear { + pendingValue = nil + isEditing = false } } @@ -88,12 +116,11 @@ struct SliderRowView: View { Spacer() - if let detailTextLabel = widget.labelValue, !detailTextLabel.isEmpty { - Text(detailTextLabel) - .font(.callout) - .foregroundStyle(widget.valuecolor.isEmpty ? Color(uiColor: UIColor.ohSecondaryLabel) : Color(fromString: widget.valuecolor)) - .lineLimit(1) - } + // Show current slider value (pendingValue while dragging, otherwise widget value) + Text(pendingValue != nil ? currentValueText : (widget.labelValue ?? currentValueText)) + .font(.callout) + .foregroundStyle(widget.valuecolor.isEmpty ? Color(uiColor: UIColor.ohSecondaryLabel) : Color(fromString: widget.valuecolor)) + .lineLimit(1) } .contentShape(Rectangle()) } diff --git a/openHABWatch/Views/Rows/SliderRow.swift b/openHABWatch/Views/Rows/SliderRow.swift index 5f3d1e32e..234d879dc 100644 --- a/openHABWatch/Views/Rows/SliderRow.swift +++ b/openHABWatch/Views/Rows/SliderRow.swift @@ -20,6 +20,15 @@ struct SliderRow: View { @EnvironmentObject var settings: AppSettings var fallbackSymbol: SFSymbol? @State private var pendingValue: Double? + + private var currentValue: Double { + pendingValue ?? widget.adjustedValue + } + + private var currentValueText: String { + currentValue.valueText(step: widget.step) + } + var valueBinding: Binding { .init( get: { @@ -64,7 +73,13 @@ struct SliderRow: View { IconView(widget: widget, settings: settings, fallbackSymbol: fallbackSymbol) VStack(alignment: .leading) { TextLabelView(widget: widget, font: .caption, lineLimit: 2) - DetailTextLabelView(widget: widget) + if pendingValue != nil { + Text(currentValueText) + .font(.caption2) + .foregroundStyle(.secondary) + } else { + DetailTextLabelView(widget: widget) + } } } } @@ -75,7 +90,13 @@ struct SliderRow: View { IconView(widget: widget, settings: settings, fallbackSymbol: fallbackSymbol) TextLabelView(widget: widget, font: .caption, lineLimit: 2) Spacer() - DetailTextLabelView(widget: widget) + if pendingValue != nil { + Text(currentValueText) + .font(.caption2) + .foregroundStyle(.secondary) + } else { + DetailTextLabelView(widget: widget) + } }.padding(.top, 8) } From 872fa01ad55321bab9200e9b2c7e87db0824a0ef Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Tue, 3 Feb 2026 20:49:27 +0100 Subject: [PATCH 392/476] Wrap PreviewConstants in DEBUG and fix preview labels - Wrap PreviewConstants in #if DEBUG to exclude from release builds - Fix SegmentedRowView preview detail labels to use numeric values Signed-off-by: Tim Mueller-Seydlitz --- CommonUI/Sources/CommonUI/PreviewConstants.swift | 2 ++ openHAB.xcodeproj/project.pbxproj | 8 ++++---- openHAB/SwiftUI/Rows/SegmentedRowView.swift | 12 ++++++------ 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/CommonUI/Sources/CommonUI/PreviewConstants.swift b/CommonUI/Sources/CommonUI/PreviewConstants.swift index f5c02123d..bda1d6366 100644 --- a/CommonUI/Sources/CommonUI/PreviewConstants.swift +++ b/CommonUI/Sources/CommonUI/PreviewConstants.swift @@ -13,6 +13,7 @@ import Foundation import OpenHABCore import os.log +#if DEBUG // swiftlint:disable type_body_length public enum PreviewConstants { public static let logger = Logger(subsystem: "org.openhab", category: "PreviewConstants") @@ -579,3 +580,4 @@ public enum PreviewConstants { } // swiftlint:enable type_body_length +#endif diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index 041e243ad..6c8566a24 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 10472F7AF99C4940A6144817 /* TextRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 399449C421544C61AD83450C /* TextRow.swift */; }; 1224F78F228A89FD00750965 /* WatchMessageService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1224F78D228A89FC00750965 /* WatchMessageService.swift */; }; 2F08AFC72E5FADCF00E70611 /* NotificationCenterDelegateImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F08AFC62E5FADC500E70611 /* NotificationCenterDelegateImpl.swift */; }; 2F55E7BB2DEE447700EC8350 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F55E7BA2DEE447700EC8350 /* SettingsView.swift */; }; @@ -72,6 +73,7 @@ 93F8065027AE7A830035A6B0 /* SideMenu in Frameworks */ = {isa = PBXBuildFile; productRef = 93F8064F27AE7A830035A6B0 /* SideMenu */; }; A3F4C3A51A49A5940019A09F /* MainLaunchScreen.xib in Resources */ = {isa = PBXBuildFile; fileRef = A3F4C3A41A49A5940019A09F /* MainLaunchScreen.xib */; }; B7D5ECE121499E55001B0EC6 /* MapViewTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7D5ECE021499E55001B0EC6 /* MapViewTableViewCell.swift */; }; + C4377202F7D642B5A8349008 /* SelectionRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = D86D8BF295C448039B2B85EB /* SelectionRow.swift */; }; DA0749DE23E0B5950057FA83 /* ColorPickerRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA0749DD23E0B5950057FA83 /* ColorPickerRow.swift */; }; DA0749E023E0BF510057FA83 /* ColorSelection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA0749DF23E0BF510057FA83 /* ColorSelection.swift */; }; DA07751B2346705F0086C685 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DA07751A2346705F0086C685 /* Assets.xcassets */; }; @@ -189,12 +191,10 @@ DAF4578223D630C70018B495 /* IconView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF4578123D630C70018B495 /* IconView.swift */; }; DAF4578923D79AA50018B495 /* DetailTextLabelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF4578823D79AA50018B495 /* DetailTextLabelView.swift */; }; DAF457A023DA3E1C0018B495 /* SegmentRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF4579F23DA3E1C0018B495 /* SegmentRow.swift */; }; - C4377202F7D642B5A8349008 /* SelectionRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = D86D8BF295C448039B2B85EB /* SelectionRow.swift */; }; DAF457A223DB6C640018B495 /* RollershutterRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF457A123DB6C640018B495 /* RollershutterRow.swift */; }; DAF457A623DB9CE00018B495 /* SetpointRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF457A523DB9CE00018B495 /* SetpointRow.swift */; }; DAF457A923DBA4990018B495 /* FrameRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF457A823DBA4990018B495 /* FrameRow.swift */; }; DAF4581623DC48400018B495 /* GenericRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF4581523DC483F0018B495 /* GenericRow.swift */; }; - 10472F7AF99C4940A6144817 /* TextRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 399449C421544C61AD83450C /* TextRow.swift */; }; DAF4581823DC4A050018B495 /* ImageRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF4581723DC4A050018B495 /* ImageRow.swift */; }; DAF4581E23DC60020018B495 /* ImageRawRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF4581D23DC60020018B495 /* ImageRawRow.swift */; }; DAF4F6C0222734D300C24876 /* NewImageUITableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF4F6BF222734D200C24876 /* NewImageUITableViewCell.swift */; }; @@ -325,6 +325,7 @@ 2FBCF58B2DEB0B7700CD5D83 /* HomeSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeSelectionView.swift; sourceTree = ""; }; 2FEFD8F52BE7C5BE00E387B9 /* TextInputUITableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextInputUITableViewCell.swift; sourceTree = ""; }; 2FF459352E230C6A00C0B640 /* OpenHABIntentHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenHABIntentHelper.swift; sourceTree = ""; }; + 399449C421544C61AD83450C /* TextRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextRow.swift; sourceTree = ""; }; 4D38D951256897490039DA6E /* SetNumberValueIntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetNumberValueIntentHandler.swift; sourceTree = ""; }; 4D38D959256897770039DA6E /* SetStringValueIntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetStringValueIntentHandler.swift; sourceTree = ""; }; 4D38D9612568978E0039DA6E /* SetColorValueIntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetColorValueIntentHandler.swift; sourceTree = ""; }; @@ -389,6 +390,7 @@ 938EDCE022C4FEB800661CA1 /* ScaleAspectFitImageView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScaleAspectFitImageView.swift; sourceTree = ""; }; A3F4C3A41A49A5940019A09F /* MainLaunchScreen.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; name = MainLaunchScreen.xib; path = ../MainLaunchScreen.xib; sourceTree = ""; }; B7D5ECE021499E55001B0EC6 /* MapViewTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapViewTableViewCell.swift; sourceTree = ""; }; + D86D8BF295C448039B2B85EB /* SelectionRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectionRow.swift; sourceTree = ""; }; DA0749DD23E0B5950057FA83 /* ColorPickerRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorPickerRow.swift; sourceTree = ""; }; DA0749DF23E0BF510057FA83 /* ColorSelection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorSelection.swift; sourceTree = ""; }; DA0775152346705D0086C685 /* openHABWatch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = openHABWatch.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -547,12 +549,10 @@ DAF4578123D630C70018B495 /* IconView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconView.swift; sourceTree = ""; }; DAF4578823D79AA50018B495 /* DetailTextLabelView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DetailTextLabelView.swift; sourceTree = ""; }; DAF4579F23DA3E1C0018B495 /* SegmentRow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SegmentRow.swift; sourceTree = ""; }; - D86D8BF295C448039B2B85EB /* SelectionRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectionRow.swift; sourceTree = ""; }; DAF457A123DB6C640018B495 /* RollershutterRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RollershutterRow.swift; sourceTree = ""; }; DAF457A523DB9CE00018B495 /* SetpointRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetpointRow.swift; sourceTree = ""; }; DAF457A823DBA4990018B495 /* FrameRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrameRow.swift; sourceTree = ""; }; DAF4581523DC483F0018B495 /* GenericRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GenericRow.swift; sourceTree = ""; }; - 399449C421544C61AD83450C /* TextRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextRow.swift; sourceTree = ""; }; DAF4581723DC4A050018B495 /* ImageRow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageRow.swift; sourceTree = ""; }; DAF4581D23DC60020018B495 /* ImageRawRow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageRawRow.swift; sourceTree = ""; }; DAF4F6BF222734D200C24876 /* NewImageUITableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewImageUITableViewCell.swift; sourceTree = ""; }; diff --git a/openHAB/SwiftUI/Rows/SegmentedRowView.swift b/openHAB/SwiftUI/Rows/SegmentedRowView.swift index ac0d308a8..f81c62817 100644 --- a/openHAB/SwiftUI/Rows/SegmentedRowView.swift +++ b/openHAB/SwiftUI/Rows/SegmentedRowView.swift @@ -286,7 +286,7 @@ private extension SegmentedRowView { SegmentedRowView( widget: SegmentedRowView.createPreviewWidget( label: "Light Switch", - detailLabel: "ON", + detailLabel: "1", mappings: [ OpenHABWidgetMapping(command: "ON", label: "ON"), OpenHABWidgetMapping(command: "OFF", label: "OFF") @@ -321,7 +321,7 @@ private extension SegmentedRowView { SegmentedRowView( widget: SegmentedRowView.createPreviewWidget( label: "Temperature Control", - detailLabel: "Automatic", + detailLabel: "3", mappings: [ OpenHABWidgetMapping(command: "manual", label: "Manual"), OpenHABWidgetMapping(command: "calendar", label: "Calendar"), @@ -339,7 +339,7 @@ private extension SegmentedRowView { SegmentedRowView( widget: SegmentedRowView.createPreviewWidget( label: "Fan Speed", - detailLabel: "High", + detailLabel: "4", mappings: [ OpenHABWidgetMapping(command: "0", label: "Off"), OpenHABWidgetMapping(command: "1", label: "Low"), @@ -374,7 +374,7 @@ private extension SegmentedRowView { SegmentedRowView( widget: SegmentedRowView.createPreviewWidget( label: "Light", - detailLabel: "ON", + detailLabel: "1", mappings: [ OpenHABWidgetMapping(command: "ON", label: "ON"), OpenHABWidgetMapping(command: "OFF", label: "OFF") @@ -386,7 +386,7 @@ private extension SegmentedRowView { SegmentedRowView( widget: SegmentedRowView.createPreviewWidget( label: "Climate Mode", - detailLabel: "Auto", + detailLabel: "2", mappings: [ OpenHABWidgetMapping(command: "m", label: "Manual"), OpenHABWidgetMapping(command: "a", label: "Auto"), @@ -399,7 +399,7 @@ private extension SegmentedRowView { SegmentedRowView( widget: SegmentedRowView.createPreviewWidget( label: "Fan Speed", - detailLabel: "Medium", + detailLabel: "2", mappings: [ OpenHABWidgetMapping(command: "0", label: "Off"), OpenHABWidgetMapping(command: "1", label: "Low"), From 6626e9d71bff635ddd09c355f07d824cb1e11ca5 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Tue, 3 Feb 2026 22:35:49 +0100 Subject: [PATCH 393/476] Style pressReleaseButtons to match segmentedButtons - Apply consistent styling with rounded background and border - Add pressed indicator on each button - Use equal spacing and minimum width for buttons Signed-off-by: Tim Mueller-Seydlitz --- openHAB/SwiftUI/Rows/SegmentedRowView.swift | 57 ++++++++++++++------- 1 file changed, 39 insertions(+), 18 deletions(-) diff --git a/openHAB/SwiftUI/Rows/SegmentedRowView.swift b/openHAB/SwiftUI/Rows/SegmentedRowView.swift index f81c62817..db07e1fe8 100644 --- a/openHAB/SwiftUI/Rows/SegmentedRowView.swift +++ b/openHAB/SwiftUI/Rows/SegmentedRowView.swift @@ -47,8 +47,9 @@ struct SegmentedRowView: View { .layoutPriority(1) } + Spacer(minLength: 8) + if let detailTextLabel = widget.labelValue, !detailTextLabel.isEmpty { - Spacer(minLength: 8) Text(detailTextLabel) .foregroundStyle(widget.valuecolor.isEmpty ? Color(uiColor: UIColor.ohSecondaryLabel) : Color(fromString: widget.valuecolor)) .lineLimit(1) @@ -60,16 +61,13 @@ struct SegmentedRowView: View { if widget.hasPressReleaseMappings { // Press-release buttons for mappings with releaseCommand pressReleaseButtons - .padding(.leading, 8) .fixedSize(horizontal: true, vertical: false) } else if mappings.count == 1 { singleMappingButton - .padding(.leading, 8) .fixedSize(horizontal: true, vertical: false) } else { // Button-based segmented control with animated selection indicator segmentedButtons - .padding(.leading, 8) .frame(minWidth: 75) .layoutPriority(1) } @@ -124,12 +122,24 @@ struct SegmentedRowView: View { @ViewBuilder private var pressReleaseButtons: some View { - HStack { + HStack(spacing: 2) { ForEach(mappings.indices, id: \.self) { index in - let mapping = mappings[index] - pressReleaseButton(for: mapping, at: index) + pressReleaseButton(for: mappings[index], at: index) } } + .padding(2) + .background( + RoundedRectangle(cornerRadius: 7) + .fill( + colorScheme == .dark + ? Color(uiColor: .tertiarySystemBackground) + : Color(uiColor: .secondarySystemBackground) + ) + ) + .overlay( + RoundedRectangle(cornerRadius: 7) + .stroke(Color.secondary.opacity(0.3), lineWidth: 0.5) + ) } @ViewBuilder @@ -193,14 +203,25 @@ struct SegmentedRowView: View { @ViewBuilder private func pressReleaseButton(for mapping: OpenHABWidgetMapping, at index: Int) -> some View { + let isPressed = pressedIndex == index Text(mapping.label) - .padding(.horizontal, 10) - .padding(.vertical, 6) + .font(.footnote) + .bold() + .lineLimit(1) + .truncationMode(.tail) + .padding(.horizontal, 8) + .padding(.vertical, 5) + .frame(minWidth: 50) + .foregroundStyle(.primary) .background( - RoundedRectangle(cornerRadius: 8) - .fill(pressedIndex == index ? Color(uiColor: .systemGray3) : Color(uiColor: .systemGray5)) + RoundedRectangle(cornerRadius: 6) + .fill( + isPressed + ? (colorScheme == .dark ? Color(uiColor: .systemGray2) : Color(uiColor: .systemBackground)) + : Color.clear + ) ) - .foregroundStyle(.primary) + .contentShape(Rectangle()) .gesture( DragGesture(minimumDistance: 0) .onChanged { _ in @@ -353,18 +374,18 @@ private extension SegmentedRowView { } } -#Preview("Single Mapping") { +#Preview("PressRelease") { PreviewList { SegmentedRowView( widget: SegmentedRowView.createPreviewWidget( - label: "Scene", - detailLabel: "Movie Night", + label: "All Shutters", + detailLabel: "NA", mappings: [ - OpenHABWidgetMapping(command: "PLAY", label: "Run") + OpenHABWidgetMapping(command: "DOWN", label: "DOWN", releaseCommand: "OFF") ], - selectedState: "PLAY" + selectedState: "DOWN" ), - fallbackSymbol: .theatermasksFill + fallbackSymbol: .romanShadeClosed ) } } From 31f032442b414ef43017d9e1b2b18b01428f4f2e Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Wed, 4 Feb 2026 07:51:36 +0100 Subject: [PATCH 394/476] Unify singleMappingButton and pressReleaseButton styling - Style singleMappingButton as momentary press button - Match height, width, and indicator styling with segmentedButtons - Set common minWidth on both single and pressRelease containers - Add all widget types to All Scenarios preview Signed-off-by: Tim Mueller-Seydlitz --- openHAB/SwiftUI/Rows/SegmentedRowView.swift | 133 +++++++++++++++----- 1 file changed, 100 insertions(+), 33 deletions(-) diff --git a/openHAB/SwiftUI/Rows/SegmentedRowView.swift b/openHAB/SwiftUI/Rows/SegmentedRowView.swift index db07e1fe8..5e2e00c85 100644 --- a/openHAB/SwiftUI/Rows/SegmentedRowView.swift +++ b/openHAB/SwiftUI/Rows/SegmentedRowView.swift @@ -47,9 +47,8 @@ struct SegmentedRowView: View { .layoutPriority(1) } - Spacer(minLength: 8) - if let detailTextLabel = widget.labelValue, !detailTextLabel.isEmpty { + Spacer(minLength: 8) Text(detailTextLabel) .foregroundStyle(widget.valuecolor.isEmpty ? Color(uiColor: UIColor.ohSecondaryLabel) : Color(fromString: widget.valuecolor)) .lineLimit(1) @@ -59,15 +58,26 @@ struct SegmentedRowView: View { if !mappings.isEmpty { if widget.hasPressReleaseMappings { + if !(widget.labelValue?.isEmpty == false) { + Spacer(minLength: 8) + } // Press-release buttons for mappings with releaseCommand pressReleaseButtons + .frame(minWidth: 80) + .padding(.leading, 8) .fixedSize(horizontal: true, vertical: false) } else if mappings.count == 1 { + if !(widget.labelValue?.isEmpty == false) { + Spacer(minLength: 8) + } singleMappingButton + .frame(minWidth: 80) + .padding(.leading, 8) .fixedSize(horizontal: true, vertical: false) } else { // Button-based segmented control with animated selection indicator segmentedButtons + .padding(.leading, 8) .frame(minWidth: 75) .layoutPriority(1) } @@ -142,37 +152,56 @@ struct SegmentedRowView: View { ) } + @State private var singleButtonPressed = false + @ViewBuilder private var singleMappingButton: some View { let mapping = mappings[0] - let isSelected = selectedIndex == 0 - Button { - logger.info("Segment tapped: 0, command: \(mapping.command)") - withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { - selectedIndex = 0 - } - viewModel.sendCommand(widget.item, commandToSend: mapping.command) - } label: { - Text(mapping.label) - .font(.footnote) - .bold() - .lineLimit(1) - .truncationMode(.tail) - .padding(.horizontal, 8) - .padding(.vertical, 5) - .frame(minWidth: 30, maxWidth: 120) - .foregroundStyle(.primary) - } - .background( - RoundedRectangle(cornerRadius: 7) - .fill(Color(uiColor: isSelected ? .systemBackground : .secondarySystemBackground)) - ) - .overlay( - RoundedRectangle(cornerRadius: 7) - .stroke(Color(uiColor: .separator).opacity(0.6), lineWidth: 0.75) - ) - .buttonStyle(.plain) + Text(mapping.label) + .font(.footnote) + .bold() + .lineLimit(1) + .truncationMode(.tail) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .frame(minWidth: 50) + .foregroundStyle(.primary) + .background( + RoundedRectangle(cornerRadius: 6) + .fill( + singleButtonPressed + ? (colorScheme == .dark ? Color(uiColor: .systemGray2) : Color(uiColor: .systemBackground)) + : Color.clear + ) + ) + .contentShape(Rectangle()) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { _ in + if !singleButtonPressed { + singleButtonPressed = true + } + } + .onEnded { _ in + singleButtonPressed = false + logger.info("Segment tapped: 0, command: \(mapping.command)") + viewModel.sendCommand(widget.item, commandToSend: mapping.command) + } + ) + .padding(2) + .background( + RoundedRectangle(cornerRadius: 7) + .fill( + colorScheme == .dark + ? Color(uiColor: .tertiarySystemBackground) + : Color(uiColor: .secondarySystemBackground) + ) + ) + .overlay( + RoundedRectangle(cornerRadius: 7) + .stroke(Color.secondary.opacity(0.3), lineWidth: 0.5) + ) } // MARK: - Helper Methods @@ -193,7 +222,6 @@ struct SegmentedRowView: View { .bold() .lineLimit(1) .truncationMode(.tail) - .padding(.horizontal, 8) .padding(.vertical, 5) .frame(minWidth: 30, maxWidth: 120) .foregroundStyle(.primary) @@ -210,7 +238,7 @@ struct SegmentedRowView: View { .lineLimit(1) .truncationMode(.tail) .padding(.horizontal, 8) - .padding(.vertical, 5) + .padding(.vertical, 3) .frame(minWidth: 50) .foregroundStyle(.primary) .background( @@ -390,6 +418,21 @@ private extension SegmentedRowView { } } +#Preview("Single Mapping") { + PreviewList { + SegmentedRowView( + widget: SegmentedRowView.createPreviewWidget( + label: "Scene", + mappings: [ + OpenHABWidgetMapping(command: "RUN", label: "Run") + ], + selectedState: "RUN" + ), + fallbackSymbol: .theatermasksFill + ) + } +} + #Preview("All Scenarios") { PreviewList { SegmentedRowView( @@ -431,13 +474,37 @@ private extension SegmentedRowView { ), fallbackSymbol: .fanOscillation ) + SegmentedRowView( + widget: SegmentedRowView.createPreviewWidget( + label: "Charts Period", + mappings: [ + OpenHABWidgetMapping(command: "D", label: "Day"), + OpenHABWidgetMapping(command: "W", label: "Week"), + OpenHABWidgetMapping(command: "M", label: "M"), + OpenHABWidgetMapping(command: "4h", label: "4h") + ], + selectedState: "D" + ), + fallbackSymbol: .chartBarFill + ) + SegmentedRowView( + widget: SegmentedRowView.createPreviewWidget( + label: "All Shutters", + detailLabel: "NA", + mappings: [ + OpenHABWidgetMapping(command: "DOWN", label: "DOWN", releaseCommand: "OFF") + ], + selectedState: "DOWN" + ), + fallbackSymbol: .romanShadeClosed + ) SegmentedRowView( widget: SegmentedRowView.createPreviewWidget( label: "Scene", mappings: [ - OpenHABWidgetMapping(command: "RUN", label: "Run") + OpenHABWidgetMapping(command: "RUN", label: "DOWN") ], - selectedState: "RUN" + selectedState: "DOWN" ), fallbackSymbol: .theatermasksFill ) From 233e73a2585a54fab0a748ffc86f1271db43b41d Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Wed, 4 Feb 2026 08:19:55 +0100 Subject: [PATCH 395/476] Align single mapping and pressRelease buttons to trailing edge - Use trailing-aligned frame for single mapping button - Use fixedSize with leading padding for pressRelease buttons Signed-off-by: Tim Mueller-Seydlitz --- openHAB/SwiftUI/Rows/SegmentedRowView.swift | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/openHAB/SwiftUI/Rows/SegmentedRowView.swift b/openHAB/SwiftUI/Rows/SegmentedRowView.swift index 5e2e00c85..385f53e12 100644 --- a/openHAB/SwiftUI/Rows/SegmentedRowView.swift +++ b/openHAB/SwiftUI/Rows/SegmentedRowView.swift @@ -58,27 +58,22 @@ struct SegmentedRowView: View { if !mappings.isEmpty { if widget.hasPressReleaseMappings { - if !(widget.labelValue?.isEmpty == false) { - Spacer(minLength: 8) - } // Press-release buttons for mappings with releaseCommand pressReleaseButtons - .frame(minWidth: 80) - .padding(.leading, 8) .fixedSize(horizontal: true, vertical: false) + .padding(.leading, 8) } else if mappings.count == 1 { - if !(widget.labelValue?.isEmpty == false) { - Spacer(minLength: 8) - } singleMappingButton - .frame(minWidth: 80) + .frame(maxWidth: .infinity, alignment: .trailing) .padding(.leading, 8) - .fixedSize(horizontal: true, vertical: false) } else { + if !(widget.labelValue?.isEmpty == false) { + Spacer(minLength: 8) + } // Button-based segmented control with animated selection indicator segmentedButtons - .padding(.leading, 8) .frame(minWidth: 75) + .padding(.leading, 8) .layoutPriority(1) } } From 5cd3cdcca31951c1cf77a5441f181d4411512426 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Wed, 4 Feb 2026 13:56:06 +0100 Subject: [PATCH 396/476] Add isNilOrEmpty extension and use in SegmentedRowView - Add isNilOrEmpty computed property on Optional - Replace verbose nil/empty checks in SegmentedRowView Signed-off-by: Tim Mueller-Seydlitz --- OpenHABCore/Sources/OpenHABCore/Util/StringExtension.swift | 4 ++++ openHAB/SwiftUI/Rows/SegmentedRowView.swift | 5 ++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/StringExtension.swift b/OpenHABCore/Sources/OpenHABCore/Util/StringExtension.swift index bb576e893..595bd2a64 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/StringExtension.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/StringExtension.swift @@ -184,6 +184,10 @@ public extension String? { "" } } + + var isNilOrEmpty: Bool { + self?.isEmpty ?? true + } } public extension String { diff --git a/openHAB/SwiftUI/Rows/SegmentedRowView.swift b/openHAB/SwiftUI/Rows/SegmentedRowView.swift index 385f53e12..495c75b9a 100644 --- a/openHAB/SwiftUI/Rows/SegmentedRowView.swift +++ b/openHAB/SwiftUI/Rows/SegmentedRowView.swift @@ -59,6 +59,9 @@ struct SegmentedRowView: View { if !mappings.isEmpty { if widget.hasPressReleaseMappings { // Press-release buttons for mappings with releaseCommand + if widget.labelValue.isNilOrEmpty { + Spacer(minLength: 8) + } pressReleaseButtons .fixedSize(horizontal: true, vertical: false) .padding(.leading, 8) @@ -67,7 +70,7 @@ struct SegmentedRowView: View { .frame(maxWidth: .infinity, alignment: .trailing) .padding(.leading, 8) } else { - if !(widget.labelValue?.isEmpty == false) { + if widget.labelValue.isNilOrEmpty { Spacer(minLength: 8) } // Button-based segmented control with animated selection indicator From e29078cc49ff29a69f1e90837c89f216af8321d4 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Wed, 4 Feb 2026 14:15:43 +0100 Subject: [PATCH 397/476] Simplify isNilOrEmpty implementation Signed-off-by: Tim Mueller-Seydlitz --- OpenHABCore/Sources/OpenHABCore/Util/StringExtension.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/StringExtension.swift b/OpenHABCore/Sources/OpenHABCore/Util/StringExtension.swift index 595bd2a64..15f8528e8 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/StringExtension.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/StringExtension.swift @@ -186,7 +186,7 @@ public extension String? { } var isNilOrEmpty: Bool { - self?.isEmpty ?? true + self == nil || self == "" } } From b92fcb7a50a5ad0b7d029a2b465e1f5e97d9c600 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Wed, 4 Feb 2026 20:51:42 +0100 Subject: [PATCH 398/476] Redo preferences storage with JSON --- .../OpenHABCore/Util/Preferences.swift | 546 +++++++++++------- .../OpenHABCoreTests/UserDefaultsTests.swift | 61 +- 2 files changed, 403 insertions(+), 204 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift index 5e5ecc9e0..628533cfd 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift @@ -13,82 +13,346 @@ import os.log import UIKit -@MainActor private let sharedDefaults = UserDefaults(suiteName: "group.org.openhab.app")! -@MainActor @propertyWrapper -public struct UserDefault { - private let key: String - private let defaultValue: T - private let isHomeProperty: Bool +private struct Preference { + private let keyPath: WritableKeyPath private let subject: CurrentValueSubject - public var wrappedValue: T { + var wrappedValue: T { get { - PreferencesAccess.getPreference(key: key, defaultValue: defaultValue, encoder: { $0 }, decoder: { $0 as? T }) + PreferencesStoreAccess.shared.value(for: keyPath) } set { - PreferencesAccess.preferenceChanged(newValue: newValue, key: key, isHomeProperty: isHomeProperty, subject: subject) { $0 } + PreferencesStoreAccess.shared.setValue(newValue, for: keyPath) + subject.send(newValue) } } - public var projectedValue: AnyPublisher { + var projectedValue: AnyPublisher { subject.eraseToAnyPublisher() } - public init(_ key: String, defaultValue: T, isHomeProperty: Bool = false) { - self.key = key - self.defaultValue = defaultValue - self.isHomeProperty = isHomeProperty - let currentValue = PreferencesAccess.getPreference(key: key, defaultValue: defaultValue, encoder: { $0 }, decoder: { $0 as? T }) + init(_ keyPath: WritableKeyPath) { + self.keyPath = keyPath + let currentValue = PreferencesStoreAccess.shared.value(for: keyPath) subject = CurrentValueSubject(currentValue) } } -@MainActor -@propertyWrapper -public struct UserDefaultObject { - private let key: String - private let defaultValue: T - private let isHomeProperty: Bool - private let subject: CurrentValueSubject +private struct PreferencesStore: Codable, Sendable, Equatable { + static let currentVersion = 1 + + var version: Int + var currentHomePreferences: HomePreferences + var storedHomes: [UUID: HomePreferences] + var activeHomeId: UUID + var applicationPreferences: ApplicationPreferences + var sendCrashReports: Bool + var idleOff: Bool + var screensaverEnabled: Bool + var screensaverShowsTime: Bool + var screensaverShowsDate: Bool + var screensaverIdleInterval: Double + var screensaverMovementInterval: Double + var screensaverFontName: String + var screensaverTimeFontRatio: Double + var screensaverDateFontRatio: Double + var screensaverEnableDimming: Bool + var screensaverDimLevel: Double + var screensaverShowsSeconds: Bool + var screensaverUse24Hour: Bool + var screensaverFadeDuration: Double + var screensaverRestoreBrightness: Bool + var screensaverWakeBrightness: Double + var hideStatusBar: Bool + var currentWebViewPath: String + + private enum CodingKeys: String, CodingKey { + case version + case currentHomePreferences + case storedHomes + case activeHomeId + case applicationPreferences + case sendCrashReports + case idleOff + case screensaverEnabled + case screensaverShowsTime + case screensaverShowsDate + case screensaverIdleInterval + case screensaverMovementInterval + case screensaverFontName + case screensaverTimeFontRatio + case screensaverDateFontRatio + case screensaverEnableDimming + case screensaverDimLevel + case screensaverShowsSeconds + case screensaverUse24Hour + case screensaverFadeDuration + case screensaverRestoreBrightness + case screensaverWakeBrightness + case hideStatusBar + case currentWebViewPath + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let defaults = PreferencesStore.defaultStore() + version = try container.decodeIfPresent(Int.self, forKey: .version) ?? defaults.version + currentHomePreferences = try container.decodeIfPresent(HomePreferences.self, forKey: .currentHomePreferences) ?? defaults.currentHomePreferences + storedHomes = try container.decodeIfPresent([UUID: HomePreferences].self, forKey: .storedHomes) ?? defaults.storedHomes + activeHomeId = try container.decodeIfPresent(UUID.self, forKey: .activeHomeId) ?? defaults.activeHomeId + applicationPreferences = try container.decodeIfPresent(ApplicationPreferences.self, forKey: .applicationPreferences) ?? defaults.applicationPreferences + sendCrashReports = try container.decodeIfPresent(Bool.self, forKey: .sendCrashReports) ?? defaults.sendCrashReports + idleOff = try container.decodeIfPresent(Bool.self, forKey: .idleOff) ?? defaults.idleOff + screensaverEnabled = try container.decodeIfPresent(Bool.self, forKey: .screensaverEnabled) ?? defaults.screensaverEnabled + screensaverShowsTime = try container.decodeIfPresent(Bool.self, forKey: .screensaverShowsTime) ?? defaults.screensaverShowsTime + screensaverShowsDate = try container.decodeIfPresent(Bool.self, forKey: .screensaverShowsDate) ?? defaults.screensaverShowsDate + screensaverIdleInterval = try container.decodeIfPresent(Double.self, forKey: .screensaverIdleInterval) ?? defaults.screensaverIdleInterval + screensaverMovementInterval = try container.decodeIfPresent(Double.self, forKey: .screensaverMovementInterval) ?? defaults.screensaverMovementInterval + screensaverFontName = try container.decodeIfPresent(String.self, forKey: .screensaverFontName) ?? defaults.screensaverFontName + screensaverTimeFontRatio = try container.decodeIfPresent(Double.self, forKey: .screensaverTimeFontRatio) ?? defaults.screensaverTimeFontRatio + screensaverDateFontRatio = try container.decodeIfPresent(Double.self, forKey: .screensaverDateFontRatio) ?? defaults.screensaverDateFontRatio + screensaverEnableDimming = try container.decodeIfPresent(Bool.self, forKey: .screensaverEnableDimming) ?? defaults.screensaverEnableDimming + screensaverDimLevel = try container.decodeIfPresent(Double.self, forKey: .screensaverDimLevel) ?? defaults.screensaverDimLevel + screensaverShowsSeconds = try container.decodeIfPresent(Bool.self, forKey: .screensaverShowsSeconds) ?? defaults.screensaverShowsSeconds + screensaverUse24Hour = try container.decodeIfPresent(Bool.self, forKey: .screensaverUse24Hour) ?? defaults.screensaverUse24Hour + screensaverFadeDuration = try container.decodeIfPresent(Double.self, forKey: .screensaverFadeDuration) ?? defaults.screensaverFadeDuration + screensaverRestoreBrightness = try container.decodeIfPresent(Bool.self, forKey: .screensaverRestoreBrightness) ?? defaults.screensaverRestoreBrightness + screensaverWakeBrightness = try container.decodeIfPresent(Double.self, forKey: .screensaverWakeBrightness) ?? defaults.screensaverWakeBrightness + hideStatusBar = try container.decodeIfPresent(Bool.self, forKey: .hideStatusBar) ?? defaults.hideStatusBar + currentWebViewPath = try container.decodeIfPresent(String.self, forKey: .currentWebViewPath) ?? defaults.currentWebViewPath + } + + static func defaultStore() -> PreferencesStore { + let homeId = UUID() + let home = HomePreferences(id: homeId) + return PreferencesStore( + version: currentVersion, + currentHomePreferences: home, + storedHomes: [homeId: home], + activeHomeId: homeId, + applicationPreferences: ApplicationPreferences(), + sendCrashReports: false, + idleOff: false, + screensaverEnabled: false, + screensaverShowsTime: true, + screensaverShowsDate: true, + screensaverIdleInterval: 120.0, + screensaverMovementInterval: 8.0, + screensaverFontName: "", + screensaverTimeFontRatio: 0.2, + screensaverDateFontRatio: 0.4, + screensaverEnableDimming: true, + screensaverDimLevel: 0.3, + screensaverShowsSeconds: false, + screensaverUse24Hour: false, + screensaverFadeDuration: 2.0, + screensaverRestoreBrightness: true, + screensaverWakeBrightness: 1.0, + hideStatusBar: false, + currentWebViewPath: "" + ) + } +} + +private final class PreferencesStoreAccess { + static let shared = PreferencesStoreAccess() + private static let storeKey = "preferencesStore" + private var store: PreferencesStore + + private init() { + store = Self.loadStore() + } + + func value(for keyPath: KeyPath) -> T { + store[keyPath: keyPath] + } - private let objectDecoder: (Any) -> (T?) = { - guard let data = $0 as? Data else { - return nil + func setValue(_ value: T, for keyPath: WritableKeyPath) { + store[keyPath: keyPath] = value + persistStore() + } + + static func migrateIfNeeded() { + _ = PreferencesStoreAccess.shared + } + + private static func loadStore() -> PreferencesStore { + if let data = sharedDefaults.data(forKey: storeKey) { + let decoder = JSONDecoder() + if let decoded = try? decoder.decode(PreferencesStore.self, from: data) { + if decoded.version == PreferencesStore.currentVersion { + return decoded + } + return migrateStore(decoded) + } else { + Logger.preferences.error("Preferences JSON failed to decode, falling back to legacy preferences.") + } } - return try? JSONDecoder().decode(T.self, from: data) + + let legacyStore = LegacyPreferencesMapper.loadStore() + persistStore(legacyStore) + return legacyStore + } + + private static func migrateStore(_ store: PreferencesStore) -> PreferencesStore { + var migrated = store + migrated.version = PreferencesStore.currentVersion + persistStore(migrated) + return migrated } - private let objectEncoder: (T) -> (any Sendable)? = { try? JSONEncoder().encode($0) } + private func persistStore() { + Self.persistStore(store) + } - public var wrappedValue: T { - get { - PreferencesAccess.getPreference(key: key, defaultValue: defaultValue, encoder: objectEncoder, decoder: objectDecoder) + private static func persistStore(_ store: PreferencesStore) { + let encoder = JSONEncoder() + guard let data = try? encoder.encode(store) else { + Logger.preferences.error("Failed to encode preferences JSON.") + return } - set { - PreferencesAccess.preferenceChanged(newValue: newValue, key: key, isHomeProperty: isHomeProperty, subject: subject, converter: objectEncoder) + sharedDefaults.set(data, forKey: storeKey) + } + + #if DEBUG + func reloadFromDefaultsForTesting() { + store = Self.loadStore() + } + #endif +} + +private enum LegacyPreferencesMapper { + static func loadStore() -> PreferencesStore { + let decoder = JSONDecoder() + var store = PreferencesStore.defaultStore() + + if let data = sharedDefaults.data(forKey: "currentHomePreferences"), + let decoded = try? decoder.decode(HomePreferences.self, from: data) { + store.currentHomePreferences = decoded + } + + if let data = sharedDefaults.data(forKey: "storedHomes"), + let decoded = try? decoder.decode([UUID: HomePreferences].self, from: data) { + store.storedHomes = decoded + } + + if let data = sharedDefaults.data(forKey: "activeHomeId"), + let decoded = try? decoder.decode(UUID.self, from: data) { + store.activeHomeId = decoded + } + + if let data = sharedDefaults.data(forKey: "applicationPreferences"), + let decoded = try? decoder.decode(ApplicationPreferences.self, from: data) { + store.applicationPreferences = decoded + } + + store.sendCrashReports = sharedDefaults.object(forKey: "sendCrashReports") as? Bool ?? store.sendCrashReports + store.idleOff = sharedDefaults.object(forKey: "idleOff") as? Bool ?? store.idleOff + store.screensaverEnabled = sharedDefaults.object(forKey: "screensaverEnabled") as? Bool ?? store.screensaverEnabled + store.screensaverShowsTime = sharedDefaults.object(forKey: "screensaverShowsTime") as? Bool ?? store.screensaverShowsTime + store.screensaverShowsDate = sharedDefaults.object(forKey: "screensaverShowsDate") as? Bool ?? store.screensaverShowsDate + store.screensaverIdleInterval = sharedDefaults.object(forKey: "screensaverIdleInterval") as? Double ?? store.screensaverIdleInterval + store.screensaverMovementInterval = sharedDefaults.object(forKey: "screensaverMovementInterval") as? Double ?? store.screensaverMovementInterval + store.screensaverFontName = sharedDefaults.string(forKey: "screensaverFontName") ?? store.screensaverFontName + store.screensaverTimeFontRatio = sharedDefaults.object(forKey: "screensaverTimeFontRatio") as? Double ?? store.screensaverTimeFontRatio + store.screensaverDateFontRatio = sharedDefaults.object(forKey: "screensaverDateFontRatio") as? Double ?? store.screensaverDateFontRatio + store.screensaverEnableDimming = sharedDefaults.object(forKey: "screensaverEnableDimming") as? Bool ?? store.screensaverEnableDimming + store.screensaverDimLevel = sharedDefaults.object(forKey: "screensaverDimLevel") as? Double ?? store.screensaverDimLevel + store.screensaverShowsSeconds = sharedDefaults.object(forKey: "screensaverShowsSeconds") as? Bool ?? store.screensaverShowsSeconds + store.screensaverUse24Hour = sharedDefaults.object(forKey: "screensaverUse24Hour") as? Bool ?? store.screensaverUse24Hour + store.screensaverFadeDuration = sharedDefaults.object(forKey: "screensaverFadeDuration") as? Double ?? store.screensaverFadeDuration + store.screensaverRestoreBrightness = sharedDefaults.object(forKey: "screensaverRestoreBrightness") as? Bool ?? store.screensaverRestoreBrightness + store.screensaverWakeBrightness = sharedDefaults.object(forKey: "screensaverWakeBrightness") as? Double ?? store.screensaverWakeBrightness + store.hideStatusBar = sharedDefaults.object(forKey: "hideStatusBar") as? Bool ?? store.hideStatusBar + store.currentWebViewPath = sharedDefaults.string(forKey: "currentWebViewPath") ?? store.currentWebViewPath + + let didMigrateToSharedDefaults = sharedDefaults.bool(forKey: "didMigrateToSharedDefaults") + let didMigrateToMultipleHomes = sharedDefaults.bool(forKey: "didMigrateToMultipleHomes") + + if !didMigrateToSharedDefaults { + applyStandardDefaultsMigration(to: &store) + sharedDefaults.set(true, forKey: "didMigrateToSharedDefaults") + sharedDefaults.set(true, forKey: "didMigrateToMultipleHomes") + } + + if !didMigrateToMultipleHomes { + applyMultipleHomesMigration(to: &store) + sharedDefaults.set(true, forKey: "didMigrateToMultipleHomes") + } + + if store.storedHomes.isEmpty { + store.storedHomes[store.currentHomePreferences.id] = store.currentHomePreferences } + + if store.storedHomes[store.activeHomeId] == nil { + store.storedHomes[store.activeHomeId] = store.currentHomePreferences + } + + if let storedHome = store.storedHomes[store.activeHomeId] { + store.currentHomePreferences = storedHome + } + + return store } - public var projectedValue: AnyPublisher { - subject.eraseToAnyPublisher() + private static func applyStandardDefaultsMigration(to store: inout PreferencesStore) { + let standardDefaults = UserDefaults.standard + store.currentHomePreferences.localConnectionConfig.url = standardDefaults.string(forKey: "localUrl") ?? store.currentHomePreferences.localConnectionConfig.url + store.currentHomePreferences.localConnectionConfig.alwaysSendBasicAuth = standardDefaults.object(forKey: "alwaysSendCreds") as? Bool ?? store.currentHomePreferences.localConnectionConfig.alwaysSendBasicAuth + store.currentHomePreferences.localConnectionConfig.ignoreSSL = standardDefaults.object(forKey: "ignoreSSL") as? Bool ?? store.currentHomePreferences.localConnectionConfig.ignoreSSL + store.currentHomePreferences.remoteConnectionConfig.url = standardDefaults.string(forKey: "remoteUrl") ?? store.currentHomePreferences.remoteConnectionConfig.url + store.currentHomePreferences.remoteConnectionConfig.username = standardDefaults.string(forKey: "username") ?? store.currentHomePreferences.remoteConnectionConfig.username + store.currentHomePreferences.remoteConnectionConfig.password = standardDefaults.string(forKey: "password") ?? store.currentHomePreferences.remoteConnectionConfig.password + store.currentHomePreferences.remoteConnectionConfig.alwaysSendBasicAuth = standardDefaults.object(forKey: "alwaysSendCreds") as? Bool ?? store.currentHomePreferences.remoteConnectionConfig.alwaysSendBasicAuth + store.currentHomePreferences.remoteConnectionConfig.ignoreSSL = standardDefaults.object(forKey: "ignoreSSL") as? Bool ?? store.currentHomePreferences.remoteConnectionConfig.ignoreSSL + store.currentHomePreferences.demomode = standardDefaults.object(forKey: "demomode") as? Bool ?? store.currentHomePreferences.demomode + store.currentHomePreferences.realTimeSliders = standardDefaults.object(forKey: "realTimeSliders") as? Bool ?? store.currentHomePreferences.realTimeSliders + store.currentHomePreferences.iconType = standardDefaults.object(forKey: "iconType") as? Int ?? store.currentHomePreferences.iconType + store.currentHomePreferences.defaultSitemap = standardDefaults.string(forKey: "defaultSitemap") ?? store.currentHomePreferences.defaultSitemap + + store.idleOff = standardDefaults.object(forKey: "idleOff") as? Bool ?? store.idleOff + store.sendCrashReports = standardDefaults.object(forKey: "sendCrashReports") as? Bool ?? store.sendCrashReports } - init(_ key: String, defaultValue: T, isHomeProperty: Bool = false) { - self.key = key - self.defaultValue = defaultValue - self.isHomeProperty = isHomeProperty + private static func applyMultipleHomesMigration(to store: inout PreferencesStore) { + let oldLocalUrl = sharedDefaults.string(forKey: "localUrl") + let oldRemoteUrl = sharedDefaults.string(forKey: "remoteUrl") + let oldUsername = sharedDefaults.string(forKey: "username") + let oldPassword = sharedDefaults.string(forKey: "password") + let oldAlwaysSendCreds = sharedDefaults.object(forKey: "alwaysSendCreds") as? Bool + let oldIgnoreSSL = sharedDefaults.object(forKey: "ignoreSSL") as? Bool + + var newLocalConfiguration = store.currentHomePreferences.localConnectionConfig + newLocalConfiguration.url = oldLocalUrl ?? newLocalConfiguration.url + newLocalConfiguration.alwaysSendBasicAuth = oldAlwaysSendCreds ?? newLocalConfiguration.alwaysSendBasicAuth + newLocalConfiguration.ignoreSSL = oldIgnoreSSL ?? newLocalConfiguration.ignoreSSL + + var newRemoteConfiguration = store.currentHomePreferences.remoteConnectionConfig + newRemoteConfiguration.url = oldRemoteUrl ?? newRemoteConfiguration.url + newRemoteConfiguration.username = oldUsername ?? newRemoteConfiguration.username + newRemoteConfiguration.password = oldPassword ?? newRemoteConfiguration.password + newRemoteConfiguration.alwaysSendBasicAuth = oldAlwaysSendCreds ?? newRemoteConfiguration.alwaysSendBasicAuth + newRemoteConfiguration.ignoreSSL = oldIgnoreSSL ?? newRemoteConfiguration.ignoreSSL - // Combine publication - let currentValue = PreferencesAccess.getPreference(key: key, defaultValue: defaultValue, encoder: objectEncoder, decoder: objectDecoder) - subject = CurrentValueSubject(currentValue) + store.currentHomePreferences.defaultView = sharedDefaults.string(forKey: "defaultView") ?? store.currentHomePreferences.defaultView + store.currentHomePreferences.demomode = sharedDefaults.object(forKey: "demomode") as? Bool ?? store.currentHomePreferences.demomode + store.currentHomePreferences.realTimeSliders = sharedDefaults.object(forKey: "realTimeSliders") as? Bool ?? store.currentHomePreferences.realTimeSliders + store.currentHomePreferences.iconType = sharedDefaults.object(forKey: "iconType") as? Int ?? store.currentHomePreferences.iconType + store.currentHomePreferences.defaultSitemap = sharedDefaults.string(forKey: "defaultSitemap") ?? store.currentHomePreferences.defaultSitemap + store.currentHomePreferences.sortSitemapsBy = sharedDefaults.object(forKey: "sortSitemapsBy") as? Int ?? store.currentHomePreferences.sortSitemapsBy + store.currentHomePreferences.defaultMainUIPath = sharedDefaults.string(forKey: "defaultMainUIPath") ?? store.currentHomePreferences.defaultMainUIPath + store.currentHomePreferences.alwaysAllowWebRTC = sharedDefaults.object(forKey: "alwaysAllowWebRTC") as? Bool ?? store.currentHomePreferences.alwaysAllowWebRTC + store.currentHomePreferences.sitemapForWatch = sharedDefaults.string(forKey: "sitemapForWatch") ?? store.currentHomePreferences.sitemapForWatch + store.currentHomePreferences.localConnectionConfig = newLocalConfiguration + store.currentHomePreferences.remoteConnectionConfig = newRemoteConfiguration + store.currentHomePreferences.sitemapForWatchLabel = sharedDefaults.string(forKey: "sitemapForWatchLabel") ?? store.currentHomePreferences.sitemapForWatchLabel } } -@MainActor -public struct HomePreferences: Codable, Equatable { +public struct HomePreferences: Codable, Equatable, Sendable { public let id: UUID public var defaultView = "web" public var demomode = true @@ -110,148 +374,90 @@ public struct HomePreferences: Codable, Equatable { } } -@MainActor -public struct ApplicationPreferences: Codable, Equatable { +public struct ApplicationPreferences: Codable, Equatable, Sendable { public var showSearchField = true } -// MARK: Retrieving preference from user defaults, reacting to preference change - -// MARK: !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - -// MARK: !! - -// MARK: When making changes to Preferences, always consider a migration for existing users. Otherwise, they risk to loose their existing preferences. - -// MARK: !! - -// MARK: !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - -private enum PreferencesAccess { - @MainActor fileprivate static func getPreference(key: String, defaultValue: T, encoder: (T) -> (some Sendable)?, decoder: (Any?) -> T?) -> T { - let preferenceValue = sharedDefaults.object(forKey: key) - if let preferenceConverted = decoder(preferenceValue) { - return preferenceConverted - } else { - if let preferenceValue { - Logger.preferences.error("Preference value \(key) was \(String(describing: preferenceValue)) but did not conform to \(T.self). Replace with default value.") - } else { - Logger.preferences.info("Preference value \(key) was set for the first time. Using default value.") - } - let fallback = defaultValue - sharedDefaults.set(encoder(fallback), forKey: key) - return fallback - } - } - - @MainActor fileprivate static func preferenceChanged(newValue: T, key: String, isHomeProperty: Bool, subject: CurrentValueSubject, sanitize: (T) -> (T?) = { $0 }, converter: (T) -> (some Sendable)?) { - guard let sanitized = sanitize(newValue) else { - Logger.preferences.debug("Preference \(key) new value \(String(describing: newValue), privacy: .private) could not be sanitized, will be ignored") - return - } - let convertedValue = converter(sanitized) - guard convertedValue != nil else { - Logger.preferences.debug("Preference \(key) conversion of new value \(String(describing: sanitized), privacy: .private) failed, do not store.") - return - } - Logger.preferences.debug("Preference \(key) will be changed to value \(String(describing: newValue), privacy: .private)") - sharedDefaults.set(convertedValue, forKey: key) - - subject.send(sanitized) - } -} +// MARK: Preferences stored in JSON with versioning +@MainActor public actor Preferences { public static let shared = Preferences() - private static let defaultHomeId = UUID() - /// the currently applied settings set from storedHomes - @UserDefaultObject("currentHomePreferences", defaultValue: HomePreferences(id: defaultHomeId)) + @Preference(\.currentHomePreferences) public private(set) var currentHomePreferences: HomePreferences - @UserDefault("sendCrashReports", defaultValue: false) + @Preference(\.sendCrashReports) public var sendCrashReports: Bool - @UserDefault("idleOff", defaultValue: false) + @Preference(\.idleOff) public var idleOff: Bool - @UserDefaultObject( - "applicationPreferences", - defaultValue: - ApplicationPreferences() - ) + @Preference(\.applicationPreferences) public private(set) var applicationPreferences: ApplicationPreferences - @UserDefault("screensaverEnabled", defaultValue: false) + @Preference(\.screensaverEnabled) public var screensaverEnabled: Bool - @UserDefault("screensaverShowsTime", defaultValue: true) + @Preference(\.screensaverShowsTime) public var screensaverShowsTime: Bool - @UserDefault("screensaverShowsDate", defaultValue: true) + @Preference(\.screensaverShowsDate) public var screensaverShowsDate: Bool - @UserDefault("screensaverIdleInterval", defaultValue: 120.0) + @Preference(\.screensaverIdleInterval) public var screensaverIdleInterval: Double - @UserDefault("screensaverMovementInterval", defaultValue: 8.0) + @Preference(\.screensaverMovementInterval) public var screensaverMovementInterval: Double - @UserDefault("screensaverFontName", defaultValue: "") + @Preference(\.screensaverFontName) public var screensaverFontName: String - @UserDefault("screensaverTimeFontRatio", defaultValue: 0.2) + @Preference(\.screensaverTimeFontRatio) public var screensaverTimeFontRatio: Double - @UserDefault("screensaverDateFontRatio", defaultValue: 0.4) + @Preference(\.screensaverDateFontRatio) public var screensaverDateFontRatio: Double - @UserDefault("screensaverEnableDimming", defaultValue: true) + @Preference(\.screensaverEnableDimming) public var screensaverEnableDimming: Bool - @UserDefault("screensaverDimLevel", defaultValue: 0.3) + @Preference(\.screensaverDimLevel) public var screensaverDimLevel: Double - @UserDefault("screensaverShowsSeconds", defaultValue: false) + @Preference(\.screensaverShowsSeconds) public var screensaverShowsSeconds: Bool - @UserDefault("screensaverUse24Hour", defaultValue: false) + @Preference(\.screensaverUse24Hour) public var screensaverUse24Hour: Bool - @UserDefault("screensaverFadeDuration", defaultValue: 2.0) + @Preference(\.screensaverFadeDuration) public var screensaverFadeDuration: Double - @UserDefault("screensaverRestoreBrightness", defaultValue: true) + @Preference(\.screensaverRestoreBrightness) public var screensaverRestoreBrightness: Bool - @UserDefault("screensaverWakeBrightness", defaultValue: 1.0) + @Preference(\.screensaverWakeBrightness) public var screensaverWakeBrightness: Double - @UserDefault("hideStatusBar", defaultValue: false) + @Preference(\.hideStatusBar) public var hideStatusBar: Bool - @UserDefault("currentWebViewPath", defaultValue: "") + @Preference(\.currentWebViewPath) public var currentWebViewPath: String /// settings for different homes - @UserDefaultObject("storedHomes", defaultValue: [:]) + @Preference(\.storedHomes) public private(set) var storedHomes: [UUID: HomePreferences] /// the currently applied settings set from storedHomes - @UserDefaultObject("activeHomeId", defaultValue: defaultHomeId) + @Preference(\.activeHomeId) private var activeHomeId: UUID - @UserDefault("didMigrateToSharedDefaults", defaultValue: false) - private var didMigrateToSharedDefaults: Bool - - @UserDefault("didMigrateToMultipleHomes", defaultValue: false) - private var didMigrateToMultipleHomes: Bool - - @MainActor private var internalPreferenceChangeOngoing = false - @MainActor private func internalPreferenceChange(_ change: () -> Void) { internalPreferenceChangeOngoing = true change() @@ -261,7 +467,6 @@ public actor Preferences { // MARK: Multiple homes -@MainActor public extension Preferences { func listStoredHomes() -> [UUID] { let preferenceIds = storedHomes @@ -349,21 +554,20 @@ public extension Preferences { Logger.preferences.debug("Stored preferences for current home \(homeId.uuidString)") } - func modifyActiveHome(modificationFunction: @MainActor (inout HomePreferences) -> Void) { + func modifyActiveHome(modificationFunction: (inout HomePreferences) -> Void) { var homePreferences = currentHomePreferences modificationFunction(&homePreferences) currentHomePreferences = homePreferences storeActiveHome() } - func modifyApplicationPreferences(modificationFunction: @MainActor (inout ApplicationPreferences) -> Void) { + func modifyApplicationPreferences(modificationFunction: (inout ApplicationPreferences) -> Void) { var applicationPreferences = applicationPreferences modificationFunction(&applicationPreferences) self.applicationPreferences = applicationPreferences } } -@MainActor public extension Preferences { func firstStoredHome(where predicate: (HomePreferences) -> Bool) -> (id: UUID, record: HomePreferences)? { for (uuid, record) in storedHomes { @@ -382,88 +586,24 @@ public extension Preferences { // MARK: Migration -@MainActor public extension Preferences { static func migratePreferences() { + PreferencesStoreAccess.migrateIfNeeded() Preferences.shared.initializeStoredHomes() - migrateToSharedDefaultsIfRequired() - migrateToMultipleHomesIfRequired() - } - - private static func migrateToSharedDefaultsIfRequired() { - guard !Preferences.shared.didMigrateToSharedDefaults else { return } - - Preferences.shared.modifyActiveHome { currentHomePreferences in - currentHomePreferences.localConnectionConfig.url = UserDefaults.standard.string(forKey: "localUrl") ?? currentHomePreferences.localConnectionConfig.url - currentHomePreferences.localConnectionConfig.alwaysSendBasicAuth = UserDefaults.standard.object(forKey: "alwaysSendCreds") as? Bool ?? currentHomePreferences.localConnectionConfig.alwaysSendBasicAuth - currentHomePreferences.localConnectionConfig.ignoreSSL = UserDefaults.standard.object(forKey: "ignoreSSL") as? Bool ?? currentHomePreferences.localConnectionConfig.ignoreSSL - currentHomePreferences.remoteConnectionConfig.url = UserDefaults.standard.string(forKey: "remoteUrl") ?? currentHomePreferences.remoteConnectionConfig.url - currentHomePreferences.remoteConnectionConfig.username = UserDefaults.standard.string(forKey: "username") ?? currentHomePreferences.remoteConnectionConfig.username - currentHomePreferences.remoteConnectionConfig.password = UserDefaults.standard.string(forKey: "password") ?? currentHomePreferences.remoteConnectionConfig.password - currentHomePreferences.remoteConnectionConfig.alwaysSendBasicAuth = UserDefaults.standard.object(forKey: "alwaysSendCreds") as? Bool ?? currentHomePreferences.remoteConnectionConfig.alwaysSendBasicAuth - currentHomePreferences.remoteConnectionConfig.ignoreSSL = UserDefaults.standard.object(forKey: "ignoreSSL") as? Bool ?? currentHomePreferences.remoteConnectionConfig.ignoreSSL - currentHomePreferences.demomode = UserDefaults.standard.object(forKey: "demomode") as? Bool ?? currentHomePreferences.demomode - currentHomePreferences.realTimeSliders = UserDefaults.standard.object(forKey: "realTimeSliders") as? Bool ?? currentHomePreferences.realTimeSliders - currentHomePreferences.iconType = UserDefaults.standard.object(forKey: "iconType") as? Int ?? currentHomePreferences.iconType - currentHomePreferences.defaultSitemap = UserDefaults.standard.string(forKey: "defaultSitemap") ?? currentHomePreferences.defaultSitemap - } - - Preferences.shared.idleOff = UserDefaults.standard.object(forKey: "idleOff") as? Bool ?? Preferences.shared.idleOff - Preferences.shared.sendCrashReports = UserDefaults.standard.object(forKey: "sendCrashReports") as? Bool ?? Preferences.shared.sendCrashReports - - Preferences.shared.didMigrateToSharedDefaults = true - // this was done implicitly - Preferences.shared.didMigrateToMultipleHomes = true } +} - private static func migrateToMultipleHomesIfRequired() { - guard !Preferences.shared.didMigrateToMultipleHomes else { return } - - migrateToSharedDefaultsIfRequired() - - let oldLocalUrl = sharedDefaults.string(forKey: "localUrl") - let oldRemoteUrl = sharedDefaults.string(forKey: "remoteUrl") - let oldUsername = sharedDefaults.string(forKey: "username") - let oldPassword = sharedDefaults.string(forKey: "password") - let oldAlwaysSendCreds = sharedDefaults.object(forKey: "alwaysSendCreds") as? Bool - let oldIgnoreSSL = sharedDefaults.object(forKey: "ignoreSSL") as? Bool - - // Create new configuration - var newLocalConfiguration = Preferences.shared.currentHomePreferences.localConnectionConfig - newLocalConfiguration.url = oldLocalUrl ?? newLocalConfiguration.url - newLocalConfiguration.alwaysSendBasicAuth = oldAlwaysSendCreds ?? newLocalConfiguration.alwaysSendBasicAuth - newLocalConfiguration.ignoreSSL = oldIgnoreSSL ?? newLocalConfiguration.ignoreSSL - - var newRemoteConfiguration = Preferences.shared.currentHomePreferences.remoteConnectionConfig - newRemoteConfiguration.url = oldRemoteUrl ?? newRemoteConfiguration.url - newRemoteConfiguration.username = oldUsername ?? newRemoteConfiguration.username - newRemoteConfiguration.password = oldPassword ?? newRemoteConfiguration.password - newRemoteConfiguration.alwaysSendBasicAuth = oldAlwaysSendCreds ?? newRemoteConfiguration.alwaysSendBasicAuth - newRemoteConfiguration.ignoreSSL = oldIgnoreSSL ?? newRemoteConfiguration.ignoreSSL - - // Save to Preferences - Preferences.shared.modifyActiveHome { currentHomePreferences in - currentHomePreferences.defaultView = sharedDefaults.string(forKey: "defaultView") ?? currentHomePreferences.defaultView - currentHomePreferences.demomode = sharedDefaults.object(forKey: "demomode") as? Bool ?? currentHomePreferences.demomode - currentHomePreferences.realTimeSliders = sharedDefaults.object(forKey: "realTimeSliders") as? Bool ?? currentHomePreferences.realTimeSliders - currentHomePreferences.iconType = sharedDefaults.object(forKey: "iconType") as? Int ?? currentHomePreferences.iconType - currentHomePreferences.defaultSitemap = sharedDefaults.string(forKey: "defaultSitemap") ?? currentHomePreferences.defaultSitemap - currentHomePreferences.sortSitemapsBy = sharedDefaults.object(forKey: "sortSitemapsBy") as? Int ?? currentHomePreferences.sortSitemapsBy - currentHomePreferences.defaultMainUIPath = sharedDefaults.string(forKey: "defaultMainUIPath") ?? currentHomePreferences.defaultMainUIPath - currentHomePreferences.alwaysAllowWebRTC = sharedDefaults.object(forKey: "alwaysAllowWebRTC") as? Bool ?? currentHomePreferences.alwaysAllowWebRTC - currentHomePreferences.sitemapForWatch = sharedDefaults.string(forKey: "sitemapForWatch") ?? currentHomePreferences.sitemapForWatch - currentHomePreferences.localConnectionConfig = newLocalConfiguration - currentHomePreferences.remoteConnectionConfig = newRemoteConfiguration - currentHomePreferences.sitemapForWatchLabel = sharedDefaults.string(forKey: "sitemapForWatchLabel") ?? currentHomePreferences.sitemapForWatchLabel - } - - Preferences.shared.didMigrateToMultipleHomes = true +#if DEBUG +public extension Preferences { + static func reloadForTesting() { + PreferencesStoreAccess.shared.reloadFromDefaultsForTesting() + Preferences.shared.initializeStoredHomes() } } +#endif // MARK: All connections -@MainActor public extension Preferences { func getNotificationConnection() -> ConnectionConfiguration? { getNotificationConnection(of: [Preferences.shared.currentHomePreferences.remoteConnectionConfig]) diff --git a/OpenHABCore/Tests/OpenHABCoreTests/UserDefaultsTests.swift b/OpenHABCore/Tests/OpenHABCoreTests/UserDefaultsTests.swift index 5219fffe6..60fc4a537 100644 --- a/OpenHABCore/Tests/OpenHABCoreTests/UserDefaultsTests.swift +++ b/OpenHABCore/Tests/OpenHABCoreTests/UserDefaultsTests.swift @@ -27,6 +27,8 @@ final class UserDefaultsTests: XCTestCase { fatalError() } data.removePersistentDomain(forName: defaultsName) + data.removePersistentDomain(forName: "group.org.openhab.app") + Preferences.reloadForTesting() let random: String = UUID().uuidString @@ -65,6 +67,63 @@ final class UserDefaultsTests: XCTestCase { XCTAssertEqual(Preferences.shared.currentHomePreferences.iconType, home.iconType) XCTAssertEqual(Preferences.shared.currentHomePreferences.defaultSitemap, home.defaultSitemap) XCTAssertEqual(Preferences.shared.currentHomePreferences.sitemapForWatch, home.sitemapForWatch) - XCTAssertEqual(home, try? JSONDecoder().decode(HomePreferences.self, from: data.data(forKey: "currentHomePreferences")!)) + + let storeData = try XCTUnwrap(data.data(forKey: "preferencesStore")) + let storeObject = try XCTUnwrap(JSONSerialization.jsonObject(with: storeData) as? [String: Any]) + XCTAssertEqual(storeObject["version"] as? Int, 1) + XCTAssertNotNil(storeObject["currentHomePreferences"]) + } + + func testPreferencesStoreForwardCompatibility() throws { + let data = UserDefaults(suiteName: "group.org.openhab.app")! + data.removePersistentDomain(forName: "group.org.openhab.app") + + Preferences.reloadForTesting() + let home = Preferences.shared.currentHomePreferences + let homeId = home.id + let homeData = try JSONEncoder().encode(home) + let homeObject = try XCTUnwrap(JSONSerialization.jsonObject(with: homeData) as? [String: Any]) + + let appPrefs = ApplicationPreferences() + let appPrefsData = try JSONEncoder().encode(appPrefs) + let appPrefsObject = try XCTUnwrap(JSONSerialization.jsonObject(with: appPrefsData) as? [String: Any]) + + let storedHomesObject: [String: Any] = [homeId.uuidString: homeObject] + + var legacyStore: [String: Any] = [ + "version": 1, + "currentHomePreferences": homeObject, + "storedHomes": storedHomesObject, + "activeHomeId": homeId.uuidString, + "applicationPreferences": appPrefsObject, + "sendCrashReports": true, + "idleOff": true, + "screensaverEnabled": false, + "screensaverShowsTime": true, + "screensaverShowsDate": true, + "screensaverIdleInterval": 120.0, + "screensaverMovementInterval": 8.0, + "screensaverFontName": "", + "screensaverTimeFontRatio": 0.2, + "screensaverDateFontRatio": 0.4, + "screensaverEnableDimming": true, + "screensaverDimLevel": 0.3, + "screensaverShowsSeconds": false, + "screensaverUse24Hour": false, + "screensaverFadeDuration": 2.0, + "screensaverRestoreBrightness": true, + "screensaverWakeBrightness": 1.0, + "hideStatusBar": false + ] + + // Deliberately omit currentWebViewPath to simulate older payloads + let legacyData = try JSONSerialization.data(withJSONObject: legacyStore) + data.set(legacyData, forKey: "preferencesStore") + + Preferences.reloadForTesting() + + XCTAssertTrue(Preferences.shared.sendCrashReports) + XCTAssertTrue(Preferences.shared.idleOff) + XCTAssertEqual(Preferences.shared.currentWebViewPath, "") } } From 3bc535eb39289ec3e04bf5c9e7632e6618849e04 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Wed, 4 Feb 2026 21:39:50 +0100 Subject: [PATCH 399/476] Style pressRelease buttons individually with spacing - Move background/border to each pressRelease button - Add spacing between buttons - Match row height with segmentedButtons - Add two-button pressRelease preview Signed-off-by: Tim Mueller-Seydlitz --- openHAB/SwiftUI/Rows/SegmentedRowView.swift | 32 ++++++++------------- 1 file changed, 12 insertions(+), 20 deletions(-) diff --git a/openHAB/SwiftUI/Rows/SegmentedRowView.swift b/openHAB/SwiftUI/Rows/SegmentedRowView.swift index 495c75b9a..a729aa4c3 100644 --- a/openHAB/SwiftUI/Rows/SegmentedRowView.swift +++ b/openHAB/SwiftUI/Rows/SegmentedRowView.swift @@ -130,24 +130,11 @@ struct SegmentedRowView: View { @ViewBuilder private var pressReleaseButtons: some View { - HStack(spacing: 2) { + HStack(spacing: 4) { ForEach(mappings.indices, id: \.self) { index in pressReleaseButton(for: mappings[index], at: index) } } - .padding(2) - .background( - RoundedRectangle(cornerRadius: 7) - .fill( - colorScheme == .dark - ? Color(uiColor: .tertiarySystemBackground) - : Color(uiColor: .secondarySystemBackground) - ) - ) - .overlay( - RoundedRectangle(cornerRadius: 7) - .stroke(Color.secondary.opacity(0.3), lineWidth: 0.5) - ) } @State private var singleButtonPressed = false @@ -162,7 +149,7 @@ struct SegmentedRowView: View { .lineLimit(1) .truncationMode(.tail) .padding(.horizontal, 8) - .padding(.vertical, 3) + .padding(.vertical, 5) .frame(minWidth: 50) .foregroundStyle(.primary) .background( @@ -187,7 +174,6 @@ struct SegmentedRowView: View { viewModel.sendCommand(widget.item, commandToSend: mapping.command) } ) - .padding(2) .background( RoundedRectangle(cornerRadius: 7) .fill( @@ -236,17 +222,21 @@ struct SegmentedRowView: View { .lineLimit(1) .truncationMode(.tail) .padding(.horizontal, 8) - .padding(.vertical, 3) + .padding(.vertical, 5) .frame(minWidth: 50) .foregroundStyle(.primary) .background( - RoundedRectangle(cornerRadius: 6) + RoundedRectangle(cornerRadius: 7) .fill( isPressed ? (colorScheme == .dark ? Color(uiColor: .systemGray2) : Color(uiColor: .systemBackground)) - : Color.clear + : (colorScheme == .dark ? Color(uiColor: .tertiarySystemBackground) : Color(uiColor: .secondarySystemBackground)) ) ) + .overlay( + RoundedRectangle(cornerRadius: 7) + .stroke(Color.secondary.opacity(0.3), lineWidth: 0.5) + ) .contentShape(Rectangle()) .gesture( DragGesture(minimumDistance: 0) @@ -490,7 +480,9 @@ private extension SegmentedRowView { label: "All Shutters", detailLabel: "NA", mappings: [ - OpenHABWidgetMapping(command: "DOWN", label: "DOWN", releaseCommand: "OFF") + OpenHABWidgetMapping(command: "DOWN", label: "DOWN", releaseCommand: "OFF"), + OpenHABWidgetMapping(command: "UP", label: " UP ", releaseCommand: "UP"), + ], selectedState: "DOWN" ), From 8d300830d7473cca61ce4992d410eabbbc1be8d5 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Wed, 4 Feb 2026 21:44:17 +0100 Subject: [PATCH 400/476] Revert "Redo preferences storage with JSON" This reverts commit b92fcb7a50a5ad0b7d029a2b465e1f5e97d9c600. --- .../OpenHABCore/Util/Preferences.swift | 546 +++++++----------- .../OpenHABCoreTests/UserDefaultsTests.swift | 61 +- 2 files changed, 204 insertions(+), 403 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift index 628533cfd..5e5ecc9e0 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift @@ -13,346 +13,82 @@ import os.log import UIKit +@MainActor private let sharedDefaults = UserDefaults(suiteName: "group.org.openhab.app")! +@MainActor @propertyWrapper -private struct Preference { - private let keyPath: WritableKeyPath +public struct UserDefault { + private let key: String + private let defaultValue: T + private let isHomeProperty: Bool private let subject: CurrentValueSubject - var wrappedValue: T { + public var wrappedValue: T { get { - PreferencesStoreAccess.shared.value(for: keyPath) + PreferencesAccess.getPreference(key: key, defaultValue: defaultValue, encoder: { $0 }, decoder: { $0 as? T }) } set { - PreferencesStoreAccess.shared.setValue(newValue, for: keyPath) - subject.send(newValue) + PreferencesAccess.preferenceChanged(newValue: newValue, key: key, isHomeProperty: isHomeProperty, subject: subject) { $0 } } } - var projectedValue: AnyPublisher { + public var projectedValue: AnyPublisher { subject.eraseToAnyPublisher() } - init(_ keyPath: WritableKeyPath) { - self.keyPath = keyPath - let currentValue = PreferencesStoreAccess.shared.value(for: keyPath) + public init(_ key: String, defaultValue: T, isHomeProperty: Bool = false) { + self.key = key + self.defaultValue = defaultValue + self.isHomeProperty = isHomeProperty + let currentValue = PreferencesAccess.getPreference(key: key, defaultValue: defaultValue, encoder: { $0 }, decoder: { $0 as? T }) subject = CurrentValueSubject(currentValue) } } -private struct PreferencesStore: Codable, Sendable, Equatable { - static let currentVersion = 1 - - var version: Int - var currentHomePreferences: HomePreferences - var storedHomes: [UUID: HomePreferences] - var activeHomeId: UUID - var applicationPreferences: ApplicationPreferences - var sendCrashReports: Bool - var idleOff: Bool - var screensaverEnabled: Bool - var screensaverShowsTime: Bool - var screensaverShowsDate: Bool - var screensaverIdleInterval: Double - var screensaverMovementInterval: Double - var screensaverFontName: String - var screensaverTimeFontRatio: Double - var screensaverDateFontRatio: Double - var screensaverEnableDimming: Bool - var screensaverDimLevel: Double - var screensaverShowsSeconds: Bool - var screensaverUse24Hour: Bool - var screensaverFadeDuration: Double - var screensaverRestoreBrightness: Bool - var screensaverWakeBrightness: Double - var hideStatusBar: Bool - var currentWebViewPath: String - - private enum CodingKeys: String, CodingKey { - case version - case currentHomePreferences - case storedHomes - case activeHomeId - case applicationPreferences - case sendCrashReports - case idleOff - case screensaverEnabled - case screensaverShowsTime - case screensaverShowsDate - case screensaverIdleInterval - case screensaverMovementInterval - case screensaverFontName - case screensaverTimeFontRatio - case screensaverDateFontRatio - case screensaverEnableDimming - case screensaverDimLevel - case screensaverShowsSeconds - case screensaverUse24Hour - case screensaverFadeDuration - case screensaverRestoreBrightness - case screensaverWakeBrightness - case hideStatusBar - case currentWebViewPath - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - let defaults = PreferencesStore.defaultStore() - version = try container.decodeIfPresent(Int.self, forKey: .version) ?? defaults.version - currentHomePreferences = try container.decodeIfPresent(HomePreferences.self, forKey: .currentHomePreferences) ?? defaults.currentHomePreferences - storedHomes = try container.decodeIfPresent([UUID: HomePreferences].self, forKey: .storedHomes) ?? defaults.storedHomes - activeHomeId = try container.decodeIfPresent(UUID.self, forKey: .activeHomeId) ?? defaults.activeHomeId - applicationPreferences = try container.decodeIfPresent(ApplicationPreferences.self, forKey: .applicationPreferences) ?? defaults.applicationPreferences - sendCrashReports = try container.decodeIfPresent(Bool.self, forKey: .sendCrashReports) ?? defaults.sendCrashReports - idleOff = try container.decodeIfPresent(Bool.self, forKey: .idleOff) ?? defaults.idleOff - screensaverEnabled = try container.decodeIfPresent(Bool.self, forKey: .screensaverEnabled) ?? defaults.screensaverEnabled - screensaverShowsTime = try container.decodeIfPresent(Bool.self, forKey: .screensaverShowsTime) ?? defaults.screensaverShowsTime - screensaverShowsDate = try container.decodeIfPresent(Bool.self, forKey: .screensaverShowsDate) ?? defaults.screensaverShowsDate - screensaverIdleInterval = try container.decodeIfPresent(Double.self, forKey: .screensaverIdleInterval) ?? defaults.screensaverIdleInterval - screensaverMovementInterval = try container.decodeIfPresent(Double.self, forKey: .screensaverMovementInterval) ?? defaults.screensaverMovementInterval - screensaverFontName = try container.decodeIfPresent(String.self, forKey: .screensaverFontName) ?? defaults.screensaverFontName - screensaverTimeFontRatio = try container.decodeIfPresent(Double.self, forKey: .screensaverTimeFontRatio) ?? defaults.screensaverTimeFontRatio - screensaverDateFontRatio = try container.decodeIfPresent(Double.self, forKey: .screensaverDateFontRatio) ?? defaults.screensaverDateFontRatio - screensaverEnableDimming = try container.decodeIfPresent(Bool.self, forKey: .screensaverEnableDimming) ?? defaults.screensaverEnableDimming - screensaverDimLevel = try container.decodeIfPresent(Double.self, forKey: .screensaverDimLevel) ?? defaults.screensaverDimLevel - screensaverShowsSeconds = try container.decodeIfPresent(Bool.self, forKey: .screensaverShowsSeconds) ?? defaults.screensaverShowsSeconds - screensaverUse24Hour = try container.decodeIfPresent(Bool.self, forKey: .screensaverUse24Hour) ?? defaults.screensaverUse24Hour - screensaverFadeDuration = try container.decodeIfPresent(Double.self, forKey: .screensaverFadeDuration) ?? defaults.screensaverFadeDuration - screensaverRestoreBrightness = try container.decodeIfPresent(Bool.self, forKey: .screensaverRestoreBrightness) ?? defaults.screensaverRestoreBrightness - screensaverWakeBrightness = try container.decodeIfPresent(Double.self, forKey: .screensaverWakeBrightness) ?? defaults.screensaverWakeBrightness - hideStatusBar = try container.decodeIfPresent(Bool.self, forKey: .hideStatusBar) ?? defaults.hideStatusBar - currentWebViewPath = try container.decodeIfPresent(String.self, forKey: .currentWebViewPath) ?? defaults.currentWebViewPath - } - - static func defaultStore() -> PreferencesStore { - let homeId = UUID() - let home = HomePreferences(id: homeId) - return PreferencesStore( - version: currentVersion, - currentHomePreferences: home, - storedHomes: [homeId: home], - activeHomeId: homeId, - applicationPreferences: ApplicationPreferences(), - sendCrashReports: false, - idleOff: false, - screensaverEnabled: false, - screensaverShowsTime: true, - screensaverShowsDate: true, - screensaverIdleInterval: 120.0, - screensaverMovementInterval: 8.0, - screensaverFontName: "", - screensaverTimeFontRatio: 0.2, - screensaverDateFontRatio: 0.4, - screensaverEnableDimming: true, - screensaverDimLevel: 0.3, - screensaverShowsSeconds: false, - screensaverUse24Hour: false, - screensaverFadeDuration: 2.0, - screensaverRestoreBrightness: true, - screensaverWakeBrightness: 1.0, - hideStatusBar: false, - currentWebViewPath: "" - ) - } -} - -private final class PreferencesStoreAccess { - static let shared = PreferencesStoreAccess() - private static let storeKey = "preferencesStore" - private var store: PreferencesStore - - private init() { - store = Self.loadStore() - } - - func value(for keyPath: KeyPath) -> T { - store[keyPath: keyPath] - } - - func setValue(_ value: T, for keyPath: WritableKeyPath) { - store[keyPath: keyPath] = value - persistStore() - } - - static func migrateIfNeeded() { - _ = PreferencesStoreAccess.shared - } +@MainActor +@propertyWrapper +public struct UserDefaultObject { + private let key: String + private let defaultValue: T + private let isHomeProperty: Bool + private let subject: CurrentValueSubject - private static func loadStore() -> PreferencesStore { - if let data = sharedDefaults.data(forKey: storeKey) { - let decoder = JSONDecoder() - if let decoded = try? decoder.decode(PreferencesStore.self, from: data) { - if decoded.version == PreferencesStore.currentVersion { - return decoded - } - return migrateStore(decoded) - } else { - Logger.preferences.error("Preferences JSON failed to decode, falling back to legacy preferences.") - } + private let objectDecoder: (Any) -> (T?) = { + guard let data = $0 as? Data else { + return nil } - - let legacyStore = LegacyPreferencesMapper.loadStore() - persistStore(legacyStore) - return legacyStore - } - - private static func migrateStore(_ store: PreferencesStore) -> PreferencesStore { - var migrated = store - migrated.version = PreferencesStore.currentVersion - persistStore(migrated) - return migrated - } - - private func persistStore() { - Self.persistStore(store) + return try? JSONDecoder().decode(T.self, from: data) } - private static func persistStore(_ store: PreferencesStore) { - let encoder = JSONEncoder() - guard let data = try? encoder.encode(store) else { - Logger.preferences.error("Failed to encode preferences JSON.") - return - } - sharedDefaults.set(data, forKey: storeKey) - } + private let objectEncoder: (T) -> (any Sendable)? = { try? JSONEncoder().encode($0) } - #if DEBUG - func reloadFromDefaultsForTesting() { - store = Self.loadStore() - } - #endif -} - -private enum LegacyPreferencesMapper { - static func loadStore() -> PreferencesStore { - let decoder = JSONDecoder() - var store = PreferencesStore.defaultStore() - - if let data = sharedDefaults.data(forKey: "currentHomePreferences"), - let decoded = try? decoder.decode(HomePreferences.self, from: data) { - store.currentHomePreferences = decoded - } - - if let data = sharedDefaults.data(forKey: "storedHomes"), - let decoded = try? decoder.decode([UUID: HomePreferences].self, from: data) { - store.storedHomes = decoded - } - - if let data = sharedDefaults.data(forKey: "activeHomeId"), - let decoded = try? decoder.decode(UUID.self, from: data) { - store.activeHomeId = decoded - } - - if let data = sharedDefaults.data(forKey: "applicationPreferences"), - let decoded = try? decoder.decode(ApplicationPreferences.self, from: data) { - store.applicationPreferences = decoded - } - - store.sendCrashReports = sharedDefaults.object(forKey: "sendCrashReports") as? Bool ?? store.sendCrashReports - store.idleOff = sharedDefaults.object(forKey: "idleOff") as? Bool ?? store.idleOff - store.screensaverEnabled = sharedDefaults.object(forKey: "screensaverEnabled") as? Bool ?? store.screensaverEnabled - store.screensaverShowsTime = sharedDefaults.object(forKey: "screensaverShowsTime") as? Bool ?? store.screensaverShowsTime - store.screensaverShowsDate = sharedDefaults.object(forKey: "screensaverShowsDate") as? Bool ?? store.screensaverShowsDate - store.screensaverIdleInterval = sharedDefaults.object(forKey: "screensaverIdleInterval") as? Double ?? store.screensaverIdleInterval - store.screensaverMovementInterval = sharedDefaults.object(forKey: "screensaverMovementInterval") as? Double ?? store.screensaverMovementInterval - store.screensaverFontName = sharedDefaults.string(forKey: "screensaverFontName") ?? store.screensaverFontName - store.screensaverTimeFontRatio = sharedDefaults.object(forKey: "screensaverTimeFontRatio") as? Double ?? store.screensaverTimeFontRatio - store.screensaverDateFontRatio = sharedDefaults.object(forKey: "screensaverDateFontRatio") as? Double ?? store.screensaverDateFontRatio - store.screensaverEnableDimming = sharedDefaults.object(forKey: "screensaverEnableDimming") as? Bool ?? store.screensaverEnableDimming - store.screensaverDimLevel = sharedDefaults.object(forKey: "screensaverDimLevel") as? Double ?? store.screensaverDimLevel - store.screensaverShowsSeconds = sharedDefaults.object(forKey: "screensaverShowsSeconds") as? Bool ?? store.screensaverShowsSeconds - store.screensaverUse24Hour = sharedDefaults.object(forKey: "screensaverUse24Hour") as? Bool ?? store.screensaverUse24Hour - store.screensaverFadeDuration = sharedDefaults.object(forKey: "screensaverFadeDuration") as? Double ?? store.screensaverFadeDuration - store.screensaverRestoreBrightness = sharedDefaults.object(forKey: "screensaverRestoreBrightness") as? Bool ?? store.screensaverRestoreBrightness - store.screensaverWakeBrightness = sharedDefaults.object(forKey: "screensaverWakeBrightness") as? Double ?? store.screensaverWakeBrightness - store.hideStatusBar = sharedDefaults.object(forKey: "hideStatusBar") as? Bool ?? store.hideStatusBar - store.currentWebViewPath = sharedDefaults.string(forKey: "currentWebViewPath") ?? store.currentWebViewPath - - let didMigrateToSharedDefaults = sharedDefaults.bool(forKey: "didMigrateToSharedDefaults") - let didMigrateToMultipleHomes = sharedDefaults.bool(forKey: "didMigrateToMultipleHomes") - - if !didMigrateToSharedDefaults { - applyStandardDefaultsMigration(to: &store) - sharedDefaults.set(true, forKey: "didMigrateToSharedDefaults") - sharedDefaults.set(true, forKey: "didMigrateToMultipleHomes") - } - - if !didMigrateToMultipleHomes { - applyMultipleHomesMigration(to: &store) - sharedDefaults.set(true, forKey: "didMigrateToMultipleHomes") - } - - if store.storedHomes.isEmpty { - store.storedHomes[store.currentHomePreferences.id] = store.currentHomePreferences - } - - if store.storedHomes[store.activeHomeId] == nil { - store.storedHomes[store.activeHomeId] = store.currentHomePreferences + public var wrappedValue: T { + get { + PreferencesAccess.getPreference(key: key, defaultValue: defaultValue, encoder: objectEncoder, decoder: objectDecoder) } - - if let storedHome = store.storedHomes[store.activeHomeId] { - store.currentHomePreferences = storedHome + set { + PreferencesAccess.preferenceChanged(newValue: newValue, key: key, isHomeProperty: isHomeProperty, subject: subject, converter: objectEncoder) } - - return store } - private static func applyStandardDefaultsMigration(to store: inout PreferencesStore) { - let standardDefaults = UserDefaults.standard - store.currentHomePreferences.localConnectionConfig.url = standardDefaults.string(forKey: "localUrl") ?? store.currentHomePreferences.localConnectionConfig.url - store.currentHomePreferences.localConnectionConfig.alwaysSendBasicAuth = standardDefaults.object(forKey: "alwaysSendCreds") as? Bool ?? store.currentHomePreferences.localConnectionConfig.alwaysSendBasicAuth - store.currentHomePreferences.localConnectionConfig.ignoreSSL = standardDefaults.object(forKey: "ignoreSSL") as? Bool ?? store.currentHomePreferences.localConnectionConfig.ignoreSSL - store.currentHomePreferences.remoteConnectionConfig.url = standardDefaults.string(forKey: "remoteUrl") ?? store.currentHomePreferences.remoteConnectionConfig.url - store.currentHomePreferences.remoteConnectionConfig.username = standardDefaults.string(forKey: "username") ?? store.currentHomePreferences.remoteConnectionConfig.username - store.currentHomePreferences.remoteConnectionConfig.password = standardDefaults.string(forKey: "password") ?? store.currentHomePreferences.remoteConnectionConfig.password - store.currentHomePreferences.remoteConnectionConfig.alwaysSendBasicAuth = standardDefaults.object(forKey: "alwaysSendCreds") as? Bool ?? store.currentHomePreferences.remoteConnectionConfig.alwaysSendBasicAuth - store.currentHomePreferences.remoteConnectionConfig.ignoreSSL = standardDefaults.object(forKey: "ignoreSSL") as? Bool ?? store.currentHomePreferences.remoteConnectionConfig.ignoreSSL - store.currentHomePreferences.demomode = standardDefaults.object(forKey: "demomode") as? Bool ?? store.currentHomePreferences.demomode - store.currentHomePreferences.realTimeSliders = standardDefaults.object(forKey: "realTimeSliders") as? Bool ?? store.currentHomePreferences.realTimeSliders - store.currentHomePreferences.iconType = standardDefaults.object(forKey: "iconType") as? Int ?? store.currentHomePreferences.iconType - store.currentHomePreferences.defaultSitemap = standardDefaults.string(forKey: "defaultSitemap") ?? store.currentHomePreferences.defaultSitemap - - store.idleOff = standardDefaults.object(forKey: "idleOff") as? Bool ?? store.idleOff - store.sendCrashReports = standardDefaults.object(forKey: "sendCrashReports") as? Bool ?? store.sendCrashReports + public var projectedValue: AnyPublisher { + subject.eraseToAnyPublisher() } - private static func applyMultipleHomesMigration(to store: inout PreferencesStore) { - let oldLocalUrl = sharedDefaults.string(forKey: "localUrl") - let oldRemoteUrl = sharedDefaults.string(forKey: "remoteUrl") - let oldUsername = sharedDefaults.string(forKey: "username") - let oldPassword = sharedDefaults.string(forKey: "password") - let oldAlwaysSendCreds = sharedDefaults.object(forKey: "alwaysSendCreds") as? Bool - let oldIgnoreSSL = sharedDefaults.object(forKey: "ignoreSSL") as? Bool - - var newLocalConfiguration = store.currentHomePreferences.localConnectionConfig - newLocalConfiguration.url = oldLocalUrl ?? newLocalConfiguration.url - newLocalConfiguration.alwaysSendBasicAuth = oldAlwaysSendCreds ?? newLocalConfiguration.alwaysSendBasicAuth - newLocalConfiguration.ignoreSSL = oldIgnoreSSL ?? newLocalConfiguration.ignoreSSL - - var newRemoteConfiguration = store.currentHomePreferences.remoteConnectionConfig - newRemoteConfiguration.url = oldRemoteUrl ?? newRemoteConfiguration.url - newRemoteConfiguration.username = oldUsername ?? newRemoteConfiguration.username - newRemoteConfiguration.password = oldPassword ?? newRemoteConfiguration.password - newRemoteConfiguration.alwaysSendBasicAuth = oldAlwaysSendCreds ?? newRemoteConfiguration.alwaysSendBasicAuth - newRemoteConfiguration.ignoreSSL = oldIgnoreSSL ?? newRemoteConfiguration.ignoreSSL + init(_ key: String, defaultValue: T, isHomeProperty: Bool = false) { + self.key = key + self.defaultValue = defaultValue + self.isHomeProperty = isHomeProperty - store.currentHomePreferences.defaultView = sharedDefaults.string(forKey: "defaultView") ?? store.currentHomePreferences.defaultView - store.currentHomePreferences.demomode = sharedDefaults.object(forKey: "demomode") as? Bool ?? store.currentHomePreferences.demomode - store.currentHomePreferences.realTimeSliders = sharedDefaults.object(forKey: "realTimeSliders") as? Bool ?? store.currentHomePreferences.realTimeSliders - store.currentHomePreferences.iconType = sharedDefaults.object(forKey: "iconType") as? Int ?? store.currentHomePreferences.iconType - store.currentHomePreferences.defaultSitemap = sharedDefaults.string(forKey: "defaultSitemap") ?? store.currentHomePreferences.defaultSitemap - store.currentHomePreferences.sortSitemapsBy = sharedDefaults.object(forKey: "sortSitemapsBy") as? Int ?? store.currentHomePreferences.sortSitemapsBy - store.currentHomePreferences.defaultMainUIPath = sharedDefaults.string(forKey: "defaultMainUIPath") ?? store.currentHomePreferences.defaultMainUIPath - store.currentHomePreferences.alwaysAllowWebRTC = sharedDefaults.object(forKey: "alwaysAllowWebRTC") as? Bool ?? store.currentHomePreferences.alwaysAllowWebRTC - store.currentHomePreferences.sitemapForWatch = sharedDefaults.string(forKey: "sitemapForWatch") ?? store.currentHomePreferences.sitemapForWatch - store.currentHomePreferences.localConnectionConfig = newLocalConfiguration - store.currentHomePreferences.remoteConnectionConfig = newRemoteConfiguration - store.currentHomePreferences.sitemapForWatchLabel = sharedDefaults.string(forKey: "sitemapForWatchLabel") ?? store.currentHomePreferences.sitemapForWatchLabel + // Combine publication + let currentValue = PreferencesAccess.getPreference(key: key, defaultValue: defaultValue, encoder: objectEncoder, decoder: objectDecoder) + subject = CurrentValueSubject(currentValue) } } -public struct HomePreferences: Codable, Equatable, Sendable { +@MainActor +public struct HomePreferences: Codable, Equatable { public let id: UUID public var defaultView = "web" public var demomode = true @@ -374,90 +110,148 @@ public struct HomePreferences: Codable, Equatable, Sendable { } } -public struct ApplicationPreferences: Codable, Equatable, Sendable { +@MainActor +public struct ApplicationPreferences: Codable, Equatable { public var showSearchField = true } -// MARK: Preferences stored in JSON with versioning +// MARK: Retrieving preference from user defaults, reacting to preference change + +// MARK: !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +// MARK: !! + +// MARK: When making changes to Preferences, always consider a migration for existing users. Otherwise, they risk to loose their existing preferences. + +// MARK: !! + +// MARK: !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +private enum PreferencesAccess { + @MainActor fileprivate static func getPreference(key: String, defaultValue: T, encoder: (T) -> (some Sendable)?, decoder: (Any?) -> T?) -> T { + let preferenceValue = sharedDefaults.object(forKey: key) + if let preferenceConverted = decoder(preferenceValue) { + return preferenceConverted + } else { + if let preferenceValue { + Logger.preferences.error("Preference value \(key) was \(String(describing: preferenceValue)) but did not conform to \(T.self). Replace with default value.") + } else { + Logger.preferences.info("Preference value \(key) was set for the first time. Using default value.") + } + let fallback = defaultValue + sharedDefaults.set(encoder(fallback), forKey: key) + return fallback + } + } + + @MainActor fileprivate static func preferenceChanged(newValue: T, key: String, isHomeProperty: Bool, subject: CurrentValueSubject, sanitize: (T) -> (T?) = { $0 }, converter: (T) -> (some Sendable)?) { + guard let sanitized = sanitize(newValue) else { + Logger.preferences.debug("Preference \(key) new value \(String(describing: newValue), privacy: .private) could not be sanitized, will be ignored") + return + } + let convertedValue = converter(sanitized) + guard convertedValue != nil else { + Logger.preferences.debug("Preference \(key) conversion of new value \(String(describing: sanitized), privacy: .private) failed, do not store.") + return + } + Logger.preferences.debug("Preference \(key) will be changed to value \(String(describing: newValue), privacy: .private)") + sharedDefaults.set(convertedValue, forKey: key) + + subject.send(sanitized) + } +} -@MainActor public actor Preferences { public static let shared = Preferences() + private static let defaultHomeId = UUID() + /// the currently applied settings set from storedHomes - @Preference(\.currentHomePreferences) + @UserDefaultObject("currentHomePreferences", defaultValue: HomePreferences(id: defaultHomeId)) public private(set) var currentHomePreferences: HomePreferences - @Preference(\.sendCrashReports) + @UserDefault("sendCrashReports", defaultValue: false) public var sendCrashReports: Bool - @Preference(\.idleOff) + @UserDefault("idleOff", defaultValue: false) public var idleOff: Bool - @Preference(\.applicationPreferences) + @UserDefaultObject( + "applicationPreferences", + defaultValue: + ApplicationPreferences() + ) public private(set) var applicationPreferences: ApplicationPreferences - @Preference(\.screensaverEnabled) + @UserDefault("screensaverEnabled", defaultValue: false) public var screensaverEnabled: Bool - @Preference(\.screensaverShowsTime) + @UserDefault("screensaverShowsTime", defaultValue: true) public var screensaverShowsTime: Bool - @Preference(\.screensaverShowsDate) + @UserDefault("screensaverShowsDate", defaultValue: true) public var screensaverShowsDate: Bool - @Preference(\.screensaverIdleInterval) + @UserDefault("screensaverIdleInterval", defaultValue: 120.0) public var screensaverIdleInterval: Double - @Preference(\.screensaverMovementInterval) + @UserDefault("screensaverMovementInterval", defaultValue: 8.0) public var screensaverMovementInterval: Double - @Preference(\.screensaverFontName) + @UserDefault("screensaverFontName", defaultValue: "") public var screensaverFontName: String - @Preference(\.screensaverTimeFontRatio) + @UserDefault("screensaverTimeFontRatio", defaultValue: 0.2) public var screensaverTimeFontRatio: Double - @Preference(\.screensaverDateFontRatio) + @UserDefault("screensaverDateFontRatio", defaultValue: 0.4) public var screensaverDateFontRatio: Double - @Preference(\.screensaverEnableDimming) + @UserDefault("screensaverEnableDimming", defaultValue: true) public var screensaverEnableDimming: Bool - @Preference(\.screensaverDimLevel) + @UserDefault("screensaverDimLevel", defaultValue: 0.3) public var screensaverDimLevel: Double - @Preference(\.screensaverShowsSeconds) + @UserDefault("screensaverShowsSeconds", defaultValue: false) public var screensaverShowsSeconds: Bool - @Preference(\.screensaverUse24Hour) + @UserDefault("screensaverUse24Hour", defaultValue: false) public var screensaverUse24Hour: Bool - @Preference(\.screensaverFadeDuration) + @UserDefault("screensaverFadeDuration", defaultValue: 2.0) public var screensaverFadeDuration: Double - @Preference(\.screensaverRestoreBrightness) + @UserDefault("screensaverRestoreBrightness", defaultValue: true) public var screensaverRestoreBrightness: Bool - @Preference(\.screensaverWakeBrightness) + @UserDefault("screensaverWakeBrightness", defaultValue: 1.0) public var screensaverWakeBrightness: Double - @Preference(\.hideStatusBar) + @UserDefault("hideStatusBar", defaultValue: false) public var hideStatusBar: Bool - @Preference(\.currentWebViewPath) + @UserDefault("currentWebViewPath", defaultValue: "") public var currentWebViewPath: String /// settings for different homes - @Preference(\.storedHomes) + @UserDefaultObject("storedHomes", defaultValue: [:]) public private(set) var storedHomes: [UUID: HomePreferences] /// the currently applied settings set from storedHomes - @Preference(\.activeHomeId) + @UserDefaultObject("activeHomeId", defaultValue: defaultHomeId) private var activeHomeId: UUID + @UserDefault("didMigrateToSharedDefaults", defaultValue: false) + private var didMigrateToSharedDefaults: Bool + + @UserDefault("didMigrateToMultipleHomes", defaultValue: false) + private var didMigrateToMultipleHomes: Bool + + @MainActor private var internalPreferenceChangeOngoing = false + @MainActor private func internalPreferenceChange(_ change: () -> Void) { internalPreferenceChangeOngoing = true change() @@ -467,6 +261,7 @@ public actor Preferences { // MARK: Multiple homes +@MainActor public extension Preferences { func listStoredHomes() -> [UUID] { let preferenceIds = storedHomes @@ -554,20 +349,21 @@ public extension Preferences { Logger.preferences.debug("Stored preferences for current home \(homeId.uuidString)") } - func modifyActiveHome(modificationFunction: (inout HomePreferences) -> Void) { + func modifyActiveHome(modificationFunction: @MainActor (inout HomePreferences) -> Void) { var homePreferences = currentHomePreferences modificationFunction(&homePreferences) currentHomePreferences = homePreferences storeActiveHome() } - func modifyApplicationPreferences(modificationFunction: (inout ApplicationPreferences) -> Void) { + func modifyApplicationPreferences(modificationFunction: @MainActor (inout ApplicationPreferences) -> Void) { var applicationPreferences = applicationPreferences modificationFunction(&applicationPreferences) self.applicationPreferences = applicationPreferences } } +@MainActor public extension Preferences { func firstStoredHome(where predicate: (HomePreferences) -> Bool) -> (id: UUID, record: HomePreferences)? { for (uuid, record) in storedHomes { @@ -586,24 +382,88 @@ public extension Preferences { // MARK: Migration +@MainActor public extension Preferences { static func migratePreferences() { - PreferencesStoreAccess.migrateIfNeeded() Preferences.shared.initializeStoredHomes() + migrateToSharedDefaultsIfRequired() + migrateToMultipleHomesIfRequired() + } + + private static func migrateToSharedDefaultsIfRequired() { + guard !Preferences.shared.didMigrateToSharedDefaults else { return } + + Preferences.shared.modifyActiveHome { currentHomePreferences in + currentHomePreferences.localConnectionConfig.url = UserDefaults.standard.string(forKey: "localUrl") ?? currentHomePreferences.localConnectionConfig.url + currentHomePreferences.localConnectionConfig.alwaysSendBasicAuth = UserDefaults.standard.object(forKey: "alwaysSendCreds") as? Bool ?? currentHomePreferences.localConnectionConfig.alwaysSendBasicAuth + currentHomePreferences.localConnectionConfig.ignoreSSL = UserDefaults.standard.object(forKey: "ignoreSSL") as? Bool ?? currentHomePreferences.localConnectionConfig.ignoreSSL + currentHomePreferences.remoteConnectionConfig.url = UserDefaults.standard.string(forKey: "remoteUrl") ?? currentHomePreferences.remoteConnectionConfig.url + currentHomePreferences.remoteConnectionConfig.username = UserDefaults.standard.string(forKey: "username") ?? currentHomePreferences.remoteConnectionConfig.username + currentHomePreferences.remoteConnectionConfig.password = UserDefaults.standard.string(forKey: "password") ?? currentHomePreferences.remoteConnectionConfig.password + currentHomePreferences.remoteConnectionConfig.alwaysSendBasicAuth = UserDefaults.standard.object(forKey: "alwaysSendCreds") as? Bool ?? currentHomePreferences.remoteConnectionConfig.alwaysSendBasicAuth + currentHomePreferences.remoteConnectionConfig.ignoreSSL = UserDefaults.standard.object(forKey: "ignoreSSL") as? Bool ?? currentHomePreferences.remoteConnectionConfig.ignoreSSL + currentHomePreferences.demomode = UserDefaults.standard.object(forKey: "demomode") as? Bool ?? currentHomePreferences.demomode + currentHomePreferences.realTimeSliders = UserDefaults.standard.object(forKey: "realTimeSliders") as? Bool ?? currentHomePreferences.realTimeSliders + currentHomePreferences.iconType = UserDefaults.standard.object(forKey: "iconType") as? Int ?? currentHomePreferences.iconType + currentHomePreferences.defaultSitemap = UserDefaults.standard.string(forKey: "defaultSitemap") ?? currentHomePreferences.defaultSitemap + } + + Preferences.shared.idleOff = UserDefaults.standard.object(forKey: "idleOff") as? Bool ?? Preferences.shared.idleOff + Preferences.shared.sendCrashReports = UserDefaults.standard.object(forKey: "sendCrashReports") as? Bool ?? Preferences.shared.sendCrashReports + + Preferences.shared.didMigrateToSharedDefaults = true + // this was done implicitly + Preferences.shared.didMigrateToMultipleHomes = true } -} -#if DEBUG -public extension Preferences { - static func reloadForTesting() { - PreferencesStoreAccess.shared.reloadFromDefaultsForTesting() - Preferences.shared.initializeStoredHomes() + private static func migrateToMultipleHomesIfRequired() { + guard !Preferences.shared.didMigrateToMultipleHomes else { return } + + migrateToSharedDefaultsIfRequired() + + let oldLocalUrl = sharedDefaults.string(forKey: "localUrl") + let oldRemoteUrl = sharedDefaults.string(forKey: "remoteUrl") + let oldUsername = sharedDefaults.string(forKey: "username") + let oldPassword = sharedDefaults.string(forKey: "password") + let oldAlwaysSendCreds = sharedDefaults.object(forKey: "alwaysSendCreds") as? Bool + let oldIgnoreSSL = sharedDefaults.object(forKey: "ignoreSSL") as? Bool + + // Create new configuration + var newLocalConfiguration = Preferences.shared.currentHomePreferences.localConnectionConfig + newLocalConfiguration.url = oldLocalUrl ?? newLocalConfiguration.url + newLocalConfiguration.alwaysSendBasicAuth = oldAlwaysSendCreds ?? newLocalConfiguration.alwaysSendBasicAuth + newLocalConfiguration.ignoreSSL = oldIgnoreSSL ?? newLocalConfiguration.ignoreSSL + + var newRemoteConfiguration = Preferences.shared.currentHomePreferences.remoteConnectionConfig + newRemoteConfiguration.url = oldRemoteUrl ?? newRemoteConfiguration.url + newRemoteConfiguration.username = oldUsername ?? newRemoteConfiguration.username + newRemoteConfiguration.password = oldPassword ?? newRemoteConfiguration.password + newRemoteConfiguration.alwaysSendBasicAuth = oldAlwaysSendCreds ?? newRemoteConfiguration.alwaysSendBasicAuth + newRemoteConfiguration.ignoreSSL = oldIgnoreSSL ?? newRemoteConfiguration.ignoreSSL + + // Save to Preferences + Preferences.shared.modifyActiveHome { currentHomePreferences in + currentHomePreferences.defaultView = sharedDefaults.string(forKey: "defaultView") ?? currentHomePreferences.defaultView + currentHomePreferences.demomode = sharedDefaults.object(forKey: "demomode") as? Bool ?? currentHomePreferences.demomode + currentHomePreferences.realTimeSliders = sharedDefaults.object(forKey: "realTimeSliders") as? Bool ?? currentHomePreferences.realTimeSliders + currentHomePreferences.iconType = sharedDefaults.object(forKey: "iconType") as? Int ?? currentHomePreferences.iconType + currentHomePreferences.defaultSitemap = sharedDefaults.string(forKey: "defaultSitemap") ?? currentHomePreferences.defaultSitemap + currentHomePreferences.sortSitemapsBy = sharedDefaults.object(forKey: "sortSitemapsBy") as? Int ?? currentHomePreferences.sortSitemapsBy + currentHomePreferences.defaultMainUIPath = sharedDefaults.string(forKey: "defaultMainUIPath") ?? currentHomePreferences.defaultMainUIPath + currentHomePreferences.alwaysAllowWebRTC = sharedDefaults.object(forKey: "alwaysAllowWebRTC") as? Bool ?? currentHomePreferences.alwaysAllowWebRTC + currentHomePreferences.sitemapForWatch = sharedDefaults.string(forKey: "sitemapForWatch") ?? currentHomePreferences.sitemapForWatch + currentHomePreferences.localConnectionConfig = newLocalConfiguration + currentHomePreferences.remoteConnectionConfig = newRemoteConfiguration + currentHomePreferences.sitemapForWatchLabel = sharedDefaults.string(forKey: "sitemapForWatchLabel") ?? currentHomePreferences.sitemapForWatchLabel + } + + Preferences.shared.didMigrateToMultipleHomes = true } } -#endif // MARK: All connections +@MainActor public extension Preferences { func getNotificationConnection() -> ConnectionConfiguration? { getNotificationConnection(of: [Preferences.shared.currentHomePreferences.remoteConnectionConfig]) diff --git a/OpenHABCore/Tests/OpenHABCoreTests/UserDefaultsTests.swift b/OpenHABCore/Tests/OpenHABCoreTests/UserDefaultsTests.swift index 60fc4a537..5219fffe6 100644 --- a/OpenHABCore/Tests/OpenHABCoreTests/UserDefaultsTests.swift +++ b/OpenHABCore/Tests/OpenHABCoreTests/UserDefaultsTests.swift @@ -27,8 +27,6 @@ final class UserDefaultsTests: XCTestCase { fatalError() } data.removePersistentDomain(forName: defaultsName) - data.removePersistentDomain(forName: "group.org.openhab.app") - Preferences.reloadForTesting() let random: String = UUID().uuidString @@ -67,63 +65,6 @@ final class UserDefaultsTests: XCTestCase { XCTAssertEqual(Preferences.shared.currentHomePreferences.iconType, home.iconType) XCTAssertEqual(Preferences.shared.currentHomePreferences.defaultSitemap, home.defaultSitemap) XCTAssertEqual(Preferences.shared.currentHomePreferences.sitemapForWatch, home.sitemapForWatch) - - let storeData = try XCTUnwrap(data.data(forKey: "preferencesStore")) - let storeObject = try XCTUnwrap(JSONSerialization.jsonObject(with: storeData) as? [String: Any]) - XCTAssertEqual(storeObject["version"] as? Int, 1) - XCTAssertNotNil(storeObject["currentHomePreferences"]) - } - - func testPreferencesStoreForwardCompatibility() throws { - let data = UserDefaults(suiteName: "group.org.openhab.app")! - data.removePersistentDomain(forName: "group.org.openhab.app") - - Preferences.reloadForTesting() - let home = Preferences.shared.currentHomePreferences - let homeId = home.id - let homeData = try JSONEncoder().encode(home) - let homeObject = try XCTUnwrap(JSONSerialization.jsonObject(with: homeData) as? [String: Any]) - - let appPrefs = ApplicationPreferences() - let appPrefsData = try JSONEncoder().encode(appPrefs) - let appPrefsObject = try XCTUnwrap(JSONSerialization.jsonObject(with: appPrefsData) as? [String: Any]) - - let storedHomesObject: [String: Any] = [homeId.uuidString: homeObject] - - var legacyStore: [String: Any] = [ - "version": 1, - "currentHomePreferences": homeObject, - "storedHomes": storedHomesObject, - "activeHomeId": homeId.uuidString, - "applicationPreferences": appPrefsObject, - "sendCrashReports": true, - "idleOff": true, - "screensaverEnabled": false, - "screensaverShowsTime": true, - "screensaverShowsDate": true, - "screensaverIdleInterval": 120.0, - "screensaverMovementInterval": 8.0, - "screensaverFontName": "", - "screensaverTimeFontRatio": 0.2, - "screensaverDateFontRatio": 0.4, - "screensaverEnableDimming": true, - "screensaverDimLevel": 0.3, - "screensaverShowsSeconds": false, - "screensaverUse24Hour": false, - "screensaverFadeDuration": 2.0, - "screensaverRestoreBrightness": true, - "screensaverWakeBrightness": 1.0, - "hideStatusBar": false - ] - - // Deliberately omit currentWebViewPath to simulate older payloads - let legacyData = try JSONSerialization.data(withJSONObject: legacyStore) - data.set(legacyData, forKey: "preferencesStore") - - Preferences.reloadForTesting() - - XCTAssertTrue(Preferences.shared.sendCrashReports) - XCTAssertTrue(Preferences.shared.idleOff) - XCTAssertEqual(Preferences.shared.currentWebViewPath, "") + XCTAssertEqual(home, try? JSONDecoder().decode(HomePreferences.self, from: data.data(forKey: "currentHomePreferences")!)) } } From 4834cfb934d4795267752493b800efd905b174c7 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Wed, 4 Feb 2026 22:24:32 +0100 Subject: [PATCH 401/476] Update SegmentedRowView layout and add shutter preview - Add Office Shutter two-button pressRelease preview - Restore consistent Spacer logic for button alignment Signed-off-by: Tim Mueller-Seydlitz --- openHAB/SwiftUI/Rows/SegmentedRowView.swift | 26 ++++++++++++++++----- 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/openHAB/SwiftUI/Rows/SegmentedRowView.swift b/openHAB/SwiftUI/Rows/SegmentedRowView.swift index a729aa4c3..c9d47e47f 100644 --- a/openHAB/SwiftUI/Rows/SegmentedRowView.swift +++ b/openHAB/SwiftUI/Rows/SegmentedRowView.swift @@ -59,20 +59,20 @@ struct SegmentedRowView: View { if !mappings.isEmpty { if widget.hasPressReleaseMappings { // Press-release buttons for mappings with releaseCommand - if widget.labelValue.isNilOrEmpty { + if !(widget.labelValue?.isEmpty == false) { Spacer(minLength: 8) } pressReleaseButtons .fixedSize(horizontal: true, vertical: false) .padding(.leading, 8) } else if mappings.count == 1 { + if !(widget.labelValue?.isEmpty == false) { + Spacer(minLength: 8) + } singleMappingButton - .frame(maxWidth: .infinity, alignment: .trailing) + .fixedSize(horizontal: true, vertical: false) .padding(.leading, 8) } else { - if widget.labelValue.isNilOrEmpty { - Spacer(minLength: 8) - } // Button-based segmented control with animated selection indicator segmentedButtons .frame(minWidth: 75) @@ -481,13 +481,27 @@ private extension SegmentedRowView { detailLabel: "NA", mappings: [ OpenHABWidgetMapping(command: "DOWN", label: "DOWN", releaseCommand: "OFF"), - OpenHABWidgetMapping(command: "UP", label: " UP ", releaseCommand: "UP"), + OpenHABWidgetMapping(command: "UP", label: " UP ", releaseCommand: "OFF") ], selectedState: "DOWN" ), fallbackSymbol: .romanShadeClosed ) + + SegmentedRowView( + widget: SegmentedRowView.createPreviewWidget( + label: "Office Shutter", + detailLabel: "NA", + mappings: [ + OpenHABWidgetMapping(command: "DOWN", label: "DOWN", releaseCommand: "OFF"), + OpenHABWidgetMapping(command: "UP", label: "UP", releaseCommand: "OFF") + ], + selectedState: "DOWN" + ), + fallbackSymbol: .romanShadeClosed + ) + SegmentedRowView( widget: SegmentedRowView.createPreviewWidget( label: "Scene", From 79f7d85575a49471dec01492b914f0246e5b0e1a Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Wed, 4 Feb 2026 22:30:33 +0100 Subject: [PATCH 402/476] Use isNilOrEmpty for singleMappingButton spacer check Signed-off-by: Tim Mueller-Seydlitz --- openHAB/SwiftUI/Rows/SegmentedRowView.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/openHAB/SwiftUI/Rows/SegmentedRowView.swift b/openHAB/SwiftUI/Rows/SegmentedRowView.swift index c9d47e47f..77a76cfc0 100644 --- a/openHAB/SwiftUI/Rows/SegmentedRowView.swift +++ b/openHAB/SwiftUI/Rows/SegmentedRowView.swift @@ -65,8 +65,9 @@ struct SegmentedRowView: View { pressReleaseButtons .fixedSize(horizontal: true, vertical: false) .padding(.leading, 8) - } else if mappings.count == 1 { - if !(widget.labelValue?.isEmpty == false) { + + } else if mappings.count == 1 { + if widget.labelValue.isNilOrEmpty { Spacer(minLength: 8) } singleMappingButton From d681b495acb441d9a3e12e3bfad119e4868bb983 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Wed, 4 Feb 2026 22:42:28 +0100 Subject: [PATCH 403/476] Increase spacing between pressRelease buttons Signed-off-by: Tim Mueller-Seydlitz --- openHAB/SwiftUI/Rows/SegmentedRowView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openHAB/SwiftUI/Rows/SegmentedRowView.swift b/openHAB/SwiftUI/Rows/SegmentedRowView.swift index 77a76cfc0..19bbb6f9c 100644 --- a/openHAB/SwiftUI/Rows/SegmentedRowView.swift +++ b/openHAB/SwiftUI/Rows/SegmentedRowView.swift @@ -131,7 +131,7 @@ struct SegmentedRowView: View { @ViewBuilder private var pressReleaseButtons: some View { - HStack(spacing: 4) { + HStack(spacing: 8) { ForEach(mappings.indices, id: \.self) { index in pressReleaseButton(for: mappings[index], at: index) } From 24f4942a2bc4219aae9d219a8bbf0e708744a507 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Thu, 5 Feb 2026 14:46:06 +0100 Subject: [PATCH 404/476] Show selected state on single mapping button Align iOS behavior with Android by highlighting the single mapping button when the item's state matches the mapping command. Signed-off-by: Tim Mueller-Seydlitz --- openHAB/SwiftUI/Rows/SegmentedRowView.swift | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/openHAB/SwiftUI/Rows/SegmentedRowView.swift b/openHAB/SwiftUI/Rows/SegmentedRowView.swift index 19bbb6f9c..e0908da24 100644 --- a/openHAB/SwiftUI/Rows/SegmentedRowView.swift +++ b/openHAB/SwiftUI/Rows/SegmentedRowView.swift @@ -65,8 +65,8 @@ struct SegmentedRowView: View { pressReleaseButtons .fixedSize(horizontal: true, vertical: false) .padding(.leading, 8) - - } else if mappings.count == 1 { + + } else if mappings.count == 1 { if widget.labelValue.isNilOrEmpty { Spacer(minLength: 8) } @@ -140,9 +140,16 @@ struct SegmentedRowView: View { @State private var singleButtonPressed = false + /// Whether the single mapping button is selected (item state matches the mapping command) + private var isSingleMappingSelected: Bool { + guard let state = widget.item?.state else { return false } + return state == mappings[0].command + } + @ViewBuilder private var singleMappingButton: some View { let mapping = mappings[0] + let isSelected = isSingleMappingSelected Text(mapping.label) .font(.footnote) @@ -156,7 +163,7 @@ struct SegmentedRowView: View { .background( RoundedRectangle(cornerRadius: 6) .fill( - singleButtonPressed + singleButtonPressed || isSelected ? (colorScheme == .dark ? Color(uiColor: .systemGray2) : Color(uiColor: .systemBackground)) : Color.clear ) From c23351a04bdeb8208a363195922e792ab5c56c51 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Thu, 5 Feb 2026 22:38:47 +0100 Subject: [PATCH 405/476] Fix slider jumping by always sending final value and adding step - Always send slider value on release, not just in releaseOnly mode - Add step parameter to Slider to match watchOS and prevent continuous vs step-adjusted value mismatch - Only clear pendingValue when server confirms the value we sent, preventing jumps from intermediate throttled responses Signed-off-by: Tim Mueller-Seydlitz --- openHAB/SwiftUI/Rows/SliderRowView.swift | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/openHAB/SwiftUI/Rows/SliderRowView.swift b/openHAB/SwiftUI/Rows/SliderRowView.swift index aaafd539e..22c88ff21 100644 --- a/openHAB/SwiftUI/Rows/SliderRowView.swift +++ b/openHAB/SwiftUI/Rows/SliderRowView.swift @@ -70,11 +70,11 @@ struct SliderRowView: View { labelContent } - Slider(value: valueBinding, in: sliderRange) { editing in + Slider(value: valueBinding, in: sliderRange, step: widget.step) { editing in isEditing = editing if !editing { - // User released slider - send final value for release-only mode - if !widget.shouldUseSliderUpdatesDuringMove(), let value = pendingValue { + // Always send the final value on release + if let value = pendingValue { sendSliderUpdate(value) } // Keep pendingValue set until server responds to avoid visual jump @@ -90,8 +90,9 @@ struct SliderRowView: View { .disabled(widget.readOnly ?? false) } .onChange(of: widget.adjustedValue) { _ in - // Clear pending value when server responds (and user is not editing) - if !isEditing { + // Clear pending value only when server confirms our value (not intermediate responses) + if !isEditing, let pending = pendingValue, + abs(widget.adjustedValue - pending) < max(widget.step * 0.5, 0.01) { pendingValue = nil } } From 0771e678adafd21f1df1f03e0a2438a28b8a47f7 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Thu, 5 Feb 2026 23:03:44 +0100 Subject: [PATCH 406/476] Send single mapping button command on touch down Simplify the drag gesture to send the command immediately on touch instead of waiting for release, removing the pressed state tracking. Signed-off-by: Tim Mueller-Seydlitz --- openHAB/SwiftUI/Rows/SegmentedRowView.swift | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/openHAB/SwiftUI/Rows/SegmentedRowView.swift b/openHAB/SwiftUI/Rows/SegmentedRowView.swift index e0908da24..cbffabc4d 100644 --- a/openHAB/SwiftUI/Rows/SegmentedRowView.swift +++ b/openHAB/SwiftUI/Rows/SegmentedRowView.swift @@ -172,13 +172,7 @@ struct SegmentedRowView: View { .gesture( DragGesture(minimumDistance: 0) .onChanged { _ in - if !singleButtonPressed { - singleButtonPressed = true - } - } - .onEnded { _ in - singleButtonPressed = false - logger.info("Segment tapped: 0, command: \(mapping.command)") + logger.info("Segment mapping tapped:, command: \(mapping.command)") viewModel.sendCommand(widget.item, commandToSend: mapping.command) } ) From 1f28532092fd417bdcf4355613daef948af1fff4 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Fri, 6 Feb 2026 08:36:22 +0100 Subject: [PATCH 407/476] Fix single mapping button sending duplicate commands Guard against repeated sends in onChanged by tracking singlePressed state. Add press/release visual feedback and selection animation. Signed-off-by: Tim Mueller-Seydlitz --- openHAB/SwiftUI/Rows/SegmentedRowView.swift | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/openHAB/SwiftUI/Rows/SegmentedRowView.swift b/openHAB/SwiftUI/Rows/SegmentedRowView.swift index cbffabc4d..b6d36de62 100644 --- a/openHAB/SwiftUI/Rows/SegmentedRowView.swift +++ b/openHAB/SwiftUI/Rows/SegmentedRowView.swift @@ -32,6 +32,7 @@ struct SegmentedRowView: View { @State private var selectedIndex: Int? @State private var pressedIndex: Int? + @State private var singlePressed: Bool = false var body: some View { HStack(spacing: 0) { @@ -138,8 +139,6 @@ struct SegmentedRowView: View { } } - @State private var singleButtonPressed = false - /// Whether the single mapping button is selected (item state matches the mapping command) private var isSingleMappingSelected: Bool { guard let state = widget.item?.state else { return false } @@ -163,7 +162,7 @@ struct SegmentedRowView: View { .background( RoundedRectangle(cornerRadius: 6) .fill( - singleButtonPressed || isSelected + (singlePressed || isSelected) ? (colorScheme == .dark ? Color(uiColor: .systemGray2) : Color(uiColor: .systemBackground)) : Color.clear ) @@ -172,8 +171,14 @@ struct SegmentedRowView: View { .gesture( DragGesture(minimumDistance: 0) .onChanged { _ in - logger.info("Segment mapping tapped:, command: \(mapping.command)") - viewModel.sendCommand(widget.item, commandToSend: mapping.command) + if singlePressed == false { + singlePressed = true + logger.info("Segment mapping pressed, command: \(mapping.command)") + viewModel.sendCommand(widget.item, commandToSend: mapping.command) + } + } + .onEnded { _ in + singlePressed = false } ) .background( @@ -188,6 +193,8 @@ struct SegmentedRowView: View { RoundedRectangle(cornerRadius: 7) .stroke(Color.secondary.opacity(0.3), lineWidth: 0.5) ) + .animation(.spring(response: 0.3, dampingFraction: 0.7), value: isSelected) + .animation(.easeInOut(duration: 0.1), value: singlePressed) } // MARK: - Helper Methods From 78a8a47a42f2f5cffd3c12b1ecbacd3d5252dc9d Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Fri, 6 Feb 2026 14:47:36 +0100 Subject: [PATCH 408/476] Fix crash, bugs, and duplication from PR review - Replace force unwrap with guard let in loadCurrentPage() to prevent crash when pollDataForPage returns nil - Remove duplicate property assignments in OpenHABWidget convenience init that overwrote nil-coalesced defaults with raw optionals - Fix ButtonGridButton sending commands twice for press-release buttons by separating Button action (regular taps) from gesture (press-release) - Fix onPressGesture firing onPress on every drag movement by using a stateful ViewModifier that guards against re-entry - Extract duplicate PreviewList struct into PreviewConstants.swift Signed-off-by: Tim Mueller-Seydlitz --- .../OpenHABCore/Model/OpenHABWidget.swift | 9 ++-- openHAB/PreviewConstants.swift | 17 +++++++ openHAB/SitemapPageViewModel.swift | 11 +++-- openHAB/SwiftUI/Rows/ButtonGridRowView.swift | 44 +++++++++++++++---- openHAB/SwiftUI/Rows/SegmentedRowView.swift | 16 ------- openHAB/SwiftUI/Rows/SliderRowView.swift | 15 ------- 6 files changed, 63 insertions(+), 49 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift index 8e02d107f..1a91a0311 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABWidget.swift @@ -372,9 +372,9 @@ public extension OpenHABWidget { self.switchSupport = switchSupport ?? false self.forceAsItem = forceAsItem - self.unit = unit ?? "" - self.pattern = pattern ?? "" - self.staticIcon = staticIcon ?? false + self.unit = unit + self.pattern = pattern + self.staticIcon = staticIcon self.labelSource = labelSource self.releaseOnly = releaseOnly self.row = row @@ -382,9 +382,6 @@ public extension OpenHABWidget { self.releaseCommand = releaseCommand self.command = command self.stateless = stateless - self.staticIcon = staticIcon - self.unit = unit - self.pattern = pattern self.yAxisDecimalPattern = yAxisDecimalPattern } diff --git a/openHAB/PreviewConstants.swift b/openHAB/PreviewConstants.swift index 3d883de81..f0f41ab7d 100644 --- a/openHAB/PreviewConstants.swift +++ b/openHAB/PreviewConstants.swift @@ -12,6 +12,7 @@ import Foundation import OpenHABCore import os.log +import SwiftUI // swiftlint:disable type_body_length enum PreviewConstants { @@ -579,3 +580,19 @@ enum PreviewConstants { } // swiftlint:enable type_body_length + +/// Wrapper for consistent preview list styling matching SitemapPageView +struct PreviewList: View { + @ViewBuilder let content: () -> Content + + var body: some View { + List { + content() + .listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16)) + } + .listStyle(.plain) + .listRowSpacing(0) + .environment(\.defaultMinListRowHeight, 32) + .environmentObject(SitemapPageViewModel()) + } +} diff --git a/openHAB/SitemapPageViewModel.swift b/openHAB/SitemapPageViewModel.swift index 6158b66d1..a11724cbf 100644 --- a/openHAB/SitemapPageViewModel.swift +++ b/openHAB/SitemapPageViewModel.swift @@ -20,6 +20,7 @@ private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "org.open enum SitemapPageError: LocalizedError { case noActiveConnection case serviceUnavailable + case noData var errorDescription: String? { switch self { @@ -27,6 +28,8 @@ enum SitemapPageError: LocalizedError { "No active connection available." case .serviceUnavailable: "Service unavailable." + case .noData: + "No page data received." } } } @@ -282,13 +285,15 @@ class SitemapPageViewModel: ObservableObject { private func loadCurrentPage() async throws { guard let service = openAPIService else { throw SitemapPageError.serviceUnavailable } - let page = try await service.pollDataForPage( + guard let page = try await service.pollDataForPage( sitemapname: defaultSitemap, pageId: pageId, longPolling: false - ) + ) else { + throw SitemapPageError.noData + } - injectSendCommand(for: page!.widgets) + injectSendCommand(for: page.widgets) currentPage = page } diff --git a/openHAB/SwiftUI/Rows/ButtonGridRowView.swift b/openHAB/SwiftUI/Rows/ButtonGridRowView.swift index 04541214f..c14de8506 100644 --- a/openHAB/SwiftUI/Rows/ButtonGridRowView.swift +++ b/openHAB/SwiftUI/Rows/ButtonGridRowView.swift @@ -28,10 +28,21 @@ struct ButtonGridButton: View { return widget.item?.state == widget.command } + private var hasPressRelease: Bool { + if let releaseCommand = widget.releaseCommand, !releaseCommand.isEmpty { + return true + } + return false + } + var body: some View { Button { - triggerFeedback.toggle() - handleButtonPress() + // Only handle tap for non-press-release buttons; + // press-release buttons are handled entirely by the gesture + if !hasPressRelease { + triggerFeedback.toggle() + handleButtonPress() + } } label: { HStack { if !widget.icon.isEmpty { @@ -71,7 +82,6 @@ struct ButtonGridButton: View { } private func handleButtonPress() { - // Send command on tap for mappings if let command = widget.command, !command.isEmpty { logger.info("Sending command: \(command)") viewModel.sendCommand(widget.item, commandToSend: widget.command) @@ -79,18 +89,20 @@ struct ButtonGridButton: View { } private func handleTouchDown() { + guard !isPressed else { return } isPressed = true - // For buttons with releaseCommand, send command on press - if let releaseCommand = widget.releaseCommand, !releaseCommand.isEmpty, - let command = widget.command { + // For press-release buttons, send command on press + if hasPressRelease, let command = widget.command { + triggerFeedback.toggle() logger.info("Sending press command: \(command)") widget.sendCommand(command) } } private func handleTouchUp() { + guard isPressed else { return } isPressed = false - // For buttons with releaseCommand, send release command on release + // For press-release buttons, send release command on release if let releaseCommand = widget.releaseCommand, !releaseCommand.isEmpty { logger.info("Sending release command: \(releaseCommand)") widget.sendCommand(releaseCommand) @@ -177,12 +189,26 @@ struct ButtonGridRowView: View { extension View { func onPressGesture(onPress: @escaping () -> Void, onRelease: @escaping () -> Void) -> some View { - gesture( + modifier(PressGestureModifier(onPress: onPress, onRelease: onRelease)) + } +} + +private struct PressGestureModifier: ViewModifier { + let onPress: () -> Void + let onRelease: () -> Void + @State private var pressed = false + + func body(content: Content) -> some View { + content.gesture( DragGesture(minimumDistance: 0) .onChanged { _ in - onPress() + if !pressed { + pressed = true + onPress() + } } .onEnded { _ in + pressed = false onRelease() } ) diff --git a/openHAB/SwiftUI/Rows/SegmentedRowView.swift b/openHAB/SwiftUI/Rows/SegmentedRowView.swift index b6d36de62..905d71a45 100644 --- a/openHAB/SwiftUI/Rows/SegmentedRowView.swift +++ b/openHAB/SwiftUI/Rows/SegmentedRowView.swift @@ -272,22 +272,6 @@ struct SegmentedRowView: View { // MARK: - Preview Helpers #if DEBUG -/// Wrapper for consistent preview list styling matching SitemapPageView -private struct PreviewList: View { - @ViewBuilder let content: () -> Content - - var body: some View { - List { - content() - .listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16)) - } - .listStyle(.plain) - .listRowSpacing(0) - .environment(\.defaultMinListRowHeight, 32) - .environmentObject(SitemapPageViewModel()) - } -} - private extension SegmentedRowView { static func createPreviewWidget(label: String, detailLabel: String? = nil, diff --git a/openHAB/SwiftUI/Rows/SliderRowView.swift b/openHAB/SwiftUI/Rows/SliderRowView.swift index 22c88ff21..0e27ff19b 100644 --- a/openHAB/SwiftUI/Rows/SliderRowView.swift +++ b/openHAB/SwiftUI/Rows/SliderRowView.swift @@ -137,21 +137,6 @@ struct SliderRowView: View { // MARK: - Preview Helpers #if DEBUG -private struct PreviewList: View { - @ViewBuilder let content: () -> Content - - var body: some View { - List { - content() - .listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16)) - } - .listStyle(.plain) - .listRowSpacing(0) - .environment(\.defaultMinListRowHeight, 32) - .environmentObject(SitemapPageViewModel()) - } -} - private extension SliderRowView { static func createPreviewWidget(label: String, value: Double? = nil, From 9fd4930f84722cd359fdbd09a8c6f681e99507d4 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Fri, 6 Feb 2026 14:50:20 +0100 Subject: [PATCH 409/476] Move PreviewList to SegmentedRowView as shared internal type PreviewConstants.swift has no Xcode target membership so types defined there are invisible to the compiler. Place PreviewList in SegmentedRowView.swift as a non-private type inside #if DEBUG so SliderRowView.swift (same module) can reference it too. Signed-off-by: Tim Mueller-Seydlitz --- openHAB/PreviewConstants.swift | 17 ----------------- openHAB/SwiftUI/Rows/SegmentedRowView.swift | 18 +++++++++++++++++- 2 files changed, 17 insertions(+), 18 deletions(-) diff --git a/openHAB/PreviewConstants.swift b/openHAB/PreviewConstants.swift index f0f41ab7d..3d883de81 100644 --- a/openHAB/PreviewConstants.swift +++ b/openHAB/PreviewConstants.swift @@ -12,7 +12,6 @@ import Foundation import OpenHABCore import os.log -import SwiftUI // swiftlint:disable type_body_length enum PreviewConstants { @@ -580,19 +579,3 @@ enum PreviewConstants { } // swiftlint:enable type_body_length - -/// Wrapper for consistent preview list styling matching SitemapPageView -struct PreviewList: View { - @ViewBuilder let content: () -> Content - - var body: some View { - List { - content() - .listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16)) - } - .listStyle(.plain) - .listRowSpacing(0) - .environment(\.defaultMinListRowHeight, 32) - .environmentObject(SitemapPageViewModel()) - } -} diff --git a/openHAB/SwiftUI/Rows/SegmentedRowView.swift b/openHAB/SwiftUI/Rows/SegmentedRowView.swift index 905d71a45..b0c823a68 100644 --- a/openHAB/SwiftUI/Rows/SegmentedRowView.swift +++ b/openHAB/SwiftUI/Rows/SegmentedRowView.swift @@ -32,7 +32,7 @@ struct SegmentedRowView: View { @State private var selectedIndex: Int? @State private var pressedIndex: Int? - @State private var singlePressed: Bool = false + @State private var singlePressed = false var body: some View { HStack(spacing: 0) { @@ -272,6 +272,22 @@ struct SegmentedRowView: View { // MARK: - Preview Helpers #if DEBUG +/// Wrapper for consistent preview list styling matching SitemapPageView +struct PreviewList: View { + @ViewBuilder let content: () -> Content + + var body: some View { + List { + content() + .listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16)) + } + .listStyle(.plain) + .listRowSpacing(0) + .environment(\.defaultMinListRowHeight, 32) + .environmentObject(SitemapPageViewModel()) + } +} + private extension SegmentedRowView { static func createPreviewWidget(label: String, detailLabel: String? = nil, From 7f9e7760cbe882e668601a5643a8b215d628d295 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Fri, 6 Feb 2026 14:53:08 +0100 Subject: [PATCH 410/476] Reorder ButtonGridRowView declarations and remove lint suppression Signed-off-by: Tim Mueller-Seydlitz --- openHAB/SwiftUI/Rows/ButtonGridRowView.swift | 58 ++++++++++---------- openHAB/SwiftUI/Rows/SliderRowView.swift | 1 - 2 files changed, 29 insertions(+), 30 deletions(-) diff --git a/openHAB/SwiftUI/Rows/ButtonGridRowView.swift b/openHAB/SwiftUI/Rows/ButtonGridRowView.swift index c14de8506..8a004251e 100644 --- a/openHAB/SwiftUI/Rows/ButtonGridRowView.swift +++ b/openHAB/SwiftUI/Rows/ButtonGridRowView.swift @@ -110,6 +110,28 @@ struct ButtonGridButton: View { } } +private struct PressGestureModifier: ViewModifier { + let onPress: () -> Void + let onRelease: () -> Void + @State private var pressed = false + + func body(content: Content) -> some View { + content.gesture( + DragGesture(minimumDistance: 0) + .onChanged { _ in + if !pressed { + pressed = true + onPress() + } + } + .onEnded { _ in + pressed = false + onRelease() + } + ) + } +} + struct ButtonGridRowView: View { @ObservedObject var widget: OpenHABWidget @EnvironmentObject var viewModel: SitemapPageViewModel @@ -186,35 +208,6 @@ struct ButtonGridRowView: View { } } -extension View { - func onPressGesture(onPress: @escaping () -> Void, - onRelease: @escaping () -> Void) -> some View { - modifier(PressGestureModifier(onPress: onPress, onRelease: onRelease)) - } -} - -private struct PressGestureModifier: ViewModifier { - let onPress: () -> Void - let onRelease: () -> Void - @State private var pressed = false - - func body(content: Content) -> some View { - content.gesture( - DragGesture(minimumDistance: 0) - .onChanged { _ in - if !pressed { - pressed = true - onPress() - } - } - .onEnded { _ in - pressed = false - onRelease() - } - ) - } -} - // Extension to convert OpenHABWidgetMapping to OpenHABWidget extension OpenHABWidgetMapping { func toWidget(widgetId: String, item: OpenHABItem?) -> OpenHABWidget { @@ -235,6 +228,13 @@ extension OpenHABWidgetMapping { } } +extension View { + func onPressGesture(onPress: @escaping () -> Void, + onRelease: @escaping () -> Void) -> some View { + modifier(PressGestureModifier(onPress: onPress, onRelease: onRelease)) + } +} + #Preview { if let widget = PreviewConstants.openHABSitemapPage!.widgets.first(where: { $0.type == .buttongrid }) { VStack { diff --git a/openHAB/SwiftUI/Rows/SliderRowView.swift b/openHAB/SwiftUI/Rows/SliderRowView.swift index 0e27ff19b..d88d31eb0 100644 --- a/openHAB/SwiftUI/Rows/SliderRowView.swift +++ b/openHAB/SwiftUI/Rows/SliderRowView.swift @@ -14,7 +14,6 @@ import OpenHABCore import SFSafeSymbols import SwiftUI -// swiftlint:disable:next file_types_order struct SliderRowView: View { @ObservedObject var widget: OpenHABWidget var fallbackSymbol: SFSymbol? From 9d69736f7cac2bae8b78dcd85f35538ae23d7e82 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Fri, 6 Feb 2026 17:00:34 +0100 Subject: [PATCH 411/476] Remove empty setupActiveConnectionObserver stub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The method body was empty — connection handling moved to the async for-await loop in init() and the view's onChange modifier. Signed-off-by: Tim Mueller-Seydlitz --- openHAB/SitemapPageViewModel.swift | 7 ------- 1 file changed, 7 deletions(-) diff --git a/openHAB/SitemapPageViewModel.swift b/openHAB/SitemapPageViewModel.swift index a11724cbf..e998dc67f 100644 --- a/openHAB/SitemapPageViewModel.swift +++ b/openHAB/SitemapPageViewModel.swift @@ -83,7 +83,6 @@ class SitemapPageViewModel: ObservableObject { init() { loadSettings() - setupActiveConnectionObserver() // Observe network connection changes directly Task { @MainActor in @@ -95,7 +94,6 @@ class SitemapPageViewModel: ObservableObject { init(pageUrl: String, title: String, pageId: String = "") { loadSettings() - setupActiveConnectionObserver() isLinkedPage = true // Set openHABRootUrl from current active connection for charts/images @@ -387,11 +385,6 @@ class SitemapPageViewModel: ObservableObject { } } - private func setupActiveConnectionObserver() { - // The @ObservedObject will automatically trigger view updates - // We'll handle the connection changes in the view's onChange modifier - } - func handleActiveConnectionChange(_ activeConnection: ConnectionInfo?) { guard let activeConnection else { return } From 7afbc8f8f2cbc26f081c96d72fd06fb7db93bb9b Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Fri, 6 Feb 2026 22:01:37 +0100 Subject: [PATCH 412/476] Defer non-essential AppDelegate setup and add placeholder title Move Firebase, push notifications, audio session, watch connectivity, and screensaver setup into a deferred Task so the UI renders faster. Add a redacted placeholder title in SitemapPageView while loading, and use EmbeddingRowView for placeholder rows. Signed-off-by: Tim Mueller-Seydlitz --- openHAB/AppDelegate.swift | 23 ++++++++++++++++++----- openHAB/SwiftUI/SitemapPageView.swift | 19 +++++++++++++++---- 2 files changed, 33 insertions(+), 9 deletions(-) diff --git a/openHAB/AppDelegate.swift b/openHAB/AppDelegate.swift index 85e9eb744..c0b672d6c 100644 --- a/openHAB/AppDelegate.swift +++ b/openHAB/AppDelegate.swift @@ -55,15 +55,31 @@ class AppDelegate: UIResponder, UIApplicationDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { Logger.appDelegate.info("didFinishLaunchingWithOptions started") + // Only essential setup here - defer everything else to show UI faster let appDefaults = ["CacheDataAgressively": NSNumber(value: true)] UserDefaults.standard.register(defaults: appDefaults) Preferences.migratePreferences() - setupFirebase() - UNUserNotificationCenter.current().delegate = notificationDelegate + Logger.appDelegate.info("didFinishLaunchingWithOptions ended") + + // Defer non-essential initialization to after first frame renders + Task { @MainActor in + // Small delay to ensure UI has appeared + try? await Task.sleep(for: .milliseconds(100)) + performDeferredSetup() + } + + return true + } + + /// Setup that can be deferred until after the UI appears + @MainActor + private func performDeferredSetup() { + setupFirebase() + registerForPushNotifications() Logger.appDelegate.info("uniq id: \(UIDevice.current.identifierForVendor?.uuidString ?? "")") Logger.appDelegate.info("device name: \(UIDevice.current.name)") @@ -74,7 +90,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } catch { Logger.appDelegate.info("Setting category to AVAudioSessionCategoryPlayback failed.") } - Logger.appDelegate.info("didFinishLaunchingWithOptions ended") activateWatchConnectivity() @@ -100,8 +115,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate { ScreenSaverManager.shared.startMonitoring(window: keyWindow, configuration: config) } - - return true } @MainActor diff --git a/openHAB/SwiftUI/SitemapPageView.swift b/openHAB/SwiftUI/SitemapPageView.swift index 047203224..dae18fd8d 100644 --- a/openHAB/SwiftUI/SitemapPageView.swift +++ b/openHAB/SwiftUI/SitemapPageView.swift @@ -25,10 +25,21 @@ struct SitemapPageView: View { Group { if viewModel.isLoading, viewModel.relevantWidgets.isEmpty { // Show skeleton/placeholder rows while loading - List(placeholderWidgets, id: \.id) { widget in - RowViewFactory.view(for: widget) - .redacted(reason: .placeholder) - .disabled(true) + List { + // Redacted large title header + if viewModel.pageTitle.isEmpty { + Text("Placeholder Title") + .font(.largeTitle.bold()) + .redacted(reason: .placeholder) + .listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 8, trailing: 16)) + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) + } + ForEach(placeholderWidgets, id: \.id) { widget in + EmbeddingRowView(widget: widget) + .redacted(reason: .placeholder) + .disabled(true) + } } } else { List(viewModel.relevantWidgets) { widget in From 2167c868eb9f4e58dd7517f349999735b8831058 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Fri, 6 Feb 2026 22:30:12 +0100 Subject: [PATCH 413/476] Fix Task leak, hidden TextField, ignored search pref, and linked-page title - Remove untracked `for await` Task from SitemapPageViewModel.init() that leaked and duplicated connection handling already done by .onChange - Always show TextField in TextInputRowView instead of gating on labelValue - Conditionally apply .searchable in SitemapNavigationView based on the showSearchField preference - Store linked-page title as defaultSitemapLabel so pageTitle returns it immediately Signed-off-by: Tim Mueller-Seydlitz --- openHAB/SitemapPageViewModel.swift | 8 +---- openHAB/SwiftUI/Rows/TextInputRowView.swift | 18 +++++----- openHAB/SwiftUI/SitemapNavigationView.swift | 37 +++++++++++++-------- 3 files changed, 33 insertions(+), 30 deletions(-) diff --git a/openHAB/SitemapPageViewModel.swift b/openHAB/SitemapPageViewModel.swift index e998dc67f..5d58e7d55 100644 --- a/openHAB/SitemapPageViewModel.swift +++ b/openHAB/SitemapPageViewModel.swift @@ -83,18 +83,12 @@ class SitemapPageViewModel: ObservableObject { init() { loadSettings() - - // Observe network connection changes directly - Task { @MainActor in - for await connection in networkTracker.$activeConnection.values { - handleActiveConnectionChange(connection) - } - } } init(pageUrl: String, title: String, pageId: String = "") { loadSettings() isLinkedPage = true + defaultSitemapLabel = title // Set openHABRootUrl from current active connection for charts/images openHABRootUrl = networkTracker.activeConnection?.configuration.url diff --git a/openHAB/SwiftUI/Rows/TextInputRowView.swift b/openHAB/SwiftUI/Rows/TextInputRowView.swift index 35d042706..40620dfc8 100644 --- a/openHAB/SwiftUI/Rows/TextInputRowView.swift +++ b/openHAB/SwiftUI/Rows/TextInputRowView.swift @@ -35,16 +35,14 @@ struct TextInputRowView: View { Spacer() - if let labelValue = widget.labelValue, !labelValue.isEmpty { - TextField("Enter text", text: $inputText) - .multilineTextAlignment(widget.inputHint == .number ? .trailing : .leading) - .textFieldStyle(.roundedBorder) - .focused($isTextFieldFocused) - .onSubmit { - sendTextCommand() - } - .disabled(widget.readOnly ?? false) - } + TextField("Enter text", text: $inputText) + .multilineTextAlignment(widget.inputHint == .number ? .trailing : .leading) + .textFieldStyle(.roundedBorder) + .focused($isTextFieldFocused) + .onSubmit { + sendTextCommand() + } + .disabled(widget.readOnly ?? false) } .onAppear { inputText = widget.item?.state ?? "" diff --git a/openHAB/SwiftUI/SitemapNavigationView.swift b/openHAB/SwiftUI/SitemapNavigationView.swift index 666f7cb54..2cbba0b88 100644 --- a/openHAB/SwiftUI/SitemapNavigationView.swift +++ b/openHAB/SwiftUI/SitemapNavigationView.swift @@ -20,22 +20,33 @@ struct SitemapNavigationView: View { var body: some View { NavigationStack { - SitemapPageView(viewModel: viewModel) - .navigationTitle(viewModel.pageTitle) - .navigationBarTitleDisplayMode(.inline) + sitemapContent + } + } + + @ViewBuilder + private var sitemapContent: some View { + let page = SitemapPageView(viewModel: viewModel) + .navigationTitle(viewModel.pageTitle) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button { + onShowSideMenu() + } label: { + Image(systemSymbol: .line3Horizontal) + .font(.title) + } + } + } + + if viewModel.showSearchField { + page .searchable(text: $viewModel.searchText, prompt: Text(NSLocalizedString("search_items", comment: ""))) .autocorrectionDisabled() .textInputAutocapitalization(.never) - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - Button { - onShowSideMenu() - } label: { - Image(systemSymbol: .line3Horizontal) - .font(.title) - } - } - } + } else { + page } } From 8b10413d9868e58e87f74d3ab765d700b03d420b Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Sat, 7 Feb 2026 02:19:41 +0100 Subject: [PATCH 414/476] Fix connection observer lifecycle and redacted title during loading Restore for-await connection observer in SitemapPageViewModel with proper Task tracking (cancelled in deinit) to fix startup hang. Root page observes all emissions for initial load; linked pages use dropFirst() to defer loading until the view appears via .task. Remove unreliable .onChange observer from SitemapPageView. Return empty pageTitle when no label is available so the navigation bar shows a redacted placeholder instead of the raw sitemap name. Signed-off-by: Tim Mueller-Seydlitz --- openHAB/SitemapPageViewModel.swift | 23 +++++++++++++++++++---- openHAB/SwiftUI/SitemapPageView.swift | 9 +++++---- 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/openHAB/SitemapPageViewModel.swift b/openHAB/SitemapPageViewModel.swift index 5d58e7d55..64f7c9093 100644 --- a/openHAB/SitemapPageViewModel.swift +++ b/openHAB/SitemapPageViewModel.swift @@ -48,6 +48,7 @@ class SitemapPageViewModel: ObservableObject { private var openAPIService: OpenAPIService? private var activeConnectionInfo: ConnectionInfo? private var pageHandlingTask: Task? + private var connectionObserverTask: Task? private var defaultSitemap = "" private var defaultSitemapLabel = "" @Published var pageId = "" @@ -70,10 +71,9 @@ class SitemapPageViewModel: ObservableObject { return title } else if !defaultSitemapLabel.isEmpty { return defaultSitemapLabel - } else if !defaultSitemap.isEmpty { - return defaultSitemap } else { - return "Sitemap" + // Return empty — SitemapPageView shows a redacted placeholder title when loading + return "" } } @@ -83,6 +83,12 @@ class SitemapPageViewModel: ObservableObject { init() { loadSettings() + connectionObserverTask = Task { @MainActor [weak self] in + guard let self else { return } + for await connection in networkTracker.$activeConnection.values { + handleActiveConnectionChange(connection) + } + } } init(pageUrl: String, title: String, pageId: String = "") { @@ -104,6 +110,14 @@ class SitemapPageViewModel: ObservableObject { } else { self.pageId = pageId } + + // Only observe connection changes (skip initial value) — initial load is triggered by .task in the view + connectionObserverTask = Task { @MainActor [weak self] in + guard let self else { return } + for await connection in networkTracker.$activeConnection.values.dropFirst() { + handleActiveConnectionChange(connection) + } + } } /// Initializes the view model with a fixed set of widgets, without loading or polling @@ -128,6 +142,7 @@ class SitemapPageViewModel: ObservableObject { func startPageHandling() { pageHandlingTask?.cancel() error = nil // Clear any previous errors when starting a new page handling session + isLoading = true // Show redacted view immediately logger.info("🚀 Starting page load and long polling flow...") @@ -160,7 +175,6 @@ class SitemapPageViewModel: ObservableObject { } // 1. Initial page load (longPolling: false) - isLoading = true let initialPage = try await openAPIService?.pollDataForPage( sitemapname: defaultSitemap, pageId: pageId, @@ -472,6 +486,7 @@ class SitemapPageViewModel: ObservableObject { } deinit { + connectionObserverTask?.cancel() pageHandlingTask?.cancel() } } diff --git a/openHAB/SwiftUI/SitemapPageView.swift b/openHAB/SwiftUI/SitemapPageView.swift index dae18fd8d..3f26f6d9a 100644 --- a/openHAB/SwiftUI/SitemapPageView.swift +++ b/openHAB/SwiftUI/SitemapPageView.swift @@ -55,7 +55,11 @@ struct SitemapPageView: View { await viewModel.reload() } .task { - viewModel.startPageHandling() + // Linked pages start loading when the view appears (after navigation). + // The root page is handled by the for-await connection observer in init(). + if viewModel.isLinked { + viewModel.startPageHandling() + } } .onAppear { // Disable idle timer if configured in settings @@ -70,9 +74,6 @@ struct SitemapPageView: View { UIApplication.shared.isIdleTimerDisabled = false } } - .onChange(of: viewModel.networkTracker.activeConnection) { activeConnection in - viewModel.handleActiveConnectionChange(activeConnection) - } .navigationTitle(viewModel.pageTitle) .navigationBarTitleDisplayMode(.large) .alert("Error", isPresented: Binding( From 4ec8a52380a57591558080d25a9dbb08c7b9a084 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Sat, 7 Feb 2026 15:08:26 +0100 Subject: [PATCH 415/476] Simplify connection observer and prevent same-URL restart loop Use dropFirst() so for-await only handles connection changes, not initial load. Remove connectionObserverTask from linked-page init to avoid eager loading. Restore unconditional .task for all pages. Add URL guard in handleActiveConnectionChange to skip when NetworkTracker re-evaluates to the same connection, preventing long-polling restarts. Set openHABRootUrl in startPageHandling so the guard works for .task initiated loads. Signed-off-by: Tim Mueller-Seydlitz --- openHAB/SitemapPageViewModel.swift | 18 +++++++++--------- openHAB/SwiftUI/SitemapPageView.swift | 6 +----- 2 files changed, 10 insertions(+), 14 deletions(-) diff --git a/openHAB/SitemapPageViewModel.swift b/openHAB/SitemapPageViewModel.swift index 64f7c9093..21d5aa3ac 100644 --- a/openHAB/SitemapPageViewModel.swift +++ b/openHAB/SitemapPageViewModel.swift @@ -83,9 +83,10 @@ class SitemapPageViewModel: ObservableObject { init() { loadSettings() + // Observe connection changes (skip initial value) — initial load is triggered by .task in the view connectionObserverTask = Task { @MainActor [weak self] in guard let self else { return } - for await connection in networkTracker.$activeConnection.values { + for await connection in networkTracker.$activeConnection.values.dropFirst() { handleActiveConnectionChange(connection) } } @@ -110,14 +111,6 @@ class SitemapPageViewModel: ObservableObject { } else { self.pageId = pageId } - - // Only observe connection changes (skip initial value) — initial load is triggered by .task in the view - connectionObserverTask = Task { @MainActor [weak self] in - guard let self else { return } - for await connection in networkTracker.$activeConnection.values.dropFirst() { - handleActiveConnectionChange(connection) - } - } } /// Initializes the view model with a fixed set of widgets, without loading or polling @@ -164,6 +157,7 @@ class SitemapPageViewModel: ObservableObject { return } let configuration = activeConnection.configuration + openHABRootUrl = configuration.url if openAPIService == nil { openAPIService = try OpenAPIService(connectionConfiguration: configuration) @@ -407,6 +401,12 @@ class SitemapPageViewModel: ObservableObject { return } + // Skip if already connected to this URL — avoids restarting long-polling + // when the NetworkTracker re-evaluates to the same connection + guard openHABRootUrl != activeConnection.configuration.url else { + return + } + Task { await handleActiveConnection(activeConnection) } diff --git a/openHAB/SwiftUI/SitemapPageView.swift b/openHAB/SwiftUI/SitemapPageView.swift index 3f26f6d9a..ec8dada3a 100644 --- a/openHAB/SwiftUI/SitemapPageView.swift +++ b/openHAB/SwiftUI/SitemapPageView.swift @@ -55,11 +55,7 @@ struct SitemapPageView: View { await viewModel.reload() } .task { - // Linked pages start loading when the view appears (after navigation). - // The root page is handled by the for-await connection observer in init(). - if viewModel.isLinked { - viewModel.startPageHandling() - } + viewModel.startPageHandling() } .onAppear { // Disable idle timer if configured in settings From 5154a61cbb3a0f17e009e7ccef6e16e6363f9308 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Sat, 7 Feb 2026 15:24:52 +0100 Subject: [PATCH 416/476] Default isLoading to true to show skeleton on first render Eliminates the empty black screen between SwiftUI view creation and .task firing by starting in the loading state. The skeleton condition (isLoading && relevantWidgets.isEmpty) still correctly shows content for the preview init which populates widgets directly. Signed-off-by: Tim Mueller-Seydlitz --- openHAB/SitemapPageViewModel.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openHAB/SitemapPageViewModel.swift b/openHAB/SitemapPageViewModel.swift index 21d5aa3ac..3937da883 100644 --- a/openHAB/SitemapPageViewModel.swift +++ b/openHAB/SitemapPageViewModel.swift @@ -39,7 +39,7 @@ class SitemapPageViewModel: ObservableObject { @Published var currentPage: OpenHABPage? @Published var searchText = "" @Published var error: (any LocalizedError)? - @Published var isLoading = false + @Published var isLoading = true @Published var isUpdating = false @Published var openHABRootUrl: String? @Published var showSearchField = false From 798b5ffc63dfae8c1336f03aebcbb4ee920d3914 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Sat, 7 Feb 2026 16:10:36 +0100 Subject: [PATCH 417/476] Remove duplicate PreviewConstants and decouple skeleton from preview data Delete openHAB/PreviewConstants.swift which duplicated CommonUI's #if DEBUG-guarded version and shipped preview JSON in Release builds. Replace placeholderWidgets with lightweight static widgets constructed inline, removing the PreviewConstants dependency from SitemapPageView. Remove the fake inline placeholder title that was offset from the navigation bar title. Signed-off-by: Tim Mueller-Seydlitz --- openHAB/PreviewConstants.swift | 581 -------------------------- openHAB/SwiftUI/SitemapPageView.swift | 42 +- 2 files changed, 18 insertions(+), 605 deletions(-) delete mode 100644 openHAB/PreviewConstants.swift diff --git a/openHAB/PreviewConstants.swift b/openHAB/PreviewConstants.swift deleted file mode 100644 index 3d883de81..000000000 --- a/openHAB/PreviewConstants.swift +++ /dev/null @@ -1,581 +0,0 @@ -// Copyright (c) 2010-2026 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 Foundation -import OpenHABCore -import os.log - -// swiftlint:disable type_body_length -enum PreviewConstants { - static let logger = Logger(subsystem: "org.openhab", category: "PreviewConstants") - - static let remoteURLString = "http://192.168.2.10:8080" - - static var openHABSitemapPage: OpenHABPage? { - let data = sitemapJson - do { - let sitemapPage = try data.decoded(as: Components.Schemas.PageDTO.self) - let openHABSitemapPage = OpenHABPage(sitemapPage) - return openHABSitemapPage - } catch { - logger.error("Should not throw \(error.localizedDescription)") - return nil - } - } - - static let sitemapJson = Data(""" - { - "id": "watch", - "title": "watch", - "link": "http://192.168.2.10:8080/rest/sitemaps/watch/watch", - "leaf": true, - "timeout": false, - "widgets": [ - { - "widgetId": "00", - "type": "Switch", - "visibility": true, - "label": "Licht Keller WC Decke", - "icon": "switch", - "mappings": [], - "item": { - "link": "http://192.168.2.15:8081/rest/items/lcnLightSwitch6_1", - "state": "OFF", - "type": "Switch", - "name": "lcnLightSwitch6_1", - "label": "Licht Keller WC Decke", - "tags": [ - "Lighting" - ], - "groupNames": [ - "gKellerLicht", - "gLcn" - ] - }, - "widgets": [] - }, - { - "widgetId": "01", - "type": "Switch", - "visibility": true, - "label": "Licht Oberlicht", - "icon": "switch", - "mappings": [], - "item": { - "link": "http://192.168.2.15:8081/rest/items/lcnLightSwitch14_1", - "state": "OFF", - "type": "Switch", - "name": "lcnLightSwitch14_1", - "label": "Licht Oberlicht", - "tags": [ - "Lighting" - ], - "groupNames": [ - "gEGLicht", - "G_PresenceSimulation", - "gLcn" - ] - }, - "widgets": [] - }, - { - "widgetId": "02", - "type": "Switch", - "visibility": true, - "label": "Licht Esstisch [Test]", - "icon": "switch", - "mappings": [], - "item": { - "link": "http://192.168.2.15:8081/rest/items/lcnLightSwitch20_1", - "state": "ON", - "type": "Switch", - "name": "lcnLightSwitch20_1", - "label": "Licht Esstisch", - "tags": [], - "groupNames": [ - "gEGLicht", - "G_PresenceSimulation", - "gLcn", - "gStateON" - ] - }, - "widgets": [] - }, - { - "widgetId": "03", - "type": "Slider", - "visibility": true, - "label": "Esstisch [100]", - "icon": "slider", - "mappings": [], - "switchSupport": false, - "sendFrequency": 0, - "item": { - "link": "http://192.168.2.10:8080/rest/items/lcnLightDimmer", - "state": "95", - "stateDescription": { - "pattern": "%s", - "readOnly": false, - "options": [] - }, - "type": "Dimmer", - "name": "lcnLightDimmer", - "label": "Esstisch", - "tags": [ - "Lighting" - ], - "groupNames": [ - "gEGLicht", - "gLcn" - ] - }, - "widgets": [] - }, - { - "widgetId": "04", - "type": "Switch", - "visibility": true, - "label": "Fernsteuerung", - "icon": "switch", - "mappings": [ - { - "command": "0", - "label": "Overwrite" - }, - { - "command": "1", - "label": "Kalender" - }, - { - "command": "2", - "label": "Automatik" - } - ], - "item": { - "link": "http://192.168.2.15:8081/rest/items/Automatik", - "state": "2", - "type": "String", - "name": "Automatik", - "tags": [], - "groupNames": [] - }, - "widgets": [] - }, - { - "widgetId": "05", - "type": "Switch", - "visibility": true, - "label": "Jalousie WZ Süd links", - "icon": "rollershutter", - "mappings": [], - "item": { - "link": "http://192.168.2.15:8081/rest/items/lcnJalousieWZSuedLinks", - "state": "0", - "type": "Rollershutter", - "name": "lcnJalousieWZSuedLinks", - "label": "Jalousie WZ Süd links", - "tags": [], - "groupNames": [ - "gWZ", - "gEGJalousien", - "gHausJalousie", - "gJalousienSued", - "gEGJalousienSued", - "gLcn" - ] - }, - "widgets": [] - }, - { - "widgetId": "06", - "type": "Setpoint", - "visibility": true, - "label": "Setpoint Temperature [21.0 °C]", - "icon": "temperature", - "mappings": [], - "minValue": 8, - "maxValue": 25, - "step": 0.5, - "item": { - "link": "http://192.168.2.15:8081/rest/items/ZimmerPaul_SetpointTemperature", - "state": "21.0 °C", - "stateDescription": { - "pattern": "%.1f %unit%", - "readOnly": false, - "options": [] - }, - "type": "Number:Temperature", - "name": "ZimmerPaul_SetpointTemperature", - "label": "Setpoint Temperature [%.1f °C]", - "category": "Temperature", - "tags": [], - "groupNames": [] - }, - "widgets": [] - }, - { - "widgetId": "07", - "type": "Text", - "visibility": true, - "label": "Aussentemperatur [11.5 °C]", - "icon": "temperature", - "mappings": [], - "item": { - "link": "http://192.168.2.15:8081/rest/items/TempAussen", - "state": "11.5", - "stateDescription": { - "pattern": "%.1f °C", - "readOnly": false, - "options": [] - }, - "type": "Number", - "name": "TempAussen", - "label": "Aussentemperatur", - "category": "temperature", - "tags": [], - "groupNames": [ - "gOnewire" - ] - }, - "widgets": [] - }, - { - "widgetId": "08", - "type": "Image", - "visibility": true, - "label": "", - "icon": "image", - "mappings": [], - "url": "http://192.168.2.15:8081/proxy?sitemap=watch.sitemap&widgetId=08", - "widgets": [] - }, - { - "widgetId": "09", - "type": "Mapview", - "visibility": true, - "label": "Location [-°N -°E -m]", - "icon": "mapview", - "mappings": [], - "height": 5, - "item": { - "link": "http://192.168.2.15:8081/rest/items/GPSTrackerTi_Location", - "state":"52.5200066,13.4049540", - "stateDescription": { - "pattern": "%2$s°N %3$s°E %1$sm", - "readOnly": true, - "options": [] - }, - "type": "Location", - "name": "GPSTrackerTi_Location", - "label": "Location", - "tags": [], - "groupNames": [] - }, - "widgets": [] - }, - { - "widgetId": "10", - "type": "Colorpicker", - "visibility": true, - "label": "Color", - "icon": "colorlight", - "mappings": [], - "item": { - "link": "http://192.168.2.160:8080/rest/items/LEDVANCECLA60RGBWZ3_Color", - "state": "UNDEF", - "type": "Color", - "name": "LEDVANCECLA60RGBWZ3_Color", - "label": "Color", - "category": "ColorLight", - "tags": [], - "groupNames": [ - "dg_AllItems" - ] - }, - "widgets": [] - }, - { - "widgetId": "11", - "type": "Setpoint", - "visibility": true, - "label": "item in seconds [2400.0 s]", - "labelSource": "SITEMAP_WIDGET", - "icon": "", - "staticIcon": false, - "pattern": "%.1f s", - "unit": "s", - "mappings": [], - "minValue": 300, - "maxValue": 3600, - "step": 60, - "item": { - "link": "http://192.168.2.10:8080/rest/items/testTime", - "state": "2400 s", - "stateDescription": { - "pattern": "%.0f %unit%", - "readOnly": false, - "options": [] - }, - "unitSymbol": "s", - "type": "Number:Time", - "name": "testTime", - "label": "", - "category": "", - "tags": [], - "groupNames": [] - }, - "widgets": [] - }, - { - "widgetId": "12", - "type": "Setpoint", - "visibility": true, - "label": "item in minutes [40.0 min]", - "labelSource": "SITEMAP_WIDGET", - "icon": "", - "staticIcon": false, - "pattern": "%.1f min", - "unit": "min", - "mappings": [], - "minValue": 5, - "maxValue": 60, - "step": 5, - "state": "40 min", - "item": { - "link": "http://192.168.2.10:8080/rest/items/testTime", - "state": "2400 s", - "stateDescription": { - "pattern": "%.0f %unit%", - "readOnly": false, - "options": [] - }, - "unitSymbol": "s", - "type": "Number:Time", - "name": "testTime", - "label": "", - "category": "", - "tags": [], - "groupNames": [] - }, - "widgets": [] - }, - { - "widgetId": "13", - "type": "Colortemperaturepicker", - "visibility": true, - "label": "Color Temperature [2700 K]", - "labelSource": "SITEMAP_WIDGET", - "icon": "colorwheel", - "staticIcon": true, - "pattern": "%.0f %unit%", - "unit": "K", - "mappings": [], - "item": { - "link": "http://192.168.2.10:8080/rest/items/test_LEDLight_ColorTemp", - "state": "2700.0 K", - "stateDescription": { - "pattern": "%.0f %unit%", - "readOnly": false, - "options": [] - }, - "unitSymbol": "K", - "type": "Number:Temperature", - "name": "test_LEDLight_ColorTemp", - "label": "", - "category": "", - "tags": [], - "groupNames": [] - }, - "widgets": [] - }, - { - "widgetId": "14", - "type": "Slider", - "visibility": true, - "label": "Brightness", - "labelSource": "SITEMAP_WIDGET", - "icon": "", - "staticIcon": false, - "unit": "", - "mappings": [], - "switchSupport": false, - "releaseOnly": false, - "item": { - "link": "http://192.168.2.10:8080/rest/items/test_LEDLight_Brightness", - "state": "NULL", - "type": "Dimmer", - "name": "test_LEDLight_Brightness", - "label": "", - "category": "", - "tags": [], - "groupNames": [] - }, - "widgets": [] - }, - { - "widgetId": "15", - "type": "Colorpicker", - "visibility": true, - "label": "Color", - "labelSource": "SITEMAP_WIDGET", - "icon": "colorwheel", - "staticIcon": false, - "unit": "", - "mappings": [], - "item": { - "link": "http://192.168.2.10:8080/rest/items/test_LEDLight_color", - "state": "0,0,73", - "type": "Color", - "name": "test_LEDLight_color", - "label": "test_LEDLight_color", - "category": "", - "tags": [], - "groupNames": [] - }, - "widgets": [] - }, - { - "widgetId": "16", - "type": "Buttongrid", - "visibility": true, - "label": "Remote Control [-]", - "labelSource": "SITEMAP_WIDGET", - "icon": "screen", - "staticIcon": true, - "pattern": "%s", - "unit": "", - "mappings": [ - { - "row": 1, - "column": 1, - "command": "POWER", - "label": "Power", - "icon": "switch-off" - }, - { - "row": 1, - "column": 2, - "command": "MENU", - "label": "Menu" - }, - { - "row": 1, - "column": 3, - "command": "EXIT", - "label": "Exit" - }, - { - "row": 2, - "column": 2, - "command": "UP", - "label": "Up", - "icon": "f7:arrowtriangle_up" - }, - { - "row": 2, - "column": 4, - "command": "VOL_PLUS", - "label": "Volume +" - }, - { - "row": 3, - "column": 1, - "command": "LEFT", - "label": "Left", - "icon": "f7:arrowtriangle_left" - }, - { - "row": 3, - "column": 2, - "command": "OK", - "label": "Ok" - }, - { - "row": 3, - "column": 3, - "command": "RIGHT", - "label": "Right", - "icon": "f7:arrowtriangle_right" - }, - { - "row": 3, - "column": 4, - "command": "MUTE", - "label": "Mute", - "icon": "soundvolume_mute" - }, - { - "row": 4, - "column": 2, - "command": "DOWN", - "label": "Down", - "icon": "f7:arrowtriangle_down" - }, - { - "row": 4, - "column": 4, - "command": "VOL_MINUS", - "label": "Volume -" - } - ], - "item": { - "link": "http://192.168.2.10:8080/rest/items/test_RemoteControl", - "state": "NULL", - "stateDescription": { - "pattern": "%s", - "readOnly": false, - "options": [] - }, - "type": "String", - "name": "test_RemoteControl", - "label": "test_RemoteControl", - "category": "", - "tags": [], - "groupNames": [] - }, - "widgets": [] - }, - { - "widgetId": "17", - "type": "Input", - "visibility": true, - "label": "Meter [166000]", - "labelSource": "SITEMAP_WIDGET", - "icon": "energy", - "staticIcon": true, - "pattern": "%.0f %unit%", - "unit": "", - "mappings": [], - "inputHint": "number", - "item": { - "link": "http://192.168.2.10:8080/rest/items/Test_Meter_Reading", - "state": "166000.0", - "stateDescription": { - "pattern": "%.0f", - "readOnly": false, - "options": [] - }, - "type": "Number", - "name": "Test_Meter_Reading", - "label": "Test_Meter_Reading", - "category": "", - "tags": [], - "groupNames": [] - }, - "widgets": [] - }, - - ] - } - """.utf8) -} - -// swiftlint:enable type_body_length diff --git a/openHAB/SwiftUI/SitemapPageView.swift b/openHAB/SwiftUI/SitemapPageView.swift index ec8dada3a..1e2f5a409 100644 --- a/openHAB/SwiftUI/SitemapPageView.swift +++ b/openHAB/SwiftUI/SitemapPageView.swift @@ -26,16 +26,7 @@ struct SitemapPageView: View { if viewModel.isLoading, viewModel.relevantWidgets.isEmpty { // Show skeleton/placeholder rows while loading List { - // Redacted large title header - if viewModel.pageTitle.isEmpty { - Text("Placeholder Title") - .font(.largeTitle.bold()) - .redacted(reason: .placeholder) - .listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 8, trailing: 16)) - .listRowSeparator(.hidden) - .listRowBackground(Color.clear) - } - ForEach(placeholderWidgets, id: \.id) { widget in + ForEach(Self.placeholderWidgets, id: \.id) { widget in EmbeddingRowView(widget: widget) .redacted(reason: .placeholder) .disabled(true) @@ -90,25 +81,28 @@ struct SitemapPageView: View { } extension SitemapPageView { - /// Creates placeholder widgets for skeleton loading state - var placeholderWidgets: [OpenHABWidget] { - guard let page = PreviewConstants.openHABSitemapPage else { return [] } - return [ - page.widgets[safe: 3], - page.widgets[safe: 5], - page.widgets[safe: 2], - page.widgets[safe: 6], - page.widgets[safe: 17], - page.widgets[safe: 4] - ].compactMap(\.self) + /// Lightweight placeholder widgets for skeleton loading state — no dependency on PreviewConstants + static let placeholderWidgets: [OpenHABWidget] = (0 ..< 6).map { i in + OpenHABWidget( + widgetId: "placeholder_\(i)", + label: "Placeholder [100]", + icon: "none", + type: .text, + url: nil, period: nil, minValue: nil, maxValue: nil, step: nil, + refresh: nil, height: nil, isLeaf: nil, iconColor: nil, + labelColor: nil, valueColor: nil, service: nil, state: nil, + text: nil, legend: nil, inputHint: nil, encoding: nil, + item: nil, linkedPage: nil, mappings: [], widgets: [], + visibility: true, switchSupport: nil, forceAsItem: nil, + labelSource: .unknown, releaseOnly: nil + ) } } #Preview { let previewViewModel = SitemapPageViewModel( - pageUrl: PreviewConstants.openHABSitemapPage?.link ?? "", - title: PreviewConstants.openHABSitemapPage?.title ?? "Preview Page", - widgets: SitemapPageView(viewModel: SitemapPageViewModel()).placeholderWidgets + title: "Preview Page", + widgets: SitemapPageView.placeholderWidgets ) SitemapPageView(viewModel: previewViewModel) } From 6c31741495f5e0843822ed183c135f6826aa6109 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Sun, 8 Feb 2026 08:02:39 +0100 Subject: [PATCH 418/476] Use semantic gray colors for better segmented control track contrast Replace tertiarySystemBackground/secondarySystemBackground with systemGray4 (dark) and systemGray5 (light) for slightly improved track visibility in both color schemes. Signed-off-by: Tim Mueller-Seydlitz --- openHAB/SwiftUI/Rows/SegmentedRowView.swift | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/openHAB/SwiftUI/Rows/SegmentedRowView.swift b/openHAB/SwiftUI/Rows/SegmentedRowView.swift index b0c823a68..8095bc862 100644 --- a/openHAB/SwiftUI/Rows/SegmentedRowView.swift +++ b/openHAB/SwiftUI/Rows/SegmentedRowView.swift @@ -103,11 +103,7 @@ struct SegmentedRowView: View { GeometryReader { geometry in // Layer 1: Dark gray background RoundedRectangle(cornerRadius: 7) - .fill( - colorScheme == .dark - ? Color(uiColor: .tertiarySystemBackground) - : Color(uiColor: .secondarySystemBackground) - ) + .fill(Color(uiColor: colorScheme == .dark ? .systemGray4 : .systemGray5)) // Layer 2: Selection indicator (lighter, more visible) if let selectedIndex, !mappings.isEmpty { let segmentWidth = geometry.size.width / CGFloat(mappings.count) @@ -183,11 +179,7 @@ struct SegmentedRowView: View { ) .background( RoundedRectangle(cornerRadius: 7) - .fill( - colorScheme == .dark - ? Color(uiColor: .tertiarySystemBackground) - : Color(uiColor: .secondarySystemBackground) - ) + .fill(Color(uiColor: colorScheme == .dark ? .systemGray4 : .systemGray5)) ) .overlay( RoundedRectangle(cornerRadius: 7) @@ -238,8 +230,8 @@ struct SegmentedRowView: View { RoundedRectangle(cornerRadius: 7) .fill( isPressed - ? (colorScheme == .dark ? Color(uiColor: .systemGray2) : Color(uiColor: .systemBackground)) - : (colorScheme == .dark ? Color(uiColor: .tertiarySystemBackground) : Color(uiColor: .secondarySystemBackground)) + ? Color(uiColor: colorScheme == .dark ? .systemGray2 : .systemBackground) + : Color(uiColor: colorScheme == .dark ? .systemGray4 : .systemGray5) ) ) .overlay( From f909def8d84f36e51fd608b4ed21297058f4c087 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Sun, 8 Feb 2026 09:51:59 +0100 Subject: [PATCH 419/476] Add inline buttons for single and press-release mappings on watchOS Render inline buttons directly in SegmentRow for single-mapping and press-release scenarios instead of always navigating to the full-screen SegmentSelectionView. Multi-segment (2+ regular mappings) retains the existing NavigationLink flow. Layout adapts based on label presence: VStack with title row when labeled, HStack when not. Signed-off-by: Tim Mueller-Seydlitz --- openHABWatch/Views/Rows/SegmentRow.swift | 127 +++++++++++++++++++++++ 1 file changed, 127 insertions(+) diff --git a/openHABWatch/Views/Rows/SegmentRow.swift b/openHABWatch/Views/Rows/SegmentRow.swift index 9af0ce900..5d815b06e 100644 --- a/openHABWatch/Views/Rows/SegmentRow.swift +++ b/openHABWatch/Views/Rows/SegmentRow.swift @@ -18,12 +18,115 @@ struct SegmentRow: View { @ObservedObject var widget: OpenHABWidget @EnvironmentObject var settings: AppSettings @State private var selectedIndex: Int? + @State private var pressedIndex: Int? private var currentIndex: Int { selectedIndex ?? widget.mappingIndex(byCommand: widget.item?.state).map { Int($0) } ?? 0 } + private var hasLabel: Bool { + if let labelText = widget.labelText, !labelText.isEmpty { + return true + } + return false + } + var body: some View { + if widget.hasPressReleaseMappings { + pressReleaseContent + } else if widget.mappingsOrItemOptions.count == 1 { + singleMappingContent + } else { + multiSegmentContent + } + } + + // MARK: - Press-Release + + @ViewBuilder + private var pressReleaseContent: some View { + if hasLabel { + VStack(alignment: .leading, spacing: 4) { + iconTitleRow + HStack { + Spacer() + pressReleaseButtons + } + } + } else { + HStack { + IconView(widget: widget, settings: settings) + Spacer() + pressReleaseButtons + } + } + } + + @ViewBuilder + private var pressReleaseButtons: some View { + HStack(spacing: 8) { + ForEach(widget.mappingsOrItemOptions.indices, id: \.self) { index in + let mapping = widget.mappingsOrItemOptions[index] + inlineButton(label: mapping.label, isPressed: pressedIndex == index) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { _ in + if pressedIndex != index { + pressedIndex = index + Logger.rowViews.info("Sending press command: \(mapping.command)") + widget.sendCommand(mapping.command) + } + } + .onEnded { _ in + pressedIndex = nil + if let releaseCommand = mapping.releaseCommand, !releaseCommand.isEmpty { + Logger.rowViews.info("Sending release command: \(releaseCommand)") + widget.sendCommand(releaseCommand) + } + } + ) + } + } + } + + // MARK: - Single Mapping + + @ViewBuilder + private var singleMappingContent: some View { + let mapping = widget.mappingsOrItemOptions[0] + if hasLabel { + VStack(alignment: .leading, spacing: 4) { + iconTitleRow + HStack { + Spacer() + Button { + Logger.rowViews.info("Sending command: \(mapping.command)") + widget.sendCommand(mapping.command) + } label: { + inlineButton(label: mapping.label, isPressed: false) + } + .buttonStyle(.plain) + } + } + } else { + HStack { + IconView(widget: widget, settings: settings) + Spacer() + Button { + Logger.rowViews.info("Sending command: \(mapping.command)") + widget.sendCommand(mapping.command) + } label: { + inlineButton(label: mapping.label, isPressed: false) + } + .buttonStyle(.plain) + } + } + } + + // MARK: - Multi-Segment (existing NavigationLink) + + @ViewBuilder + private var multiSegmentContent: some View { HStack { HStack { IconView(widget: widget, settings: settings) @@ -54,6 +157,30 @@ struct SegmentRow: View { selectedIndex = widget.mappingIndex(byCommand: newValue).map { Int($0) } } } + + // MARK: - Shared Components + + @ViewBuilder + private var iconTitleRow: some View { + HStack { + IconView(widget: widget, settings: settings) + TextLabelView(widget: widget, font: .caption) + Spacer() + } + } + + @ViewBuilder + private func inlineButton(label: String, isPressed: Bool) -> some View { + Text(label) + .font(.caption) + .lineLimit(1) + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color.gray.opacity(isPressed ? 0.6 : 0.3)) + ) + } } #Preview { From 0800e107a9313f8addc6d1d0ee2c6356c2dd99be Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Sun, 8 Feb 2026 17:53:38 +0100 Subject: [PATCH 420/476] Constrain inline button hit area with contentShape Add contentShape(RoundedRectangle) to inlineButton so DragGesture and Button tap targets are clipped to the visible button bounds, preventing touches on the title row above from triggering button actions. Signed-off-by: Tim Mueller-Seydlitz --- openHABWatch/Views/Rows/SegmentRow.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/openHABWatch/Views/Rows/SegmentRow.swift b/openHABWatch/Views/Rows/SegmentRow.swift index 5d815b06e..763a69e3e 100644 --- a/openHABWatch/Views/Rows/SegmentRow.swift +++ b/openHABWatch/Views/Rows/SegmentRow.swift @@ -180,6 +180,7 @@ struct SegmentRow: View { RoundedRectangle(cornerRadius: 8) .fill(Color.gray.opacity(isPressed ? 0.6 : 0.3)) ) + .contentShape(RoundedRectangle(cornerRadius: 8)) } } From 1987513c4283c529b058e3ebdfeb6bf11f928fbf Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Sun, 8 Feb 2026 17:58:09 +0100 Subject: [PATCH 421/476] Fix inline button hit area extending beyond visible bounds Move gesture recognizers into an overlay on the button view so the hit-test area is physically bounded to the button frame. This prevents touches on the title row above from triggering button actions on watchOS. Signed-off-by: Tim Mueller-Seydlitz --- openHABWatch/Views/Rows/SegmentRow.swift | 67 +++++++++++++----------- 1 file changed, 36 insertions(+), 31 deletions(-) diff --git a/openHABWatch/Views/Rows/SegmentRow.swift b/openHABWatch/Views/Rows/SegmentRow.swift index 763a69e3e..726e2ffac 100644 --- a/openHABWatch/Views/Rows/SegmentRow.swift +++ b/openHABWatch/Views/Rows/SegmentRow.swift @@ -68,23 +68,27 @@ struct SegmentRow: View { ForEach(widget.mappingsOrItemOptions.indices, id: \.self) { index in let mapping = widget.mappingsOrItemOptions[index] inlineButton(label: mapping.label, isPressed: pressedIndex == index) - .gesture( - DragGesture(minimumDistance: 0) - .onChanged { _ in - if pressedIndex != index { - pressedIndex = index - Logger.rowViews.info("Sending press command: \(mapping.command)") - widget.sendCommand(mapping.command) - } - } - .onEnded { _ in - pressedIndex = nil - if let releaseCommand = mapping.releaseCommand, !releaseCommand.isEmpty { - Logger.rowViews.info("Sending release command: \(releaseCommand)") - widget.sendCommand(releaseCommand) - } - } - ) + .overlay { + Color.clear + .contentShape(Rectangle()) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { _ in + if pressedIndex != index { + pressedIndex = index + Logger.rowViews.info("Sending press command: \(mapping.command)") + widget.sendCommand(mapping.command) + } + } + .onEnded { _ in + pressedIndex = nil + if let releaseCommand = mapping.releaseCommand, !releaseCommand.isEmpty { + Logger.rowViews.info("Sending release command: \(releaseCommand)") + widget.sendCommand(releaseCommand) + } + } + ) + } } } } @@ -99,30 +103,31 @@ struct SegmentRow: View { iconTitleRow HStack { Spacer() - Button { - Logger.rowViews.info("Sending command: \(mapping.command)") - widget.sendCommand(mapping.command) - } label: { - inlineButton(label: mapping.label, isPressed: false) - } - .buttonStyle(.plain) + singleButton(for: mapping) } } } else { HStack { IconView(widget: widget, settings: settings) Spacer() - Button { - Logger.rowViews.info("Sending command: \(mapping.command)") - widget.sendCommand(mapping.command) - } label: { - inlineButton(label: mapping.label, isPressed: false) - } - .buttonStyle(.plain) + singleButton(for: mapping) } } } + @ViewBuilder + private func singleButton(for mapping: OpenHABWidgetMapping) -> some View { + inlineButton(label: mapping.label, isPressed: false) + .overlay { + Color.clear + .contentShape(Rectangle()) + .onTapGesture { + Logger.rowViews.info("Sending command: \(mapping.command)") + widget.sendCommand(mapping.command) + } + } + } + // MARK: - Multi-Segment (existing NavigationLink) @ViewBuilder From b3bad83ae610fe5be99d01eaf305edc60ae36b14 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Sun, 8 Feb 2026 18:07:58 +0100 Subject: [PATCH 422/476] Validate touch coordinates to constrain button hit area on watchOS Use GeometryReader inside gesture overlays to check that the touch startLocation falls within the button bounds, rejecting touches routed from outside (e.g. the title row above) by watchOS list row hit testing. Signed-off-by: Tim Mueller-Seydlitz --- openHABWatch/Views/Rows/SegmentRow.swift | 67 +++++++++++++++--------- 1 file changed, 43 insertions(+), 24 deletions(-) diff --git a/openHABWatch/Views/Rows/SegmentRow.swift b/openHABWatch/Views/Rows/SegmentRow.swift index 726e2ffac..865e938a3 100644 --- a/openHABWatch/Views/Rows/SegmentRow.swift +++ b/openHABWatch/Views/Rows/SegmentRow.swift @@ -19,6 +19,7 @@ struct SegmentRow: View { @EnvironmentObject var settings: AppSettings @State private var selectedIndex: Int? @State private var pressedIndex: Int? + @State private var singlePressed = false private var currentIndex: Int { selectedIndex ?? widget.mappingIndex(byCommand: widget.item?.state).map { Int($0) } ?? 0 @@ -69,25 +70,30 @@ struct SegmentRow: View { let mapping = widget.mappingsOrItemOptions[index] inlineButton(label: mapping.label, isPressed: pressedIndex == index) .overlay { - Color.clear - .contentShape(Rectangle()) - .gesture( - DragGesture(minimumDistance: 0) - .onChanged { _ in - if pressedIndex != index { - pressedIndex = index - Logger.rowViews.info("Sending press command: \(mapping.command)") - widget.sendCommand(mapping.command) + GeometryReader { geometry in + Color.clear + .contentShape(Rectangle()) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { value in + let bounds = CGRect(origin: .zero, size: geometry.size) + guard bounds.contains(value.startLocation) else { return } + if pressedIndex != index { + pressedIndex = index + Logger.rowViews.info("Sending press command: \(mapping.command)") + widget.sendCommand(mapping.command) + } } - } - .onEnded { _ in - pressedIndex = nil - if let releaseCommand = mapping.releaseCommand, !releaseCommand.isEmpty { - Logger.rowViews.info("Sending release command: \(releaseCommand)") - widget.sendCommand(releaseCommand) + .onEnded { _ in + guard pressedIndex == index else { return } + pressedIndex = nil + if let releaseCommand = mapping.releaseCommand, !releaseCommand.isEmpty { + Logger.rowViews.info("Sending release command: \(releaseCommand)") + widget.sendCommand(releaseCommand) + } } - } - ) + ) + } } } } @@ -117,14 +123,27 @@ struct SegmentRow: View { @ViewBuilder private func singleButton(for mapping: OpenHABWidgetMapping) -> some View { - inlineButton(label: mapping.label, isPressed: false) + inlineButton(label: mapping.label, isPressed: singlePressed) .overlay { - Color.clear - .contentShape(Rectangle()) - .onTapGesture { - Logger.rowViews.info("Sending command: \(mapping.command)") - widget.sendCommand(mapping.command) - } + GeometryReader { geometry in + Color.clear + .contentShape(Rectangle()) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { value in + let bounds = CGRect(origin: .zero, size: geometry.size) + guard bounds.contains(value.startLocation) else { return } + if !singlePressed { + singlePressed = true + Logger.rowViews.info("Sending command: \(mapping.command)") + widget.sendCommand(mapping.command) + } + } + .onEnded { _ in + singlePressed = false + } + ) + } } } From 05f15fc34979f7750a6afe7635f274b56aac9940 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Sun, 8 Feb 2026 18:16:49 +0100 Subject: [PATCH 423/476] Skip widgets array reassignment when list structure is unchanged Only reassign the @Published widgets array when widgets are added, removed, or reordered. Property-only updates are already applied in place on existing widget instances, triggering per-row re-renders via @ObservedObject without rebuilding the ScrollView and losing scroll position. Signed-off-by: Tim Mueller-Seydlitz --- openHABWatch/Domain/UserData.swift | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/openHABWatch/Domain/UserData.swift b/openHABWatch/Domain/UserData.swift index 189eda70f..f8639bfcf 100644 --- a/openHABWatch/Domain/UserData.swift +++ b/openHABWatch/Domain/UserData.swift @@ -389,13 +389,16 @@ final class UserData: ObservableObject { /// Updates existing widget instances instead of replacing them to preserve @ObservedObject references private func updateWidgets(with newWidgets: [OpenHABWidget]) { - // Build a map of existing widgets by ID for quick lookup - var existingWidgetsMap = Dictionary(uniqueKeysWithValues: widgets.map { ($0.widgetId, $0) }) - var updatedWidgets: [OpenHABWidget] = [] + let existingWidgetsMap = Dictionary(uniqueKeysWithValues: widgets.map { ($0.widgetId, $0) }) + + // Check if the widget list structure changed (count, order, or IDs) + let structureChanged = widgets.count != newWidgets.count + || !zip(widgets, newWidgets).allSatisfy({ $0.widgetId == $1.widgetId }) for newWidget in newWidgets { if let existingWidget = existingWidgetsMap[newWidget.widgetId] { - // Update existing widget's properties to preserve the instance + // Update existing widget's properties in place — this triggers + // per-row re-renders via @ObservedObject without rebuilding the list existingWidget.label = newWidget.label existingWidget.type = newWidget.type existingWidget.icon = newWidget.icon @@ -413,15 +416,18 @@ final class UserData: ObservableObject { existingWidget.forceAsItem = newWidget.forceAsItem existingWidget.mappings = newWidget.mappings existingWidget.widgets = newWidget.widgets - // Add other properties as needed - updatedWidgets.append(existingWidget) - existingWidgetsMap.removeValue(forKey: newWidget.widgetId) - } else { - // New widget, add it - updatedWidgets.append(newWidget) } } - widgets = updatedWidgets + // Only reassign the @Published array when the list structure actually + // changed (widgets added, removed, or reordered). This avoids a full + // ScrollView rebuild that resets the scroll position. + if structureChanged { + var updatedWidgets: [OpenHABWidget] = [] + for newWidget in newWidgets { + updatedWidgets.append(existingWidgetsMap[newWidget.widgetId] ?? newWidget) + } + widgets = updatedWidgets + } } } From b89b6fa4529f335b87f8535d40af34bd77e08ed2 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Sun, 8 Feb 2026 19:20:26 +0100 Subject: [PATCH 424/476] Copy sendCommand closure when updating existing widget instances The sendCommand closure on each widget captures a weak reference to its OpenHABPage. When a long poll replaces the page, the old page is deallocated and the existing widgets' closures silently fail. Copy sendCommand from the new widget to keep the command chain alive. Signed-off-by: Tim Mueller-Seydlitz --- openHABWatch/Domain/UserData.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/openHABWatch/Domain/UserData.swift b/openHABWatch/Domain/UserData.swift index f8639bfcf..ff7e24844 100644 --- a/openHABWatch/Domain/UserData.swift +++ b/openHABWatch/Domain/UserData.swift @@ -416,6 +416,7 @@ final class UserData: ObservableObject { existingWidget.forceAsItem = newWidget.forceAsItem existingWidget.mappings = newWidget.mappings existingWidget.widgets = newWidget.widgets + existingWidget.sendCommand = newWidget.sendCommand } } From 36fd1069aa2f3b27ccc4b59db573b24ca934f185 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Sun, 8 Feb 2026 20:15:09 +0100 Subject: [PATCH 425/476] Fire single-mapping button on release within bounds, not on press Move command send from onChanged to onEnded with a bounds check so the user can cancel by dragging away before lifting. Visual pressed state tracks the finger position, matching native watchOS Switch behavior for scroll-safe interaction. Signed-off-by: Tim Mueller-Seydlitz --- openHABWatch/Views/Rows/SegmentRow.swift | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/openHABWatch/Views/Rows/SegmentRow.swift b/openHABWatch/Views/Rows/SegmentRow.swift index 865e938a3..937751659 100644 --- a/openHABWatch/Views/Rows/SegmentRow.swift +++ b/openHABWatch/Views/Rows/SegmentRow.swift @@ -126,20 +126,19 @@ struct SegmentRow: View { inlineButton(label: mapping.label, isPressed: singlePressed) .overlay { GeometryReader { geometry in + let bounds = CGRect(origin: .zero, size: geometry.size) Color.clear .contentShape(Rectangle()) .gesture( DragGesture(minimumDistance: 0) .onChanged { value in - let bounds = CGRect(origin: .zero, size: geometry.size) - guard bounds.contains(value.startLocation) else { return } - if !singlePressed { - singlePressed = true + singlePressed = bounds.contains(value.location) + } + .onEnded { value in + if singlePressed, bounds.contains(value.location) { Logger.rowViews.info("Sending command: \(mapping.command)") widget.sendCommand(mapping.command) } - } - .onEnded { _ in singlePressed = false } ) From 3dee5cc1e2fc4673fa712d60a259fa673c361e59 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Sun, 8 Feb 2026 22:18:53 +0100 Subject: [PATCH 426/476] Put up to 2 inline buttons on same row as title on watchOS Single-mapping and press-release buttons with up to 2 mappings now share a single HStack with icon and title. Buttons get higher layoutPriority so the title truncates first. Press-release with 3+ buttons keeps the two-row VStack layout. Signed-off-by: Tim Mueller-Seydlitz --- openHABWatch/Views/Rows/SegmentRow.swift | 43 +++++++++--------------- 1 file changed, 15 insertions(+), 28 deletions(-) diff --git a/openHABWatch/Views/Rows/SegmentRow.swift b/openHABWatch/Views/Rows/SegmentRow.swift index 937751659..bc086e3b8 100644 --- a/openHABWatch/Views/Rows/SegmentRow.swift +++ b/openHABWatch/Views/Rows/SegmentRow.swift @@ -25,13 +25,6 @@ struct SegmentRow: View { selectedIndex ?? widget.mappingIndex(byCommand: widget.item?.state).map { Int($0) } ?? 0 } - private var hasLabel: Bool { - if let labelText = widget.labelText, !labelText.isEmpty { - return true - } - return false - } - var body: some View { if widget.hasPressReleaseMappings { pressReleaseContent @@ -46,7 +39,15 @@ struct SegmentRow: View { @ViewBuilder private var pressReleaseContent: some View { - if hasLabel { + if widget.mappingsOrItemOptions.count <= 2 { + HStack { + IconView(widget: widget, settings: settings) + TextLabelView(widget: widget, font: .caption) + Spacer() + pressReleaseButtons + .layoutPriority(1) + } + } else { VStack(alignment: .leading, spacing: 4) { iconTitleRow HStack { @@ -54,12 +55,6 @@ struct SegmentRow: View { pressReleaseButtons } } - } else { - HStack { - IconView(widget: widget, settings: settings) - Spacer() - pressReleaseButtons - } } } @@ -104,20 +99,12 @@ struct SegmentRow: View { @ViewBuilder private var singleMappingContent: some View { let mapping = widget.mappingsOrItemOptions[0] - if hasLabel { - VStack(alignment: .leading, spacing: 4) { - iconTitleRow - HStack { - Spacer() - singleButton(for: mapping) - } - } - } else { - HStack { - IconView(widget: widget, settings: settings) - Spacer() - singleButton(for: mapping) - } + HStack { + IconView(widget: widget, settings: settings) + TextLabelView(widget: widget, font: .caption) + Spacer() + singleButton(for: mapping) + .layoutPriority(1) } } From 8bc10e0714ea82e694d2e7debf6a80803f571dd4 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Sun, 8 Feb 2026 23:14:32 +0100 Subject: [PATCH 427/476] Removing unused code Signed-off-by: Tim Mueller-Seydlitz --- openHABWatch/Base.lproj/Interface.storyboard | 51 ------------ openHABWatch/Domain/UserData.swift | 2 +- .../ButtonTableRowController.swift | 65 --------------- .../InterfaceController.swift | 79 ------------------- .../NotificationController.swift | 23 ------ .../PrefsInterfaceController.swift | 40 ---------- openHABWatch/it.lproj/Interface.strings | 3 - openHABWatch/nb.lproj/Interface.strings | 3 - 8 files changed, 1 insertion(+), 265 deletions(-) delete mode 100644 openHABWatch/Base.lproj/Interface.storyboard delete mode 100644 openHABWatch/Extension/openHABWatch Extension/ButtonTableRowController.swift delete mode 100644 openHABWatch/Extension/openHABWatch Extension/InterfaceController.swift delete mode 100644 openHABWatch/Extension/openHABWatch Extension/NotificationController.swift delete mode 100644 openHABWatch/Extension/openHABWatch Extension/PrefsInterfaceController.swift delete mode 100644 openHABWatch/it.lproj/Interface.strings delete mode 100644 openHABWatch/nb.lproj/Interface.strings diff --git a/openHABWatch/Base.lproj/Interface.storyboard b/openHABWatch/Base.lproj/Interface.storyboard deleted file mode 100644 index 26f8ff19f..000000000 --- a/openHABWatch/Base.lproj/Interface.storyboard +++ /dev/null @@ -1,51 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/openHABWatch/Domain/UserData.swift b/openHABWatch/Domain/UserData.swift index ff7e24844..3410718ba 100644 --- a/openHABWatch/Domain/UserData.swift +++ b/openHABWatch/Domain/UserData.swift @@ -393,7 +393,7 @@ final class UserData: ObservableObject { // Check if the widget list structure changed (count, order, or IDs) let structureChanged = widgets.count != newWidgets.count - || !zip(widgets, newWidgets).allSatisfy({ $0.widgetId == $1.widgetId }) + || !zip(widgets, newWidgets).allSatisfy { $0.widgetId == $1.widgetId } for newWidget in newWidgets { if let existingWidget = existingWidgetsMap[newWidget.widgetId] { diff --git a/openHABWatch/Extension/openHABWatch Extension/ButtonTableRowController.swift b/openHABWatch/Extension/openHABWatch Extension/ButtonTableRowController.swift deleted file mode 100644 index 7cd9bb999..000000000 --- a/openHABWatch/Extension/openHABWatch Extension/ButtonTableRowController.swift +++ /dev/null @@ -1,65 +0,0 @@ -// Copyright (c) 2010-2026 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 Foundation -import OpenHABCore -import os.log -import WatchKit - -class ButtonTableRowController: NSObject { - var item: Item? - var interfaceController: InterfaceController? - - @IBOutlet private var buttonSwitch: WKInterfaceSwitch! - - @IBAction private func doSwitchButtonPressed(_ value: Bool) { - guard let item else { return } - let command = value ? "ON" : "OFF" - switchOpenHabItem(for: item, command: command) - } - - func setInterfaceController(interfaceController: InterfaceController) { - self.interfaceController = interfaceController - } - - func setItem(item: Item) { - self.item = item - buttonSwitch.setTitle(item.label) - buttonSwitch.setOn(item.state == "ON") - } - - private func toggleButtonColor(button: WKInterfaceButton) { - button.setBackgroundColor(UIColor.darkGray) - DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + .milliseconds(250)) { - button.setBackgroundColor(UIColor.lightGray) - } - } - - private func switchOpenHabItem(for item: Item, command: String) { - interfaceController!.displayActivityImage() - OpenHabService.singleton.switchOpenHabItem(for: item, command: command) { (data, response, error) in - self.interfaceController!.hideActivityImage() - guard let data, error == nil else { // check for fundamental networking error - self.interfaceController!.displayAlert(message: "error=\(String(describing: error))") - return - } - - if let httpStatus = response as? HTTPURLResponse, httpStatus.statusCode != 200 { // check for http errors - let message = "statusCode should be 200, but is \(httpStatus.statusCode)\n" + - "response = \(String(describing: response))" - self.interfaceController!.displayAlert(message: message) - } - - let responseString = String(data: data, encoding: .utf8) - Logger.watchService.debug("responseString = \(String(describing: responseString))") - } - } -} diff --git a/openHABWatch/Extension/openHABWatch Extension/InterfaceController.swift b/openHABWatch/Extension/openHABWatch Extension/InterfaceController.swift deleted file mode 100644 index 260dd6f9a..000000000 --- a/openHABWatch/Extension/openHABWatch Extension/InterfaceController.swift +++ /dev/null @@ -1,79 +0,0 @@ -// Copyright (c) 2010-2026 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 Foundation -import OpenHABCore -import os.log -import WatchKit - -class InterfaceController: WKInterfaceController { - @IBOutlet private var activityImage: WKInterfaceImage! - @IBOutlet private var buttonTable: WKInterfaceTable! - - override func awake(withContext context: Any?) { - super.awake(withContext: context) - - activityImage.setImageNamed("Activity") - activityImage.setHidden(true) - } - - override func willActivate() { - // This method is called when watch view controller is about to be visible to user - super.willActivate() - - refresh(Preferences.sitemap) - // load the current Sitemap - OpenHabService.singleton.readSitemap { (sitemap, errorString) in - if errorString != "" { - // Timeouts happen when the app is in background state. - // This shouldn't popup an error message. - if AppState.singleton.active { - self.displayAlert(message: errorString) - return - } - } - Preferences.sitemap = sitemap - self.refresh(sitemap) - } - } - - fileprivate func refresh(_ sitemap: Sitemap) { - if sitemap.frames.isEmpty { - return - } - - buttonTable.setNumberOfRows(sitemap.frames[0].items.count, withRowType: "buttonRow") - for i in 0 ..< buttonTable.numberOfRows { - let row = buttonTable.rowController(at: i) as! ButtonTableRowController - row.setInterfaceController(interfaceController: self) - row.setItem(item: sitemap.frames[0].items[i]) - } - } - - func displayAlert(message: String) { - DispatchQueue.main.async { - let okAction = WKAlertAction(title: "Ok", style: .default) { - Logger.watchInterface.debug("OK action pressed") - } - self.presentAlert(withTitle: "Fehler", message: message, preferredStyle: .actionSheet, actions: [okAction]) - } - } - - func displayActivityImage() { - activityImage.setHidden(false) - activityImage.startAnimatingWithImages(in: NSRange(1 ... 15), duration: 1.0, repeatCount: 0) - } - - func hideActivityImage() { - activityImage.setHidden(true) - activityImage.stopAnimating() - } -} diff --git a/openHABWatch/Extension/openHABWatch Extension/NotificationController.swift b/openHABWatch/Extension/openHABWatch Extension/NotificationController.swift deleted file mode 100644 index 42bd94db7..000000000 --- a/openHABWatch/Extension/openHABWatch Extension/NotificationController.swift +++ /dev/null @@ -1,23 +0,0 @@ -// Copyright (c) 2010-2026 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 Foundation -import UserNotifications -import WatchKit - -class NotificationController: WKUserNotificationInterfaceController { - override init() { - // Initialize variables here. - super.init() - - // Configure interface objects here. - } -} diff --git a/openHABWatch/Extension/openHABWatch Extension/PrefsInterfaceController.swift b/openHABWatch/Extension/openHABWatch Extension/PrefsInterfaceController.swift deleted file mode 100644 index 7f5028e5e..000000000 --- a/openHABWatch/Extension/openHABWatch Extension/PrefsInterfaceController.swift +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright (c) 2010-2026 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 Foundation -import WatchKit - -class PrefsInterfaceController: WKInterfaceController { - @IBOutlet private var versionLabel: WKInterfaceLabel! - @IBOutlet private var localUrlLabel: WKInterfaceLabel! - @IBOutlet private var remoteUrlLabel: WKInterfaceLabel! - @IBOutlet private var usernameLabel: WKInterfaceLabel! - @IBOutlet private var sitemapLabel: WKInterfaceLabel! - - override func willActivate() { - // This method is called when watch view controller is about to be visible to user - super.willActivate() - - displayTheApplicationVersionNumber() - - localUrlLabel.setText(Preferences.localUrl) - remoteUrlLabel.setText(Preferences.remoteUrl) - sitemapLabel.setText(Preferences.sitemapName) - usernameLabel.setText(Preferences.username) - } - - func displayTheApplicationVersionNumber() { - let versionNumber: String = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as! String - let buildNumber: String = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as! String - - versionLabel.setText("V\(versionNumber).\(buildNumber)") - } -} diff --git a/openHABWatch/it.lproj/Interface.strings b/openHABWatch/it.lproj/Interface.strings deleted file mode 100644 index 6f6372118..000000000 --- a/openHABWatch/it.lproj/Interface.strings +++ /dev/null @@ -1,3 +0,0 @@ - -/* Class = "WKInterfaceLabel"; text = "Alert Label"; ObjectID = "IdU-wH-bcW"; */ -"IdU-wH-bcW.text" = "Alert Label"; diff --git a/openHABWatch/nb.lproj/Interface.strings b/openHABWatch/nb.lproj/Interface.strings deleted file mode 100644 index 6f6372118..000000000 --- a/openHABWatch/nb.lproj/Interface.strings +++ /dev/null @@ -1,3 +0,0 @@ - -/* Class = "WKInterfaceLabel"; text = "Alert Label"; ObjectID = "IdU-wH-bcW"; */ -"IdU-wH-bcW.text" = "Alert Label"; From 07fe9eb55919e503e31c2f70a6eb40d1638e365c Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Sun, 8 Feb 2026 23:34:27 +0100 Subject: [PATCH 428/476] Sync WidgetRowViewModel for all SegmentRow branches Lift onAppear/onReceive from multiSegmentContent to body so press-release and single-mapping branches also update the view model when the widget changes via long polling. Signed-off-by: Tim Mueller-Seydlitz --- openHABWatch/Views/Rows/SegmentRow.swift | 122 +++++++++++++---------- 1 file changed, 71 insertions(+), 51 deletions(-) diff --git a/openHABWatch/Views/Rows/SegmentRow.swift b/openHABWatch/Views/Rows/SegmentRow.swift index bc086e3b8..7a15421c9 100644 --- a/openHABWatch/Views/Rows/SegmentRow.swift +++ b/openHABWatch/Views/Rows/SegmentRow.swift @@ -17,21 +17,31 @@ import SwiftUI struct SegmentRow: View { @ObservedObject var widget: OpenHABWidget @EnvironmentObject var settings: AppSettings - @State private var selectedIndex: Int? @State private var pressedIndex: Int? @State private var singlePressed = false + @StateObject private var viewModel: WidgetRowViewModel - private var currentIndex: Int { - selectedIndex ?? widget.mappingIndex(byCommand: widget.item?.state).map { Int($0) } ?? 0 + private var currentIndex: Int? { + viewModel.selectedIndex } var body: some View { - if widget.hasPressReleaseMappings { - pressReleaseContent - } else if widget.mappingsOrItemOptions.count == 1 { - singleMappingContent - } else { - multiSegmentContent + Group { + if viewModel.hasPressReleaseMappings { + pressReleaseContent + } else if viewModel.mappings.count == 1 { + singleMappingContent + } else { + multiSegmentContent + } + } + .onAppear { + viewModel.update(from: widget) + } + .onReceive(widget.objectWillChange) { _ in + DispatchQueue.main.async { + viewModel.update(from: widget) + } } } @@ -39,7 +49,7 @@ struct SegmentRow: View { @ViewBuilder private var pressReleaseContent: some View { - if widget.mappingsOrItemOptions.count <= 2 { + if viewModel.mappings.count <= 2 { HStack { IconView(widget: widget, settings: settings) TextLabelView(widget: widget, font: .caption) @@ -61,8 +71,8 @@ struct SegmentRow: View { @ViewBuilder private var pressReleaseButtons: some View { HStack(spacing: 8) { - ForEach(widget.mappingsOrItemOptions.indices, id: \.self) { index in - let mapping = widget.mappingsOrItemOptions[index] + ForEach(viewModel.mappings.indices, id: \.self) { index in + let mapping = viewModel.mappings[index] inlineButton(label: mapping.label, isPressed: pressedIndex == index) .overlay { GeometryReader { geometry in @@ -94,43 +104,22 @@ struct SegmentRow: View { } } - // MARK: - Single Mapping + // MARK: - Shared Components @ViewBuilder - private var singleMappingContent: some View { - let mapping = widget.mappingsOrItemOptions[0] + private var iconTitleRow: some View { HStack { IconView(widget: widget, settings: settings) TextLabelView(widget: widget, font: .caption) Spacer() - singleButton(for: mapping) - .layoutPriority(1) } } - @ViewBuilder - private func singleButton(for mapping: OpenHABWidgetMapping) -> some View { - inlineButton(label: mapping.label, isPressed: singlePressed) - .overlay { - GeometryReader { geometry in - let bounds = CGRect(origin: .zero, size: geometry.size) - Color.clear - .contentShape(Rectangle()) - .gesture( - DragGesture(minimumDistance: 0) - .onChanged { value in - singlePressed = bounds.contains(value.location) - } - .onEnded { value in - if singlePressed, bounds.contains(value.location) { - Logger.rowViews.info("Sending command: \(mapping.command)") - widget.sendCommand(mapping.command) - } - singlePressed = false - } - ) - } - } + private var selectedIndexBinding: Binding { + Binding( + get: { viewModel.selectedIndex }, + set: { viewModel.selectedIndex = $0 } + ) } // MARK: - Multi-Segment (existing NavigationLink) @@ -143,10 +132,10 @@ struct SegmentRow: View { TextLabelView(widget: widget, font: .caption) Spacer() } - NavigationLink(destination: LazyView(SegmentSelectionView(widget: widget, selectedIndex: $selectedIndex))) { + NavigationLink(destination: LazyView(SegmentSelectionView(widget: widget, selectedIndex: selectedIndexBinding))) { HStack { - if currentIndex >= 0, currentIndex < widget.mappingsOrItemOptions.count { - Text(widget.mappingsOrItemOptions[currentIndex].label) + if let currentIndex, currentIndex >= 0, currentIndex < viewModel.mappings.count { + Text(viewModel.mappings[currentIndex].label) .foregroundStyle(.secondary) .lineLimit(1) } @@ -160,25 +149,56 @@ struct SegmentRow: View { } .buttonStyle(.plain) } - .onAppear { - selectedIndex = widget.mappingIndex(byCommand: widget.item?.state).map { Int($0) } - } - .onChange(of: widget.item?.state, initial: false) { _, newValue in - selectedIndex = widget.mappingIndex(byCommand: newValue).map { Int($0) } - } } - // MARK: - Shared Components + // MARK: - Single Mapping @ViewBuilder - private var iconTitleRow: some View { + private var singleMappingContent: some View { + let mapping = viewModel.mappings[0] HStack { IconView(widget: widget, settings: settings) TextLabelView(widget: widget, font: .caption) Spacer() + singleButton(for: mapping) + .layoutPriority(1) } } + init(widget: OpenHABWidget) { + self.widget = widget + _viewModel = StateObject(wrappedValue: WidgetRowViewModel(widget: widget)) + } + + // MARK: - Single Mapping + + @ViewBuilder + private func singleButton(for mapping: OpenHABWidgetMapping) -> some View { + inlineButton(label: mapping.label, isPressed: singlePressed) + .overlay { + GeometryReader { geometry in + let bounds = CGRect(origin: .zero, size: geometry.size) + Color.clear + .contentShape(Rectangle()) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { value in + singlePressed = bounds.contains(value.location) + } + .onEnded { value in + if singlePressed, bounds.contains(value.location) { + Logger.rowViews.info("Sending command: \(mapping.command)") + widget.sendCommand(mapping.command) + } + singlePressed = false + } + ) + } + } + } + + // MARK: - Shared Components + @ViewBuilder private func inlineButton(label: String, isPressed: Bool) -> some View { Text(label) From 314bca42372abe4949ec3ca551788bacab0f360a Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Mon, 9 Feb 2026 08:20:09 +0100 Subject: [PATCH 429/476] Migrate WidgetRowViewModel to Observation framework Replace ObservableObject/@Published with @Observable macro and switch from @StateObject to @State in SegmentRow. Replace onReceive with onChange(of: widget.item?.state) to avoid the DispatchQueue.main.async deferral hack needed by objectWillChange's pre-change timing. Signed-off-by: Tim Mueller-Seydlitz --- openHABWatch/Model/WidgetRowViewModel.swift | 41 +++++++++++++++++++++ openHABWatch/Views/Rows/SegmentRow.swift | 10 ++--- 2 files changed, 45 insertions(+), 6 deletions(-) create mode 100644 openHABWatch/Model/WidgetRowViewModel.swift diff --git a/openHABWatch/Model/WidgetRowViewModel.swift b/openHABWatch/Model/WidgetRowViewModel.swift new file mode 100644 index 000000000..4e954dbbd --- /dev/null +++ b/openHABWatch/Model/WidgetRowViewModel.swift @@ -0,0 +1,41 @@ +// Copyright (c) 2010-2026 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 Foundation +import Observation +import OpenHABCore + +@MainActor +@Observable +final class WidgetRowViewModel { + var mappings: [OpenHABWidgetMapping] = [] + var selectedIndex: Int? + var hasPressReleaseMappings = false + var labelText = "" + var selectedLabel: String? + + init(widget: OpenHABWidget) { + update(from: widget) + } + + func update(from widget: OpenHABWidget) { + let newMappings = widget.mappingsOrItemOptions + mappings = newMappings + hasPressReleaseMappings = widget.hasPressReleaseMappings + labelText = widget.labelText ?? widget.label + selectedIndex = widget.mappingIndex(byCommand: widget.item?.state).map { Int($0) } + if let index = selectedIndex, index >= 0, index < newMappings.count { + selectedLabel = newMappings[index].label + } else { + selectedLabel = nil + } + } +} diff --git a/openHABWatch/Views/Rows/SegmentRow.swift b/openHABWatch/Views/Rows/SegmentRow.swift index 7a15421c9..4dc12d038 100644 --- a/openHABWatch/Views/Rows/SegmentRow.swift +++ b/openHABWatch/Views/Rows/SegmentRow.swift @@ -19,7 +19,7 @@ struct SegmentRow: View { @EnvironmentObject var settings: AppSettings @State private var pressedIndex: Int? @State private var singlePressed = false - @StateObject private var viewModel: WidgetRowViewModel + @State private var viewModel: WidgetRowViewModel private var currentIndex: Int? { viewModel.selectedIndex @@ -38,10 +38,8 @@ struct SegmentRow: View { .onAppear { viewModel.update(from: widget) } - .onReceive(widget.objectWillChange) { _ in - DispatchQueue.main.async { - viewModel.update(from: widget) - } + .onChange(of: widget.item?.state, initial: false) { _, _ in + viewModel.update(from: widget) } } @@ -167,7 +165,7 @@ struct SegmentRow: View { init(widget: OpenHABWidget) { self.widget = widget - _viewModel = StateObject(wrappedValue: WidgetRowViewModel(widget: widget)) + _viewModel = State(wrappedValue: WidgetRowViewModel(widget: widget)) } // MARK: - Single Mapping From 8e02461a89a7f26575a99b17e1a5647245a80f74 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Mon, 9 Feb 2026 08:28:40 +0100 Subject: [PATCH 430/476] Update project Signed-off-by: Tim Mueller-Seydlitz --- openHAB.xcodeproj/project.pbxproj | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index 6c8566a24..1c4fed1f6 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -95,6 +95,7 @@ DA2AEB702D92CF3E00897D80 /* UITableViewCellExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2AEB6F2D92CF3E00897D80 /* UITableViewCellExtension.swift */; }; DA2AEBA02D92FB6500897D80 /* NoIconDisplayableCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2AEB9F2D92FB6500897D80 /* NoIconDisplayableCell.swift */; }; DA2C4FD52B4F573300D1C533 /* SDWebImageSVGCoder in Frameworks */ = {isa = PBXBuildFile; productRef = DA2C4FD42B4F573300D1C533 /* SDWebImageSVGCoder */; }; + DA2D2F8A2F3943A800EC605A /* WidgetRowViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2D2F892F3943A800EC605A /* WidgetRowViewModel.swift */; }; DA2E0AA423DC96E9009B0A99 /* ImageWithAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2E0AA323DC96E9009B0A99 /* ImageWithAction.swift */; }; DA2E0B0E23DCC153009B0A99 /* MapView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2E0B0D23DCC152009B0A99 /* MapView.swift */; }; DA2E0B1023DCC439009B0A99 /* MapViewRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2E0B0F23DCC439009B0A99 /* MapViewRow.swift */; }; @@ -448,6 +449,7 @@ DA2AEB6D2D92BAD800897D80 /* PageLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageLoader.swift; sourceTree = ""; }; DA2AEB6F2D92CF3E00897D80 /* UITableViewCellExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITableViewCellExtension.swift; sourceTree = ""; }; DA2AEB9F2D92FB6500897D80 /* NoIconDisplayableCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoIconDisplayableCell.swift; sourceTree = ""; }; + DA2D2F892F3943A800EC605A /* WidgetRowViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetRowViewModel.swift; sourceTree = ""; }; DA2DC22F21F2736C00830730 /* openHABTestsSwift.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = openHABTestsSwift.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; DA2DC23321F2736C00830730 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; DA2E0AA323DC96E9009B0A99 /* ImageWithAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageWithAction.swift; sourceTree = ""; }; @@ -773,6 +775,7 @@ DA9721C224E29A8F0092CCFD /* UserDefaultsBacked.swift */, DAC9AF4624F9669F006DAE93 /* OpenHABWidgetExtension.swift */, DAC9AF4824F966FA006DAE93 /* LazyView.swift */, + DA2D2F892F3943A800EC605A /* WidgetRowViewModel.swift */, ); path = Model; sourceTree = ""; @@ -1681,6 +1684,7 @@ DA07752D2346705F0086C685 /* NotificationController.swift in Sources */, DA0749E023E0BF510057FA83 /* ColorSelection.swift in Sources */, DA65871F236F83CE007E2E7F /* UserDefaultsExtension.swift in Sources */, + DA2D2F8A2F3943A800EC605A /* WidgetRowViewModel.swift in Sources */, DA9721C324E29A8F0092CCFD /* UserDefaultsBacked.swift in Sources */, DA72E1B8236DEA0900B8EF3A /* AppMessageService.swift in Sources */, DA07752B2346705F0086C685 /* OpenHABWatchAppDelegate.swift in Sources */, From b5be93a9dc4e793c0717f3fd667117c3201ecec0 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Mon, 9 Feb 2026 09:40:46 +0100 Subject: [PATCH 431/476] Adopt WidgetRowViewModel and clean up watchOS row views - Migrate all rows from onReceive(objectWillChange) to onChange(of: widget.item?.state) for post-change timing - Eliminate circular selectedIndex sync in SelectionRow and SegmentSelectionView by using computed bindings - Consolidate ImageRawRow duplicated modifiers into a Group - Replace separate numberStateValue/unit with NumberState in viewModel to fix mixed widget/viewModel access in SetpointRow - Remove dead code (isIntStep, stateFormat) from SetpointRow Signed-off-by: Tim Mueller-Seydlitz --- openHABWatch/Model/WidgetRowViewModel.swift | 27 +++++++++ openHABWatch/Views/Rows/ColorPickerRow.swift | 14 ++++- openHABWatch/Views/Rows/FrameRow.swift | 14 ++++- openHABWatch/Views/Rows/GenericRow.swift | 13 ++++ openHABWatch/Views/Rows/ImageRawRow.swift | 28 ++++++--- openHABWatch/Views/Rows/MapViewRow.swift | 13 ++++ .../Views/Rows/RollershutterRow.swift | 13 ++++ openHABWatch/Views/Rows/SegmentRow.swift | 4 +- .../Views/Rows/SegmentSelectionView.swift | 32 +++++++--- openHABWatch/Views/Rows/SelectionRow.swift | 60 +++++++++++++------ openHABWatch/Views/Rows/SetpointRow.swift | 43 ++++++------- openHABWatch/Views/Rows/SliderRow.swift | 31 +++++++--- openHABWatch/Views/Rows/SwitchRow.swift | 24 ++++---- openHABWatch/Views/Rows/TextRow.swift | 14 ++++- 14 files changed, 251 insertions(+), 79 deletions(-) diff --git a/openHABWatch/Model/WidgetRowViewModel.swift b/openHABWatch/Model/WidgetRowViewModel.swift index 4e954dbbd..46cdd314b 100644 --- a/openHABWatch/Model/WidgetRowViewModel.swift +++ b/openHABWatch/Model/WidgetRowViewModel.swift @@ -12,6 +12,7 @@ import Foundation import Observation import OpenHABCore +import UIKit @MainActor @Observable @@ -20,7 +21,18 @@ final class WidgetRowViewModel { var selectedIndex: Int? var hasPressReleaseMappings = false var labelText = "" + var labelValue: String? var selectedLabel: String? + var effectiveState = "" + var isOn = false + var adjustedValue: Double = 0.0 + var minValue: Double = 0.0 + var maxValue: Double = 100.0 + var step: Double = 1.0 + var switchSupport = false + var hasLinkedPage = false + var numberState: NumberState? + var colorState: UIColor? init(widget: OpenHABWidget) { update(from: widget) @@ -31,11 +43,26 @@ final class WidgetRowViewModel { mappings = newMappings hasPressReleaseMappings = widget.hasPressReleaseMappings labelText = widget.labelText ?? widget.label + labelValue = widget.labelValue selectedIndex = widget.mappingIndex(byCommand: widget.item?.state).map { Int($0) } if let index = selectedIndex, index >= 0, index < newMappings.count { selectedLabel = newMappings[index].label } else { selectedLabel = nil } + if widget.state.isEmpty { + effectiveState = widget.item?.state ?? "" + } else { + effectiveState = widget.state + } + isOn = effectiveState.parseAsBool() + adjustedValue = widget.adjustedValue + minValue = widget.minValue + maxValue = widget.maxValue + step = widget.step + switchSupport = widget.switchSupport + hasLinkedPage = widget.linkedPage != nil + numberState = widget.stateValueAsNumberState + colorState = widget.item?.stateAsUIColor() } } diff --git a/openHABWatch/Views/Rows/ColorPickerRow.swift b/openHABWatch/Views/Rows/ColorPickerRow.swift index 7a1a4513b..763ba5e65 100644 --- a/openHABWatch/Views/Rows/ColorPickerRow.swift +++ b/openHABWatch/Views/Rows/ColorPickerRow.swift @@ -18,8 +18,9 @@ import SwiftUI struct ColorPickerRow: View { @ObservedObject var widget: OpenHABWidget @ObservedObject var settings = AppSettings.shared + @State private var viewModel: WidgetRowViewModel var body: some View { - let uiColor = widget.item?.stateAsUIColor() + let uiColor = viewModel.colorState return VStack(spacing: 0) { @@ -59,6 +60,12 @@ struct ColorPickerRow: View { Spacer() } } + .onAppear { + viewModel.update(from: widget) + } + .onChange(of: widget.item?.state, initial: false) { _, _ in + viewModel.update(from: widget) + } } func upButtonPressed() { @@ -70,6 +77,11 @@ struct ColorPickerRow: View { Logger.rowViews.info("OFF button pressed") widget.sendCommand("OFF") } + + init(widget: OpenHABWidget) { + self.widget = widget + _viewModel = State(wrappedValue: WidgetRowViewModel(widget: widget)) + } } #Preview { diff --git a/openHABWatch/Views/Rows/FrameRow.swift b/openHABWatch/Views/Rows/FrameRow.swift index bf83155c9..a10c0854d 100644 --- a/openHABWatch/Views/Rows/FrameRow.swift +++ b/openHABWatch/Views/Rows/FrameRow.swift @@ -15,14 +15,26 @@ import SwiftUI struct FrameRow: View { @ObservedObject var widget: OpenHABWidget @EnvironmentObject var settings: AppSettings + @State private var viewModel: WidgetRowViewModel var body: some View { HStack { - Text(widget.labelText?.uppercased() ?? "") + Text(viewModel.labelText.uppercased()) .font(.callout) .lineLimit(1) Spacer() } + .onAppear { + viewModel.update(from: widget) + } + .onChange(of: widget.item?.state, initial: false) { _, _ in + viewModel.update(from: widget) + } + } + + init(widget: OpenHABWidget) { + self.widget = widget + _viewModel = State(wrappedValue: WidgetRowViewModel(widget: widget)) } } diff --git a/openHABWatch/Views/Rows/GenericRow.swift b/openHABWatch/Views/Rows/GenericRow.swift index dcf4b48b4..71ada4973 100644 --- a/openHABWatch/Views/Rows/GenericRow.swift +++ b/openHABWatch/Views/Rows/GenericRow.swift @@ -17,6 +17,7 @@ import SwiftUI struct GenericRow: View { @ObservedObject var widget: OpenHABWidget @ObservedObject var settings = AppSettings.shared + @State private var viewModel: WidgetRowViewModel var body: some View { HStack { @@ -26,6 +27,18 @@ struct GenericRow: View { DetailTextLabelView(widget: widget) widget.makeView(settings: settings) } + .accessibilityLabel(viewModel.labelText) + .onAppear { + viewModel.update(from: widget) + } + .onChange(of: widget.item?.state, initial: false) { _, _ in + viewModel.update(from: widget) + } + } + + init(widget: OpenHABWidget) { + self.widget = widget + _viewModel = State(wrappedValue: WidgetRowViewModel(widget: widget)) } } diff --git a/openHABWatch/Views/Rows/ImageRawRow.swift b/openHABWatch/Views/Rows/ImageRawRow.swift index 7d7637770..b7bcd4ee0 100644 --- a/openHABWatch/Views/Rows/ImageRawRow.swift +++ b/openHABWatch/Views/Rows/ImageRawRow.swift @@ -16,17 +16,29 @@ import SwiftUI struct ImageRawRow: View { @ObservedObject var widget: OpenHABWidget @EnvironmentObject var settings: AppSettings + @State private var viewModel: WidgetRowViewModel var body: some View { - if let data = widget.item?.state?.components(separatedBy: ",")[safe: 1], - let decodedData = Data(base64Encoded: data, options: .ignoreUnknownCharacters), - let image = UIImage(data: decodedData) { - Image(uiImage: image) - .resizable() - .scaledToFit() - } else { - EmptyView() + Group { + if let data = viewModel.effectiveState.components(separatedBy: ",")[safe: 1], + let decodedData = Data(base64Encoded: data, options: .ignoreUnknownCharacters), + let image = UIImage(data: decodedData) { + Image(uiImage: image) + .resizable() + .scaledToFit() + } } + .onAppear { + viewModel.update(from: widget) + } + .onChange(of: widget.item?.state, initial: false) { _, _ in + viewModel.update(from: widget) + } + } + + init(widget: OpenHABWidget) { + self.widget = widget + _viewModel = State(wrappedValue: WidgetRowViewModel(widget: widget)) } } diff --git a/openHABWatch/Views/Rows/MapViewRow.swift b/openHABWatch/Views/Rows/MapViewRow.swift index 6867ee2db..dbde8cfaa 100644 --- a/openHABWatch/Views/Rows/MapViewRow.swift +++ b/openHABWatch/Views/Rows/MapViewRow.swift @@ -15,6 +15,7 @@ import SwiftUI struct MapViewRow: View { @ObservedObject var widget: OpenHABWidget @EnvironmentObject var settings: AppSettings + @State private var viewModel: WidgetRowViewModel var body: some View { VStack { @@ -23,6 +24,18 @@ struct MapViewRow: View { .padding() // .frame(height: 300) } + .accessibilityLabel(viewModel.labelText) + .onAppear { + viewModel.update(from: widget) + } + .onChange(of: widget.item?.state, initial: false) { _, _ in + viewModel.update(from: widget) + } + } + + init(widget: OpenHABWidget) { + self.widget = widget + _viewModel = State(wrappedValue: WidgetRowViewModel(widget: widget)) } } diff --git a/openHABWatch/Views/Rows/RollershutterRow.swift b/openHABWatch/Views/Rows/RollershutterRow.swift index f88bef76c..65e513656 100644 --- a/openHABWatch/Views/Rows/RollershutterRow.swift +++ b/openHABWatch/Views/Rows/RollershutterRow.swift @@ -17,6 +17,7 @@ import SwiftUI struct RollershutterRow: View { @ObservedObject var widget: OpenHABWidget @EnvironmentObject var settings: AppSettings + @State private var viewModel: WidgetRowViewModel var body: some View { VStack(spacing: -5) { @@ -43,6 +44,18 @@ struct RollershutterRow: View { } .frame(height: 50) } + .accessibilityLabel(viewModel.labelText) + .onAppear { + viewModel.update(from: widget) + } + .onChange(of: widget.item?.state, initial: false) { _, _ in + viewModel.update(from: widget) + } + } + + init(widget: OpenHABWidget) { + self.widget = widget + _viewModel = State(wrappedValue: WidgetRowViewModel(widget: widget)) } } diff --git a/openHABWatch/Views/Rows/SegmentRow.swift b/openHABWatch/Views/Rows/SegmentRow.swift index 4dc12d038..71d7ce57a 100644 --- a/openHABWatch/Views/Rows/SegmentRow.swift +++ b/openHABWatch/Views/Rows/SegmentRow.swift @@ -149,7 +149,7 @@ struct SegmentRow: View { } } - // MARK: - Single Mapping + // MARK: - Single Mapping var @ViewBuilder private var singleMappingContent: some View { @@ -168,7 +168,7 @@ struct SegmentRow: View { _viewModel = State(wrappedValue: WidgetRowViewModel(widget: widget)) } - // MARK: - Single Mapping + // MARK: - Single Mapping func @ViewBuilder private func singleButton(for mapping: OpenHABWidgetMapping) -> some View { diff --git a/openHABWatch/Views/Rows/SegmentSelectionView.swift b/openHABWatch/Views/Rows/SegmentSelectionView.swift index 06fa3d9ab..a58e8cec4 100644 --- a/openHABWatch/Views/Rows/SegmentSelectionView.swift +++ b/openHABWatch/Views/Rows/SegmentSelectionView.swift @@ -19,18 +19,19 @@ struct SegmentSelectionView: View { @Environment(\.dismiss) private var dismiss @State private var pendingValue: String? @State private var pressedIndex: Int? + @State private var viewModel: WidgetRowViewModel - private var currentIndex: Int { - selectedIndex ?? widget.mappingIndex(byCommand: widget.item?.state).map { Int($0) } ?? 0 + private var currentIndex: Int? { + viewModel.selectedIndex } var body: some View { ScrollView { LazyVStack(spacing: 12) { - ForEach(0 ..< widget.mappingsOrItemOptions.count, id: \.self) { index in - let mapping = widget.mappingsOrItemOptions[index] + ForEach(0 ..< viewModel.mappings.count, id: \.self) { index in + let mapping = viewModel.mappings[index] - if widget.hasPressReleaseMappings { + if viewModel.hasPressReleaseMappings { // Press-release button for mappings with releaseCommand pressReleaseButton(for: mapping, at: index) } else { @@ -43,6 +44,14 @@ struct SegmentSelectionView: View { } .navigationTitle("Select Option") .navigationBarTitleDisplayMode(.inline) + .onAppear { + viewModel.update(from: widget) + selectedIndex = viewModel.selectedIndex + } + .onChange(of: widget.item?.state, initial: false) { _, _ in + viewModel.update(from: widget) + selectedIndex = viewModel.selectedIndex + } } @ViewBuilder @@ -86,7 +95,7 @@ struct SegmentSelectionView: View { .foregroundStyle(.primary) .multilineTextAlignment(.leading) Spacer() - if isSelected(index: index), !widget.hasPressReleaseMappings { + if isSelected(index: index), !viewModel.hasPressReleaseMappings { Image(systemSymbol: .checkmark) .foregroundStyle(Color.accentColor) .font(.caption.weight(.bold)) @@ -118,8 +127,9 @@ struct SegmentSelectionView: View { } private func selectOption(at index: Int) { - selectedIndex = index - if let selectedCommand = widget.mappingsOrItemOptions[safe: index]?.command { + viewModel.selectedIndex = index + selectedIndex = viewModel.selectedIndex + if let selectedCommand = viewModel.mappings[safe: index]?.command { pendingValue = selectedCommand Task { @MainActor in try? await Task.sleep(for: .milliseconds(300)) @@ -131,6 +141,12 @@ struct SegmentSelectionView: View { } } } + + init(widget: OpenHABWidget, selectedIndex: Binding) { + self.widget = widget + _selectedIndex = selectedIndex + _viewModel = State(wrappedValue: WidgetRowViewModel(widget: widget)) + } } #Preview { diff --git a/openHABWatch/Views/Rows/SelectionRow.swift b/openHABWatch/Views/Rows/SelectionRow.swift index a6c88e4a4..d48c5b3a8 100644 --- a/openHABWatch/Views/Rows/SelectionRow.swift +++ b/openHABWatch/Views/Rows/SelectionRow.swift @@ -20,13 +20,14 @@ struct SelectionListView: View { @ObservedObject var widget: OpenHABWidget @Binding var selectedIndex: Int? @Environment(\.dismiss) private var dismiss + @State private var viewModel: WidgetRowViewModel private var mappings: [OpenHABWidgetMapping] { - widget.mappingsOrItemOptions + viewModel.mappings } private var currentIndex: Int? { - selectedIndex ?? widget.mappingIndex(byCommand: widget.item?.state).map { Int($0) } + viewModel.selectedIndex } var body: some View { @@ -65,37 +66,53 @@ struct SelectionListView: View { } .navigationTitle(widget.labelText ?? "Select") .navigationBarTitleDisplayMode(.inline) + .onAppear { + viewModel.update(from: widget) + selectedIndex = viewModel.selectedIndex + } + .onChange(of: widget.item?.state, initial: false) { _, _ in + viewModel.update(from: widget) + selectedIndex = viewModel.selectedIndex + } } private func selectOption(at index: Int) { - selectedIndex = index + viewModel.selectedIndex = index + selectedIndex = viewModel.selectedIndex if let selectedCommand = mappings[safe: index]?.command { Logger.rowViews.info("Selection changed to: \(selectedCommand)") widget.sendCommand(selectedCommand) dismiss() } } + + init(widget: OpenHABWidget, selectedIndex: Binding) { + self.widget = widget + _selectedIndex = selectedIndex + _viewModel = State(wrappedValue: WidgetRowViewModel(widget: widget)) + } } struct SelectionRow: View { @ObservedObject var widget: OpenHABWidget @EnvironmentObject var settings: AppSettings - @State private var selectedIndex: Int? - - private var mappings: [OpenHABWidgetMapping] { - widget.mappingsOrItemOptions - } - - private var currentIndex: Int? { - selectedIndex ?? widget.mappingIndex(byCommand: widget.item?.state).map { Int($0) } - } + @State private var viewModel: WidgetRowViewModel /// Returns the label of the currently selected mapping private var selectedValueText: String? { - if let index = currentIndex, index >= 0, index < mappings.count { - return mappings[index].label + if let index = viewModel.selectedIndex, + index >= 0, + index < viewModel.mappings.count { + return viewModel.mappings[index].label } - return widget.labelValue + return viewModel.labelValue + } + + private var selectedIndexBinding: Binding { + Binding( + get: { viewModel.selectedIndex }, + set: { viewModel.selectedIndex = $0 } + ) } var body: some View { @@ -105,7 +122,7 @@ struct SelectionRow: View { TextLabelView(widget: widget, font: .caption) Spacer() } - NavigationLink(destination: LazyView(SelectionListView(widget: widget, selectedIndex: $selectedIndex))) { + NavigationLink(destination: LazyView(SelectionListView(widget: widget, selectedIndex: selectedIndexBinding))) { HStack(spacing: 4) { if let valueText = selectedValueText { Text(valueText) @@ -120,12 +137,17 @@ struct SelectionRow: View { .buttonStyle(.plain) } .onAppear { - selectedIndex = widget.mappingIndex(byCommand: widget.item?.state).map { Int($0) } + viewModel.update(from: widget) } - .onChange(of: widget.item?.state) { _, newState in - selectedIndex = widget.mappingIndex(byCommand: newState).map { Int($0) } + .onChange(of: widget.item?.state, initial: false) { _, _ in + viewModel.update(from: widget) } } + + init(widget: OpenHABWidget) { + self.widget = widget + _viewModel = State(wrappedValue: WidgetRowViewModel(widget: widget)) + } } #Preview { diff --git a/openHABWatch/Views/Rows/SetpointRow.swift b/openHABWatch/Views/Rows/SetpointRow.swift index cd593c34e..0b3b42983 100644 --- a/openHABWatch/Views/Rows/SetpointRow.swift +++ b/openHABWatch/Views/Rows/SetpointRow.swift @@ -20,17 +20,10 @@ struct SetpointRow: View { @EnvironmentObject var settings: AppSettings private let setpointService = SetPointService() private let logger = Logger(subsystem: "org.openhab.watch", category: "SetpointRow") - - private var isIntStep: Bool { - widget.step.truncatingRemainder(dividingBy: 1) == 0 - } - - private var stateFormat: String { - isIntStep ? "%ld" : "%.01f" - } + @State private var viewModel: WidgetRowViewModel private var currentValue: Double { - widget.stateValueAsNumberState?.value ?? widget.minValue + viewModel.numberState?.value ?? viewModel.minValue } var body: some View { @@ -46,10 +39,10 @@ struct SetpointRow: View { Button(action: decreaseValue) { Image(systemSymbol: .chevronDownCircleFill) .font(.system(size: 25)) - .foregroundStyle(currentValue <= widget.minValue ? Color.gray : Color.blue) + .foregroundStyle(currentValue <= viewModel.minValue ? Color.gray : Color.blue) } .buttonStyle(.plain) - .disabled(currentValue <= widget.minValue) + .disabled(currentValue <= viewModel.minValue) Spacer() @@ -61,25 +54,31 @@ struct SetpointRow: View { Button(action: increaseValue) { Image(systemSymbol: .chevronUpCircleFill) .font(.system(size: 25)) - .foregroundStyle(currentValue >= widget.maxValue ? Color.gray : Color.blue) + .foregroundStyle(currentValue >= viewModel.maxValue ? Color.gray : Color.blue) } .buttonStyle(.plain) - .disabled(currentValue >= widget.maxValue) + .disabled(currentValue >= viewModel.maxValue) Spacer() } } + .onAppear { + viewModel.update(from: widget) + } + .onChange(of: widget.item?.state, initial: false) { _, _ in + viewModel.update(from: widget) + } } private func handleUpDown(isDecreasing: Bool) { - var numberState = widget.stateValueAsNumberState - let currentValue = numberState?.value ?? widget.minValue + var numberState = viewModel.numberState + let currentValue = numberState?.value ?? viewModel.minValue let limitedNewValue = setpointService.calculateNewValue( currentValue: currentValue, - step: widget.step, - minValue: widget.minValue, - maxValue: widget.maxValue, + step: viewModel.step, + minValue: viewModel.minValue, + maxValue: viewModel.maxValue, isDecreasing: isDecreasing ) @@ -88,8 +87,7 @@ struct SetpointRow: View { return } - // Use widget's unit as fallback when creating NumberState - numberState = numberState ?? NumberState(value: limitedNewValue, unit: widget.unit) + numberState = numberState ?? NumberState(value: limitedNewValue) numberState?.value = limitedNewValue logger.info("Setpoint \(isDecreasing ? "decreased" : "increased") to \(numberState?.description ?? String(limitedNewValue))") @@ -103,6 +101,11 @@ struct SetpointRow: View { func increaseValue() { handleUpDown(isDecreasing: false) } + + init(widget: OpenHABWidget) { + self.widget = widget + _viewModel = State(wrappedValue: WidgetRowViewModel(widget: widget)) + } } #Preview { diff --git a/openHABWatch/Views/Rows/SliderRow.swift b/openHABWatch/Views/Rows/SliderRow.swift index 234d879dc..2e06c5e24 100644 --- a/openHABWatch/Views/Rows/SliderRow.swift +++ b/openHABWatch/Views/Rows/SliderRow.swift @@ -20,19 +20,20 @@ struct SliderRow: View { @EnvironmentObject var settings: AppSettings var fallbackSymbol: SFSymbol? @State private var pendingValue: Double? + @State private var viewModel: WidgetRowViewModel private var currentValue: Double { - pendingValue ?? widget.adjustedValue + pendingValue ?? viewModel.adjustedValue } private var currentValueText: String { - currentValue.valueText(step: widget.step) + currentValue.valueText(step: viewModel.step) } var valueBinding: Binding { .init( get: { - pendingValue ?? widget.adjustedValue + pendingValue ?? viewModel.adjustedValue }, set: { newValue in Logger.rowViews.info("SliderRow new value = \(newValue)") @@ -40,7 +41,7 @@ struct SliderRow: View { Task { @MainActor in try? await Task.sleep(for: .milliseconds(500)) if pendingValue == newValue { // Ensure no new updates came in - widget.sendCommand(newValue.valueText(step: widget.step)) + widget.sendCommand(newValue.valueText(step: viewModel.step)) pendingValue = nil } } @@ -51,15 +52,15 @@ struct SliderRow: View { private var stateBinding: Binding { Binding( get: { - widget.adjustedValue > widget.minValue + viewModel.adjustedValue > viewModel.minValue }, set: { newValue in if newValue { Logger.rowViews.info("SliderRow switch to ON") - widget.sendCommand(widget.maxValue.valueText(step: widget.step)) + widget.sendCommand(viewModel.maxValue.valueText(step: viewModel.step)) } else { Logger.rowViews.info("SliderRow switch to OFF") - widget.sendCommand(widget.minValue.valueText(step: widget.step)) + widget.sendCommand(viewModel.minValue.valueText(step: viewModel.step)) } } ) @@ -67,7 +68,7 @@ struct SliderRow: View { var body: some View { VStack(spacing: 3) { - if widget.switchSupport { + if viewModel.switchSupport { Toggle(isOn: stateBinding) { HStack { IconView(widget: widget, settings: settings, fallbackSymbol: fallbackSymbol) @@ -100,9 +101,21 @@ struct SliderRow: View { }.padding(.top, 8) } - Slider(value: valueBinding, in: widget.minValue ... widget.maxValue, step: widget.step) + Slider(value: valueBinding, in: viewModel.minValue ... viewModel.maxValue, step: viewModel.step) .labelsHidden() } + .onAppear { + viewModel.update(from: widget) + } + .onChange(of: widget.item?.state, initial: false) { _, _ in + viewModel.update(from: widget) + } + } + + init(widget: OpenHABWidget, fallbackSymbol: SFSymbol? = nil) { + self.widget = widget + self.fallbackSymbol = fallbackSymbol + _viewModel = State(wrappedValue: WidgetRowViewModel(widget: widget)) } } diff --git a/openHABWatch/Views/Rows/SwitchRow.swift b/openHABWatch/Views/Rows/SwitchRow.swift index 5d8ba59b2..cc4ad4ed3 100644 --- a/openHABWatch/Views/Rows/SwitchRow.swift +++ b/openHABWatch/Views/Rows/SwitchRow.swift @@ -18,17 +18,10 @@ struct SwitchRow: View { @ObservedObject var widget: OpenHABWidget @EnvironmentObject var settings: AppSettings @State private var localIsOn: Bool? - - private var effectiveState: String { - var state = widget.state - if state.isEmpty { - state = widget.item?.state ?? "" - } - return state - } + @State private var viewModel: WidgetRowViewModel private var isOn: Bool { - localIsOn ?? effectiveState.parseAsBool() + localIsOn ?? viewModel.isOn } var body: some View { @@ -55,10 +48,21 @@ struct SwitchRow: View { } .padding(.trailing) .cornerRadius(5) - .onChange(of: effectiveState) { + .onAppear { + viewModel.update(from: widget) + } + .onChange(of: widget.item?.state, initial: false) { _, _ in + viewModel.update(from: widget) + } + .onChange(of: viewModel.effectiveState) { localIsOn = nil } } + + init(widget: OpenHABWidget) { + self.widget = widget + _viewModel = State(wrappedValue: WidgetRowViewModel(widget: widget)) + } } #Preview { diff --git a/openHABWatch/Views/Rows/TextRow.swift b/openHABWatch/Views/Rows/TextRow.swift index 4b2f2adef..116fc5b23 100644 --- a/openHABWatch/Views/Rows/TextRow.swift +++ b/openHABWatch/Views/Rows/TextRow.swift @@ -17,6 +17,7 @@ import SwiftUI struct TextRow: View { @ObservedObject var widget: OpenHABWidget @EnvironmentObject var settings: AppSettings + @State private var viewModel: WidgetRowViewModel var body: some View { HStack { @@ -24,12 +25,23 @@ struct TextRow: View { TextLabelView(widget: widget, font: .caption, lineLimit: 2) Spacer() DetailTextLabelView(widget: widget) - if widget.linkedPage != nil { + if viewModel.hasLinkedPage { Image(systemSymbol: .chevronRight) .foregroundStyle(.secondary) .font(.caption) } } + .onAppear { + viewModel.update(from: widget) + } + .onChange(of: widget.item?.state, initial: false) { _, _ in + viewModel.update(from: widget) + } + } + + init(widget: OpenHABWidget) { + self.widget = widget + _viewModel = State(wrappedValue: WidgetRowViewModel(widget: widget)) } } From 8a5f408386bdb88360afb99d9a54392f8d667afc Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Mon, 9 Feb 2026 14:08:34 +0100 Subject: [PATCH 432/476] Extract WidgetRowFactory and decouple previews from UserData Extract widget-to-row mapping into WidgetRowFactory and introduce PreviewWidgetFactory for standalone preview data across all watchOS row views, removing the dependency on UserData(preview: true). Signed-off-by: Tim Mueller-Seydlitz --- openHAB.xcodeproj/project.pbxproj | 8 + openHABWatch/Model/WidgetRowFactory.swift | 71 ++++++ .../PreviewWidgetFactory.swift | 239 ++++++++++++++++++ openHABWatch/Views/Rows/ColorPickerRow.swift | 6 +- openHABWatch/Views/Rows/FrameRow.swift | 2 +- openHABWatch/Views/Rows/GenericRow.swift | 2 +- openHABWatch/Views/Rows/ImageRawRow.swift | 3 +- openHABWatch/Views/Rows/MapViewRow.swift | 2 +- .../Views/Rows/RollershutterRow.swift | 2 +- openHABWatch/Views/Rows/SegmentRow.swift | 164 +++++++++++- .../Views/Rows/SegmentSelectionView.swift | 11 +- openHABWatch/Views/Rows/SelectionRow.swift | 12 +- openHABWatch/Views/Rows/SetpointRow.swift | 9 +- openHABWatch/Views/Rows/SliderRow.swift | 58 +---- openHABWatch/Views/Rows/SwitchRow.swift | 4 +- openHABWatch/Views/Rows/TextRow.swift | 2 +- openHABWatch/Views/SitemapPageView.swift | 58 +---- 17 files changed, 527 insertions(+), 126 deletions(-) create mode 100644 openHABWatch/Model/WidgetRowFactory.swift create mode 100644 openHABWatch/Preview Content/PreviewWidgetFactory.swift diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index 1c4fed1f6..b38e4c9b6 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -139,6 +139,8 @@ DA7F002D2EB376CF00DE943A /* ServerCertificatesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA7F002C2EB376CF00DE943A /* ServerCertificatesView.swift */; }; DA817E7A234BF39B00C91824 /* CHANGELOG.md in Resources */ = {isa = PBXBuildFile; fileRef = DA817E79234BF39B00C91824 /* CHANGELOG.md */; }; DA88F8C622EC377200B408E5 /* ReleaseNotes.md in Resources */ = {isa = PBXBuildFile; fileRef = DA88F8C522EC377100B408E5 /* ReleaseNotes.md */; }; + DA8B14B62F3A0DFF007753FD /* WidgetRowFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA8B14B52F3A0DFF007753FD /* WidgetRowFactory.swift */; }; + DA8B14B82F3A120E007753FD /* PreviewWidgetFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA8B14B72F3A11F0007753FD /* PreviewWidgetFactory.swift */; }; DA94AEB42EC896BE003BB3C8 /* SimpleMJPEGPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA94AEB32EC896BE003BB3C8 /* SimpleMJPEGPlayer.swift */; }; DA95F3332E0F2B1700FE4474 /* OpenHABRootViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA95F3322E0F2B1700FE4474 /* OpenHABRootViewController.swift */; }; DA95F3352E0F2C1600FE4474 /* OpenHABSitemapViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA95F3342E0F2C1600FE4474 /* OpenHABSitemapViewController.swift */; }; @@ -495,6 +497,8 @@ DA7F002C2EB376CF00DE943A /* ServerCertificatesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerCertificatesView.swift; sourceTree = ""; }; DA817E79234BF39B00C91824 /* CHANGELOG.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = CHANGELOG.md; sourceTree = ""; }; DA88F8C522EC377100B408E5 /* ReleaseNotes.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = ReleaseNotes.md; sourceTree = ""; }; + DA8B14B52F3A0DFF007753FD /* WidgetRowFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetRowFactory.swift; sourceTree = ""; }; + DA8B14B72F3A11F0007753FD /* PreviewWidgetFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewWidgetFactory.swift; sourceTree = ""; }; DA94AEB32EC896BE003BB3C8 /* SimpleMJPEGPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleMJPEGPlayer.swift; sourceTree = ""; }; DA94AF432EC8DE41003BB3C8 /* VideoStreamManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoStreamManager.swift; sourceTree = ""; }; DA95F3322E0F2B1700FE4474 /* OpenHABRootViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenHABRootViewController.swift; sourceTree = ""; }; @@ -775,6 +779,7 @@ DA9721C224E29A8F0092CCFD /* UserDefaultsBacked.swift */, DAC9AF4624F9669F006DAE93 /* OpenHABWidgetExtension.swift */, DAC9AF4824F966FA006DAE93 /* LazyView.swift */, + DA8B14B52F3A0DFF007753FD /* WidgetRowFactory.swift */, DA2D2F892F3943A800EC605A /* WidgetRowViewModel.swift */, ); path = Model; @@ -1001,6 +1006,7 @@ isa = PBXGroup; children = ( DAD085662AE4782A001D36BE /* Preview Assets.xcassets */, + DA8B14B72F3A11F0007753FD /* PreviewWidgetFactory.swift */, ); path = "Preview Content"; sourceTree = ""; @@ -1677,6 +1683,8 @@ DA32D1B42C8C98C40018D974 /* IconWithAction.swift in Sources */, DA07764A234683BC0086C685 /* SwitchRow.swift in Sources */, DA2E0AA423DC96E9009B0A99 /* ImageWithAction.swift in Sources */, + DA8B14B82F3A120E007753FD /* PreviewWidgetFactory.swift in Sources */, + DA8B14B62F3A0DFF007753FD /* WidgetRowFactory.swift in Sources */, DA0749DE23E0B5950057FA83 /* ColorPickerRow.swift in Sources */, DAF4581E23DC60020018B495 /* ImageRawRow.swift in Sources */, DAD0858B2AE56F0E001D36BE /* OpenHABWatch.swift in Sources */, diff --git a/openHABWatch/Model/WidgetRowFactory.swift b/openHABWatch/Model/WidgetRowFactory.swift new file mode 100644 index 000000000..3f48a8306 --- /dev/null +++ b/openHABWatch/Model/WidgetRowFactory.swift @@ -0,0 +1,71 @@ +// Copyright (c) 2010-2026 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 OpenHABCore +import SwiftUI + +enum WidgetRowFactory { + @MainActor + @ViewBuilder + static func make(widget: OpenHABWidget, settings: AppSettings) -> some View { + switch widget.type { + case .switchWidget: + if !widget.mappings.isEmpty { + SegmentRow(widget: widget) + } else if widget.item?.isOfTypeOrGroupType(.switchItem) ?? false { + SwitchRow(widget: widget) + } else if widget.item?.isOfTypeOrGroupType(.rollershutter) ?? false { + RollershutterRow(widget: widget) + } else if !widget.mappingsOrItemOptions.isEmpty { + SegmentRow(widget: widget) + } else { + SwitchRow(widget: widget) + } + case .slider: + SliderRow(widget: widget) + case .setpoint: + SetpointRow(widget: widget) + case .frame: + FrameRow(widget: widget) + case .text: + TextRow(widget: widget) + case .image: + if widget.item != nil { + ImageRawRow(widget: widget) + } else { + EquatableView(content: ImageRow(url: URL(string: widget.url), refresh: widget.refresh)) + } + case .chart: + let url = Endpoint.chart( + rootUrl: settings.openHABRootUrl, + period: widget.period, + type: widget.item?.type ?? .none, + service: widget.service, + name: widget.item?.name, + legend: widget.legend, + theme: .dark, + forceAsItem: widget.forceAsItem + ).url + EquatableView(content: ImageRow(url: url, refresh: widget.refresh)) + case .mapview: + MapViewRow(widget: widget) + case .colorpicker: + ColorPickerRow(widget: widget) + case .selection: + SelectionRow(widget: widget) + case .video, .webview, .input, .colortemperaturepicker, .buttongrid: + // Not yet implemented for watchOS + GenericRow(widget: widget) + case .group, .defaultWidget, .button, .unknown: + GenericRow(widget: widget) + } + } +} diff --git a/openHABWatch/Preview Content/PreviewWidgetFactory.swift b/openHABWatch/Preview Content/PreviewWidgetFactory.swift new file mode 100644 index 000000000..c02648f89 --- /dev/null +++ b/openHABWatch/Preview Content/PreviewWidgetFactory.swift @@ -0,0 +1,239 @@ +// Copyright (c) 2010-2026 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 + +#if DEBUG +import Foundation +import OpenHABCore + +enum PreviewWidgetFactory { + static func slider(label: String, + value: Double? = nil, + minValue: Double = 0.0, + maxValue: Double = 100.0, + step: Double = 1.0, + icon: String = "slider", + switchSupport: Bool = false) -> OpenHABWidget { + makeWidget( + type: .slider, + label: label, + valueText: value.map { String(Int($0)) }, + state: value.map { String($0) } ?? "NULL", + itemType: "Dimmer", + icon: icon, + minValue: minValue, + maxValue: maxValue, + step: step, + switchSupport: switchSupport + ) + } + + static func switchWidget(label: String, + state: String = "OFF", + icon: String = "switch") -> OpenHABWidget { + makeWidget( + type: .switchWidget, + label: label, + valueText: state, + state: state, + itemType: "Switch", + icon: icon + ) + } + + static func setpoint(label: String, + value: Double, + minValue: Double = 0.0, + maxValue: Double = 100.0, + step: Double = 1.0, + unit: String? = nil, + icon: String = "temperature") -> OpenHABWidget { + let widget = makeWidget( + type: .setpoint, + label: label, + valueText: String(value), + state: String(value), + itemType: "Number", + icon: icon, + minValue: minValue, + maxValue: maxValue, + step: step + ) + widget.unit = unit + return widget + } + + static func selection(label: String, + options: [(String, String)], + selectedIndex: Int = 0, + icon: String = "selection") -> OpenHABWidget { + let mappings = options.map { OpenHABWidgetMapping(command: $0.0, label: $0.1) } + let selectedCommand = options[safe: selectedIndex]?.0 ?? "" + let widget = makeWidget( + type: .selection, + label: label, + valueText: options[safe: selectedIndex]?.1, + state: selectedCommand, + itemType: "String", + icon: icon + ) + widget.mappings = mappings + return widget + } + + static func segmented(label: String, + mappings: [OpenHABWidgetMapping], + selectedState: String? = nil, + icon: String = "switch") -> OpenHABWidget { + let widget = makeWidget( + type: .switchWidget, + label: label, + valueText: nil, + state: selectedState ?? mappings.first?.command ?? "", + itemType: "String", + icon: icon + ) + widget.mappings = mappings + return widget + } + + static func rollershutter(label: String, + state: String = "STOP", + icon: String = "rollershutter") -> OpenHABWidget { + makeWidget( + type: .switchWidget, + label: label, + valueText: state, + state: state, + itemType: "Rollershutter", + icon: icon + ) + } + + static func colorpicker(label: String, + state: String = "0,100,100", + icon: String = "colorwheel") -> OpenHABWidget { + makeWidget( + type: .colorpicker, + label: label, + valueText: nil, + state: state, + itemType: "Color", + icon: icon + ) + } + + static func frame(label: String) -> OpenHABWidget { + makeWidget( + type: .frame, + label: label, + valueText: nil, + state: "", + itemType: "Group", + icon: "frame" + ) + } + + static func mapview(label: String, + state: String = "0,0", + icon: String = "map") -> OpenHABWidget { + makeWidget( + type: .mapview, + label: label, + valueText: nil, + state: state, + itemType: "Location", + icon: icon + ) + } + + static func imageRaw(label: String, + base64: String, + icon: String = "image") -> OpenHABWidget { + makeWidget( + type: .image, + label: label, + valueText: nil, + state: "image/png,\(base64)", + itemType: "Image", + icon: icon + ) + } + + static func text(label: String, + valueText: String? = nil, + icon: String = "text") -> OpenHABWidget { + makeWidget( + type: .text, + label: label, + valueText: valueText, + state: valueText ?? "", + itemType: "String", + icon: icon + ) + } + + static func generic(label: String, + valueText: String? = nil, + icon: String = "item") -> OpenHABWidget { + makeWidget( + type: .unknown, + label: label, + valueText: valueText, + state: valueText ?? "", + itemType: "String", + icon: icon + ) + } + + private static func makeWidget(type: OpenHABWidget.WidgetType, + label: String, + valueText: String?, + state: String, + itemType: String, + icon: String, + minValue: Double = 0.0, + maxValue: Double = 100.0, + step: Double = 1.0, + switchSupport: Bool = false) -> OpenHABWidget { + let widget = OpenHABWidget() + widget.widgetId = UUID().uuidString + widget.type = type + widget.icon = icon + widget.minValue = minValue + widget.maxValue = maxValue + widget.step = step + widget.switchSupport = switchSupport + + if let valueText { + widget.label = "\(label) [\(valueText)]" + } else { + widget.label = label + } + + let item = OpenHABItem( + name: "Preview_\(label.replacingOccurrences(of: " ", with: "_"))", + type: itemType, + state: state, + link: "", + label: label, + groupType: nil, + stateDescription: nil, + commandDescription: nil, + members: [], + category: nil, + options: nil + ) + widget.item = item + + return widget + } +} +#endif diff --git a/openHABWatch/Views/Rows/ColorPickerRow.swift b/openHABWatch/Views/Rows/ColorPickerRow.swift index 763ba5e65..6ee09fc97 100644 --- a/openHABWatch/Views/Rows/ColorPickerRow.swift +++ b/openHABWatch/Views/Rows/ColorPickerRow.swift @@ -85,7 +85,11 @@ struct ColorPickerRow: View { } #Preview { - let widget = UserData(preview: true).widgets[10] + let widget = PreviewWidgetFactory.colorpicker( + label: "Color", + state: "120,100,100", + icon: "colorwheel" + ) ColorPickerRow(widget: widget) .environmentObject(AppSettings()) } diff --git a/openHABWatch/Views/Rows/FrameRow.swift b/openHABWatch/Views/Rows/FrameRow.swift index a10c0854d..43ecbfa22 100644 --- a/openHABWatch/Views/Rows/FrameRow.swift +++ b/openHABWatch/Views/Rows/FrameRow.swift @@ -39,7 +39,7 @@ struct FrameRow: View { } #Preview { - let widget = UserData(preview: true).widgets[6] + let widget = PreviewWidgetFactory.frame(label: "Environment") FrameRow(widget: widget) .environmentObject(AppSettings()) } diff --git a/openHABWatch/Views/Rows/GenericRow.swift b/openHABWatch/Views/Rows/GenericRow.swift index 71ada4973..360a6ea72 100644 --- a/openHABWatch/Views/Rows/GenericRow.swift +++ b/openHABWatch/Views/Rows/GenericRow.swift @@ -43,7 +43,7 @@ struct GenericRow: View { } #Preview { - let widget = UserData(preview: true).widgets[6] + let widget = PreviewWidgetFactory.generic(label: "Unsupported Widget", valueText: "N/A") GenericRow(widget: widget) .environmentObject(AppSettings()) } diff --git a/openHABWatch/Views/Rows/ImageRawRow.swift b/openHABWatch/Views/Rows/ImageRawRow.swift index b7bcd4ee0..13ca791d5 100644 --- a/openHABWatch/Views/Rows/ImageRawRow.swift +++ b/openHABWatch/Views/Rows/ImageRawRow.swift @@ -43,7 +43,8 @@ struct ImageRawRow: View { } #Preview { - let widget = UserData(preview: true).widgets[4] + let base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGMAAQAABQABDQottAAAAABJRU5ErkJggg==" + let widget = PreviewWidgetFactory.imageRaw(label: "Camera", base64: base64) ImageRawRow(widget: widget) .environmentObject(AppSettings()) } diff --git a/openHABWatch/Views/Rows/MapViewRow.swift b/openHABWatch/Views/Rows/MapViewRow.swift index dbde8cfaa..4546eb91c 100644 --- a/openHABWatch/Views/Rows/MapViewRow.swift +++ b/openHABWatch/Views/Rows/MapViewRow.swift @@ -40,7 +40,7 @@ struct MapViewRow: View { } #Preview { - let widget = UserData(preview: true).widgets[9] + let widget = PreviewWidgetFactory.mapview(label: "Location", state: "51.5074,0.1278") MapViewRow(widget: widget) .environmentObject(AppSettings()) } diff --git a/openHABWatch/Views/Rows/RollershutterRow.swift b/openHABWatch/Views/Rows/RollershutterRow.swift index 65e513656..fc4562f68 100644 --- a/openHABWatch/Views/Rows/RollershutterRow.swift +++ b/openHABWatch/Views/Rows/RollershutterRow.swift @@ -60,7 +60,7 @@ struct RollershutterRow: View { } #Preview { - let widget = UserData(preview: true).widgets[5] + let widget = PreviewWidgetFactory.rollershutter(label: "Blinds", state: "STOP") RollershutterRow(widget: widget) .environmentObject(AppSettings()) } diff --git a/openHABWatch/Views/Rows/SegmentRow.swift b/openHABWatch/Views/Rows/SegmentRow.swift index 71d7ce57a..b5c5e4210 100644 --- a/openHABWatch/Views/Rows/SegmentRow.swift +++ b/openHABWatch/Views/Rows/SegmentRow.swift @@ -212,11 +212,165 @@ struct SegmentRow: View { } } -#Preview { - let widget = UserData(preview: true).widgets[4] - return Group { - SegmentRow(widget: widget) - SegmentRow(widget: widget) +#Preview("Short Labels") { + VStack(spacing: 8) { + SegmentRow( + widget: PreviewWidgetFactory.segmented( + label: "Light Switch", + mappings: [ + OpenHABWidgetMapping(command: "ON", label: "ON"), + OpenHABWidgetMapping(command: "OFF", label: "OFF") + ], + selectedState: "ON", + icon: "switch" + ) + ) + } + .environmentObject(AppSettings()) +} + +#Preview("Long Labels") { + VStack(spacing: 8) { + SegmentRow( + widget: PreviewWidgetFactory.segmented( + label: "Temperature Control", + mappings: [ + OpenHABWidgetMapping(command: "manual", label: "Manual"), + OpenHABWidgetMapping(command: "calendar", label: "Calendar"), + OpenHABWidgetMapping(command: "automatic", label: "Automatic") + ], + selectedState: "automatic", + icon: "temperature" + ) + ) + } + .environmentObject(AppSettings()) +} + +#Preview("Multiple Segments (4)") { + VStack(spacing: 8) { + SegmentRow( + widget: PreviewWidgetFactory.segmented( + label: "Fan Speed", + mappings: [ + OpenHABWidgetMapping(command: "0", label: "Off"), + OpenHABWidgetMapping(command: "1", label: "Low"), + OpenHABWidgetMapping(command: "2", label: "Med"), + OpenHABWidgetMapping(command: "3", label: "High") + ], + selectedState: "3", + icon: "fan" + ) + ) + } + .environmentObject(AppSettings()) +} + +#Preview("PressRelease") { + VStack(spacing: 8) { + SegmentRow( + widget: PreviewWidgetFactory.segmented( + label: "All Shutters", + mappings: [ + OpenHABWidgetMapping(command: "DOWN", label: "DOWN", releaseCommand: "OFF"), + OpenHABWidgetMapping(command: "UP", label: "UP", releaseCommand: "OFF") + ], + selectedState: "DOWN", + icon: "rollershutter" + ) + ) + } + .environmentObject(AppSettings()) +} + +#Preview("Single Mapping") { + VStack(spacing: 8) { + SegmentRow( + widget: PreviewWidgetFactory.segmented( + label: "Scene", + mappings: [ + OpenHABWidgetMapping(command: "RUN", label: "Run") + ], + selectedState: "RUN", + icon: "scene" + ) + ) + } + .environmentObject(AppSettings()) +} + +#Preview("All Scenarios") { + VStack(spacing: 8) { + SegmentRow( + widget: PreviewWidgetFactory.segmented( + label: "Light", + mappings: [ + OpenHABWidgetMapping(command: "ON", label: "ON"), + OpenHABWidgetMapping(command: "OFF", label: "OFF") + ], + selectedState: "ON", + icon: "light" + ) + ) + SegmentRow( + widget: PreviewWidgetFactory.segmented( + label: "Climate Mode", + mappings: [ + OpenHABWidgetMapping(command: "m", label: "Manual"), + OpenHABWidgetMapping(command: "a", label: "Auto"), + OpenHABWidgetMapping(command: "s", label: "Schedule") + ], + selectedState: "a", + icon: "temperature" + ) + ) + SegmentRow( + widget: PreviewWidgetFactory.segmented( + label: "Fan Speed", + mappings: [ + OpenHABWidgetMapping(command: "0", label: "Off"), + OpenHABWidgetMapping(command: "1", label: "Low"), + OpenHABWidgetMapping(command: "2", label: "Med"), + OpenHABWidgetMapping(command: "3", label: "High") + ], + selectedState: "2", + icon: "fan" + ) + ) + SegmentRow( + widget: PreviewWidgetFactory.segmented( + label: "Charts Period", + mappings: [ + OpenHABWidgetMapping(command: "D", label: "Day"), + OpenHABWidgetMapping(command: "W", label: "Week"), + OpenHABWidgetMapping(command: "M", label: "M"), + OpenHABWidgetMapping(command: "4h", label: "4h") + ], + selectedState: "D", + icon: "chart" + ) + ) + SegmentRow( + widget: PreviewWidgetFactory.segmented( + label: "All Shutters", + mappings: [ + OpenHABWidgetMapping(command: "DOWN", label: "DOWN", releaseCommand: "OFF"), + OpenHABWidgetMapping(command: "UP", label: "UP", releaseCommand: "OFF") + ], + selectedState: "DOWN", + icon: "rollershutter" + ) + ) + SegmentRow( + widget: PreviewWidgetFactory.segmented( + label: "Scene", + mappings: [ + OpenHABWidgetMapping(command: "RUN", label: "RUN") + ], + selectedState: "RUN", + icon: "scene" + ) + ) } .environmentObject(AppSettings()) } diff --git a/openHABWatch/Views/Rows/SegmentSelectionView.swift b/openHABWatch/Views/Rows/SegmentSelectionView.swift index a58e8cec4..bbe5ae372 100644 --- a/openHABWatch/Views/Rows/SegmentSelectionView.swift +++ b/openHABWatch/Views/Rows/SegmentSelectionView.swift @@ -151,7 +151,16 @@ struct SegmentSelectionView: View { #Preview { @Previewable @State var selectedIndex: Int? = 0 - let widget = UserData(preview: true).widgets[4] + let widget = PreviewWidgetFactory.segmented( + label: "Climate Mode", + mappings: [ + OpenHABWidgetMapping(command: "manual", label: "Manual"), + OpenHABWidgetMapping(command: "auto", label: "Auto"), + OpenHABWidgetMapping(command: "schedule", label: "Schedule") + ], + selectedState: "auto", + icon: "temperature" + ) return NavigationStack { SegmentSelectionView(widget: widget, selectedIndex: $selectedIndex) } diff --git a/openHABWatch/Views/Rows/SelectionRow.swift b/openHABWatch/Views/Rows/SelectionRow.swift index d48c5b3a8..f1db27682 100644 --- a/openHABWatch/Views/Rows/SelectionRow.swift +++ b/openHABWatch/Views/Rows/SelectionRow.swift @@ -151,14 +151,22 @@ struct SelectionRow: View { } #Preview { - let widget = UserData(preview: true).widgets[4] + let widget = PreviewWidgetFactory.selection( + label: "Mode", + options: [("auto", "Auto"), ("manual", "Manual"), ("away", "Away")], + selectedIndex: 1 + ) SelectionRow(widget: widget) .environmentObject(AppSettings()) } #Preview("Selection List") { @Previewable @State var selectedIndex: Int? = 0 - let widget = UserData(preview: true).widgets[4] + let widget = PreviewWidgetFactory.selection( + label: "Mode", + options: [("auto", "Auto"), ("manual", "Manual"), ("away", "Away")], + selectedIndex: 0 + ) NavigationStack { SelectionListView(widget: widget, selectedIndex: $selectedIndex) } diff --git a/openHABWatch/Views/Rows/SetpointRow.swift b/openHABWatch/Views/Rows/SetpointRow.swift index 0b3b42983..060b1f239 100644 --- a/openHABWatch/Views/Rows/SetpointRow.swift +++ b/openHABWatch/Views/Rows/SetpointRow.swift @@ -109,7 +109,14 @@ struct SetpointRow: View { } #Preview { - let widget = UserData(preview: true).widgets[3] + let widget = PreviewWidgetFactory.setpoint( + label: "Temperature", + value: 21, + minValue: 16, + maxValue: 28, + step: 0.5, + unit: "°C" + ) SetpointRow(widget: widget) .environmentObject(AppSettings()) } diff --git a/openHABWatch/Views/Rows/SliderRow.swift b/openHABWatch/Views/Rows/SliderRow.swift index 2e06c5e24..d2324f269 100644 --- a/openHABWatch/Views/Rows/SliderRow.swift +++ b/openHABWatch/Views/Rows/SliderRow.swift @@ -119,57 +119,11 @@ struct SliderRow: View { } } -// MARK: - Preview Helpers - -#if DEBUG -private extension SliderRow { - static func createPreviewWidget(label: String, - value: Double? = nil, - minValue: Double = 0.0, - maxValue: Double = 100.0, - step: Double = 1.0, - icon: String = "slider", - switchSupport: Bool = false) -> OpenHABWidget { - let widget = OpenHABWidget() - widget.widgetId = UUID().uuidString - widget.type = .slider - widget.icon = icon - widget.minValue = minValue - widget.maxValue = maxValue - widget.step = step - widget.switchSupport = switchSupport - - if let value { - widget.label = "\(label) [\(Int(value))]" - } else { - widget.label = label - } - - let item = OpenHABItem( - name: "Preview_\(label.replacingOccurrences(of: " ", with: "_"))", - type: "Dimmer", - state: value.map { String($0) } ?? "NULL", - link: "", - label: label, - groupType: nil, - stateDescription: nil, - commandDescription: nil, - members: [], - category: nil, - options: nil - ) - widget.item = item - - return widget - } -} -#endif - // MARK: - Previews #Preview("Default Range") { SliderRow( - widget: SliderRow.createPreviewWidget( + widget: PreviewWidgetFactory.slider( label: "Brightness", value: 75 ), @@ -180,7 +134,7 @@ private extension SliderRow { #Preview("Custom Range (minValue)") { SliderRow( - widget: SliderRow.createPreviewWidget( + widget: PreviewWidgetFactory.slider( label: "Temperature", value: 16, minValue: 16, @@ -194,7 +148,7 @@ private extension SliderRow { #Preview("With Switch Support") { SliderRow( - widget: SliderRow.createPreviewWidget( + widget: PreviewWidgetFactory.slider( label: "Dimmer", value: 50, switchSupport: true @@ -207,14 +161,14 @@ private extension SliderRow { #Preview("All Scenarios") { List { SliderRow( - widget: SliderRow.createPreviewWidget( + widget: PreviewWidgetFactory.slider( label: "Brightness", value: 75 ), fallbackSymbol: .sliderHorizontal3 ) SliderRow( - widget: SliderRow.createPreviewWidget( + widget: PreviewWidgetFactory.slider( label: "Temperature", value: 21, minValue: 16, @@ -224,7 +178,7 @@ private extension SliderRow { fallbackSymbol: .thermometerMedium ) SliderRow( - widget: SliderRow.createPreviewWidget( + widget: PreviewWidgetFactory.slider( label: "Dimmer", value: 50, switchSupport: true diff --git a/openHABWatch/Views/Rows/SwitchRow.swift b/openHABWatch/Views/Rows/SwitchRow.swift index cc4ad4ed3..3126bbbfc 100644 --- a/openHABWatch/Views/Rows/SwitchRow.swift +++ b/openHABWatch/Views/Rows/SwitchRow.swift @@ -66,13 +66,13 @@ struct SwitchRow: View { } #Preview { - let widget = UserData(preview: true).widgets[2] + let widget = PreviewWidgetFactory.switchWidget(label: "Outdoor Light", state: "ON") SwitchRow(widget: widget) .environmentObject(AppSettings()) } #Preview { - let widget = UserData(preview: true).widgets[2] + let widget = PreviewWidgetFactory.switchWidget(label: "Outdoor Light", state: "OFF") let mockSettings = { let obj = AppSettings() obj.openHABRootUrl = PreviewConstants.remoteURLString diff --git a/openHABWatch/Views/Rows/TextRow.swift b/openHABWatch/Views/Rows/TextRow.swift index 116fc5b23..cdb7a1966 100644 --- a/openHABWatch/Views/Rows/TextRow.swift +++ b/openHABWatch/Views/Rows/TextRow.swift @@ -46,7 +46,7 @@ struct TextRow: View { } #Preview { - let widget = UserData(preview: true).widgets[3] + let widget = PreviewWidgetFactory.text(label: "Energy Usage", valueText: "450 W") TextRow(widget: widget) .environmentObject(AppSettings()) } diff --git a/openHABWatch/Views/SitemapPageView.swift b/openHABWatch/Views/SitemapPageView.swift index 0f6f3a7fd..c8c99f7c6 100644 --- a/openHABWatch/Views/SitemapPageView.swift +++ b/openHABWatch/Views/SitemapPageView.swift @@ -21,65 +21,11 @@ struct WidgetRowView: View { var body: some View { if let linkedPage = widget.linkedPage { NavigationLink(value: linkedPage) { - rowWidget(widget: widget) + WidgetRowFactory.make(widget: widget, settings: settings) } .buttonStyle(.plain) } else { - rowWidget(widget: widget) - } - } - - @ViewBuilder func rowWidget(widget: OpenHABWidget) -> some View { - switch widget.type { - case .switchWidget: - if !widget.mappings.isEmpty { - SegmentRow(widget: widget) - } else if widget.item?.isOfTypeOrGroupType(.switchItem) ?? false { - SwitchRow(widget: widget) - } else if widget.item?.isOfTypeOrGroupType(.rollershutter) ?? false { - RollershutterRow(widget: widget) - } else if !widget.mappingsOrItemOptions.isEmpty { - SegmentRow(widget: widget) - } else { - SwitchRow(widget: widget) - } - case .slider: - SliderRow(widget: widget) - case .setpoint: - SetpointRow(widget: widget) - case .frame: - FrameRow(widget: widget) - case .text: - TextRow(widget: widget) - case .image: - if widget.item != nil { - ImageRawRow(widget: widget) - } else { - EquatableView(content: ImageRow(url: URL(string: widget.url), refresh: widget.refresh)) - } - case .chart: - let url = Endpoint.chart( - rootUrl: settings.openHABRootUrl, - period: widget.period, - type: widget.item?.type ?? .none, - service: widget.service, - name: widget.item?.name, - legend: widget.legend, - theme: .dark, - forceAsItem: widget.forceAsItem - ).url - EquatableView(content: ImageRow(url: url, refresh: widget.refresh)) - case .mapview: - MapViewRow(widget: widget) - case .colorpicker: - ColorPickerRow(widget: widget) - case .selection: - SelectionRow(widget: widget) - case .video, .webview, .input, .colortemperaturepicker, .buttongrid: - // Not yet implemented for watchOS - GenericRow(widget: widget) - case .group, .defaultWidget, .button, .unknown: - GenericRow(widget: widget) + WidgetRowFactory.make(widget: widget, settings: settings) } } } From ce492e4903c49f0575d7ea947cc11feaaa992323 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Mon, 9 Feb 2026 16:45:00 +0100 Subject: [PATCH 433/476] Add PreviewNavigationContainer and use it across all row previews Introduce a reusable PreviewNavigationContainer that wraps content in NavigationStack with AppSettings, replacing repetitive boilerplate in all watchOS row preview blocks. Signed-off-by: Tim Mueller-Seydlitz --- openHAB.xcodeproj/project.pbxproj | 4 + .../PreviewNavigationContainer.swift | 30 ++ openHABWatch/Views/Rows/ColorPickerRow.swift | 5 +- openHABWatch/Views/Rows/FrameRow.swift | 5 +- openHABWatch/Views/Rows/GenericRow.swift | 5 +- openHABWatch/Views/Rows/ImageRawRow.swift | 5 +- openHABWatch/Views/Rows/MapViewRow.swift | 5 +- .../Views/Rows/RollershutterRow.swift | 5 +- openHABWatch/Views/Rows/SegmentRow.swift | 262 +++++++++--------- .../Views/Rows/SegmentSelectionView.swift | 3 +- openHABWatch/Views/Rows/SelectionRow.swift | 8 +- openHABWatch/Views/Rows/SetpointRow.swift | 5 +- openHABWatch/Views/Rows/SliderRow.swift | 95 ++++--- openHABWatch/Views/Rows/SwitchRow.swift | 11 +- openHABWatch/Views/Rows/TextRow.swift | 5 +- 15 files changed, 254 insertions(+), 199 deletions(-) create mode 100644 openHABWatch/Preview Content/PreviewNavigationContainer.swift diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index b38e4c9b6..80f5fbe44 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -141,6 +141,7 @@ DA88F8C622EC377200B408E5 /* ReleaseNotes.md in Resources */ = {isa = PBXBuildFile; fileRef = DA88F8C522EC377100B408E5 /* ReleaseNotes.md */; }; DA8B14B62F3A0DFF007753FD /* WidgetRowFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA8B14B52F3A0DFF007753FD /* WidgetRowFactory.swift */; }; DA8B14B82F3A120E007753FD /* PreviewWidgetFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA8B14B72F3A11F0007753FD /* PreviewWidgetFactory.swift */; }; + DA8B14BA2F3A373A007753FD /* PreviewNavigationContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA8B14B92F3A373A007753FD /* PreviewNavigationContainer.swift */; }; DA94AEB42EC896BE003BB3C8 /* SimpleMJPEGPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA94AEB32EC896BE003BB3C8 /* SimpleMJPEGPlayer.swift */; }; DA95F3332E0F2B1700FE4474 /* OpenHABRootViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA95F3322E0F2B1700FE4474 /* OpenHABRootViewController.swift */; }; DA95F3352E0F2C1600FE4474 /* OpenHABSitemapViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA95F3342E0F2C1600FE4474 /* OpenHABSitemapViewController.swift */; }; @@ -499,6 +500,7 @@ DA88F8C522EC377100B408E5 /* ReleaseNotes.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = ReleaseNotes.md; sourceTree = ""; }; DA8B14B52F3A0DFF007753FD /* WidgetRowFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetRowFactory.swift; sourceTree = ""; }; DA8B14B72F3A11F0007753FD /* PreviewWidgetFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewWidgetFactory.swift; sourceTree = ""; }; + DA8B14B92F3A373A007753FD /* PreviewNavigationContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewNavigationContainer.swift; sourceTree = ""; }; DA94AEB32EC896BE003BB3C8 /* SimpleMJPEGPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleMJPEGPlayer.swift; sourceTree = ""; }; DA94AF432EC8DE41003BB3C8 /* VideoStreamManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoStreamManager.swift; sourceTree = ""; }; DA95F3322E0F2B1700FE4474 /* OpenHABRootViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenHABRootViewController.swift; sourceTree = ""; }; @@ -1006,6 +1008,7 @@ isa = PBXGroup; children = ( DAD085662AE4782A001D36BE /* Preview Assets.xcassets */, + DA8B14B92F3A373A007753FD /* PreviewNavigationContainer.swift */, DA8B14B72F3A11F0007753FD /* PreviewWidgetFactory.swift */, ); path = "Preview Content"; @@ -1710,6 +1713,7 @@ DA07752F2346705F0086C685 /* NotificationView.swift in Sources */, DA0775312346705F0086C685 /* ComplicationController.swift in Sources */, DAF457A223DB6C640018B495 /* RollershutterRow.swift in Sources */, + DA8B14BA2F3A373A007753FD /* PreviewNavigationContainer.swift in Sources */, DAF457A923DBA4990018B495 /* FrameRow.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/openHABWatch/Preview Content/PreviewNavigationContainer.swift b/openHABWatch/Preview Content/PreviewNavigationContainer.swift new file mode 100644 index 000000000..4afa7b9f9 --- /dev/null +++ b/openHABWatch/Preview Content/PreviewNavigationContainer.swift @@ -0,0 +1,30 @@ +// Copyright (c) 2010-2026 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 + +#if DEBUG +import SwiftUI + +struct PreviewNavigationContainer: View { + @State private var settings = AppSettings() + private let content: Content + + init(@ViewBuilder content: () -> Content) { + self.content = content() + } + + var body: some View { + NavigationStack { + content + } + .environmentObject(settings) + } +} +#endif diff --git a/openHABWatch/Views/Rows/ColorPickerRow.swift b/openHABWatch/Views/Rows/ColorPickerRow.swift index 6ee09fc97..63361e7a8 100644 --- a/openHABWatch/Views/Rows/ColorPickerRow.swift +++ b/openHABWatch/Views/Rows/ColorPickerRow.swift @@ -90,6 +90,7 @@ struct ColorPickerRow: View { state: "120,100,100", icon: "colorwheel" ) - ColorPickerRow(widget: widget) - .environmentObject(AppSettings()) + PreviewNavigationContainer { + ColorPickerRow(widget: widget) + } } diff --git a/openHABWatch/Views/Rows/FrameRow.swift b/openHABWatch/Views/Rows/FrameRow.swift index 43ecbfa22..54477b551 100644 --- a/openHABWatch/Views/Rows/FrameRow.swift +++ b/openHABWatch/Views/Rows/FrameRow.swift @@ -40,6 +40,7 @@ struct FrameRow: View { #Preview { let widget = PreviewWidgetFactory.frame(label: "Environment") - FrameRow(widget: widget) - .environmentObject(AppSettings()) + PreviewNavigationContainer { + FrameRow(widget: widget) + } } diff --git a/openHABWatch/Views/Rows/GenericRow.swift b/openHABWatch/Views/Rows/GenericRow.swift index 360a6ea72..89ef1cc90 100644 --- a/openHABWatch/Views/Rows/GenericRow.swift +++ b/openHABWatch/Views/Rows/GenericRow.swift @@ -44,6 +44,7 @@ struct GenericRow: View { #Preview { let widget = PreviewWidgetFactory.generic(label: "Unsupported Widget", valueText: "N/A") - GenericRow(widget: widget) - .environmentObject(AppSettings()) + PreviewNavigationContainer { + GenericRow(widget: widget) + } } diff --git a/openHABWatch/Views/Rows/ImageRawRow.swift b/openHABWatch/Views/Rows/ImageRawRow.swift index 13ca791d5..d8e1988d3 100644 --- a/openHABWatch/Views/Rows/ImageRawRow.swift +++ b/openHABWatch/Views/Rows/ImageRawRow.swift @@ -45,6 +45,7 @@ struct ImageRawRow: View { #Preview { let base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGMAAQAABQABDQottAAAAABJRU5ErkJggg==" let widget = PreviewWidgetFactory.imageRaw(label: "Camera", base64: base64) - ImageRawRow(widget: widget) - .environmentObject(AppSettings()) + PreviewNavigationContainer { + ImageRawRow(widget: widget) + } } diff --git a/openHABWatch/Views/Rows/MapViewRow.swift b/openHABWatch/Views/Rows/MapViewRow.swift index 4546eb91c..ae9172be5 100644 --- a/openHABWatch/Views/Rows/MapViewRow.swift +++ b/openHABWatch/Views/Rows/MapViewRow.swift @@ -41,6 +41,7 @@ struct MapViewRow: View { #Preview { let widget = PreviewWidgetFactory.mapview(label: "Location", state: "51.5074,0.1278") - MapViewRow(widget: widget) - .environmentObject(AppSettings()) + PreviewNavigationContainer { + MapViewRow(widget: widget) + } } diff --git a/openHABWatch/Views/Rows/RollershutterRow.swift b/openHABWatch/Views/Rows/RollershutterRow.swift index fc4562f68..00d0f46e5 100644 --- a/openHABWatch/Views/Rows/RollershutterRow.swift +++ b/openHABWatch/Views/Rows/RollershutterRow.swift @@ -61,6 +61,7 @@ struct RollershutterRow: View { #Preview { let widget = PreviewWidgetFactory.rollershutter(label: "Blinds", state: "STOP") - RollershutterRow(widget: widget) - .environmentObject(AppSettings()) + PreviewNavigationContainer { + RollershutterRow(widget: widget) + } } diff --git a/openHABWatch/Views/Rows/SegmentRow.swift b/openHABWatch/Views/Rows/SegmentRow.swift index b5c5e4210..add01f0ed 100644 --- a/openHABWatch/Views/Rows/SegmentRow.swift +++ b/openHABWatch/Views/Rows/SegmentRow.swift @@ -213,164 +213,170 @@ struct SegmentRow: View { } #Preview("Short Labels") { - VStack(spacing: 8) { - SegmentRow( - widget: PreviewWidgetFactory.segmented( - label: "Light Switch", - mappings: [ - OpenHABWidgetMapping(command: "ON", label: "ON"), - OpenHABWidgetMapping(command: "OFF", label: "OFF") - ], - selectedState: "ON", - icon: "switch" + PreviewNavigationContainer { + VStack(spacing: 8) { + SegmentRow( + widget: PreviewWidgetFactory.segmented( + label: "Light Switch", + mappings: [ + OpenHABWidgetMapping(command: "ON", label: "ON"), + OpenHABWidgetMapping(command: "OFF", label: "OFF") + ], + selectedState: "ON", + icon: "switch" + ) ) - ) + } } - .environmentObject(AppSettings()) } #Preview("Long Labels") { - VStack(spacing: 8) { - SegmentRow( - widget: PreviewWidgetFactory.segmented( - label: "Temperature Control", - mappings: [ - OpenHABWidgetMapping(command: "manual", label: "Manual"), - OpenHABWidgetMapping(command: "calendar", label: "Calendar"), - OpenHABWidgetMapping(command: "automatic", label: "Automatic") - ], - selectedState: "automatic", - icon: "temperature" + PreviewNavigationContainer { + VStack(spacing: 8) { + SegmentRow( + widget: PreviewWidgetFactory.segmented( + label: "Temperature Control", + mappings: [ + OpenHABWidgetMapping(command: "manual", label: "Manual"), + OpenHABWidgetMapping(command: "calendar", label: "Calendar"), + OpenHABWidgetMapping(command: "automatic", label: "Automatic") + ], + selectedState: "automatic", + icon: "temperature" + ) ) - ) + } } - .environmentObject(AppSettings()) } #Preview("Multiple Segments (4)") { - VStack(spacing: 8) { - SegmentRow( - widget: PreviewWidgetFactory.segmented( - label: "Fan Speed", - mappings: [ - OpenHABWidgetMapping(command: "0", label: "Off"), - OpenHABWidgetMapping(command: "1", label: "Low"), - OpenHABWidgetMapping(command: "2", label: "Med"), - OpenHABWidgetMapping(command: "3", label: "High") - ], - selectedState: "3", - icon: "fan" + PreviewNavigationContainer { + VStack(spacing: 8) { + SegmentRow( + widget: PreviewWidgetFactory.segmented( + label: "Fan Speed", + mappings: [ + OpenHABWidgetMapping(command: "0", label: "Off"), + OpenHABWidgetMapping(command: "1", label: "Low"), + OpenHABWidgetMapping(command: "2", label: "Med"), + OpenHABWidgetMapping(command: "3", label: "High") + ], + selectedState: "3", + icon: "fan" + ) ) - ) + } } - .environmentObject(AppSettings()) } #Preview("PressRelease") { - VStack(spacing: 8) { - SegmentRow( - widget: PreviewWidgetFactory.segmented( - label: "All Shutters", - mappings: [ - OpenHABWidgetMapping(command: "DOWN", label: "DOWN", releaseCommand: "OFF"), - OpenHABWidgetMapping(command: "UP", label: "UP", releaseCommand: "OFF") - ], - selectedState: "DOWN", - icon: "rollershutter" + PreviewNavigationContainer { + VStack(spacing: 8) { + SegmentRow( + widget: PreviewWidgetFactory.segmented( + label: "All Shutters", + mappings: [ + OpenHABWidgetMapping(command: "DOWN", label: "DOWN", releaseCommand: "OFF"), + OpenHABWidgetMapping(command: "UP", label: "UP", releaseCommand: "OFF") + ], + selectedState: "DOWN", + icon: "rollershutter" + ) ) - ) + } } - .environmentObject(AppSettings()) } #Preview("Single Mapping") { - VStack(spacing: 8) { - SegmentRow( - widget: PreviewWidgetFactory.segmented( - label: "Scene", - mappings: [ - OpenHABWidgetMapping(command: "RUN", label: "Run") - ], - selectedState: "RUN", - icon: "scene" + PreviewNavigationContainer { + VStack(spacing: 8) { + SegmentRow( + widget: PreviewWidgetFactory.segmented( + label: "Scene", + mappings: [ + OpenHABWidgetMapping(command: "RUN", label: "Run") + ], + selectedState: "RUN", + icon: "scene" + ) ) - ) + } } - .environmentObject(AppSettings()) } #Preview("All Scenarios") { - VStack(spacing: 8) { - SegmentRow( - widget: PreviewWidgetFactory.segmented( - label: "Light", - mappings: [ - OpenHABWidgetMapping(command: "ON", label: "ON"), - OpenHABWidgetMapping(command: "OFF", label: "OFF") - ], - selectedState: "ON", - icon: "light" + PreviewNavigationContainer { + VStack(spacing: 8) { + SegmentRow( + widget: PreviewWidgetFactory.segmented( + label: "Light", + mappings: [ + OpenHABWidgetMapping(command: "ON", label: "ON"), + OpenHABWidgetMapping(command: "OFF", label: "OFF") + ], + selectedState: "ON", + icon: "light" + ) ) - ) - SegmentRow( - widget: PreviewWidgetFactory.segmented( - label: "Climate Mode", - mappings: [ - OpenHABWidgetMapping(command: "m", label: "Manual"), - OpenHABWidgetMapping(command: "a", label: "Auto"), - OpenHABWidgetMapping(command: "s", label: "Schedule") - ], - selectedState: "a", - icon: "temperature" + SegmentRow( + widget: PreviewWidgetFactory.segmented( + label: "Climate Mode", + mappings: [ + OpenHABWidgetMapping(command: "m", label: "Manual"), + OpenHABWidgetMapping(command: "a", label: "Auto"), + OpenHABWidgetMapping(command: "s", label: "Schedule") + ], + selectedState: "a", + icon: "temperature" + ) ) - ) - SegmentRow( - widget: PreviewWidgetFactory.segmented( - label: "Fan Speed", - mappings: [ - OpenHABWidgetMapping(command: "0", label: "Off"), - OpenHABWidgetMapping(command: "1", label: "Low"), - OpenHABWidgetMapping(command: "2", label: "Med"), - OpenHABWidgetMapping(command: "3", label: "High") - ], - selectedState: "2", - icon: "fan" + SegmentRow( + widget: PreviewWidgetFactory.segmented( + label: "Fan Speed", + mappings: [ + OpenHABWidgetMapping(command: "0", label: "Off"), + OpenHABWidgetMapping(command: "1", label: "Low"), + OpenHABWidgetMapping(command: "2", label: "Med"), + OpenHABWidgetMapping(command: "3", label: "High") + ], + selectedState: "2", + icon: "fan" + ) ) - ) - SegmentRow( - widget: PreviewWidgetFactory.segmented( - label: "Charts Period", - mappings: [ - OpenHABWidgetMapping(command: "D", label: "Day"), - OpenHABWidgetMapping(command: "W", label: "Week"), - OpenHABWidgetMapping(command: "M", label: "M"), - OpenHABWidgetMapping(command: "4h", label: "4h") - ], - selectedState: "D", - icon: "chart" + SegmentRow( + widget: PreviewWidgetFactory.segmented( + label: "Charts Period", + mappings: [ + OpenHABWidgetMapping(command: "D", label: "Day"), + OpenHABWidgetMapping(command: "W", label: "Week"), + OpenHABWidgetMapping(command: "M", label: "M"), + OpenHABWidgetMapping(command: "4h", label: "4h") + ], + selectedState: "D", + icon: "chart" + ) ) - ) - SegmentRow( - widget: PreviewWidgetFactory.segmented( - label: "All Shutters", - mappings: [ - OpenHABWidgetMapping(command: "DOWN", label: "DOWN", releaseCommand: "OFF"), - OpenHABWidgetMapping(command: "UP", label: "UP", releaseCommand: "OFF") - ], - selectedState: "DOWN", - icon: "rollershutter" + SegmentRow( + widget: PreviewWidgetFactory.segmented( + label: "All Shutters", + mappings: [ + OpenHABWidgetMapping(command: "DOWN", label: "DOWN", releaseCommand: "OFF"), + OpenHABWidgetMapping(command: "UP", label: "UP", releaseCommand: "OFF") + ], + selectedState: "DOWN", + icon: "rollershutter" + ) ) - ) - SegmentRow( - widget: PreviewWidgetFactory.segmented( - label: "Scene", - mappings: [ - OpenHABWidgetMapping(command: "RUN", label: "RUN") - ], - selectedState: "RUN", - icon: "scene" + SegmentRow( + widget: PreviewWidgetFactory.segmented( + label: "Scene", + mappings: [ + OpenHABWidgetMapping(command: "RUN", label: "RUN") + ], + selectedState: "RUN", + icon: "scene" + ) ) - ) + } } - .environmentObject(AppSettings()) } diff --git a/openHABWatch/Views/Rows/SegmentSelectionView.swift b/openHABWatch/Views/Rows/SegmentSelectionView.swift index bbe5ae372..fedef149f 100644 --- a/openHABWatch/Views/Rows/SegmentSelectionView.swift +++ b/openHABWatch/Views/Rows/SegmentSelectionView.swift @@ -161,8 +161,7 @@ struct SegmentSelectionView: View { selectedState: "auto", icon: "temperature" ) - return NavigationStack { + return PreviewNavigationContainer { SegmentSelectionView(widget: widget, selectedIndex: $selectedIndex) } - .environmentObject(AppSettings()) } diff --git a/openHABWatch/Views/Rows/SelectionRow.swift b/openHABWatch/Views/Rows/SelectionRow.swift index f1db27682..9d9b22e02 100644 --- a/openHABWatch/Views/Rows/SelectionRow.swift +++ b/openHABWatch/Views/Rows/SelectionRow.swift @@ -156,8 +156,9 @@ struct SelectionRow: View { options: [("auto", "Auto"), ("manual", "Manual"), ("away", "Away")], selectedIndex: 1 ) - SelectionRow(widget: widget) - .environmentObject(AppSettings()) + PreviewNavigationContainer { + SelectionRow(widget: widget) + } } #Preview("Selection List") { @@ -167,8 +168,7 @@ struct SelectionRow: View { options: [("auto", "Auto"), ("manual", "Manual"), ("away", "Away")], selectedIndex: 0 ) - NavigationStack { + PreviewNavigationContainer { SelectionListView(widget: widget, selectedIndex: $selectedIndex) } - .environmentObject(AppSettings()) } diff --git a/openHABWatch/Views/Rows/SetpointRow.swift b/openHABWatch/Views/Rows/SetpointRow.swift index 060b1f239..24c7750cd 100644 --- a/openHABWatch/Views/Rows/SetpointRow.swift +++ b/openHABWatch/Views/Rows/SetpointRow.swift @@ -117,6 +117,7 @@ struct SetpointRow: View { step: 0.5, unit: "°C" ) - SetpointRow(widget: widget) - .environmentObject(AppSettings()) + PreviewNavigationContainer { + SetpointRow(widget: widget) + } } diff --git a/openHABWatch/Views/Rows/SliderRow.swift b/openHABWatch/Views/Rows/SliderRow.swift index d2324f269..48921df3c 100644 --- a/openHABWatch/Views/Rows/SliderRow.swift +++ b/openHABWatch/Views/Rows/SliderRow.swift @@ -122,44 +122,7 @@ struct SliderRow: View { // MARK: - Previews #Preview("Default Range") { - SliderRow( - widget: PreviewWidgetFactory.slider( - label: "Brightness", - value: 75 - ), - fallbackSymbol: .sliderHorizontal3 - ) - .environmentObject(AppSettings()) -} - -#Preview("Custom Range (minValue)") { - SliderRow( - widget: PreviewWidgetFactory.slider( - label: "Temperature", - value: 16, - minValue: 16, - maxValue: 28, - step: 0.5 - ), - fallbackSymbol: .thermometerMedium - ) - .environmentObject(AppSettings()) -} - -#Preview("With Switch Support") { - SliderRow( - widget: PreviewWidgetFactory.slider( - label: "Dimmer", - value: 50, - switchSupport: true - ), - fallbackSymbol: .lightbulbFill - ) - .environmentObject(AppSettings()) -} - -#Preview("All Scenarios") { - List { + PreviewNavigationContainer { SliderRow( widget: PreviewWidgetFactory.slider( label: "Brightness", @@ -167,16 +130,26 @@ struct SliderRow: View { ), fallbackSymbol: .sliderHorizontal3 ) + } +} + +#Preview("Custom Range (minValue)") { + PreviewNavigationContainer { SliderRow( widget: PreviewWidgetFactory.slider( label: "Temperature", - value: 21, + value: 16, minValue: 16, maxValue: 28, step: 0.5 ), fallbackSymbol: .thermometerMedium ) + } +} + +#Preview("With Switch Support") { + PreviewNavigationContainer { SliderRow( widget: PreviewWidgetFactory.slider( label: "Dimmer", @@ -186,13 +159,45 @@ struct SliderRow: View { fallbackSymbol: .lightbulbFill ) } - .environmentObject(AppSettings()) +} + +#Preview("All Scenarios") { + PreviewNavigationContainer { + List { + SliderRow( + widget: PreviewWidgetFactory.slider( + label: "Brightness", + value: 75 + ), + fallbackSymbol: .sliderHorizontal3 + ) + SliderRow( + widget: PreviewWidgetFactory.slider( + label: "Temperature", + value: 21, + minValue: 16, + maxValue: 28, + step: 0.5 + ), + fallbackSymbol: .thermometerMedium + ) + SliderRow( + widget: PreviewWidgetFactory.slider( + label: "Dimmer", + value: 50, + switchSupport: true + ), + fallbackSymbol: .lightbulbFill + ) + } + } } #Preview("From UserData") { - SliderRow( - widget: UserData(preview: true).widgets[3], - fallbackSymbol: .sliderHorizontal3 - ) - .environmentObject(AppSettings()) + PreviewNavigationContainer { + SliderRow( + widget: UserData(preview: true).widgets[3], + fallbackSymbol: .sliderHorizontal3 + ) + } } diff --git a/openHABWatch/Views/Rows/SwitchRow.swift b/openHABWatch/Views/Rows/SwitchRow.swift index 3126bbbfc..46209c205 100644 --- a/openHABWatch/Views/Rows/SwitchRow.swift +++ b/openHABWatch/Views/Rows/SwitchRow.swift @@ -67,8 +67,9 @@ struct SwitchRow: View { #Preview { let widget = PreviewWidgetFactory.switchWidget(label: "Outdoor Light", state: "ON") - SwitchRow(widget: widget) - .environmentObject(AppSettings()) + PreviewNavigationContainer { + SwitchRow(widget: widget) + } } #Preview { @@ -78,6 +79,8 @@ struct SwitchRow: View { obj.openHABRootUrl = PreviewConstants.remoteURLString return obj }() - SwitchRow(widget: widget) - .environmentObject(mockSettings) + NavigationStack { + SwitchRow(widget: widget) + } + .environmentObject(mockSettings) } diff --git a/openHABWatch/Views/Rows/TextRow.swift b/openHABWatch/Views/Rows/TextRow.swift index cdb7a1966..98137b701 100644 --- a/openHABWatch/Views/Rows/TextRow.swift +++ b/openHABWatch/Views/Rows/TextRow.swift @@ -47,6 +47,7 @@ struct TextRow: View { #Preview { let widget = PreviewWidgetFactory.text(label: "Energy Usage", valueText: "450 W") - TextRow(widget: widget) - .environmentObject(AppSettings()) + PreviewNavigationContainer { + TextRow(widget: widget) + } } From 50d3d76ff0165984e39a3e29033e3fecae5289e7 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Mon, 9 Feb 2026 17:08:26 +0100 Subject: [PATCH 434/476] Centralize watchOS typography with WatchTypography and WatchLabelText Replace hardcoded font sizes, line limits, and scale factors across all watchOS row views with shared WatchTypography constants and the WatchLabelText convenience view for consistent styling. Signed-off-by: Tim Mueller-Seydlitz --- openHAB.xcodeproj/project.pbxproj | 4 ++ openHABWatch/Views/Rows/ColorPickerRow.swift | 2 +- openHABWatch/Views/Rows/FrameRow.swift | 6 ++- openHABWatch/Views/Rows/GenericRow.swift | 2 +- .../Views/Rows/RollershutterRow.swift | 2 +- openHABWatch/Views/Rows/SegmentRow.swift | 19 ++++--- .../Views/Rows/SegmentSelectionView.swift | 4 ++ openHABWatch/Views/Rows/SelectionRow.swift | 11 +++- openHABWatch/Views/Rows/SetpointRow.swift | 4 +- openHABWatch/Views/Rows/SliderRow.swift | 14 +++-- openHABWatch/Views/Rows/SwitchRow.swift | 2 +- openHABWatch/Views/Rows/TextRow.swift | 2 +- .../Views/Utils/DetailTextLabelView.swift | 6 ++- .../Views/Utils/WatchTypography.swift | 52 +++++++++++++++++++ 14 files changed, 106 insertions(+), 24 deletions(-) create mode 100644 openHABWatch/Views/Utils/WatchTypography.swift diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index 80f5fbe44..5c93f1caf 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -142,6 +142,7 @@ DA8B14B62F3A0DFF007753FD /* WidgetRowFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA8B14B52F3A0DFF007753FD /* WidgetRowFactory.swift */; }; DA8B14B82F3A120E007753FD /* PreviewWidgetFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA8B14B72F3A11F0007753FD /* PreviewWidgetFactory.swift */; }; DA8B14BA2F3A373A007753FD /* PreviewNavigationContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA8B14B92F3A373A007753FD /* PreviewNavigationContainer.swift */; }; + DA8B14BC2F3A3CB5007753FD /* WatchTypography.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA8B14BB2F3A3CB5007753FD /* WatchTypography.swift */; }; DA94AEB42EC896BE003BB3C8 /* SimpleMJPEGPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA94AEB32EC896BE003BB3C8 /* SimpleMJPEGPlayer.swift */; }; DA95F3332E0F2B1700FE4474 /* OpenHABRootViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA95F3322E0F2B1700FE4474 /* OpenHABRootViewController.swift */; }; DA95F3352E0F2C1600FE4474 /* OpenHABSitemapViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA95F3342E0F2C1600FE4474 /* OpenHABSitemapViewController.swift */; }; @@ -501,6 +502,7 @@ DA8B14B52F3A0DFF007753FD /* WidgetRowFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetRowFactory.swift; sourceTree = ""; }; DA8B14B72F3A11F0007753FD /* PreviewWidgetFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewWidgetFactory.swift; sourceTree = ""; }; DA8B14B92F3A373A007753FD /* PreviewNavigationContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewNavigationContainer.swift; sourceTree = ""; }; + DA8B14BB2F3A3CB5007753FD /* WatchTypography.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchTypography.swift; sourceTree = ""; }; DA94AEB32EC896BE003BB3C8 /* SimpleMJPEGPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleMJPEGPlayer.swift; sourceTree = ""; }; DA94AF432EC8DE41003BB3C8 /* VideoStreamManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoStreamManager.swift; sourceTree = ""; }; DA95F3322E0F2B1700FE4474 /* OpenHABRootViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenHABRootViewController.swift; sourceTree = ""; }; @@ -1063,6 +1065,7 @@ DA2E0B0D23DCC152009B0A99 /* MapView.swift */, DA0749DF23E0BF510057FA83 /* ColorSelection.swift */, DA32D1B32C8C98C40018D974 /* IconWithAction.swift */, + DA8B14BB2F3A3CB5007753FD /* WatchTypography.swift */, ); path = Utils; sourceTree = ""; @@ -1702,6 +1705,7 @@ DAF4581623DC48400018B495 /* GenericRow.swift in Sources */, 10472F7AF99C4940A6144817 /* TextRow.swift in Sources */, DAF457A023DA3E1C0018B495 /* SegmentRow.swift in Sources */, + DA8B14BC2F3A3CB5007753FD /* WatchTypography.swift in Sources */, C4377202F7D642B5A8349008 /* SelectionRow.swift in Sources */, DAF4578923D79AA50018B495 /* DetailTextLabelView.swift in Sources */, DAC9AF4924F966FA006DAE93 /* LazyView.swift in Sources */, diff --git a/openHABWatch/Views/Rows/ColorPickerRow.swift b/openHABWatch/Views/Rows/ColorPickerRow.swift index 63361e7a8..79ae11e91 100644 --- a/openHABWatch/Views/Rows/ColorPickerRow.swift +++ b/openHABWatch/Views/Rows/ColorPickerRow.swift @@ -26,7 +26,7 @@ struct ColorPickerRow: View { VStack(spacing: 0) { HStack { IconView(widget: widget, settings: settings) - TextLabelView(widget: widget, font: .caption, lineLimit: 2) + WatchLabelText(widget: widget) Spacer() } HStack { diff --git a/openHABWatch/Views/Rows/FrameRow.swift b/openHABWatch/Views/Rows/FrameRow.swift index 54477b551..1df77105f 100644 --- a/openHABWatch/Views/Rows/FrameRow.swift +++ b/openHABWatch/Views/Rows/FrameRow.swift @@ -20,8 +20,10 @@ struct FrameRow: View { var body: some View { HStack { Text(viewModel.labelText.uppercased()) - .font(.callout) - .lineLimit(1) + .font(WatchTypography.sectionFont) + .lineLimit(WatchTypography.sectionLineLimit) + .minimumScaleFactor(WatchTypography.sectionMinScale) + .truncationMode(.tail) Spacer() } .onAppear { diff --git a/openHABWatch/Views/Rows/GenericRow.swift b/openHABWatch/Views/Rows/GenericRow.swift index 89ef1cc90..0420ccdf1 100644 --- a/openHABWatch/Views/Rows/GenericRow.swift +++ b/openHABWatch/Views/Rows/GenericRow.swift @@ -22,7 +22,7 @@ struct GenericRow: View { var body: some View { HStack { IconView(widget: widget, settings: settings) - TextLabelView(widget: widget, font: .caption, lineLimit: 2) + WatchLabelText(widget: widget) Spacer() DetailTextLabelView(widget: widget) widget.makeView(settings: settings) diff --git a/openHABWatch/Views/Rows/RollershutterRow.swift b/openHABWatch/Views/Rows/RollershutterRow.swift index 00d0f46e5..6f26d427a 100644 --- a/openHABWatch/Views/Rows/RollershutterRow.swift +++ b/openHABWatch/Views/Rows/RollershutterRow.swift @@ -23,7 +23,7 @@ struct RollershutterRow: View { VStack(spacing: -5) { HStack { IconView(widget: widget, settings: settings) - TextLabelView(widget: widget, font: .caption, lineLimit: 2) + WatchLabelText(widget: widget) Spacer() } HStack { diff --git a/openHABWatch/Views/Rows/SegmentRow.swift b/openHABWatch/Views/Rows/SegmentRow.swift index add01f0ed..4ccb23c67 100644 --- a/openHABWatch/Views/Rows/SegmentRow.swift +++ b/openHABWatch/Views/Rows/SegmentRow.swift @@ -50,7 +50,7 @@ struct SegmentRow: View { if viewModel.mappings.count <= 2 { HStack { IconView(widget: widget, settings: settings) - TextLabelView(widget: widget, font: .caption) + WatchLabelText(widget: widget) Spacer() pressReleaseButtons .layoutPriority(1) @@ -108,7 +108,7 @@ struct SegmentRow: View { private var iconTitleRow: some View { HStack { IconView(widget: widget, settings: settings) - TextLabelView(widget: widget, font: .caption) + WatchLabelText(widget: widget) Spacer() } } @@ -127,7 +127,7 @@ struct SegmentRow: View { HStack { HStack { IconView(widget: widget, settings: settings) - TextLabelView(widget: widget, font: .caption) + WatchLabelText(widget: widget) Spacer() } NavigationLink(destination: LazyView(SegmentSelectionView(widget: widget, selectedIndex: selectedIndexBinding))) { @@ -135,7 +135,10 @@ struct SegmentRow: View { if let currentIndex, currentIndex >= 0, currentIndex < viewModel.mappings.count { Text(viewModel.mappings[currentIndex].label) .foregroundStyle(.secondary) - .lineLimit(1) + .font(WatchTypography.secondaryFont) + .lineLimit(WatchTypography.secondaryLineLimit) + .minimumScaleFactor(WatchTypography.secondaryMinScale) + .truncationMode(.tail) } Image(systemSymbol: .chevronRight) .foregroundStyle(.secondary) @@ -156,7 +159,7 @@ struct SegmentRow: View { let mapping = viewModel.mappings[0] HStack { IconView(widget: widget, settings: settings) - TextLabelView(widget: widget, font: .caption) + WatchLabelText(widget: widget) Spacer() singleButton(for: mapping) .layoutPriority(1) @@ -200,8 +203,10 @@ struct SegmentRow: View { @ViewBuilder private func inlineButton(label: String, isPressed: Bool) -> some View { Text(label) - .font(.caption) - .lineLimit(1) + .font(WatchTypography.controlFont) + .lineLimit(WatchTypography.controlLineLimit) + .minimumScaleFactor(WatchTypography.controlMinScale) + .truncationMode(.tail) .padding(.horizontal, 8) .padding(.vertical, 6) .background( diff --git a/openHABWatch/Views/Rows/SegmentSelectionView.swift b/openHABWatch/Views/Rows/SegmentSelectionView.swift index fedef149f..e163ee98b 100644 --- a/openHABWatch/Views/Rows/SegmentSelectionView.swift +++ b/openHABWatch/Views/Rows/SegmentSelectionView.swift @@ -94,6 +94,10 @@ struct SegmentSelectionView: View { Text(mapping.label) .foregroundStyle(.primary) .multilineTextAlignment(.leading) + .font(WatchTypography.labelFont) + .lineLimit(WatchTypography.labelLineLimit) + .minimumScaleFactor(WatchTypography.labelMinScale) + .truncationMode(.tail) Spacer() if isSelected(index: index), !viewModel.hasPressReleaseMappings { Image(systemSymbol: .checkmark) diff --git a/openHABWatch/Views/Rows/SelectionRow.swift b/openHABWatch/Views/Rows/SelectionRow.swift index 9d9b22e02..e23a25b94 100644 --- a/openHABWatch/Views/Rows/SelectionRow.swift +++ b/openHABWatch/Views/Rows/SelectionRow.swift @@ -42,6 +42,10 @@ struct SelectionListView: View { Text(mapping.label) .foregroundStyle(.primary) .multilineTextAlignment(.leading) + .font(WatchTypography.labelFont) + .lineLimit(WatchTypography.labelLineLimit) + .minimumScaleFactor(WatchTypography.labelMinScale) + .truncationMode(.tail) Spacer() if currentIndex == index { Image(systemSymbol: .checkmark) @@ -119,7 +123,7 @@ struct SelectionRow: View { HStack { HStack { IconView(widget: widget, settings: settings) - TextLabelView(widget: widget, font: .caption) + WatchLabelText(widget: widget) Spacer() } NavigationLink(destination: LazyView(SelectionListView(widget: widget, selectedIndex: selectedIndexBinding))) { @@ -127,7 +131,10 @@ struct SelectionRow: View { if let valueText = selectedValueText { Text(valueText) .foregroundStyle(.secondary) - .lineLimit(1) + .font(WatchTypography.secondaryFont) + .lineLimit(WatchTypography.secondaryLineLimit) + .minimumScaleFactor(WatchTypography.secondaryMinScale) + .truncationMode(.tail) } Image(systemSymbol: .chevronUpChevronDown) .foregroundStyle(.secondary) diff --git a/openHABWatch/Views/Rows/SetpointRow.swift b/openHABWatch/Views/Rows/SetpointRow.swift index 24c7750cd..834e8d754 100644 --- a/openHABWatch/Views/Rows/SetpointRow.swift +++ b/openHABWatch/Views/Rows/SetpointRow.swift @@ -30,7 +30,7 @@ struct SetpointRow: View { VStack(spacing: 5) { HStack { IconView(widget: widget, settings: settings) - TextLabelView(widget: widget, font: .caption, lineLimit: 2) + WatchLabelText(widget: widget) Spacer() } HStack { @@ -47,7 +47,7 @@ struct SetpointRow: View { Spacer() DetailTextLabelView(widget: widget) - .font(.headline) + .font(WatchTypography.emphasisFont) Spacer() diff --git a/openHABWatch/Views/Rows/SliderRow.swift b/openHABWatch/Views/Rows/SliderRow.swift index 48921df3c..47b4d7e86 100644 --- a/openHABWatch/Views/Rows/SliderRow.swift +++ b/openHABWatch/Views/Rows/SliderRow.swift @@ -73,10 +73,13 @@ struct SliderRow: View { HStack { IconView(widget: widget, settings: settings, fallbackSymbol: fallbackSymbol) VStack(alignment: .leading) { - TextLabelView(widget: widget, font: .caption, lineLimit: 2) + WatchLabelText(widget: widget) if pendingValue != nil { Text(currentValueText) - .font(.caption2) + .font(WatchTypography.secondaryFont) + .lineLimit(WatchTypography.secondaryLineLimit) + .minimumScaleFactor(WatchTypography.secondaryMinScale) + .truncationMode(.tail) .foregroundStyle(.secondary) } else { DetailTextLabelView(widget: widget) @@ -89,11 +92,14 @@ struct SliderRow: View { } else { HStack { IconView(widget: widget, settings: settings, fallbackSymbol: fallbackSymbol) - TextLabelView(widget: widget, font: .caption, lineLimit: 2) + WatchLabelText(widget: widget) Spacer() if pendingValue != nil { Text(currentValueText) - .font(.caption2) + .font(WatchTypography.secondaryFont) + .lineLimit(WatchTypography.secondaryLineLimit) + .minimumScaleFactor(WatchTypography.secondaryMinScale) + .truncationMode(.tail) .foregroundStyle(.secondary) } else { DetailTextLabelView(widget: widget) diff --git a/openHABWatch/Views/Rows/SwitchRow.swift b/openHABWatch/Views/Rows/SwitchRow.swift index 46209c205..a28f4cc4f 100644 --- a/openHABWatch/Views/Rows/SwitchRow.swift +++ b/openHABWatch/Views/Rows/SwitchRow.swift @@ -41,7 +41,7 @@ struct SwitchRow: View { HStack { IconView(widget: widget, settings: settings) VStack { - TextLabelView(widget: widget, font: .caption) + WatchLabelText(widget: widget) DetailTextLabelView(widget: widget) } } diff --git a/openHABWatch/Views/Rows/TextRow.swift b/openHABWatch/Views/Rows/TextRow.swift index 98137b701..f110bcaa8 100644 --- a/openHABWatch/Views/Rows/TextRow.swift +++ b/openHABWatch/Views/Rows/TextRow.swift @@ -22,7 +22,7 @@ struct TextRow: View { var body: some View { HStack { IconView(widget: widget, settings: settings) - TextLabelView(widget: widget, font: .caption, lineLimit: 2) + WatchLabelText(widget: widget) Spacer() DetailTextLabelView(widget: widget) if viewModel.hasLinkedPage { diff --git a/openHABWatch/Views/Utils/DetailTextLabelView.swift b/openHABWatch/Views/Utils/DetailTextLabelView.swift index 76b34744b..504523ef1 100644 --- a/openHABWatch/Views/Utils/DetailTextLabelView.swift +++ b/openHABWatch/Views/Utils/DetailTextLabelView.swift @@ -19,8 +19,10 @@ struct DetailTextLabelView: View { var body: some View { if let label = widget.labelValue { Text(label) - .font(.footnote) - .lineLimit(1) + .font(WatchTypography.detailFont) + .lineLimit(WatchTypography.detailLineLimit) + .minimumScaleFactor(WatchTypography.detailMinScale) + .truncationMode(.tail) .foregroundStyle(!widget.valuecolor.isEmpty ? Color(fromString: widget.valuecolor) : .secondary) } } diff --git a/openHABWatch/Views/Utils/WatchTypography.swift b/openHABWatch/Views/Utils/WatchTypography.swift new file mode 100644 index 000000000..7af9ac5c3 --- /dev/null +++ b/openHABWatch/Views/Utils/WatchTypography.swift @@ -0,0 +1,52 @@ +// Copyright (c) 2010-2026 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 CommonUI +import OpenHABCore +import SwiftUI + +enum WatchTypography { + static let labelFont: Font = .caption + static let labelLineLimit = 2 + static let labelMinScale: CGFloat = 0.85 + + static let detailFont: Font = .footnote + static let detailLineLimit = 1 + static let detailMinScale: CGFloat = 0.85 + + static let sectionFont: Font = .callout + static let sectionLineLimit = 1 + static let sectionMinScale: CGFloat = 0.85 + + static let controlFont: Font = .caption + static let controlLineLimit = 1 + static let controlMinScale: CGFloat = 0.8 + + static let secondaryFont: Font = .caption2 + static let secondaryLineLimit = 1 + static let secondaryMinScale: CGFloat = 0.8 + + static let emphasisFont: Font = .headline +} + +struct WatchLabelText: View { + @ObservedObject var widget: OpenHABWidget + + var body: some View { + TextLabelView(widget: widget, font: WatchTypography.labelFont, lineLimit: WatchTypography.labelLineLimit) + .minimumScaleFactor(WatchTypography.labelMinScale) + .truncationMode(.tail) + } + + init(widget: OpenHABWidget) { + self.widget = widget + } +} From 30b610d8c4ff59dde106e0dcd2d2d9f4abe7c284 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Mon, 9 Feb 2026 23:42:53 +0100 Subject: [PATCH 435/476] Extract WidgetCommandSender for centralized command dispatch Introduce WidgetCommandSender and WidgetCommandPolicy to replace inline Task.sleep debounce patterns and direct widget.sendCommand calls across all watchOS row views with a unified, policy-driven command sending abstraction. Signed-off-by: Tim Mueller-Seydlitz --- openHAB.xcodeproj/project.pbxproj | 8 ++ openHABWatch/Model/WidgetCommandPolicy.swift | 33 ++++++++ openHABWatch/Model/WidgetCommandSender.swift | 82 +++++++++++++++++++ openHABWatch/Views/Rows/ColorPickerRow.swift | 8 +- .../Views/Rows/RollershutterRow.swift | 7 +- openHABWatch/Views/Rows/SegmentRow.swift | 13 +-- .../Views/Rows/SegmentSelectionView.swift | 29 +++---- openHABWatch/Views/Rows/SelectionRow.swift | 10 ++- openHABWatch/Views/Rows/SetpointRow.swift | 3 +- openHABWatch/Views/Rows/SliderRow.swift | 31 ++++--- openHABWatch/Views/Rows/SwitchRow.swift | 8 +- 11 files changed, 176 insertions(+), 56 deletions(-) create mode 100644 openHABWatch/Model/WidgetCommandPolicy.swift create mode 100644 openHABWatch/Model/WidgetCommandSender.swift diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index 5c93f1caf..23aeb36e0 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -143,6 +143,8 @@ DA8B14B82F3A120E007753FD /* PreviewWidgetFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA8B14B72F3A11F0007753FD /* PreviewWidgetFactory.swift */; }; DA8B14BA2F3A373A007753FD /* PreviewNavigationContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA8B14B92F3A373A007753FD /* PreviewNavigationContainer.swift */; }; DA8B14BC2F3A3CB5007753FD /* WatchTypography.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA8B14BB2F3A3CB5007753FD /* WatchTypography.swift */; }; + DA8B14BF2F3A98A1007753FD /* WidgetCommandSender.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA8B14BE2F3A98A1007753FD /* WidgetCommandSender.swift */; }; + DA8B14C02F3A98A1007753FD /* WidgetCommandPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA8B14BD2F3A98A1007753FD /* WidgetCommandPolicy.swift */; }; DA94AEB42EC896BE003BB3C8 /* SimpleMJPEGPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA94AEB32EC896BE003BB3C8 /* SimpleMJPEGPlayer.swift */; }; DA95F3332E0F2B1700FE4474 /* OpenHABRootViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA95F3322E0F2B1700FE4474 /* OpenHABRootViewController.swift */; }; DA95F3352E0F2C1600FE4474 /* OpenHABSitemapViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA95F3342E0F2C1600FE4474 /* OpenHABSitemapViewController.swift */; }; @@ -503,6 +505,8 @@ DA8B14B72F3A11F0007753FD /* PreviewWidgetFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewWidgetFactory.swift; sourceTree = ""; }; DA8B14B92F3A373A007753FD /* PreviewNavigationContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewNavigationContainer.swift; sourceTree = ""; }; DA8B14BB2F3A3CB5007753FD /* WatchTypography.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchTypography.swift; sourceTree = ""; }; + DA8B14BD2F3A98A1007753FD /* WidgetCommandPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetCommandPolicy.swift; sourceTree = ""; }; + DA8B14BE2F3A98A1007753FD /* WidgetCommandSender.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetCommandSender.swift; sourceTree = ""; }; DA94AEB32EC896BE003BB3C8 /* SimpleMJPEGPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleMJPEGPlayer.swift; sourceTree = ""; }; DA94AF432EC8DE41003BB3C8 /* VideoStreamManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoStreamManager.swift; sourceTree = ""; }; DA95F3322E0F2B1700FE4474 /* OpenHABRootViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenHABRootViewController.swift; sourceTree = ""; }; @@ -779,6 +783,8 @@ 93F38D4623803731001B1451 /* Model */ = { isa = PBXGroup; children = ( + DA8B14BD2F3A98A1007753FD /* WidgetCommandPolicy.swift */, + DA8B14BE2F3A98A1007753FD /* WidgetCommandSender.swift */, DA15BFBC23C6726400BD8ADA /* AppSettings.swift */, DA9721C224E29A8F0092CCFD /* UserDefaultsBacked.swift */, DAC9AF4624F9669F006DAE93 /* OpenHABWidgetExtension.swift */, @@ -1719,6 +1725,8 @@ DAF457A223DB6C640018B495 /* RollershutterRow.swift in Sources */, DA8B14BA2F3A373A007753FD /* PreviewNavigationContainer.swift in Sources */, DAF457A923DBA4990018B495 /* FrameRow.swift in Sources */, + DA8B14BF2F3A98A1007753FD /* WidgetCommandSender.swift in Sources */, + DA8B14C02F3A98A1007753FD /* WidgetCommandPolicy.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/openHABWatch/Model/WidgetCommandPolicy.swift b/openHABWatch/Model/WidgetCommandPolicy.swift new file mode 100644 index 000000000..5a8d50c79 --- /dev/null +++ b/openHABWatch/Model/WidgetCommandPolicy.swift @@ -0,0 +1,33 @@ +// Copyright (c) 2010-2026 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 Foundation +import OpenHABCore + +enum WidgetCommandPolicy: Sendable { + case immediate + case debounce(Duration) +} + +enum WidgetCommandDefaults { + static let slider: WidgetCommandPolicy = .debounce(.milliseconds(500)) + static let segmentedSelection: WidgetCommandPolicy = .debounce(.milliseconds(300)) + static let immediate: WidgetCommandPolicy = .immediate + + static func policy(for widget: OpenHABWidget) -> WidgetCommandPolicy { + switch widget.type { + case .slider: + slider + default: + immediate + } + } +} diff --git a/openHABWatch/Model/WidgetCommandSender.swift b/openHABWatch/Model/WidgetCommandSender.swift new file mode 100644 index 000000000..93b998da3 --- /dev/null +++ b/openHABWatch/Model/WidgetCommandSender.swift @@ -0,0 +1,82 @@ +// Copyright (c) 2010-2026 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 Foundation +import OpenHABCore +import os.log + +final class WidgetCommandSender { + private var pendingTasks: [String: Task] = [:] + + @MainActor + func send(_ command: String?, for widget: OpenHABWidget, policy: WidgetCommandPolicy, key: String? = nil) { + guard let command, !command.isEmpty else { return } + switch policy { + case .immediate: + Logger.rowViews.info("Sending command immediately: \(command)") + widget.sendCommand(command) + case .debounce(let duration): + sendDebounced(command, for: widget, duration: duration, key: key) + } + } + + @MainActor + func sendPress(_ command: String?, for widget: OpenHABWidget) { + guard let command, !command.isEmpty else { return } + Logger.rowViews.info("Sending press command: \(command)") + widget.sendCommand(command) + } + + @MainActor + func sendRelease(_ command: String?, for widget: OpenHABWidget) { + guard let command, !command.isEmpty else { return } + Logger.rowViews.info("Sending release command: \(command)") + widget.sendCommand(command) + } + + @MainActor + func sendItemUpdate(_ state: NumberState?, for widget: OpenHABWidget) { + guard state != nil else { return } + Logger.rowViews.info("Sending item update") + widget.sendItemUpdate(state: state) + } + + @MainActor + func cancelPending(for widget: OpenHABWidget, key: String? = nil) { + let taskKey = commandKey(for: widget, key: key) + pendingTasks[taskKey]?.cancel() + pendingTasks.removeValue(forKey: taskKey) + } + + @MainActor + private func sendDebounced(_ command: String, + for widget: OpenHABWidget, + duration: Duration, + key: String?) { + let taskKey = commandKey(for: widget, key: key) + pendingTasks[taskKey]?.cancel() + pendingTasks[taskKey] = Task { @MainActor [weak self] in + try? await Task.sleep(for: duration) + guard !Task.isCancelled else { return } + Logger.rowViews.info("Sending debounced command: \(command)") + widget.sendCommand(command) + self?.pendingTasks.removeValue(forKey: taskKey) + } + } + + @MainActor + private func commandKey(for widget: OpenHABWidget, key: String?) -> String { + if let key { + return "\(widget.widgetId)-\(key)" + } + return widget.widgetId + } +} diff --git a/openHABWatch/Views/Rows/ColorPickerRow.swift b/openHABWatch/Views/Rows/ColorPickerRow.swift index 79ae11e91..67665878c 100644 --- a/openHABWatch/Views/Rows/ColorPickerRow.swift +++ b/openHABWatch/Views/Rows/ColorPickerRow.swift @@ -11,7 +11,6 @@ import CommonUI import OpenHABCore -import os.log import SFSafeSymbols import SwiftUI @@ -19,6 +18,7 @@ struct ColorPickerRow: View { @ObservedObject var widget: OpenHABWidget @ObservedObject var settings = AppSettings.shared @State private var viewModel: WidgetRowViewModel + @State private var commandSender = WidgetCommandSender() var body: some View { let uiColor = viewModel.colorState @@ -69,13 +69,11 @@ struct ColorPickerRow: View { } func upButtonPressed() { - Logger.rowViews.info("ON button pressed") - widget.sendCommand("ON") + commandSender.send("ON", for: widget, policy: .immediate) } func downButtonPressed() { - Logger.rowViews.info("OFF button pressed") - widget.sendCommand("OFF") + commandSender.send("OFF", for: widget, policy: .immediate) } init(widget: OpenHABWidget) { diff --git a/openHABWatch/Views/Rows/RollershutterRow.swift b/openHABWatch/Views/Rows/RollershutterRow.swift index 6f26d427a..a8ccf17a7 100644 --- a/openHABWatch/Views/Rows/RollershutterRow.swift +++ b/openHABWatch/Views/Rows/RollershutterRow.swift @@ -18,6 +18,7 @@ struct RollershutterRow: View { @ObservedObject var widget: OpenHABWidget @EnvironmentObject var settings: AppSettings @State private var viewModel: WidgetRowViewModel + @State private var commandSender = WidgetCommandSender() var body: some View { VStack(spacing: -5) { @@ -29,16 +30,16 @@ struct RollershutterRow: View { HStack { Spacer() IconWithAction(systemSymbol: .chevronUpCircleFill) { - widget.sendCommand("UP") + commandSender.send("UP", for: widget, policy: .immediate) } Spacer() IconWithAction(systemSymbol: .square) { - widget.sendCommand("STOP") + commandSender.send("STOP", for: widget, policy: .immediate) } Spacer() IconWithAction(systemSymbol: .chevronDownCircleFill) { - widget.sendCommand("DOWN") + commandSender.send("DOWN", for: widget, policy: .immediate) } Spacer() } diff --git a/openHABWatch/Views/Rows/SegmentRow.swift b/openHABWatch/Views/Rows/SegmentRow.swift index 4ccb23c67..7fdedecea 100644 --- a/openHABWatch/Views/Rows/SegmentRow.swift +++ b/openHABWatch/Views/Rows/SegmentRow.swift @@ -11,7 +11,6 @@ import CommonUI import OpenHABCore -import os.log import SwiftUI struct SegmentRow: View { @@ -20,6 +19,7 @@ struct SegmentRow: View { @State private var pressedIndex: Int? @State private var singlePressed = false @State private var viewModel: WidgetRowViewModel + @State private var commandSender = WidgetCommandSender() private var currentIndex: Int? { viewModel.selectedIndex @@ -83,17 +83,13 @@ struct SegmentRow: View { guard bounds.contains(value.startLocation) else { return } if pressedIndex != index { pressedIndex = index - Logger.rowViews.info("Sending press command: \(mapping.command)") - widget.sendCommand(mapping.command) + commandSender.sendPress(mapping.command, for: widget) } } .onEnded { _ in guard pressedIndex == index else { return } pressedIndex = nil - if let releaseCommand = mapping.releaseCommand, !releaseCommand.isEmpty { - Logger.rowViews.info("Sending release command: \(releaseCommand)") - widget.sendCommand(releaseCommand) - } + commandSender.sendRelease(mapping.releaseCommand, for: widget) } ) } @@ -188,8 +184,7 @@ struct SegmentRow: View { } .onEnded { value in if singlePressed, bounds.contains(value.location) { - Logger.rowViews.info("Sending command: \(mapping.command)") - widget.sendCommand(mapping.command) + commandSender.send(mapping.command, for: widget, policy: .immediate) } singlePressed = false } diff --git a/openHABWatch/Views/Rows/SegmentSelectionView.swift b/openHABWatch/Views/Rows/SegmentSelectionView.swift index e163ee98b..d7a87fcd6 100644 --- a/openHABWatch/Views/Rows/SegmentSelectionView.swift +++ b/openHABWatch/Views/Rows/SegmentSelectionView.swift @@ -10,16 +10,15 @@ // SPDX-License-Identifier: EPL-2.0 import OpenHABCore -import os.log import SwiftUI struct SegmentSelectionView: View { @ObservedObject var widget: OpenHABWidget @Binding var selectedIndex: Int? @Environment(\.dismiss) private var dismiss - @State private var pendingValue: String? @State private var pressedIndex: Int? @State private var viewModel: WidgetRowViewModel + @State private var commandSender = WidgetCommandSender() private var currentIndex: Int? { viewModel.selectedIndex @@ -72,18 +71,12 @@ struct SegmentSelectionView: View { .onChanged { _ in if pressedIndex != index { pressedIndex = index - // Send command on press - Logger.rowViews.info("Sending press command: \(mapping.command)") - widget.sendCommand(mapping.command) + commandSender.sendPress(mapping.command, for: widget) } } .onEnded { _ in pressedIndex = nil - // Send release command on release - if let releaseCommand = mapping.releaseCommand, !releaseCommand.isEmpty { - Logger.rowViews.info("Sending release command: \(releaseCommand)") - widget.sendCommand(releaseCommand) - } + commandSender.sendRelease(mapping.releaseCommand, for: widget) } ) } @@ -134,15 +127,13 @@ struct SegmentSelectionView: View { viewModel.selectedIndex = index selectedIndex = viewModel.selectedIndex if let selectedCommand = viewModel.mappings[safe: index]?.command { - pendingValue = selectedCommand - Task { @MainActor in - try? await Task.sleep(for: .milliseconds(300)) - if pendingValue == selectedCommand { - widget.sendCommand(selectedCommand) - pendingValue = nil - dismiss() - } - } + commandSender.send( + selectedCommand, + for: widget, + policy: WidgetCommandDefaults.segmentedSelection, + key: "segment-selection" + ) + dismiss() } } diff --git a/openHABWatch/Views/Rows/SelectionRow.swift b/openHABWatch/Views/Rows/SelectionRow.swift index e23a25b94..74d0099db 100644 --- a/openHABWatch/Views/Rows/SelectionRow.swift +++ b/openHABWatch/Views/Rows/SelectionRow.swift @@ -11,7 +11,6 @@ import CommonUI import OpenHABCore -import os.log import SFSafeSymbols import SwiftUI @@ -21,6 +20,7 @@ struct SelectionListView: View { @Binding var selectedIndex: Int? @Environment(\.dismiss) private var dismiss @State private var viewModel: WidgetRowViewModel + @State private var commandSender = WidgetCommandSender() private var mappings: [OpenHABWidgetMapping] { viewModel.mappings @@ -84,8 +84,12 @@ struct SelectionListView: View { viewModel.selectedIndex = index selectedIndex = viewModel.selectedIndex if let selectedCommand = mappings[safe: index]?.command { - Logger.rowViews.info("Selection changed to: \(selectedCommand)") - widget.sendCommand(selectedCommand) + commandSender.send( + selectedCommand, + for: widget, + policy: WidgetCommandDefaults.immediate, + key: "selection-list" + ) dismiss() } } diff --git a/openHABWatch/Views/Rows/SetpointRow.swift b/openHABWatch/Views/Rows/SetpointRow.swift index 834e8d754..9272bf058 100644 --- a/openHABWatch/Views/Rows/SetpointRow.swift +++ b/openHABWatch/Views/Rows/SetpointRow.swift @@ -21,6 +21,7 @@ struct SetpointRow: View { private let setpointService = SetPointService() private let logger = Logger(subsystem: "org.openhab.watch", category: "SetpointRow") @State private var viewModel: WidgetRowViewModel + @State private var commandSender = WidgetCommandSender() private var currentValue: Double { viewModel.numberState?.value ?? viewModel.minValue @@ -91,7 +92,7 @@ struct SetpointRow: View { numberState?.value = limitedNewValue logger.info("Setpoint \(isDecreasing ? "decreased" : "increased") to \(numberState?.description ?? String(limitedNewValue))") - widget.sendItemUpdate(state: numberState) + commandSender.sendItemUpdate(numberState, for: widget) } func decreaseValue() { diff --git a/openHABWatch/Views/Rows/SliderRow.swift b/openHABWatch/Views/Rows/SliderRow.swift index 47b4d7e86..4a18cc5cd 100644 --- a/openHABWatch/Views/Rows/SliderRow.swift +++ b/openHABWatch/Views/Rows/SliderRow.swift @@ -21,6 +21,7 @@ struct SliderRow: View { var fallbackSymbol: SFSymbol? @State private var pendingValue: Double? @State private var viewModel: WidgetRowViewModel + @State private var commandSender = WidgetCommandSender() private var currentValue: Double { pendingValue ?? viewModel.adjustedValue @@ -38,13 +39,12 @@ struct SliderRow: View { set: { newValue in Logger.rowViews.info("SliderRow new value = \(newValue)") pendingValue = newValue - Task { @MainActor in - try? await Task.sleep(for: .milliseconds(500)) - if pendingValue == newValue { // Ensure no new updates came in - widget.sendCommand(newValue.valueText(step: viewModel.step)) - pendingValue = nil - } - } + commandSender.send( + newValue.valueText(step: viewModel.step), + for: widget, + policy: WidgetCommandDefaults.slider, + key: "slider-value" + ) } ) } @@ -56,11 +56,19 @@ struct SliderRow: View { }, set: { newValue in if newValue { - Logger.rowViews.info("SliderRow switch to ON") - widget.sendCommand(viewModel.maxValue.valueText(step: viewModel.step)) + commandSender.send( + viewModel.maxValue.valueText(step: viewModel.step), + for: widget, + policy: .immediate, + key: "slider-toggle" + ) } else { - Logger.rowViews.info("SliderRow switch to OFF") - widget.sendCommand(viewModel.minValue.valueText(step: viewModel.step)) + commandSender.send( + viewModel.minValue.valueText(step: viewModel.step), + for: widget, + policy: .immediate, + key: "slider-toggle" + ) } } ) @@ -115,6 +123,7 @@ struct SliderRow: View { } .onChange(of: widget.item?.state, initial: false) { _, _ in viewModel.update(from: widget) + pendingValue = nil } } diff --git a/openHABWatch/Views/Rows/SwitchRow.swift b/openHABWatch/Views/Rows/SwitchRow.swift index a28f4cc4f..b89500798 100644 --- a/openHABWatch/Views/Rows/SwitchRow.swift +++ b/openHABWatch/Views/Rows/SwitchRow.swift @@ -11,7 +11,6 @@ import CommonUI import OpenHABCore -import os.log import SwiftUI struct SwitchRow: View { @@ -19,6 +18,7 @@ struct SwitchRow: View { @EnvironmentObject var settings: AppSettings @State private var localIsOn: Bool? @State private var viewModel: WidgetRowViewModel + @State private var commandSender = WidgetCommandSender() private var isOn: Bool { localIsOn ?? viewModel.isOn @@ -30,11 +30,9 @@ struct SwitchRow: View { set: { newValue in localIsOn = newValue if newValue { - Logger.rowViews.info("Switch to ON") - widget.sendCommand("ON") + commandSender.send("ON", for: widget, policy: .immediate) } else { - Logger.rowViews.info("Switch to OFF") - widget.sendCommand("OFF") + commandSender.send("OFF", for: widget, policy: .immediate) } } )) { From b67e80e72dabcd7eec8717c30592f4256fa3db94 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Tue, 10 Feb 2026 06:05:50 +0100 Subject: [PATCH 436/476] Fix WidgetCommandSender cleanup and setpoint unit preservation Add deinit to cancel pending tasks in WidgetCommandSender, simplify segment selection to immediate policy, and preserve unit when creating fallback NumberState in SetpointRow. Signed-off-by: Tim Mueller-Seydlitz --- openHABWatch/Model/WidgetCommandPolicy.swift | 1 - openHABWatch/Model/WidgetCommandSender.swift | 5 +++++ openHABWatch/Views/Rows/SegmentSelectionView.swift | 2 +- openHABWatch/Views/Rows/SetpointRow.swift | 2 +- 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/openHABWatch/Model/WidgetCommandPolicy.swift b/openHABWatch/Model/WidgetCommandPolicy.swift index 5a8d50c79..016a4a8fd 100644 --- a/openHABWatch/Model/WidgetCommandPolicy.swift +++ b/openHABWatch/Model/WidgetCommandPolicy.swift @@ -19,7 +19,6 @@ enum WidgetCommandPolicy: Sendable { enum WidgetCommandDefaults { static let slider: WidgetCommandPolicy = .debounce(.milliseconds(500)) - static let segmentedSelection: WidgetCommandPolicy = .debounce(.milliseconds(300)) static let immediate: WidgetCommandPolicy = .immediate static func policy(for widget: OpenHABWidget) -> WidgetCommandPolicy { diff --git a/openHABWatch/Model/WidgetCommandSender.swift b/openHABWatch/Model/WidgetCommandSender.swift index 93b998da3..95c3a7458 100644 --- a/openHABWatch/Model/WidgetCommandSender.swift +++ b/openHABWatch/Model/WidgetCommandSender.swift @@ -16,6 +16,11 @@ import os.log final class WidgetCommandSender { private var pendingTasks: [String: Task] = [:] + deinit { + pendingTasks.values.forEach { $0.cancel() } + pendingTasks.removeAll() + } + @MainActor func send(_ command: String?, for widget: OpenHABWidget, policy: WidgetCommandPolicy, key: String? = nil) { guard let command, !command.isEmpty else { return } diff --git a/openHABWatch/Views/Rows/SegmentSelectionView.swift b/openHABWatch/Views/Rows/SegmentSelectionView.swift index d7a87fcd6..31d95b0c9 100644 --- a/openHABWatch/Views/Rows/SegmentSelectionView.swift +++ b/openHABWatch/Views/Rows/SegmentSelectionView.swift @@ -130,7 +130,7 @@ struct SegmentSelectionView: View { commandSender.send( selectedCommand, for: widget, - policy: WidgetCommandDefaults.segmentedSelection, + policy: WidgetCommandDefaults.immediate, key: "segment-selection" ) dismiss() diff --git a/openHABWatch/Views/Rows/SetpointRow.swift b/openHABWatch/Views/Rows/SetpointRow.swift index 9272bf058..1124b13ec 100644 --- a/openHABWatch/Views/Rows/SetpointRow.swift +++ b/openHABWatch/Views/Rows/SetpointRow.swift @@ -88,7 +88,7 @@ struct SetpointRow: View { return } - numberState = numberState ?? NumberState(value: limitedNewValue) + numberState = numberState ?? NumberState(value: limitedNewValue, unit: widget.unit) numberState?.value = limitedNewValue logger.info("Setpoint \(isDecreasing ? "decreased" : "increased") to \(numberState?.description ?? String(limitedNewValue))") From ab7b3fe8f12a179c17776adfc77b527f8bed216b Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Tue, 10 Feb 2026 06:21:15 +0100 Subject: [PATCH 437/476] Simplify stateless rows by removing unnecessary WidgetRowViewModel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FrameRow, GenericRow, RollershutterRow, and TextRow don't need to observe item state changes — remove WidgetRowViewModel and simplify their interfaces (FrameRow now takes a plain title string). Signed-off-by: Tim Mueller-Seydlitz --- openHABWatch/Model/WidgetRowFactory.swift | 4 ++-- openHABWatch/Views/Rows/FrameRow.swift | 20 +++---------------- openHABWatch/Views/Rows/GenericRow.swift | 17 ++-------------- .../Views/Rows/RollershutterRow.swift | 16 ++------------- openHABWatch/Views/Rows/TextRow.swift | 19 ++++-------------- 5 files changed, 13 insertions(+), 63 deletions(-) diff --git a/openHABWatch/Model/WidgetRowFactory.swift b/openHABWatch/Model/WidgetRowFactory.swift index 3f48a8306..64a767bf8 100644 --- a/openHABWatch/Model/WidgetRowFactory.swift +++ b/openHABWatch/Model/WidgetRowFactory.swift @@ -34,9 +34,9 @@ enum WidgetRowFactory { case .setpoint: SetpointRow(widget: widget) case .frame: - FrameRow(widget: widget) + FrameRow(title: widget.labelText ?? "") case .text: - TextRow(widget: widget) + TextRow(widget: widget, hasLinkedPage: widget.linkedPage != nil) case .image: if widget.item != nil { ImageRawRow(widget: widget) diff --git a/openHABWatch/Views/Rows/FrameRow.swift b/openHABWatch/Views/Rows/FrameRow.swift index 1df77105f..c1a4c84db 100644 --- a/openHABWatch/Views/Rows/FrameRow.swift +++ b/openHABWatch/Views/Rows/FrameRow.swift @@ -9,40 +9,26 @@ // // SPDX-License-Identifier: EPL-2.0 -import OpenHABCore import SwiftUI struct FrameRow: View { - @ObservedObject var widget: OpenHABWidget - @EnvironmentObject var settings: AppSettings - @State private var viewModel: WidgetRowViewModel + let title: String var body: some View { HStack { - Text(viewModel.labelText.uppercased()) + Text(title.uppercased()) .font(WatchTypography.sectionFont) .lineLimit(WatchTypography.sectionLineLimit) .minimumScaleFactor(WatchTypography.sectionMinScale) .truncationMode(.tail) Spacer() } - .onAppear { - viewModel.update(from: widget) - } - .onChange(of: widget.item?.state, initial: false) { _, _ in - viewModel.update(from: widget) - } - } - - init(widget: OpenHABWidget) { - self.widget = widget - _viewModel = State(wrappedValue: WidgetRowViewModel(widget: widget)) } } #Preview { let widget = PreviewWidgetFactory.frame(label: "Environment") PreviewNavigationContainer { - FrameRow(widget: widget) + FrameRow(title: widget.labelText ?? "") } } diff --git a/openHABWatch/Views/Rows/GenericRow.swift b/openHABWatch/Views/Rows/GenericRow.swift index 0420ccdf1..f2d42326a 100644 --- a/openHABWatch/Views/Rows/GenericRow.swift +++ b/openHABWatch/Views/Rows/GenericRow.swift @@ -11,13 +11,11 @@ import CommonUI import OpenHABCore -import os.log import SwiftUI struct GenericRow: View { - @ObservedObject var widget: OpenHABWidget + let widget: OpenHABWidget @ObservedObject var settings = AppSettings.shared - @State private var viewModel: WidgetRowViewModel var body: some View { HStack { @@ -27,18 +25,7 @@ struct GenericRow: View { DetailTextLabelView(widget: widget) widget.makeView(settings: settings) } - .accessibilityLabel(viewModel.labelText) - .onAppear { - viewModel.update(from: widget) - } - .onChange(of: widget.item?.state, initial: false) { _, _ in - viewModel.update(from: widget) - } - } - - init(widget: OpenHABWidget) { - self.widget = widget - _viewModel = State(wrappedValue: WidgetRowViewModel(widget: widget)) + .accessibilityLabel(widget.labelText ?? "") } } diff --git a/openHABWatch/Views/Rows/RollershutterRow.swift b/openHABWatch/Views/Rows/RollershutterRow.swift index a8ccf17a7..c6c540d64 100644 --- a/openHABWatch/Views/Rows/RollershutterRow.swift +++ b/openHABWatch/Views/Rows/RollershutterRow.swift @@ -15,9 +15,8 @@ import SFSafeSymbols import SwiftUI struct RollershutterRow: View { - @ObservedObject var widget: OpenHABWidget + let widget: OpenHABWidget @EnvironmentObject var settings: AppSettings - @State private var viewModel: WidgetRowViewModel @State private var commandSender = WidgetCommandSender() var body: some View { @@ -45,18 +44,7 @@ struct RollershutterRow: View { } .frame(height: 50) } - .accessibilityLabel(viewModel.labelText) - .onAppear { - viewModel.update(from: widget) - } - .onChange(of: widget.item?.state, initial: false) { _, _ in - viewModel.update(from: widget) - } - } - - init(widget: OpenHABWidget) { - self.widget = widget - _viewModel = State(wrappedValue: WidgetRowViewModel(widget: widget)) + .accessibilityLabel(widget.labelText ?? "") } } diff --git a/openHABWatch/Views/Rows/TextRow.swift b/openHABWatch/Views/Rows/TextRow.swift index f110bcaa8..bb58d0571 100644 --- a/openHABWatch/Views/Rows/TextRow.swift +++ b/openHABWatch/Views/Rows/TextRow.swift @@ -15,9 +15,9 @@ import SFSafeSymbols import SwiftUI struct TextRow: View { - @ObservedObject var widget: OpenHABWidget + let widget: OpenHABWidget @EnvironmentObject var settings: AppSettings - @State private var viewModel: WidgetRowViewModel + let hasLinkedPage: Bool var body: some View { HStack { @@ -25,29 +25,18 @@ struct TextRow: View { WatchLabelText(widget: widget) Spacer() DetailTextLabelView(widget: widget) - if viewModel.hasLinkedPage { + if hasLinkedPage { Image(systemSymbol: .chevronRight) .foregroundStyle(.secondary) .font(.caption) } } - .onAppear { - viewModel.update(from: widget) - } - .onChange(of: widget.item?.state, initial: false) { _, _ in - viewModel.update(from: widget) - } - } - - init(widget: OpenHABWidget) { - self.widget = widget - _viewModel = State(wrappedValue: WidgetRowViewModel(widget: widget)) } } #Preview { let widget = PreviewWidgetFactory.text(label: "Energy Usage", valueText: "450 W") PreviewNavigationContainer { - TextRow(widget: widget) + TextRow(widget: widget, hasLinkedPage: false) } } From c634c23c2c30fef44a9ff23c238d6f6a6e20488e Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Tue, 10 Feb 2026 06:43:45 +0100 Subject: [PATCH 438/476] Replace IconView with model-driven WatchIconView Rename IconView to WatchIconView backed by IconRenderModel, moving icon URL construction logic into OpenHABWidgetExtension. Reorder init blocks before private methods for consistency across row views. Signed-off-by: Tim Mueller-Seydlitz --- openHAB.xcodeproj/project.pbxproj | 8 +- .../Model/OpenHABWidgetExtension.swift | 15 ++++ openHABWatch/Model/WidgetCommandPolicy.swift | 10 +-- openHABWatch/Model/WidgetCommandSender.swift | 12 +-- openHABWatch/Model/WidgetRowViewModel.swift | 8 +- .../PreviewNavigationContainer.swift | 8 +- .../PreviewWidgetFactory.swift | 5 +- openHABWatch/Views/Rows/ColorPickerRow.swift | 12 +-- openHABWatch/Views/Rows/GenericRow.swift | 2 +- .../Views/Rows/RollershutterRow.swift | 2 +- openHABWatch/Views/Rows/SegmentRow.swift | 8 +- .../Views/Rows/SegmentSelectionView.swift | 12 +-- openHABWatch/Views/Rows/SelectionRow.swift | 14 ++-- openHABWatch/Views/Rows/SetpointRow.swift | 12 +-- openHABWatch/Views/Rows/SliderRow.swift | 4 +- openHABWatch/Views/Rows/SwitchRow.swift | 2 +- openHABWatch/Views/Rows/TextRow.swift | 2 +- .../{IconView.swift => WatchIconView.swift} | 79 ++++++------------- .../Views/Utils/WatchTypography.swift | 28 +++---- 19 files changed, 114 insertions(+), 129 deletions(-) rename openHABWatch/Views/Utils/{IconView.swift => WatchIconView.swift} (65%) diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index 23aeb36e0..720b5230b 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -195,7 +195,7 @@ DAF231D927BB702500AB916C /* invalid_xmlns.svg in Resources */ = {isa = PBXBuildFile; fileRef = DAF231D627BB702500AB916C /* invalid_xmlns.svg */; }; DAF231DB27BB828000AB916C /* pantryUseTagPoints2NonExistentElement.svg in Resources */ = {isa = PBXBuildFile; fileRef = DAF231DA27BB828000AB916C /* pantryUseTagPoints2NonExistentElement.svg */; }; DAF231E327BBD1A000AB916C /* embeddedpng_valid.svg in Resources */ = {isa = PBXBuildFile; fileRef = DAF231E227BBD1A000AB916C /* embeddedpng_valid.svg */; }; - DAF4578223D630C70018B495 /* IconView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF4578123D630C70018B495 /* IconView.swift */; }; + DAF4578223D630C70018B495 /* WatchIconView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF4578123D630C70018B495 /* WatchIconView.swift */; }; DAF4578923D79AA50018B495 /* DetailTextLabelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF4578823D79AA50018B495 /* DetailTextLabelView.swift */; }; DAF457A023DA3E1C0018B495 /* SegmentRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF4579F23DA3E1C0018B495 /* SegmentRow.swift */; }; DAF457A223DB6C640018B495 /* RollershutterRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF457A123DB6C640018B495 /* RollershutterRow.swift */; }; @@ -560,7 +560,7 @@ DAF231D627BB702500AB916C /* invalid_xmlns.svg */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xml; path = invalid_xmlns.svg; sourceTree = ""; }; DAF231DA27BB828000AB916C /* pantryUseTagPoints2NonExistentElement.svg */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xml; path = pantryUseTagPoints2NonExistentElement.svg; sourceTree = ""; }; DAF231E227BBD1A000AB916C /* embeddedpng_valid.svg */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xml; path = embeddedpng_valid.svg; sourceTree = ""; }; - DAF4578123D630C70018B495 /* IconView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconView.swift; sourceTree = ""; }; + DAF4578123D630C70018B495 /* WatchIconView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchIconView.swift; sourceTree = ""; }; DAF4578823D79AA50018B495 /* DetailTextLabelView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DetailTextLabelView.swift; sourceTree = ""; }; DAF4579F23DA3E1C0018B495 /* SegmentRow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SegmentRow.swift; sourceTree = ""; }; DAF457A123DB6C640018B495 /* RollershutterRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RollershutterRow.swift; sourceTree = ""; }; @@ -1065,7 +1065,7 @@ DAF457A723DBA2C40018B495 /* Utils */ = { isa = PBXGroup; children = ( - DAF4578123D630C70018B495 /* IconView.swift */, + DAF4578123D630C70018B495 /* WatchIconView.swift */, DAF4578823D79AA50018B495 /* DetailTextLabelView.swift */, DA2E0AA323DC96E9009B0A99 /* ImageWithAction.swift */, DA2E0B0D23DCC152009B0A99 /* MapView.swift */, @@ -1700,7 +1700,7 @@ DA0749DE23E0B5950057FA83 /* ColorPickerRow.swift in Sources */, DAF4581E23DC60020018B495 /* ImageRawRow.swift in Sources */, DAD0858B2AE56F0E001D36BE /* OpenHABWatch.swift in Sources */, - DAF4578223D630C70018B495 /* IconView.swift in Sources */, + DAF4578223D630C70018B495 /* WatchIconView.swift in Sources */, DA07752D2346705F0086C685 /* NotificationController.swift in Sources */, DA0749E023E0BF510057FA83 /* ColorSelection.swift in Sources */, DA65871F236F83CE007E2E7F /* UserDefaultsExtension.swift in Sources */, diff --git a/openHABWatch/Model/OpenHABWidgetExtension.swift b/openHABWatch/Model/OpenHABWidgetExtension.swift index 4122d0309..b8e3ee0e1 100644 --- a/openHABWatch/Model/OpenHABWidgetExtension.swift +++ b/openHABWatch/Model/OpenHABWidgetExtension.swift @@ -14,8 +14,23 @@ import OpenHABCore import os.log import SFSafeSymbols import SwiftUI +import UIKit extension OpenHABWidget { + @MainActor + func iconRenderModel(fallbackSymbol: SFSymbol? = nil) -> IconRenderModel { + let logicColor = !iconColor.isEmpty ? UIColor(fromString: iconColor) : .ohBlack + let encodedIconColor = logicColor.semanticColorToHex() ?? "#FFFFFF" + return IconRenderModel( + icon: icon, + iconState: iconState(), + iconColorHex: encodedIconColor, + staticIcon: staticIcon, + stateToken: item?.state ?? state, + fallbackSymbol: fallbackSymbol + ) + } + @ViewBuilder @MainActor func makeView(settings: AppSettings) -> some View { if linkedPage != nil { Image(systemSymbol: .chevronRight) diff --git a/openHABWatch/Model/WidgetCommandPolicy.swift b/openHABWatch/Model/WidgetCommandPolicy.swift index 016a4a8fd..d9b56ede8 100644 --- a/openHABWatch/Model/WidgetCommandPolicy.swift +++ b/openHABWatch/Model/WidgetCommandPolicy.swift @@ -12,11 +12,6 @@ import Foundation import OpenHABCore -enum WidgetCommandPolicy: Sendable { - case immediate - case debounce(Duration) -} - enum WidgetCommandDefaults { static let slider: WidgetCommandPolicy = .debounce(.milliseconds(500)) static let immediate: WidgetCommandPolicy = .immediate @@ -30,3 +25,8 @@ enum WidgetCommandDefaults { } } } + +enum WidgetCommandPolicy: Sendable { + case immediate + case debounce(Duration) +} diff --git a/openHABWatch/Model/WidgetCommandSender.swift b/openHABWatch/Model/WidgetCommandSender.swift index 95c3a7458..7c7197bfd 100644 --- a/openHABWatch/Model/WidgetCommandSender.swift +++ b/openHABWatch/Model/WidgetCommandSender.swift @@ -16,11 +16,6 @@ import os.log final class WidgetCommandSender { private var pendingTasks: [String: Task] = [:] - deinit { - pendingTasks.values.forEach { $0.cancel() } - pendingTasks.removeAll() - } - @MainActor func send(_ command: String?, for widget: OpenHABWidget, policy: WidgetCommandPolicy, key: String? = nil) { guard let command, !command.isEmpty else { return } @@ -28,7 +23,7 @@ final class WidgetCommandSender { case .immediate: Logger.rowViews.info("Sending command immediately: \(command)") widget.sendCommand(command) - case .debounce(let duration): + case let .debounce(duration): sendDebounced(command, for: widget, duration: duration, key: key) } } @@ -84,4 +79,9 @@ final class WidgetCommandSender { } return widget.widgetId } + + deinit { + pendingTasks.values.forEach { $0.cancel() } + pendingTasks.removeAll() + } } diff --git a/openHABWatch/Model/WidgetRowViewModel.swift b/openHABWatch/Model/WidgetRowViewModel.swift index 46cdd314b..e07189c5d 100644 --- a/openHABWatch/Model/WidgetRowViewModel.swift +++ b/openHABWatch/Model/WidgetRowViewModel.swift @@ -25,10 +25,10 @@ final class WidgetRowViewModel { var selectedLabel: String? var effectiveState = "" var isOn = false - var adjustedValue: Double = 0.0 - var minValue: Double = 0.0 - var maxValue: Double = 100.0 - var step: Double = 1.0 + var adjustedValue = 0.0 + var minValue = 0.0 + var maxValue = 100.0 + var step = 1.0 var switchSupport = false var hasLinkedPage = false var numberState: NumberState? diff --git a/openHABWatch/Preview Content/PreviewNavigationContainer.swift b/openHABWatch/Preview Content/PreviewNavigationContainer.swift index 4afa7b9f9..eeadc27b7 100644 --- a/openHABWatch/Preview Content/PreviewNavigationContainer.swift +++ b/openHABWatch/Preview Content/PreviewNavigationContainer.swift @@ -16,15 +16,15 @@ struct PreviewNavigationContainer: View { @State private var settings = AppSettings() private let content: Content - init(@ViewBuilder content: () -> Content) { - self.content = content() - } - var body: some View { NavigationStack { content } .environmentObject(settings) } + + init(@ViewBuilder content: () -> Content) { + self.content = content() + } } #endif diff --git a/openHABWatch/Preview Content/PreviewWidgetFactory.swift b/openHABWatch/Preview Content/PreviewWidgetFactory.swift index c02648f89..107b58cff 100644 --- a/openHABWatch/Preview Content/PreviewWidgetFactory.swift +++ b/openHABWatch/Preview Content/PreviewWidgetFactory.swift @@ -105,8 +105,8 @@ enum PreviewWidgetFactory { } static func rollershutter(label: String, - state: String = "STOP", - icon: String = "rollershutter") -> OpenHABWidget { + state: String = "STOP", + icon: String = "rollershutter") -> OpenHABWidget { makeWidget( type: .switchWidget, label: label, @@ -193,6 +193,7 @@ enum PreviewWidgetFactory { ) } + // swiftlint:disable:next function_parameter_count private static func makeWidget(type: OpenHABWidget.WidgetType, label: String, valueText: String?, diff --git a/openHABWatch/Views/Rows/ColorPickerRow.swift b/openHABWatch/Views/Rows/ColorPickerRow.swift index 67665878c..5a8c91b06 100644 --- a/openHABWatch/Views/Rows/ColorPickerRow.swift +++ b/openHABWatch/Views/Rows/ColorPickerRow.swift @@ -25,7 +25,7 @@ struct ColorPickerRow: View { return VStack(spacing: 0) { HStack { - IconView(widget: widget, settings: settings) + WatchIconView(model: widget.iconRenderModel(), settings: settings) WatchLabelText(widget: widget) Spacer() } @@ -68,6 +68,11 @@ struct ColorPickerRow: View { } } + init(widget: OpenHABWidget) { + self.widget = widget + _viewModel = State(wrappedValue: WidgetRowViewModel(widget: widget)) + } + func upButtonPressed() { commandSender.send("ON", for: widget, policy: .immediate) } @@ -75,11 +80,6 @@ struct ColorPickerRow: View { func downButtonPressed() { commandSender.send("OFF", for: widget, policy: .immediate) } - - init(widget: OpenHABWidget) { - self.widget = widget - _viewModel = State(wrappedValue: WidgetRowViewModel(widget: widget)) - } } #Preview { diff --git a/openHABWatch/Views/Rows/GenericRow.swift b/openHABWatch/Views/Rows/GenericRow.swift index f2d42326a..5f536b674 100644 --- a/openHABWatch/Views/Rows/GenericRow.swift +++ b/openHABWatch/Views/Rows/GenericRow.swift @@ -19,7 +19,7 @@ struct GenericRow: View { var body: some View { HStack { - IconView(widget: widget, settings: settings) + WatchIconView(model: widget.iconRenderModel(), settings: settings) WatchLabelText(widget: widget) Spacer() DetailTextLabelView(widget: widget) diff --git a/openHABWatch/Views/Rows/RollershutterRow.swift b/openHABWatch/Views/Rows/RollershutterRow.swift index c6c540d64..d66dff437 100644 --- a/openHABWatch/Views/Rows/RollershutterRow.swift +++ b/openHABWatch/Views/Rows/RollershutterRow.swift @@ -22,7 +22,7 @@ struct RollershutterRow: View { var body: some View { VStack(spacing: -5) { HStack { - IconView(widget: widget, settings: settings) + WatchIconView(model: widget.iconRenderModel(), settings: settings) WatchLabelText(widget: widget) Spacer() } diff --git a/openHABWatch/Views/Rows/SegmentRow.swift b/openHABWatch/Views/Rows/SegmentRow.swift index 7fdedecea..d969ae6ad 100644 --- a/openHABWatch/Views/Rows/SegmentRow.swift +++ b/openHABWatch/Views/Rows/SegmentRow.swift @@ -49,7 +49,7 @@ struct SegmentRow: View { private var pressReleaseContent: some View { if viewModel.mappings.count <= 2 { HStack { - IconView(widget: widget, settings: settings) + WatchIconView(model: widget.iconRenderModel(), settings: settings) WatchLabelText(widget: widget) Spacer() pressReleaseButtons @@ -103,7 +103,7 @@ struct SegmentRow: View { @ViewBuilder private var iconTitleRow: some View { HStack { - IconView(widget: widget, settings: settings) + WatchIconView(model: widget.iconRenderModel(), settings: settings) WatchLabelText(widget: widget) Spacer() } @@ -122,7 +122,7 @@ struct SegmentRow: View { private var multiSegmentContent: some View { HStack { HStack { - IconView(widget: widget, settings: settings) + WatchIconView(model: widget.iconRenderModel(), settings: settings) WatchLabelText(widget: widget) Spacer() } @@ -154,7 +154,7 @@ struct SegmentRow: View { private var singleMappingContent: some View { let mapping = viewModel.mappings[0] HStack { - IconView(widget: widget, settings: settings) + WatchIconView(model: widget.iconRenderModel(), settings: settings) WatchLabelText(widget: widget) Spacer() singleButton(for: mapping) diff --git a/openHABWatch/Views/Rows/SegmentSelectionView.swift b/openHABWatch/Views/Rows/SegmentSelectionView.swift index 31d95b0c9..d452ecd08 100644 --- a/openHABWatch/Views/Rows/SegmentSelectionView.swift +++ b/openHABWatch/Views/Rows/SegmentSelectionView.swift @@ -53,6 +53,12 @@ struct SegmentSelectionView: View { } } + init(widget: OpenHABWidget, selectedIndex: Binding) { + self.widget = widget + _selectedIndex = selectedIndex + _viewModel = State(wrappedValue: WidgetRowViewModel(widget: widget)) + } + @ViewBuilder private func standardButton(for mapping: OpenHABWidgetMapping, at index: Int) -> some View { Button { @@ -136,12 +142,6 @@ struct SegmentSelectionView: View { dismiss() } } - - init(widget: OpenHABWidget, selectedIndex: Binding) { - self.widget = widget - _selectedIndex = selectedIndex - _viewModel = State(wrappedValue: WidgetRowViewModel(widget: widget)) - } } #Preview { diff --git a/openHABWatch/Views/Rows/SelectionRow.swift b/openHABWatch/Views/Rows/SelectionRow.swift index 74d0099db..98a78bd28 100644 --- a/openHABWatch/Views/Rows/SelectionRow.swift +++ b/openHABWatch/Views/Rows/SelectionRow.swift @@ -80,6 +80,12 @@ struct SelectionListView: View { } } + init(widget: OpenHABWidget, selectedIndex: Binding) { + self.widget = widget + _selectedIndex = selectedIndex + _viewModel = State(wrappedValue: WidgetRowViewModel(widget: widget)) + } + private func selectOption(at index: Int) { viewModel.selectedIndex = index selectedIndex = viewModel.selectedIndex @@ -93,12 +99,6 @@ struct SelectionListView: View { dismiss() } } - - init(widget: OpenHABWidget, selectedIndex: Binding) { - self.widget = widget - _selectedIndex = selectedIndex - _viewModel = State(wrappedValue: WidgetRowViewModel(widget: widget)) - } } struct SelectionRow: View { @@ -126,7 +126,7 @@ struct SelectionRow: View { var body: some View { HStack { HStack { - IconView(widget: widget, settings: settings) + WatchIconView(model: widget.iconRenderModel(), settings: settings) WatchLabelText(widget: widget) Spacer() } diff --git a/openHABWatch/Views/Rows/SetpointRow.swift b/openHABWatch/Views/Rows/SetpointRow.swift index 1124b13ec..6e4698545 100644 --- a/openHABWatch/Views/Rows/SetpointRow.swift +++ b/openHABWatch/Views/Rows/SetpointRow.swift @@ -30,7 +30,7 @@ struct SetpointRow: View { var body: some View { VStack(spacing: 5) { HStack { - IconView(widget: widget, settings: settings) + WatchIconView(model: widget.iconRenderModel(), settings: settings) WatchLabelText(widget: widget) Spacer() } @@ -71,6 +71,11 @@ struct SetpointRow: View { } } + init(widget: OpenHABWidget) { + self.widget = widget + _viewModel = State(wrappedValue: WidgetRowViewModel(widget: widget)) + } + private func handleUpDown(isDecreasing: Bool) { var numberState = viewModel.numberState let currentValue = numberState?.value ?? viewModel.minValue @@ -102,11 +107,6 @@ struct SetpointRow: View { func increaseValue() { handleUpDown(isDecreasing: false) } - - init(widget: OpenHABWidget) { - self.widget = widget - _viewModel = State(wrappedValue: WidgetRowViewModel(widget: widget)) - } } #Preview { diff --git a/openHABWatch/Views/Rows/SliderRow.swift b/openHABWatch/Views/Rows/SliderRow.swift index 4a18cc5cd..e72f19dc4 100644 --- a/openHABWatch/Views/Rows/SliderRow.swift +++ b/openHABWatch/Views/Rows/SliderRow.swift @@ -79,7 +79,7 @@ struct SliderRow: View { if viewModel.switchSupport { Toggle(isOn: stateBinding) { HStack { - IconView(widget: widget, settings: settings, fallbackSymbol: fallbackSymbol) + WatchIconView(model: widget.iconRenderModel(fallbackSymbol: fallbackSymbol), settings: settings) VStack(alignment: .leading) { WatchLabelText(widget: widget) if pendingValue != nil { @@ -99,7 +99,7 @@ struct SliderRow: View { .cornerRadius(5) } else { HStack { - IconView(widget: widget, settings: settings, fallbackSymbol: fallbackSymbol) + WatchIconView(model: widget.iconRenderModel(fallbackSymbol: fallbackSymbol), settings: settings) WatchLabelText(widget: widget) Spacer() if pendingValue != nil { diff --git a/openHABWatch/Views/Rows/SwitchRow.swift b/openHABWatch/Views/Rows/SwitchRow.swift index b89500798..237a95779 100644 --- a/openHABWatch/Views/Rows/SwitchRow.swift +++ b/openHABWatch/Views/Rows/SwitchRow.swift @@ -37,7 +37,7 @@ struct SwitchRow: View { } )) { HStack { - IconView(widget: widget, settings: settings) + WatchIconView(model: widget.iconRenderModel(), settings: settings) VStack { WatchLabelText(widget: widget) DetailTextLabelView(widget: widget) diff --git a/openHABWatch/Views/Rows/TextRow.swift b/openHABWatch/Views/Rows/TextRow.swift index bb58d0571..2daaca0db 100644 --- a/openHABWatch/Views/Rows/TextRow.swift +++ b/openHABWatch/Views/Rows/TextRow.swift @@ -21,7 +21,7 @@ struct TextRow: View { var body: some View { HStack { - IconView(widget: widget, settings: settings) + WatchIconView(model: widget.iconRenderModel(), settings: settings) WatchLabelText(widget: widget) Spacer() DetailTextLabelView(widget: widget) diff --git a/openHABWatch/Views/Utils/IconView.swift b/openHABWatch/Views/Utils/WatchIconView.swift similarity index 65% rename from openHABWatch/Views/Utils/IconView.swift rename to openHABWatch/Views/Utils/WatchIconView.swift index 2e924908b..fa74e5838 100644 --- a/openHABWatch/Views/Utils/IconView.swift +++ b/openHABWatch/Views/Utils/WatchIconView.swift @@ -15,40 +15,37 @@ import os.log import SFSafeSymbols import SwiftUI -struct IconView: View { - @ObservedObject var widget: OpenHABWidget - @ObservedObject var settings = AppSettings.shared - @ObservedObject private var networkTracker = MainActorNetworkTracker.shared - /// Optional SF Symbol to show as fallback when network icon is unavailable (useful for previews) +struct IconRenderModel: Equatable { + var icon: String + var iconState: String? + var iconColorHex: String + var staticIcon: Bool? + var stateToken: String var fallbackSymbol: SFSymbol? +} - @State private var imageLoadingFailed = false - @State private var retryCount = 0 - private let maxRetries = 3 - private let retryDelay: TimeInterval = 1.0 - - var iconColor: String { - let logicColor = !(widget.iconColor.isEmpty) ? UIColor(fromString: widget.iconColor) : .ohBlack - return logicColor.semanticColorToHex() ?? "#FFFFFF" - } +struct WatchIconView: View { + let model: IconRenderModel + @ObservedObject var settings = AppSettings.shared + @ObservedObject private var networkTracker = MainActorNetworkTracker.shared var iconURL: URL? { - guard !widget.icon.isEmpty, + guard !model.icon.isEmpty, let activeConnection = networkTracker.activeConnection, !activeConnection.configuration.url.isEmpty else { return nil } // Skip loading number icons as they don't exist/aren't useful - if widget.icon == "number" { + if model.icon == "number" { return nil } return Endpoint.icon( rootUrl: activeConnection.configuration.url, version: activeConnection.version, - icon: widget.icon, - state: widget.iconState(), + icon: model.icon, + state: model.iconState, iconType: settings.iconType, - iconColor: iconColor, - staticIcon: widget.staticIcon + iconColor: model.iconColorHex, + staticIcon: model.staticIcon )?.url } @@ -56,7 +53,7 @@ struct IconView: View { Group { if let iconURL { // Only apply color preprocessing for non-iconify icons - let processorIconColor = iconURL.host == "api.iconify.design" ? nil : iconColor + let processorIconColor = iconURL.host == "api.iconify.design" ? nil : model.iconColorHex KFImage.url(iconURL) .onFailure { _ in Logger.rowViews.debug("Failed to load image : \(iconURL.absoluteString)") @@ -71,8 +68,8 @@ struct IconView: View { .resizable() .aspectRatio(contentMode: .fit) .frame(width: 20, height: 20) - .id("\(iconURL.absoluteString)-\(widget.item?.state ?? "")-\(widget.iconColor)") - } else if let fallbackSymbol { + .id("\(iconURL.absoluteString)-\(model.stateToken)-\(model.iconColorHex)") + } else if let fallbackSymbol = model.fallbackSymbol { Image(systemSymbol: fallbackSymbol) .resizable() .aspectRatio(contentMode: .fit) @@ -84,34 +81,6 @@ struct IconView: View { .frame(width: 20, height: 20) } } - .onChange(of: widget.icon) { - resetLoadingState() - } - .onChange(of: widget.iconState()) { - resetLoadingState() - } - .onChange(of: networkTracker.activeConnection) { - resetLoadingState() - } - } - - private func handleLoadingFailure() { - if retryCount < maxRetries { - retryCount += 1 - Logger.rowViews.info("Retrying icon load for widget \(widget.label), attempt \(retryCount)/\(maxRetries)") - - DispatchQueue.main.asyncAfter(deadline: .now() + retryDelay * Double(retryCount)) { - imageLoadingFailed = false - } - } else { - Logger.rowViews.warning("Max retries reached for widget \(widget.label), giving up") - imageLoadingFailed = true - } - } - - private func resetLoadingState() { - imageLoadingFailed = false - retryCount = 0 } } @@ -132,8 +101,8 @@ struct IconView: View { let settings = AppSettings(debug: true, openHABRootUrl: localTestingURL) let widget = UserData(preview: true).widgets[4] - IconView( - widget: widget, + WatchIconView( + model: widget.iconRenderModel(), settings: settings ) @@ -144,8 +113,8 @@ struct IconView: View { .frame(width: 20, height: 20) let widget2 = UserData(preview: true).widgets[11] - IconView( - widget: widget2, + WatchIconView( + model: widget2.iconRenderModel(), settings: settings ) } diff --git a/openHABWatch/Views/Utils/WatchTypography.swift b/openHABWatch/Views/Utils/WatchTypography.swift index 7af9ac5c3..64e1a88f0 100644 --- a/openHABWatch/Views/Utils/WatchTypography.swift +++ b/openHABWatch/Views/Utils/WatchTypography.swift @@ -13,6 +13,20 @@ import CommonUI import OpenHABCore import SwiftUI +struct WatchLabelText: View { + @ObservedObject var widget: OpenHABWidget + + var body: some View { + TextLabelView(widget: widget, font: WatchTypography.labelFont, lineLimit: WatchTypography.labelLineLimit) + .minimumScaleFactor(WatchTypography.labelMinScale) + .truncationMode(.tail) + } + + init(widget: OpenHABWidget) { + self.widget = widget + } +} + enum WatchTypography { static let labelFont: Font = .caption static let labelLineLimit = 2 @@ -36,17 +50,3 @@ enum WatchTypography { static let emphasisFont: Font = .headline } - -struct WatchLabelText: View { - @ObservedObject var widget: OpenHABWidget - - var body: some View { - TextLabelView(widget: widget, font: WatchTypography.labelFont, lineLimit: WatchTypography.labelLineLimit) - .minimumScaleFactor(WatchTypography.labelMinScale) - .truncationMode(.tail) - } - - init(widget: OpenHABWidget) { - self.widget = widget - } -} From cf4d6542aad4a38802a6d883a9c420c3aa1cbf5f Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Tue, 10 Feb 2026 07:14:56 +0100 Subject: [PATCH 439/476] Simplify SwitchRow and SelectionRow with direct data passing Remove WidgetRowViewModel from SwitchRow and SelectionRow by passing state and mappings directly through init parameters, reducing indirection and unnecessary observation overhead. Signed-off-by: Tim Mueller-Seydlitz --- openHABWatch/Model/WidgetRowFactory.swift | 12 ++- openHABWatch/Views/Rows/SelectionRow.swift | 91 ++++++++++------------ openHABWatch/Views/Rows/SwitchRow.swift | 23 ++---- 3 files changed, 57 insertions(+), 69 deletions(-) diff --git a/openHABWatch/Model/WidgetRowFactory.swift b/openHABWatch/Model/WidgetRowFactory.swift index 64a767bf8..af4195cca 100644 --- a/openHABWatch/Model/WidgetRowFactory.swift +++ b/openHABWatch/Model/WidgetRowFactory.swift @@ -21,13 +21,13 @@ enum WidgetRowFactory { if !widget.mappings.isEmpty { SegmentRow(widget: widget) } else if widget.item?.isOfTypeOrGroupType(.switchItem) ?? false { - SwitchRow(widget: widget) + SwitchRow(widget: widget, effectiveState: widget.state.isEmpty ? (widget.item?.state ?? "") : widget.state) } else if widget.item?.isOfTypeOrGroupType(.rollershutter) ?? false { RollershutterRow(widget: widget) } else if !widget.mappingsOrItemOptions.isEmpty { SegmentRow(widget: widget) } else { - SwitchRow(widget: widget) + SwitchRow(widget: widget, effectiveState: widget.state.isEmpty ? (widget.item?.state ?? "") : widget.state) } case .slider: SliderRow(widget: widget) @@ -60,7 +60,13 @@ enum WidgetRowFactory { case .colorpicker: ColorPickerRow(widget: widget) case .selection: - SelectionRow(widget: widget) + SelectionRow( + widget: widget, + mappings: widget.mappingsOrItemOptions, + title: widget.labelText ?? "Select", + initialSelectedIndex: widget.mappingIndex(byCommand: widget.item?.state).map { Int($0) }, + labelValue: widget.labelValue + ) case .video, .webview, .input, .colortemperaturepicker, .buttongrid: // Not yet implemented for watchOS GenericRow(widget: widget) diff --git a/openHABWatch/Views/Rows/SelectionRow.swift b/openHABWatch/Views/Rows/SelectionRow.swift index 98a78bd28..22008c7ba 100644 --- a/openHABWatch/Views/Rows/SelectionRow.swift +++ b/openHABWatch/Views/Rows/SelectionRow.swift @@ -16,20 +16,13 @@ import SwiftUI /// Selection list view for picking from available options struct SelectionListView: View { - @ObservedObject var widget: OpenHABWidget + let widget: OpenHABWidget + let mappings: [OpenHABWidgetMapping] + let title: String @Binding var selectedIndex: Int? @Environment(\.dismiss) private var dismiss - @State private var viewModel: WidgetRowViewModel @State private var commandSender = WidgetCommandSender() - private var mappings: [OpenHABWidgetMapping] { - viewModel.mappings - } - - private var currentIndex: Int? { - viewModel.selectedIndex - } - var body: some View { ScrollView { LazyVStack(spacing: 12) { @@ -47,7 +40,7 @@ struct SelectionListView: View { .minimumScaleFactor(WatchTypography.labelMinScale) .truncationMode(.tail) Spacer() - if currentIndex == index { + if selectedIndex == index { Image(systemSymbol: .checkmark) .foregroundStyle(Color.accentColor) .font(.caption.weight(.bold)) @@ -56,7 +49,7 @@ struct SelectionListView: View { .padding() .background( RoundedRectangle(cornerRadius: 8) - .fill(currentIndex == index ? Color.accentColor.opacity(0.2) : Color.clear) + .fill(selectedIndex == index ? Color.accentColor.opacity(0.2) : Color.clear) ) .overlay( RoundedRectangle(cornerRadius: 8) @@ -68,32 +61,17 @@ struct SelectionListView: View { } .padding() } - .navigationTitle(widget.labelText ?? "Select") + .navigationTitle(title) .navigationBarTitleDisplayMode(.inline) - .onAppear { - viewModel.update(from: widget) - selectedIndex = viewModel.selectedIndex - } - .onChange(of: widget.item?.state, initial: false) { _, _ in - viewModel.update(from: widget) - selectedIndex = viewModel.selectedIndex - } - } - - init(widget: OpenHABWidget, selectedIndex: Binding) { - self.widget = widget - _selectedIndex = selectedIndex - _viewModel = State(wrappedValue: WidgetRowViewModel(widget: widget)) } private func selectOption(at index: Int) { - viewModel.selectedIndex = index - selectedIndex = viewModel.selectedIndex + selectedIndex = index if let selectedCommand = mappings[safe: index]?.command { commandSender.send( selectedCommand, for: widget, - policy: WidgetCommandDefaults.immediate, + policy: .immediate, key: "selection-list" ) dismiss() @@ -102,24 +80,28 @@ struct SelectionListView: View { } struct SelectionRow: View { - @ObservedObject var widget: OpenHABWidget + let widget: OpenHABWidget + let mappings: [OpenHABWidgetMapping] + let title: String + let initialSelectedIndex: Int? + let labelValue: String? @EnvironmentObject var settings: AppSettings - @State private var viewModel: WidgetRowViewModel + @State private var selectedIndex: Int? /// Returns the label of the currently selected mapping private var selectedValueText: String? { - if let index = viewModel.selectedIndex, + if let index = selectedIndex, index >= 0, - index < viewModel.mappings.count { - return viewModel.mappings[index].label + index < mappings.count { + return mappings[index].label } - return viewModel.labelValue + return labelValue } private var selectedIndexBinding: Binding { Binding( - get: { viewModel.selectedIndex }, - set: { viewModel.selectedIndex = $0 } + get: { selectedIndex }, + set: { selectedIndex = $0 } ) } @@ -130,7 +112,12 @@ struct SelectionRow: View { WatchLabelText(widget: widget) Spacer() } - NavigationLink(destination: LazyView(SelectionListView(widget: widget, selectedIndex: selectedIndexBinding))) { + NavigationLink(destination: LazyView(SelectionListView( + widget: widget, + mappings: mappings, + title: title, + selectedIndex: selectedIndexBinding + ))) { HStack(spacing: 4) { if let valueText = selectedValueText { Text(valueText) @@ -148,17 +135,12 @@ struct SelectionRow: View { .buttonStyle(.plain) } .onAppear { - viewModel.update(from: widget) + selectedIndex = initialSelectedIndex } - .onChange(of: widget.item?.state, initial: false) { _, _ in - viewModel.update(from: widget) + .onChange(of: initialSelectedIndex) { _, newValue in + selectedIndex = newValue } } - - init(widget: OpenHABWidget) { - self.widget = widget - _viewModel = State(wrappedValue: WidgetRowViewModel(widget: widget)) - } } #Preview { @@ -168,7 +150,13 @@ struct SelectionRow: View { selectedIndex: 1 ) PreviewNavigationContainer { - SelectionRow(widget: widget) + SelectionRow( + widget: widget, + mappings: widget.mappingsOrItemOptions, + title: widget.labelText ?? "Select", + initialSelectedIndex: widget.mappingIndex(byCommand: widget.item?.state).map { Int($0) }, + labelValue: widget.labelValue + ) } } @@ -180,6 +168,11 @@ struct SelectionRow: View { selectedIndex: 0 ) PreviewNavigationContainer { - SelectionListView(widget: widget, selectedIndex: $selectedIndex) + SelectionListView( + widget: widget, + mappings: widget.mappingsOrItemOptions, + title: widget.labelText ?? "Select", + selectedIndex: $selectedIndex + ) } } diff --git a/openHABWatch/Views/Rows/SwitchRow.swift b/openHABWatch/Views/Rows/SwitchRow.swift index 237a95779..80bcf85c4 100644 --- a/openHABWatch/Views/Rows/SwitchRow.swift +++ b/openHABWatch/Views/Rows/SwitchRow.swift @@ -14,14 +14,14 @@ import OpenHABCore import SwiftUI struct SwitchRow: View { - @ObservedObject var widget: OpenHABWidget + let widget: OpenHABWidget @EnvironmentObject var settings: AppSettings + let effectiveState: String @State private var localIsOn: Bool? - @State private var viewModel: WidgetRowViewModel @State private var commandSender = WidgetCommandSender() private var isOn: Bool { - localIsOn ?? viewModel.isOn + localIsOn ?? effectiveState.parseAsBool() } var body: some View { @@ -46,27 +46,16 @@ struct SwitchRow: View { } .padding(.trailing) .cornerRadius(5) - .onAppear { - viewModel.update(from: widget) - } - .onChange(of: widget.item?.state, initial: false) { _, _ in - viewModel.update(from: widget) - } - .onChange(of: viewModel.effectiveState) { + .onChange(of: effectiveState) { localIsOn = nil } } - - init(widget: OpenHABWidget) { - self.widget = widget - _viewModel = State(wrappedValue: WidgetRowViewModel(widget: widget)) - } } #Preview { let widget = PreviewWidgetFactory.switchWidget(label: "Outdoor Light", state: "ON") PreviewNavigationContainer { - SwitchRow(widget: widget) + SwitchRow(widget: widget, effectiveState: widget.item?.state ?? "OFF") } } @@ -78,7 +67,7 @@ struct SwitchRow: View { return obj }() NavigationStack { - SwitchRow(widget: widget) + SwitchRow(widget: widget, effectiveState: widget.item?.state ?? "OFF") } .environmentObject(mockSettings) } From 456c620e7a63b6b2f933d7782cc31e9e492ca49b Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Tue, 10 Feb 2026 07:19:47 +0100 Subject: [PATCH 440/476] Simplify SliderRow, SetpointRow, and SegmentSelectionView with direct data passing Remove WidgetRowViewModel from SliderRow, SetpointRow, and SegmentSelectionView by passing configuration and state directly through init parameters, completing the migration away from unnecessary view model indirection. Signed-off-by: Tim Mueller-Seydlitz --- openHABWatch/Model/WidgetRowFactory.swift | 21 ++++- openHABWatch/Views/Rows/SegmentRow.swift | 8 +- .../Views/Rows/SegmentSelectionView.swift | 53 +++++------ openHABWatch/Views/Rows/SetpointRow.swift | 91 +++++++++++++------ openHABWatch/Views/Rows/SliderRow.swift | 81 +++++++++++++---- 5 files changed, 181 insertions(+), 73 deletions(-) diff --git a/openHABWatch/Model/WidgetRowFactory.swift b/openHABWatch/Model/WidgetRowFactory.swift index af4195cca..6d4530d25 100644 --- a/openHABWatch/Model/WidgetRowFactory.swift +++ b/openHABWatch/Model/WidgetRowFactory.swift @@ -30,9 +30,26 @@ enum WidgetRowFactory { SwitchRow(widget: widget, effectiveState: widget.state.isEmpty ? (widget.item?.state ?? "") : widget.state) } case .slider: - SliderRow(widget: widget) + SliderRow( + widget: widget, + adjustedValue: widget.adjustedValue, + minValue: widget.minValue, + maxValue: widget.maxValue, + step: widget.step, + switchSupport: widget.switchSupport + ) case .setpoint: - SetpointRow(widget: widget) + SetpointRow( + widget: widget, + title: widget.labelText ?? widget.label, + minValue: widget.minValue, + maxValue: widget.maxValue, + step: widget.step, + stateValue: widget.stateValueAsNumberState?.value, + labelValue: widget.labelValue, + unit: widget.unit, + stateToken: widget.item?.state ?? widget.state + ) case .frame: FrameRow(title: widget.labelText ?? "") case .text: diff --git a/openHABWatch/Views/Rows/SegmentRow.swift b/openHABWatch/Views/Rows/SegmentRow.swift index d969ae6ad..ef7bbea78 100644 --- a/openHABWatch/Views/Rows/SegmentRow.swift +++ b/openHABWatch/Views/Rows/SegmentRow.swift @@ -126,7 +126,13 @@ struct SegmentRow: View { WatchLabelText(widget: widget) Spacer() } - NavigationLink(destination: LazyView(SegmentSelectionView(widget: widget, selectedIndex: selectedIndexBinding))) { + NavigationLink(destination: LazyView(SegmentSelectionView( + widget: widget, + mappings: viewModel.mappings, + title: viewModel.labelText, + hasPressReleaseMappings: viewModel.hasPressReleaseMappings, + selectedIndex: selectedIndexBinding + ))) { HStack { if let currentIndex, currentIndex >= 0, currentIndex < viewModel.mappings.count { Text(viewModel.mappings[currentIndex].label) diff --git a/openHABWatch/Views/Rows/SegmentSelectionView.swift b/openHABWatch/Views/Rows/SegmentSelectionView.swift index d452ecd08..42c097237 100644 --- a/openHABWatch/Views/Rows/SegmentSelectionView.swift +++ b/openHABWatch/Views/Rows/SegmentSelectionView.swift @@ -13,24 +13,22 @@ import OpenHABCore import SwiftUI struct SegmentSelectionView: View { - @ObservedObject var widget: OpenHABWidget + let widget: OpenHABWidget + let mappings: [OpenHABWidgetMapping] + let title: String + let hasPressReleaseMappings: Bool @Binding var selectedIndex: Int? @Environment(\.dismiss) private var dismiss @State private var pressedIndex: Int? - @State private var viewModel: WidgetRowViewModel @State private var commandSender = WidgetCommandSender() - private var currentIndex: Int? { - viewModel.selectedIndex - } - var body: some View { ScrollView { LazyVStack(spacing: 12) { - ForEach(0 ..< viewModel.mappings.count, id: \.self) { index in - let mapping = viewModel.mappings[index] + ForEach(0 ..< mappings.count, id: \.self) { index in + let mapping = mappings[index] - if viewModel.hasPressReleaseMappings { + if hasPressReleaseMappings { // Press-release button for mappings with releaseCommand pressReleaseButton(for: mapping, at: index) } else { @@ -41,22 +39,20 @@ struct SegmentSelectionView: View { } .padding() } - .navigationTitle("Select Option") + .navigationTitle(title) .navigationBarTitleDisplayMode(.inline) - .onAppear { - viewModel.update(from: widget) - selectedIndex = viewModel.selectedIndex - } - .onChange(of: widget.item?.state, initial: false) { _, _ in - viewModel.update(from: widget) - selectedIndex = viewModel.selectedIndex - } } - init(widget: OpenHABWidget, selectedIndex: Binding) { + init(widget: OpenHABWidget, + mappings: [OpenHABWidgetMapping], + title: String, + hasPressReleaseMappings: Bool, + selectedIndex: Binding) { self.widget = widget + self.mappings = mappings + self.title = title + self.hasPressReleaseMappings = hasPressReleaseMappings _selectedIndex = selectedIndex - _viewModel = State(wrappedValue: WidgetRowViewModel(widget: widget)) } @ViewBuilder @@ -98,7 +94,7 @@ struct SegmentSelectionView: View { .minimumScaleFactor(WatchTypography.labelMinScale) .truncationMode(.tail) Spacer() - if isSelected(index: index), !viewModel.hasPressReleaseMappings { + if isSelected(index: index), !hasPressReleaseMappings { Image(systemSymbol: .checkmark) .foregroundStyle(Color.accentColor) .font(.caption.weight(.bold)) @@ -126,13 +122,12 @@ struct SegmentSelectionView: View { } private func isSelected(index: Int) -> Bool { - currentIndex == index + selectedIndex == index } private func selectOption(at index: Int) { - viewModel.selectedIndex = index - selectedIndex = viewModel.selectedIndex - if let selectedCommand = viewModel.mappings[safe: index]?.command { + selectedIndex = index + if let selectedCommand = mappings[safe: index]?.command { commandSender.send( selectedCommand, for: widget, @@ -157,6 +152,12 @@ struct SegmentSelectionView: View { icon: "temperature" ) return PreviewNavigationContainer { - SegmentSelectionView(widget: widget, selectedIndex: $selectedIndex) + SegmentSelectionView( + widget: widget, + mappings: widget.mappingsOrItemOptions, + title: "Select Option", + hasPressReleaseMappings: widget.hasPressReleaseMappings, + selectedIndex: $selectedIndex + ) } } diff --git a/openHABWatch/Views/Rows/SetpointRow.swift b/openHABWatch/Views/Rows/SetpointRow.swift index 6e4698545..a2da2a189 100644 --- a/openHABWatch/Views/Rows/SetpointRow.swift +++ b/openHABWatch/Views/Rows/SetpointRow.swift @@ -16,22 +16,42 @@ import SFSafeSymbols import SwiftUI struct SetpointRow: View { - @ObservedObject var widget: OpenHABWidget + let widget: OpenHABWidget + let title: String + let minValue: Double + let maxValue: Double + let step: Double + let stateValue: Double? + let labelValue: String? + let unit: String? + let stateToken: String @EnvironmentObject var settings: AppSettings private let setpointService = SetPointService() private let logger = Logger(subsystem: "org.openhab.watch", category: "SetpointRow") - @State private var viewModel: WidgetRowViewModel + @State private var localValue: Double? @State private var commandSender = WidgetCommandSender() private var currentValue: Double { - viewModel.numberState?.value ?? viewModel.minValue + localValue ?? stateValue ?? minValue + } + + private var valueText: String { + let value = currentValue.valueText(step: step) + if let unit, !unit.isEmpty { + return "\(value) \(unit)" + } + return value } var body: some View { VStack(spacing: 5) { HStack { WatchIconView(model: widget.iconRenderModel(), settings: settings) - WatchLabelText(widget: widget) + Text(title) + .font(WatchTypography.labelFont) + .lineLimit(WatchTypography.labelLineLimit) + .minimumScaleFactor(WatchTypography.labelMinScale) + .truncationMode(.tail) Spacer() } HStack { @@ -40,14 +60,14 @@ struct SetpointRow: View { Button(action: decreaseValue) { Image(systemSymbol: .chevronDownCircleFill) .font(.system(size: 25)) - .foregroundStyle(currentValue <= viewModel.minValue ? Color.gray : Color.blue) + .foregroundStyle(currentValue <= minValue ? Color.gray : Color.blue) } .buttonStyle(.plain) - .disabled(currentValue <= viewModel.minValue) + .disabled(currentValue <= minValue) Spacer() - DetailTextLabelView(widget: widget) + Text(localValue == nil ? (labelValue ?? valueText) : valueText) .font(WatchTypography.emphasisFont) Spacer() @@ -55,36 +75,45 @@ struct SetpointRow: View { Button(action: increaseValue) { Image(systemSymbol: .chevronUpCircleFill) .font(.system(size: 25)) - .foregroundStyle(currentValue >= viewModel.maxValue ? Color.gray : Color.blue) + .foregroundStyle(currentValue >= maxValue ? Color.gray : Color.blue) } .buttonStyle(.plain) - .disabled(currentValue >= viewModel.maxValue) + .disabled(currentValue >= maxValue) Spacer() } } - .onAppear { - viewModel.update(from: widget) - } - .onChange(of: widget.item?.state, initial: false) { _, _ in - viewModel.update(from: widget) + .onChange(of: stateToken, initial: false) { _, _ in + localValue = nil } } - init(widget: OpenHABWidget) { + init(widget: OpenHABWidget, + title: String, + minValue: Double, + maxValue: Double, + step: Double, + stateValue: Double?, + labelValue: String?, + unit: String?, + stateToken: String) { self.widget = widget - _viewModel = State(wrappedValue: WidgetRowViewModel(widget: widget)) + self.title = title + self.minValue = minValue + self.maxValue = maxValue + self.step = step + self.stateValue = stateValue + self.labelValue = labelValue + self.unit = unit + self.stateToken = stateToken } private func handleUpDown(isDecreasing: Bool) { - var numberState = viewModel.numberState - let currentValue = numberState?.value ?? viewModel.minValue - let limitedNewValue = setpointService.calculateNewValue( currentValue: currentValue, - step: viewModel.step, - minValue: viewModel.minValue, - maxValue: viewModel.maxValue, + step: step, + minValue: minValue, + maxValue: maxValue, isDecreasing: isDecreasing ) @@ -93,10 +122,10 @@ struct SetpointRow: View { return } - numberState = numberState ?? NumberState(value: limitedNewValue, unit: widget.unit) - numberState?.value = limitedNewValue + localValue = limitedNewValue + let numberState = NumberState(value: limitedNewValue, unit: unit) - logger.info("Setpoint \(isDecreasing ? "decreased" : "increased") to \(numberState?.description ?? String(limitedNewValue))") + logger.info("Setpoint \(isDecreasing ? "decreased" : "increased") to \(numberState.description)") commandSender.sendItemUpdate(numberState, for: widget) } @@ -119,6 +148,16 @@ struct SetpointRow: View { unit: "°C" ) PreviewNavigationContainer { - SetpointRow(widget: widget) + SetpointRow( + widget: widget, + title: widget.labelText ?? widget.label, + minValue: widget.minValue, + maxValue: widget.maxValue, + step: widget.step, + stateValue: widget.stateValueAsNumberState?.value, + labelValue: widget.labelValue, + unit: widget.unit, + stateToken: widget.item?.state ?? widget.state + ) } } diff --git a/openHABWatch/Views/Rows/SliderRow.swift b/openHABWatch/Views/Rows/SliderRow.swift index e72f19dc4..b1f064774 100644 --- a/openHABWatch/Views/Rows/SliderRow.swift +++ b/openHABWatch/Views/Rows/SliderRow.swift @@ -16,31 +16,35 @@ import SFSafeSymbols import SwiftUI struct SliderRow: View { - @ObservedObject var widget: OpenHABWidget + let widget: OpenHABWidget + let adjustedValue: Double + let minValue: Double + let maxValue: Double + let step: Double + let switchSupport: Bool @EnvironmentObject var settings: AppSettings var fallbackSymbol: SFSymbol? @State private var pendingValue: Double? - @State private var viewModel: WidgetRowViewModel @State private var commandSender = WidgetCommandSender() private var currentValue: Double { - pendingValue ?? viewModel.adjustedValue + pendingValue ?? adjustedValue } private var currentValueText: String { - currentValue.valueText(step: viewModel.step) + currentValue.valueText(step: step) } var valueBinding: Binding { .init( get: { - pendingValue ?? viewModel.adjustedValue + pendingValue ?? adjustedValue }, set: { newValue in Logger.rowViews.info("SliderRow new value = \(newValue)") pendingValue = newValue commandSender.send( - newValue.valueText(step: viewModel.step), + newValue.valueText(step: step), for: widget, policy: WidgetCommandDefaults.slider, key: "slider-value" @@ -52,19 +56,19 @@ struct SliderRow: View { private var stateBinding: Binding { Binding( get: { - viewModel.adjustedValue > viewModel.minValue + adjustedValue > minValue }, set: { newValue in if newValue { commandSender.send( - viewModel.maxValue.valueText(step: viewModel.step), + maxValue.valueText(step: step), for: widget, policy: .immediate, key: "slider-toggle" ) } else { commandSender.send( - viewModel.minValue.valueText(step: viewModel.step), + minValue.valueText(step: step), for: widget, policy: .immediate, key: "slider-toggle" @@ -76,7 +80,7 @@ struct SliderRow: View { var body: some View { VStack(spacing: 3) { - if viewModel.switchSupport { + if switchSupport { Toggle(isOn: stateBinding) { HStack { WatchIconView(model: widget.iconRenderModel(fallbackSymbol: fallbackSymbol), settings: settings) @@ -115,22 +119,28 @@ struct SliderRow: View { }.padding(.top, 8) } - Slider(value: valueBinding, in: viewModel.minValue ... viewModel.maxValue, step: viewModel.step) + Slider(value: valueBinding, in: minValue ... maxValue, step: step) .labelsHidden() } - .onAppear { - viewModel.update(from: widget) - } - .onChange(of: widget.item?.state, initial: false) { _, _ in - viewModel.update(from: widget) + .onChange(of: adjustedValue, initial: false) { _, _ in pendingValue = nil } } - init(widget: OpenHABWidget, fallbackSymbol: SFSymbol? = nil) { + init(widget: OpenHABWidget, + adjustedValue: Double, + minValue: Double, + maxValue: Double, + step: Double, + switchSupport: Bool, + fallbackSymbol: SFSymbol? = nil) { self.widget = widget + self.adjustedValue = adjustedValue + self.minValue = minValue + self.maxValue = maxValue + self.step = step + self.switchSupport = switchSupport self.fallbackSymbol = fallbackSymbol - _viewModel = State(wrappedValue: WidgetRowViewModel(widget: widget)) } } @@ -143,6 +153,11 @@ struct SliderRow: View { label: "Brightness", value: 75 ), + adjustedValue: 75, + minValue: 0, + maxValue: 100, + step: 1, + switchSupport: false, fallbackSymbol: .sliderHorizontal3 ) } @@ -158,6 +173,11 @@ struct SliderRow: View { maxValue: 28, step: 0.5 ), + adjustedValue: 16, + minValue: 16, + maxValue: 28, + step: 0.5, + switchSupport: false, fallbackSymbol: .thermometerMedium ) } @@ -171,6 +191,11 @@ struct SliderRow: View { value: 50, switchSupport: true ), + adjustedValue: 50, + minValue: 0, + maxValue: 100, + step: 1, + switchSupport: true, fallbackSymbol: .lightbulbFill ) } @@ -184,6 +209,11 @@ struct SliderRow: View { label: "Brightness", value: 75 ), + adjustedValue: 75, + minValue: 0, + maxValue: 100, + step: 1, + switchSupport: false, fallbackSymbol: .sliderHorizontal3 ) SliderRow( @@ -194,6 +224,11 @@ struct SliderRow: View { maxValue: 28, step: 0.5 ), + adjustedValue: 21, + minValue: 16, + maxValue: 28, + step: 0.5, + switchSupport: false, fallbackSymbol: .thermometerMedium ) SliderRow( @@ -202,6 +237,11 @@ struct SliderRow: View { value: 50, switchSupport: true ), + adjustedValue: 50, + minValue: 0, + maxValue: 100, + step: 1, + switchSupport: true, fallbackSymbol: .lightbulbFill ) } @@ -212,6 +252,11 @@ struct SliderRow: View { PreviewNavigationContainer { SliderRow( widget: UserData(preview: true).widgets[3], + adjustedValue: 0, + minValue: 0, + maxValue: 100, + step: 1, + switchSupport: false, fallbackSymbol: .sliderHorizontal3 ) } From b1d06bb8227d89a13fc084e0f43d2ad09f5b2339 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Tue, 10 Feb 2026 07:51:08 +0100 Subject: [PATCH 441/476] Restore WidgetRowViewModel for complex rows and unify stateToken pattern Bring back WidgetRowViewModel in SliderRow, SetpointRow, and SegmentSelectionView where derived state is needed, using a slim (widget, stateToken) init. Rename SwitchRow's effectiveState to stateToken for consistency across all row views. Signed-off-by: Tim Mueller-Seydlitz --- openHABWatch/Model/WidgetRowFactory.swift | 25 +----- openHABWatch/Views/Rows/SegmentRow.swift | 4 +- .../Views/Rows/SegmentSelectionView.swift | 40 ++++----- openHABWatch/Views/Rows/SetpointRow.swift | 65 ++++---------- openHABWatch/Views/Rows/SliderRow.swift | 85 +++++-------------- openHABWatch/Views/Rows/SwitchRow.swift | 10 +-- 6 files changed, 71 insertions(+), 158 deletions(-) diff --git a/openHABWatch/Model/WidgetRowFactory.swift b/openHABWatch/Model/WidgetRowFactory.swift index 6d4530d25..5885a2afb 100644 --- a/openHABWatch/Model/WidgetRowFactory.swift +++ b/openHABWatch/Model/WidgetRowFactory.swift @@ -21,35 +21,18 @@ enum WidgetRowFactory { if !widget.mappings.isEmpty { SegmentRow(widget: widget) } else if widget.item?.isOfTypeOrGroupType(.switchItem) ?? false { - SwitchRow(widget: widget, effectiveState: widget.state.isEmpty ? (widget.item?.state ?? "") : widget.state) + SwitchRow(widget: widget, stateToken: widget.state.isEmpty ? (widget.item?.state ?? "") : widget.state) } else if widget.item?.isOfTypeOrGroupType(.rollershutter) ?? false { RollershutterRow(widget: widget) } else if !widget.mappingsOrItemOptions.isEmpty { SegmentRow(widget: widget) } else { - SwitchRow(widget: widget, effectiveState: widget.state.isEmpty ? (widget.item?.state ?? "") : widget.state) + SwitchRow(widget: widget, stateToken: widget.state.isEmpty ? (widget.item?.state ?? "") : widget.state) } case .slider: - SliderRow( - widget: widget, - adjustedValue: widget.adjustedValue, - minValue: widget.minValue, - maxValue: widget.maxValue, - step: widget.step, - switchSupport: widget.switchSupport - ) + SliderRow(widget: widget, stateToken: widget.item?.state ?? widget.state) case .setpoint: - SetpointRow( - widget: widget, - title: widget.labelText ?? widget.label, - minValue: widget.minValue, - maxValue: widget.maxValue, - step: widget.step, - stateValue: widget.stateValueAsNumberState?.value, - labelValue: widget.labelValue, - unit: widget.unit, - stateToken: widget.item?.state ?? widget.state - ) + SetpointRow(widget: widget, stateToken: widget.item?.state ?? widget.state) case .frame: FrameRow(title: widget.labelText ?? "") case .text: diff --git a/openHABWatch/Views/Rows/SegmentRow.swift b/openHABWatch/Views/Rows/SegmentRow.swift index ef7bbea78..260c8ab9e 100644 --- a/openHABWatch/Views/Rows/SegmentRow.swift +++ b/openHABWatch/Views/Rows/SegmentRow.swift @@ -128,9 +128,7 @@ struct SegmentRow: View { } NavigationLink(destination: LazyView(SegmentSelectionView( widget: widget, - mappings: viewModel.mappings, - title: viewModel.labelText, - hasPressReleaseMappings: viewModel.hasPressReleaseMappings, + stateToken: widget.item?.state ?? widget.state, selectedIndex: selectedIndexBinding ))) { HStack { diff --git a/openHABWatch/Views/Rows/SegmentSelectionView.swift b/openHABWatch/Views/Rows/SegmentSelectionView.swift index 42c097237..a13e13f95 100644 --- a/openHABWatch/Views/Rows/SegmentSelectionView.swift +++ b/openHABWatch/Views/Rows/SegmentSelectionView.swift @@ -14,21 +14,20 @@ import SwiftUI struct SegmentSelectionView: View { let widget: OpenHABWidget - let mappings: [OpenHABWidgetMapping] - let title: String - let hasPressReleaseMappings: Bool + let stateToken: String @Binding var selectedIndex: Int? @Environment(\.dismiss) private var dismiss @State private var pressedIndex: Int? + @State private var viewModel: WidgetRowViewModel @State private var commandSender = WidgetCommandSender() var body: some View { ScrollView { LazyVStack(spacing: 12) { - ForEach(0 ..< mappings.count, id: \.self) { index in - let mapping = mappings[index] + ForEach(0 ..< viewModel.mappings.count, id: \.self) { index in + let mapping = viewModel.mappings[index] - if hasPressReleaseMappings { + if viewModel.hasPressReleaseMappings { // Press-release button for mappings with releaseCommand pressReleaseButton(for: mapping, at: index) } else { @@ -39,20 +38,23 @@ struct SegmentSelectionView: View { } .padding() } - .navigationTitle(title) + .navigationTitle(viewModel.labelText) .navigationBarTitleDisplayMode(.inline) + .onAppear { + selectedIndex = viewModel.selectedIndex + } + .onChange(of: stateToken, initial: false) { _, _ in + viewModel.update(from: widget) + selectedIndex = viewModel.selectedIndex + } } - init(widget: OpenHABWidget, - mappings: [OpenHABWidgetMapping], - title: String, - hasPressReleaseMappings: Bool, - selectedIndex: Binding) { + init(widget: OpenHABWidget, stateToken: String, selectedIndex: Binding) { self.widget = widget - self.mappings = mappings - self.title = title - self.hasPressReleaseMappings = hasPressReleaseMappings + self.stateToken = stateToken _selectedIndex = selectedIndex + let viewModel = WidgetRowViewModel(widget: widget) + _viewModel = State(wrappedValue: viewModel) } @ViewBuilder @@ -94,7 +96,7 @@ struct SegmentSelectionView: View { .minimumScaleFactor(WatchTypography.labelMinScale) .truncationMode(.tail) Spacer() - if isSelected(index: index), !hasPressReleaseMappings { + if isSelected(index: index), !viewModel.hasPressReleaseMappings { Image(systemSymbol: .checkmark) .foregroundStyle(Color.accentColor) .font(.caption.weight(.bold)) @@ -127,7 +129,7 @@ struct SegmentSelectionView: View { private func selectOption(at index: Int) { selectedIndex = index - if let selectedCommand = mappings[safe: index]?.command { + if let selectedCommand = viewModel.mappings[safe: index]?.command { commandSender.send( selectedCommand, for: widget, @@ -154,9 +156,7 @@ struct SegmentSelectionView: View { return PreviewNavigationContainer { SegmentSelectionView( widget: widget, - mappings: widget.mappingsOrItemOptions, - title: "Select Option", - hasPressReleaseMappings: widget.hasPressReleaseMappings, + stateToken: widget.item?.state ?? widget.state, selectedIndex: $selectedIndex ) } diff --git a/openHABWatch/Views/Rows/SetpointRow.swift b/openHABWatch/Views/Rows/SetpointRow.swift index a2da2a189..19c53cd0d 100644 --- a/openHABWatch/Views/Rows/SetpointRow.swift +++ b/openHABWatch/Views/Rows/SetpointRow.swift @@ -17,27 +17,21 @@ import SwiftUI struct SetpointRow: View { let widget: OpenHABWidget - let title: String - let minValue: Double - let maxValue: Double - let step: Double - let stateValue: Double? - let labelValue: String? - let unit: String? let stateToken: String @EnvironmentObject var settings: AppSettings private let setpointService = SetPointService() private let logger = Logger(subsystem: "org.openhab.watch", category: "SetpointRow") + @State private var viewModel: WidgetRowViewModel @State private var localValue: Double? @State private var commandSender = WidgetCommandSender() private var currentValue: Double { - localValue ?? stateValue ?? minValue + localValue ?? viewModel.numberState?.value ?? viewModel.minValue } private var valueText: String { - let value = currentValue.valueText(step: step) - if let unit, !unit.isEmpty { + let value = currentValue.valueText(step: viewModel.step) + if let unit = widget.unit, !unit.isEmpty { return "\(value) \(unit)" } return value @@ -47,7 +41,7 @@ struct SetpointRow: View { VStack(spacing: 5) { HStack { WatchIconView(model: widget.iconRenderModel(), settings: settings) - Text(title) + Text(viewModel.labelText) .font(WatchTypography.labelFont) .lineLimit(WatchTypography.labelLineLimit) .minimumScaleFactor(WatchTypography.labelMinScale) @@ -60,14 +54,14 @@ struct SetpointRow: View { Button(action: decreaseValue) { Image(systemSymbol: .chevronDownCircleFill) .font(.system(size: 25)) - .foregroundStyle(currentValue <= minValue ? Color.gray : Color.blue) + .foregroundStyle(currentValue <= viewModel.minValue ? Color.gray : Color.blue) } .buttonStyle(.plain) - .disabled(currentValue <= minValue) + .disabled(currentValue <= viewModel.minValue) Spacer() - Text(localValue == nil ? (labelValue ?? valueText) : valueText) + Text(localValue == nil ? (viewModel.labelValue ?? valueText) : valueText) .font(WatchTypography.emphasisFont) Spacer() @@ -75,45 +69,32 @@ struct SetpointRow: View { Button(action: increaseValue) { Image(systemSymbol: .chevronUpCircleFill) .font(.system(size: 25)) - .foregroundStyle(currentValue >= maxValue ? Color.gray : Color.blue) + .foregroundStyle(currentValue >= viewModel.maxValue ? Color.gray : Color.blue) } .buttonStyle(.plain) - .disabled(currentValue >= maxValue) + .disabled(currentValue >= viewModel.maxValue) Spacer() } } .onChange(of: stateToken, initial: false) { _, _ in + viewModel.update(from: widget) localValue = nil } } - init(widget: OpenHABWidget, - title: String, - minValue: Double, - maxValue: Double, - step: Double, - stateValue: Double?, - labelValue: String?, - unit: String?, - stateToken: String) { + init(widget: OpenHABWidget, stateToken: String) { self.widget = widget - self.title = title - self.minValue = minValue - self.maxValue = maxValue - self.step = step - self.stateValue = stateValue - self.labelValue = labelValue - self.unit = unit self.stateToken = stateToken + _viewModel = State(wrappedValue: WidgetRowViewModel(widget: widget)) } private func handleUpDown(isDecreasing: Bool) { let limitedNewValue = setpointService.calculateNewValue( currentValue: currentValue, - step: step, - minValue: minValue, - maxValue: maxValue, + step: viewModel.step, + minValue: viewModel.minValue, + maxValue: viewModel.maxValue, isDecreasing: isDecreasing ) @@ -123,7 +104,7 @@ struct SetpointRow: View { } localValue = limitedNewValue - let numberState = NumberState(value: limitedNewValue, unit: unit) + let numberState = NumberState(value: limitedNewValue, unit: widget.unit) logger.info("Setpoint \(isDecreasing ? "decreased" : "increased") to \(numberState.description)") commandSender.sendItemUpdate(numberState, for: widget) @@ -148,16 +129,6 @@ struct SetpointRow: View { unit: "°C" ) PreviewNavigationContainer { - SetpointRow( - widget: widget, - title: widget.labelText ?? widget.label, - minValue: widget.minValue, - maxValue: widget.maxValue, - step: widget.step, - stateValue: widget.stateValueAsNumberState?.value, - labelValue: widget.labelValue, - unit: widget.unit, - stateToken: widget.item?.state ?? widget.state - ) + SetpointRow(widget: widget, stateToken: widget.item?.state ?? widget.state) } } diff --git a/openHABWatch/Views/Rows/SliderRow.swift b/openHABWatch/Views/Rows/SliderRow.swift index b1f064774..74caabb82 100644 --- a/openHABWatch/Views/Rows/SliderRow.swift +++ b/openHABWatch/Views/Rows/SliderRow.swift @@ -17,34 +17,31 @@ import SwiftUI struct SliderRow: View { let widget: OpenHABWidget - let adjustedValue: Double - let minValue: Double - let maxValue: Double - let step: Double - let switchSupport: Bool + let stateToken: String @EnvironmentObject var settings: AppSettings var fallbackSymbol: SFSymbol? @State private var pendingValue: Double? + @State private var viewModel: WidgetRowViewModel @State private var commandSender = WidgetCommandSender() private var currentValue: Double { - pendingValue ?? adjustedValue + pendingValue ?? viewModel.adjustedValue } private var currentValueText: String { - currentValue.valueText(step: step) + currentValue.valueText(step: viewModel.step) } var valueBinding: Binding { .init( get: { - pendingValue ?? adjustedValue + pendingValue ?? viewModel.adjustedValue }, set: { newValue in Logger.rowViews.info("SliderRow new value = \(newValue)") pendingValue = newValue commandSender.send( - newValue.valueText(step: step), + newValue.valueText(step: viewModel.step), for: widget, policy: WidgetCommandDefaults.slider, key: "slider-value" @@ -56,19 +53,19 @@ struct SliderRow: View { private var stateBinding: Binding { Binding( get: { - adjustedValue > minValue + viewModel.adjustedValue > viewModel.minValue }, set: { newValue in if newValue { commandSender.send( - maxValue.valueText(step: step), + viewModel.maxValue.valueText(step: viewModel.step), for: widget, policy: .immediate, key: "slider-toggle" ) } else { commandSender.send( - minValue.valueText(step: step), + viewModel.minValue.valueText(step: viewModel.step), for: widget, policy: .immediate, key: "slider-toggle" @@ -80,7 +77,7 @@ struct SliderRow: View { var body: some View { VStack(spacing: 3) { - if switchSupport { + if viewModel.switchSupport { Toggle(isOn: stateBinding) { HStack { WatchIconView(model: widget.iconRenderModel(fallbackSymbol: fallbackSymbol), settings: settings) @@ -119,28 +116,20 @@ struct SliderRow: View { }.padding(.top, 8) } - Slider(value: valueBinding, in: minValue ... maxValue, step: step) + Slider(value: valueBinding, in: viewModel.minValue ... viewModel.maxValue, step: viewModel.step) .labelsHidden() } - .onChange(of: adjustedValue, initial: false) { _, _ in + .onChange(of: stateToken, initial: false) { _, _ in + viewModel.update(from: widget) pendingValue = nil } } - init(widget: OpenHABWidget, - adjustedValue: Double, - minValue: Double, - maxValue: Double, - step: Double, - switchSupport: Bool, - fallbackSymbol: SFSymbol? = nil) { + init(widget: OpenHABWidget, stateToken: String, fallbackSymbol: SFSymbol? = nil) { self.widget = widget - self.adjustedValue = adjustedValue - self.minValue = minValue - self.maxValue = maxValue - self.step = step - self.switchSupport = switchSupport + self.stateToken = stateToken self.fallbackSymbol = fallbackSymbol + _viewModel = State(wrappedValue: WidgetRowViewModel(widget: widget)) } } @@ -153,11 +142,7 @@ struct SliderRow: View { label: "Brightness", value: 75 ), - adjustedValue: 75, - minValue: 0, - maxValue: 100, - step: 1, - switchSupport: false, + stateToken: "75", fallbackSymbol: .sliderHorizontal3 ) } @@ -173,11 +158,7 @@ struct SliderRow: View { maxValue: 28, step: 0.5 ), - adjustedValue: 16, - minValue: 16, - maxValue: 28, - step: 0.5, - switchSupport: false, + stateToken: "16", fallbackSymbol: .thermometerMedium ) } @@ -191,11 +172,7 @@ struct SliderRow: View { value: 50, switchSupport: true ), - adjustedValue: 50, - minValue: 0, - maxValue: 100, - step: 1, - switchSupport: true, + stateToken: "50", fallbackSymbol: .lightbulbFill ) } @@ -209,11 +186,7 @@ struct SliderRow: View { label: "Brightness", value: 75 ), - adjustedValue: 75, - minValue: 0, - maxValue: 100, - step: 1, - switchSupport: false, + stateToken: "75", fallbackSymbol: .sliderHorizontal3 ) SliderRow( @@ -224,11 +197,7 @@ struct SliderRow: View { maxValue: 28, step: 0.5 ), - adjustedValue: 21, - minValue: 16, - maxValue: 28, - step: 0.5, - switchSupport: false, + stateToken: "21", fallbackSymbol: .thermometerMedium ) SliderRow( @@ -237,11 +206,7 @@ struct SliderRow: View { value: 50, switchSupport: true ), - adjustedValue: 50, - minValue: 0, - maxValue: 100, - step: 1, - switchSupport: true, + stateToken: "50", fallbackSymbol: .lightbulbFill ) } @@ -252,11 +217,7 @@ struct SliderRow: View { PreviewNavigationContainer { SliderRow( widget: UserData(preview: true).widgets[3], - adjustedValue: 0, - minValue: 0, - maxValue: 100, - step: 1, - switchSupport: false, + stateToken: "0", fallbackSymbol: .sliderHorizontal3 ) } diff --git a/openHABWatch/Views/Rows/SwitchRow.swift b/openHABWatch/Views/Rows/SwitchRow.swift index 80bcf85c4..d93233ada 100644 --- a/openHABWatch/Views/Rows/SwitchRow.swift +++ b/openHABWatch/Views/Rows/SwitchRow.swift @@ -16,12 +16,12 @@ import SwiftUI struct SwitchRow: View { let widget: OpenHABWidget @EnvironmentObject var settings: AppSettings - let effectiveState: String + let stateToken: String @State private var localIsOn: Bool? @State private var commandSender = WidgetCommandSender() private var isOn: Bool { - localIsOn ?? effectiveState.parseAsBool() + localIsOn ?? stateToken.parseAsBool() } var body: some View { @@ -46,7 +46,7 @@ struct SwitchRow: View { } .padding(.trailing) .cornerRadius(5) - .onChange(of: effectiveState) { + .onChange(of: stateToken) { localIsOn = nil } } @@ -55,7 +55,7 @@ struct SwitchRow: View { #Preview { let widget = PreviewWidgetFactory.switchWidget(label: "Outdoor Light", state: "ON") PreviewNavigationContainer { - SwitchRow(widget: widget, effectiveState: widget.item?.state ?? "OFF") + SwitchRow(widget: widget, stateToken: widget.item?.state ?? "OFF") } } @@ -67,7 +67,7 @@ struct SwitchRow: View { return obj }() NavigationStack { - SwitchRow(widget: widget, effectiveState: widget.item?.state ?? "OFF") + SwitchRow(widget: widget, stateToken: widget.item?.state ?? "OFF") } .environmentObject(mockSettings) } From c37ae53647f206f2bf9886a0575f704fa21e7275 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Tue, 10 Feb 2026 07:55:54 +0100 Subject: [PATCH 442/476] Migrate ColorSelection to WidgetCommandSender with debounce policy Replace manual time-based throttle in ColorSelection with WidgetCommandSender using a 200ms debounce policy for color wheel drag and immediate send on force updates. Signed-off-by: Tim Mueller-Seydlitz --- openHABWatch/Model/WidgetCommandPolicy.swift | 3 +++ openHABWatch/Views/Utils/ColorSelection.swift | 18 +++++++++--------- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/openHABWatch/Model/WidgetCommandPolicy.swift b/openHABWatch/Model/WidgetCommandPolicy.swift index d9b56ede8..ca90bfb28 100644 --- a/openHABWatch/Model/WidgetCommandPolicy.swift +++ b/openHABWatch/Model/WidgetCommandPolicy.swift @@ -14,12 +14,15 @@ import OpenHABCore enum WidgetCommandDefaults { static let slider: WidgetCommandPolicy = .debounce(.milliseconds(500)) + static let colorPicker: WidgetCommandPolicy = .debounce(.milliseconds(200)) static let immediate: WidgetCommandPolicy = .immediate static func policy(for widget: OpenHABWidget) -> WidgetCommandPolicy { switch widget.type { case .slider: slider + case .colorpicker: + colorPicker default: immediate } diff --git a/openHABWatch/Views/Utils/ColorSelection.swift b/openHABWatch/Views/Utils/ColorSelection.swift index de024d8f4..97c58961f 100644 --- a/openHABWatch/Views/Utils/ColorSelection.swift +++ b/openHABWatch/Views/Utils/ColorSelection.swift @@ -59,7 +59,7 @@ struct ColorSelection: View { @State private var xpos: Double = 100 @State private var ypos: Double = 100 @State private var dragStart: CGPoint? - @State private var lastSendTime: Date = .distantPast + @State private var commandSender = WidgetCommandSender() private let handleRadius = 12.5 @@ -210,21 +210,21 @@ struct ColorSelection: View { let maxRadius = max(1.0, Double(min(size.width, size.height) / 2)) saturation = max(0.0, min(1.0, radius / maxRadius)) if send { - let now = Date() - if force || now.timeIntervalSince(lastSendTime) > 0.2 { - lastSendTime = now - sendColorCommand() - } + sendColorCommand(force: force) } } - private func sendColorCommand() { + private func sendColorCommand(force: Bool) { let hueValue = Int(hue * 360.0) let saturationValue = Int(saturation * 100.0) let brightnessValue = Int(brightness * 100.0) let command = "\(hueValue),\(saturationValue),\(brightnessValue)" - Logger.rowViews.info("Sending color command: \(command)") - widget.sendCommand(command) + if force { + commandSender.cancelPending(for: widget, key: "color-wheel") + commandSender.send(command, for: widget, policy: .immediate, key: "color-wheel-final") + return + } + commandSender.send(command, for: widget, policy: WidgetCommandDefaults.colorPicker, key: "color-wheel") } } From 0fd73d1768ff61cfbbc22ea95e5d6bd5bbf6020a Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Tue, 10 Feb 2026 07:59:46 +0100 Subject: [PATCH 443/476] Decouple WatchLabelText and DetailTextLabelView from OpenHABWidget Make WatchLabelText and DetailTextLabelView pure presentation views that accept plain String values instead of observing OpenHABWidget directly, removing their dependency on OpenHABCore. Signed-off-by: Tim Mueller-Seydlitz --- openHABWatch/Views/Rows/ColorPickerRow.swift | 2 +- openHABWatch/Views/Rows/GenericRow.swift | 4 ++-- openHABWatch/Views/Rows/RollershutterRow.swift | 2 +- openHABWatch/Views/Rows/SegmentRow.swift | 8 ++++---- openHABWatch/Views/Rows/SelectionRow.swift | 2 +- openHABWatch/Views/Rows/SliderRow.swift | 8 ++++---- openHABWatch/Views/Rows/SwitchRow.swift | 4 ++-- openHABWatch/Views/Rows/TextRow.swift | 4 ++-- .../Views/Utils/DetailTextLabelView.swift | 18 +++++++++++------- openHABWatch/Views/Utils/WatchTypography.swift | 12 ++++-------- 10 files changed, 32 insertions(+), 32 deletions(-) diff --git a/openHABWatch/Views/Rows/ColorPickerRow.swift b/openHABWatch/Views/Rows/ColorPickerRow.swift index 5a8c91b06..efcf98c18 100644 --- a/openHABWatch/Views/Rows/ColorPickerRow.swift +++ b/openHABWatch/Views/Rows/ColorPickerRow.swift @@ -26,7 +26,7 @@ struct ColorPickerRow: View { VStack(spacing: 0) { HStack { WatchIconView(model: widget.iconRenderModel(), settings: settings) - WatchLabelText(widget: widget) + WatchLabelText(text: viewModel.labelText) Spacer() } HStack { diff --git a/openHABWatch/Views/Rows/GenericRow.swift b/openHABWatch/Views/Rows/GenericRow.swift index 5f536b674..52a097f11 100644 --- a/openHABWatch/Views/Rows/GenericRow.swift +++ b/openHABWatch/Views/Rows/GenericRow.swift @@ -20,9 +20,9 @@ struct GenericRow: View { var body: some View { HStack { WatchIconView(model: widget.iconRenderModel(), settings: settings) - WatchLabelText(widget: widget) + WatchLabelText(text: widget.labelText ?? widget.label) Spacer() - DetailTextLabelView(widget: widget) + DetailTextLabelView(text: widget.labelValue, valueColor: widget.valuecolor) widget.makeView(settings: settings) } .accessibilityLabel(widget.labelText ?? "") diff --git a/openHABWatch/Views/Rows/RollershutterRow.swift b/openHABWatch/Views/Rows/RollershutterRow.swift index d66dff437..282e9d0c5 100644 --- a/openHABWatch/Views/Rows/RollershutterRow.swift +++ b/openHABWatch/Views/Rows/RollershutterRow.swift @@ -23,7 +23,7 @@ struct RollershutterRow: View { VStack(spacing: -5) { HStack { WatchIconView(model: widget.iconRenderModel(), settings: settings) - WatchLabelText(widget: widget) + WatchLabelText(text: widget.labelText ?? widget.label) Spacer() } HStack { diff --git a/openHABWatch/Views/Rows/SegmentRow.swift b/openHABWatch/Views/Rows/SegmentRow.swift index 260c8ab9e..c9af00e83 100644 --- a/openHABWatch/Views/Rows/SegmentRow.swift +++ b/openHABWatch/Views/Rows/SegmentRow.swift @@ -50,7 +50,7 @@ struct SegmentRow: View { if viewModel.mappings.count <= 2 { HStack { WatchIconView(model: widget.iconRenderModel(), settings: settings) - WatchLabelText(widget: widget) + WatchLabelText(text: viewModel.labelText) Spacer() pressReleaseButtons .layoutPriority(1) @@ -104,7 +104,7 @@ struct SegmentRow: View { private var iconTitleRow: some View { HStack { WatchIconView(model: widget.iconRenderModel(), settings: settings) - WatchLabelText(widget: widget) + WatchLabelText(text: viewModel.labelText) Spacer() } } @@ -123,7 +123,7 @@ struct SegmentRow: View { HStack { HStack { WatchIconView(model: widget.iconRenderModel(), settings: settings) - WatchLabelText(widget: widget) + WatchLabelText(text: viewModel.labelText) Spacer() } NavigationLink(destination: LazyView(SegmentSelectionView( @@ -159,7 +159,7 @@ struct SegmentRow: View { let mapping = viewModel.mappings[0] HStack { WatchIconView(model: widget.iconRenderModel(), settings: settings) - WatchLabelText(widget: widget) + WatchLabelText(text: viewModel.labelText) Spacer() singleButton(for: mapping) .layoutPriority(1) diff --git a/openHABWatch/Views/Rows/SelectionRow.swift b/openHABWatch/Views/Rows/SelectionRow.swift index 22008c7ba..f3d980e09 100644 --- a/openHABWatch/Views/Rows/SelectionRow.swift +++ b/openHABWatch/Views/Rows/SelectionRow.swift @@ -109,7 +109,7 @@ struct SelectionRow: View { HStack { HStack { WatchIconView(model: widget.iconRenderModel(), settings: settings) - WatchLabelText(widget: widget) + WatchLabelText(text: title) Spacer() } NavigationLink(destination: LazyView(SelectionListView( diff --git a/openHABWatch/Views/Rows/SliderRow.swift b/openHABWatch/Views/Rows/SliderRow.swift index 74caabb82..ba1da38c9 100644 --- a/openHABWatch/Views/Rows/SliderRow.swift +++ b/openHABWatch/Views/Rows/SliderRow.swift @@ -82,7 +82,7 @@ struct SliderRow: View { HStack { WatchIconView(model: widget.iconRenderModel(fallbackSymbol: fallbackSymbol), settings: settings) VStack(alignment: .leading) { - WatchLabelText(widget: widget) + WatchLabelText(text: viewModel.labelText) if pendingValue != nil { Text(currentValueText) .font(WatchTypography.secondaryFont) @@ -91,7 +91,7 @@ struct SliderRow: View { .truncationMode(.tail) .foregroundStyle(.secondary) } else { - DetailTextLabelView(widget: widget) + DetailTextLabelView(text: viewModel.labelValue, valueColor: widget.valuecolor) } } } @@ -101,7 +101,7 @@ struct SliderRow: View { } else { HStack { WatchIconView(model: widget.iconRenderModel(fallbackSymbol: fallbackSymbol), settings: settings) - WatchLabelText(widget: widget) + WatchLabelText(text: viewModel.labelText) Spacer() if pendingValue != nil { Text(currentValueText) @@ -111,7 +111,7 @@ struct SliderRow: View { .truncationMode(.tail) .foregroundStyle(.secondary) } else { - DetailTextLabelView(widget: widget) + DetailTextLabelView(text: viewModel.labelValue, valueColor: widget.valuecolor) } }.padding(.top, 8) } diff --git a/openHABWatch/Views/Rows/SwitchRow.swift b/openHABWatch/Views/Rows/SwitchRow.swift index d93233ada..c84b8c8d4 100644 --- a/openHABWatch/Views/Rows/SwitchRow.swift +++ b/openHABWatch/Views/Rows/SwitchRow.swift @@ -39,8 +39,8 @@ struct SwitchRow: View { HStack { WatchIconView(model: widget.iconRenderModel(), settings: settings) VStack { - WatchLabelText(widget: widget) - DetailTextLabelView(widget: widget) + WatchLabelText(text: widget.labelText ?? widget.label) + DetailTextLabelView(text: widget.labelValue, valueColor: widget.valuecolor) } } } diff --git a/openHABWatch/Views/Rows/TextRow.swift b/openHABWatch/Views/Rows/TextRow.swift index 2daaca0db..860b5f05b 100644 --- a/openHABWatch/Views/Rows/TextRow.swift +++ b/openHABWatch/Views/Rows/TextRow.swift @@ -22,9 +22,9 @@ struct TextRow: View { var body: some View { HStack { WatchIconView(model: widget.iconRenderModel(), settings: settings) - WatchLabelText(widget: widget) + WatchLabelText(text: widget.labelText ?? widget.label) Spacer() - DetailTextLabelView(widget: widget) + DetailTextLabelView(text: widget.labelValue, valueColor: widget.valuecolor) if hasLinkedPage { Image(systemSymbol: .chevronRight) .foregroundStyle(.secondary) diff --git a/openHABWatch/Views/Utils/DetailTextLabelView.swift b/openHABWatch/Views/Utils/DetailTextLabelView.swift index 504523ef1..5e5aa8af4 100644 --- a/openHABWatch/Views/Utils/DetailTextLabelView.swift +++ b/openHABWatch/Views/Utils/DetailTextLabelView.swift @@ -10,25 +10,29 @@ // SPDX-License-Identifier: EPL-2.0 import CommonUI -import OpenHABCore import SwiftUI struct DetailTextLabelView: View { - @ObservedObject var widget: OpenHABWidget + let text: String? + let valueColor: String var body: some View { - if let label = widget.labelValue { - Text(label) + if let text { + Text(text) .font(WatchTypography.detailFont) .lineLimit(WatchTypography.detailLineLimit) .minimumScaleFactor(WatchTypography.detailMinScale) .truncationMode(.tail) - .foregroundStyle(!widget.valuecolor.isEmpty ? Color(fromString: widget.valuecolor) : .secondary) + .foregroundStyle(!valueColor.isEmpty ? Color(fromString: valueColor) : .secondary) } } + + init(text: String?, valueColor: String = "") { + self.text = text + self.valueColor = valueColor + } } #Preview { - let widget = UserData(preview: true).widgets[2] - DetailTextLabelView(widget: widget) + DetailTextLabelView(text: "450 W", valueColor: "#00AEEF") } diff --git a/openHABWatch/Views/Utils/WatchTypography.swift b/openHABWatch/Views/Utils/WatchTypography.swift index 64e1a88f0..c11eca7d1 100644 --- a/openHABWatch/Views/Utils/WatchTypography.swift +++ b/openHABWatch/Views/Utils/WatchTypography.swift @@ -9,22 +9,18 @@ // // SPDX-License-Identifier: EPL-2.0 -import CommonUI -import OpenHABCore import SwiftUI struct WatchLabelText: View { - @ObservedObject var widget: OpenHABWidget + let text: String var body: some View { - TextLabelView(widget: widget, font: WatchTypography.labelFont, lineLimit: WatchTypography.labelLineLimit) + Text(text) + .font(WatchTypography.labelFont) + .lineLimit(WatchTypography.labelLineLimit) .minimumScaleFactor(WatchTypography.labelMinScale) .truncationMode(.tail) } - - init(widget: OpenHABWidget) { - self.widget = widget - } } enum WatchTypography { From 45fb0dfacb1083629221b2658261fc4160d64ae7 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Tue, 10 Feb 2026 08:07:41 +0100 Subject: [PATCH 444/476] Add watchTextStyle modifier and accessibility labels across watchOS views Introduce WatchTextModifier with .watchTextStyle() for consistent typography application, replacing repetitive 4-line font/lineLimit/ minimumScaleFactor/truncationMode blocks. Add accessibility labels and traits to IconWithAction, setpoint buttons, segment buttons, slider, and switch views. Signed-off-by: Tim Mueller-Seydlitz --- openHABWatch/Views/Rows/ColorPickerRow.swift | 3 + openHABWatch/Views/Rows/FrameRow.swift | 5 +- .../Views/Rows/RollershutterRow.swift | 6 +- openHABWatch/Views/Rows/SegmentRow.swift | 12 ++-- .../Views/Rows/SegmentSelectionView.swift | 8 +-- openHABWatch/Views/Rows/SelectionRow.swift | 11 +-- openHABWatch/Views/Rows/SetpointRow.swift | 13 ++-- openHABWatch/Views/Rows/SliderRow.swift | 13 ++-- openHABWatch/Views/Rows/SwitchRow.swift | 1 + openHABWatch/Views/SitemapPageView.swift | 4 +- .../Views/Utils/DetailTextLabelView.swift | 5 +- openHABWatch/Views/Utils/IconWithAction.swift | 8 ++- .../Views/Utils/WatchTypography.swift | 71 +++++++++++++++++-- 13 files changed, 106 insertions(+), 54 deletions(-) diff --git a/openHABWatch/Views/Rows/ColorPickerRow.swift b/openHABWatch/Views/Rows/ColorPickerRow.swift index efcf98c18..4e89014f3 100644 --- a/openHABWatch/Views/Rows/ColorPickerRow.swift +++ b/openHABWatch/Views/Rows/ColorPickerRow.swift @@ -33,6 +33,7 @@ struct ColorPickerRow: View { Spacer() IconWithAction( systemSymbol: .chevronDownCircleFill, + accessibilityLabel: "Decrease brightness", action: downButtonPressed ) @@ -49,12 +50,14 @@ struct ColorPickerRow: View { } } } + .accessibilityLabel("Select color") Spacer() IconWithAction( systemSymbol: .chevronUpCircleFill, + accessibilityLabel: "Increase brightness", action: upButtonPressed ) Spacer() diff --git a/openHABWatch/Views/Rows/FrameRow.swift b/openHABWatch/Views/Rows/FrameRow.swift index c1a4c84db..c6f9a5665 100644 --- a/openHABWatch/Views/Rows/FrameRow.swift +++ b/openHABWatch/Views/Rows/FrameRow.swift @@ -17,10 +17,7 @@ struct FrameRow: View { var body: some View { HStack { Text(title.uppercased()) - .font(WatchTypography.sectionFont) - .lineLimit(WatchTypography.sectionLineLimit) - .minimumScaleFactor(WatchTypography.sectionMinScale) - .truncationMode(.tail) + .watchTextStyle(.section) Spacer() } } diff --git a/openHABWatch/Views/Rows/RollershutterRow.swift b/openHABWatch/Views/Rows/RollershutterRow.swift index 282e9d0c5..0a6b391bc 100644 --- a/openHABWatch/Views/Rows/RollershutterRow.swift +++ b/openHABWatch/Views/Rows/RollershutterRow.swift @@ -28,16 +28,16 @@ struct RollershutterRow: View { } HStack { Spacer() - IconWithAction(systemSymbol: .chevronUpCircleFill) { + IconWithAction(systemSymbol: .chevronUpCircleFill, accessibilityLabel: "Move up") { commandSender.send("UP", for: widget, policy: .immediate) } Spacer() - IconWithAction(systemSymbol: .square) { + IconWithAction(systemSymbol: .square, accessibilityLabel: "Stop") { commandSender.send("STOP", for: widget, policy: .immediate) } Spacer() - IconWithAction(systemSymbol: .chevronDownCircleFill) { + IconWithAction(systemSymbol: .chevronDownCircleFill, accessibilityLabel: "Move down") { commandSender.send("DOWN", for: widget, policy: .immediate) } Spacer() diff --git a/openHABWatch/Views/Rows/SegmentRow.swift b/openHABWatch/Views/Rows/SegmentRow.swift index c9af00e83..cc95c387f 100644 --- a/openHABWatch/Views/Rows/SegmentRow.swift +++ b/openHABWatch/Views/Rows/SegmentRow.swift @@ -135,10 +135,7 @@ struct SegmentRow: View { if let currentIndex, currentIndex >= 0, currentIndex < viewModel.mappings.count { Text(viewModel.mappings[currentIndex].label) .foregroundStyle(.secondary) - .font(WatchTypography.secondaryFont) - .lineLimit(WatchTypography.secondaryLineLimit) - .minimumScaleFactor(WatchTypography.secondaryMinScale) - .truncationMode(.tail) + .watchTextStyle(.secondary) } Image(systemSymbol: .chevronRight) .foregroundStyle(.secondary) @@ -202,10 +199,7 @@ struct SegmentRow: View { @ViewBuilder private func inlineButton(label: String, isPressed: Bool) -> some View { Text(label) - .font(WatchTypography.controlFont) - .lineLimit(WatchTypography.controlLineLimit) - .minimumScaleFactor(WatchTypography.controlMinScale) - .truncationMode(.tail) + .watchTextStyle(.control) .padding(.horizontal, 8) .padding(.vertical, 6) .background( @@ -213,6 +207,8 @@ struct SegmentRow: View { .fill(Color.gray.opacity(isPressed ? 0.6 : 0.3)) ) .contentShape(RoundedRectangle(cornerRadius: 8)) + .accessibilityLabel(label) + .accessibilityAddTraits(.isButton) } } diff --git a/openHABWatch/Views/Rows/SegmentSelectionView.swift b/openHABWatch/Views/Rows/SegmentSelectionView.swift index a13e13f95..facdb36b8 100644 --- a/openHABWatch/Views/Rows/SegmentSelectionView.swift +++ b/openHABWatch/Views/Rows/SegmentSelectionView.swift @@ -90,11 +90,7 @@ struct SegmentSelectionView: View { HStack { Text(mapping.label) .foregroundStyle(.primary) - .multilineTextAlignment(.leading) - .font(WatchTypography.labelFont) - .lineLimit(WatchTypography.labelLineLimit) - .minimumScaleFactor(WatchTypography.labelMinScale) - .truncationMode(.tail) + .watchTextStyle(.label) Spacer() if isSelected(index: index), !viewModel.hasPressReleaseMappings { Image(systemSymbol: .checkmark) @@ -111,6 +107,8 @@ struct SegmentSelectionView: View { RoundedRectangle(cornerRadius: 8) .stroke(Color.secondary.opacity(0.3), lineWidth: 1) ) + .accessibilityLabel(mapping.label) + .accessibilityAddTraits(.isButton) } private func backgroundColor(for index: Int, isPressed: Bool) -> Color { diff --git a/openHABWatch/Views/Rows/SelectionRow.swift b/openHABWatch/Views/Rows/SelectionRow.swift index f3d980e09..3d466e6fa 100644 --- a/openHABWatch/Views/Rows/SelectionRow.swift +++ b/openHABWatch/Views/Rows/SelectionRow.swift @@ -34,11 +34,7 @@ struct SelectionListView: View { HStack { Text(mapping.label) .foregroundStyle(.primary) - .multilineTextAlignment(.leading) - .font(WatchTypography.labelFont) - .lineLimit(WatchTypography.labelLineLimit) - .minimumScaleFactor(WatchTypography.labelMinScale) - .truncationMode(.tail) + .watchTextStyle(.label) Spacer() if selectedIndex == index { Image(systemSymbol: .checkmark) @@ -122,10 +118,7 @@ struct SelectionRow: View { if let valueText = selectedValueText { Text(valueText) .foregroundStyle(.secondary) - .font(WatchTypography.secondaryFont) - .lineLimit(WatchTypography.secondaryLineLimit) - .minimumScaleFactor(WatchTypography.secondaryMinScale) - .truncationMode(.tail) + .watchTextStyle(.secondary) } Image(systemSymbol: .chevronUpChevronDown) .foregroundStyle(.secondary) diff --git a/openHABWatch/Views/Rows/SetpointRow.swift b/openHABWatch/Views/Rows/SetpointRow.swift index 19c53cd0d..f44b34db2 100644 --- a/openHABWatch/Views/Rows/SetpointRow.swift +++ b/openHABWatch/Views/Rows/SetpointRow.swift @@ -42,10 +42,7 @@ struct SetpointRow: View { HStack { WatchIconView(model: widget.iconRenderModel(), settings: settings) Text(viewModel.labelText) - .font(WatchTypography.labelFont) - .lineLimit(WatchTypography.labelLineLimit) - .minimumScaleFactor(WatchTypography.labelMinScale) - .truncationMode(.tail) + .watchTextStyle(.label) Spacer() } HStack { @@ -58,11 +55,15 @@ struct SetpointRow: View { } .buttonStyle(.plain) .disabled(currentValue <= viewModel.minValue) + .accessibilityLabel("Decrease \(viewModel.labelText)") + .accessibilityHint("Lowers by \(viewModel.step.valueText(step: viewModel.step))") Spacer() Text(localValue == nil ? (viewModel.labelValue ?? valueText) : valueText) - .font(WatchTypography.emphasisFont) + .watchTextStyle(.emphasis) + .accessibilityLabel("\(viewModel.labelText) value") + .accessibilityValue(localValue == nil ? (viewModel.labelValue ?? valueText) : valueText) Spacer() @@ -73,6 +74,8 @@ struct SetpointRow: View { } .buttonStyle(.plain) .disabled(currentValue >= viewModel.maxValue) + .accessibilityLabel("Increase \(viewModel.labelText)") + .accessibilityHint("Raises by \(viewModel.step.valueText(step: viewModel.step))") Spacer() } diff --git a/openHABWatch/Views/Rows/SliderRow.swift b/openHABWatch/Views/Rows/SliderRow.swift index ba1da38c9..451a451ec 100644 --- a/openHABWatch/Views/Rows/SliderRow.swift +++ b/openHABWatch/Views/Rows/SliderRow.swift @@ -85,10 +85,7 @@ struct SliderRow: View { WatchLabelText(text: viewModel.labelText) if pendingValue != nil { Text(currentValueText) - .font(WatchTypography.secondaryFont) - .lineLimit(WatchTypography.secondaryLineLimit) - .minimumScaleFactor(WatchTypography.secondaryMinScale) - .truncationMode(.tail) + .watchTextStyle(.secondary) .foregroundStyle(.secondary) } else { DetailTextLabelView(text: viewModel.labelValue, valueColor: widget.valuecolor) @@ -105,10 +102,7 @@ struct SliderRow: View { Spacer() if pendingValue != nil { Text(currentValueText) - .font(WatchTypography.secondaryFont) - .lineLimit(WatchTypography.secondaryLineLimit) - .minimumScaleFactor(WatchTypography.secondaryMinScale) - .truncationMode(.tail) + .watchTextStyle(.secondary) .foregroundStyle(.secondary) } else { DetailTextLabelView(text: viewModel.labelValue, valueColor: widget.valuecolor) @@ -118,7 +112,10 @@ struct SliderRow: View { Slider(value: valueBinding, in: viewModel.minValue ... viewModel.maxValue, step: viewModel.step) .labelsHidden() + .accessibilityLabel(viewModel.labelText) + .accessibilityValue(currentValueText) } + .accessibilityElement(children: .contain) .onChange(of: stateToken, initial: false) { _, _ in viewModel.update(from: widget) pendingValue = nil diff --git a/openHABWatch/Views/Rows/SwitchRow.swift b/openHABWatch/Views/Rows/SwitchRow.swift index c84b8c8d4..04caac1e1 100644 --- a/openHABWatch/Views/Rows/SwitchRow.swift +++ b/openHABWatch/Views/Rows/SwitchRow.swift @@ -46,6 +46,7 @@ struct SwitchRow: View { } .padding(.trailing) .cornerRadius(5) + .accessibilityValue(isOn ? "On" : "Off") .onChange(of: stateToken) { localIsOn = nil } diff --git a/openHABWatch/Views/SitemapPageView.swift b/openHABWatch/Views/SitemapPageView.swift index c8c99f7c6..a9ff7cfcf 100644 --- a/openHABWatch/Views/SitemapPageView.swift +++ b/openHABWatch/Views/SitemapPageView.swift @@ -78,7 +78,7 @@ struct SitemapPageView: View { .progressViewStyle(CircularProgressViewStyle(tint: .secondary)) .scaleEffect(0.7) Text("Updating...") - .font(.caption2) + .watchTextStyle(.secondary) .foregroundStyle(.secondary) Spacer() } @@ -93,7 +93,7 @@ struct SitemapPageView: View { VStack { Spacer() Text("No widgets available.") - .font(.footnote) + .watchTextStyle(.detail) .foregroundStyle(.secondary) Spacer() } diff --git a/openHABWatch/Views/Utils/DetailTextLabelView.swift b/openHABWatch/Views/Utils/DetailTextLabelView.swift index 5e5aa8af4..08a3eb52b 100644 --- a/openHABWatch/Views/Utils/DetailTextLabelView.swift +++ b/openHABWatch/Views/Utils/DetailTextLabelView.swift @@ -19,10 +19,7 @@ struct DetailTextLabelView: View { var body: some View { if let text { Text(text) - .font(WatchTypography.detailFont) - .lineLimit(WatchTypography.detailLineLimit) - .minimumScaleFactor(WatchTypography.detailMinScale) - .truncationMode(.tail) + .watchTextStyle(.detail) .foregroundStyle(!valueColor.isEmpty ? Color(fromString: valueColor) : .secondary) } } diff --git a/openHABWatch/Views/Utils/IconWithAction.swift b/openHABWatch/Views/Utils/IconWithAction.swift index ab7a23710..0cdb16834 100644 --- a/openHABWatch/Views/Utils/IconWithAction.swift +++ b/openHABWatch/Views/Utils/IconWithAction.swift @@ -14,19 +14,23 @@ import SwiftUI struct IconWithAction: View { var systemSymbol: SFSymbol + var accessibilityLabel: String var action: () -> Void + var body: some View { Button(action: action) { Image(systemSymbol: systemSymbol) .font(.system(size: 25)) .colorMultiply(.blue) .saturation(0.8) + .frame(width: 32, height: 32) } .buttonStyle(.plain) + .accessibilityLabel(accessibilityLabel) + .accessibilityAddTraits(.isButton) } } #Preview { - IconWithAction(systemSymbol: - .chevronUpCircleFill) {} + IconWithAction(systemSymbol: .chevronUpCircleFill, accessibilityLabel: "Increase") {} } diff --git a/openHABWatch/Views/Utils/WatchTypography.swift b/openHABWatch/Views/Utils/WatchTypography.swift index c11eca7d1..483d79976 100644 --- a/openHABWatch/Views/Utils/WatchTypography.swift +++ b/openHABWatch/Views/Utils/WatchTypography.swift @@ -16,10 +16,73 @@ struct WatchLabelText: View { var body: some View { Text(text) - .font(WatchTypography.labelFont) - .lineLimit(WatchTypography.labelLineLimit) - .minimumScaleFactor(WatchTypography.labelMinScale) - .truncationMode(.tail) + .watchTextStyle(.label) + } +} + +enum WatchTextStyle { + case label + case detail + case section + case control + case secondary + case emphasis +} + +private struct WatchTextModifier: ViewModifier { + let style: WatchTextStyle + + func body(content: Content) -> some View { + switch style { + case .label: + content + .font(WatchTypography.labelFont) + .lineLimit(WatchTypography.labelLineLimit) + .minimumScaleFactor(WatchTypography.labelMinScale) + .truncationMode(.tail) + .multilineTextAlignment(.leading) + case .detail: + content + .font(WatchTypography.detailFont) + .lineLimit(WatchTypography.detailLineLimit) + .minimumScaleFactor(WatchTypography.detailMinScale) + .truncationMode(.tail) + .multilineTextAlignment(.leading) + case .section: + content + .font(WatchTypography.sectionFont) + .lineLimit(WatchTypography.sectionLineLimit) + .minimumScaleFactor(WatchTypography.sectionMinScale) + .truncationMode(.tail) + .multilineTextAlignment(.leading) + case .control: + content + .font(WatchTypography.controlFont) + .lineLimit(WatchTypography.controlLineLimit) + .minimumScaleFactor(WatchTypography.controlMinScale) + .truncationMode(.tail) + .multilineTextAlignment(.leading) + case .secondary: + content + .font(WatchTypography.secondaryFont) + .lineLimit(WatchTypography.secondaryLineLimit) + .minimumScaleFactor(WatchTypography.secondaryMinScale) + .truncationMode(.tail) + .multilineTextAlignment(.leading) + case .emphasis: + content + .font(WatchTypography.emphasisFont) + .lineLimit(1) + .minimumScaleFactor(0.8) + .truncationMode(.tail) + .multilineTextAlignment(.leading) + } + } +} + +extension View { + func watchTextStyle(_ style: WatchTextStyle) -> some View { + modifier(WatchTextModifier(style: style)) } } From 0bc1bccae8bfddf02d127f75d8c992c858de1d2c Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Tue, 10 Feb 2026 08:11:49 +0100 Subject: [PATCH 445/476] Adopt stateToken pattern in SegmentRow and ColorPickerRow Migrate SegmentRow and ColorPickerRow to use stateToken for change detection, replacing @ObservedObject widget observation and onAppear with onChange(of: stateToken) for consistency with other row views. Signed-off-by: Tim Mueller-Seydlitz --- openHABWatch/Model/WidgetRowFactory.swift | 6 +++--- openHABWatch/Views/Rows/ColorPickerRow.swift | 19 ++++++++++++------- openHABWatch/Views/Rows/SegmentRow.swift | 17 +++++++++++------ 3 files changed, 26 insertions(+), 16 deletions(-) diff --git a/openHABWatch/Model/WidgetRowFactory.swift b/openHABWatch/Model/WidgetRowFactory.swift index 5885a2afb..e47b62c12 100644 --- a/openHABWatch/Model/WidgetRowFactory.swift +++ b/openHABWatch/Model/WidgetRowFactory.swift @@ -19,13 +19,13 @@ enum WidgetRowFactory { switch widget.type { case .switchWidget: if !widget.mappings.isEmpty { - SegmentRow(widget: widget) + SegmentRow(widget: widget, stateToken: widget.item?.state ?? widget.state) } else if widget.item?.isOfTypeOrGroupType(.switchItem) ?? false { SwitchRow(widget: widget, stateToken: widget.state.isEmpty ? (widget.item?.state ?? "") : widget.state) } else if widget.item?.isOfTypeOrGroupType(.rollershutter) ?? false { RollershutterRow(widget: widget) } else if !widget.mappingsOrItemOptions.isEmpty { - SegmentRow(widget: widget) + SegmentRow(widget: widget, stateToken: widget.item?.state ?? widget.state) } else { SwitchRow(widget: widget, stateToken: widget.state.isEmpty ? (widget.item?.state ?? "") : widget.state) } @@ -58,7 +58,7 @@ enum WidgetRowFactory { case .mapview: MapViewRow(widget: widget) case .colorpicker: - ColorPickerRow(widget: widget) + ColorPickerRow(widget: widget, stateToken: widget.item?.state ?? widget.state) case .selection: SelectionRow( widget: widget, diff --git a/openHABWatch/Views/Rows/ColorPickerRow.swift b/openHABWatch/Views/Rows/ColorPickerRow.swift index 4e89014f3..61ca6874d 100644 --- a/openHABWatch/Views/Rows/ColorPickerRow.swift +++ b/openHABWatch/Views/Rows/ColorPickerRow.swift @@ -15,8 +15,9 @@ import SFSafeSymbols import SwiftUI struct ColorPickerRow: View { - @ObservedObject var widget: OpenHABWidget - @ObservedObject var settings = AppSettings.shared + let widget: OpenHABWidget + let stateToken: String + @EnvironmentObject var settings: AppSettings @State private var viewModel: WidgetRowViewModel @State private var commandSender = WidgetCommandSender() var body: some View { @@ -63,16 +64,20 @@ struct ColorPickerRow: View { Spacer() } } - .onAppear { - viewModel.update(from: widget) - } - .onChange(of: widget.item?.state, initial: false) { _, _ in + .onChange(of: stateToken, initial: false) { _, _ in viewModel.update(from: widget) } } init(widget: OpenHABWidget) { self.widget = widget + stateToken = widget.item?.state ?? widget.state + _viewModel = State(wrappedValue: WidgetRowViewModel(widget: widget)) + } + + init(widget: OpenHABWidget, stateToken: String) { + self.widget = widget + self.stateToken = stateToken _viewModel = State(wrappedValue: WidgetRowViewModel(widget: widget)) } @@ -92,6 +97,6 @@ struct ColorPickerRow: View { icon: "colorwheel" ) PreviewNavigationContainer { - ColorPickerRow(widget: widget) + ColorPickerRow(widget: widget, stateToken: widget.item?.state ?? widget.state) } } diff --git a/openHABWatch/Views/Rows/SegmentRow.swift b/openHABWatch/Views/Rows/SegmentRow.swift index cc95c387f..785d65e22 100644 --- a/openHABWatch/Views/Rows/SegmentRow.swift +++ b/openHABWatch/Views/Rows/SegmentRow.swift @@ -14,7 +14,8 @@ import OpenHABCore import SwiftUI struct SegmentRow: View { - @ObservedObject var widget: OpenHABWidget + let widget: OpenHABWidget + let stateToken: String @EnvironmentObject var settings: AppSettings @State private var pressedIndex: Int? @State private var singlePressed = false @@ -35,10 +36,7 @@ struct SegmentRow: View { multiSegmentContent } } - .onAppear { - viewModel.update(from: widget) - } - .onChange(of: widget.item?.state, initial: false) { _, _ in + .onChange(of: stateToken, initial: false) { _, _ in viewModel.update(from: widget) } } @@ -128,7 +126,7 @@ struct SegmentRow: View { } NavigationLink(destination: LazyView(SegmentSelectionView( widget: widget, - stateToken: widget.item?.state ?? widget.state, + stateToken: stateToken, selectedIndex: selectedIndexBinding ))) { HStack { @@ -165,6 +163,13 @@ struct SegmentRow: View { init(widget: OpenHABWidget) { self.widget = widget + stateToken = widget.item?.state ?? widget.state + _viewModel = State(wrappedValue: WidgetRowViewModel(widget: widget)) + } + + init(widget: OpenHABWidget, stateToken: String) { + self.widget = widget + self.stateToken = stateToken _viewModel = State(wrappedValue: WidgetRowViewModel(widget: widget)) } From 8b4b75dd786340d430ffb37671c29e16931b733a Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Tue, 10 Feb 2026 20:47:42 +0100 Subject: [PATCH 446/476] Add dedicated watchOS test target and interactive state token previews Create openHABWatchTests target replacing the old SwiftUI watch app tests, move OpenHABWatchTests from iOS to watchOS target, and add InteractiveStateTokenPreview helper with interactive previews for ColorPickerRow and SegmentRow. Signed-off-by: Tim Mueller-Seydlitz --- openHAB.xcodeproj/project.pbxproj | 167 ++++++++++- .../openHABWatch (Notification).xcscheme | 11 + .../xcschemes/openHABWatch.xcscheme | 11 + .../openHABWatchSwift (Complication).xcscheme | 11 + openHABTestsSwift/OpenHABWatchTests.swift | 279 ------------------ .../PreviewNavigationContainer.swift | 34 +++ openHABWatch/Views/Rows/ColorPickerRow.swift | 16 + openHABWatch/Views/Rows/SegmentRow.swift | 21 ++ .../Views/Utils/WatchTypography.swift | 12 +- .../OpenHABWatchAppTests.swift | 49 --- openHABWatchTests/OpenHABWatchAppTests.swift | 45 +++ 11 files changed, 307 insertions(+), 349 deletions(-) delete mode 100644 openHABTestsSwift/OpenHABWatchTests.swift delete mode 100644 openHABWatchSwiftUI Watch AppTests/OpenHABWatchAppTests.swift create mode 100644 openHABWatchTests/OpenHABWatchAppTests.swift diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index 720b5230b..2e7a0585b 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -172,7 +172,6 @@ DAC949FE2E21A2D1007E67B7 /* FrameRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC949FD2E21A2D1007E67B7 /* FrameRowView.swift */; }; DAC9AF4924F966FA006DAE93 /* LazyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC9AF4824F966FA006DAE93 /* LazyView.swift */; }; DACA368E2D7440B9003CD237 /* OpenHABWidgetExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC9AF4624F9669F006DAE93 /* OpenHABWidgetExtension.swift */; }; - DAD085712AE4782D001D36BE /* OpenHABWatchAppTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAD085702AE4782D001D36BE /* OpenHABWatchAppTests.swift */; }; DAD0857B2AE4782F001D36BE /* OpenHABWatchUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAD0857A2AE4782F001D36BE /* OpenHABWatchUITests.swift */; }; DAD0857D2AE4782F001D36BE /* OpenHABWatchLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAD0857C2AE4782F001D36BE /* OpenHABWatchLaunchTests.swift */; }; DAD0858B2AE56F0E001D36BE /* OpenHABWatch.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAD0855F2AE47824001D36BE /* OpenHABWatch.swift */; }; @@ -261,6 +260,13 @@ remoteGlobalIDString = DFB2622618830A3600D3244D; remoteInfo = openHAB; }; + DA8B15552F3BB74B007753FD /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = DFB2621F18830A3600D3244D /* Project object */; + proxyType = 1; + remoteGlobalIDString = DA0775142346705D0086C685; + remoteInfo = openHABWatch; + }; DAA0708C2B504B280060BB0E /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = DFB2621F18830A3600D3244D /* Project object */; @@ -507,6 +513,7 @@ DA8B14BB2F3A3CB5007753FD /* WatchTypography.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchTypography.swift; sourceTree = ""; }; DA8B14BD2F3A98A1007753FD /* WidgetCommandPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetCommandPolicy.swift; sourceTree = ""; }; DA8B14BE2F3A98A1007753FD /* WidgetCommandSender.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetCommandSender.swift; sourceTree = ""; }; + DA8B15512F3BB74B007753FD /* openHABWatchTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = openHABWatchTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; DA94AEB32EC896BE003BB3C8 /* SimpleMJPEGPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleMJPEGPlayer.swift; sourceTree = ""; }; DA94AF432EC8DE41003BB3C8 /* VideoStreamManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoStreamManager.swift; sourceTree = ""; }; DA95F3322E0F2B1700FE4474 /* OpenHABRootViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenHABRootViewController.swift; sourceTree = ""; }; @@ -525,7 +532,6 @@ DAC65FC6236EDF3900F4501E /* SpinnerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpinnerViewController.swift; sourceTree = ""; }; DAC6608B236F6F4200F4501E /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; DAC6608C236F771600F4501E /* PreferencesSwiftUIView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesSwiftUIView.swift; sourceTree = ""; }; - DAC9394322AD4A7A00C5F423 /* OpenHABWatchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenHABWatchTests.swift; sourceTree = ""; }; DAC9395422B00E7600C5F423 /* XCTestCaseExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCTestCaseExtension.swift; sourceTree = ""; }; DAC949FD2E21A2D1007E67B7 /* FrameRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrameRowView.swift; sourceTree = ""; }; DAC9AF4624F9669F006DAE93 /* OpenHABWidgetExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenHABWidgetExtension.swift; sourceTree = ""; }; @@ -535,7 +541,6 @@ DAD0855F2AE47824001D36BE /* OpenHABWatch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenHABWatch.swift; sourceTree = ""; }; DAD085662AE4782A001D36BE /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; DAD0856C2AE4782B001D36BE /* openHABWatchSwiftUI Watch AppTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "openHABWatchSwiftUI Watch AppTests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; - DAD085702AE4782D001D36BE /* OpenHABWatchAppTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenHABWatchAppTests.swift; sourceTree = ""; }; DAD085762AE4782E001D36BE /* openHABWatchSwiftUI Watch AppUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "openHABWatchSwiftUI Watch AppUITests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; DAD0857A2AE4782F001D36BE /* OpenHABWatchUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenHABWatchUITests.swift; sourceTree = ""; }; DAD0857C2AE4782F001D36BE /* OpenHABWatchLaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenHABWatchLaunchTests.swift; sourceTree = ""; }; @@ -596,6 +601,7 @@ /* Begin PBXFileSystemSynchronizedRootGroup section */ DA2AEB752D92D32000897D80 /* Cells */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Cells; sourceTree = ""; }; + DA8B15522F3BB74B007753FD /* openHABWatchTests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = openHABWatchTests; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -648,6 +654,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + DA8B154E2F3BB74B007753FD /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; DAD085692AE4782A001D36BE /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -898,7 +911,6 @@ 938BF89524EFBC5400E6B52F /* LocalizationTests.swift */, DA9641592F292EE200CEC181 /* BonjourDiscoveryViewModelTests.swift */, DAC9395422B00E7600C5F423 /* XCTestCaseExtension.swift */, - DAC9394322AD4A7A00C5F423 /* OpenHABWatchTests.swift */, DAF231D127BB6EEA00AB916C /* OpenHABSVGTests.swift */, DA2DC23321F2736C00830730 /* Info.plist */, DA96415B2F292F0600CEC181 /* OpenHABEndPoint.swift */, @@ -995,14 +1007,6 @@ path = openapi/openapitest; sourceTree = ""; }; - DAD0856F2AE4782D001D36BE /* openHABWatchSwiftUI Watch AppTests */ = { - isa = PBXGroup; - children = ( - DAD085702AE4782D001D36BE /* OpenHABWatchAppTests.swift */, - ); - path = "openHABWatchSwiftUI Watch AppTests"; - sourceTree = ""; - }; DAD085792AE4782F001D36BE /* openHABWatchSwiftUI Watch AppUITests */ = { isa = PBXGroup; children = ( @@ -1162,9 +1166,9 @@ DA0775162346705D0086C685 /* openHABWatch */, 4D6470D42561F935007B03FC /* openHABIntents */, DAC1310F2DA3208E00075AE2 /* openHABIntentsTests */, - DAD0856F2AE4782D001D36BE /* openHABWatchSwiftUI Watch AppTests */, DAD085792AE4782F001D36BE /* openHABWatchSwiftUI Watch AppUITests */, 6571444F2C1E438700C8A1F3 /* NotificationService */, + DA8B15522F3BB74B007753FD /* openHABWatchTests */, DFB2622818830A3600D3244D /* Products */, DFB2622918830A3600D3244D /* Frameworks */, DA1C2E4A230DC28F00FACFB0 /* fastlane */, @@ -1185,6 +1189,7 @@ DAD0856C2AE4782B001D36BE /* openHABWatchSwiftUI Watch AppTests.xctest */, DAD085762AE4782E001D36BE /* openHABWatchSwiftUI Watch AppUITests.xctest */, 6571444E2C1E438700C8A1F3 /* NotificationService.appex */, + DA8B15512F3BB74B007753FD /* openHABWatchTests.xctest */, ); name = Products; sourceTree = ""; @@ -1362,6 +1367,29 @@ productReference = DA2DC22F21F2736C00830730 /* openHABTestsSwift.xctest */; productType = "com.apple.product-type.bundle.unit-test"; }; + DA8B15502F3BB74B007753FD /* openHABWatchTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = DA8B15592F3BB74B007753FD /* Build configuration list for PBXNativeTarget "openHABWatchTests" */; + buildPhases = ( + DA8B154D2F3BB74B007753FD /* Sources */, + DA8B154E2F3BB74B007753FD /* Frameworks */, + DA8B154F2F3BB74B007753FD /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + DA8B15562F3BB74B007753FD /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + DA8B15522F3BB74B007753FD /* openHABWatchTests */, + ); + name = openHABWatchTests; + packageProductDependencies = ( + ); + productName = openHABWatchTests; + productReference = DA8B15512F3BB74B007753FD /* openHABWatchTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; DAD0856B2AE4782A001D36BE /* openHABWatchSwiftUI Watch AppTests */ = { isa = PBXNativeTarget; buildConfigurationList = DAD085842AE47831001D36BE /* Build configuration list for PBXNativeTarget "openHABWatchSwiftUI Watch AppTests" */; @@ -1445,7 +1473,7 @@ attributes = { BuildIndependentTargetsInParallel = YES; CLASSPREFIX = OpenHAB; - LastSwiftUpdateCheck = 1540; + LastSwiftUpdateCheck = 2620; LastUpgradeCheck = 2610; ORGANIZATIONNAME = "openHAB e.V."; TargetAttributes = { @@ -1467,6 +1495,10 @@ LastSwiftMigration = 1020; TestTargetID = DFB2622618830A3600D3244D; }; + DA8B15502F3BB74B007753FD = { + CreatedOnToolsVersion = 26.2; + TestTargetID = DA0775142346705D0086C685; + }; DAD0856B2AE4782A001D36BE = { CreatedOnToolsVersion = 15.0; TestTargetID = DA0775142346705D0086C685; @@ -1537,6 +1569,7 @@ DAD0856B2AE4782A001D36BE /* openHABWatchSwiftUI Watch AppTests */, DAD085752AE4782D001D36BE /* openHABWatchSwiftUI Watch AppUITests */, 6571444D2C1E438700C8A1F3 /* NotificationService */, + DA8B15502F3BB74B007753FD /* openHABWatchTests */, ); }; /* End PBXProject section */ @@ -1587,6 +1620,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + DA8B154F2F3BB74B007753FD /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; DAD0856A2AE4782A001D36BE /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -1743,11 +1783,17 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + DA8B154D2F3BB74B007753FD /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; DAD085682AE4782A001D36BE /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - DAD085712AE4782D001D36BE /* OpenHABWatchAppTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1888,6 +1934,11 @@ target = DFB2622618830A3600D3244D /* openHAB */; targetProxy = DA2DC23421F2736C00830730 /* PBXContainerItemProxy */; }; + DA8B15562F3BB74B007753FD /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = DA0775142346705D0086C685 /* openHABWatch */; + targetProxy = DA8B15552F3BB74B007753FD /* PBXContainerItemProxy */; + }; DAA0708D2B504B280060BB0E /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = DA0775142346705D0086C685 /* openHABWatch */; @@ -2350,6 +2401,83 @@ }; name = Release; }; + DA8B15572F3BB74B007753FD /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = PBAPXHRAM9; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GENERATE_INFOPLIST_FILE = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = org.openhab.openHABWatchTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/openHABWatch.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/openHABWatch"; + WATCHOS_DEPLOYMENT_TARGET = 10.6; + }; + name = Debug; + }; + DA8B15582F3BB74B007753FD /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = PBAPXHRAM9; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GENERATE_INFOPLIST_FILE = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = org.openhab.openHABWatchTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/openHABWatch.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/openHABWatch"; + WATCHOS_DEPLOYMENT_TARGET = 10.6; + }; + name = Release; + }; DAD085852AE47831001D36BE /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -2780,6 +2908,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + DA8B15592F3BB74B007753FD /* Build configuration list for PBXNativeTarget "openHABWatchTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + DA8B15572F3BB74B007753FD /* Debug */, + DA8B15582F3BB74B007753FD /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; DAD085842AE47831001D36BE /* Build configuration list for PBXNativeTarget "openHABWatchSwiftUI Watch AppTests" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/openHAB.xcodeproj/xcshareddata/xcschemes/openHABWatch (Notification).xcscheme b/openHAB.xcodeproj/xcshareddata/xcschemes/openHABWatch (Notification).xcscheme index f69e4b287..675d0dcba 100644 --- a/openHAB.xcodeproj/xcshareddata/xcschemes/openHABWatch (Notification).xcscheme +++ b/openHAB.xcodeproj/xcshareddata/xcschemes/openHABWatch (Notification).xcscheme @@ -42,6 +42,17 @@ selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv = "YES"> + + + + + + + + + + + + : View { + let widget: OpenHABWidget + let states: [String] + private let content: (OpenHABWidget, String) -> Content + @State private var selectedStateIndex = 0 + + private var currentToken: String { + let token = states[safe: selectedStateIndex] + return token ?? widget.item?.state ?? widget.state + } + + var body: some View { + VStack(spacing: 8) { + content(widget, currentToken) + Picker("State", selection: $selectedStateIndex) { + ForEach(0 ..< states.count, id: \.self) { index in + Text(states[index]).tag(index) + } + } + .labelsHidden() + .accessibilityLabel("Preview state") + } + } + + init(widget: OpenHABWidget, + states: [String], + @ViewBuilder content: @escaping (OpenHABWidget, String) -> Content) { + self.widget = widget + self.states = states.isEmpty ? [widget.item?.state ?? widget.state] : states + self.content = content + } +} + struct PreviewNavigationContainer: View { @State private var settings = AppSettings() private let content: Content diff --git a/openHABWatch/Views/Rows/ColorPickerRow.swift b/openHABWatch/Views/Rows/ColorPickerRow.swift index 61ca6874d..3b730f0a7 100644 --- a/openHABWatch/Views/Rows/ColorPickerRow.swift +++ b/openHABWatch/Views/Rows/ColorPickerRow.swift @@ -100,3 +100,19 @@ struct ColorPickerRow: View { ColorPickerRow(widget: widget, stateToken: widget.item?.state ?? widget.state) } } + +#Preview("Interactive State Token") { + let widget = PreviewWidgetFactory.colorpicker( + label: "Color", + state: "120,100,100", + icon: "colorwheel" + ) + PreviewNavigationContainer { + InteractiveStateTokenPreview( + widget: widget, + states: ["0,100,100", "120,100,100", "240,100,100", "NULL"] + ) { targetWidget, stateToken in + ColorPickerRow(widget: targetWidget, stateToken: stateToken) + } + } +} diff --git a/openHABWatch/Views/Rows/SegmentRow.swift b/openHABWatch/Views/Rows/SegmentRow.swift index 785d65e22..50925b2aa 100644 --- a/openHABWatch/Views/Rows/SegmentRow.swift +++ b/openHABWatch/Views/Rows/SegmentRow.swift @@ -385,3 +385,24 @@ struct SegmentRow: View { } } } + +#Preview("Interactive State Token") { + let widget = PreviewWidgetFactory.segmented( + label: "Climate Mode", + mappings: [ + OpenHABWidgetMapping(command: "manual", label: "Manual"), + OpenHABWidgetMapping(command: "auto", label: "Auto"), + OpenHABWidgetMapping(command: "schedule", label: "Schedule") + ], + selectedState: "auto", + icon: "temperature" + ) + PreviewNavigationContainer { + InteractiveStateTokenPreview( + widget: widget, + states: ["manual", "auto", "schedule"] + ) { targetWidget, stateToken in + SegmentRow(widget: targetWidget, stateToken: stateToken) + } + } +} diff --git a/openHABWatch/Views/Utils/WatchTypography.swift b/openHABWatch/Views/Utils/WatchTypography.swift index 483d79976..d030c0a5b 100644 --- a/openHABWatch/Views/Utils/WatchTypography.swift +++ b/openHABWatch/Views/Utils/WatchTypography.swift @@ -80,12 +80,6 @@ private struct WatchTextModifier: ViewModifier { } } -extension View { - func watchTextStyle(_ style: WatchTextStyle) -> some View { - modifier(WatchTextModifier(style: style)) - } -} - enum WatchTypography { static let labelFont: Font = .caption static let labelLineLimit = 2 @@ -109,3 +103,9 @@ enum WatchTypography { static let emphasisFont: Font = .headline } + +extension View { + func watchTextStyle(_ style: WatchTextStyle) -> some View { + modifier(WatchTextModifier(style: style)) + } +} diff --git a/openHABWatchSwiftUI Watch AppTests/OpenHABWatchAppTests.swift b/openHABWatchSwiftUI Watch AppTests/OpenHABWatchAppTests.swift deleted file mode 100644 index 9cc9c4fb3..000000000 --- a/openHABWatchSwiftUI Watch AppTests/OpenHABWatchAppTests.swift +++ /dev/null @@ -1,49 +0,0 @@ -// Copyright (c) 2010-2026 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 - -// Copyright (c) 2010-2023 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 - -@testable import openHABWatch -import XCTest - -final class OpenHABWatchAppTests: XCTestCase { - override func setUpWithError() throws { - // Put setup code here. This method is called before the invocation of each test method in the class. - } - - override func tearDownWithError() throws { - // Put teardown code here. This method is called after the invocation of each test method in the class. - } - - func testExample() throws { - // This is an example of a functional test case. - // Use XCTAssert and related functions to verify your tests produce the correct results. - // Any test you write for XCTest can be annotated as throws and async. - // Mark your test throws to produce an unexpected failure when your test encounters an uncaught error. - // Tests marked async will run the test method on an arbitrary thread managed by the Swift runtime. - } - - func testPerformanceExample() throws { - // This is an example of a performance test case. - measure { - // Put the code you want to measure the time of here. - } - } -} diff --git a/openHABWatchTests/OpenHABWatchAppTests.swift b/openHABWatchTests/OpenHABWatchAppTests.swift new file mode 100644 index 000000000..529a51818 --- /dev/null +++ b/openHABWatchTests/OpenHABWatchAppTests.swift @@ -0,0 +1,45 @@ +// Copyright (c) 2010-2026 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 Foundation +import OpenHABCore +import Testing + +// swiftlint:disable line_length +struct OpenHABWatchAppTests { + private let watchSitemapJSON = Data(""" + {"name":"watch","label":"watch","link":"https://192.168.2.15:8444/rest/sitemaps/watch","homepage":{"id":"watch","title":"watch","link":"https://192.168.2.15:8444/rest/sitemaps/watch/watch","leaf":false,"timeout":false,"widgets":[{"widgetId":"00","type":"Frame","label":"Ground floor","icon":"frame","mappings":[],"widgets":[{"widgetId":"0000","type":"Switch","label":"Licht Oberlicht","icon":"switch","mappings":[],"item":{"link":"https://192.168.2.15:8444/rest/items/lcnLightSwitch14_1","state":"OFF","editable":false,"type":"Switch","name":"lcnLightSwitch14_1","label":"Licht Oberlicht","tags":["Lighting"],"groupNames":["G_PresenceSimulation","gLcn"]},"widgets":[]}]}]}} + """.utf8) + + @Test("Watch sitemap JSON decodes into SitemapDTO") + func decodeWatchSitemapDTO() throws { + let decoder = JSONDecoder() + let dto = try decoder.decode(Components.Schemas.SitemapDTO.self, from: watchSitemapJSON) + + #expect(dto.name == "watch") + #expect(dto.homepage?.link == "https://192.168.2.15:8444/rest/sitemaps/watch/watch") + #expect(dto.homepage?.widgets?.first?._type == "Frame") + } + + @Test("Watch sitemap DTO maps into OpenHABPage model") + func mapWatchSitemapPageModel() throws { + let decoder = JSONDecoder() + let dto = try decoder.decode(Components.Schemas.SitemapDTO.self, from: watchSitemapJSON) + let page = try #require(OpenHABPage(dto.homepage)) + + #expect(page.title == "watch") + #expect(page.link == "https://192.168.2.15:8444/rest/sitemaps/watch/watch") + #expect(page.widgets.first?.type == .frame) + #expect(page.widgets.first?.widgets.first?.item?.name == "lcnLightSwitch14_1") + } +} + +// swiftlint:enable line_length From 29fb7d6f057fe0b33ad3a6a62e3790447b0a9820 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Tue, 10 Feb 2026 20:58:03 +0100 Subject: [PATCH 447/476] Add unit tests for WidgetCommandSender Test immediate dispatch, debounce coalescing, cancel pending, press/release ordering, and item update behavior using a CommandRecorder mock. Signed-off-by: Tim Mueller-Seydlitz --- .../WidgetCommandSenderTests.swift | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 openHABWatchTests/WidgetCommandSenderTests.swift diff --git a/openHABWatchTests/WidgetCommandSenderTests.swift b/openHABWatchTests/WidgetCommandSenderTests.swift new file mode 100644 index 000000000..8c0bfecb0 --- /dev/null +++ b/openHABWatchTests/WidgetCommandSenderTests.swift @@ -0,0 +1,111 @@ +// Copyright (c) 2010-2026 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 Foundation +import OpenHABCore +import Testing +@testable import openHABWatch + +struct WidgetCommandSenderTests { + @Test("Immediate policy sends command once") + @MainActor + func immediatePolicySendsOnce() async { + let recorder = CommandRecorder() + let widget = makeWidget(widgetId: "immediate", recorder: recorder) + let sender = WidgetCommandSender() + + sender.send("ON", for: widget, policy: .immediate) + + #expect(recorder.commands == ["ON"]) + } + + @Test("Debounce policy keeps only final command") + @MainActor + func debounceKeepsFinalCommand() async throws { + let recorder = CommandRecorder() + let widget = makeWidget(widgetId: "debounce", recorder: recorder) + let sender = WidgetCommandSender() + + sender.send("ON", for: widget, policy: .debounce(.milliseconds(80))) + sender.send("OFF", for: widget, policy: .debounce(.milliseconds(80))) + + try await Task.sleep(for: .milliseconds(200)) + #expect(recorder.commands == ["OFF"]) + } + + @Test("Pending debounced command can be cancelled") + @MainActor + func cancelPendingPreventsDispatch() async throws { + let recorder = CommandRecorder() + let widget = makeWidget(widgetId: "cancel", recorder: recorder) + let sender = WidgetCommandSender() + + sender.send("ON", for: widget, policy: .debounce(.milliseconds(120))) + sender.cancelPending(for: widget) + + try await Task.sleep(for: .milliseconds(220)) + #expect(recorder.commands.isEmpty) + } + + @Test("Press and release commands dispatch in order") + @MainActor + func pressReleaseDispatchesInOrder() async { + let recorder = CommandRecorder() + let widget = makeWidget(widgetId: "press-release", recorder: recorder) + let sender = WidgetCommandSender() + + sender.sendPress("UP", for: widget) + sender.sendRelease("STOP", for: widget) + + #expect(recorder.commands == ["UP", "STOP"]) + } + + @Test("Item update dispatches command and ignores nil state") + @MainActor + func itemUpdateDispatchesAndSkipsNil() async { + let recorder = CommandRecorder() + let widget = makeWidget(widgetId: "item-update", recorder: recorder) + let sender = WidgetCommandSender() + + sender.sendItemUpdate(NumberState(value: 21), for: widget) + sender.sendItemUpdate(nil, for: widget) + + #expect(recorder.commands.count == 1) + #expect(recorder.commands.first?.contains("21") == true) + } +} + +private final class CommandRecorder { + var commands: [String] = [] +} + +private func makeWidget(widgetId: String, recorder: CommandRecorder) -> OpenHABWidget { + let item = OpenHABItem( + name: "TestItem", + type: "Switch", + state: "OFF", + link: "", + label: nil, + groupType: nil, + stateDescription: nil, + commandDescription: nil, + members: [], + category: nil, + options: nil + ) + let widget = OpenHABWidget(icon: "switch") + widget.widgetId = widgetId + widget.item = item + widget.sendCommand = { _, command in + recorder.commands.append(command ?? "") + } + return widget +} From 4b5a649231a4dfd5f4688c469ec6263fea7a7d42 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Tue, 10 Feb 2026 23:45:18 +0100 Subject: [PATCH 448/476] Fix scroll-to-top on long-poll updates and image flickering On watchOS, conditionalize openHABSitemapPage assignment to only fire objectWillChange when the page title actually changes, preventing ScrollView scroll position resets every long-poll cycle. Also add missing linkedPage/visibility updates to updateWidgets(with:). On iOS, replace the updateUI method with in-place widget property updates that preserve @ObservedObject identity, avoiding full body re-evaluation that causes chart/image flickering from regenerated URLs. Remove unnecessary isUpdating toggles during long-poll that fired objectWillChange twice per cycle for no reason. Signed-off-by: Tim Mueller-Seydlitz --- openHAB/SitemapPageViewModel.swift | 50 +++++++++++++++++++++++++++--- openHABWatch/Domain/UserData.swift | 9 ++++-- 2 files changed, 53 insertions(+), 6 deletions(-) diff --git a/openHAB/SitemapPageViewModel.swift b/openHAB/SitemapPageViewModel.swift index 3937da883..04bc4ad54 100644 --- a/openHAB/SitemapPageViewModel.swift +++ b/openHAB/SitemapPageViewModel.swift @@ -56,6 +56,9 @@ class SitemapPageViewModel: ObservableObject { private var pageNetworkStatus: NetworkStatus? private var pageNetworkStatusAvailable = false + /// Cache of current widget objects by widgetId for in-place updates + private var currentWidgetMap: [String: OpenHABWidget] = [:] + var relevantWidgets: [OpenHABWidget] { let widgets = currentPage?.widgets ?? [] guard !searchText.isEmpty else { return widgets } @@ -184,13 +187,11 @@ class SitemapPageViewModel: ObservableObject { // 2. Start long polling loop while !Task.isCancelled { - isUpdating = true let page = try await openAPIService?.pollDataForPage( sitemapname: defaultSitemap, pageId: pageId, longPolling: true ) - isUpdating = false try Task.checkCancellation() if let page { @@ -254,8 +255,49 @@ class SitemapPageViewModel: ObservableObject { @MainActor private func updateUI(with page: OpenHABPage) { - injectSendCommand(for: page.widgets) - currentPage = page + let newWidgets = page.widgets + + // Check if list structure changed (count, order, or IDs) + let currentWidgets = currentPage?.widgets ?? [] + let structureChanged = currentWidgets.count != newWidgets.count + || !zip(currentWidgets, newWidgets).allSatisfy { $0.widgetId == $1.widgetId } + + // Update existing widget properties in-place to preserve @ObservedObject + // references and avoid image/chart flickering from body re-evaluation + for newWidget in newWidgets { + if let existing = currentWidgetMap[newWidget.widgetId] { + existing.label = newWidget.label + existing.icon = newWidget.icon + existing.state = newWidget.state + existing.item = newWidget.item + existing.iconColor = newWidget.iconColor + existing.labelcolor = newWidget.labelcolor + existing.valuecolor = newWidget.valuecolor + existing.url = newWidget.url + existing.period = newWidget.period + existing.service = newWidget.service + existing.legend = newWidget.legend + existing.refresh = newWidget.refresh + existing.height = newWidget.height + existing.forceAsItem = newWidget.forceAsItem + existing.mappings = newWidget.mappings + existing.widgets = newWidget.widgets + existing.linkedPage = newWidget.linkedPage + existing.visibility = newWidget.visibility + existing.staticIcon = newWidget.staticIcon + } + } + + // Only replace currentPage when structure or title changed + if structureChanged || currentPage?.title != page.title || currentPage == nil { + injectSendCommand(for: page.widgets) + currentPage = page + // Rebuild the widget map + currentWidgetMap = Dictionary(uniqueKeysWithValues: page.widgets.map { ($0.widgetId, $0) }) + } else { + // Inject sendCommand into existing widgets without replacing the page + injectSendCommand(for: currentPage?.widgets ?? []) + } } func reload() async { diff --git a/openHABWatch/Domain/UserData.swift b/openHABWatch/Domain/UserData.swift index 3410718ba..fd5253bae 100644 --- a/openHABWatch/Domain/UserData.swift +++ b/openHABWatch/Domain/UserData.swift @@ -311,13 +311,16 @@ final class UserData: ObservableObject { try Task.checkCancellation() await MainActor.run { - // Set command handler BEFORE assigning to @Published property to prevent race condition if let page { page.sendCommand = { [weak self] item, command in Task { await self?.sendCommand(item, command: command) } } } - self.openHABSitemapPage = page + // Only update page object when title changes to avoid + // firing objectWillChange and resetting scroll position + if self.openHABSitemapPage?.title != page?.title { + self.openHABSitemapPage = page + } let newWidgets = page?.widgets ?? [] self.updateWidgets(with: newWidgets) if !newWidgets.isEmpty { @@ -416,6 +419,8 @@ final class UserData: ObservableObject { existingWidget.forceAsItem = newWidget.forceAsItem existingWidget.mappings = newWidget.mappings existingWidget.widgets = newWidget.widgets + existingWidget.linkedPage = newWidget.linkedPage + existingWidget.visibility = newWidget.visibility existingWidget.sendCommand = newWidget.sendCommand } } From c7c0c958876dd907e09f526f457bce1735eabc51 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Tue, 10 Feb 2026 23:46:40 +0100 Subject: [PATCH 449/476] Fix ButtonGrid commands to target parent widget item Route button grid commands through the parent widget's item instead of the synthesized button widget, matching the openHAB server expectation. Also propagate releaseCommand from mappings to support press-release button behavior. Signed-off-by: Tim Mueller-Seydlitz --- openHAB/SwiftUI/Rows/ButtonGridRowView.swift | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/openHAB/SwiftUI/Rows/ButtonGridRowView.swift b/openHAB/SwiftUI/Rows/ButtonGridRowView.swift index 8a004251e..0395c0ac0 100644 --- a/openHAB/SwiftUI/Rows/ButtonGridRowView.swift +++ b/openHAB/SwiftUI/Rows/ButtonGridRowView.swift @@ -16,6 +16,7 @@ import SwiftUI struct ButtonGridButton: View { @ObservedObject var widget: OpenHABWidget + let parentItem: OpenHABItem? @State private var isPressed = false @EnvironmentObject var viewModel: SitemapPageViewModel @@ -84,7 +85,7 @@ struct ButtonGridButton: View { private func handleButtonPress() { if let command = widget.command, !command.isEmpty { logger.info("Sending command: \(command)") - viewModel.sendCommand(widget.item, commandToSend: widget.command) + sendCommand(command) } } @@ -95,7 +96,7 @@ struct ButtonGridButton: View { if hasPressRelease, let command = widget.command { triggerFeedback.toggle() logger.info("Sending press command: \(command)") - widget.sendCommand(command) + sendCommand(command) } } @@ -105,9 +106,18 @@ struct ButtonGridButton: View { // For press-release buttons, send release command on release if let releaseCommand = widget.releaseCommand, !releaseCommand.isEmpty { logger.info("Sending release command: \(releaseCommand)") - widget.sendCommand(releaseCommand) + sendCommand(releaseCommand) } } + + private func sendCommand(_ command: String) { + // Most button grid commands target the parent widget item. + if let commandItem = widget.item ?? parentItem { + viewModel.sendCommand(commandItem, commandToSend: command) + return + } + widget.sendCommand(command) + } } private struct PressGestureModifier: ViewModifier { @@ -185,7 +195,7 @@ struct ButtonGridRowView: View { if let button, button.visibility { - ButtonGridButton(widget: button) + ButtonGridButton(widget: button, parentItem: widget.item) .id(viewModel.pageId + button.widgetId) } else { // Empty cell to maintain grid structure @@ -221,7 +231,7 @@ extension OpenHABWidgetMapping { widget.visibility = true widget.row = row widget.column = column - widget.releaseCommand = "" + widget.releaseCommand = releaseCommand widget.stateless = true widget.icon = icon ?? "" return widget From d7353daf07b6cd1a0d9492318d8f48806c37cee1 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Tue, 10 Feb 2026 23:46:50 +0100 Subject: [PATCH 450/476] Move test helpers above test suites for consistency Reorder MockURLProtocol and helper functions to appear before the test suites that use them, matching Swift convention of declaring types before usage. Signed-off-by: Tim Mueller-Seydlitz --- .../OpenHABCoreTests/ETagCheckerTests.swift | 128 +++++++++--------- .../WidgetCommandSenderTests.swift | 56 ++++---- 2 files changed, 92 insertions(+), 92 deletions(-) diff --git a/OpenHABCore/Tests/OpenHABCoreTests/ETagCheckerTests.swift b/OpenHABCore/Tests/OpenHABCoreTests/ETagCheckerTests.swift index 797e537d5..4c0059f4e 100644 --- a/OpenHABCore/Tests/OpenHABCoreTests/ETagCheckerTests.swift +++ b/OpenHABCore/Tests/OpenHABCoreTests/ETagCheckerTests.swift @@ -13,6 +13,70 @@ import Foundation @testable import OpenHABCore import Testing +// MARK: - Mock URLProtocol + +class MockURLProtocol: URLProtocol { + // Use nonisolated(unsafe) to suppress concurrency warnings + // Safe because tests run with .serialized, ensuring sequential execution + nonisolated(unsafe) static var mockResponses: [URL: (statusCode: Int, headers: [String: String])] = [:] + nonisolated(unsafe) static var shouldFail = false + nonisolated(unsafe) static var error: Error? + + static func reset() { + mockResponses.removeAll() + shouldFail = false + error = nil + } + + override class func canInit(with request: URLRequest) -> Bool { + true + } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + if MockURLProtocol.shouldFail { + let error = MockURLProtocol.error ?? URLError(.badServerResponse) + client?.urlProtocol(self, didFailWithError: error) + return + } + + guard let url = request.url else { + client?.urlProtocol(self, didFailWithError: URLError(.badURL)) + return + } + + guard let mock = MockURLProtocol.mockResponses[url] else { + // Return 404 if no mock configured + let response = HTTPURLResponse( + url: url, + statusCode: 404, + httpVersion: "HTTP/1.1", + headerFields: nil + )! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: Data()) + client?.urlProtocolDidFinishLoading(self) + return + } + + let response = HTTPURLResponse( + url: url, + statusCode: mock.statusCode, + httpVersion: "HTTP/1.1", + headerFields: mock.headers + )! + + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: Data()) + client?.urlProtocolDidFinishLoading(self) + } + + override func stopLoading() {} +} + @Suite("ETagChecker Tests", .serialized) struct ETagCheckerTests { @Test("First run stores ETag and returns changed") @@ -246,67 +310,3 @@ struct ETagCheckerTests { ) } } - -// MARK: - Mock URLProtocol - -class MockURLProtocol: URLProtocol { - // Use nonisolated(unsafe) to suppress concurrency warnings - // Safe because tests run with .serialized, ensuring sequential execution - nonisolated(unsafe) static var mockResponses: [URL: (statusCode: Int, headers: [String: String])] = [:] - nonisolated(unsafe) static var shouldFail: Bool = false - nonisolated(unsafe) static var error: Error? - - override class func canInit(with request: URLRequest) -> Bool { - true - } - - override class func canonicalRequest(for request: URLRequest) -> URLRequest { - request - } - - override func startLoading() { - if MockURLProtocol.shouldFail { - let error = MockURLProtocol.error ?? URLError(.badServerResponse) - client?.urlProtocol(self, didFailWithError: error) - return - } - - guard let url = request.url else { - client?.urlProtocol(self, didFailWithError: URLError(.badURL)) - return - } - - guard let mock = MockURLProtocol.mockResponses[url] else { - // Return 404 if no mock configured - let response = HTTPURLResponse( - url: url, - statusCode: 404, - httpVersion: "HTTP/1.1", - headerFields: nil - )! - client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) - client?.urlProtocol(self, didLoad: Data()) - client?.urlProtocolDidFinishLoading(self) - return - } - - let response = HTTPURLResponse( - url: url, - statusCode: mock.statusCode, - httpVersion: "HTTP/1.1", - headerFields: mock.headers - )! - - client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) - client?.urlProtocol(self, didLoad: Data()) - client?.urlProtocolDidFinishLoading(self) - } - - override func stopLoading() {} - - static func reset() { - mockResponses.removeAll() - shouldFail = false - error = nil - } -} diff --git a/openHABWatchTests/WidgetCommandSenderTests.swift b/openHABWatchTests/WidgetCommandSenderTests.swift index 8c0bfecb0..2526fb2bc 100644 --- a/openHABWatchTests/WidgetCommandSenderTests.swift +++ b/openHABWatchTests/WidgetCommandSenderTests.swift @@ -11,8 +11,35 @@ import Foundation import OpenHABCore -import Testing @testable import openHABWatch +import Testing + +private final class CommandRecorder { + var commands: [String] = [] +} + +private func makeWidget(widgetId: String, recorder: CommandRecorder) -> OpenHABWidget { + let item = OpenHABItem( + name: "TestItem", + type: "Switch", + state: "OFF", + link: "", + label: nil, + groupType: nil, + stateDescription: nil, + commandDescription: nil, + members: [], + category: nil, + options: nil + ) + let widget = OpenHABWidget(icon: "switch") + widget.widgetId = widgetId + widget.item = item + widget.sendCommand = { _, command in + recorder.commands.append(command ?? "") + } + return widget +} struct WidgetCommandSenderTests { @Test("Immediate policy sends command once") @@ -82,30 +109,3 @@ struct WidgetCommandSenderTests { #expect(recorder.commands.first?.contains("21") == true) } } - -private final class CommandRecorder { - var commands: [String] = [] -} - -private func makeWidget(widgetId: String, recorder: CommandRecorder) -> OpenHABWidget { - let item = OpenHABItem( - name: "TestItem", - type: "Switch", - state: "OFF", - link: "", - label: nil, - groupType: nil, - stateDescription: nil, - commandDescription: nil, - members: [], - category: nil, - options: nil - ) - let widget = OpenHABWidget(icon: "switch") - widget.widgetId = widgetId - widget.item = item - widget.sendCommand = { _, command in - recorder.commands.append(command ?? "") - } - return widget -} From b88f390c5dd07c49540354438e40eccf0632f4a9 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Wed, 11 Feb 2026 08:42:00 +0100 Subject: [PATCH 451/476] Fix webview authentication in SwiftUI sitemap renderer Add respondTo challenge delegate to WebRowView's Coordinator so that webviews requiring HTTP Basic Auth receive credentials, matching the existing behavior in WebUITableViewCell. Fixes #1034. Signed-off-by: Tim Mueller-Seydlitz --- openHAB/SwiftUI/Rows/WebRowView.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/openHAB/SwiftUI/Rows/WebRowView.swift b/openHAB/SwiftUI/Rows/WebRowView.swift index a6235e411..6511992bc 100644 --- a/openHAB/SwiftUI/Rows/WebRowView.swift +++ b/openHAB/SwiftUI/Rows/WebRowView.swift @@ -47,6 +47,11 @@ struct WebRowView: UIViewRepresentable { func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: any Error) { logger.debug("WebView failed to load: \(error.localizedDescription)") } + + func webView(_ webView: WKWebView, + respondTo challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { + await onReceiveSessionChallenge(with: challenge) + } } @ObservedObject var widget: OpenHABWidget From 5a558a3b4495fef787e38d96d7e05a3c861c78a5 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Wed, 11 Feb 2026 22:28:29 +0100 Subject: [PATCH 452/476] Minor cleanup Signed-off-by: Tim Mueller-Seydlitz --- openHAB/SwiftUI/Rows/ButtonGridRowView.swift | 3 +++ openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift | 2 -- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/openHAB/SwiftUI/Rows/ButtonGridRowView.swift b/openHAB/SwiftUI/Rows/ButtonGridRowView.swift index 0395c0ac0..e8e481272 100644 --- a/openHAB/SwiftUI/Rows/ButtonGridRowView.swift +++ b/openHAB/SwiftUI/Rows/ButtonGridRowView.swift @@ -238,6 +238,9 @@ extension OpenHABWidgetMapping { } } +/// A SwiftUI View extension to handle press and release gesture events. +/// Used specifically for button grid buttons that need to send commands on press and release +/// (supporting the releaseCommand feature from openHAB). extension View { func onPressGesture(onPress: @escaping () -> Void, onRelease: @escaping () -> Void) -> some View { diff --git a/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift b/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift index 7e4938297..be2c313dd 100644 --- a/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift +++ b/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift @@ -21,8 +21,6 @@ struct CustomSliderView: View { let step: Double let onEditingChanged: () -> Void - @GestureState private var dragOffset: CGSize = .zero - @State private var lastSendTime: Date = .distantPast var body: some View { From 719fe960e969afd88e633adb6d20f37586775edc Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Thu, 12 Feb 2026 20:08:22 +0100 Subject: [PATCH 453/476] Provide command source for SwiftUI and watchOS Extend PR #1064 command source support to the Watch app and deep-link command path. NetworkTracker now forwards sourcePrefix and deviceId to OpenAPIService, and OpenAPIService uses org.openhab.watchos when compiled for watchOS. Signed-off-by: Tim Mueller-Seydlitz --- .../Sources/OpenHABCore/Util/NetworkTracker.swift | 12 ++++++------ .../Sources/OpenHABCore/Util/OpenAPIService.swift | 4 ++++ openHAB/OpenHABRootViewController.swift | 3 ++- openHABWatch/Domain/UserData.swift | 13 ++++++++++++- 4 files changed, 24 insertions(+), 8 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift index 0eff3686a..5d4e170f0 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift @@ -523,16 +523,16 @@ public extension NetworkTracker { return service } - func send(to item: OpenHABItem, command: String) async throws { - try await send(to: item.name, command: command) + func send(to item: OpenHABItem, command: String, sourcePrefix: String? = nil, deviceId: String? = nil) async throws { + try await send(to: item.name, command: command, sourcePrefix: sourcePrefix, deviceId: deviceId) } - func send(to item: String, command: String) async throws { - try await service().sendItemCommand(itemname: item, command: command, sourcePrefix: nil, deviceId: nil) + func send(to item: String, command: String, sourcePrefix: String? = nil, deviceId: String? = nil) async throws { + try await service().sendItemCommand(itemname: item, command: command, sourcePrefix: sourcePrefix, deviceId: deviceId) } - func updateState(item: OpenHABItem, state: String) async throws { - try await service().updateItemState(itemname: item.name, with: state, sourcePrefix: nil, deviceId: nil) + func updateState(item: OpenHABItem, state: String, sourcePrefix: String? = nil, deviceId: String? = nil) async throws { + try await service().updateItemState(itemname: item.name, with: state, sourcePrefix: sourcePrefix, deviceId: deviceId) } func getStaticItems() async throws -> [OpenHABItem] { diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift index 99a8331c0..f535540c9 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift @@ -117,7 +117,11 @@ public actor OpenAPIService { } private func sourceComponent(deviceId: String?) -> String? { + #if os(watchOS) + let base = "org.openhab.watchos" + #else let base = "org.openhab.ios" + #endif guard let deviceId else { return base } let trimmed = deviceId.trimmingCharacters(in: .whitespacesAndNewlines) // Actor must not include the delegation separator per openHAB source spec. diff --git a/openHAB/OpenHABRootViewController.swift b/openHAB/OpenHABRootViewController.swift index 3afa1d486..1e66f1027 100644 --- a/openHAB/OpenHABRootViewController.swift +++ b/openHAB/OpenHABRootViewController.swift @@ -678,10 +678,11 @@ class OpenHABRootViewController: UIViewController { let itemName = String(components[0]) let itemCommand = String(components[1]) + let deviceId = UIDevice.current.identifierForVendor?.uuidString Task { do { Logger.viewController.info("Sending command") - try await NetworkTracker.shared.send(to: itemName, command: itemCommand) + try await NetworkTracker.shared.send(to: itemName, command: itemCommand, deviceId: deviceId) } catch NetworkTrackerError.noActiveConnection { displayErrorNotification("Could not find server") } catch { diff --git a/openHABWatch/Domain/UserData.swift b/openHABWatch/Domain/UserData.swift index fd5253bae..f04466755 100644 --- a/openHABWatch/Domain/UserData.swift +++ b/openHABWatch/Domain/UserData.swift @@ -15,6 +15,7 @@ import Foundation import OpenHABCore import os.log import SwiftUI +import WatchKit @MainActor final class UserData: ObservableObject { @@ -376,13 +377,23 @@ final class UserData: ObservableObject { func sendCommand(_ item: OpenHABItem?, command: String?) async { guard let item, let command else { return } + let sourcePrefix = sitemapSourcePrefix() + let deviceId = WKInterfaceDevice.current().identifierForVendor?.uuidString do { - try await NetworkTracker.shared.send(to: item, command: command) + try await NetworkTracker.shared.send(to: item, command: command, sourcePrefix: sourcePrefix, deviceId: deviceId) } catch { Logger.userData.error("Failed to send command '\(command)' to '\(item.name)': \(error.localizedDescription)") } } + private func sitemapSourcePrefix() -> String? { + let sitemapName = currentlyLoadingSitemap ?? "" + guard !sitemapName.isEmpty else { return nil } + let pageId = openHABSitemapPage?.pageId ?? "" + let suffix = pageId.isEmpty ? "" : ":\(pageId)" + return "org.openhab.ui.basic$\(sitemapName)\(suffix)" + } + func refreshUrl() async { guard AppSettings.shared.haveReceivedAppContext, !AppSettings.shared.openHABRootUrl.isEmpty else { return } From 44aee423d06fb034ba302fa9beafcc45c0c9f73b Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Fri, 13 Feb 2026 17:04:08 +0100 Subject: [PATCH 454/476] Move WidgetCommandDispatcher into OpenHABCore for shared use Consolidate command dispatching logic from the watchOS-only WidgetCommandSender into a shared WidgetCommandDispatcher in OpenHABCore, making debounce and immediate command policies available to both iOS SwiftUI rows and watchOS rows. Update SitemapPageViewModel and all row views to use the shared dispatcher. Signed-off-by: Tim Mueller-Seydlitz --- .../Util/WidgetCommandDispatcher.swift | 172 ++++++++++++++++++ .../Util}/WidgetCommandPolicy.swift | 13 +- openHAB.xcodeproj/project.pbxproj | 19 -- openHAB/SitemapPageViewModel.swift | 41 ++++- openHAB/SwiftUI/Rows/ButtonGridRowView.swift | 11 +- openHAB/SwiftUI/Rows/ColorPickerRowView.swift | 33 ++-- .../Rows/ColorTemperaturePickerRowView.swift | 18 +- .../SwiftUI/Rows/DatePickerInputRowView.swift | 2 +- .../SwiftUI/Rows/RollershutterRowView.swift | 6 +- openHAB/SwiftUI/Rows/SegmentedRowView.swift | 8 +- openHAB/SwiftUI/Rows/SelectionRowView.swift | 2 +- openHAB/SwiftUI/Rows/SetpointRowView.swift | 2 +- openHAB/SwiftUI/Rows/SliderRowView.swift | 30 ++- openHAB/SwiftUI/Rows/SwitchRowView.swift | 2 +- openHAB/SwiftUI/Rows/TextInputRowView.swift | 2 +- openHABWatch/Model/WidgetCommandSender.swift | 87 --------- openHABWatch/Views/Rows/ColorPickerRow.swift | 2 +- .../Views/Rows/RollershutterRow.swift | 2 +- openHABWatch/Views/Rows/SegmentRow.swift | 2 +- .../Views/Rows/SegmentSelectionView.swift | 2 +- openHABWatch/Views/Rows/SelectionRow.swift | 2 +- openHABWatch/Views/Rows/SetpointRow.swift | 2 +- openHABWatch/Views/Rows/SliderRow.swift | 2 +- openHABWatch/Views/Rows/SwitchRow.swift | 2 +- openHABWatch/Views/Utils/ColorSelection.swift | 2 +- ...ift => WidgetCommandDispatcherTests.swift} | 12 +- 26 files changed, 299 insertions(+), 179 deletions(-) create mode 100644 OpenHABCore/Sources/OpenHABCore/Util/WidgetCommandDispatcher.swift rename {openHABWatch/Model => OpenHABCore/Sources/OpenHABCore/Util}/WidgetCommandPolicy.swift (61%) delete mode 100644 openHABWatch/Model/WidgetCommandSender.swift rename openHABWatchTests/{WidgetCommandSenderTests.swift => WidgetCommandDispatcherTests.swift} (92%) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/WidgetCommandDispatcher.swift b/OpenHABCore/Sources/OpenHABCore/Util/WidgetCommandDispatcher.swift new file mode 100644 index 000000000..dba159565 --- /dev/null +++ b/OpenHABCore/Sources/OpenHABCore/Util/WidgetCommandDispatcher.swift @@ -0,0 +1,172 @@ +// Copyright (c) 2010-2026 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 Foundation + +public final class WidgetCommandDispatcher { + private var pendingTasks: [String: Task] = [:] + + public init() {} + + @MainActor + public func send(_ command: String?, + for widget: OpenHABWidget, + policy: WidgetCommandPolicy, + key: String? = nil, + fallbackItem: OpenHABItem? = nil) { + guard let command, !command.isEmpty else { return } + + switch policy { + case .immediate: + dispatch(command: command, for: widget, fallbackItem: fallbackItem) + case let .debounce(duration): + sendDebounced( + command, + for: widget, + duration: duration, + key: key, + fallbackItem: fallbackItem + ) + } + } + + @MainActor + public func send(_ command: String?, + for item: OpenHABItem?, + policy: WidgetCommandPolicy, + key: String? = nil, + execute: @escaping @MainActor (_ itemname: String, _ command: String) -> Void) { + guard let command, !command.isEmpty, let item else { return } + + switch policy { + case .immediate: + execute(item.name, command) + case let .debounce(duration): + sendDebounced( + command, + for: item, + duration: duration, + key: key, + execute: execute + ) + } + } + + @MainActor + public func sendPress(_ command: String?, + for widget: OpenHABWidget, + fallbackItem: OpenHABItem? = nil) { + guard let command, !command.isEmpty else { return } + dispatch(command: command, for: widget, fallbackItem: fallbackItem) + } + + @MainActor + public func sendRelease(_ command: String?, + for widget: OpenHABWidget, + fallbackItem: OpenHABItem? = nil) { + guard let command, !command.isEmpty else { return } + dispatch(command: command, for: widget, fallbackItem: fallbackItem) + } + + @MainActor + public func sendItemUpdate(_ state: NumberState?, + for widget: OpenHABWidget, + fallbackItem: OpenHABItem? = nil) { + guard let state else { return } + + if let item = widget.item ?? fallbackItem, + let sendCommand = widget.sendCommand { + if item.isOfTypeOrGroupType(.numberWithDimension) { + sendCommand(item, state.toString(locale: Locale(identifier: "US"))) + } else { + sendCommand(item, state.stringValue) + } + return + } + + widget.sendItemUpdate(state: state) + } + + @MainActor + public func cancelPending(for widget: OpenHABWidget, key: String? = nil) { + let taskKey = commandKey(for: widget, key: key) + pendingTasks[taskKey]?.cancel() + pendingTasks.removeValue(forKey: taskKey) + } + + @MainActor + public func cancelPending(for item: OpenHABItem, key: String? = nil) { + let taskKey = commandKey(for: item, key: key) + pendingTasks[taskKey]?.cancel() + pendingTasks.removeValue(forKey: taskKey) + } + + @MainActor + private func sendDebounced(_ command: String, + for widget: OpenHABWidget, + duration: Duration, + key: String?, + fallbackItem: OpenHABItem?) { + let taskKey = commandKey(for: widget, key: key) + pendingTasks[taskKey]?.cancel() + pendingTasks[taskKey] = Task { @MainActor [weak self] in + try? await Task.sleep(for: duration) + guard !Task.isCancelled else { return } + self?.dispatch(command: command, for: widget, fallbackItem: fallbackItem) + self?.pendingTasks.removeValue(forKey: taskKey) + } + } + + @MainActor + private func sendDebounced(_ command: String, + for item: OpenHABItem, + duration: Duration, + key: String?, + execute: @escaping @MainActor (_ itemname: String, _ command: String) -> Void) { + let taskKey = commandKey(for: item, key: key) + pendingTasks[taskKey]?.cancel() + pendingTasks[taskKey] = Task { @MainActor [weak self] in + try? await Task.sleep(for: duration) + guard !Task.isCancelled else { return } + execute(item.name, command) + self?.pendingTasks.removeValue(forKey: taskKey) + } + } + + @MainActor + private func dispatch(command: String, for widget: OpenHABWidget, fallbackItem: OpenHABItem?) { + if let item = widget.item ?? fallbackItem, + let sendCommand = widget.sendCommand { + sendCommand(item, command) + return + } + widget.sendCommand(command) + } + + private func commandKey(for widget: OpenHABWidget, key: String?) -> String { + if let key { + return "\(widget.widgetId)-\(key)" + } + return widget.widgetId + } + + private func commandKey(for item: OpenHABItem, key: String?) -> String { + if let key { + return "\(item.name)-\(key)" + } + return item.name + } + + deinit { + pendingTasks.values.forEach { $0.cancel() } + pendingTasks.removeAll() + } +} diff --git a/openHABWatch/Model/WidgetCommandPolicy.swift b/OpenHABCore/Sources/OpenHABCore/Util/WidgetCommandPolicy.swift similarity index 61% rename from openHABWatch/Model/WidgetCommandPolicy.swift rename to OpenHABCore/Sources/OpenHABCore/Util/WidgetCommandPolicy.swift index ca90bfb28..647f4f746 100644 --- a/openHABWatch/Model/WidgetCommandPolicy.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/WidgetCommandPolicy.swift @@ -10,14 +10,13 @@ // SPDX-License-Identifier: EPL-2.0 import Foundation -import OpenHABCore -enum WidgetCommandDefaults { - static let slider: WidgetCommandPolicy = .debounce(.milliseconds(500)) - static let colorPicker: WidgetCommandPolicy = .debounce(.milliseconds(200)) - static let immediate: WidgetCommandPolicy = .immediate +public enum WidgetCommandDefaults { + public static let slider: WidgetCommandPolicy = .debounce(.milliseconds(500)) + public static let colorPicker: WidgetCommandPolicy = .debounce(.milliseconds(200)) + public static let immediate: WidgetCommandPolicy = .immediate - static func policy(for widget: OpenHABWidget) -> WidgetCommandPolicy { + public static func policy(for widget: OpenHABWidget) -> WidgetCommandPolicy { switch widget.type { case .slider: slider @@ -29,7 +28,7 @@ enum WidgetCommandDefaults { } } -enum WidgetCommandPolicy: Sendable { +public enum WidgetCommandPolicy: Sendable { case immediate case debounce(Duration) } diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index d8e3e228e..a9d5d916b 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -143,8 +143,6 @@ DA8B14B82F3A120E007753FD /* PreviewWidgetFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA8B14B72F3A11F0007753FD /* PreviewWidgetFactory.swift */; }; DA8B14BA2F3A373A007753FD /* PreviewNavigationContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA8B14B92F3A373A007753FD /* PreviewNavigationContainer.swift */; }; DA8B14BC2F3A3CB5007753FD /* WatchTypography.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA8B14BB2F3A3CB5007753FD /* WatchTypography.swift */; }; - DA8B14BF2F3A98A1007753FD /* WidgetCommandSender.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA8B14BE2F3A98A1007753FD /* WidgetCommandSender.swift */; }; - DA8B14C02F3A98A1007753FD /* WidgetCommandPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA8B14BD2F3A98A1007753FD /* WidgetCommandPolicy.swift */; }; DA94AEB42EC896BE003BB3C8 /* SimpleMJPEGPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA94AEB32EC896BE003BB3C8 /* SimpleMJPEGPlayer.swift */; }; DA95F3332E0F2B1700FE4474 /* OpenHABRootViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA95F3322E0F2B1700FE4474 /* OpenHABRootViewController.swift */; }; DA95F3352E0F2C1600FE4474 /* OpenHABSitemapViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA95F3342E0F2C1600FE4474 /* OpenHABSitemapViewController.swift */; }; @@ -511,8 +509,6 @@ DA8B14B72F3A11F0007753FD /* PreviewWidgetFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewWidgetFactory.swift; sourceTree = ""; }; DA8B14B92F3A373A007753FD /* PreviewNavigationContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewNavigationContainer.swift; sourceTree = ""; }; DA8B14BB2F3A3CB5007753FD /* WatchTypography.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchTypography.swift; sourceTree = ""; }; - DA8B14BD2F3A98A1007753FD /* WidgetCommandPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetCommandPolicy.swift; sourceTree = ""; }; - DA8B14BE2F3A98A1007753FD /* WidgetCommandSender.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetCommandSender.swift; sourceTree = ""; }; DA8B15512F3BB74B007753FD /* openHABWatchTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = openHABWatchTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; DA94AEB32EC896BE003BB3C8 /* SimpleMJPEGPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleMJPEGPlayer.swift; sourceTree = ""; }; DA94AF432EC8DE41003BB3C8 /* VideoStreamManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoStreamManager.swift; sourceTree = ""; }; @@ -602,17 +598,6 @@ /* Begin PBXFileSystemSynchronizedRootGroup section */ DA2AEB752D92D32000897D80 /* Cells */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Cells; sourceTree = ""; }; DA8B15522F3BB74B007753FD /* openHABWatchTests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = openHABWatchTests; sourceTree = ""; }; - DA2AEB752D92D32000897D80 /* Cells */ = { - isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - ); - explicitFileTypes = { - }; - explicitFolders = ( - ); - path = Cells; - sourceTree = ""; - }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -807,8 +792,6 @@ 93F38D4623803731001B1451 /* Model */ = { isa = PBXGroup; children = ( - DA8B14BD2F3A98A1007753FD /* WidgetCommandPolicy.swift */, - DA8B14BE2F3A98A1007753FD /* WidgetCommandSender.swift */, DA15BFBC23C6726400BD8ADA /* AppSettings.swift */, DA9721C224E29A8F0092CCFD /* UserDefaultsBacked.swift */, DAC9AF4624F9669F006DAE93 /* OpenHABWidgetExtension.swift */, @@ -1776,8 +1759,6 @@ DAF457A223DB6C640018B495 /* RollershutterRow.swift in Sources */, DA8B14BA2F3A373A007753FD /* PreviewNavigationContainer.swift in Sources */, DAF457A923DBA4990018B495 /* FrameRow.swift in Sources */, - DA8B14BF2F3A98A1007753FD /* WidgetCommandSender.swift in Sources */, - DA8B14C02F3A98A1007753FD /* WidgetCommandPolicy.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/openHAB/SitemapPageViewModel.swift b/openHAB/SitemapPageViewModel.swift index 04bc4ad54..cd1a7f2ef 100644 --- a/openHAB/SitemapPageViewModel.swift +++ b/openHAB/SitemapPageViewModel.swift @@ -49,6 +49,7 @@ class SitemapPageViewModel: ObservableObject { private var activeConnectionInfo: ConnectionInfo? private var pageHandlingTask: Task? private var connectionObserverTask: Task? + private let commandDispatcher = WidgetCommandDispatcher() private var defaultSitemap = "" private var defaultSitemapLabel = "" @Published var pageId = "" @@ -496,9 +497,31 @@ class SitemapPageViewModel: ObservableObject { // MARK: - Command Sending + func sendCommand(_ command: String?, + for widget: OpenHABWidget, + policy: WidgetCommandPolicy = .immediate, + key: String? = nil, + fallbackItem: OpenHABItem? = nil) { + commandDispatcher.send( + command, + for: widget, + policy: policy, + key: key, + fallbackItem: fallbackItem + ) + } + + func cancelPendingCommand(for widget: OpenHABWidget, key: String? = nil) { + commandDispatcher.cancelPending(for: widget, key: key) + } + + func cancelPendingCommand(for item: OpenHABItem, key: String? = nil) { + commandDispatcher.cancelPending(for: item, key: key) + } + func sendCommand(_ item: OpenHABItem?, commandToSend command: String?) { - if let item, let command { - sendCommand(itemname: item.name, command: command) + commandDispatcher.send(command, for: item, policy: .immediate) { [weak self] itemname, command in + self?.sendCommand(itemname: itemname, command: command) } } @@ -513,17 +536,23 @@ class SitemapPageViewModel: ObservableObject { } } - func sendToUpdate(item: OpenHABItem?, state: NumberState?) { + func sendToUpdate(item: OpenHABItem?, + state: NumberState?, + policy: WidgetCommandPolicy = .immediate, + key: String? = nil) { guard let item, let state else { logger.info("ItemUpdate for Item or State = nil") return } - if item.isOfTypeOrGroupType(.numberWithDimension) { + let command: String = if item.isOfTypeOrGroupType(.numberWithDimension) { // For number items, include unit (if present) in command - sendCommand(item, commandToSend: state.toString(locale: Locale(identifier: "US"))) + state.toString(locale: Locale(identifier: "US")) } else { // For all other items, send the plain value - sendCommand(item, commandToSend: state.stringValue) + state.stringValue + } + commandDispatcher.send(command, for: item, policy: policy, key: key) { [weak self] itemname, command in + self?.sendCommand(itemname: itemname, command: command) } } diff --git a/openHAB/SwiftUI/Rows/ButtonGridRowView.swift b/openHAB/SwiftUI/Rows/ButtonGridRowView.swift index e8e481272..11e943471 100644 --- a/openHAB/SwiftUI/Rows/ButtonGridRowView.swift +++ b/openHAB/SwiftUI/Rows/ButtonGridRowView.swift @@ -111,12 +111,11 @@ struct ButtonGridButton: View { } private func sendCommand(_ command: String) { - // Most button grid commands target the parent widget item. - if let commandItem = widget.item ?? parentItem { - viewModel.sendCommand(commandItem, commandToSend: command) - return - } - widget.sendCommand(command) + viewModel.sendCommand( + command, + for: widget, + fallbackItem: parentItem + ) } } diff --git a/openHAB/SwiftUI/Rows/ColorPickerRowView.swift b/openHAB/SwiftUI/Rows/ColorPickerRowView.swift index d9c509e1b..0aa95990e 100644 --- a/openHAB/SwiftUI/Rows/ColorPickerRowView.swift +++ b/openHAB/SwiftUI/Rows/ColorPickerRowView.swift @@ -19,8 +19,9 @@ struct ColorPickerRowView: View { @State private var selectedColor: Color = .white @EnvironmentObject var viewModel: SitemapPageViewModel - @State private var lastSendTime: Date = .distantPast - @State private var debounceTask: Task? + private var colorCommandKey: String { + "color-\(widget.widgetId)" + } private let logger = Logger(subsystem: "org.openhab", category: "WidgetColorPickerView") @@ -40,19 +41,7 @@ struct ColorPickerRowView: View { ColorPicker("Color", selection: $selectedColor, supportsOpacity: false) .labelsHidden() .onChange(of: selectedColor) { newColor in - let now = Date() - if now.timeIntervalSince(lastSendTime) > 0.2 { - lastSendTime = now - sendColorCommand(newColor) - } - // ColorPicker does not provide an .onEnded like Slider as it doesn't expose the drag lifecycle. - // It only emits onChange when the color value changes. - // Therefore, we debounce final send after 0.3s of no changes - debounceTask?.cancel() - debounceTask = Task { - try? await Task.sleep(nanoseconds: 300_000_000) // 0.3s - sendColorCommand(newColor) - } + sendColorCommand(newColor) } .disabled(widget.readOnly ?? false) @@ -72,6 +61,13 @@ struct ColorPickerRowView: View { guard !newState.isEmpty else { return } selectedColor = parseColor(from: newState) ?? .white } + .onDisappear { + if let item = widget.item { + viewModel.cancelPendingCommand(for: item, key: colorCommandKey) + } else { + viewModel.cancelPendingCommand(for: widget, key: colorCommandKey) + } + } } private func sendColorCommand(_ color: Color) { @@ -89,7 +85,12 @@ struct ColorPickerRowView: View { let command = "\(hueValue),\(saturationValue),\(brightnessValue)" logger.info("Sending color command: \(command)") - viewModel.sendCommand(widget.item, commandToSend: command) + viewModel.sendCommand( + command, + for: widget, + policy: WidgetCommandDefaults.colorPicker, + key: colorCommandKey + ) } private func parseColor(from state: String) -> Color? { diff --git a/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift b/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift index be2c313dd..75c520a84 100644 --- a/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift +++ b/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift @@ -66,6 +66,10 @@ struct ColorTemperaturePickerRowView: View { @State private var selectedTemperature: Double = 2700 // Default warm white @EnvironmentObject var viewModel: SitemapPageViewModel + private var colorTemperatureCommandKey: String { + "color-temperature-\(widget.widgetId)" + } + private let logger = Logger(subsystem: "org.openhab", category: "ColorTemperaturePickerRowView") // Use widget's min/max values, similar to Android implementation @@ -153,6 +157,13 @@ struct ColorTemperaturePickerRowView: View { .onChange(of: widget.item?.state ?? "") { newState in selectedTemperature = loadCurrentTemperature(state: newState) ?? 2700 } + .onDisappear { + if let item = widget.item { + viewModel.cancelPendingCommand(for: item, key: colorTemperatureCommandKey) + } else { + viewModel.cancelPendingCommand(for: widget, key: colorTemperatureCommandKey) + } + } } private var temperatureDescription: String { @@ -186,7 +197,12 @@ struct ColorTemperaturePickerRowView: View { let command = "\(Int(selectedTemperature))" logger.info("Sending color temperature command: \(command)K") - viewModel.sendCommand(widget.item, commandToSend: command) + viewModel.sendCommand( + command, + for: widget, + policy: WidgetCommandDefaults.slider, + key: colorTemperatureCommandKey + ) } } diff --git a/openHAB/SwiftUI/Rows/DatePickerInputRowView.swift b/openHAB/SwiftUI/Rows/DatePickerInputRowView.swift index cf7aded12..dcd46aaf0 100644 --- a/openHAB/SwiftUI/Rows/DatePickerInputRowView.swift +++ b/openHAB/SwiftUI/Rows/DatePickerInputRowView.swift @@ -81,7 +81,7 @@ struct DatePickerInputRowView: View { let command = formatter.string(from: date) logger.info("Sending date command: \(command)") - viewModel.sendCommand(widget.item, commandToSend: command) + viewModel.sendCommand(command, for: widget) } private func parseDate(from state: String) -> Date? { diff --git a/openHAB/SwiftUI/Rows/RollershutterRowView.swift b/openHAB/SwiftUI/Rows/RollershutterRowView.swift index ef97b3fff..2a04cbbf1 100644 --- a/openHAB/SwiftUI/Rows/RollershutterRowView.swift +++ b/openHAB/SwiftUI/Rows/RollershutterRowView.swift @@ -49,7 +49,7 @@ struct RollershutterRowView: View { Button { triggerUpFeedback.toggle() logger.info("\("up button pressed")") - viewModel.sendCommand(widget.item, commandToSend: RollerShutterCommand.up.rawValue) + viewModel.sendCommand(RollerShutterCommand.up.rawValue, for: widget) } label: { Image(systemSymbol: .chevronUp) .font(.title2) @@ -61,7 +61,7 @@ struct RollershutterRowView: View { Button { triggerStopFeedback.toggle() logger.info("\("stop button pressed")") - viewModel.sendCommand(widget.item, commandToSend: RollerShutterCommand.stop.rawValue) + viewModel.sendCommand(RollerShutterCommand.stop.rawValue, for: widget) } label: { Image(systemSymbol: .stop) .font(.title2) @@ -73,7 +73,7 @@ struct RollershutterRowView: View { Button { triggerDownFeedback.toggle() logger.info("\("down button pressed")") - viewModel.sendCommand(widget.item, commandToSend: RollerShutterCommand.down.rawValue) + viewModel.sendCommand(RollerShutterCommand.down.rawValue, for: widget) } label: { Image(systemSymbol: .chevronDown) .font(.title2) diff --git a/openHAB/SwiftUI/Rows/SegmentedRowView.swift b/openHAB/SwiftUI/Rows/SegmentedRowView.swift index 8095bc862..eabd8e500 100644 --- a/openHAB/SwiftUI/Rows/SegmentedRowView.swift +++ b/openHAB/SwiftUI/Rows/SegmentedRowView.swift @@ -170,7 +170,7 @@ struct SegmentedRowView: View { if singlePressed == false { singlePressed = true logger.info("Segment mapping pressed, command: \(mapping.command)") - viewModel.sendCommand(widget.item, commandToSend: mapping.command) + viewModel.sendCommand(mapping.command, for: widget) } } .onEnded { _ in @@ -200,7 +200,7 @@ struct SegmentedRowView: View { withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { selectedIndex = index } - viewModel.sendCommand(widget.item, commandToSend: mapping.command) + viewModel.sendCommand(mapping.command, for: widget) } label: { Text(mapping.label) .font(.footnote) @@ -246,7 +246,7 @@ struct SegmentedRowView: View { pressedIndex = index // Send command on press logger.info("Sending press command: \(mapping.command)") - viewModel.sendCommand(widget.item, commandToSend: mapping.command) + viewModel.sendCommand(mapping.command, for: widget) } } .onEnded { _ in @@ -254,7 +254,7 @@ struct SegmentedRowView: View { // Send release command on release if let releaseCommand = mapping.releaseCommand, !releaseCommand.isEmpty { logger.info("Sending release command: \(releaseCommand)") - viewModel.sendCommand(widget.item, commandToSend: releaseCommand) + viewModel.sendCommand(releaseCommand, for: widget) } } ) diff --git a/openHAB/SwiftUI/Rows/SelectionRowView.swift b/openHAB/SwiftUI/Rows/SelectionRowView.swift index c74e5c50b..a2e859449 100644 --- a/openHAB/SwiftUI/Rows/SelectionRowView.swift +++ b/openHAB/SwiftUI/Rows/SelectionRowView.swift @@ -46,7 +46,7 @@ struct SelectionRowView: View { let isSelected = widget.item?.state == mapping.command Button { logger.info("Selection changed to: \(mapping.label)") - viewModel.sendCommand(widget.item, commandToSend: mapping.command) + viewModel.sendCommand(mapping.command, for: widget) } label: { if isSelected { Label(mapping.label, systemSymbol: .checkmark) diff --git a/openHAB/SwiftUI/Rows/SetpointRowView.swift b/openHAB/SwiftUI/Rows/SetpointRowView.swift index 5af3423cd..b53f7fa4d 100644 --- a/openHAB/SwiftUI/Rows/SetpointRowView.swift +++ b/openHAB/SwiftUI/Rows/SetpointRowView.swift @@ -117,7 +117,7 @@ struct SetpointRowView: View { numberState?.value = limitedNewValue logger.info("Setpoint \(isDecreasing ? "decreased" : "increased") to \(numberState?.description ?? String(limitedNewValue))") - viewModel.sendToUpdate(item: widget.item, state: numberState) + viewModel.sendToUpdate(item: widget.item, state: numberState, policy: .immediate) } } diff --git a/openHAB/SwiftUI/Rows/SliderRowView.swift b/openHAB/SwiftUI/Rows/SliderRowView.swift index d88d31eb0..2daaad4f7 100644 --- a/openHAB/SwiftUI/Rows/SliderRowView.swift +++ b/openHAB/SwiftUI/Rows/SliderRowView.swift @@ -23,7 +23,10 @@ struct SliderRowView: View { /// Pending value while user is dragging; nil when not actively changing @State private var pendingValue: Double? @State private var isEditing = false - @State private var lastSendTime: Date = .distantPast + + private var sliderCommandKey: String { + "slider-\(widget.widgetId)" + } private var sliderRange: ClosedRange { widget.minValue ... widget.maxValue @@ -45,11 +48,11 @@ struct SliderRowView: View { // Send updates during drag if enabled (throttled) if widget.shouldUseSliderUpdatesDuringMove() { - let now = Date() - if now.timeIntervalSince(lastSendTime) > 0.2 { - sendSliderUpdate(newValue) - lastSendTime = now - } + sendSliderUpdate( + newValue, + policy: WidgetCommandDefaults.slider, + key: sliderCommandKey + ) } } ) @@ -59,7 +62,7 @@ struct SliderRowView: View { HStack { if widget.switchSupport { Button { - viewModel.sendCommand(widget.item, commandToSend: currentValue <= widget.minValue ? "ON" : "OFF") + viewModel.sendCommand(currentValue <= widget.minValue ? "ON" : "OFF", for: widget) } label: { labelContent } @@ -74,7 +77,12 @@ struct SliderRowView: View { if !editing { // Always send the final value on release if let value = pendingValue { - sendSliderUpdate(value) + if let item = widget.item { + viewModel.cancelPendingCommand(for: item, key: sliderCommandKey) + } else { + viewModel.cancelPendingCommand(for: widget, key: sliderCommandKey) + } + sendSliderUpdate(value, policy: .immediate, key: sliderCommandKey) } // Keep pendingValue set until server responds to avoid visual jump // Fallback: clear after delay if server doesn't respond @@ -125,11 +133,13 @@ struct SliderRowView: View { .contentShape(Rectangle()) } - private func sendSliderUpdate(_ newValue: Double) { + private func sendSliderUpdate(_ newValue: Double, + policy: WidgetCommandPolicy, + key: String?) { var numberState = widget.stateValueAsNumberState numberState = numberState ?? NumberState(value: newValue) numberState?.value = newValue - viewModel.sendToUpdate(item: widget.item, state: numberState) + viewModel.sendToUpdate(item: widget.item, state: numberState, policy: policy, key: key) } } diff --git a/openHAB/SwiftUI/Rows/SwitchRowView.swift b/openHAB/SwiftUI/Rows/SwitchRowView.swift index 43c46b5df..80306361a 100644 --- a/openHAB/SwiftUI/Rows/SwitchRowView.swift +++ b/openHAB/SwiftUI/Rows/SwitchRowView.swift @@ -64,7 +64,7 @@ struct SwitchRowView: View { } else { logger.info("\("Switch to OFF")") } - viewModel.sendCommand(widget.item, commandToSend: newState) + viewModel.sendCommand(newState, for: widget) } )) .labelsHidden() diff --git a/openHAB/SwiftUI/Rows/TextInputRowView.swift b/openHAB/SwiftUI/Rows/TextInputRowView.swift index 40620dfc8..8736beafa 100644 --- a/openHAB/SwiftUI/Rows/TextInputRowView.swift +++ b/openHAB/SwiftUI/Rows/TextInputRowView.swift @@ -56,7 +56,7 @@ struct TextInputRowView: View { private func sendTextCommand() { logger.info("Sending text command: \(inputText)") - viewModel.sendCommand(widget.item, commandToSend: inputText) + viewModel.sendCommand(inputText, for: widget) isTextFieldFocused = false } } diff --git a/openHABWatch/Model/WidgetCommandSender.swift b/openHABWatch/Model/WidgetCommandSender.swift deleted file mode 100644 index 7c7197bfd..000000000 --- a/openHABWatch/Model/WidgetCommandSender.swift +++ /dev/null @@ -1,87 +0,0 @@ -// Copyright (c) 2010-2026 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 Foundation -import OpenHABCore -import os.log - -final class WidgetCommandSender { - private var pendingTasks: [String: Task] = [:] - - @MainActor - func send(_ command: String?, for widget: OpenHABWidget, policy: WidgetCommandPolicy, key: String? = nil) { - guard let command, !command.isEmpty else { return } - switch policy { - case .immediate: - Logger.rowViews.info("Sending command immediately: \(command)") - widget.sendCommand(command) - case let .debounce(duration): - sendDebounced(command, for: widget, duration: duration, key: key) - } - } - - @MainActor - func sendPress(_ command: String?, for widget: OpenHABWidget) { - guard let command, !command.isEmpty else { return } - Logger.rowViews.info("Sending press command: \(command)") - widget.sendCommand(command) - } - - @MainActor - func sendRelease(_ command: String?, for widget: OpenHABWidget) { - guard let command, !command.isEmpty else { return } - Logger.rowViews.info("Sending release command: \(command)") - widget.sendCommand(command) - } - - @MainActor - func sendItemUpdate(_ state: NumberState?, for widget: OpenHABWidget) { - guard state != nil else { return } - Logger.rowViews.info("Sending item update") - widget.sendItemUpdate(state: state) - } - - @MainActor - func cancelPending(for widget: OpenHABWidget, key: String? = nil) { - let taskKey = commandKey(for: widget, key: key) - pendingTasks[taskKey]?.cancel() - pendingTasks.removeValue(forKey: taskKey) - } - - @MainActor - private func sendDebounced(_ command: String, - for widget: OpenHABWidget, - duration: Duration, - key: String?) { - let taskKey = commandKey(for: widget, key: key) - pendingTasks[taskKey]?.cancel() - pendingTasks[taskKey] = Task { @MainActor [weak self] in - try? await Task.sleep(for: duration) - guard !Task.isCancelled else { return } - Logger.rowViews.info("Sending debounced command: \(command)") - widget.sendCommand(command) - self?.pendingTasks.removeValue(forKey: taskKey) - } - } - - @MainActor - private func commandKey(for widget: OpenHABWidget, key: String?) -> String { - if let key { - return "\(widget.widgetId)-\(key)" - } - return widget.widgetId - } - - deinit { - pendingTasks.values.forEach { $0.cancel() } - pendingTasks.removeAll() - } -} diff --git a/openHABWatch/Views/Rows/ColorPickerRow.swift b/openHABWatch/Views/Rows/ColorPickerRow.swift index 3b730f0a7..b5a0af56e 100644 --- a/openHABWatch/Views/Rows/ColorPickerRow.swift +++ b/openHABWatch/Views/Rows/ColorPickerRow.swift @@ -19,7 +19,7 @@ struct ColorPickerRow: View { let stateToken: String @EnvironmentObject var settings: AppSettings @State private var viewModel: WidgetRowViewModel - @State private var commandSender = WidgetCommandSender() + @State private var commandSender = WidgetCommandDispatcher() var body: some View { let uiColor = viewModel.colorState diff --git a/openHABWatch/Views/Rows/RollershutterRow.swift b/openHABWatch/Views/Rows/RollershutterRow.swift index 0a6b391bc..969a591e6 100644 --- a/openHABWatch/Views/Rows/RollershutterRow.swift +++ b/openHABWatch/Views/Rows/RollershutterRow.swift @@ -17,7 +17,7 @@ import SwiftUI struct RollershutterRow: View { let widget: OpenHABWidget @EnvironmentObject var settings: AppSettings - @State private var commandSender = WidgetCommandSender() + @State private var commandSender = WidgetCommandDispatcher() var body: some View { VStack(spacing: -5) { diff --git a/openHABWatch/Views/Rows/SegmentRow.swift b/openHABWatch/Views/Rows/SegmentRow.swift index 50925b2aa..8ea2797a6 100644 --- a/openHABWatch/Views/Rows/SegmentRow.swift +++ b/openHABWatch/Views/Rows/SegmentRow.swift @@ -20,7 +20,7 @@ struct SegmentRow: View { @State private var pressedIndex: Int? @State private var singlePressed = false @State private var viewModel: WidgetRowViewModel - @State private var commandSender = WidgetCommandSender() + @State private var commandSender = WidgetCommandDispatcher() private var currentIndex: Int? { viewModel.selectedIndex diff --git a/openHABWatch/Views/Rows/SegmentSelectionView.swift b/openHABWatch/Views/Rows/SegmentSelectionView.swift index facdb36b8..fd87588fe 100644 --- a/openHABWatch/Views/Rows/SegmentSelectionView.swift +++ b/openHABWatch/Views/Rows/SegmentSelectionView.swift @@ -19,7 +19,7 @@ struct SegmentSelectionView: View { @Environment(\.dismiss) private var dismiss @State private var pressedIndex: Int? @State private var viewModel: WidgetRowViewModel - @State private var commandSender = WidgetCommandSender() + @State private var commandSender = WidgetCommandDispatcher() var body: some View { ScrollView { diff --git a/openHABWatch/Views/Rows/SelectionRow.swift b/openHABWatch/Views/Rows/SelectionRow.swift index 3d466e6fa..e1407fd7f 100644 --- a/openHABWatch/Views/Rows/SelectionRow.swift +++ b/openHABWatch/Views/Rows/SelectionRow.swift @@ -21,7 +21,7 @@ struct SelectionListView: View { let title: String @Binding var selectedIndex: Int? @Environment(\.dismiss) private var dismiss - @State private var commandSender = WidgetCommandSender() + @State private var commandSender = WidgetCommandDispatcher() var body: some View { ScrollView { diff --git a/openHABWatch/Views/Rows/SetpointRow.swift b/openHABWatch/Views/Rows/SetpointRow.swift index f44b34db2..403e79b7e 100644 --- a/openHABWatch/Views/Rows/SetpointRow.swift +++ b/openHABWatch/Views/Rows/SetpointRow.swift @@ -23,7 +23,7 @@ struct SetpointRow: View { private let logger = Logger(subsystem: "org.openhab.watch", category: "SetpointRow") @State private var viewModel: WidgetRowViewModel @State private var localValue: Double? - @State private var commandSender = WidgetCommandSender() + @State private var commandSender = WidgetCommandDispatcher() private var currentValue: Double { localValue ?? viewModel.numberState?.value ?? viewModel.minValue diff --git a/openHABWatch/Views/Rows/SliderRow.swift b/openHABWatch/Views/Rows/SliderRow.swift index 451a451ec..98bec28ca 100644 --- a/openHABWatch/Views/Rows/SliderRow.swift +++ b/openHABWatch/Views/Rows/SliderRow.swift @@ -22,7 +22,7 @@ struct SliderRow: View { var fallbackSymbol: SFSymbol? @State private var pendingValue: Double? @State private var viewModel: WidgetRowViewModel - @State private var commandSender = WidgetCommandSender() + @State private var commandSender = WidgetCommandDispatcher() private var currentValue: Double { pendingValue ?? viewModel.adjustedValue diff --git a/openHABWatch/Views/Rows/SwitchRow.swift b/openHABWatch/Views/Rows/SwitchRow.swift index 04caac1e1..2158159d1 100644 --- a/openHABWatch/Views/Rows/SwitchRow.swift +++ b/openHABWatch/Views/Rows/SwitchRow.swift @@ -18,7 +18,7 @@ struct SwitchRow: View { @EnvironmentObject var settings: AppSettings let stateToken: String @State private var localIsOn: Bool? - @State private var commandSender = WidgetCommandSender() + @State private var commandSender = WidgetCommandDispatcher() private var isOn: Bool { localIsOn ?? stateToken.parseAsBool() diff --git a/openHABWatch/Views/Utils/ColorSelection.swift b/openHABWatch/Views/Utils/ColorSelection.swift index 97c58961f..bc98b615d 100644 --- a/openHABWatch/Views/Utils/ColorSelection.swift +++ b/openHABWatch/Views/Utils/ColorSelection.swift @@ -59,7 +59,7 @@ struct ColorSelection: View { @State private var xpos: Double = 100 @State private var ypos: Double = 100 @State private var dragStart: CGPoint? - @State private var commandSender = WidgetCommandSender() + @State private var commandSender = WidgetCommandDispatcher() private let handleRadius = 12.5 diff --git a/openHABWatchTests/WidgetCommandSenderTests.swift b/openHABWatchTests/WidgetCommandDispatcherTests.swift similarity index 92% rename from openHABWatchTests/WidgetCommandSenderTests.swift rename to openHABWatchTests/WidgetCommandDispatcherTests.swift index 2526fb2bc..aca74e19d 100644 --- a/openHABWatchTests/WidgetCommandSenderTests.swift +++ b/openHABWatchTests/WidgetCommandDispatcherTests.swift @@ -41,13 +41,13 @@ private func makeWidget(widgetId: String, recorder: CommandRecorder) -> OpenHABW return widget } -struct WidgetCommandSenderTests { +struct WidgetCommandDispatcherTests { @Test("Immediate policy sends command once") @MainActor func immediatePolicySendsOnce() async { let recorder = CommandRecorder() let widget = makeWidget(widgetId: "immediate", recorder: recorder) - let sender = WidgetCommandSender() + let sender = WidgetCommandDispatcher() sender.send("ON", for: widget, policy: .immediate) @@ -59,7 +59,7 @@ struct WidgetCommandSenderTests { func debounceKeepsFinalCommand() async throws { let recorder = CommandRecorder() let widget = makeWidget(widgetId: "debounce", recorder: recorder) - let sender = WidgetCommandSender() + let sender = WidgetCommandDispatcher() sender.send("ON", for: widget, policy: .debounce(.milliseconds(80))) sender.send("OFF", for: widget, policy: .debounce(.milliseconds(80))) @@ -73,7 +73,7 @@ struct WidgetCommandSenderTests { func cancelPendingPreventsDispatch() async throws { let recorder = CommandRecorder() let widget = makeWidget(widgetId: "cancel", recorder: recorder) - let sender = WidgetCommandSender() + let sender = WidgetCommandDispatcher() sender.send("ON", for: widget, policy: .debounce(.milliseconds(120))) sender.cancelPending(for: widget) @@ -87,7 +87,7 @@ struct WidgetCommandSenderTests { func pressReleaseDispatchesInOrder() async { let recorder = CommandRecorder() let widget = makeWidget(widgetId: "press-release", recorder: recorder) - let sender = WidgetCommandSender() + let sender = WidgetCommandDispatcher() sender.sendPress("UP", for: widget) sender.sendRelease("STOP", for: widget) @@ -100,7 +100,7 @@ struct WidgetCommandSenderTests { func itemUpdateDispatchesAndSkipsNil() async { let recorder = CommandRecorder() let widget = makeWidget(widgetId: "item-update", recorder: recorder) - let sender = WidgetCommandSender() + let sender = WidgetCommandDispatcher() sender.sendItemUpdate(NumberState(value: 21), for: widget) sender.sendItemUpdate(nil, for: widget) From 74154fffea93f1e8dc545bb271e8325ec72fc44f Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Fri, 13 Feb 2026 17:18:16 +0100 Subject: [PATCH 455/476] Extract WidgetDisplayState for consistent widget rendering Introduce an immutable WidgetDisplayState snapshot in OpenHABCore that centralizes label text, effective state, value ranges, and mapping resolution. All iOS SwiftUI row views and the watchOS WidgetRowViewModel now derive display properties from this single source instead of querying widget/item properties directly. Signed-off-by: Tim Mueller-Seydlitz --- .../Model/WidgetDisplayState.swift | 62 ++++++++++ .../WidgetDisplayStateTests.swift | 117 ++++++++++++++++++ openHAB/SwiftUI/Rows/ButtonGridRowView.swift | 7 +- openHAB/SwiftUI/Rows/ColorPickerRowView.swift | 11 +- .../Rows/ColorTemperaturePickerRowView.swift | 12 +- .../SwiftUI/Rows/DatePickerInputRowView.swift | 7 +- openHAB/SwiftUI/Rows/FrameRowView.swift | 3 +- openHAB/SwiftUI/Rows/GenericRowView.swift | 5 +- openHAB/SwiftUI/Rows/ImageRowView.swift | 6 +- openHAB/SwiftUI/Rows/MapRowView.swift | 4 +- .../SwiftUI/Rows/RollershutterRowView.swift | 4 +- openHAB/SwiftUI/Rows/SegmentedRowView.swift | 20 +-- openHAB/SwiftUI/Rows/SelectionRowView.swift | 17 +-- openHAB/SwiftUI/Rows/SetpointRowView.swift | 29 +++-- openHAB/SwiftUI/Rows/SliderRowView.swift | 21 ++-- openHAB/SwiftUI/Rows/SwitchRowView.swift | 18 ++- openHAB/SwiftUI/Rows/TextInputRowView.swift | 10 +- openHAB/SwiftUI/Rows/TextRowView.swift | 7 +- openHAB/SwiftUI/Rows/VideoRowView.swift | 6 +- openHAB/SwiftUI/Rows/WebRowView.swift | 6 +- openHABWatch/Model/WidgetRowViewModel.swift | 38 +++--- 21 files changed, 309 insertions(+), 101 deletions(-) create mode 100644 OpenHABCore/Sources/OpenHABCore/Model/WidgetDisplayState.swift create mode 100644 OpenHABCore/Tests/OpenHABCoreTests/WidgetDisplayStateTests.swift diff --git a/OpenHABCore/Sources/OpenHABCore/Model/WidgetDisplayState.swift b/OpenHABCore/Sources/OpenHABCore/Model/WidgetDisplayState.swift new file mode 100644 index 000000000..8ca0baa89 --- /dev/null +++ b/OpenHABCore/Sources/OpenHABCore/Model/WidgetDisplayState.swift @@ -0,0 +1,62 @@ +// Copyright (c) 2010-2026 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 Foundation + +/// Immutable snapshot of widget values required for row rendering. +public struct WidgetDisplayState: Sendable { + public let widgetId: String + public let labelText: String + public let labelValue: String? + public let effectiveState: String + public let isOn: Bool + public let adjustedValue: Double + public let minValue: Double + public let maxValue: Double + public let step: Double + public let switchSupport: Bool + public let hasLinkedPage: Bool + public let readOnly: Bool + public let mappings: [OpenHABWidgetMapping] + public let hasPressReleaseMappings: Bool + public let selectedIndex: Int? + public let selectedLabel: String? +} + +public extension OpenHABWidget { + var displayState: WidgetDisplayState { + let mappings = mappingsOrItemOptions + let effectiveState = state.isEmpty ? (item?.state ?? "") : state + let selectedIndex = mappingIndex(byCommand: item?.state).map { Int($0) } + let selectedLabel = selectedIndex.flatMap { index in + mappings.indices.contains(index) ? mappings[index].label : nil + } + + return WidgetDisplayState( + widgetId: widgetId, + labelText: labelText ?? label, + labelValue: labelValue, + effectiveState: effectiveState, + isOn: effectiveState.parseAsBool(), + adjustedValue: adjustedValue, + minValue: minValue, + maxValue: maxValue, + step: step, + switchSupport: switchSupport, + hasLinkedPage: linkedPage != nil, + readOnly: readOnly ?? false, + mappings: mappings, + hasPressReleaseMappings: hasPressReleaseMappings, + selectedIndex: selectedIndex, + selectedLabel: selectedLabel + ) + } +} diff --git a/OpenHABCore/Tests/OpenHABCoreTests/WidgetDisplayStateTests.swift b/OpenHABCore/Tests/OpenHABCoreTests/WidgetDisplayStateTests.swift new file mode 100644 index 000000000..b2a44694e --- /dev/null +++ b/OpenHABCore/Tests/OpenHABCoreTests/WidgetDisplayStateTests.swift @@ -0,0 +1,117 @@ +// Copyright (c) 2010-2026 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 OpenHABCore +import Testing + +@Suite +struct WidgetDisplayStateTests { + @Test + func usesItemStateWhenWidgetStateIsEmpty() { + let widget = makeWidget( + widgetState: "", + itemState: "ON", + label: "Kitchen Light [On]" + ) + + let display = widget.displayState + + #expect(display.effectiveState == "ON") + #expect(display.isOn) + #expect(display.labelText == "Kitchen Light") + #expect(display.labelValue == "On") + } + + @Test + func widgetStateOverridesItemState() { + let widget = makeWidget( + widgetState: "OFF", + itemState: "ON", + label: "Switch" + ) + + let display = widget.displayState + + #expect(display.effectiveState == "OFF") + #expect(!display.isOn) + } + + @Test + func resolvesSelectedMappingFromItemState() { + let widget = makeWidget( + widgetState: "", + itemState: "AUTO", + label: "Mode", + mappings: [ + OpenHABWidgetMapping(command: "MANUAL", label: "Manual"), + OpenHABWidgetMapping(command: "AUTO", label: "Auto") + ] + ) + + let display = widget.displayState + + #expect(display.selectedIndex == 1) + #expect(display.selectedLabel == "Auto") + } + + // MARK: - Helpers + + private func makeWidget(widgetState: String, + itemState: String, + label: String, + mappings: [OpenHABWidgetMapping] = []) -> OpenHABWidget { + let item = OpenHABItem( + name: "Item", + type: "Switch", + state: itemState, + link: "", + label: nil, + groupType: nil, + stateDescription: nil, + commandDescription: nil, + members: [], + category: nil, + options: nil + ) + let widget = OpenHABWidget( + widgetId: "widget-id", + label: label, + icon: "switch", + type: .switchWidget, + url: nil, + period: nil, + minValue: 0, + maxValue: 100, + step: 1, + refresh: nil, + height: nil, + isLeaf: nil, + iconColor: nil, + labelColor: nil, + valueColor: nil, + service: nil, + state: widgetState, + text: nil, + legend: nil, + inputHint: nil, + encoding: nil, + item: item, + linkedPage: nil, + mappings: mappings, + widgets: [], + visibility: true, + switchSupport: false, + forceAsItem: nil, + labelSource: .sitemapDefinition + ) + return widget + } +} diff --git a/openHAB/SwiftUI/Rows/ButtonGridRowView.swift b/openHAB/SwiftUI/Rows/ButtonGridRowView.swift index 11e943471..01c52c4ff 100644 --- a/openHAB/SwiftUI/Rows/ButtonGridRowView.swift +++ b/openHAB/SwiftUI/Rows/ButtonGridRowView.swift @@ -23,10 +23,11 @@ struct ButtonGridButton: View { @State private var triggerFeedback = false private let logger = Logger(subsystem: "org.openhab", category: "ButtonGridButton") + private var displayState: WidgetDisplayState { widget.displayState } private var isChecked: Bool { if let stateless = widget.stateless, stateless { return false } - return widget.item?.state == widget.command + return displayState.effectiveState == widget.command } private var hasPressRelease: Bool { @@ -149,6 +150,7 @@ struct ButtonGridRowView: View { // Maximum number of columns based on screen width private let maxColumns = 12 + private var displayState: WidgetDisplayState { widget.displayState } private var buttons: [OpenHABWidget] { let childButtons = widget.widgets // .filter(\.visibility) @@ -177,7 +179,8 @@ struct ButtonGridRowView: View { IconView(widget: widget) .frame(width: 32, height: 32) - if let labelText = widget.labelText, !labelText.isEmpty { + if !displayState.labelText.isEmpty { + let labelText = displayState.labelText Text(labelText) .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) .lineLimit(1) diff --git a/openHAB/SwiftUI/Rows/ColorPickerRowView.swift b/openHAB/SwiftUI/Rows/ColorPickerRowView.swift index 0aa95990e..190f3afbe 100644 --- a/openHAB/SwiftUI/Rows/ColorPickerRowView.swift +++ b/openHAB/SwiftUI/Rows/ColorPickerRowView.swift @@ -24,13 +24,15 @@ struct ColorPickerRowView: View { } private let logger = Logger(subsystem: "org.openhab", category: "WidgetColorPickerView") + private var displayState: WidgetDisplayState { widget.displayState } var body: some View { HStack { IconView(widget: widget) .frame(width: 32, height: 32) - if let labelText = widget.labelText, !labelText.isEmpty { + if !displayState.labelText.isEmpty { + let labelText = displayState.labelText Text(labelText) .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) .lineLimit(1) @@ -45,7 +47,7 @@ struct ColorPickerRowView: View { } .disabled(widget.readOnly ?? false) - if let labelValue = widget.labelValue, !labelValue.isEmpty { + if let labelValue = displayState.labelValue, !labelValue.isEmpty { Text(labelValue) .font(.caption) .foregroundStyle(widget.valuecolor.isEmpty ? .secondary : Color(fromString: widget.valuecolor)) @@ -53,11 +55,12 @@ struct ColorPickerRowView: View { } } .onAppear { - if let state = widget.item?.state, !state.isEmpty { + let state = displayState.effectiveState + if !state.isEmpty { selectedColor = parseColor(from: state) ?? .white } } - .onChange(of: widget.item?.state ?? "") { newState in + .onChange(of: displayState.effectiveState) { newState in guard !newState.isEmpty else { return } selectedColor = parseColor(from: newState) ?? .white } diff --git a/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift b/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift index 75c520a84..317cb7702 100644 --- a/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift +++ b/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift @@ -71,14 +71,15 @@ struct ColorTemperaturePickerRowView: View { } private let logger = Logger(subsystem: "org.openhab", category: "ColorTemperaturePickerRowView") + private var displayState: WidgetDisplayState { widget.displayState } // Use widget's min/max values, similar to Android implementation private var minTemperature: Double { - max(widget.minValue, 1000) + max(displayState.minValue, 1000) } private var maxTemperature: Double { - min(widget.maxValue, 10000) + min(displayState.maxValue, 10000) } var body: some View { @@ -88,7 +89,8 @@ struct ColorTemperaturePickerRowView: View { VStack(spacing: 8) { HStack { - if let labelText = widget.labelText, !labelText.isEmpty { + if !displayState.labelText.isEmpty { + let labelText = displayState.labelText Text(labelText) .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) .lineLimit(1) @@ -152,9 +154,9 @@ struct ColorTemperaturePickerRowView: View { } } .onAppear { - selectedTemperature = loadCurrentTemperature(state: widget.item?.state) ?? 2700 + selectedTemperature = loadCurrentTemperature(state: displayState.effectiveState) ?? 2700 } - .onChange(of: widget.item?.state ?? "") { newState in + .onChange(of: displayState.effectiveState) { newState in selectedTemperature = loadCurrentTemperature(state: newState) ?? 2700 } .onDisappear { diff --git a/openHAB/SwiftUI/Rows/DatePickerInputRowView.swift b/openHAB/SwiftUI/Rows/DatePickerInputRowView.swift index dcd46aaf0..980c2eb64 100644 --- a/openHAB/SwiftUI/Rows/DatePickerInputRowView.swift +++ b/openHAB/SwiftUI/Rows/DatePickerInputRowView.swift @@ -20,6 +20,7 @@ struct DatePickerInputRowView: View { @EnvironmentObject var viewModel: SitemapPageViewModel private let logger = Logger(subsystem: "org.openhab", category: "WidgetDatePickerInputView") + private var displayState: WidgetDisplayState { widget.displayState } private var datePickerComponents: DatePickerComponents { switch widget.inputHint { @@ -39,7 +40,8 @@ struct DatePickerInputRowView: View { IconView(widget: widget) .frame(width: 32, height: 32) - if let labelText = widget.labelText, !labelText.isEmpty { + if !displayState.labelText.isEmpty { + let labelText = displayState.labelText Text(labelText) .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) .lineLimit(1) @@ -59,7 +61,8 @@ struct DatePickerInputRowView: View { .disabled(widget.readOnly ?? false) } .onAppear { - if let state = widget.item?.state, !state.isEmpty { + let state = displayState.effectiveState + if !state.isEmpty { selectedDate = parseDate(from: state) ?? Date() } } diff --git a/openHAB/SwiftUI/Rows/FrameRowView.swift b/openHAB/SwiftUI/Rows/FrameRowView.swift index 455906925..d86ab4e49 100644 --- a/openHAB/SwiftUI/Rows/FrameRowView.swift +++ b/openHAB/SwiftUI/Rows/FrameRowView.swift @@ -16,10 +16,11 @@ import SwiftUI struct FrameRowView: View { @ObservedObject var widget: OpenHABWidget @EnvironmentObject var viewModel: SitemapPageViewModel + private var displayState: WidgetDisplayState { widget.displayState } var body: some View { HStack { - Text(widget.labelText?.uppercased() ?? "") + Text(displayState.labelText.uppercased()) .font(.callout) .foregroundStyle(.secondary) .lineLimit(1) diff --git a/openHAB/SwiftUI/Rows/GenericRowView.swift b/openHAB/SwiftUI/Rows/GenericRowView.swift index 8a0a17e1b..25d588b4e 100644 --- a/openHAB/SwiftUI/Rows/GenericRowView.swift +++ b/openHAB/SwiftUI/Rows/GenericRowView.swift @@ -16,19 +16,20 @@ import SwiftUI struct GenericRowView: View { @ObservedObject var widget: OpenHABWidget @EnvironmentObject var viewModel: SitemapPageViewModel + private var displayState: WidgetDisplayState { widget.displayState } var body: some View { HStack { IconView(widget: widget) .frame(width: 32, height: 32) - Text(widget.labelText ?? "") + Text(displayState.labelText) .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) .lineLimit(1) Spacer() - if let value = widget.labelValue { + if let value = displayState.labelValue { Text(value) .foregroundStyle(widget.valuecolor.isEmpty ? .secondary : Color(fromString: widget.valuecolor)) .lineLimit(1) diff --git a/openHAB/SwiftUI/Rows/ImageRowView.swift b/openHAB/SwiftUI/Rows/ImageRowView.swift index d3c7568bb..2128cbbea 100644 --- a/openHAB/SwiftUI/Rows/ImageRowView.swift +++ b/openHAB/SwiftUI/Rows/ImageRowView.swift @@ -23,6 +23,7 @@ struct ImageRowView: View { @State private var forceRefreshKey = UUID() private let logger = Logger(subsystem: "org.openhab", category: "ImageRowView") + private var displayState: WidgetDisplayState { widget.displayState } private var imageURL: URL? { guard !widget.url.isEmpty else { return nil } @@ -39,7 +40,8 @@ struct ImageRowView: View { var body: some View { VStack(alignment: .leading, spacing: 8) { - if let labelText = widget.labelText, !labelText.isEmpty, widget.labelSource == .sitemapDefinition { + if !displayState.labelText.isEmpty, widget.labelSource == .sitemapDefinition { + let labelText = displayState.labelText Text(labelText) .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) .lineLimit(1) @@ -74,7 +76,7 @@ struct ImageRowView: View { } // Only show labelValue for image widgets, not charts - if widget.type == .image, let labelValue = widget.labelValue, !labelValue.isEmpty { + if widget.type == .image, let labelValue = displayState.labelValue, !labelValue.isEmpty { Text(labelValue) .font(.caption) .foregroundStyle(widget.valuecolor.isEmpty ? .secondary : Color(fromString: widget.valuecolor)) diff --git a/openHAB/SwiftUI/Rows/MapRowView.swift b/openHAB/SwiftUI/Rows/MapRowView.swift index bf3251381..119ca9454 100644 --- a/openHAB/SwiftUI/Rows/MapRowView.swift +++ b/openHAB/SwiftUI/Rows/MapRowView.swift @@ -17,6 +17,7 @@ import SwiftUI struct MapRowViewLegacy: View { @ObservedObject var widget: OpenHABWidget + private var displayState: WidgetDisplayState { widget.displayState } private var region: MKCoordinateRegion { let coordinate = CLLocationCoordinate2DIsValid(widget.coordinate) ? widget.coordinate : CLLocationCoordinate2D(latitude: 0, longitude: 0) @@ -29,7 +30,8 @@ struct MapRowViewLegacy: View { var body: some View { VStack(alignment: .leading, spacing: 8) { - if let labelText = widget.labelText, !labelText.isEmpty, widget.labelSource == .sitemapDefinition { + if !displayState.labelText.isEmpty, widget.labelSource == .sitemapDefinition { + let labelText = displayState.labelText Text(labelText) .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) .lineLimit(1) diff --git a/openHAB/SwiftUI/Rows/RollershutterRowView.swift b/openHAB/SwiftUI/Rows/RollershutterRowView.swift index 2a04cbbf1..d63c73973 100644 --- a/openHAB/SwiftUI/Rows/RollershutterRowView.swift +++ b/openHAB/SwiftUI/Rows/RollershutterRowView.swift @@ -29,6 +29,7 @@ struct RollershutterRowView: View { @State private var triggerDownFeedback = false private let logger = Logger(subsystem: "org.openhab", category: "WidgetRollershutterView") + private var displayState: WidgetDisplayState { widget.displayState } var body: some View { VStack(alignment: .leading, spacing: 8) { @@ -37,7 +38,8 @@ struct RollershutterRowView: View { .frame(width: 32, height: 32) VStack(alignment: .leading, spacing: 2) { - if let labelText = widget.labelText, !labelText.isEmpty { + if !displayState.labelText.isEmpty { + let labelText = displayState.labelText Text(labelText) .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) .lineLimit(1) diff --git a/openHAB/SwiftUI/Rows/SegmentedRowView.swift b/openHAB/SwiftUI/Rows/SegmentedRowView.swift index eabd8e500..072f755cc 100644 --- a/openHAB/SwiftUI/Rows/SegmentedRowView.swift +++ b/openHAB/SwiftUI/Rows/SegmentedRowView.swift @@ -30,6 +30,10 @@ struct SegmentedRowView: View { widget.mappingsOrItemOptions } + private var displayState: WidgetDisplayState { + widget.displayState + } + @State private var selectedIndex: Int? @State private var pressedIndex: Int? @State private var singlePressed = false @@ -39,7 +43,8 @@ struct SegmentedRowView: View { IconView(widget: widget, fallbackSymbol: fallbackSymbol) .frame(width: 32, height: 32) - if let labelText = widget.labelText, !labelText.isEmpty { + if !displayState.labelText.isEmpty { + let labelText = displayState.labelText Text(labelText) .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) .lineLimit(1) @@ -48,7 +53,7 @@ struct SegmentedRowView: View { .layoutPriority(1) } - if let detailTextLabel = widget.labelValue, !detailTextLabel.isEmpty { + if let detailTextLabel = displayState.labelValue, !detailTextLabel.isEmpty { Spacer(minLength: 8) Text(detailTextLabel) .foregroundStyle(widget.valuecolor.isEmpty ? Color(uiColor: UIColor.ohSecondaryLabel) : Color(fromString: widget.valuecolor)) @@ -60,7 +65,7 @@ struct SegmentedRowView: View { if !mappings.isEmpty { if widget.hasPressReleaseMappings { // Press-release buttons for mappings with releaseCommand - if !(widget.labelValue?.isEmpty == false) { + if !(displayState.labelValue?.isEmpty == false) { Spacer(minLength: 8) } pressReleaseButtons @@ -68,7 +73,7 @@ struct SegmentedRowView: View { .padding(.leading, 8) } else if mappings.count == 1 { - if widget.labelValue.isNilOrEmpty { + if displayState.labelValue.isNilOrEmpty { Spacer(minLength: 8) } singleMappingButton @@ -84,9 +89,9 @@ struct SegmentedRowView: View { } } .onAppear { - selectedIndex = widget.mapCommandtoIndex(with: widget.item?.state) + selectedIndex = widget.mapCommandtoIndex(with: displayState.effectiveState) } - .onChange(of: widget.item?.state) { newState in + .onChange(of: displayState.effectiveState) { newState in selectedIndex = widget.mapCommandtoIndex(with: newState) } } @@ -137,8 +142,7 @@ struct SegmentedRowView: View { /// Whether the single mapping button is selected (item state matches the mapping command) private var isSingleMappingSelected: Bool { - guard let state = widget.item?.state else { return false } - return state == mappings[0].command + displayState.effectiveState == mappings[0].command } @ViewBuilder diff --git a/openHAB/SwiftUI/Rows/SelectionRowView.swift b/openHAB/SwiftUI/Rows/SelectionRowView.swift index a2e859449..a2660280d 100644 --- a/openHAB/SwiftUI/Rows/SelectionRowView.swift +++ b/openHAB/SwiftUI/Rows/SelectionRowView.swift @@ -25,25 +25,25 @@ struct SelectionRowView: View { widget.mappingsOrItemOptions } + private var displayState: WidgetDisplayState { + widget.displayState + } + /// Returns the label of the currently selected mapping, or the widget's labelValue as fallback private var selectedValueText: String? { - if let state = widget.item?.state, - let selectedMapping = mappings.first(where: { $0.command == state }) { - return selectedMapping.label - } - return widget.labelValue + displayState.selectedLabel ?? displayState.labelValue } var body: some View { ZStack { rowContent .frame(maxWidth: .infinity, alignment: .leading) - .animation(nil, value: widget.item?.state) + .animation(nil, value: displayState.effectiveState) Menu { ForEach(mappings.indices, id: \.self) { index in let mapping = mappings[index] - let isSelected = widget.item?.state == mapping.command + let isSelected = displayState.effectiveState == mapping.command Button { logger.info("Selection changed to: \(mapping.label)") viewModel.sendCommand(mapping.command, for: widget) @@ -71,7 +71,8 @@ struct SelectionRowView: View { IconView(widget: widget) .frame(width: 32, height: 32) - if let labelText = widget.labelText, !labelText.isEmpty { + if !displayState.labelText.isEmpty { + let labelText = displayState.labelText Text(labelText) .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) .lineLimit(1) diff --git a/openHAB/SwiftUI/Rows/SetpointRowView.swift b/openHAB/SwiftUI/Rows/SetpointRowView.swift index b53f7fa4d..eb15ab168 100644 --- a/openHAB/SwiftUI/Rows/SetpointRowView.swift +++ b/openHAB/SwiftUI/Rows/SetpointRowView.swift @@ -23,15 +23,19 @@ struct SetpointRowView: View { private let logger = Logger(subsystem: "org.openhab", category: "WidgetSetpointView") private let setpointService = SetPointService() + private var displayState: WidgetDisplayState { + widget.displayState + } + private var currentValue: Double { - widget.stateValueAsNumberState?.value ?? widget.minValue + widget.stateValueAsNumberState?.value ?? displayState.minValue } private var formattedValue: String { - if let labelValue = widget.labelValue, !labelValue.isEmpty { + if let labelValue = displayState.labelValue, !labelValue.isEmpty { return labelValue } else { - let step = widget.step + let step = displayState.step if step.truncatingRemainder(dividingBy: 1) == 0 { return String(format: "%.0f", currentValue) } else { @@ -45,7 +49,8 @@ struct SetpointRowView: View { IconView(widget: widget) .frame(width: 32, height: 32) - if let labelText = widget.labelText, !labelText.isEmpty { + if !displayState.labelText.isEmpty { + let labelText = displayState.labelText Text(labelText) .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) .lineLimit(1) @@ -60,10 +65,10 @@ struct SetpointRowView: View { } label: { Image(systemSymbol: .chevronDown) .font(.body) - .foregroundStyle(currentValue <= widget.minValue ? Color(.systemGray2) : Color(UIColor.systemBlue)) + .foregroundStyle(currentValue <= displayState.minValue ? Color(.systemGray2) : Color(UIColor.systemBlue)) } .buttonStyle(.plain) - .disabled(currentValue <= widget.minValue) + .disabled(currentValue <= displayState.minValue) .sensoryHeavyFeedbackIfAvailable(trigger: triggerFeedback) .disabled(widget.readOnly ?? false) @@ -77,10 +82,10 @@ struct SetpointRowView: View { } label: { Image(systemSymbol: .chevronUp) .font(.body) - .foregroundStyle(currentValue >= widget.maxValue ? Color(.systemGray2) : Color(UIColor.systemBlue)) + .foregroundStyle(currentValue >= displayState.maxValue ? Color(.systemGray2) : Color(UIColor.systemBlue)) } .buttonStyle(.plain) - .disabled(currentValue >= widget.maxValue) + .disabled(currentValue >= displayState.maxValue) .sensoryHeavyFeedbackIfAvailable(trigger: triggerFeedback) .disabled(widget.readOnly ?? false) } @@ -97,13 +102,13 @@ struct SetpointRowView: View { private func handleUpDown(isDecreasing: Bool) { var numberState = widget.stateValueAsNumberState - let currentValue = numberState?.value ?? widget.minValue + let currentValue = numberState?.value ?? displayState.minValue let limitedNewValue = setpointService.calculateNewValue( currentValue: currentValue, - step: widget.step, - minValue: widget.minValue, - maxValue: widget.maxValue, + step: displayState.step, + minValue: displayState.minValue, + maxValue: displayState.maxValue, isDecreasing: isDecreasing ) diff --git a/openHAB/SwiftUI/Rows/SliderRowView.swift b/openHAB/SwiftUI/Rows/SliderRowView.swift index 2daaad4f7..bdd926f42 100644 --- a/openHAB/SwiftUI/Rows/SliderRowView.swift +++ b/openHAB/SwiftUI/Rows/SliderRowView.swift @@ -28,12 +28,16 @@ struct SliderRowView: View { "slider-\(widget.widgetId)" } + private var displayState: WidgetDisplayState { + widget.displayState + } + private var sliderRange: ClosedRange { - widget.minValue ... widget.maxValue + displayState.minValue ... displayState.maxValue } private var currentValue: Double { - pendingValue ?? widget.adjustedValue + pendingValue ?? displayState.adjustedValue } private var currentValueText: String { @@ -42,7 +46,7 @@ struct SliderRowView: View { private var valueBinding: Binding { Binding( - get: { pendingValue ?? widget.adjustedValue }, + get: { pendingValue ?? displayState.adjustedValue }, set: { newValue in pendingValue = newValue @@ -62,7 +66,7 @@ struct SliderRowView: View { HStack { if widget.switchSupport { Button { - viewModel.sendCommand(currentValue <= widget.minValue ? "ON" : "OFF", for: widget) + viewModel.sendCommand(currentValue <= displayState.minValue ? "ON" : "OFF", for: widget) } label: { labelContent } @@ -96,10 +100,10 @@ struct SliderRowView: View { } .disabled(widget.readOnly ?? false) } - .onChange(of: widget.adjustedValue) { _ in + .onChange(of: displayState.adjustedValue) { _ in // Clear pending value only when server confirms our value (not intermediate responses) if !isEditing, let pending = pendingValue, - abs(widget.adjustedValue - pending) < max(widget.step * 0.5, 0.01) { + abs(displayState.adjustedValue - pending) < max(widget.step * 0.5, 0.01) { pendingValue = nil } } @@ -115,7 +119,8 @@ struct SliderRowView: View { IconView(widget: widget, fallbackSymbol: fallbackSymbol) .frame(width: 32, height: 32) - if let labelText = widget.labelText, !labelText.isEmpty { + if !displayState.labelText.isEmpty { + let labelText = displayState.labelText Text(labelText) .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) .lineLimit(1) @@ -125,7 +130,7 @@ struct SliderRowView: View { Spacer() // Show current slider value (pendingValue while dragging, otherwise widget value) - Text(pendingValue != nil ? currentValueText : (widget.labelValue ?? currentValueText)) + Text(pendingValue != nil ? currentValueText : (displayState.labelValue ?? currentValueText)) .font(.callout) .foregroundStyle(widget.valuecolor.isEmpty ? Color(uiColor: UIColor.ohSecondaryLabel) : Color(fromString: widget.valuecolor)) .lineLimit(1) diff --git a/openHAB/SwiftUI/Rows/SwitchRowView.swift b/openHAB/SwiftUI/Rows/SwitchRowView.swift index 80306361a..ef2ae8b09 100644 --- a/openHAB/SwiftUI/Rows/SwitchRowView.swift +++ b/openHAB/SwiftUI/Rows/SwitchRowView.swift @@ -21,17 +21,12 @@ struct SwitchRowView: View { private let logger = Logger(subsystem: "org.openhab", category: "WidgetSwitchView") - private var effectiveState: String { - var state = widget.state - // If state is nil or empty using the item state (OH 1.x compatibility) - if state.isEmpty { - state = widget.item?.state ?? "" - } - return state + private var displayState: WidgetDisplayState { + widget.displayState } private var isOn: Bool { - localIsOn ?? effectiveState.parseAsBool() + localIsOn ?? displayState.isOn } var body: some View { @@ -39,7 +34,8 @@ struct SwitchRowView: View { IconView(widget: widget) .frame(width: 32, height: 32) - if let labelText = widget.labelText, !labelText.isEmpty { + if !displayState.labelText.isEmpty { + let labelText = displayState.labelText Text(labelText) .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) .lineLimit(1) @@ -47,7 +43,7 @@ struct SwitchRowView: View { Spacer() - if let labelValue = widget.labelValue, !labelValue.isEmpty { + if let labelValue = displayState.labelValue, !labelValue.isEmpty { Text(labelValue) .font(.caption) .foregroundStyle(widget.valuecolor.isEmpty ? .secondary : Color(fromString: widget.valuecolor)) @@ -71,7 +67,7 @@ struct SwitchRowView: View { .disabled(widget.readOnly ?? false) } .contentShape(Rectangle()) - .onChange(of: effectiveState) { _ in + .onChange(of: displayState.effectiveState) { _ in // Sync local state when server state changes localIsOn = nil } diff --git a/openHAB/SwiftUI/Rows/TextInputRowView.swift b/openHAB/SwiftUI/Rows/TextInputRowView.swift index 8736beafa..0df8d60a5 100644 --- a/openHAB/SwiftUI/Rows/TextInputRowView.swift +++ b/openHAB/SwiftUI/Rows/TextInputRowView.swift @@ -21,13 +21,15 @@ struct TextInputRowView: View { @EnvironmentObject var viewModel: SitemapPageViewModel private let logger = Logger(subsystem: "org.openhab", category: "WidgetTextInputView") + private var displayState: WidgetDisplayState { widget.displayState } var body: some View { HStack { IconView(widget: widget) .frame(width: 32, height: 32) - if let labelText = widget.labelText, !labelText.isEmpty { + if !displayState.labelText.isEmpty { + let labelText = displayState.labelText Text(labelText) .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) .lineLimit(1) @@ -45,11 +47,11 @@ struct TextInputRowView: View { .disabled(widget.readOnly ?? false) } .onAppear { - inputText = widget.item?.state ?? "" + inputText = displayState.effectiveState } - .onChange(of: widget.item?.state) { newState in + .onChange(of: displayState.effectiveState) { newState in if !isTextFieldFocused { - inputText = newState ?? "" + inputText = newState } } } diff --git a/openHAB/SwiftUI/Rows/TextRowView.swift b/openHAB/SwiftUI/Rows/TextRowView.swift index 88513b8bb..0f9806748 100644 --- a/openHAB/SwiftUI/Rows/TextRowView.swift +++ b/openHAB/SwiftUI/Rows/TextRowView.swift @@ -17,19 +17,20 @@ import SwiftUI struct TextRowView: View { @ObservedObject var widget: OpenHABWidget @EnvironmentObject var viewModel: SitemapPageViewModel + private var displayState: WidgetDisplayState { widget.displayState } var body: some View { HStack { IconView(widget: widget) .frame(width: 32, height: 32) - Text(widget.labelText ?? "") + Text(displayState.labelText) .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) .lineLimit(1) Spacer() - if let value = widget.labelValue { + if let value = displayState.labelValue { Text(value) .font(.body) .foregroundStyle(widget.valuecolor.isEmpty ? .secondary : Color(fromString: widget.valuecolor)) @@ -37,7 +38,7 @@ struct TextRowView: View { } } .contextMenu { - if let text = widget.labelValue ?? widget.labelText, !text.isEmpty { + if let text = displayState.labelValue ?? (displayState.labelText.isEmpty ? nil : displayState.labelText), !text.isEmpty { Button { UIPasteboard.general.string = text } label: { diff --git a/openHAB/SwiftUI/Rows/VideoRowView.swift b/openHAB/SwiftUI/Rows/VideoRowView.swift index ce16f3d26..c4ebba40d 100644 --- a/openHAB/SwiftUI/Rows/VideoRowView.swift +++ b/openHAB/SwiftUI/Rows/VideoRowView.swift @@ -29,6 +29,7 @@ struct VideoRowView: View { @EnvironmentObject var viewModel: SitemapPageViewModel private let logger = Logger(subsystem: "org.openhab", category: "VideoRowView") + private var displayState: WidgetDisplayState { widget.displayState } private var videoURL: URL? { guard !widget.url.isEmpty else { return nil } @@ -41,7 +42,8 @@ struct VideoRowView: View { var body: some View { VStack(alignment: .leading, spacing: 8) { - if let labelText = widget.labelText, !labelText.isEmpty, widget.labelSource == .sitemapDefinition { + if !displayState.labelText.isEmpty, widget.labelSource == .sitemapDefinition { + let labelText = displayState.labelText Text(labelText) .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) .lineLimit(1) @@ -102,7 +104,7 @@ struct VideoRowView: View { .clipShape(.rect(cornerRadius: 8)) } - if let labelValue = widget.labelValue, !labelValue.isEmpty { + if let labelValue = displayState.labelValue, !labelValue.isEmpty { Text(labelValue) .font(.caption) .foregroundStyle(widget.valuecolor.isEmpty ? .secondary : Color(fromString: widget.valuecolor)) diff --git a/openHAB/SwiftUI/Rows/WebRowView.swift b/openHAB/SwiftUI/Rows/WebRowView.swift index 6511992bc..eceee47f4 100644 --- a/openHAB/SwiftUI/Rows/WebRowView.swift +++ b/openHAB/SwiftUI/Rows/WebRowView.swift @@ -17,10 +17,12 @@ import WebKit struct WidgetWebViewContainer: View { @ObservedObject var widget: OpenHABWidget + private var displayState: WidgetDisplayState { widget.displayState } var body: some View { VStack(alignment: .leading, spacing: 8) { - if let labelText = widget.labelText, !labelText.isEmpty, widget.labelSource == .sitemapDefinition { + if !displayState.labelText.isEmpty, widget.labelSource == .sitemapDefinition { + let labelText = displayState.labelText Text(labelText) .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) .lineLimit(1) @@ -30,7 +32,7 @@ struct WidgetWebViewContainer: View { .frame(height: widget.preferredRowHeight) .clipShape(.rect(cornerRadius: 8)) - if let labelValue = widget.labelValue, !labelValue.isEmpty { + if let labelValue = displayState.labelValue, !labelValue.isEmpty { Text(labelValue) .font(.caption) .foregroundStyle(widget.valuecolor.isEmpty ? .secondary : Color(fromString: widget.valuecolor)) diff --git a/openHABWatch/Model/WidgetRowViewModel.swift b/openHABWatch/Model/WidgetRowViewModel.swift index e07189c5d..04710f82e 100644 --- a/openHABWatch/Model/WidgetRowViewModel.swift +++ b/openHABWatch/Model/WidgetRowViewModel.swift @@ -39,29 +39,21 @@ final class WidgetRowViewModel { } func update(from widget: OpenHABWidget) { - let newMappings = widget.mappingsOrItemOptions - mappings = newMappings - hasPressReleaseMappings = widget.hasPressReleaseMappings - labelText = widget.labelText ?? widget.label - labelValue = widget.labelValue - selectedIndex = widget.mappingIndex(byCommand: widget.item?.state).map { Int($0) } - if let index = selectedIndex, index >= 0, index < newMappings.count { - selectedLabel = newMappings[index].label - } else { - selectedLabel = nil - } - if widget.state.isEmpty { - effectiveState = widget.item?.state ?? "" - } else { - effectiveState = widget.state - } - isOn = effectiveState.parseAsBool() - adjustedValue = widget.adjustedValue - minValue = widget.minValue - maxValue = widget.maxValue - step = widget.step - switchSupport = widget.switchSupport - hasLinkedPage = widget.linkedPage != nil + let displayState = widget.displayState + mappings = displayState.mappings + hasPressReleaseMappings = displayState.hasPressReleaseMappings + labelText = displayState.labelText + labelValue = displayState.labelValue + selectedIndex = displayState.selectedIndex + selectedLabel = displayState.selectedLabel + effectiveState = displayState.effectiveState + isOn = displayState.isOn + adjustedValue = displayState.adjustedValue + minValue = displayState.minValue + maxValue = displayState.maxValue + step = displayState.step + switchSupport = displayState.switchSupport + hasLinkedPage = displayState.hasLinkedPage numberState = widget.stateValueAsNumberState colorState = widget.item?.stateAsUIColor() } From 7e1685e7f47831699045fea32804796db8997839 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Sat, 14 Feb 2026 08:49:23 +0100 Subject: [PATCH 456/476] Fix watchOS commands silently failing after long poll The sendCommand closures on widgets captured [weak self] where self was the OpenHABPage. When the page title didn't change during long polling, the new page was never retained and got deallocated, killing all command closures. Wire closures directly to UserData instead, matching what iOS SitemapPageViewModel.injectSendCommand already does. Also extract WidgetRenderingKind for shared row-type resolution and simplify OpenAPIService server URL handling. Signed-off-by: Tim Mueller-Seydlitz --- .../Model/WidgetRenderingKind.swift | 88 +++++++++++++++++++ .../OpenHABCore/Util/OpenAPIService.swift | 15 +--- .../WidgetRenderingKindTests.swift | 74 ++++++++++++++++ openHAB/SwiftUI/RowViewFactory.swift | 38 ++++---- openHABWatch/Domain/UserData.swift | 82 ++++++++++------- openHABWatch/Model/WidgetRowFactory.swift | 44 +++++----- 6 files changed, 248 insertions(+), 93 deletions(-) create mode 100644 OpenHABCore/Sources/OpenHABCore/Model/WidgetRenderingKind.swift create mode 100644 OpenHABCore/Tests/OpenHABCoreTests/WidgetRenderingKindTests.swift diff --git a/OpenHABCore/Sources/OpenHABCore/Model/WidgetRenderingKind.swift b/OpenHABCore/Sources/OpenHABCore/Model/WidgetRenderingKind.swift new file mode 100644 index 000000000..4228b7811 --- /dev/null +++ b/OpenHABCore/Sources/OpenHABCore/Model/WidgetRenderingKind.swift @@ -0,0 +1,88 @@ +// Copyright (c) 2010-2026 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 Foundation + +public enum WidgetRenderingKind: Sendable { + case segmentedSwitch + case toggleSwitch + case rollershutterSwitch + case slider + case dateInput + case textInput + case text + case frame + case setpoint + case selection + case colorPicker + case image + case chart + case video + case webview + case mapview + case colorTemperaturePicker + case buttonGrid + case generic +} + +public extension OpenHABWidget { + var renderingKind: WidgetRenderingKind { + switch type { + case .switchWidget: + if !mappings.isEmpty { + return .segmentedSwitch + } + if item?.isOfTypeOrGroupType(.switchItem) ?? false { + return .toggleSwitch + } + if item?.isOfTypeOrGroupType(.rollershutter) ?? false { + return .rollershutterSwitch + } + if !mappingsOrItemOptions.isEmpty { + return .segmentedSwitch + } + return .toggleSwitch + case .slider: + return .slider + case .input: + if [.date, .time, .dateTime].contains(inputHint) { + return .dateInput + } + return .textInput + case .text: + return .text + case .frame: + return .frame + case .setpoint: + return .setpoint + case .selection: + return .selection + case .colorpicker: + return .colorPicker + case .image: + return .image + case .chart: + return .chart + case .video: + return .video + case .webview: + return .webview + case .mapview: + return .mapview + case .colortemperaturepicker: + return .colorTemperaturePicker + case .buttongrid: + return .buttonGrid + case .group, .defaultWidget, .button, .unknown: + return .generic + } + } +} diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift index f535540c9..924b2d0b9 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenAPIService.swift @@ -95,18 +95,9 @@ public actor OpenAPIService { } private static func getServerURL(for url: URL) -> URL { - if let urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false), - let host = urlComponents.host, - host.contains("myopenhab.org"), - host != "home.myopenhab.org" { -// URL(string: "https://home.myopenhab.org")! - var newComponents = urlComponents - newComponents.host = "home.myopenhab.org" -// newComponents.scheme = "https" - return newComponents.url! - } else { - return url - } + // Respect the configured connection URL. Forcing cloud hosts can fail + // DNS resolution on some networks/simulators and breaks sitemap loading. + url } private func prepareURLSessionConfiguration(longPolling: Bool) -> URLSessionConfiguration { diff --git a/OpenHABCore/Tests/OpenHABCoreTests/WidgetRenderingKindTests.swift b/OpenHABCore/Tests/OpenHABCoreTests/WidgetRenderingKindTests.swift new file mode 100644 index 000000000..40f96e4d7 --- /dev/null +++ b/OpenHABCore/Tests/OpenHABCoreTests/WidgetRenderingKindTests.swift @@ -0,0 +1,74 @@ +// Copyright (c) 2010-2026 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 OpenHABCore +import Testing + +@Suite +struct WidgetRenderingKindTests { + @Test + func switchWithMappingsUsesSegmentedKind() { + let widget = makeWidget(type: .switchWidget, itemType: "Switch") + widget.mappings = [OpenHABWidgetMapping(command: "ON", label: "On")] + + #expect(widget.renderingKind == .segmentedSwitch) + } + + @Test + func switchWithSwitchItemUsesToggleKind() { + let widget = makeWidget(type: .switchWidget, itemType: "Switch") + + #expect(widget.renderingKind == .toggleSwitch) + } + + @Test + func switchWithRollershutterItemUsesRollershutterKind() { + let widget = makeWidget(type: .switchWidget, itemType: "Rollershutter") + + #expect(widget.renderingKind == .rollershutterSwitch) + } + + @Test + func inputDateHintUsesDateInputKind() { + let widget = makeWidget(type: .input, itemType: "String") + widget.inputHint = .dateTime + + #expect(widget.renderingKind == .dateInput) + } + + @Test + func inputTextHintUsesTextInputKind() { + let widget = makeWidget(type: .input, itemType: "String") + widget.inputHint = .text + + #expect(widget.renderingKind == .textInput) + } + + private func makeWidget(type: OpenHABWidget.WidgetType, itemType: String) -> OpenHABWidget { + let item = OpenHABItem( + name: "Item", + type: itemType, + state: "OFF", + link: "", + label: nil, + groupType: nil, + stateDescription: nil, + commandDescription: nil, + members: [], + category: nil, + options: nil + ) + let widget = OpenHABWidget() + widget.type = type + widget.item = item + return widget + } +} diff --git a/openHAB/SwiftUI/RowViewFactory.swift b/openHAB/SwiftUI/RowViewFactory.swift index 180089ffe..f293c28cf 100644 --- a/openHAB/SwiftUI/RowViewFactory.swift +++ b/openHAB/SwiftUI/RowViewFactory.swift @@ -15,27 +15,19 @@ import SwiftUI enum RowViewFactory { @MainActor @ViewBuilder static func view(for widget: OpenHABWidget) -> some View { - switch widget.type { - case .switchWidget: - if !widget.mappings.isEmpty { - SegmentedRowView(widget: widget) - } else if widget.item?.isOfTypeOrGroupType(.switchItem) ?? false { - SwitchRowView(widget: widget) - } else if widget.item?.isOfTypeOrGroupType(.rollershutter) ?? false { - RollershutterRowView(widget: widget) - } else if !widget.mappingsOrItemOptions.isEmpty { - SegmentedRowView(widget: widget) - } else { - SwitchRowView(widget: widget) - } + switch widget.renderingKind { + case .segmentedSwitch: + SegmentedRowView(widget: widget) + case .toggleSwitch: + SwitchRowView(widget: widget) + case .rollershutterSwitch: + RollershutterRowView(widget: widget) case .slider: // SliderRowView also handles switchSupport SliderRowView(widget: widget) - case .input: - if [.date, .time, .dateTime].contains(widget.inputHint) { - DatePickerInputRowView(widget: widget) - } else { - TextInputRowView(widget: widget) - } + case .dateInput: + DatePickerInputRowView(widget: widget) + case .textInput: + TextInputRowView(widget: widget) case .text: TextRowView(widget: widget) case .frame: @@ -44,7 +36,7 @@ enum RowViewFactory { SetpointRowView(widget: widget) case .selection: SelectionRowView(widget: widget) - case .colorpicker: + case .colorPicker: ColorPickerRowView(widget: widget) case .image, .chart: ImageRowView(widget: widget) @@ -54,11 +46,11 @@ enum RowViewFactory { WidgetWebViewContainer(widget: widget) case .mapview: MapRowView(widget: widget) - case .colortemperaturepicker: + case .colorTemperaturePicker: ColorTemperaturePickerRowView(widget: widget) - case .buttongrid: + case .buttonGrid: ButtonGridRowView(widget: widget) - case .group, .defaultWidget, .button, .unknown: + case .generic: GenericRowView(widget: widget) } } diff --git a/openHABWatch/Domain/UserData.swift b/openHABWatch/Domain/UserData.swift index f04466755..35461e882 100644 --- a/openHABWatch/Domain/UserData.swift +++ b/openHABWatch/Domain/UserData.swift @@ -15,7 +15,6 @@ import Foundation import OpenHABCore import os.log import SwiftUI -import WatchKit @MainActor final class UserData: ObservableObject { @@ -46,9 +45,7 @@ final class UserData: ObservableObject { let sitemapPage = try data.decoded(as: Components.Schemas.PageDTO.self) openHABSitemapPage = OpenHABPage(sitemapPage) widgets = openHABSitemapPage?.widgets ?? [] - openHABSitemapPage?.sendCommand = { [weak self] item, command in - Task { await self?.sendCommand(item, command: command) } - } + decorateWidgetsWithSendCommand(widgets) } catch { Logger.userData.error("Should not throw \(error.localizedDescription)") } @@ -226,12 +223,16 @@ final class UserData: ObservableObject { } func updateNetwork() async { - guard let connection1 = AppSettings.shared.localConnectionConfig, - let connection2 = AppSettings.shared.remoteConnectionConfig else { + let connections = [ + AppSettings.shared.localConnectionConfig, + AppSettings.shared.remoteConnectionConfig + ].compactMap(\.self) + + guard !connections.isEmpty else { Logger.userData.warning("No connections defined") return } - await NetworkTracker.shared.startTracking(connectionConfigurations: [connection1, connection2]) + await NetworkTracker.shared.startTracking(connectionConfigurations: connections) } func startPageHandling(sitemapName: String, pageId: String = "", force: Bool = false) { @@ -270,8 +271,22 @@ final class UserData: ObservableObject { do { isLoadingSitemap = true - // Wait for NetworkTracker to establish a connection (no fallback hacks needed!) - guard let connectionInfo = await NetworkTracker.shared.waitForActiveConnection() else { + // Always ensure tracking is running before waiting. + // startTracking is idempotent for unchanged active configurations. + await self.updateNetwork() + + var connectionInfo = await NetworkTracker.shared.activeConnection + if connectionInfo == nil { + connectionInfo = await NetworkTracker.shared.waitForActiveConnection() + } + + if connectionInfo == nil { + Logger.userData.warning("No active connection on first attempt, restarting network tracking once") + await self.updateNetwork() + connectionInfo = await NetworkTracker.shared.waitForActiveConnection() + } + + guard let connectionInfo else { Logger.userData.error("No active connection available after timeout") await MainActor.run { self.errorDescription = NSLocalizedString("settings_not_received", comment: "") @@ -288,10 +303,6 @@ final class UserData: ObservableObject { try Task.checkCancellation() await MainActor.run { - // Set command handler BEFORE assigning to @Published property to prevent race condition - initialPage?.sendCommand = { [weak self] item, command in - Task { await self?.sendCommand(item, command: command) } - } self.openHABSitemapPage = initialPage let newWidgets = initialPage?.widgets ?? [] self.updateWidgets(with: newWidgets) @@ -312,11 +323,6 @@ final class UserData: ObservableObject { try Task.checkCancellation() await MainActor.run { - if let page { - page.sendCommand = { [weak self] item, command in - Task { await self?.sendCommand(item, command: command) } - } - } // Only update page object when title changes to avoid // firing objectWillChange and resetting scroll position if self.openHABSitemapPage?.title != page?.title { @@ -377,23 +383,15 @@ final class UserData: ObservableObject { func sendCommand(_ item: OpenHABItem?, command: String?) async { guard let item, let command else { return } - let sourcePrefix = sitemapSourcePrefix() - let deviceId = WKInterfaceDevice.current().identifierForVendor?.uuidString do { - try await NetworkTracker.shared.send(to: item, command: command, sourcePrefix: sourcePrefix, deviceId: deviceId) + // Watch commands currently rely on server defaults for `source`. + // Explicit source formatting can be rejected by some deployments. + try await NetworkTracker.shared.send(to: item, command: command) } catch { Logger.userData.error("Failed to send command '\(command)' to '\(item.name)': \(error.localizedDescription)") } } - private func sitemapSourcePrefix() -> String? { - let sitemapName = currentlyLoadingSitemap ?? "" - guard !sitemapName.isEmpty else { return nil } - let pageId = openHABSitemapPage?.pageId ?? "" - let suffix = pageId.isEmpty ? "" : ":\(pageId)" - return "org.openhab.ui.basic$\(sitemapName)\(suffix)" - } - func refreshUrl() async { guard AppSettings.shared.haveReceivedAppContext, !AppSettings.shared.openHABRootUrl.isEmpty else { return } @@ -432,19 +430,35 @@ final class UserData: ObservableObject { existingWidget.widgets = newWidget.widgets existingWidget.linkedPage = newWidget.linkedPage existingWidget.visibility = newWidget.visibility - existingWidget.sendCommand = newWidget.sendCommand } } + // Wire sendCommand closures directly to UserData rather than copying + // from the page's decorated widgets. The page object is a local variable + // that gets deallocated after each poll cycle (when the title doesn't + // change), which kills the [weak page] closures set by + // decorateWithSendCommand. By capturing [weak self] (UserData) instead, + // the closures remain alive for the lifetime of the view hierarchy. + let allWidgets = structureChanged + ? newWidgets.map { existingWidgetsMap[$0.widgetId] ?? $0 } + : widgets + decorateWidgetsWithSendCommand(allWidgets) + // Only reassign the @Published array when the list structure actually // changed (widgets added, removed, or reordered). This avoids a full // ScrollView rebuild that resets the scroll position. if structureChanged { - var updatedWidgets: [OpenHABWidget] = [] - for newWidget in newWidgets { - updatedWidgets.append(existingWidgetsMap[newWidget.widgetId] ?? newWidget) + widgets = allWidgets + } + } + + /// Sets sendCommand closures on widgets that go directly to UserData, + /// bypassing the OpenHABPage closure chain and its weak-reference lifetime issues. + private func decorateWidgetsWithSendCommand(_ widgets: [OpenHABWidget]) { + for widget in widgets { + widget.sendCommand = { [weak self] item, command in + Task { await self?.sendCommand(item, command: command) } } - widgets = updatedWidgets } } } diff --git a/openHABWatch/Model/WidgetRowFactory.swift b/openHABWatch/Model/WidgetRowFactory.swift index e47b62c12..146c24f01 100644 --- a/openHABWatch/Model/WidgetRowFactory.swift +++ b/openHABWatch/Model/WidgetRowFactory.swift @@ -16,25 +16,21 @@ enum WidgetRowFactory { @MainActor @ViewBuilder static func make(widget: OpenHABWidget, settings: AppSettings) -> some View { - switch widget.type { - case .switchWidget: - if !widget.mappings.isEmpty { - SegmentRow(widget: widget, stateToken: widget.item?.state ?? widget.state) - } else if widget.item?.isOfTypeOrGroupType(.switchItem) ?? false { - SwitchRow(widget: widget, stateToken: widget.state.isEmpty ? (widget.item?.state ?? "") : widget.state) - } else if widget.item?.isOfTypeOrGroupType(.rollershutter) ?? false { - RollershutterRow(widget: widget) - } else if !widget.mappingsOrItemOptions.isEmpty { - SegmentRow(widget: widget, stateToken: widget.item?.state ?? widget.state) - } else { - SwitchRow(widget: widget, stateToken: widget.state.isEmpty ? (widget.item?.state ?? "") : widget.state) - } + let stateToken = widget.displayState.effectiveState + + switch widget.renderingKind { + case .segmentedSwitch: + SegmentRow(widget: widget, stateToken: stateToken) + case .toggleSwitch: + SwitchRow(widget: widget, stateToken: stateToken) + case .rollershutterSwitch: + RollershutterRow(widget: widget) case .slider: - SliderRow(widget: widget, stateToken: widget.item?.state ?? widget.state) + SliderRow(widget: widget, stateToken: stateToken) case .setpoint: - SetpointRow(widget: widget, stateToken: widget.item?.state ?? widget.state) + SetpointRow(widget: widget, stateToken: stateToken) case .frame: - FrameRow(title: widget.labelText ?? "") + FrameRow(title: widget.displayState.labelText) case .text: TextRow(widget: widget, hasLinkedPage: widget.linkedPage != nil) case .image: @@ -57,20 +53,20 @@ enum WidgetRowFactory { EquatableView(content: ImageRow(url: url, refresh: widget.refresh)) case .mapview: MapViewRow(widget: widget) - case .colorpicker: - ColorPickerRow(widget: widget, stateToken: widget.item?.state ?? widget.state) + case .colorPicker: + ColorPickerRow(widget: widget, stateToken: stateToken) case .selection: SelectionRow( widget: widget, - mappings: widget.mappingsOrItemOptions, - title: widget.labelText ?? "Select", - initialSelectedIndex: widget.mappingIndex(byCommand: widget.item?.state).map { Int($0) }, - labelValue: widget.labelValue + mappings: widget.displayState.mappings, + title: widget.displayState.labelText.isEmpty ? "Select" : widget.displayState.labelText, + initialSelectedIndex: widget.displayState.selectedIndex, + labelValue: widget.displayState.labelValue ) - case .video, .webview, .input, .colortemperaturepicker, .buttongrid: + case .video, .webview, .dateInput, .textInput, .colorTemperaturePicker, .buttonGrid: // Not yet implemented for watchOS GenericRow(widget: widget) - case .group, .defaultWidget, .button, .unknown: + case .generic: GenericRow(widget: widget) } } From 37a833c706f97b546df8047673d798651c4e1e0f Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Sat, 14 Feb 2026 09:02:02 +0100 Subject: [PATCH 457/476] Align setpoint value formatting across iOS and watchOS Both platforms now use valueText(step:) + widget.unit consistently. iOS no longer prefers the server labelValue (which could differ in format), and watchOS no longer switches between server and local formatting on button press. Signed-off-by: Tim Mueller-Seydlitz --- openHAB/SwiftUI/Rows/SetpointRowView.swift | 13 ++++--------- openHABWatch/Views/Rows/SetpointRow.swift | 4 ++-- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/openHAB/SwiftUI/Rows/SetpointRowView.swift b/openHAB/SwiftUI/Rows/SetpointRowView.swift index eb15ab168..8403c8267 100644 --- a/openHAB/SwiftUI/Rows/SetpointRowView.swift +++ b/openHAB/SwiftUI/Rows/SetpointRowView.swift @@ -32,16 +32,11 @@ struct SetpointRowView: View { } private var formattedValue: String { - if let labelValue = displayState.labelValue, !labelValue.isEmpty { - return labelValue - } else { - let step = displayState.step - if step.truncatingRemainder(dividingBy: 1) == 0 { - return String(format: "%.0f", currentValue) - } else { - return String(format: "%.1f", currentValue) - } + let text = currentValue.valueText(step: displayState.step) + if let unit = widget.unit, !unit.isEmpty { + return "\(text) \(unit)" } + return text } var body: some View { diff --git a/openHABWatch/Views/Rows/SetpointRow.swift b/openHABWatch/Views/Rows/SetpointRow.swift index 403e79b7e..28f987093 100644 --- a/openHABWatch/Views/Rows/SetpointRow.swift +++ b/openHABWatch/Views/Rows/SetpointRow.swift @@ -60,10 +60,10 @@ struct SetpointRow: View { Spacer() - Text(localValue == nil ? (viewModel.labelValue ?? valueText) : valueText) + Text(valueText) .watchTextStyle(.emphasis) .accessibilityLabel("\(viewModel.labelText) value") - .accessibilityValue(localValue == nil ? (viewModel.labelValue ?? valueText) : valueText) + .accessibilityValue(valueText) Spacer() From 9521bccebf94fa2016b784f6069a541002d1ca36 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Sat, 14 Feb 2026 09:29:29 +0100 Subject: [PATCH 458/476] Fix thread safety, command source, and display state consistency Address code review findings: add @MainActor to WidgetCommandDispatcher, include sourcePrefix/deviceId in SitemapPageViewModel commands, snapshot displayState once per render in all row views, restore ColorPicker throttle+debounce for real-time feedback, and align SegmentedRowView to use displayState.mappings consistently. Signed-off-by: Tim Mueller-Seydlitz --- .../Util/WidgetCommandDispatcher.swift | 1 + openHAB/SitemapPageViewModel.swift | 16 ++++++- openHAB/SwiftUI/Rows/ButtonGridRowView.swift | 10 ++--- openHAB/SwiftUI/Rows/ColorPickerRowView.swift | 16 ++++++- .../Rows/ColorTemperaturePickerRowView.swift | 6 +-- .../SwiftUI/Rows/DatePickerInputRowView.swift | 2 +- openHAB/SwiftUI/Rows/FrameRowView.swift | 2 +- openHAB/SwiftUI/Rows/GenericRowView.swift | 2 +- openHAB/SwiftUI/Rows/ImageRowView.swift | 2 +- openHAB/SwiftUI/Rows/MapRowView.swift | 2 +- .../SwiftUI/Rows/RollershutterRowView.swift | 2 +- openHAB/SwiftUI/Rows/SegmentedRowView.swift | 43 +++++++++---------- openHAB/SwiftUI/Rows/SelectionRowView.swift | 15 +++---- openHAB/SwiftUI/Rows/SetpointRowView.swift | 28 ++++++------ openHAB/SwiftUI/Rows/SliderRowView.swift | 24 +++++------ openHAB/SwiftUI/Rows/SwitchRowView.swift | 9 ++-- openHAB/SwiftUI/Rows/TextInputRowView.swift | 2 +- openHAB/SwiftUI/Rows/TextRowView.swift | 2 +- openHAB/SwiftUI/Rows/VideoRowView.swift | 2 +- openHAB/SwiftUI/Rows/WebRowView.swift | 2 +- openHABWatch/Model/WidgetRowFactory.swift | 13 +++--- 21 files changed, 111 insertions(+), 90 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/WidgetCommandDispatcher.swift b/OpenHABCore/Sources/OpenHABCore/Util/WidgetCommandDispatcher.swift index dba159565..6eede6648 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/WidgetCommandDispatcher.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/WidgetCommandDispatcher.swift @@ -11,6 +11,7 @@ import Foundation +@MainActor public final class WidgetCommandDispatcher { private var pendingTasks: [String: Task] = [:] diff --git a/openHAB/SitemapPageViewModel.swift b/openHAB/SitemapPageViewModel.swift index cd1a7f2ef..aa854fa23 100644 --- a/openHAB/SitemapPageViewModel.swift +++ b/openHAB/SitemapPageViewModel.swift @@ -14,6 +14,7 @@ import OpenAPIRuntime import OpenHABCore import os.log import SwiftUI +import UIKit private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "org.openhab.app", category: "SitemapPageViewModel") @@ -526,9 +527,16 @@ class SitemapPageViewModel: ObservableObject { } func sendCommand(itemname: String, command: String) { + let sourcePrefix = sitemapSourcePrefix() + let deviceId = UIDevice.current.identifierForVendor?.uuidString Task { do { - try await openAPIService?.sendItemCommand(itemname: itemname, command: command) + try await openAPIService?.sendItemCommand( + itemname: itemname, + command: command, + sourcePrefix: sourcePrefix, + deviceId: deviceId + ) logger.info("Successfully sent command \(command) to \(itemname)") } catch { logger.info("Failed to send command\(command) to \(itemname): \(error.localizedDescription)") @@ -536,6 +544,12 @@ class SitemapPageViewModel: ObservableObject { } } + private func sitemapSourcePrefix() -> String? { + guard !defaultSitemap.isEmpty else { return nil } + let suffix = pageId.isEmpty ? "" : ":\(pageId)" + return "org.openhab.ui.basic$\(defaultSitemap)\(suffix)" + } + func sendToUpdate(item: OpenHABItem?, state: NumberState?, policy: WidgetCommandPolicy = .immediate, diff --git a/openHAB/SwiftUI/Rows/ButtonGridRowView.swift b/openHAB/SwiftUI/Rows/ButtonGridRowView.swift index 01c52c4ff..32c483b11 100644 --- a/openHAB/SwiftUI/Rows/ButtonGridRowView.swift +++ b/openHAB/SwiftUI/Rows/ButtonGridRowView.swift @@ -23,9 +23,8 @@ struct ButtonGridButton: View { @State private var triggerFeedback = false private let logger = Logger(subsystem: "org.openhab", category: "ButtonGridButton") - private var displayState: WidgetDisplayState { widget.displayState } - private var isChecked: Bool { + private func isChecked(displayState: WidgetDisplayState) -> Bool { if let stateless = widget.stateless, stateless { return false } return displayState.effectiveState == widget.command } @@ -38,6 +37,7 @@ struct ButtonGridButton: View { } var body: some View { + let displayState = widget.displayState Button { // Only handle tap for non-press-release buttons; // press-release buttons are handled entirely by the gesture @@ -62,11 +62,11 @@ struct ButtonGridButton: View { .frame(height: 44) .background( RoundedRectangle(cornerRadius: 8) - .fill(isChecked ? Color.accentColor : Color.secondary.opacity(0.1)) + .fill(isChecked(displayState: displayState) ? Color.accentColor : Color.secondary.opacity(0.1)) ) .overlay( RoundedRectangle(cornerRadius: 8) - .stroke(isChecked ? Color.accentColor : Color.secondary.opacity(0.3), lineWidth: 1) + .stroke(isChecked(displayState: displayState) ? Color.accentColor : Color.secondary.opacity(0.3), lineWidth: 1) ) .scaleEffect(isPressed ? 0.95 : 1.0) } @@ -150,7 +150,6 @@ struct ButtonGridRowView: View { // Maximum number of columns based on screen width private let maxColumns = 12 - private var displayState: WidgetDisplayState { widget.displayState } private var buttons: [OpenHABWidget] { let childButtons = widget.widgets // .filter(\.visibility) @@ -173,6 +172,7 @@ struct ButtonGridRowView: View { } var body: some View { + let displayState = widget.displayState VStack(alignment: .leading, spacing: 8) { if showLabelAndIcon { HStack { diff --git a/openHAB/SwiftUI/Rows/ColorPickerRowView.swift b/openHAB/SwiftUI/Rows/ColorPickerRowView.swift index 190f3afbe..a601f0e1f 100644 --- a/openHAB/SwiftUI/Rows/ColorPickerRowView.swift +++ b/openHAB/SwiftUI/Rows/ColorPickerRowView.swift @@ -17,6 +17,7 @@ import SwiftUI struct ColorPickerRowView: View { @ObservedObject var widget: OpenHABWidget @State private var selectedColor: Color = .white + @State private var lastImmediateSendAt: Date = .distantPast @EnvironmentObject var viewModel: SitemapPageViewModel private var colorCommandKey: String { @@ -24,9 +25,9 @@ struct ColorPickerRowView: View { } private let logger = Logger(subsystem: "org.openhab", category: "WidgetColorPickerView") - private var displayState: WidgetDisplayState { widget.displayState } var body: some View { + let displayState = widget.displayState HStack { IconView(widget: widget) .frame(width: 32, height: 32) @@ -88,6 +89,19 @@ struct ColorPickerRowView: View { let command = "\(hueValue),\(saturationValue),\(brightnessValue)" logger.info("Sending color command: \(command)") + + // Keep real-time feedback while dragging: throttle immediate sends. + let now = Date() + if now.timeIntervalSince(lastImmediateSendAt) >= 0.2 { + lastImmediateSendAt = now + viewModel.sendCommand( + command, + for: widget, + policy: .immediate + ) + } + + // Also debounce to ensure the final value is sent after interaction settles. viewModel.sendCommand( command, for: widget, diff --git a/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift b/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift index 317cb7702..d5f0cd67d 100644 --- a/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift +++ b/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift @@ -71,18 +71,18 @@ struct ColorTemperaturePickerRowView: View { } private let logger = Logger(subsystem: "org.openhab", category: "ColorTemperaturePickerRowView") - private var displayState: WidgetDisplayState { widget.displayState } // Use widget's min/max values, similar to Android implementation private var minTemperature: Double { - max(displayState.minValue, 1000) + max(widget.minValue, 1000) } private var maxTemperature: Double { - min(displayState.maxValue, 10000) + min(widget.maxValue, 10000) } var body: some View { + let displayState = widget.displayState HStack(alignment: .top) { IconView(widget: widget) .frame(width: 32, height: 32) diff --git a/openHAB/SwiftUI/Rows/DatePickerInputRowView.swift b/openHAB/SwiftUI/Rows/DatePickerInputRowView.swift index 980c2eb64..c1e311032 100644 --- a/openHAB/SwiftUI/Rows/DatePickerInputRowView.swift +++ b/openHAB/SwiftUI/Rows/DatePickerInputRowView.swift @@ -20,7 +20,6 @@ struct DatePickerInputRowView: View { @EnvironmentObject var viewModel: SitemapPageViewModel private let logger = Logger(subsystem: "org.openhab", category: "WidgetDatePickerInputView") - private var displayState: WidgetDisplayState { widget.displayState } private var datePickerComponents: DatePickerComponents { switch widget.inputHint { @@ -36,6 +35,7 @@ struct DatePickerInputRowView: View { } var body: some View { + let displayState = widget.displayState HStack { IconView(widget: widget) .frame(width: 32, height: 32) diff --git a/openHAB/SwiftUI/Rows/FrameRowView.swift b/openHAB/SwiftUI/Rows/FrameRowView.swift index d86ab4e49..e75bf9a9a 100644 --- a/openHAB/SwiftUI/Rows/FrameRowView.swift +++ b/openHAB/SwiftUI/Rows/FrameRowView.swift @@ -16,9 +16,9 @@ import SwiftUI struct FrameRowView: View { @ObservedObject var widget: OpenHABWidget @EnvironmentObject var viewModel: SitemapPageViewModel - private var displayState: WidgetDisplayState { widget.displayState } var body: some View { + let displayState = widget.displayState HStack { Text(displayState.labelText.uppercased()) .font(.callout) diff --git a/openHAB/SwiftUI/Rows/GenericRowView.swift b/openHAB/SwiftUI/Rows/GenericRowView.swift index 25d588b4e..1a423884c 100644 --- a/openHAB/SwiftUI/Rows/GenericRowView.swift +++ b/openHAB/SwiftUI/Rows/GenericRowView.swift @@ -16,9 +16,9 @@ import SwiftUI struct GenericRowView: View { @ObservedObject var widget: OpenHABWidget @EnvironmentObject var viewModel: SitemapPageViewModel - private var displayState: WidgetDisplayState { widget.displayState } var body: some View { + let displayState = widget.displayState HStack { IconView(widget: widget) .frame(width: 32, height: 32) diff --git a/openHAB/SwiftUI/Rows/ImageRowView.swift b/openHAB/SwiftUI/Rows/ImageRowView.swift index 2128cbbea..1d43c89d3 100644 --- a/openHAB/SwiftUI/Rows/ImageRowView.swift +++ b/openHAB/SwiftUI/Rows/ImageRowView.swift @@ -23,7 +23,6 @@ struct ImageRowView: View { @State private var forceRefreshKey = UUID() private let logger = Logger(subsystem: "org.openhab", category: "ImageRowView") - private var displayState: WidgetDisplayState { widget.displayState } private var imageURL: URL? { guard !widget.url.isEmpty else { return nil } @@ -39,6 +38,7 @@ struct ImageRowView: View { } var body: some View { + let displayState = widget.displayState VStack(alignment: .leading, spacing: 8) { if !displayState.labelText.isEmpty, widget.labelSource == .sitemapDefinition { let labelText = displayState.labelText diff --git a/openHAB/SwiftUI/Rows/MapRowView.swift b/openHAB/SwiftUI/Rows/MapRowView.swift index 119ca9454..4fef40e70 100644 --- a/openHAB/SwiftUI/Rows/MapRowView.swift +++ b/openHAB/SwiftUI/Rows/MapRowView.swift @@ -17,7 +17,6 @@ import SwiftUI struct MapRowViewLegacy: View { @ObservedObject var widget: OpenHABWidget - private var displayState: WidgetDisplayState { widget.displayState } private var region: MKCoordinateRegion { let coordinate = CLLocationCoordinate2DIsValid(widget.coordinate) ? widget.coordinate : CLLocationCoordinate2D(latitude: 0, longitude: 0) @@ -29,6 +28,7 @@ struct MapRowViewLegacy: View { } var body: some View { + let displayState = widget.displayState VStack(alignment: .leading, spacing: 8) { if !displayState.labelText.isEmpty, widget.labelSource == .sitemapDefinition { let labelText = displayState.labelText diff --git a/openHAB/SwiftUI/Rows/RollershutterRowView.swift b/openHAB/SwiftUI/Rows/RollershutterRowView.swift index d63c73973..debc165dd 100644 --- a/openHAB/SwiftUI/Rows/RollershutterRowView.swift +++ b/openHAB/SwiftUI/Rows/RollershutterRowView.swift @@ -29,9 +29,9 @@ struct RollershutterRowView: View { @State private var triggerDownFeedback = false private let logger = Logger(subsystem: "org.openhab", category: "WidgetRollershutterView") - private var displayState: WidgetDisplayState { widget.displayState } var body: some View { + let displayState = widget.displayState VStack(alignment: .leading, spacing: 8) { HStack { IconView(widget: widget) diff --git a/openHAB/SwiftUI/Rows/SegmentedRowView.swift b/openHAB/SwiftUI/Rows/SegmentedRowView.swift index 072f755cc..eef1e0853 100644 --- a/openHAB/SwiftUI/Rows/SegmentedRowView.swift +++ b/openHAB/SwiftUI/Rows/SegmentedRowView.swift @@ -26,19 +26,13 @@ struct SegmentedRowView: View { private let logger = Logger(subsystem: "org.openhab", category: "WidgetSegmentedView") - private var mappings: [OpenHABWidgetMapping] { - widget.mappingsOrItemOptions - } - - private var displayState: WidgetDisplayState { - widget.displayState - } - @State private var selectedIndex: Int? @State private var pressedIndex: Int? @State private var singlePressed = false var body: some View { + let displayState = widget.displayState + let mappings = displayState.mappings HStack(spacing: 0) { IconView(widget: widget, fallbackSymbol: fallbackSymbol) .frame(width: 32, height: 32) @@ -63,12 +57,12 @@ struct SegmentedRowView: View { } if !mappings.isEmpty { - if widget.hasPressReleaseMappings { + if displayState.hasPressReleaseMappings { // Press-release buttons for mappings with releaseCommand if !(displayState.labelValue?.isEmpty == false) { Spacer(minLength: 8) } - pressReleaseButtons + pressReleaseButtons(mappings: mappings) .fixedSize(horizontal: true, vertical: false) .padding(.leading, 8) @@ -76,12 +70,12 @@ struct SegmentedRowView: View { if displayState.labelValue.isNilOrEmpty { Spacer(minLength: 8) } - singleMappingButton + singleMappingButton(displayState: displayState, mappings: mappings) .fixedSize(horizontal: true, vertical: false) .padding(.leading, 8) } else { // Button-based segmented control with animated selection indicator - segmentedButtons + segmentedButtons(mappings: mappings) .frame(minWidth: 75) .padding(.leading, 8) .layoutPriority(1) @@ -89,19 +83,19 @@ struct SegmentedRowView: View { } } .onAppear { - selectedIndex = widget.mapCommandtoIndex(with: displayState.effectiveState) + selectedIndex = selectedIndex(for: displayState.effectiveState, mappings: mappings) } .onChange(of: displayState.effectiveState) { newState in - selectedIndex = widget.mapCommandtoIndex(with: newState) + selectedIndex = selectedIndex(for: newState, mappings: mappings) } } /// Button-based segmented control with animated selection indicator @ViewBuilder - private var segmentedButtons: some View { + private func segmentedButtons(mappings: [OpenHABWidgetMapping]) -> some View { HStack(spacing: 0) { ForEach(0 ..< mappings.count, id: \.self) { index in - segmentButton(at: index) + segmentButton(at: index, mappings: mappings) } } .background( @@ -132,7 +126,7 @@ struct SegmentedRowView: View { } @ViewBuilder - private var pressReleaseButtons: some View { + private func pressReleaseButtons(mappings: [OpenHABWidgetMapping]) -> some View { HStack(spacing: 8) { ForEach(mappings.indices, id: \.self) { index in pressReleaseButton(for: mappings[index], at: index) @@ -141,14 +135,15 @@ struct SegmentedRowView: View { } /// Whether the single mapping button is selected (item state matches the mapping command) - private var isSingleMappingSelected: Bool { - displayState.effectiveState == mappings[0].command + private func isSingleMappingSelected(displayState: WidgetDisplayState, mappings: [OpenHABWidgetMapping]) -> Bool { + guard let mapping = mappings.first else { return false } + return displayState.effectiveState == mapping.command } @ViewBuilder - private var singleMappingButton: some View { + private func singleMappingButton(displayState: WidgetDisplayState, mappings: [OpenHABWidgetMapping]) -> some View { let mapping = mappings[0] - let isSelected = isSingleMappingSelected + let isSelected = isSingleMappingSelected(displayState: displayState, mappings: mappings) Text(mapping.label) .font(.footnote) @@ -195,8 +190,12 @@ struct SegmentedRowView: View { // MARK: - Helper Methods + private func selectedIndex(for state: String, mappings: [OpenHABWidgetMapping]) -> Int? { + mappings.firstIndex { $0.command == state } + } + @ViewBuilder - private func segmentButton(at index: Int) -> some View { + private func segmentButton(at index: Int, mappings: [OpenHABWidgetMapping]) -> some View { let mapping = mappings[index] Button { diff --git a/openHAB/SwiftUI/Rows/SelectionRowView.swift b/openHAB/SwiftUI/Rows/SelectionRowView.swift index a2660280d..6c6c286b1 100644 --- a/openHAB/SwiftUI/Rows/SelectionRowView.swift +++ b/openHAB/SwiftUI/Rows/SelectionRowView.swift @@ -25,18 +25,15 @@ struct SelectionRowView: View { widget.mappingsOrItemOptions } - private var displayState: WidgetDisplayState { - widget.displayState - } - - /// Returns the label of the currently selected mapping, or the widget's labelValue as fallback - private var selectedValueText: String? { + /// Returns the label of the currently selected mapping, or the widget's labelValue as fallback. + private func selectedValueText(displayState: WidgetDisplayState) -> String? { displayState.selectedLabel ?? displayState.labelValue } var body: some View { + let displayState = widget.displayState ZStack { - rowContent + rowContent(displayState: displayState) .frame(maxWidth: .infinity, alignment: .leading) .animation(nil, value: displayState.effectiveState) @@ -66,7 +63,7 @@ struct SelectionRowView: View { } @ViewBuilder - private var rowContent: some View { + private func rowContent(displayState: WidgetDisplayState) -> some View { HStack { IconView(widget: widget) .frame(width: 32, height: 32) @@ -80,7 +77,7 @@ struct SelectionRowView: View { Spacer() - if let valueText = selectedValueText, !valueText.isEmpty { + if let valueText = selectedValueText(displayState: displayState), !valueText.isEmpty { Text(valueText) .foregroundStyle(widget.valuecolor.isEmpty ? .secondary : Color(fromString: widget.valuecolor)) .lineLimit(1) diff --git a/openHAB/SwiftUI/Rows/SetpointRowView.swift b/openHAB/SwiftUI/Rows/SetpointRowView.swift index 8403c8267..98a8f515c 100644 --- a/openHAB/SwiftUI/Rows/SetpointRowView.swift +++ b/openHAB/SwiftUI/Rows/SetpointRowView.swift @@ -23,16 +23,12 @@ struct SetpointRowView: View { private let logger = Logger(subsystem: "org.openhab", category: "WidgetSetpointView") private let setpointService = SetPointService() - private var displayState: WidgetDisplayState { - widget.displayState - } - - private var currentValue: Double { + private func currentValue(displayState: WidgetDisplayState) -> Double { widget.stateValueAsNumberState?.value ?? displayState.minValue } - private var formattedValue: String { - let text = currentValue.valueText(step: displayState.step) + private func formattedValue(displayState: WidgetDisplayState) -> String { + let text = currentValue(displayState: displayState).valueText(step: displayState.step) if let unit = widget.unit, !unit.isEmpty { return "\(text) \(unit)" } @@ -40,6 +36,8 @@ struct SetpointRowView: View { } var body: some View { + let displayState = widget.displayState + let currentValue = currentValue(displayState: displayState) HStack { IconView(widget: widget) .frame(width: 32, height: 32) @@ -56,7 +54,7 @@ struct SetpointRowView: View { HStack(spacing: 12) { Button { triggerFeedback.toggle() - decreaseValue() + decreaseValue(displayState: displayState) } label: { Image(systemSymbol: .chevronDown) .font(.body) @@ -67,13 +65,13 @@ struct SetpointRowView: View { .sensoryHeavyFeedbackIfAvailable(trigger: triggerFeedback) .disabled(widget.readOnly ?? false) - Text(formattedValue) + Text(formattedValue(displayState: displayState)) .font(.body.monospacedDigit()) .foregroundStyle(widget.valuecolor.isEmpty ? .secondary : Color(fromString: widget.valuecolor)) Button { triggerFeedback.toggle() - increaseValue() + increaseValue(displayState: displayState) } label: { Image(systemSymbol: .chevronUp) .font(.body) @@ -87,15 +85,15 @@ struct SetpointRowView: View { } } - private func decreaseValue() { - handleUpDown(isDecreasing: true) + private func decreaseValue(displayState: WidgetDisplayState) { + handleUpDown(isDecreasing: true, displayState: displayState) } - private func increaseValue() { - handleUpDown(isDecreasing: false) + private func increaseValue(displayState: WidgetDisplayState) { + handleUpDown(isDecreasing: false, displayState: displayState) } - private func handleUpDown(isDecreasing: Bool) { + private func handleUpDown(isDecreasing: Bool, displayState: WidgetDisplayState) { var numberState = widget.stateValueAsNumberState let currentValue = numberState?.value ?? displayState.minValue diff --git a/openHAB/SwiftUI/Rows/SliderRowView.swift b/openHAB/SwiftUI/Rows/SliderRowView.swift index bdd926f42..ac22b2b70 100644 --- a/openHAB/SwiftUI/Rows/SliderRowView.swift +++ b/openHAB/SwiftUI/Rows/SliderRowView.swift @@ -28,23 +28,20 @@ struct SliderRowView: View { "slider-\(widget.widgetId)" } - private var displayState: WidgetDisplayState { - widget.displayState - } - - private var sliderRange: ClosedRange { + private func sliderRange(displayState: WidgetDisplayState) -> ClosedRange { displayState.minValue ... displayState.maxValue } - private var currentValue: Double { + private func currentValue(displayState: WidgetDisplayState) -> Double { pendingValue ?? displayState.adjustedValue } - private var currentValueText: String { + private func currentValueText(displayState: WidgetDisplayState) -> String { + let currentValue = currentValue(displayState: displayState) currentValue.valueText(step: widget.step) } - private var valueBinding: Binding { + private func valueBinding(displayState: WidgetDisplayState) -> Binding { Binding( get: { pendingValue ?? displayState.adjustedValue }, set: { newValue in @@ -63,20 +60,22 @@ struct SliderRowView: View { } var body: some View { + let displayState = widget.displayState + let currentValue = currentValue(displayState: displayState) HStack { if widget.switchSupport { Button { viewModel.sendCommand(currentValue <= displayState.minValue ? "ON" : "OFF", for: widget) } label: { - labelContent + labelContent(displayState: displayState) } .buttonStyle(.plain) .disabled(widget.readOnly ?? false) } else { - labelContent + labelContent(displayState: displayState) } - Slider(value: valueBinding, in: sliderRange, step: widget.step) { editing in + Slider(value: valueBinding(displayState: displayState), in: sliderRange(displayState: displayState), step: widget.step) { editing in isEditing = editing if !editing { // Always send the final value on release @@ -114,7 +113,8 @@ struct SliderRowView: View { } @ViewBuilder - private var labelContent: some View { + private func labelContent(displayState: WidgetDisplayState) -> some View { + let currentValueText = currentValueText(displayState: displayState) HStack { IconView(widget: widget, fallbackSymbol: fallbackSymbol) .frame(width: 32, height: 32) diff --git a/openHAB/SwiftUI/Rows/SwitchRowView.swift b/openHAB/SwiftUI/Rows/SwitchRowView.swift index ef2ae8b09..0905a5242 100644 --- a/openHAB/SwiftUI/Rows/SwitchRowView.swift +++ b/openHAB/SwiftUI/Rows/SwitchRowView.swift @@ -21,15 +21,12 @@ struct SwitchRowView: View { private let logger = Logger(subsystem: "org.openhab", category: "WidgetSwitchView") - private var displayState: WidgetDisplayState { - widget.displayState - } - - private var isOn: Bool { + private func isOn(displayState: WidgetDisplayState) -> Bool { localIsOn ?? displayState.isOn } var body: some View { + let displayState = widget.displayState HStack { IconView(widget: widget) .frame(width: 32, height: 32) @@ -51,7 +48,7 @@ struct SwitchRowView: View { } Toggle("", isOn: Binding( - get: { isOn }, + get: { isOn(displayState: displayState) }, set: { newValue in localIsOn = newValue let newState = newValue ? "ON" : "OFF" diff --git a/openHAB/SwiftUI/Rows/TextInputRowView.swift b/openHAB/SwiftUI/Rows/TextInputRowView.swift index 0df8d60a5..01fe78260 100644 --- a/openHAB/SwiftUI/Rows/TextInputRowView.swift +++ b/openHAB/SwiftUI/Rows/TextInputRowView.swift @@ -21,9 +21,9 @@ struct TextInputRowView: View { @EnvironmentObject var viewModel: SitemapPageViewModel private let logger = Logger(subsystem: "org.openhab", category: "WidgetTextInputView") - private var displayState: WidgetDisplayState { widget.displayState } var body: some View { + let displayState = widget.displayState HStack { IconView(widget: widget) .frame(width: 32, height: 32) diff --git a/openHAB/SwiftUI/Rows/TextRowView.swift b/openHAB/SwiftUI/Rows/TextRowView.swift index 0f9806748..7cae01266 100644 --- a/openHAB/SwiftUI/Rows/TextRowView.swift +++ b/openHAB/SwiftUI/Rows/TextRowView.swift @@ -17,9 +17,9 @@ import SwiftUI struct TextRowView: View { @ObservedObject var widget: OpenHABWidget @EnvironmentObject var viewModel: SitemapPageViewModel - private var displayState: WidgetDisplayState { widget.displayState } var body: some View { + let displayState = widget.displayState HStack { IconView(widget: widget) .frame(width: 32, height: 32) diff --git a/openHAB/SwiftUI/Rows/VideoRowView.swift b/openHAB/SwiftUI/Rows/VideoRowView.swift index c4ebba40d..f7fd40884 100644 --- a/openHAB/SwiftUI/Rows/VideoRowView.swift +++ b/openHAB/SwiftUI/Rows/VideoRowView.swift @@ -29,7 +29,6 @@ struct VideoRowView: View { @EnvironmentObject var viewModel: SitemapPageViewModel private let logger = Logger(subsystem: "org.openhab", category: "VideoRowView") - private var displayState: WidgetDisplayState { widget.displayState } private var videoURL: URL? { guard !widget.url.isEmpty else { return nil } @@ -41,6 +40,7 @@ struct VideoRowView: View { } var body: some View { + let displayState = widget.displayState VStack(alignment: .leading, spacing: 8) { if !displayState.labelText.isEmpty, widget.labelSource == .sitemapDefinition { let labelText = displayState.labelText diff --git a/openHAB/SwiftUI/Rows/WebRowView.swift b/openHAB/SwiftUI/Rows/WebRowView.swift index eceee47f4..9d95e2084 100644 --- a/openHAB/SwiftUI/Rows/WebRowView.swift +++ b/openHAB/SwiftUI/Rows/WebRowView.swift @@ -17,9 +17,9 @@ import WebKit struct WidgetWebViewContainer: View { @ObservedObject var widget: OpenHABWidget - private var displayState: WidgetDisplayState { widget.displayState } var body: some View { + let displayState = widget.displayState VStack(alignment: .leading, spacing: 8) { if !displayState.labelText.isEmpty, widget.labelSource == .sitemapDefinition { let labelText = displayState.labelText diff --git a/openHABWatch/Model/WidgetRowFactory.swift b/openHABWatch/Model/WidgetRowFactory.swift index 146c24f01..6694a4be1 100644 --- a/openHABWatch/Model/WidgetRowFactory.swift +++ b/openHABWatch/Model/WidgetRowFactory.swift @@ -16,7 +16,8 @@ enum WidgetRowFactory { @MainActor @ViewBuilder static func make(widget: OpenHABWidget, settings: AppSettings) -> some View { - let stateToken = widget.displayState.effectiveState + let displayState = widget.displayState + let stateToken = displayState.effectiveState switch widget.renderingKind { case .segmentedSwitch: @@ -30,7 +31,7 @@ enum WidgetRowFactory { case .setpoint: SetpointRow(widget: widget, stateToken: stateToken) case .frame: - FrameRow(title: widget.displayState.labelText) + FrameRow(title: displayState.labelText) case .text: TextRow(widget: widget, hasLinkedPage: widget.linkedPage != nil) case .image: @@ -58,10 +59,10 @@ enum WidgetRowFactory { case .selection: SelectionRow( widget: widget, - mappings: widget.displayState.mappings, - title: widget.displayState.labelText.isEmpty ? "Select" : widget.displayState.labelText, - initialSelectedIndex: widget.displayState.selectedIndex, - labelValue: widget.displayState.labelValue + mappings: displayState.mappings, + title: displayState.labelText.isEmpty ? "Select" : displayState.labelText, + initialSelectedIndex: displayState.selectedIndex, + labelValue: displayState.labelValue ) case .video, .webview, .dateInput, .textInput, .colorTemperaturePicker, .buttonGrid: // Not yet implemented for watchOS From f53e77392832ca6de27c7f1809d3c16abf2998e4 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Sat, 14 Feb 2026 09:50:22 +0100 Subject: [PATCH 459/476] Missing return Signed-off-by: Tim Mueller-Seydlitz --- openHAB/SwiftUI/Rows/SliderRowView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openHAB/SwiftUI/Rows/SliderRowView.swift b/openHAB/SwiftUI/Rows/SliderRowView.swift index ac22b2b70..993664fd4 100644 --- a/openHAB/SwiftUI/Rows/SliderRowView.swift +++ b/openHAB/SwiftUI/Rows/SliderRowView.swift @@ -38,7 +38,7 @@ struct SliderRowView: View { private func currentValueText(displayState: WidgetDisplayState) -> String { let currentValue = currentValue(displayState: displayState) - currentValue.valueText(step: widget.step) + return currentValue.valueText(step: widget.step) } private func valueBinding(displayState: WidgetDisplayState) -> Binding { From fa0ce1210b5e1675d39d7b9bc5f83c98381ad06b Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Sat, 14 Feb 2026 10:19:49 +0100 Subject: [PATCH 460/476] Refactor SwiftUI views for improved readability Extract inline view code into computed properties in settings views and reorder helper functions after body in row views for consistent structure. Signed-off-by: Tim Mueller-Seydlitz --- openHAB/SettingsView/ItemSelectionView.swift | 59 ++-- .../ScreenSaverSettingsView.swift | 293 ++++++++++-------- .../SettingsView/SitemapSettingsView.swift | 148 +++++---- openHAB/SwiftUI/Rows/ButtonGridRowView.swift | 10 +- openHAB/SwiftUI/Rows/SelectionRowView.swift | 10 +- openHAB/SwiftUI/Rows/SetpointRowView.swift | 24 +- openHAB/SwiftUI/Rows/SliderRowView.swift | 62 ++-- openHAB/SwiftUI/Rows/SwitchRowView.swift | 8 +- 8 files changed, 345 insertions(+), 269 deletions(-) diff --git a/openHAB/SettingsView/ItemSelectionView.swift b/openHAB/SettingsView/ItemSelectionView.swift index 80337d8fc..1928f09f6 100644 --- a/openHAB/SettingsView/ItemSelectionView.swift +++ b/openHAB/SettingsView/ItemSelectionView.swift @@ -37,29 +37,9 @@ struct ItemSelectionView: View { var body: some View { VStack { if isLoading { - Spacer() - ProgressView("Loading Items…") - Spacer() + loadingView } else { - TextField("Search", text: $searchText) - .textFieldStyle(.roundedBorder) - .padding(.horizontal) - - List { - ForEach(filteredItems, id: \.name) { item in - Button { - selectedItemName = (selectedItemName == item.name) ? nil : item.name - } label: { - HStack { - Text(item.name) - Spacer() - if selectedItemName == item.name { - Image(systemSymbol: .checkmark) - } - } - } - } - } + loadedView } } .navigationTitle("Items") @@ -74,4 +54,39 @@ struct ItemSelectionView: View { } } } + + @ViewBuilder + private var loadingView: some View { + Spacer() + ProgressView("Loading Items…") + Spacer() + } + + @ViewBuilder + private var loadedView: some View { + TextField("Search", text: $searchText) + .textFieldStyle(.roundedBorder) + .padding(.horizontal) + + List { + ForEach(filteredItems, id: \.name) { item in + itemRow(item) + } + } + } + + @ViewBuilder + private func itemRow(_ item: OpenHABItem) -> some View { + Button { + selectedItemName = (selectedItemName == item.name) ? nil : item.name + } label: { + HStack { + Text(item.name) + Spacer() + if selectedItemName == item.name { + Image(systemSymbol: .checkmark) + } + } + } + } } diff --git a/openHAB/SettingsView/ScreenSaverSettingsView.swift b/openHAB/SettingsView/ScreenSaverSettingsView.swift index 202bd08c7..5b92ba4cb 100644 --- a/openHAB/SettingsView/ScreenSaverSettingsView.swift +++ b/openHAB/SettingsView/ScreenSaverSettingsView.swift @@ -15,141 +15,17 @@ import UIKit struct ScreenSaverSettingsView: View { @State private var config = ScreenSaverConfiguration() + private let fontOptions: [String] = ["", "Arial", "Helvetica Neue", "Courier New", "Menlo", "Avenir Next"] var body: some View { Form { - Section { - Toggle("Enable Screen Saver", isOn: Binding( - get: { config.isEnabled }, - set: { config.isEnabled = $0 } - )) - } - - Section("Appearance") { - Toggle("Show Time", isOn: Binding( - get: { config.showsTime }, - set: { newVal in config.showsTime = newVal } - )) - - Toggle("Show Date", isOn: Binding( - get: { config.showsDate }, - set: { newVal in config.showsDate = newVal } - )) - - Toggle("Show Seconds", isOn: Binding( - get: { config.showsSeconds }, - set: { config.showsSeconds = $0 } - )) - - Toggle("24-Hour Clock", isOn: Binding( - get: { config.uses24HourTime }, - set: { config.uses24HourTime = $0 } - )) - - let fontOptions: [String] = ["", "Arial", "Helvetica Neue", "Courier New", "Menlo", "Avenir Next"] - Picker("Font", selection: Binding( - get: { config.fontName ?? "" }, - set: { config.fontName = $0.isEmpty ? nil : $0 } - )) { - ForEach(fontOptions, id: \.self) { name in - Text(name.isEmpty ? "Default" : name).tag(name) - } - } - } - .disabled(!config.isEnabled) - - Section("Timing") { - Stepper(value: Binding( - get: { Int(config.idleInterval) }, - set: { config.idleInterval = TimeInterval($0) } - ), in: 5 ... 600, step: 5) { - Text("Idle Interval: \(Int(config.idleInterval)) s") - } - - Stepper(value: Binding( - get: { Int(config.movementInterval) }, - set: { config.movementInterval = TimeInterval($0) } - ), in: 2 ... 60, step: 1) { - Text("Movement Interval: \(Int(config.movementInterval)) s") - } - } - .disabled(!config.isEnabled) - - Section("Font Size") { - VStack(alignment: .leading) { - Text("Clock Size: \(Int(config.timeFontSizeRatio * 100)) %") - .font(.caption) - Slider(value: Binding( - get: { Double(config.timeFontSizeRatio) }, - set: { config.timeFontSizeRatio = CGFloat($0) } - ), in: 0.05 ... 0.4, step: 0.01) - } - - VStack(alignment: .leading) { - Text("Date relative: \(Int(config.dateFontRelativeSize * 100)) %") - .font(.caption) - Slider(value: Binding( - get: { Double(config.dateFontRelativeSize) }, - set: { config.dateFontRelativeSize = CGFloat($0) } - ), in: 0.1 ... 1.0, step: 0.05) - } - } - .disabled(!config.isEnabled) - - Section("Animation") { - VStack(alignment: .leading) { - Text("Fade Duration: \(String(format: "%.1f", config.fadeDuration)) s") - .font(.caption) - Slider(value: Binding( - get: { config.fadeDuration }, - set: { config.fadeDuration = $0 } - ), in: 0.1 ... 3.0, step: 0.1) - } - } - .disabled(!config.isEnabled) - - Section("Brightness") { - Toggle("Enable Dimming", isOn: Binding( - get: { config.enablesAutoDimming }, - set: { config.enablesAutoDimming = $0 } - )) - - VStack(alignment: .leading) { - Text("Dim Level: \(Int(config.dimLevel * 100)) %") - .font(.caption) - Slider(value: Binding( - get: { Double(config.dimLevel * 100) }, - set: { config.dimLevel = CGFloat($0) / 100 } - ), in: 0 ... 100, step: 1) - } - .disabled(!config.enablesAutoDimming) - - Toggle("Restore Previous Brightness on Wake", isOn: Binding( - get: { config.restoresBrightness }, - set: { config.restoresBrightness = $0 } - )).disabled(!config.enablesAutoDimming) - - VStack(alignment: .leading) { - Text("Restore Brightness: \(Int(config.wakeBrightnessLevel * 100)) %") - .font(.caption) - Slider(value: Binding( - get: { Double(config.wakeBrightnessLevel * 100) }, - set: { config.wakeBrightnessLevel = CGFloat($0) / 100 } - ), in: 0 ... 100, step: 1) - } - .disabled(!config.enablesAutoDimming || config.restoresBrightness) - } - .disabled(!config.isEnabled) - - Section { - Button("Test Screen Saver") { - if let keyWindow = UIApplication.shared.keyWindowActiveScene { - // Ensure the manager knows about the current key window in case monitoring was not started yet. - ScreenSaverManager.shared.startMonitoring(window: keyWindow, configuration: config) - } - ScreenSaverManager.shared.presentSaver(configuration: config) - } - } + enableSection + appearanceSection + timingSection + fontSection + animationSection + brightnessSection + testSection } .navigationTitle("Screen Saver") .onDisappear { @@ -192,6 +68,159 @@ struct ScreenSaverSettingsView: View { } } + private var fontBinding: Binding { + Binding( + get: { config.fontName ?? "" }, + set: { config.fontName = $0.isEmpty ? nil : $0 } + ) + } + + private var idleIntervalBinding: Binding { + Binding( + get: { Int(config.idleInterval) }, + set: { config.idleInterval = TimeInterval($0) } + ) + } + + private var movementIntervalBinding: Binding { + Binding( + get: { Int(config.movementInterval) }, + set: { config.movementInterval = TimeInterval($0) } + ) + } + + private var timeFontSizeBinding: Binding { + Binding( + get: { Double(config.timeFontSizeRatio) }, + set: { config.timeFontSizeRatio = CGFloat($0) } + ) + } + + private var dateFontSizeBinding: Binding { + Binding( + get: { Double(config.dateFontRelativeSize) }, + set: { config.dateFontRelativeSize = CGFloat($0) } + ) + } + + private var dimLevelBinding: Binding { + Binding( + get: { Double(config.dimLevel * 100) }, + set: { config.dimLevel = CGFloat($0) / 100 } + ) + } + + private var wakeBrightnessBinding: Binding { + Binding( + get: { Double(config.wakeBrightnessLevel * 100) }, + set: { config.wakeBrightnessLevel = CGFloat($0) / 100 } + ) + } + + @ViewBuilder + private var enableSection: some View { + Section { + Toggle("Enable Screen Saver", isOn: $config.isEnabled) + } + } + + @ViewBuilder + private var appearanceSection: some View { + Section("Appearance") { + Toggle("Show Time", isOn: $config.showsTime) + Toggle("Show Date", isOn: $config.showsDate) + Toggle("Show Seconds", isOn: $config.showsSeconds) + Toggle("24-Hour Clock", isOn: $config.uses24HourTime) + Picker("Font", selection: fontBinding) { + ForEach(fontOptions, id: \.self) { name in + Text(name.isEmpty ? "Default" : name).tag(name) + } + } + } + .disabled(!config.isEnabled) + } + + @ViewBuilder + private var timingSection: some View { + Section("Timing") { + Stepper(value: idleIntervalBinding, in: 5 ... 600, step: 5) { + Text("Idle Interval: \(Int(config.idleInterval)) s") + } + + Stepper(value: movementIntervalBinding, in: 2 ... 60, step: 1) { + Text("Movement Interval: \(Int(config.movementInterval)) s") + } + } + .disabled(!config.isEnabled) + } + + @ViewBuilder + private var fontSection: some View { + Section("Font Size") { + VStack(alignment: .leading) { + Text("Clock Size: \(Int(config.timeFontSizeRatio * 100)) %") + .font(.caption) + Slider(value: timeFontSizeBinding, in: 0.05 ... 0.4, step: 0.01) + } + + VStack(alignment: .leading) { + Text("Date relative: \(Int(config.dateFontRelativeSize * 100)) %") + .font(.caption) + Slider(value: dateFontSizeBinding, in: 0.1 ... 1.0, step: 0.05) + } + } + .disabled(!config.isEnabled) + } + + @ViewBuilder + private var animationSection: some View { + Section("Animation") { + VStack(alignment: .leading) { + Text("Fade Duration: \(String(format: "%.1f", config.fadeDuration)) s") + .font(.caption) + Slider(value: $config.fadeDuration, in: 0.1 ... 3.0, step: 0.1) + } + } + .disabled(!config.isEnabled) + } + + @ViewBuilder + private var brightnessSection: some View { + Section("Brightness") { + Toggle("Enable Dimming", isOn: $config.enablesAutoDimming) + + VStack(alignment: .leading) { + Text("Dim Level: \(Int(config.dimLevel * 100)) %") + .font(.caption) + Slider(value: dimLevelBinding, in: 0 ... 100, step: 1) + } + .disabled(!config.enablesAutoDimming) + + Toggle("Restore Previous Brightness on Wake", isOn: $config.restoresBrightness) + .disabled(!config.enablesAutoDimming) + + VStack(alignment: .leading) { + Text("Restore Brightness: \(Int(config.wakeBrightnessLevel * 100)) %") + .font(.caption) + Slider(value: wakeBrightnessBinding, in: 0 ... 100, step: 1) + } + .disabled(!config.enablesAutoDimming || config.restoresBrightness) + } + .disabled(!config.isEnabled) + } + + @ViewBuilder + private var testSection: some View { + Section { + Button("Test Screen Saver") { + if let keyWindow = UIApplication.shared.keyWindowActiveScene { + ScreenSaverManager.shared.startMonitoring(window: keyWindow, configuration: config) + } + ScreenSaverManager.shared.presentSaver(configuration: config) + } + } + } + private func changeConfig(_ config: ScreenSaverConfiguration) { self.config = config } diff --git a/openHAB/SettingsView/SitemapSettingsView.swift b/openHAB/SettingsView/SitemapSettingsView.swift index ca64b4469..94c5236cf 100644 --- a/openHAB/SettingsView/SitemapSettingsView.swift +++ b/openHAB/SettingsView/SitemapSettingsView.swift @@ -27,75 +27,107 @@ struct SitemapSettingsView: View { var body: some View { Section(header: Text(LocalizedStringKey("sitemap_settings"))) { - Toggle(isOn: $settingsRealTimeSliders) { - Text("Real-time Sliders") - } + realtimeSliderToggle + searchFieldToggle + cacheButton + iconTypePicker + sortOrderPicker + watchSitemapPicker + } + } - Toggle(isOn: $settingsShowSearchField) { - Text("Show Search Field") - } + @ViewBuilder + private var realtimeSliderToggle: some View { + Toggle(isOn: $settingsRealTimeSliders) { + Text("Real-time Sliders") + } + } - Button { - KingfisherManager.shared.cache.calculateDiskStorageSize { result in - Task { @MainActor in - cacheSizeResult = result - showingCacheAlert = true - } + @ViewBuilder + private var searchFieldToggle: some View { + Toggle(isOn: $settingsShowSearchField) { + Text("Show Search Field") + } + } + + @ViewBuilder + private var cacheButton: some View { + Button { + KingfisherManager.shared.cache.calculateDiskStorageSize { result in + Task { @MainActor in + cacheSizeResult = result + showingCacheAlert = true } - } label: { - NavigationLink("Check & Clear Image Cache", destination: EmptyView()) } - .foregroundStyle(Color(uiColor: .label)) - .alert( - "Image Cache", - isPresented: $showingCacheAlert, - presenting: cacheSizeResult, - actions: { result in - switch result { - case .success: - Button("Clear") { - clearWebsiteCache() - } - Button("Cancel", role: .cancel) {} - case .failure: - Button("OK") {} - } - }, message: { result in - switch result { - case let .success(size): - Text("Size: \(size / 1_048_576) MB") - case let .failure(error): - Text(error.localizedDescription) - } - } - ) + } label: { + NavigationLink("Check & Clear Image Cache", destination: EmptyView()) + } + .foregroundStyle(Color(uiColor: .label)) + .alert( + "Image Cache", + isPresented: $showingCacheAlert, + presenting: cacheSizeResult, + actions: cacheAlertActions, + message: cacheAlertMessage + ) + } - Picker(selection: $settingsIconType) { - ForEach(IconType.allCases, id: \.self) { icontype in - Text(verbatim: "\(icontype)").tag(icontype) - } - } label: { - Text("Icon Type") + @ViewBuilder + private var iconTypePicker: some View { + Picker(selection: $settingsIconType) { + ForEach(IconType.allCases, id: \.self) { icontype in + Text(verbatim: "\(icontype)").tag(icontype) } + } label: { + Text("Icon Type") + } + } - Picker(selection: $settingsSortSitemapsBy) { - ForEach(SortSitemapsOrder.allCases, id: \.self) { sortsitemaporder in - Text(verbatim: "\(sortsitemaporder)").tag(sortsitemaporder) - } - } label: { - Text("Sort sitemaps by") + @ViewBuilder + private var sortOrderPicker: some View { + Picker(selection: $settingsSortSitemapsBy) { + ForEach(SortSitemapsOrder.allCases, id: \.self) { sortsitemaporder in + Text(verbatim: "\(sortsitemaporder)").tag(sortsitemaporder) } + } label: { + Text("Sort sitemaps by") + } + } - Picker("Sitemap For Apple Watch", selection: $settingsSitemapForWatch) { - if sitemaps.isEmpty { - Text("No sitemaps available").tag("").foregroundStyle(.secondary) - } else { - ForEach(sitemaps, id: \.name) { sitemap in - Text(sitemap.label).tag(sitemap.name) - } + @ViewBuilder + private var watchSitemapPicker: some View { + Picker("Sitemap For Apple Watch", selection: $settingsSitemapForWatch) { + if sitemaps.isEmpty { + Text("No sitemaps available").tag("").foregroundStyle(.secondary) + } else { + ForEach(sitemaps, id: \.name) { sitemap in + Text(sitemap.label).tag(sitemap.name) } } - .disabled(sitemaps.isEmpty) + } + .disabled(sitemaps.isEmpty) + } + + @ViewBuilder + private func cacheAlertActions(_ result: Result) -> some View { + switch result { + case .success: + Button("Clear") { + clearWebsiteCache() + } + Button("Cancel", role: .cancel) {} + case .failure: + Button("OK") {} + } + } + + @ViewBuilder + private func cacheAlertMessage(_ result: Result) -> some View { + switch result { + case let .success(size): + Text("Size: \(size / 1_048_576) MB") + case let .failure(error): + Text(error.localizedDescription) } } diff --git a/openHAB/SwiftUI/Rows/ButtonGridRowView.swift b/openHAB/SwiftUI/Rows/ButtonGridRowView.swift index 32c483b11..071eca3c4 100644 --- a/openHAB/SwiftUI/Rows/ButtonGridRowView.swift +++ b/openHAB/SwiftUI/Rows/ButtonGridRowView.swift @@ -24,11 +24,6 @@ struct ButtonGridButton: View { private let logger = Logger(subsystem: "org.openhab", category: "ButtonGridButton") - private func isChecked(displayState: WidgetDisplayState) -> Bool { - if let stateless = widget.stateless, stateless { return false } - return displayState.effectiveState == widget.command - } - private var hasPressRelease: Bool { if let releaseCommand = widget.releaseCommand, !releaseCommand.isEmpty { return true @@ -118,6 +113,11 @@ struct ButtonGridButton: View { fallbackItem: parentItem ) } + + private func isChecked(displayState: WidgetDisplayState) -> Bool { + if let stateless = widget.stateless, stateless { return false } + return displayState.effectiveState == widget.command + } } private struct PressGestureModifier: ViewModifier { diff --git a/openHAB/SwiftUI/Rows/SelectionRowView.swift b/openHAB/SwiftUI/Rows/SelectionRowView.swift index 6c6c286b1..4d9dfac9e 100644 --- a/openHAB/SwiftUI/Rows/SelectionRowView.swift +++ b/openHAB/SwiftUI/Rows/SelectionRowView.swift @@ -25,11 +25,6 @@ struct SelectionRowView: View { widget.mappingsOrItemOptions } - /// Returns the label of the currently selected mapping, or the widget's labelValue as fallback. - private func selectedValueText(displayState: WidgetDisplayState) -> String? { - displayState.selectedLabel ?? displayState.labelValue - } - var body: some View { let displayState = widget.displayState ZStack { @@ -90,4 +85,9 @@ struct SelectionRowView: View { } .contentShape(Rectangle()) } + + /// Returns the label of the currently selected mapping, or the widget's labelValue as fallback. + private func selectedValueText(displayState: WidgetDisplayState) -> String? { + displayState.selectedLabel ?? displayState.labelValue + } } diff --git a/openHAB/SwiftUI/Rows/SetpointRowView.swift b/openHAB/SwiftUI/Rows/SetpointRowView.swift index 98a8f515c..812293455 100644 --- a/openHAB/SwiftUI/Rows/SetpointRowView.swift +++ b/openHAB/SwiftUI/Rows/SetpointRowView.swift @@ -23,18 +23,6 @@ struct SetpointRowView: View { private let logger = Logger(subsystem: "org.openhab", category: "WidgetSetpointView") private let setpointService = SetPointService() - private func currentValue(displayState: WidgetDisplayState) -> Double { - widget.stateValueAsNumberState?.value ?? displayState.minValue - } - - private func formattedValue(displayState: WidgetDisplayState) -> String { - let text = currentValue(displayState: displayState).valueText(step: displayState.step) - if let unit = widget.unit, !unit.isEmpty { - return "\(text) \(unit)" - } - return text - } - var body: some View { let displayState = widget.displayState let currentValue = currentValue(displayState: displayState) @@ -117,6 +105,18 @@ struct SetpointRowView: View { logger.info("Setpoint \(isDecreasing ? "decreased" : "increased") to \(numberState?.description ?? String(limitedNewValue))") viewModel.sendToUpdate(item: widget.item, state: numberState, policy: .immediate) } + + private func currentValue(displayState: WidgetDisplayState) -> Double { + widget.stateValueAsNumberState?.value ?? displayState.minValue + } + + private func formattedValue(displayState: WidgetDisplayState) -> String { + let text = currentValue(displayState: displayState).valueText(step: displayState.step) + if let unit = widget.unit, !unit.isEmpty { + return "\(text) \(unit)" + } + return text + } } #Preview { diff --git a/openHAB/SwiftUI/Rows/SliderRowView.swift b/openHAB/SwiftUI/Rows/SliderRowView.swift index 993664fd4..a82ff6a9f 100644 --- a/openHAB/SwiftUI/Rows/SliderRowView.swift +++ b/openHAB/SwiftUI/Rows/SliderRowView.swift @@ -28,37 +28,6 @@ struct SliderRowView: View { "slider-\(widget.widgetId)" } - private func sliderRange(displayState: WidgetDisplayState) -> ClosedRange { - displayState.minValue ... displayState.maxValue - } - - private func currentValue(displayState: WidgetDisplayState) -> Double { - pendingValue ?? displayState.adjustedValue - } - - private func currentValueText(displayState: WidgetDisplayState) -> String { - let currentValue = currentValue(displayState: displayState) - return currentValue.valueText(step: widget.step) - } - - private func valueBinding(displayState: WidgetDisplayState) -> Binding { - Binding( - get: { pendingValue ?? displayState.adjustedValue }, - set: { newValue in - pendingValue = newValue - - // Send updates during drag if enabled (throttled) - if widget.shouldUseSliderUpdatesDuringMove() { - sendSliderUpdate( - newValue, - policy: WidgetCommandDefaults.slider, - key: sliderCommandKey - ) - } - } - ) - } - var body: some View { let displayState = widget.displayState let currentValue = currentValue(displayState: displayState) @@ -146,6 +115,37 @@ struct SliderRowView: View { numberState?.value = newValue viewModel.sendToUpdate(item: widget.item, state: numberState, policy: policy, key: key) } + + private func sliderRange(displayState: WidgetDisplayState) -> ClosedRange { + displayState.minValue ... displayState.maxValue + } + + private func currentValue(displayState: WidgetDisplayState) -> Double { + pendingValue ?? displayState.adjustedValue + } + + private func currentValueText(displayState: WidgetDisplayState) -> String { + let currentValue = currentValue(displayState: displayState) + return currentValue.valueText(step: widget.step) + } + + private func valueBinding(displayState: WidgetDisplayState) -> Binding { + Binding( + get: { pendingValue ?? displayState.adjustedValue }, + set: { newValue in + pendingValue = newValue + + // Send updates during drag if enabled (throttled) + if widget.shouldUseSliderUpdatesDuringMove() { + sendSliderUpdate( + newValue, + policy: WidgetCommandDefaults.slider, + key: sliderCommandKey + ) + } + } + ) + } } // MARK: - Preview Helpers diff --git a/openHAB/SwiftUI/Rows/SwitchRowView.swift b/openHAB/SwiftUI/Rows/SwitchRowView.swift index 0905a5242..a38f3f474 100644 --- a/openHAB/SwiftUI/Rows/SwitchRowView.swift +++ b/openHAB/SwiftUI/Rows/SwitchRowView.swift @@ -21,10 +21,6 @@ struct SwitchRowView: View { private let logger = Logger(subsystem: "org.openhab", category: "WidgetSwitchView") - private func isOn(displayState: WidgetDisplayState) -> Bool { - localIsOn ?? displayState.isOn - } - var body: some View { let displayState = widget.displayState HStack { @@ -69,6 +65,10 @@ struct SwitchRowView: View { localIsOn = nil } } + + private func isOn(displayState: WidgetDisplayState) -> Bool { + localIsOn ?? displayState.isOn + } } #Preview { From 5bfbcc072fff4a4329f27f801baa74fda8081cfd Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Sat, 14 Feb 2026 20:47:08 +0100 Subject: [PATCH 461/476] Add OHTextToken design system and improve value formatting Introduce OHTextToken style tokens to standardize typography across all row views, replacing ad-hoc font/lineLimit/truncation settings. Add number pattern formatting for setpoint and slider values on both iOS and watchOS. Improve page title fallback handling and video layout. Signed-off-by: Tim Mueller-Seydlitz --- .../Sources/CommonUI/OHTextTokenStyle.swift | 79 +++++++++++++++++++ CommonUI/Sources/CommonUI/TextLabelView.swift | 8 +- openHAB/SitemapPageViewModel.swift | 16 ++-- openHAB/SwiftUI/Rows/ButtonGridRowView.swift | 6 +- openHAB/SwiftUI/Rows/ColorPickerRowView.swift | 5 +- .../Rows/ColorTemperaturePickerRowView.swift | 12 +-- .../SwiftUI/Rows/DatePickerInputRowView.swift | 2 +- openHAB/SwiftUI/Rows/FrameRowView.swift | 3 +- openHAB/SwiftUI/Rows/GenericRowView.swift | 4 +- openHAB/SwiftUI/Rows/ImageRowView.swift | 5 +- openHAB/SwiftUI/Rows/MapRowView.swift | 2 +- .../SwiftUI/Rows/RollershutterRowView.swift | 2 +- openHAB/SwiftUI/Rows/SegmentedRowView.swift | 18 ++--- openHAB/SwiftUI/Rows/SelectionRowView.swift | 6 +- openHAB/SwiftUI/Rows/SetpointRowView.swift | 14 +++- openHAB/SwiftUI/Rows/SliderRowView.swift | 22 ++++-- openHAB/SwiftUI/Rows/SwitchRowView.swift | 5 +- openHAB/SwiftUI/Rows/TextInputRowView.swift | 2 +- openHAB/SwiftUI/Rows/TextRowView.swift | 5 +- openHAB/SwiftUI/Rows/VideoRowView.swift | 10 ++- openHAB/SwiftUI/Rows/WebRowView.swift | 5 +- openHABWatch/Views/Rows/SetpointRow.swift | 31 ++++++-- openHABWatch/Views/Rows/SliderRow.swift | 20 ++++- 23 files changed, 202 insertions(+), 80 deletions(-) create mode 100644 CommonUI/Sources/CommonUI/OHTextTokenStyle.swift diff --git a/CommonUI/Sources/CommonUI/OHTextTokenStyle.swift b/CommonUI/Sources/CommonUI/OHTextTokenStyle.swift new file mode 100644 index 000000000..a2e34e667 --- /dev/null +++ b/CommonUI/Sources/CommonUI/OHTextTokenStyle.swift @@ -0,0 +1,79 @@ +// Copyright (c) 2010-2026 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 SwiftUI + +public enum OHTextToken { + case rowLabel + case rowValue + case rowValueCompact + case rowValueCallout + case section + case control + case secondary + case emphasis +} + +public enum OHAccessibilityToken { + public static let minimumHitTarget: CGFloat = 44 +} + +private struct OHTextTokenModifier: ViewModifier { + let token: OHTextToken + + func body(content: Content) -> some View { + let style = OHTextTokenStyle.from(token) + content + .font(style.font) + .lineLimit(style.lineLimit) + .minimumScaleFactor(style.minimumScaleFactor) + .truncationMode(.tail) + .multilineTextAlignment(.leading) + } +} + +private struct OHTextTokenStyle { + let font: Font + let lineLimit: Int + let minimumScaleFactor: CGFloat + + static func from(_ token: OHTextToken) -> OHTextTokenStyle { + switch token { + case .rowLabel: + OHTextTokenStyle(font: .body, lineLimit: 1, minimumScaleFactor: 0.9) + case .rowValue: + OHTextTokenStyle(font: .body, lineLimit: 1, minimumScaleFactor: 0.9) + case .rowValueCompact: + OHTextTokenStyle(font: .caption, lineLimit: 1, minimumScaleFactor: 0.9) + case .rowValueCallout: + OHTextTokenStyle(font: .callout, lineLimit: 1, minimumScaleFactor: 0.9) + case .section: + OHTextTokenStyle(font: .callout, lineLimit: 1, minimumScaleFactor: 0.85) + case .control: + OHTextTokenStyle(font: .footnote, lineLimit: 1, minimumScaleFactor: 0.85) + case .secondary: + OHTextTokenStyle(font: .caption, lineLimit: 1, minimumScaleFactor: 0.9) + case .emphasis: + OHTextTokenStyle(font: .headline, lineLimit: 1, minimumScaleFactor: 0.9) + } + } +} + +public extension View { + func ohTextToken(_ token: OHTextToken) -> some View { + modifier(OHTextTokenModifier(token: token)) + } + + /// Applies the standard minimum tappable target used across row controls. + func ohMinimumHitTarget(_ minHeight: CGFloat = OHAccessibilityToken.minimumHitTarget) -> some View { + frame(minHeight: minHeight) + } +} diff --git a/CommonUI/Sources/CommonUI/TextLabelView.swift b/CommonUI/Sources/CommonUI/TextLabelView.swift index d8051cdc3..3fac62d18 100644 --- a/CommonUI/Sources/CommonUI/TextLabelView.swift +++ b/CommonUI/Sources/CommonUI/TextLabelView.swift @@ -15,18 +15,24 @@ import SwiftUI public struct TextLabelView: View { @ObservedObject var widget: OpenHABWidget var font: Font? + var token: OHTextToken var lineLimit: Int public var body: some View { Text(widget.labelText ?? "") + .ohTextToken(token) .font(font) .lineLimit(lineLimit) .foregroundStyle(!widget.labelcolor.isEmpty ? Color(fromString: widget.labelcolor) : .primary) } - public init(widget: OpenHABWidget, font: Font? = nil, lineLimit: Int = 1) { + public init(widget: OpenHABWidget, + font: Font? = nil, + token: OHTextToken = .rowLabel, + lineLimit: Int = 1) { self.widget = widget self.font = font + self.token = token self.lineLimit = lineLimit } } diff --git a/openHAB/SitemapPageViewModel.swift b/openHAB/SitemapPageViewModel.swift index aa854fa23..b7a612985 100644 --- a/openHAB/SitemapPageViewModel.swift +++ b/openHAB/SitemapPageViewModel.swift @@ -53,6 +53,7 @@ class SitemapPageViewModel: ObservableObject { private let commandDispatcher = WidgetCommandDispatcher() private var defaultSitemap = "" private var defaultSitemapLabel = "" + private var fallbackTitle = "" @Published var pageId = "" private var isLinkedPage = false private var pageNetworkStatus: NetworkStatus? @@ -71,9 +72,11 @@ class SitemapPageViewModel: ObservableObject { var pageTitle: String { // Strip bracket content from title (e.g., "Living Room[2]" becomes "Living Room") - let title = currentPage?.title.components(separatedBy: "[")[0] ?? "" + let title = currentPage?.title.components(separatedBy: "[")[0].trimmingCharacters(in: .whitespacesAndNewlines) ?? "" if !title.isEmpty { return title + } else if !fallbackTitle.isEmpty { + return fallbackTitle } else if !defaultSitemapLabel.isEmpty { return defaultSitemapLabel } else { @@ -100,6 +103,7 @@ class SitemapPageViewModel: ObservableObject { init(pageUrl: String, title: String, pageId: String = "") { loadSettings() isLinkedPage = true + fallbackTitle = title defaultSitemapLabel = title // Set openHABRootUrl from current active connection for charts/images @@ -121,6 +125,7 @@ class SitemapPageViewModel: ObservableObject { /// Initializes the view model with a fixed set of widgets, without loading or polling init(pageUrl: String = "", title: String = "Preview Page", pageId: String = "", widgets: [OpenHABWidget]) { isLinkedPage = !pageUrl.isEmpty + fallbackTitle = title self.pageId = pageId currentPage = OpenHABPage( pageId: pageId.isEmpty ? UUID().uuidString : pageId, @@ -527,14 +532,13 @@ class SitemapPageViewModel: ObservableObject { } func sendCommand(itemname: String, command: String) { - let sourcePrefix = sitemapSourcePrefix() let deviceId = UIDevice.current.identifierForVendor?.uuidString Task { do { try await openAPIService?.sendItemCommand( itemname: itemname, command: command, - sourcePrefix: sourcePrefix, + sourcePrefix: nil, deviceId: deviceId ) logger.info("Successfully sent command \(command) to \(itemname)") @@ -544,12 +548,6 @@ class SitemapPageViewModel: ObservableObject { } } - private func sitemapSourcePrefix() -> String? { - guard !defaultSitemap.isEmpty else { return nil } - let suffix = pageId.isEmpty ? "" : ":\(pageId)" - return "org.openhab.ui.basic$\(defaultSitemap)\(suffix)" - } - func sendToUpdate(item: OpenHABItem?, state: NumberState?, policy: WidgetCommandPolicy = .immediate, diff --git a/openHAB/SwiftUI/Rows/ButtonGridRowView.swift b/openHAB/SwiftUI/Rows/ButtonGridRowView.swift index 071eca3c4..63b15a3ea 100644 --- a/openHAB/SwiftUI/Rows/ButtonGridRowView.swift +++ b/openHAB/SwiftUI/Rows/ButtonGridRowView.swift @@ -47,10 +47,8 @@ struct ButtonGridButton: View { .frame(width: 16, height: 16) } else { Text(widget.label) - .font(.caption) + .ohTextToken(.rowValueCompact) .foregroundStyle(.primary) - .lineLimit(1) - .truncationMode(.tail) } } .frame(maxWidth: .infinity) @@ -182,8 +180,8 @@ struct ButtonGridRowView: View { if !displayState.labelText.isEmpty { let labelText = displayState.labelText Text(labelText) + .ohTextToken(.rowLabel) .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) - .lineLimit(1) } Spacer() diff --git a/openHAB/SwiftUI/Rows/ColorPickerRowView.swift b/openHAB/SwiftUI/Rows/ColorPickerRowView.swift index a601f0e1f..d08079597 100644 --- a/openHAB/SwiftUI/Rows/ColorPickerRowView.swift +++ b/openHAB/SwiftUI/Rows/ColorPickerRowView.swift @@ -35,8 +35,8 @@ struct ColorPickerRowView: View { if !displayState.labelText.isEmpty { let labelText = displayState.labelText Text(labelText) + .ohTextToken(.rowLabel) .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) - .lineLimit(1) } Spacer() @@ -50,9 +50,8 @@ struct ColorPickerRowView: View { if let labelValue = displayState.labelValue, !labelValue.isEmpty { Text(labelValue) - .font(.caption) + .ohTextToken(.rowValueCompact) .foregroundStyle(widget.valuecolor.isEmpty ? .secondary : Color(fromString: widget.valuecolor)) - .lineLimit(1) } } .onAppear { diff --git a/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift b/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift index d5f0cd67d..2209cb8c0 100644 --- a/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift +++ b/openHAB/SwiftUI/Rows/ColorTemperaturePickerRowView.swift @@ -92,8 +92,8 @@ struct ColorTemperaturePickerRowView: View { if !displayState.labelText.isEmpty { let labelText = displayState.labelText Text(labelText) + .ohTextToken(.rowLabel) .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) - .lineLimit(1) } Spacer() @@ -101,16 +101,16 @@ struct ColorTemperaturePickerRowView: View { // Temperature value display HStack { Text("\(Int(selectedTemperature))K") - .font(.caption) + .ohTextToken(.rowValueCompact) .foregroundStyle(.secondary) Text(" - ") - .font(.caption) + .ohTextToken(.rowValueCompact) .foregroundStyle(.secondary) // Temperature description Text(temperatureDescription) - .font(.caption2) + .ohTextToken(.secondary) .foregroundStyle(.secondary) } } @@ -120,7 +120,7 @@ struct ColorTemperaturePickerRowView: View { // Warm indicator Image(systemSymbol: .sunMinFill) .foregroundStyle(.orange) - .font(.caption) + .ohTextToken(.secondary) // Slider with custom gradient track ZStack(alignment: .leading) { @@ -149,7 +149,7 @@ struct ColorTemperaturePickerRowView: View { // Cool indicator Image(systemSymbol: .snowflake) .foregroundStyle(.blue) - .font(.caption) + .ohTextToken(.secondary) } } } diff --git a/openHAB/SwiftUI/Rows/DatePickerInputRowView.swift b/openHAB/SwiftUI/Rows/DatePickerInputRowView.swift index c1e311032..1bc22e0d2 100644 --- a/openHAB/SwiftUI/Rows/DatePickerInputRowView.swift +++ b/openHAB/SwiftUI/Rows/DatePickerInputRowView.swift @@ -43,8 +43,8 @@ struct DatePickerInputRowView: View { if !displayState.labelText.isEmpty { let labelText = displayState.labelText Text(labelText) + .ohTextToken(.rowLabel) .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) - .lineLimit(1) } Spacer() diff --git a/openHAB/SwiftUI/Rows/FrameRowView.swift b/openHAB/SwiftUI/Rows/FrameRowView.swift index e75bf9a9a..19e11b652 100644 --- a/openHAB/SwiftUI/Rows/FrameRowView.swift +++ b/openHAB/SwiftUI/Rows/FrameRowView.swift @@ -21,9 +21,8 @@ struct FrameRowView: View { let displayState = widget.displayState HStack { Text(displayState.labelText.uppercased()) - .font(.callout) + .ohTextToken(.section) .foregroundStyle(.secondary) - .lineLimit(1) Spacer() } } diff --git a/openHAB/SwiftUI/Rows/GenericRowView.swift b/openHAB/SwiftUI/Rows/GenericRowView.swift index 1a423884c..47da07130 100644 --- a/openHAB/SwiftUI/Rows/GenericRowView.swift +++ b/openHAB/SwiftUI/Rows/GenericRowView.swift @@ -24,15 +24,15 @@ struct GenericRowView: View { .frame(width: 32, height: 32) Text(displayState.labelText) + .ohTextToken(.rowLabel) .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) - .lineLimit(1) Spacer() if let value = displayState.labelValue { Text(value) + .ohTextToken(.rowValue) .foregroundStyle(widget.valuecolor.isEmpty ? .secondary : Color(fromString: widget.valuecolor)) - .lineLimit(1) } } } diff --git a/openHAB/SwiftUI/Rows/ImageRowView.swift b/openHAB/SwiftUI/Rows/ImageRowView.swift index 1d43c89d3..70c6e3cb2 100644 --- a/openHAB/SwiftUI/Rows/ImageRowView.swift +++ b/openHAB/SwiftUI/Rows/ImageRowView.swift @@ -43,8 +43,8 @@ struct ImageRowView: View { if !displayState.labelText.isEmpty, widget.labelSource == .sitemapDefinition { let labelText = displayState.labelText Text(labelText) + .ohTextToken(.rowLabel) .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) - .lineLimit(1) } switch widget.generateImageResult(rootUrl: viewModel.openHABRootUrl ?? "", chartStyle: chartStyle) { case let .embedded(data: data): @@ -78,9 +78,8 @@ struct ImageRowView: View { // Only show labelValue for image widgets, not charts if widget.type == .image, let labelValue = displayState.labelValue, !labelValue.isEmpty { Text(labelValue) - .font(.caption) + .ohTextToken(.rowValueCompact) .foregroundStyle(widget.valuecolor.isEmpty ? .secondary : Color(fromString: widget.valuecolor)) - .lineLimit(1) } } .onAppear { diff --git a/openHAB/SwiftUI/Rows/MapRowView.swift b/openHAB/SwiftUI/Rows/MapRowView.swift index 4fef40e70..7dd0e0f5b 100644 --- a/openHAB/SwiftUI/Rows/MapRowView.swift +++ b/openHAB/SwiftUI/Rows/MapRowView.swift @@ -33,8 +33,8 @@ struct MapRowViewLegacy: View { if !displayState.labelText.isEmpty, widget.labelSource == .sitemapDefinition { let labelText = displayState.labelText Text(labelText) + .ohTextToken(.rowLabel) .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) - .lineLimit(1) } Map(coordinateRegion: .constant(region), annotationItems: CLLocationCoordinate2DIsValid(widget.coordinate) ? [widget.coordinate] : []) { location in diff --git a/openHAB/SwiftUI/Rows/RollershutterRowView.swift b/openHAB/SwiftUI/Rows/RollershutterRowView.swift index debc165dd..c1c4a8b55 100644 --- a/openHAB/SwiftUI/Rows/RollershutterRowView.swift +++ b/openHAB/SwiftUI/Rows/RollershutterRowView.swift @@ -41,8 +41,8 @@ struct RollershutterRowView: View { if !displayState.labelText.isEmpty { let labelText = displayState.labelText Text(labelText) + .ohTextToken(.rowLabel) .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) - .lineLimit(1) } } diff --git a/openHAB/SwiftUI/Rows/SegmentedRowView.swift b/openHAB/SwiftUI/Rows/SegmentedRowView.swift index eef1e0853..eaef4cf23 100644 --- a/openHAB/SwiftUI/Rows/SegmentedRowView.swift +++ b/openHAB/SwiftUI/Rows/SegmentedRowView.swift @@ -40,9 +40,8 @@ struct SegmentedRowView: View { if !displayState.labelText.isEmpty { let labelText = displayState.labelText Text(labelText) + .ohTextToken(.rowLabel) .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) - .lineLimit(1) - .truncationMode(.tail) .padding(.leading, 8) .layoutPriority(1) } @@ -50,9 +49,8 @@ struct SegmentedRowView: View { if let detailTextLabel = displayState.labelValue, !detailTextLabel.isEmpty { Spacer(minLength: 8) Text(detailTextLabel) + .ohTextToken(.rowValue) .foregroundStyle(widget.valuecolor.isEmpty ? Color(uiColor: UIColor.ohSecondaryLabel) : Color(fromString: widget.valuecolor)) - .lineLimit(1) - .truncationMode(.tail) .layoutPriority(1) } @@ -146,10 +144,8 @@ struct SegmentedRowView: View { let isSelected = isSingleMappingSelected(displayState: displayState, mappings: mappings) Text(mapping.label) - .font(.footnote) + .ohTextToken(.control) .bold() - .lineLimit(1) - .truncationMode(.tail) .padding(.horizontal, 8) .padding(.vertical, 5) .frame(minWidth: 50) @@ -206,10 +202,8 @@ struct SegmentedRowView: View { viewModel.sendCommand(mapping.command, for: widget) } label: { Text(mapping.label) - .font(.footnote) + .ohTextToken(.control) .bold() - .lineLimit(1) - .truncationMode(.tail) .padding(.vertical, 5) .frame(minWidth: 30, maxWidth: 120) .foregroundStyle(.primary) @@ -221,10 +215,8 @@ struct SegmentedRowView: View { private func pressReleaseButton(for mapping: OpenHABWidgetMapping, at index: Int) -> some View { let isPressed = pressedIndex == index Text(mapping.label) - .font(.footnote) + .ohTextToken(.control) .bold() - .lineLimit(1) - .truncationMode(.tail) .padding(.horizontal, 8) .padding(.vertical, 5) .frame(minWidth: 50) diff --git a/openHAB/SwiftUI/Rows/SelectionRowView.swift b/openHAB/SwiftUI/Rows/SelectionRowView.swift index 4d9dfac9e..285bca913 100644 --- a/openHAB/SwiftUI/Rows/SelectionRowView.swift +++ b/openHAB/SwiftUI/Rows/SelectionRowView.swift @@ -66,21 +66,21 @@ struct SelectionRowView: View { if !displayState.labelText.isEmpty { let labelText = displayState.labelText Text(labelText) + .ohTextToken(.rowLabel) .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) - .lineLimit(1) } Spacer() if let valueText = selectedValueText(displayState: displayState), !valueText.isEmpty { Text(valueText) + .ohTextToken(.rowValue) .foregroundStyle(widget.valuecolor.isEmpty ? .secondary : Color(fromString: widget.valuecolor)) - .lineLimit(1) } // Show disclosure indicator to indicate tappable selection Image(systemSymbol: .chevronUpChevronDown) - .font(.caption) + .ohTextToken(.secondary) .foregroundStyle(.secondary) } .contentShape(Rectangle()) diff --git a/openHAB/SwiftUI/Rows/SetpointRowView.swift b/openHAB/SwiftUI/Rows/SetpointRowView.swift index 812293455..9da1a738c 100644 --- a/openHAB/SwiftUI/Rows/SetpointRowView.swift +++ b/openHAB/SwiftUI/Rows/SetpointRowView.swift @@ -33,8 +33,8 @@ struct SetpointRowView: View { if !displayState.labelText.isEmpty { let labelText = displayState.labelText Text(labelText) + .ohTextToken(.rowLabel) .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) - .lineLimit(1) } Spacer() @@ -54,7 +54,8 @@ struct SetpointRowView: View { .disabled(widget.readOnly ?? false) Text(formattedValue(displayState: displayState)) - .font(.body.monospacedDigit()) + .ohTextToken(.rowValue) + .monospacedDigit() .foregroundStyle(widget.valuecolor.isEmpty ? .secondary : Color(fromString: widget.valuecolor)) Button { @@ -99,7 +100,11 @@ struct SetpointRowView: View { } // Use widget's unit as fallback when creating NumberState - numberState = numberState ?? NumberState(value: limitedNewValue, unit: widget.unit) + numberState = numberState ?? NumberState( + value: limitedNewValue, + unit: widget.unit, + format: widget.item?.stateDescription?.numberPattern + ) numberState?.value = limitedNewValue logger.info("Setpoint \(isDecreasing ? "decreased" : "increased") to \(numberState?.description ?? String(limitedNewValue))") @@ -111,6 +116,9 @@ struct SetpointRowView: View { } private func formattedValue(displayState: WidgetDisplayState) -> String { + if let numberState = widget.stateValueAsNumberState { + return numberState.toString(locale: Locale.current) + } let text = currentValue(displayState: displayState).valueText(step: displayState.step) if let unit = widget.unit, !unit.isEmpty { return "\(text) \(unit)" diff --git a/openHAB/SwiftUI/Rows/SliderRowView.swift b/openHAB/SwiftUI/Rows/SliderRowView.swift index a82ff6a9f..49dec7e4e 100644 --- a/openHAB/SwiftUI/Rows/SliderRowView.swift +++ b/openHAB/SwiftUI/Rows/SliderRowView.swift @@ -91,18 +91,16 @@ struct SliderRowView: View { if !displayState.labelText.isEmpty { let labelText = displayState.labelText Text(labelText) + .ohTextToken(.rowLabel) .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) - .lineLimit(1) - .truncationMode(.tail) } Spacer() // Show current slider value (pendingValue while dragging, otherwise widget value) Text(pendingValue != nil ? currentValueText : (displayState.labelValue ?? currentValueText)) - .font(.callout) + .ohTextToken(.rowValueCallout) .foregroundStyle(widget.valuecolor.isEmpty ? Color(uiColor: UIColor.ohSecondaryLabel) : Color(fromString: widget.valuecolor)) - .lineLimit(1) } .contentShape(Rectangle()) } @@ -111,7 +109,11 @@ struct SliderRowView: View { policy: WidgetCommandPolicy, key: String?) { var numberState = widget.stateValueAsNumberState - numberState = numberState ?? NumberState(value: newValue) + numberState = numberState ?? NumberState( + value: newValue, + unit: widget.unit, + format: widget.item?.stateDescription?.numberPattern + ) numberState?.value = newValue viewModel.sendToUpdate(item: widget.item, state: numberState, policy: policy, key: key) } @@ -126,6 +128,16 @@ struct SliderRowView: View { private func currentValueText(displayState: WidgetDisplayState) -> String { let currentValue = currentValue(displayState: displayState) + if let numberPattern = widget.item?.stateDescription?.numberPattern, !numberPattern.isEmpty { + let formatted = NumberState( + value: currentValue, + unit: widget.unit, + format: numberPattern + ).toString(locale: Locale.current) + if !formatted.isEmpty { + return formatted + } + } return currentValue.valueText(step: widget.step) } diff --git a/openHAB/SwiftUI/Rows/SwitchRowView.swift b/openHAB/SwiftUI/Rows/SwitchRowView.swift index a38f3f474..db3650d41 100644 --- a/openHAB/SwiftUI/Rows/SwitchRowView.swift +++ b/openHAB/SwiftUI/Rows/SwitchRowView.swift @@ -30,17 +30,16 @@ struct SwitchRowView: View { if !displayState.labelText.isEmpty { let labelText = displayState.labelText Text(labelText) + .ohTextToken(.rowLabel) .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) - .lineLimit(1) } Spacer() if let labelValue = displayState.labelValue, !labelValue.isEmpty { Text(labelValue) - .font(.caption) + .ohTextToken(.rowValueCompact) .foregroundStyle(widget.valuecolor.isEmpty ? .secondary : Color(fromString: widget.valuecolor)) - .lineLimit(1) } Toggle("", isOn: Binding( diff --git a/openHAB/SwiftUI/Rows/TextInputRowView.swift b/openHAB/SwiftUI/Rows/TextInputRowView.swift index 01fe78260..97e95c1b6 100644 --- a/openHAB/SwiftUI/Rows/TextInputRowView.swift +++ b/openHAB/SwiftUI/Rows/TextInputRowView.swift @@ -31,8 +31,8 @@ struct TextInputRowView: View { if !displayState.labelText.isEmpty { let labelText = displayState.labelText Text(labelText) + .ohTextToken(.rowLabel) .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) - .lineLimit(1) } Spacer() diff --git a/openHAB/SwiftUI/Rows/TextRowView.swift b/openHAB/SwiftUI/Rows/TextRowView.swift index 7cae01266..953781fe2 100644 --- a/openHAB/SwiftUI/Rows/TextRowView.swift +++ b/openHAB/SwiftUI/Rows/TextRowView.swift @@ -25,16 +25,15 @@ struct TextRowView: View { .frame(width: 32, height: 32) Text(displayState.labelText) + .ohTextToken(.rowLabel) .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) - .lineLimit(1) Spacer() if let value = displayState.labelValue { Text(value) - .font(.body) + .ohTextToken(.rowValue) .foregroundStyle(widget.valuecolor.isEmpty ? .secondary : Color(fromString: widget.valuecolor)) - .lineLimit(1) } } .contextMenu { diff --git a/openHAB/SwiftUI/Rows/VideoRowView.swift b/openHAB/SwiftUI/Rows/VideoRowView.swift index f7fd40884..72e61bf0a 100644 --- a/openHAB/SwiftUI/Rows/VideoRowView.swift +++ b/openHAB/SwiftUI/Rows/VideoRowView.swift @@ -45,8 +45,8 @@ struct VideoRowView: View { if !displayState.labelText.isEmpty, widget.labelSource == .sitemapDefinition { let labelText = displayState.labelText Text(labelText) + .ohTextToken(.rowLabel) .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) - .lineLimit(1) } if let videoURL { @@ -57,11 +57,13 @@ struct VideoRowView: View { Image(uiImage: mjpegImage) .resizable() .aspectRatio(aspectRatio, contentMode: .fit) + .frame(maxWidth: .infinity) .frame(height: 200) .clipShape(.rect(cornerRadius: 8)) } else { Rectangle() .fill(Color.gray.opacity(0.3)) + .frame(maxWidth: .infinity) .frame(height: 200) .aspectRatio(aspectRatio, contentMode: .fit) .clipShape(.rect(cornerRadius: 8)) @@ -69,6 +71,7 @@ struct VideoRowView: View { } else { // HLS/other video formats using VideoPlayer VideoPlayer(player: player) + .frame(maxWidth: .infinity) .frame(height: 200) .aspectRatio(aspectRatio, contentMode: .fit) .clipShape(.rect(cornerRadius: 8)) @@ -80,6 +83,7 @@ struct VideoRowView: View { .progressViewStyle(CircularProgressViewStyle()) } } + .frame(maxWidth: .infinity) .onAppear { setupVideo(url: videoURL) } @@ -96,6 +100,7 @@ struct VideoRowView: View { } else { Rectangle() .fill(Color.gray.opacity(0.3)) + .frame(maxWidth: .infinity) .frame(height: 200) .overlay( Text("No Video URL") @@ -106,9 +111,8 @@ struct VideoRowView: View { if let labelValue = displayState.labelValue, !labelValue.isEmpty { Text(labelValue) - .font(.caption) + .ohTextToken(.rowValueCompact) .foregroundStyle(widget.valuecolor.isEmpty ? .secondary : Color(fromString: widget.valuecolor)) - .lineLimit(1) } } } diff --git a/openHAB/SwiftUI/Rows/WebRowView.swift b/openHAB/SwiftUI/Rows/WebRowView.swift index 9d95e2084..0380dd099 100644 --- a/openHAB/SwiftUI/Rows/WebRowView.swift +++ b/openHAB/SwiftUI/Rows/WebRowView.swift @@ -24,8 +24,8 @@ struct WidgetWebViewContainer: View { if !displayState.labelText.isEmpty, widget.labelSource == .sitemapDefinition { let labelText = displayState.labelText Text(labelText) + .ohTextToken(.rowLabel) .foregroundStyle(widget.labelcolor.isEmpty ? .primary : Color(fromString: widget.labelcolor)) - .lineLimit(1) } WebRowView(widget: widget) @@ -34,9 +34,8 @@ struct WidgetWebViewContainer: View { if let labelValue = displayState.labelValue, !labelValue.isEmpty { Text(labelValue) - .font(.caption) + .ohTextToken(.rowValueCompact) .foregroundStyle(widget.valuecolor.isEmpty ? .secondary : Color(fromString: widget.valuecolor)) - .lineLimit(1) } } } diff --git a/openHABWatch/Views/Rows/SetpointRow.swift b/openHABWatch/Views/Rows/SetpointRow.swift index 28f987093..e08e4d02c 100644 --- a/openHABWatch/Views/Rows/SetpointRow.swift +++ b/openHABWatch/Views/Rows/SetpointRow.swift @@ -30,11 +30,10 @@ struct SetpointRow: View { } private var valueText: String { - let value = currentValue.valueText(step: viewModel.step) - if let unit = widget.unit, !unit.isEmpty { - return "\(value) \(unit)" - } - return value + formattedValue( + for: currentValue, + locale: Locale.current + ) } var body: some View { @@ -107,7 +106,11 @@ struct SetpointRow: View { } localValue = limitedNewValue - let numberState = NumberState(value: limitedNewValue, unit: widget.unit) + let numberState = NumberState( + value: limitedNewValue, + unit: widget.unit, + format: widget.item?.stateDescription?.numberPattern + ) logger.info("Setpoint \(isDecreasing ? "decreased" : "increased") to \(numberState.description)") commandSender.sendItemUpdate(numberState, for: widget) @@ -120,6 +123,22 @@ struct SetpointRow: View { func increaseValue() { handleUpDown(isDecreasing: false) } + + private func formattedValue(for value: Double, locale: Locale) -> String { + if let numberPattern = widget.item?.stateDescription?.numberPattern, + !numberPattern.isEmpty { + return NumberState( + value: value, + unit: widget.unit, + format: numberPattern + ).toString(locale: locale) + } + let fallback = value.valueText(step: viewModel.step) + if let unit = widget.unit, !unit.isEmpty { + return "\(fallback) \(unit)" + } + return fallback + } } #Preview { diff --git a/openHABWatch/Views/Rows/SliderRow.swift b/openHABWatch/Views/Rows/SliderRow.swift index 98bec28ca..537928450 100644 --- a/openHABWatch/Views/Rows/SliderRow.swift +++ b/openHABWatch/Views/Rows/SliderRow.swift @@ -29,7 +29,7 @@ struct SliderRow: View { } private var currentValueText: String { - currentValue.valueText(step: viewModel.step) + formattedValue(for: currentValue, locale: Locale.current) } var valueBinding: Binding { @@ -41,7 +41,7 @@ struct SliderRow: View { Logger.rowViews.info("SliderRow new value = \(newValue)") pendingValue = newValue commandSender.send( - newValue.valueText(step: viewModel.step), + formattedValue(for: newValue, locale: Locale(identifier: "US")), for: widget, policy: WidgetCommandDefaults.slider, key: "slider-value" @@ -58,14 +58,14 @@ struct SliderRow: View { set: { newValue in if newValue { commandSender.send( - viewModel.maxValue.valueText(step: viewModel.step), + formattedValue(for: viewModel.maxValue, locale: Locale(identifier: "US")), for: widget, policy: .immediate, key: "slider-toggle" ) } else { commandSender.send( - viewModel.minValue.valueText(step: viewModel.step), + formattedValue(for: viewModel.minValue, locale: Locale(identifier: "US")), for: widget, policy: .immediate, key: "slider-toggle" @@ -128,6 +128,18 @@ struct SliderRow: View { self.fallbackSymbol = fallbackSymbol _viewModel = State(wrappedValue: WidgetRowViewModel(widget: widget)) } + + private func formattedValue(for value: Double, locale: Locale) -> String { + if let numberPattern = widget.item?.stateDescription?.numberPattern, + !numberPattern.isEmpty { + return NumberState( + value: value, + unit: widget.unit, + format: numberPattern + ).toString(locale: locale) + } + return value.valueText(step: viewModel.step) + } } // MARK: - Previews From 5bfb2395512091e135aef2dc1e4c33979ff641b9 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Sat, 14 Feb 2026 21:24:30 +0100 Subject: [PATCH 462/476] Cancel long-polling on view disappear to prevent duplicate tasks Stop page handling when SitemapPageView disappears so old polling tasks don't continue running alongside new ones when navigating between sitemaps. This also eliminates redundant getSitemaps calls caused by multiple view model instances polling simultaneously. Signed-off-by: Tim Mueller-Seydlitz --- openHAB/SitemapPageViewModel.swift | 31 +++++++++++++++++++++------ openHAB/SwiftUI/SitemapPageView.swift | 1 + 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/openHAB/SitemapPageViewModel.swift b/openHAB/SitemapPageViewModel.swift index b7a612985..c7216be99 100644 --- a/openHAB/SitemapPageViewModel.swift +++ b/openHAB/SitemapPageViewModel.swift @@ -92,10 +92,12 @@ class SitemapPageViewModel: ObservableObject { init() { loadSettings() // Observe connection changes (skip initial value) — initial load is triggered by .task in the view - connectionObserverTask = Task { @MainActor [weak self] in - guard let self else { return } - for await connection in networkTracker.$activeConnection.values.dropFirst() { - handleActiveConnectionChange(connection) + connectionObserverTask = Task { [weak self] in + guard let tracker = self?.networkTracker else { return } + for await connection in tracker.$activeConnection.values.dropFirst() { + await MainActor.run { [weak self] in + self?.handleActiveConnectionChange(connection) + } } } } @@ -142,6 +144,11 @@ class SitemapPageViewModel: ObservableObject { showSearchField = Preferences.shared.applicationPreferences.showSearchField } + func stopPageHandling() { + pageHandlingTask?.cancel() + pageHandlingTask = nil + } + func startPageHandling() { pageHandlingTask?.cancel() error = nil // Clear any previous errors when starting a new page handling session @@ -166,6 +173,7 @@ class SitemapPageViewModel: ObservableObject { isLoading = false return } + activeConnectionInfo = activeConnection let configuration = activeConnection.configuration openHABRootUrl = configuration.url @@ -483,15 +491,24 @@ class SitemapPageViewModel: ObservableObject { } private func handleActiveConnection(_ connection: ConnectionInfo) async { + let previousURL = activeConnectionInfo?.configuration.url + let newURL = connection.configuration.url + let connectionDidChange = previousURL != newURL + // Save the active connection information activeConnectionInfo = connection - openHABRootUrl = connection.configuration.url + openHABRootUrl = newURL do { // Setup the OpenAPI service based on the new connection openAPIService = try OpenAPIService(connectionConfiguration: connection.configuration) - // Start page handling which includes initial load and long polling - startPageHandling() + // Restart when connection changed, or when polling is currently inactive. + let shouldRestart = connectionDidChange + || pageHandlingTask == nil + || pageHandlingTask?.isCancelled == true + if shouldRestart { + startPageHandling() + } } catch { self.error = error as? any LocalizedError } diff --git a/openHAB/SwiftUI/SitemapPageView.swift b/openHAB/SwiftUI/SitemapPageView.swift index 1e2f5a409..5419c2759 100644 --- a/openHAB/SwiftUI/SitemapPageView.swift +++ b/openHAB/SwiftUI/SitemapPageView.swift @@ -56,6 +56,7 @@ struct SitemapPageView: View { } } .onDisappear { + viewModel.stopPageHandling() // Re-enable idle timer when leaving the view if idleTimerDisabled { UIApplication.shared.isIdleTimerDisabled = false From dd1760f18fcd2900dc3fb603d242a5d6f1433c6f Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Sat, 14 Feb 2026 21:47:20 +0100 Subject: [PATCH 463/476] Add deduplication and staleness guards to page handling Introduce a key-based deduplication check (sitemap+pageId) to skip redundant startPageHandling() calls, and a UUID-based run ID to discard results from superseded polling tasks. Adds structured logging with reason/runID/key for easier debugging. Refines connection change handling to avoid unnecessary polling restarts when the URL hasn't changed. Signed-off-by: Tim Mueller-Seydlitz --- openHAB/SitemapPageViewModel.swift | 57 ++++++++++++++++++++++-------- 1 file changed, 42 insertions(+), 15 deletions(-) diff --git a/openHAB/SitemapPageViewModel.swift b/openHAB/SitemapPageViewModel.swift index c7216be99..7c9925b58 100644 --- a/openHAB/SitemapPageViewModel.swift +++ b/openHAB/SitemapPageViewModel.swift @@ -58,6 +58,8 @@ class SitemapPageViewModel: ObservableObject { private var isLinkedPage = false private var pageNetworkStatus: NetworkStatus? private var pageNetworkStatusAvailable = false + private var activePageHandlingKey: String? + private var activePageHandlingID: UUID? /// Cache of current widget objects by widgetId for in-place updates private var currentWidgetMap: [String: OpenHABWidget] = [:] @@ -147,16 +149,38 @@ class SitemapPageViewModel: ObservableObject { func stopPageHandling() { pageHandlingTask?.cancel() pageHandlingTask = nil + activePageHandlingKey = nil + activePageHandlingID = nil } - func startPageHandling() { + func startPageHandling(forceRestart: Bool = false, reason: String = "manual") { + let requestedKey = "\(defaultSitemap)|\(pageId)" + if !forceRestart, + let activeTask = pageHandlingTask, + !activeTask.isCancelled, + activePageHandlingKey == requestedKey { + logger.info("Skipping duplicate page handling start for \(requestedKey, privacy: .public), reason: \(reason, privacy: .public)") + return + } + pageHandlingTask?.cancel() error = nil // Clear any previous errors when starting a new page handling session isLoading = true // Show redacted view immediately - logger.info("🚀 Starting page load and long polling flow...") + let runID = UUID() + activePageHandlingID = runID + activePageHandlingKey = requestedKey + + logger.info("🚀 Starting page load and long polling flow (reason: \(reason, privacy: .public), run: \(runID.uuidString, privacy: .public), key: \(requestedKey, privacy: .public))") pageHandlingTask = Task { + defer { + if activePageHandlingID == runID { + pageHandlingTask = nil + activePageHandlingID = nil + } + } + // If no default sitemap is set, try to discover and auto-select one if defaultSitemap.isEmpty { await discoverAndSelectSitemap() @@ -194,6 +218,10 @@ class SitemapPageViewModel: ObservableObject { ) try Task.checkCancellation() + guard activePageHandlingID == runID else { + logger.info("Ignoring stale initial page result for run \(runID.uuidString, privacy: .public)") + return + } if let page = initialPage { updateUI(with: page) @@ -208,6 +236,10 @@ class SitemapPageViewModel: ObservableObject { longPolling: true ) try Task.checkCancellation() + guard activePageHandlingID == runID else { + logger.info("Ignoring stale long-poll result for run \(runID.uuidString, privacy: .public)") + return + } if let page { updateUI(with: page) @@ -265,6 +297,7 @@ class SitemapPageViewModel: ObservableObject { isLoading = false isUpdating = false } + } } @@ -371,7 +404,7 @@ class SitemapPageViewModel: ObservableObject { defaultSitemapLabel = "" // Clear old label so it gets fetched for the new sitemap pageId = path ?? "" error = nil // Clear any previous errors when switching sitemaps - startPageHandling() + startPageHandling(forceRestart: true, reason: "push-sitemap") } private func fetchSitemapLabel() async { @@ -449,18 +482,12 @@ class SitemapPageViewModel: ObservableObject { logger.info("SitemapPageViewModel tracker URL \(activeConnection.configuration.url)") - // Check if network status changed - if pageNetworkStatusChanged() { - logger.info("Network status changed, restarting page handling") - pageHandlingTask?.cancel() - // Restart page handling to establish long-polling - startPageHandling() - return - } - // Skip if already connected to this URL — avoids restarting long-polling // when the NetworkTracker re-evaluates to the same connection - guard openHABRootUrl != activeConnection.configuration.url else { + let connectionDidChange = openHABRootUrl != activeConnection.configuration.url + let hasRunningPageTask = pageHandlingTask != nil && pageHandlingTask?.isCancelled == false + let networkStatusDidChange = pageNetworkStatusChanged() + guard connectionDidChange || (networkStatusDidChange && !hasRunningPageTask) else { return } @@ -507,7 +534,7 @@ class SitemapPageViewModel: ObservableObject { || pageHandlingTask == nil || pageHandlingTask?.isCancelled == true if shouldRestart { - startPageHandling() + startPageHandling(forceRestart: true, reason: connectionDidChange ? "connection-changed" : "connection-recovered") } } catch { self.error = error as? any LocalizedError @@ -515,7 +542,7 @@ class SitemapPageViewModel: ObservableObject { } func selectSitemap() async { - startPageHandling() + startPageHandling(forceRestart: true, reason: "select-sitemap") } // MARK: - Command Sending From e72fb01896bced0e62a3f4ca3740120fc6fe1bd1 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Sat, 14 Feb 2026 22:11:56 +0100 Subject: [PATCH 464/476] Add command lifecycle tracking and refactor page handling Introduce WidgetCommandLifecycleState to track sending/failed states for commands, with versioned state management and auto-reset. Add command lifecycle indicator in the navigation toolbar. Extend WidgetCommandPolicy with finalOnly and pressRelease modes, and add WidgetCommandPhase to support press/change/release semantics in ButtonGrid, Segmented, and Slider rows. Refactor startPageHandling() into smaller focused methods for readability. Signed-off-by: Tim Mueller-Seydlitz --- .../Util/WidgetCommandDispatcher.swift | 22 ++ .../Util/WidgetCommandLifecycleState.swift | 16 + .../Util/WidgetCommandPolicy.swift | 12 + openHAB/SitemapPageViewModel.swift | 330 ++++++++++++------ openHAB/SwiftUI/Rows/ButtonGridRowView.swift | 10 +- openHAB/SwiftUI/Rows/SegmentedRowView.swift | 14 +- openHAB/SwiftUI/Rows/SliderRowView.swift | 10 +- openHAB/SwiftUI/SitemapNavigationView.swift | 23 ++ 8 files changed, 320 insertions(+), 117 deletions(-) create mode 100644 OpenHABCore/Sources/OpenHABCore/Util/WidgetCommandLifecycleState.swift diff --git a/OpenHABCore/Sources/OpenHABCore/Util/WidgetCommandDispatcher.swift b/OpenHABCore/Sources/OpenHABCore/Util/WidgetCommandDispatcher.swift index 6eede6648..6c673948b 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/WidgetCommandDispatcher.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/WidgetCommandDispatcher.swift @@ -21,6 +21,7 @@ public final class WidgetCommandDispatcher { public func send(_ command: String?, for widget: OpenHABWidget, policy: WidgetCommandPolicy, + phase: WidgetCommandPhase = .change, key: String? = nil, fallbackItem: OpenHABItem? = nil) { guard let command, !command.isEmpty else { return } @@ -29,6 +30,10 @@ public final class WidgetCommandDispatcher { case .immediate: dispatch(command: command, for: widget, fallbackItem: fallbackItem) case let .debounce(duration): + guard phase != .release else { + dispatch(command: command, for: widget, fallbackItem: fallbackItem) + return + } sendDebounced( command, for: widget, @@ -36,6 +41,12 @@ public final class WidgetCommandDispatcher { key: key, fallbackItem: fallbackItem ) + case .finalOnly: + guard phase == .release else { return } + dispatch(command: command, for: widget, fallbackItem: fallbackItem) + case .pressRelease: + guard phase == .press || phase == .release else { return } + dispatch(command: command, for: widget, fallbackItem: fallbackItem) } } @@ -43,6 +54,7 @@ public final class WidgetCommandDispatcher { public func send(_ command: String?, for item: OpenHABItem?, policy: WidgetCommandPolicy, + phase: WidgetCommandPhase = .change, key: String? = nil, execute: @escaping @MainActor (_ itemname: String, _ command: String) -> Void) { guard let command, !command.isEmpty, let item else { return } @@ -51,6 +63,10 @@ public final class WidgetCommandDispatcher { case .immediate: execute(item.name, command) case let .debounce(duration): + guard phase != .release else { + execute(item.name, command) + return + } sendDebounced( command, for: item, @@ -58,6 +74,12 @@ public final class WidgetCommandDispatcher { key: key, execute: execute ) + case .finalOnly: + guard phase == .release else { return } + execute(item.name, command) + case .pressRelease: + guard phase == .press || phase == .release else { return } + execute(item.name, command) } } diff --git a/OpenHABCore/Sources/OpenHABCore/Util/WidgetCommandLifecycleState.swift b/OpenHABCore/Sources/OpenHABCore/Util/WidgetCommandLifecycleState.swift new file mode 100644 index 000000000..8bd903570 --- /dev/null +++ b/OpenHABCore/Sources/OpenHABCore/Util/WidgetCommandLifecycleState.swift @@ -0,0 +1,16 @@ +// Copyright (c) 2010-2026 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 + +public enum WidgetCommandLifecycleState: Sendable, Equatable { + case idle + case sending + case failed(message: String?) +} diff --git a/OpenHABCore/Sources/OpenHABCore/Util/WidgetCommandPolicy.swift b/OpenHABCore/Sources/OpenHABCore/Util/WidgetCommandPolicy.swift index 647f4f746..57f077cdd 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/WidgetCommandPolicy.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/WidgetCommandPolicy.swift @@ -15,6 +15,8 @@ public enum WidgetCommandDefaults { public static let slider: WidgetCommandPolicy = .debounce(.milliseconds(500)) public static let colorPicker: WidgetCommandPolicy = .debounce(.milliseconds(200)) public static let immediate: WidgetCommandPolicy = .immediate + public static let finalOnly: WidgetCommandPolicy = .finalOnly + public static let pressRelease: WidgetCommandPolicy = .pressRelease public static func policy(for widget: OpenHABWidget) -> WidgetCommandPolicy { switch widget.type { @@ -28,7 +30,17 @@ public enum WidgetCommandDefaults { } } +public enum WidgetCommandPhase: Sendable { + case press + case change + case release +} + public enum WidgetCommandPolicy: Sendable { case immediate case debounce(Duration) + /// Dispatches only when phase is `.release`. + case finalOnly + /// Dispatches only for `.press` and `.release` phases. + case pressRelease } diff --git a/openHAB/SitemapPageViewModel.swift b/openHAB/SitemapPageViewModel.swift index 7c9925b58..a69e672ed 100644 --- a/openHAB/SitemapPageViewModel.swift +++ b/openHAB/SitemapPageViewModel.swift @@ -35,6 +35,12 @@ enum SitemapPageError: LocalizedError { } } +enum CommandLifecycleSummary: Equatable { + case idle + case sending(count: Int) + case failed(count: Int) +} + @MainActor class SitemapPageViewModel: ObservableObject { @Published var currentPage: OpenHABPage? @@ -44,6 +50,7 @@ class SitemapPageViewModel: ObservableObject { @Published var isUpdating = false @Published var openHABRootUrl: String? @Published var showSearchField = false + @Published private(set) var commandStates: [String: WidgetCommandLifecycleState] = [:] let networkTracker = MainActorNetworkTracker.shared private var openAPIService: OpenAPIService? @@ -60,6 +67,8 @@ class SitemapPageViewModel: ObservableObject { private var pageNetworkStatusAvailable = false private var activePageHandlingKey: String? private var activePageHandlingID: UUID? + private var commandStateResetTasks: [String: Task] = [:] + private var commandStateVersions: [String: Int] = [:] /// Cache of current widget objects by widgetId for in-place updates private var currentWidgetMap: [String: OpenHABWidget] = [:] @@ -91,6 +100,27 @@ class SitemapPageViewModel: ObservableObject { isLinkedPage } + var commandLifecycleSummary: CommandLifecycleSummary { + let failedCount = commandStates.values.reduce(into: 0) { result, state in + if case .failed = state { + result += 1 + } + } + if failedCount > 0 { + return .failed(count: failedCount) + } + + let sendingCount = commandStates.values.reduce(into: 0) { result, state in + if case .sending = state { + result += 1 + } + } + if sendingCount > 0 { + return .sending(count: sendingCount) + } + return .idle + } + init() { loadSettings() // Observe connection changes (skip initial value) — initial load is triggered by .task in the view @@ -141,6 +171,16 @@ class SitemapPageViewModel: ObservableObject { ) } + deinit { + connectionObserverTask?.cancel() + pageHandlingTask?.cancel() + commandStateResetTasks.values.forEach { $0.cancel() } + commandStateResetTasks.removeAll() + } +} + +@MainActor +extension SitemapPageViewModel { func loadSettings() { defaultSitemap = Preferences.shared.currentHomePreferences.defaultSitemap showSearchField = Preferences.shared.applicationPreferences.showSearchField @@ -181,123 +221,88 @@ class SitemapPageViewModel: ObservableObject { } } - // If no default sitemap is set, try to discover and auto-select one - if defaultSitemap.isEmpty { - await discoverAndSelectSitemap() - } - - guard !defaultSitemap.isEmpty else { - logger.error("startPageHandling: Cannot run with empty sitemap after discovery") - isLoading = false - return - } do { - guard let activeConnection = await NetworkTracker.shared.waitForActiveConnection() else { - logger.error("Failed to establish connection within timeout") - isLoading = false - return - } - activeConnectionInfo = activeConnection - let configuration = activeConnection.configuration - openHABRootUrl = configuration.url + guard await ensureSitemapAvailableForHandling() else { return } + guard let activeConnection = await waitForConnectionForHandling() else { return } - if openAPIService == nil { - openAPIService = try OpenAPIService(connectionConfiguration: configuration) - } + try setupServiceIfNeeded(activeConnection: activeConnection) - // Fetch sitemap label if we loaded from preferences (not from discovery) if defaultSitemapLabel.isEmpty { await fetchSitemapLabel() } - // 1. Initial page load (longPolling: false) - let initialPage = try await openAPIService?.pollDataForPage( - sitemapname: defaultSitemap, - pageId: pageId, - longPolling: false - ) + try await loadInitialPageForHandling(runID: runID) + isLoading = false + try await runLongPollingLoop(runID: runID) + } catch { + handlePageHandlingError(error) + } + } + } - try Task.checkCancellation() - guard activePageHandlingID == runID else { - logger.info("Ignoring stale initial page result for run \(runID.uuidString, privacy: .public)") - return - } + private func ensureSitemapAvailableForHandling() async -> Bool { + if defaultSitemap.isEmpty { + await discoverAndSelectSitemap() + } + guard !defaultSitemap.isEmpty else { + logger.error("startPageHandling: Cannot run with empty sitemap after discovery") + isLoading = false + return false + } + return true + } - if let page = initialPage { - updateUI(with: page) - } - isLoading = false + private func waitForConnectionForHandling() async -> ConnectionInfo? { + guard let activeConnection = await NetworkTracker.shared.waitForActiveConnection() else { + logger.error("Failed to establish connection within timeout") + isLoading = false + return nil + } + activeConnectionInfo = activeConnection + openHABRootUrl = activeConnection.configuration.url + return activeConnection + } - // 2. Start long polling loop - while !Task.isCancelled { - let page = try await openAPIService?.pollDataForPage( - sitemapname: defaultSitemap, - pageId: pageId, - longPolling: true - ) - try Task.checkCancellation() - guard activePageHandlingID == runID else { - logger.info("Ignoring stale long-poll result for run \(runID.uuidString, privacy: .public)") - return - } - - if let page { - updateUI(with: page) - } - } + private func setupServiceIfNeeded(activeConnection: ConnectionInfo) throws { + if openAPIService == nil { + openAPIService = try OpenAPIService(connectionConfiguration: activeConnection.configuration) + } + } - } catch is CancellationError { - logger.info("🔁 pageHandlingTask was cancelled") - isLoading = false - isUpdating = false - } catch let error as DecodingError { - // Don't set error if task was cancelled - guard !Task.isCancelled else { - logger.info("Task cancelled, ignoring DecodingError") - isLoading = false - isUpdating = false - return - } - logger.error("Decoding error: \(error.localizedDescription)") - self.error = SitemapPageError.serviceUnavailable - isLoading = false - isUpdating = false - } catch let error as ClientError { - if let urlError = error.underlyingError as? URLError, urlError.code == .cancelled { - logger.info("Task cancelled (URLError: cancelled)") - } else if let urlError = error.underlyingError as? URLError, urlError.code == .timedOut { - logger.info("Task timed out (URLError: timedOut)") - } else { - // Don't set error if task was cancelled - guard !Task.isCancelled else { - logger.info("Task cancelled, ignoring ClientError") - isLoading = false - isUpdating = false - return - } - logger.error("ClientError: \(error.localizedDescription)") - self.error = SitemapPageError.serviceUnavailable - } - isLoading = false - isUpdating = false - } catch let openAPIError as OpenAPIServiceError { - logger.error("OpenAPIServiceError: \(openAPIError.localizedDescription)") - isLoading = false - isUpdating = false - } catch { - // Don't set error if task was cancelled - guard !Task.isCancelled else { - logger.info("Task cancelled, ignoring error") - isLoading = false - isUpdating = false - return - } - logger.error("❌ Unhandled pageHandlingTask error: \(error.localizedDescription)") - self.error = SitemapPageError.serviceUnavailable - isLoading = false - isUpdating = false + private func loadInitialPageForHandling(runID: UUID) async throws { + let initialPage = try await openAPIService?.pollDataForPage( + sitemapname: defaultSitemap, + pageId: pageId, + longPolling: false + ) + + try Task.checkCancellation() + guard activePageHandlingID == runID else { + logger.info("Ignoring stale initial page result for run \(runID.uuidString, privacy: .public)") + return + } + + if let page = initialPage { + updateUI(with: page) + } + } + + private func runLongPollingLoop(runID: UUID) async throws { + while !Task.isCancelled { + let page = try await openAPIService?.pollDataForPage( + sitemapname: defaultSitemap, + pageId: pageId, + longPolling: true + ) + try Task.checkCancellation() + guard activePageHandlingID == runID else { + logger.info("Ignoring stale long-poll result for run \(runID.uuidString, privacy: .public)") + return } + if let page { + updateUI(with: page) + } } } @@ -550,12 +555,14 @@ class SitemapPageViewModel: ObservableObject { func sendCommand(_ command: String?, for widget: OpenHABWidget, policy: WidgetCommandPolicy = .immediate, + phase: WidgetCommandPhase = .change, key: String? = nil, fallbackItem: OpenHABItem? = nil) { commandDispatcher.send( command, for: widget, policy: policy, + phase: phase, key: key, fallbackItem: fallbackItem ) @@ -570,14 +577,17 @@ class SitemapPageViewModel: ObservableObject { } func sendCommand(_ item: OpenHABItem?, commandToSend command: String?) { - commandDispatcher.send(command, for: item, policy: .immediate) { [weak self] itemname, command in + commandDispatcher.send(command, for: item, policy: .immediate, phase: .change) { [weak self] itemname, command in self?.sendCommand(itemname: itemname, command: command) } } func sendCommand(itemname: String, command: String) { + let version = nextCommandVersion(for: itemname) + setCommandState(.sending, for: itemname) let deviceId = UIDevice.current.identifierForVendor?.uuidString - Task { + Task { [weak self] in + guard let self else { return } do { try await openAPIService?.sendItemCommand( itemname: itemname, @@ -586,8 +596,10 @@ class SitemapPageViewModel: ObservableObject { deviceId: deviceId ) logger.info("Successfully sent command \(command) to \(itemname)") + handleCommandSuccess(for: itemname, version: version) } catch { logger.info("Failed to send command\(command) to \(itemname): \(error.localizedDescription)") + handleCommandFailure(for: itemname, version: version, errorDescription: error.localizedDescription) } } } @@ -595,6 +607,7 @@ class SitemapPageViewModel: ObservableObject { func sendToUpdate(item: OpenHABItem?, state: NumberState?, policy: WidgetCommandPolicy = .immediate, + phase: WidgetCommandPhase = .change, key: String? = nil) { guard let item, let state else { logger.info("ItemUpdate for Item or State = nil") @@ -607,14 +620,111 @@ class SitemapPageViewModel: ObservableObject { // For all other items, send the plain value state.stringValue } - commandDispatcher.send(command, for: item, policy: policy, key: key) { [weak self] itemname, command in + commandDispatcher.send(command, for: item, policy: policy, phase: phase, key: key) { [weak self] itemname, command in self?.sendCommand(itemname: itemname, command: command) } } +} - deinit { - connectionObserverTask?.cancel() - pageHandlingTask?.cancel() +@MainActor +private extension SitemapPageViewModel { + func handlePageHandlingError(_ error: any Error) { + if error is CancellationError { + logger.info("🔁 pageHandlingTask was cancelled") + isLoading = false + isUpdating = false + return + } + + if let decodingError = error as? DecodingError { + guard !Task.isCancelled else { + logger.info("Task cancelled, ignoring DecodingError") + isLoading = false + isUpdating = false + return + } + logger.error("Decoding error: \(decodingError.localizedDescription)") + self.error = SitemapPageError.serviceUnavailable + isLoading = false + isUpdating = false + return + } + + if let clientError = error as? ClientError { + if let urlError = clientError.underlyingError as? URLError, urlError.code == .cancelled { + logger.info("Task cancelled (URLError: cancelled)") + } else if let urlError = clientError.underlyingError as? URLError, urlError.code == .timedOut { + logger.info("Task timed out (URLError: timedOut)") + } else { + guard !Task.isCancelled else { + logger.info("Task cancelled, ignoring ClientError") + isLoading = false + isUpdating = false + return + } + logger.error("ClientError: \(clientError.localizedDescription)") + self.error = SitemapPageError.serviceUnavailable + } + isLoading = false + isUpdating = false + return + } + + if let openAPIError = error as? OpenAPIServiceError { + logger.error("OpenAPIServiceError: \(openAPIError.localizedDescription)") + isLoading = false + isUpdating = false + return + } + + guard !Task.isCancelled else { + logger.info("Task cancelled, ignoring error") + isLoading = false + isUpdating = false + return + } + logger.error("❌ Unhandled pageHandlingTask error: \(error.localizedDescription)") + self.error = SitemapPageError.serviceUnavailable + isLoading = false + isUpdating = false + } + + func nextCommandVersion(for itemname: String) -> Int { + let newVersion = (commandStateVersions[itemname] ?? 0) + 1 + commandStateVersions[itemname] = newVersion + return newVersion + } + + func setCommandState(_ state: WidgetCommandLifecycleState, for itemname: String) { + commandStateResetTasks[itemname]?.cancel() + commandStateResetTasks[itemname] = nil + + switch state { + case .idle: + commandStates.removeValue(forKey: itemname) + case .sending, .failed: + commandStates[itemname] = state + } + } + + func handleCommandSuccess(for itemname: String, version: Int) { + guard commandStateVersions[itemname] == version else { return } + scheduleCommandStateReset(for: itemname, version: version, after: .milliseconds(450)) + } + + func handleCommandFailure(for itemname: String, version: Int, errorDescription: String) { + guard commandStateVersions[itemname] == version else { return } + setCommandState(.failed(message: errorDescription), for: itemname) + } + + func scheduleCommandStateReset(for itemname: String, version: Int, after delay: Duration) { + commandStateResetTasks[itemname]?.cancel() + commandStateResetTasks[itemname] = Task { @MainActor [weak self] in + try? await Task.sleep(for: delay) + guard let self else { return } + guard commandStateVersions[itemname] == version else { return } + setCommandState(.idle, for: itemname) + } } } diff --git a/openHAB/SwiftUI/Rows/ButtonGridRowView.swift b/openHAB/SwiftUI/Rows/ButtonGridRowView.swift index 63b15a3ea..5efaf6787 100644 --- a/openHAB/SwiftUI/Rows/ButtonGridRowView.swift +++ b/openHAB/SwiftUI/Rows/ButtonGridRowView.swift @@ -90,7 +90,7 @@ struct ButtonGridButton: View { if hasPressRelease, let command = widget.command { triggerFeedback.toggle() logger.info("Sending press command: \(command)") - sendCommand(command) + sendCommand(command, policy: .pressRelease, phase: .press) } } @@ -100,14 +100,18 @@ struct ButtonGridButton: View { // For press-release buttons, send release command on release if let releaseCommand = widget.releaseCommand, !releaseCommand.isEmpty { logger.info("Sending release command: \(releaseCommand)") - sendCommand(releaseCommand) + sendCommand(releaseCommand, policy: .pressRelease, phase: .release) } } - private func sendCommand(_ command: String) { + private func sendCommand(_ command: String, + policy: WidgetCommandPolicy = .immediate, + phase: WidgetCommandPhase = .change) { viewModel.sendCommand( command, for: widget, + policy: policy, + phase: phase, fallbackItem: parentItem ) } diff --git a/openHAB/SwiftUI/Rows/SegmentedRowView.swift b/openHAB/SwiftUI/Rows/SegmentedRowView.swift index eaef4cf23..e0e0c33b1 100644 --- a/openHAB/SwiftUI/Rows/SegmentedRowView.swift +++ b/openHAB/SwiftUI/Rows/SegmentedRowView.swift @@ -241,7 +241,12 @@ struct SegmentedRowView: View { pressedIndex = index // Send command on press logger.info("Sending press command: \(mapping.command)") - viewModel.sendCommand(mapping.command, for: widget) + viewModel.sendCommand( + mapping.command, + for: widget, + policy: .pressRelease, + phase: .press + ) } } .onEnded { _ in @@ -249,7 +254,12 @@ struct SegmentedRowView: View { // Send release command on release if let releaseCommand = mapping.releaseCommand, !releaseCommand.isEmpty { logger.info("Sending release command: \(releaseCommand)") - viewModel.sendCommand(releaseCommand, for: widget) + viewModel.sendCommand( + releaseCommand, + for: widget, + policy: .pressRelease, + phase: .release + ) } } ) diff --git a/openHAB/SwiftUI/Rows/SliderRowView.swift b/openHAB/SwiftUI/Rows/SliderRowView.swift index 49dec7e4e..d45ef18c6 100644 --- a/openHAB/SwiftUI/Rows/SliderRowView.swift +++ b/openHAB/SwiftUI/Rows/SliderRowView.swift @@ -54,7 +54,12 @@ struct SliderRowView: View { } else { viewModel.cancelPendingCommand(for: widget, key: sliderCommandKey) } - sendSliderUpdate(value, policy: .immediate, key: sliderCommandKey) + sendSliderUpdate( + value, + policy: .finalOnly, + phase: .release, + key: sliderCommandKey + ) } // Keep pendingValue set until server responds to avoid visual jump // Fallback: clear after delay if server doesn't respond @@ -107,6 +112,7 @@ struct SliderRowView: View { private func sendSliderUpdate(_ newValue: Double, policy: WidgetCommandPolicy, + phase: WidgetCommandPhase = .change, key: String?) { var numberState = widget.stateValueAsNumberState numberState = numberState ?? NumberState( @@ -115,7 +121,7 @@ struct SliderRowView: View { format: widget.item?.stateDescription?.numberPattern ) numberState?.value = newValue - viewModel.sendToUpdate(item: widget.item, state: numberState, policy: policy, key: key) + viewModel.sendToUpdate(item: widget.item, state: numberState, policy: policy, phase: phase, key: key) } private func sliderRange(displayState: WidgetDisplayState) -> ClosedRange { diff --git a/openHAB/SwiftUI/SitemapNavigationView.swift b/openHAB/SwiftUI/SitemapNavigationView.swift index 2cbba0b88..8ec7a5286 100644 --- a/openHAB/SwiftUI/SitemapNavigationView.swift +++ b/openHAB/SwiftUI/SitemapNavigationView.swift @@ -30,6 +30,9 @@ struct SitemapNavigationView: View { .navigationTitle(viewModel.pageTitle) .navigationBarTitleDisplayMode(.inline) .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + commandLifecycleIndicator + } ToolbarItem(placement: .navigationBarTrailing) { Button { onShowSideMenu() @@ -50,6 +53,26 @@ struct SitemapNavigationView: View { } } + @ViewBuilder + private var commandLifecycleIndicator: some View { + switch viewModel.commandLifecycleSummary { + case .idle: + EmptyView() + case .sending: + ProgressView() + .controlSize(.small) + .accessibilityLabel("Sending command") + case let .failed(count): + HStack(spacing: 4) { + Image(systemSymbol: .exclamationmarkTriangleFill) + Text("\(count)") + } + .foregroundStyle(.red) + .font(.caption) + .accessibilityLabel("Command failures: \(count)") + } + } + init(viewModel: SitemapPageViewModel, onShowSideMenu: @escaping () -> Void) { _viewModel = StateObject(wrappedValue: viewModel) self.onShowSideMenu = onShowSideMenu From afacbfc3915d067d538debbc8231abdeb96ccf64 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Sat, 14 Feb 2026 22:18:59 +0100 Subject: [PATCH 465/476] Move command lifecycle indicator to leading toolbar and hide when idle Switch title display mode to automatic and conditionally show the command lifecycle indicator on the leading side only when not idle. Signed-off-by: Tim Mueller-Seydlitz --- openHAB/SwiftUI/SitemapNavigationView.swift | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/openHAB/SwiftUI/SitemapNavigationView.swift b/openHAB/SwiftUI/SitemapNavigationView.swift index 8ec7a5286..57f0fe1dc 100644 --- a/openHAB/SwiftUI/SitemapNavigationView.swift +++ b/openHAB/SwiftUI/SitemapNavigationView.swift @@ -28,10 +28,12 @@ struct SitemapNavigationView: View { private var sitemapContent: some View { let page = SitemapPageView(viewModel: viewModel) .navigationTitle(viewModel.pageTitle) - .navigationBarTitleDisplayMode(.inline) + .navigationBarTitleDisplayMode(.automatic) .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - commandLifecycleIndicator + if !isCommandLifecycleIdle { + ToolbarItem(placement: .navigationBarLeading) { + commandLifecycleIndicator + } } ToolbarItem(placement: .navigationBarTrailing) { Button { @@ -73,6 +75,13 @@ struct SitemapNavigationView: View { } } + private var isCommandLifecycleIdle: Bool { + if case .idle = viewModel.commandLifecycleSummary { + return true + } + return false + } + init(viewModel: SitemapPageViewModel, onShowSideMenu: @escaping () -> Void) { _viewModel = StateObject(wrappedValue: viewModel) self.onShowSideMenu = onShowSideMenu From 9f352a5685a90ca3f32d3482b155eb856d243048 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Sun, 15 Feb 2026 07:47:19 +0100 Subject: [PATCH 466/476] Add toolbar search button with on-demand searchable field Replace always-visible search bar with a magnifying glass toolbar button that toggles search presentation. On iOS 17+ uses the isPresented parameter of searchable; on older versions falls back to a custom bottom search bar with focus management. Signed-off-by: Tim Mueller-Seydlitz --- openHAB/SwiftUI/SitemapNavigationView.swift | 89 ++++++++++++++++++++- 1 file changed, 85 insertions(+), 4 deletions(-) diff --git a/openHAB/SwiftUI/SitemapNavigationView.swift b/openHAB/SwiftUI/SitemapNavigationView.swift index 57f0fe1dc..a72fef204 100644 --- a/openHAB/SwiftUI/SitemapNavigationView.swift +++ b/openHAB/SwiftUI/SitemapNavigationView.swift @@ -16,6 +16,8 @@ import SwiftUI struct SitemapNavigationView: View { @StateObject var viewModel = SitemapPageViewModel() + @State private var isSearchPresented = false + @FocusState private var isLegacySearchFocused: Bool let onShowSideMenu: () -> Void var body: some View { @@ -35,6 +37,26 @@ struct SitemapNavigationView: View { commandLifecycleIndicator } } + if viewModel.showSearchField { + ToolbarItem(placement: .navigationBarTrailing) { + if #available(iOS 17.0, *) { + Button { + isSearchPresented = true + } label: { + Image(systemSymbol: .magnifyingglass) + } + .accessibilityLabel("Search") + } else { + Button { + isSearchPresented = true + isLegacySearchFocused = true + } label: { + Image(systemSymbol: .magnifyingglass) + } + .accessibilityLabel("Search") + } + } + } ToolbarItem(placement: .navigationBarTrailing) { Button { onShowSideMenu() @@ -46,15 +68,74 @@ struct SitemapNavigationView: View { } if viewModel.showSearchField { - page - .searchable(text: $viewModel.searchText, prompt: Text(NSLocalizedString("search_items", comment: ""))) - .autocorrectionDisabled() - .textInputAutocapitalization(.never) + if #available(iOS 17.0, *) { + if isSearchPresented { + page + .searchable( + text: $viewModel.searchText, + isPresented: $isSearchPresented, + placement: .navigationBarDrawer(displayMode: .always), + prompt: Text(NSLocalizedString("search_items", comment: "")) + ) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + } else { + page + } + } else { + page + .safeAreaInset(edge: .bottom) { + if isSearchPresented { + legacySearchBar + } + } + } } else { page } } + private var legacySearchBar: some View { + HStack(spacing: 8) { + Image(systemSymbol: .magnifyingglass) + .foregroundStyle(.secondary) + .font(.footnote) + + TextField(NSLocalizedString("search_items", comment: ""), text: $viewModel.searchText) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .focused($isLegacySearchFocused) + .font(.footnote) + + if !viewModel.searchText.isEmpty { + Button { + viewModel.searchText = "" + } label: { + Image(systemSymbol: .xmarkCircleFill) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } + + Button { + isSearchPresented = false + isLegacySearchFocused = false + } label: { + Image(systemSymbol: .xmark) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + .background( + Color(.secondarySystemBackground).opacity(0.6), + in: RoundedRectangle(cornerRadius: 10) + ) + .padding(.horizontal, 12) + .padding(.bottom, 6) + } + @ViewBuilder private var commandLifecycleIndicator: some View { switch viewModel.commandLifecycleSummary { From 9aa79de359b2e50cc25751b4cce497e5bef35d1c Mon Sep 17 00:00:00 2001 From: Tassilo Karge Date: Fri, 19 Dec 2025 12:16:35 +0100 Subject: [PATCH 467/476] disable linting and format for swiftui auto build Signed-off-by: Tassilo Karge --- openHAB.xcodeproj/project.pbxproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index 10a1734ce..66e52fd35 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -1663,7 +1663,7 @@ DAF0A2902C56FE9F00A14A6A /* Run swiftformat & swiftlint */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; - buildActionMask = 2147483647; + buildActionMask = 8; files = ( ); inputFileListPaths = ( @@ -1675,7 +1675,7 @@ ); outputPaths = ( ); - runOnlyForDeploymentPostprocessing = 0; + runOnlyForDeploymentPostprocessing = 1; shellPath = /bin/sh; shellScript = "[[ -n \"$CI\" ]] && exit 0\n\ncd BuildTools\nSDKROOT=(xcrun --sdk macosx --show-sdk-path)\n\nswift package plugin --allow-writing-to-package-directory --allow-writing-to-directory \"$SRCROOT\" swiftformat \"$SRCROOT\" --config ./.swiftformat --cache /private/tmp/\nswift package plugin --allow-writing-to-package-directory --allow-writing-to-directory ../ swiftlint --cache-path /private/tmp/\n"; }; From 123da03959732ad7069eb0474d92f868d4b9899d Mon Sep 17 00:00:00 2001 From: Tassilo Karge Date: Fri, 26 Dec 2025 14:07:42 +0100 Subject: [PATCH 468/476] group views by technology Signed-off-by: Tassilo Karge --- openHAB.xcodeproj/project.pbxproj | 62 ++++++++++++++----- openHAB/{ => SwiftUI}/DrawerView.swift | 0 openHAB/{ => SwiftUI}/HomeSelectionView.swift | 0 openHAB/{ => SwiftUI}/NotificationsView.swift | 0 .../{ => SitemapView}/EmbeddingRowView.swift | 0 .../{ => SitemapView}/IconURLView.swift | 0 .../SwiftUI/{ => SitemapView}/IconView.swift | 0 .../SwiftUI/{ => SitemapView}/ImageView.swift | 0 .../SitemapView}/RTFTextView.swift | 0 .../{ => SitemapView}/RowViewFactory.swift | 0 .../SitemapView}/SelectionView.swift | 0 .../SitemapNavigationView.swift | 0 .../{ => SitemapView}/SitemapPageView.swift | 0 .../OpenHABNavigationController.swift | 0 .../OpenHABRootViewController.swift | 0 .../OpenHABSitemapViewController.swift | 1 + .../{ => UIKit}/OpenHABViewController.swift | 0 .../OpenHABWebViewController.swift | 0 .../{ => UIKit}/SpinnerViewController.swift | 0 .../UITableViewCellExtension.swift | 0 20 files changed, 46 insertions(+), 17 deletions(-) rename openHAB/{ => SwiftUI}/DrawerView.swift (100%) rename openHAB/{ => SwiftUI}/HomeSelectionView.swift (100%) rename openHAB/{ => SwiftUI}/NotificationsView.swift (100%) rename openHAB/SwiftUI/{ => SitemapView}/EmbeddingRowView.swift (100%) rename openHAB/SwiftUI/{ => SitemapView}/IconURLView.swift (100%) rename openHAB/SwiftUI/{ => SitemapView}/IconView.swift (100%) rename openHAB/SwiftUI/{ => SitemapView}/ImageView.swift (100%) rename openHAB/{ => SwiftUI/SitemapView}/RTFTextView.swift (100%) rename openHAB/SwiftUI/{ => SitemapView}/RowViewFactory.swift (100%) rename openHAB/{ => SwiftUI/SitemapView}/SelectionView.swift (100%) rename openHAB/SwiftUI/{ => SitemapView}/SitemapNavigationView.swift (100%) rename openHAB/SwiftUI/{ => SitemapView}/SitemapPageView.swift (100%) rename openHAB/{ => UIKit}/OpenHABNavigationController.swift (100%) rename openHAB/{ => UIKit}/OpenHABRootViewController.swift (100%) rename openHAB/{ => UIKit}/OpenHABSitemapViewController.swift (99%) rename openHAB/{ => UIKit}/OpenHABViewController.swift (100%) rename openHAB/{ => UIKit}/OpenHABWebViewController.swift (100%) rename openHAB/{ => UIKit}/SpinnerViewController.swift (100%) rename openHAB/{ => UIKit}/UITableViewCellExtension.swift (100%) diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index 66e52fd35..24abb71ff 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -724,6 +724,44 @@ path = openHABWatch/External; sourceTree = SOURCE_ROOT; }; + 2F7D1D572EFEBABA004A786D /* UIKit */ = { + isa = PBXGroup; + children = ( + 65C2EF482E244C8500A0C19F /* OpenHABNavigationController.swift */, + 653B54C1285E714900298ECD /* OpenHABViewController.swift */, + DA95F3322E0F2B1700FE4474 /* OpenHABRootViewController.swift */, + 65570A7C2476D16A00D524EA /* OpenHABWebViewController.swift */, + DA95F3342E0F2C1600FE4474 /* OpenHABSitemapViewController.swift */, + DAC65FC6236EDF3900F4501E /* SpinnerViewController.swift */, + DA2AEB6F2D92CF3E00897D80 /* UITableViewCellExtension.swift */, + ); + path = UIKit; + sourceTree = ""; + }; + 2F7D1D592EFEC12A004A786D /* RootView */ = { + isa = PBXGroup; + children = ( + 2F3136F82EF5655700708F89 /* OpenHABSwiftUIRootView.swift */, + ); + path = RootView; + sourceTree = ""; + }; + 2F7D1D5A2EFEC137004A786D /* SitemapView */ = { + isa = PBXGroup; + children = ( + DA9F81862C85020F00B47B72 /* RTFTextView.swift */, + DA6B2EF62C8B92E800DF77CF /* SelectionView.swift */, + DAF5AA672E4F3A38004F18D7 /* EmbeddingRowView.swift */, + DAE280092E35F5590028EE24 /* IconURLView.swift */, + DA35E2CC2E1F96CA003987BB /* IconView.swift */, + DA35E2CA2E1F93AD003987BB /* ImageView.swift */, + DAEA21D72DBF472D00D54342 /* RowViewFactory.swift */, + DA64ACA72DBEAD8300294F60 /* SitemapPageView.swift */, + DA64ACA92DBEAD9000294F60 /* SitemapNavigationView.swift */, + ); + path = SitemapView; + sourceTree = ""; + }; 4D6470D42561F935007B03FC /* openHABIntents */ = { isa = PBXGroup; children = ( @@ -915,13 +953,11 @@ DA35E2B12E1EEA58003987BB /* SwiftUI */ = { isa = PBXGroup; children = ( - DA35E2CA2E1F93AD003987BB /* ImageView.swift */, - DA35E2CC2E1F96CA003987BB /* IconView.swift */, - DAE280092E35F5590028EE24 /* IconURLView.swift */, - DA64ACA72DBEAD8300294F60 /* SitemapPageView.swift */, - DAF5AA672E4F3A38004F18D7 /* EmbeddingRowView.swift */, - DA64ACA92DBEAD9000294F60 /* SitemapNavigationView.swift */, - DAEA21D72DBF472D00D54342 /* RowViewFactory.swift */, + 2FBCF58B2DEB0B7700CD5D83 /* HomeSelectionView.swift */, + DA6B2EF02C87B59000DF77CF /* NotificationsView.swift */, + DA6B2EEE2C861BC900DF77CF /* DrawerView.swift */, + 2F7D1D592EFEC12A004A786D /* RootView */, + 2F7D1D5A2EFEC137004A786D /* SitemapView */, DAC949FF2E21A473007E67B7 /* Rows */, ); path = SwiftUI; @@ -1077,7 +1113,8 @@ DF4B83FD18857FA100F34902 /* UI */ = { isa = PBXGroup; children = ( - 65C2EF482E244C8500A0C19F /* OpenHABNavigationController.swift */, + 2F7D1D572EFEBABA004A786D /* UIKit */, + DA35E2B12E1EEA58003987BB /* SwiftUI */, 652B81082E2193DA00648510 /* ScreenSaver */, DA2AEB752D92D32000897D80 /* Cells */, DABED17C2E4516B4000B92EF /* BonjourDiscoveryViewModel.swift */, @@ -1085,18 +1122,9 @@ DA95F3322E0F2B1700FE4474 /* OpenHABRootViewController.swift */, DA94AF432EC8DE41003BB3C8 /* VideoStreamManager.swift */, DA94AEB32EC896BE003BB3C8 /* SimpleMJPEGPlayer.swift */, - 65570A7C2476D16A00D524EA /* OpenHABWebViewController.swift */, - DA95F3342E0F2C1600FE4474 /* OpenHABSitemapViewController.swift */, DA2AEB6D2D92BAD800897D80 /* PageLoader.swift */, - DAC65FC6236EDF3900F4501E /* SpinnerViewController.swift */, - DA6B2EF62C8B92E800DF77CF /* SelectionView.swift */, - DA2AEB6F2D92CF3E00897D80 /* UITableViewCellExtension.swift */, DA48001F2D837CD8009CF127 /* SettingsView */, 1224F78B228A89E300750965 /* Watch */, - 2FBCF58B2DEB0B7700CD5D83 /* HomeSelectionView.swift */, - DA9F81862C85020F00B47B72 /* RTFTextView.swift */, - DA6B2EF02C87B59000DF77CF /* NotificationsView.swift */, - DA6B2EEE2C861BC900DF77CF /* DrawerView.swift */, DF4B84101886DA9900F34902 /* Widgets */, DFFD8FCE18EDBD30003B502A /* Util */, DA35E2B12E1EEA58003987BB /* SwiftUI */, diff --git a/openHAB/DrawerView.swift b/openHAB/SwiftUI/DrawerView.swift similarity index 100% rename from openHAB/DrawerView.swift rename to openHAB/SwiftUI/DrawerView.swift diff --git a/openHAB/HomeSelectionView.swift b/openHAB/SwiftUI/HomeSelectionView.swift similarity index 100% rename from openHAB/HomeSelectionView.swift rename to openHAB/SwiftUI/HomeSelectionView.swift diff --git a/openHAB/NotificationsView.swift b/openHAB/SwiftUI/NotificationsView.swift similarity index 100% rename from openHAB/NotificationsView.swift rename to openHAB/SwiftUI/NotificationsView.swift diff --git a/openHAB/SwiftUI/EmbeddingRowView.swift b/openHAB/SwiftUI/SitemapView/EmbeddingRowView.swift similarity index 100% rename from openHAB/SwiftUI/EmbeddingRowView.swift rename to openHAB/SwiftUI/SitemapView/EmbeddingRowView.swift diff --git a/openHAB/SwiftUI/IconURLView.swift b/openHAB/SwiftUI/SitemapView/IconURLView.swift similarity index 100% rename from openHAB/SwiftUI/IconURLView.swift rename to openHAB/SwiftUI/SitemapView/IconURLView.swift diff --git a/openHAB/SwiftUI/IconView.swift b/openHAB/SwiftUI/SitemapView/IconView.swift similarity index 100% rename from openHAB/SwiftUI/IconView.swift rename to openHAB/SwiftUI/SitemapView/IconView.swift diff --git a/openHAB/SwiftUI/ImageView.swift b/openHAB/SwiftUI/SitemapView/ImageView.swift similarity index 100% rename from openHAB/SwiftUI/ImageView.swift rename to openHAB/SwiftUI/SitemapView/ImageView.swift diff --git a/openHAB/RTFTextView.swift b/openHAB/SwiftUI/SitemapView/RTFTextView.swift similarity index 100% rename from openHAB/RTFTextView.swift rename to openHAB/SwiftUI/SitemapView/RTFTextView.swift diff --git a/openHAB/SwiftUI/RowViewFactory.swift b/openHAB/SwiftUI/SitemapView/RowViewFactory.swift similarity index 100% rename from openHAB/SwiftUI/RowViewFactory.swift rename to openHAB/SwiftUI/SitemapView/RowViewFactory.swift diff --git a/openHAB/SelectionView.swift b/openHAB/SwiftUI/SitemapView/SelectionView.swift similarity index 100% rename from openHAB/SelectionView.swift rename to openHAB/SwiftUI/SitemapView/SelectionView.swift diff --git a/openHAB/SwiftUI/SitemapNavigationView.swift b/openHAB/SwiftUI/SitemapView/SitemapNavigationView.swift similarity index 100% rename from openHAB/SwiftUI/SitemapNavigationView.swift rename to openHAB/SwiftUI/SitemapView/SitemapNavigationView.swift diff --git a/openHAB/SwiftUI/SitemapPageView.swift b/openHAB/SwiftUI/SitemapView/SitemapPageView.swift similarity index 100% rename from openHAB/SwiftUI/SitemapPageView.swift rename to openHAB/SwiftUI/SitemapView/SitemapPageView.swift diff --git a/openHAB/OpenHABNavigationController.swift b/openHAB/UIKit/OpenHABNavigationController.swift similarity index 100% rename from openHAB/OpenHABNavigationController.swift rename to openHAB/UIKit/OpenHABNavigationController.swift diff --git a/openHAB/OpenHABRootViewController.swift b/openHAB/UIKit/OpenHABRootViewController.swift similarity index 100% rename from openHAB/OpenHABRootViewController.swift rename to openHAB/UIKit/OpenHABRootViewController.swift diff --git a/openHAB/OpenHABSitemapViewController.swift b/openHAB/UIKit/OpenHABSitemapViewController.swift similarity index 99% rename from openHAB/OpenHABSitemapViewController.swift rename to openHAB/UIKit/OpenHABSitemapViewController.swift index f3c073fe5..d2818cbde 100644 --- a/openHAB/OpenHABSitemapViewController.swift +++ b/openHAB/UIKit/OpenHABSitemapViewController.swift @@ -23,6 +23,7 @@ import SwiftMessages import SwiftUI import UIKit + class OpenHABSitemapViewController: OpenHABViewController, UISearchControllerDelegate { var pageUrl = "" private var iconType: IconType = .svg diff --git a/openHAB/OpenHABViewController.swift b/openHAB/UIKit/OpenHABViewController.swift similarity index 100% rename from openHAB/OpenHABViewController.swift rename to openHAB/UIKit/OpenHABViewController.swift diff --git a/openHAB/OpenHABWebViewController.swift b/openHAB/UIKit/OpenHABWebViewController.swift similarity index 100% rename from openHAB/OpenHABWebViewController.swift rename to openHAB/UIKit/OpenHABWebViewController.swift diff --git a/openHAB/SpinnerViewController.swift b/openHAB/UIKit/SpinnerViewController.swift similarity index 100% rename from openHAB/SpinnerViewController.swift rename to openHAB/UIKit/SpinnerViewController.swift diff --git a/openHAB/UITableViewCellExtension.swift b/openHAB/UIKit/UITableViewCellExtension.swift similarity index 100% rename from openHAB/UITableViewCellExtension.swift rename to openHAB/UIKit/UITableViewCellExtension.swift From b6b68ae19d5182af8c04b1d7ea50111eb60648bf Mon Sep 17 00:00:00 2001 From: Tassilo Karge Date: Sat, 14 Feb 2026 10:02:23 +0100 Subject: [PATCH 469/476] Replace SideMenu drawer with SwiftUI TabView navigation Replace the UIKit OpenHABRootViewController + SideMenu drawer with a SwiftUI TabView containing four tabs: Home (MainUI WebView), Sitemaps, Tiles, and System. Extract background services (SSE, network tracking, certificate alerts, push notification handling) into AppServicesViewModel. Remove SideMenu package dependency and deleted files (DrawerView, OpenHABRootViewController, OpenHABSwiftUIRootView). Persist last-selected tab across app restarts via HomePreferences. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Tassilo Karge --- .../OpenHABCore/Util/Preferences.swift | 1 + openHAB.xcodeproj/project.pbxproj | 80 +- .../xcshareddata/swiftpm/Package.resolved | 20 +- .../xcshareddata/swiftpm/Package.resolved | 11 +- openHAB/AppDelegate.swift | 8 + openHAB/NotificationCenterDelegateImpl.swift | 13 +- openHAB/SwiftUI/DrawerView.swift | 288 ----- .../RootView/AppServicesViewModel.swift | 571 +++++++++ openHAB/SwiftUI/RootView/MainWebTab.swift | 23 + .../SwiftUI/RootView/OpenHABTabRootView.swift | 148 +++ openHAB/SwiftUI/RootView/SafariView.swift | 25 + openHAB/SwiftUI/RootView/SitemapsTab.swift | 185 +++ openHAB/SwiftUI/RootView/SystemTab.swift | 86 ++ openHAB/SwiftUI/RootView/TilesTab.swift | 94 ++ .../SitemapView/SitemapNavigationView.swift | 19 +- openHAB/UIKit/OpenHABRootViewController.swift | 1028 ----------------- openHAB/UIKit/OpenHABViewController.swift | 10 +- openHAB/UIKit/OpenHABWebViewController.swift | 4 +- openHABUITests/OpenHABUITests.swift | 20 +- 19 files changed, 1229 insertions(+), 1405 deletions(-) delete mode 100644 openHAB/SwiftUI/DrawerView.swift create mode 100644 openHAB/SwiftUI/RootView/AppServicesViewModel.swift create mode 100644 openHAB/SwiftUI/RootView/MainWebTab.swift create mode 100644 openHAB/SwiftUI/RootView/OpenHABTabRootView.swift create mode 100644 openHAB/SwiftUI/RootView/SafariView.swift create mode 100644 openHAB/SwiftUI/RootView/SitemapsTab.swift create mode 100644 openHAB/SwiftUI/RootView/SystemTab.swift create mode 100644 openHAB/SwiftUI/RootView/TilesTab.swift delete mode 100644 openHAB/UIKit/OpenHABRootViewController.swift diff --git a/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift index 5e5ecc9e0..4b008d64a 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift @@ -104,6 +104,7 @@ public struct HomePreferences: Codable, Equatable { public var sitemapForWatchLabel = "watch" public var homeName = "Home" public var sseCommandItem = "" + public var lastSelectedTab = "main" fileprivate init(id: UUID) { self.id = id diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index 24abb71ff..94092479d 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -70,7 +70,6 @@ 93F8063527AE6C620035A6B0 /* FirebaseCrashlytics in Frameworks */ = {isa = PBXBuildFile; productRef = 93F8063427AE6C620035A6B0 /* FirebaseCrashlytics */; }; 93F8064727AE7A050035A6B0 /* SwiftMessages in Frameworks */ = {isa = PBXBuildFile; productRef = 93F8064627AE7A050035A6B0 /* SwiftMessages */; }; 93F8064A27AE7A2E0035A6B0 /* FlexColorPicker in Frameworks */ = {isa = PBXBuildFile; productRef = 93F8064927AE7A2E0035A6B0 /* FlexColorPicker */; }; - 93F8065027AE7A830035A6B0 /* SideMenu in Frameworks */ = {isa = PBXBuildFile; productRef = 93F8064F27AE7A830035A6B0 /* SideMenu */; }; A3F4C3A51A49A5940019A09F /* MainLaunchScreen.xib in Resources */ = {isa = PBXBuildFile; fileRef = A3F4C3A41A49A5940019A09F /* MainLaunchScreen.xib */; }; B7D5ECE121499E55001B0EC6 /* MapViewTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7D5ECE021499E55001B0EC6 /* MapViewTableViewCell.swift */; }; C4377202F7D642B5A8349008 /* SelectionRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = D86D8BF295C448039B2B85EB /* SelectionRow.swift */; }; @@ -128,7 +127,6 @@ DA64ACA82DBEAD8300294F60 /* SitemapPageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA64ACA72DBEAD8300294F60 /* SitemapPageView.swift */; }; DA64ACAA2DBEAD9000294F60 /* SitemapNavigationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA64ACA92DBEAD9000294F60 /* SitemapNavigationView.swift */; }; DA65871F236F83CE007E2E7F /* UserDefaultsExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA65871E236F83CD007E2E7F /* UserDefaultsExtension.swift */; }; - DA6B2EEF2C861BC900DF77CF /* DrawerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA6B2EEE2C861BC900DF77CF /* DrawerView.swift */; }; DA6B2EF12C87B59000DF77CF /* NotificationsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA6B2EF02C87B59000DF77CF /* NotificationsView.swift */; }; DA6B2EF52C89F8F200DF77CF /* ColorPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA6B2EF42C89F8F200DF77CF /* ColorPickerView.swift */; }; DA6B2EF72C8B92E800DF77CF /* SelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA6B2EF62C8B92E800DF77CF /* SelectionView.swift */; }; @@ -144,7 +142,6 @@ DA8B14BA2F3A373A007753FD /* PreviewNavigationContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA8B14B92F3A373A007753FD /* PreviewNavigationContainer.swift */; }; DA8B14BC2F3A3CB5007753FD /* WatchTypography.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA8B14BB2F3A3CB5007753FD /* WatchTypography.swift */; }; DA94AEB42EC896BE003BB3C8 /* SimpleMJPEGPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA94AEB32EC896BE003BB3C8 /* SimpleMJPEGPlayer.swift */; }; - DA95F3332E0F2B1700FE4474 /* OpenHABRootViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA95F3322E0F2B1700FE4474 /* OpenHABRootViewController.swift */; }; DA95F3352E0F2C1600FE4474 /* OpenHABSitemapViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA95F3342E0F2C1600FE4474 /* OpenHABSitemapViewController.swift */; }; DA96415A2F292EE200CEC181 /* BonjourDiscoveryViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA9641592F292EE200CEC181 /* BonjourDiscoveryViewModelTests.swift */; }; DA96415C2F292F0600CEC181 /* OpenHABEndPoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA96415B2F292F0600CEC181 /* OpenHABEndPoint.swift */; }; @@ -220,6 +217,13 @@ DFDF45311932042B00A6E581 /* legal.rtf in Resources */ = {isa = PBXBuildFile; fileRef = DFDF45301932042B00A6E581 /* legal.rtf */; }; DFE10414197415F900D94943 /* Security.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DFE10413197415F900D94943 /* Security.framework */; }; DFFD8FD118EDBD4F003B502A /* UICircleButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFFD8FD018EDBD4F003B502A /* UICircleButton.swift */; }; + 1CE7AC462008E29FA37F231D /* AppServicesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64BEA2793F07C36BB98D9B8 /* AppServicesViewModel.swift */; }; + BE875E6CE1B6E05E492F29CE /* OpenHABTabRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52411B814C63C9F1CC8CED90 /* OpenHABTabRootView.swift */; }; + 801159DCB99C03EFA71F4B9D /* MainWebTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5135184D9630899A34EFBA95 /* MainWebTab.swift */; }; + D8C89708AE75DEFCD78566EC /* SitemapsTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13181EE034B26E3A1FF7F4A8 /* SitemapsTab.swift */; }; + 2C9BBB28C068BCC10291A566 /* TilesTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE239F0348D19000408BA8AA /* TilesTab.swift */; }; + 0C93C5B057F863322A1B9820 /* SystemTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4229903F00160C965E22DE21 /* SystemTab.swift */; }; + 911CC92713E7DF11C3295A4C /* SafariView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3C3E3BF4C930ADA95E6E997 /* SafariView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -495,7 +499,6 @@ DA64ACA72DBEAD8300294F60 /* SitemapPageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SitemapPageView.swift; sourceTree = ""; }; DA64ACA92DBEAD9000294F60 /* SitemapNavigationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SitemapNavigationView.swift; sourceTree = ""; }; DA65871E236F83CD007E2E7F /* UserDefaultsExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaultsExtension.swift; sourceTree = ""; }; - DA6B2EEE2C861BC900DF77CF /* DrawerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DrawerView.swift; sourceTree = ""; }; DA6B2EF02C87B59000DF77CF /* NotificationsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsView.swift; sourceTree = ""; }; DA6B2EF42C89F8F200DF77CF /* ColorPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorPickerView.swift; sourceTree = ""; }; DA6B2EF62C8B92E800DF77CF /* SelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectionView.swift; sourceTree = ""; }; @@ -512,7 +515,6 @@ DA8B15512F3BB74B007753FD /* openHABWatchTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = openHABWatchTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; DA94AEB32EC896BE003BB3C8 /* SimpleMJPEGPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleMJPEGPlayer.swift; sourceTree = ""; }; DA94AF432EC8DE41003BB3C8 /* VideoStreamManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoStreamManager.swift; sourceTree = ""; }; - DA95F3322E0F2B1700FE4474 /* OpenHABRootViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenHABRootViewController.swift; sourceTree = ""; }; DA95F3342E0F2C1600FE4474 /* OpenHABSitemapViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenHABSitemapViewController.swift; sourceTree = ""; }; DA9641592F292EE200CEC181 /* BonjourDiscoveryViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BonjourDiscoveryViewModelTests.swift; sourceTree = ""; }; DA96415B2F292F0600CEC181 /* OpenHABEndPoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenHABEndPoint.swift; sourceTree = ""; }; @@ -593,6 +595,13 @@ DFDF45301932042B00A6E581 /* legal.rtf */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.rtf; path = legal.rtf; sourceTree = ""; }; DFE10413197415F900D94943 /* Security.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Security.framework; path = System/Library/Frameworks/Security.framework; sourceTree = SDKROOT; }; DFFD8FD018EDBD4F003B502A /* UICircleButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UICircleButton.swift; sourceTree = ""; }; + D64BEA2793F07C36BB98D9B8 /* AppServicesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppServicesViewModel.swift; sourceTree = ""; }; + 52411B814C63C9F1CC8CED90 /* OpenHABTabRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenHABTabRootView.swift; sourceTree = ""; }; + 5135184D9630899A34EFBA95 /* MainWebTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainWebTab.swift; sourceTree = ""; }; + 13181EE034B26E3A1FF7F4A8 /* SitemapsTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SitemapsTab.swift; sourceTree = ""; }; + EE239F0348D19000408BA8AA /* TilesTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TilesTab.swift; sourceTree = ""; }; + 4229903F00160C965E22DE21 /* SystemTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemTab.swift; sourceTree = ""; }; + A3C3E3BF4C930ADA95E6E997 /* SafariView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafariView.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -685,7 +694,6 @@ 93F8064A27AE7A2E0035A6B0 /* FlexColorPicker in Frameworks */, DA28C362225241DE00AB409C /* WebKit.framework in Frameworks */, DAC949FA2E219F0D007E67B7 /* CommonUI in Frameworks */, - 93F8065027AE7A830035A6B0 /* SideMenu in Frameworks */, DFE10414197415F900D94943 /* Security.framework in Frameworks */, 93F8064727AE7A050035A6B0 /* SwiftMessages in Frameworks */, DFB2622D18830A3600D3244D /* CoreGraphics.framework in Frameworks */, @@ -729,7 +737,6 @@ children = ( 65C2EF482E244C8500A0C19F /* OpenHABNavigationController.swift */, 653B54C1285E714900298ECD /* OpenHABViewController.swift */, - DA95F3322E0F2B1700FE4474 /* OpenHABRootViewController.swift */, 65570A7C2476D16A00D524EA /* OpenHABWebViewController.swift */, DA95F3342E0F2C1600FE4474 /* OpenHABSitemapViewController.swift */, DAC65FC6236EDF3900F4501E /* SpinnerViewController.swift */, @@ -741,7 +748,13 @@ 2F7D1D592EFEC12A004A786D /* RootView */ = { isa = PBXGroup; children = ( - 2F3136F82EF5655700708F89 /* OpenHABSwiftUIRootView.swift */, + D64BEA2793F07C36BB98D9B8 /* AppServicesViewModel.swift */, + 52411B814C63C9F1CC8CED90 /* OpenHABTabRootView.swift */, + 5135184D9630899A34EFBA95 /* MainWebTab.swift */, + 13181EE034B26E3A1FF7F4A8 /* SitemapsTab.swift */, + EE239F0348D19000408BA8AA /* TilesTab.swift */, + 4229903F00160C965E22DE21 /* SystemTab.swift */, + A3C3E3BF4C930ADA95E6E997 /* SafariView.swift */, ); path = RootView; sourceTree = ""; @@ -955,7 +968,6 @@ children = ( 2FBCF58B2DEB0B7700CD5D83 /* HomeSelectionView.swift */, DA6B2EF02C87B59000DF77CF /* NotificationsView.swift */, - DA6B2EEE2C861BC900DF77CF /* DrawerView.swift */, 2F7D1D592EFEC12A004A786D /* RootView */, 2F7D1D5A2EFEC137004A786D /* SitemapView */, DAC949FF2E21A473007E67B7 /* Rows */, @@ -1118,8 +1130,6 @@ 652B81082E2193DA00648510 /* ScreenSaver */, DA2AEB752D92D32000897D80 /* Cells */, DABED17C2E4516B4000B92EF /* BonjourDiscoveryViewModel.swift */, - 653B54C1285E714900298ECD /* OpenHABViewController.swift */, - DA95F3322E0F2B1700FE4474 /* OpenHABRootViewController.swift */, DA94AF432EC8DE41003BB3C8 /* VideoStreamManager.swift */, DA94AEB32EC896BE003BB3C8 /* SimpleMJPEGPlayer.swift */, DA2AEB6D2D92BAD800897D80 /* PageLoader.swift */, @@ -1478,7 +1488,6 @@ 93F8063427AE6C620035A6B0 /* FirebaseCrashlytics */, 93F8064627AE7A050035A6B0 /* SwiftMessages */, 93F8064927AE7A2E0035A6B0 /* FlexColorPicker */, - 93F8064F27AE7A830035A6B0 /* SideMenu */, 6557AF912C039D140094D0C8 /* FirebaseMessaging */, DA9A7EFE2D66915900824156 /* SFSafeSymbols */, DABB5E322D98972F009A4B8A /* SDWebImageSVGCoder */, @@ -1574,7 +1583,6 @@ 93F8063327AE6C620035A6B0 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */, 93F8064527AE7A050035A6B0 /* XCRemoteSwiftPackageReference "SwiftMessages" */, 93F8064827AE7A2E0035A6B0 /* XCRemoteSwiftPackageReference "FlexColorPicker" */, - 93F8064E27AE7A820035A6B0 /* XCRemoteSwiftPackageReference "SideMenu" */, DA3B75AC2C59729200E219AB /* XCRemoteSwiftPackageReference "SFSafeSymbols" */, DA2C4FD32B4F573300D1C533 /* XCRemoteSwiftPackageReference "SDWebImageSVGCoder" */, DADC42082E7AB899004E866F /* XCRemoteSwiftPackageReference "SDWebImage" */, @@ -1830,7 +1838,13 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - DA95F3332E0F2B1700FE4474 /* OpenHABRootViewController.swift in Sources */, + 1CE7AC462008E29FA37F231D /* AppServicesViewModel.swift in Sources */, + BE875E6CE1B6E05E492F29CE /* OpenHABTabRootView.swift in Sources */, + 801159DCB99C03EFA71F4B9D /* MainWebTab.swift in Sources */, + D8C89708AE75DEFCD78566EC /* SitemapsTab.swift in Sources */, + 2C9BBB28C068BCC10291A566 /* TilesTab.swift in Sources */, + 0C93C5B057F863322A1B9820 /* SystemTab.swift in Sources */, + 911CC92713E7DF11C3295A4C /* SafariView.swift in Sources */, DA7E1E4B2233986E002AEFD8 /* PlayerView.swift in Sources */, 65570A7D2476D16A00D524EA /* OpenHABWebViewController.swift in Sources */, DA48001E2D837905009CF127 /* ApplicationSettingsView.swift in Sources */, @@ -1889,7 +1903,6 @@ DA21EAE22339621C001AB415 /* Throttler.swift in Sources */, DAE2800A2E35F5590028EE24 /* IconURLView.swift in Sources */, DAF4F6C0222734D300C24876 /* NewImageUITableViewCell.swift in Sources */, - DA6B2EEF2C861BC900DF77CF /* DrawerView.swift in Sources */, DABED17D2E4516B4000B92EF /* BonjourDiscoveryViewModel.swift in Sources */, DA94AEB42EC896BE003BB3C8 /* SimpleMJPEGPlayer.swift in Sources */, DA48001A2D83742A009CF127 /* DebugSettingsView.swift in Sources */, @@ -2210,7 +2223,7 @@ SWIFT_PRECOMPILE_BRIDGING_HEADER = NO; TARGETED_DEVICE_FAMILY = "1,2"; TEST_TARGET_NAME = openHAB; - WATCHOS_DEPLOYMENT_TARGET = 8.0; + WATCHOS_DEPLOYMENT_TARGET = 26.2; }; name = Debug; }; @@ -2295,7 +2308,7 @@ SWIFT_EMIT_LOC_STRINGS = YES; TARGETED_DEVICE_FAMILY = 4; VERSIONING_SYSTEM = "apple-generic"; - WATCHOS_DEPLOYMENT_TARGET = 10.0; + WATCHOS_DEPLOYMENT_TARGET = 26.2; }; name = Debug; }; @@ -2343,7 +2356,7 @@ SWIFT_EMIT_LOC_STRINGS = YES; TARGETED_DEVICE_FAMILY = 4; VERSIONING_SYSTEM = "apple-generic"; - WATCHOS_DEPLOYMENT_TARGET = 10.0; + WATCHOS_DEPLOYMENT_TARGET = 26.2; }; name = Release; }; @@ -2456,7 +2469,7 @@ SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 4; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/openHABWatch.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/openHABWatch"; - WATCHOS_DEPLOYMENT_TARGET = 10.6; + WATCHOS_DEPLOYMENT_TARGET = 26.2; }; name = Debug; }; @@ -2494,7 +2507,7 @@ SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 4; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/openHABWatch.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/openHABWatch"; - WATCHOS_DEPLOYMENT_TARGET = 10.6; + WATCHOS_DEPLOYMENT_TARGET = 26.2; }; name = Release; }; @@ -2526,7 +2539,7 @@ SWIFT_EMIT_LOC_STRINGS = NO; TARGETED_DEVICE_FAMILY = 4; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/openHABWatch.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/openHABWatch"; - WATCHOS_DEPLOYMENT_TARGET = 9.0; + WATCHOS_DEPLOYMENT_TARGET = 26.2; }; name = Debug; }; @@ -2560,7 +2573,7 @@ SWIFT_EMIT_LOC_STRINGS = NO; TARGETED_DEVICE_FAMILY = 4; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/openHABWatch.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/openHABWatch"; - WATCHOS_DEPLOYMENT_TARGET = 9.0; + WATCHOS_DEPLOYMENT_TARGET = 26.2; }; name = Release; }; @@ -2591,7 +2604,7 @@ SWIFT_EMIT_LOC_STRINGS = NO; TARGETED_DEVICE_FAMILY = 4; TEST_TARGET_NAME = openHABWatch; - WATCHOS_DEPLOYMENT_TARGET = 9.0; + WATCHOS_DEPLOYMENT_TARGET = 26.2; }; name = Debug; }; @@ -2624,7 +2637,7 @@ SWIFT_EMIT_LOC_STRINGS = NO; TARGETED_DEVICE_FAMILY = 4; TEST_TARGET_NAME = openHABWatch; - WATCHOS_DEPLOYMENT_TARGET = 9.0; + WATCHOS_DEPLOYMENT_TARGET = 26.2; }; name = Release; }; @@ -2682,6 +2695,7 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 16.0; + IPHONEOS_DEPLOYMENT_TARGET = 26.2; MARKETING_VERSION = 3.1.1; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -2704,7 +2718,7 @@ SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; VERSIONING_SYSTEM = "apple-generic"; - WATCHOS_DEPLOYMENT_TARGET = 9.0; + WATCHOS_DEPLOYMENT_TARGET = 26.2; }; name = Debug; }; @@ -2757,6 +2771,7 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 16.0; + IPHONEOS_DEPLOYMENT_TARGET = 26.2; MARKETING_VERSION = 3.1.1; ONLY_ACTIVE_ARCH = NO; PROVISIONING_PROFILE_SPECIFIER = "match AppStore org.openhab.app"; @@ -2781,7 +2796,7 @@ TARGETED_DEVICE_FAMILY = "1,2"; VALIDATE_PRODUCT = YES; VERSIONING_SYSTEM = "apple-generic"; - WATCHOS_DEPLOYMENT_TARGET = 9.0; + WATCHOS_DEPLOYMENT_TARGET = 26.2; }; name = Release; }; @@ -3016,14 +3031,6 @@ minimumVersion = 1.0.0; }; }; - 93F8064E27AE7A820035A6B0 /* XCRemoteSwiftPackageReference "SideMenu" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/jonkykong/SideMenu.git"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 6.5.0; - }; - }; DA2C4FD32B4F573300D1C533 /* XCRemoteSwiftPackageReference "SDWebImageSVGCoder" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/SDWebImage/SDWebImageSVGCoder.git"; @@ -3126,11 +3133,6 @@ package = 93F8064827AE7A2E0035A6B0 /* XCRemoteSwiftPackageReference "FlexColorPicker" */; productName = FlexColorPicker; }; - 93F8064F27AE7A830035A6B0 /* SideMenu */ = { - isa = XCSwiftPackageProductDependency; - package = 93F8064E27AE7A820035A6B0 /* XCRemoteSwiftPackageReference "SideMenu" */; - productName = SideMenu; - }; DA10161A2DC7BAE500552D14 /* SFSafeSymbols */ = { isa = XCSwiftPackageProductDependency; package = DA3B75AC2C59729200E219AB /* XCRemoteSwiftPackageReference "SFSafeSymbols" */; diff --git a/openHAB.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/openHAB.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 626e199d7..54e0836b8 100644 --- a/openHAB.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/openHAB.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "510fece68ecb3b20f88be6a202204737d9bb1b0c487b784869bdd79b26798df4", + "originHash" : "8569e30a45db15e05c7c1667e727a277e8587c9ef61be4f1f5677da0a5122781", "pins" : [ { "identity" : "abseil-cpp-binary", @@ -199,6 +199,15 @@ "version" : "509.1.1" } }, + { + "identity" : "swift-timeout", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swhitty/swift-timeout.git", + "state" : { + "revision" : "4efb73b593d5553b90766d531db701ecf2306237", + "version" : "0.4.1" + } + }, { "identity" : "swiftformatplugin", "kind" : "remoteSourceControl", @@ -225,6 +234,15 @@ "revision" : "c0ff6c65bfc00e6a707957cb7069988c5cde2a30", "version" : "10.0.2" } + }, + { + "identity" : "webui", + "kind" : "remoteSourceControl", + "location" : "https://github.com/cybozu/WebUI.git", + "state" : { + "revision" : "d1408d9ad8056f4619b71f06a0b0ba1ff553f3a3", + "version" : "4.2.1" + } } ], "version" : 3 diff --git a/openHAB.xcworkspace/xcshareddata/swiftpm/Package.resolved b/openHAB.xcworkspace/xcshareddata/swiftpm/Package.resolved index c733407c2..2b8858e1f 100644 --- a/openHAB.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/openHAB.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "e00e7275c1118890f486b08d2cb2ccbbc2edf613bb88e9fb2bbb7d6f91e0df90", + "originHash" : "35128bd183a1e426d366a14b50e7fee6ab4c3c11da0a5f47b4b833d0ce7e95d9", "pins" : [ { "identity" : "abseil-cpp-binary", @@ -172,15 +172,6 @@ "version" : "7.0.0" } }, - { - "identity" : "sidemenu", - "kind" : "remoteSourceControl", - "location" : "https://github.com/jonkykong/SideMenu.git", - "state" : { - "revision" : "8bd4fd128923cf5494fa726839af8afe12908ad9", - "version" : "6.5.0" - } - }, { "identity" : "swift-collections", "kind" : "remoteSourceControl", diff --git a/openHAB/AppDelegate.swift b/openHAB/AppDelegate.swift index c0b672d6c..f1e05cbed 100644 --- a/openHAB/AppDelegate.swift +++ b/openHAB/AppDelegate.swift @@ -18,6 +18,7 @@ import OpenHABCore import os.log import SDWebImageSVGCoder import SwiftMessages +import SwiftUI import UIKit @preconcurrency import UserNotifications import WatchConnectivity @@ -63,6 +64,13 @@ class AppDelegate: UIResponder, UIApplicationDelegate { UNUserNotificationCenter.current().delegate = notificationDelegate + // Replace storyboard root with SwiftUI TabView + let rootView = OpenHABTabRootView() + let hostingController = UIHostingController(rootView: rootView) + window = UIWindow(frame: UIScreen.main.bounds) + window?.rootViewController = hostingController + window?.makeKeyAndVisible() + Logger.appDelegate.info("didFinishLaunchingWithOptions ended") // Defer non-essential initialization to after first frame renders diff --git a/openHAB/NotificationCenterDelegateImpl.swift b/openHAB/NotificationCenterDelegateImpl.swift index 79efe2efb..3cba3b45a 100644 --- a/openHAB/NotificationCenterDelegateImpl.swift +++ b/openHAB/NotificationCenterDelegateImpl.swift @@ -138,16 +138,19 @@ final class NotificationCenterDelegateImpl: NSObject, UNUserNotificationCenterDe SwiftMessages.hideAll() } - // ✅ Ensure this runs on the MainActor @MainActor func notifyNotificationListeners(action: String?, cloudUserId: String? = nil) { // Wake up screen saver immediately on incoming notification interaction NotificationCenter.default.post(name: .wakeScreenSaver, object: nil) - if let navigationController = AppDelegate.appDelegate.window?.rootViewController as? UINavigationController, - let rootViewController = navigationController.viewControllers.first as? OpenHABRootViewController { - rootViewController.handleNotification(action: action, cloudUserId: cloudUserId) - } + var userInfo: [String: Any] = [:] + if let action { userInfo["action"] = action } + if let cloudUserId { userInfo["cloudUserId"] = cloudUserId } + NotificationCenter.default.post( + name: .openHABHandleNotificationAction, + object: nil, + userInfo: userInfo + ) } } diff --git a/openHAB/SwiftUI/DrawerView.swift b/openHAB/SwiftUI/DrawerView.swift deleted file mode 100644 index 84c3fb8d7..000000000 --- a/openHAB/SwiftUI/DrawerView.swift +++ /dev/null @@ -1,288 +0,0 @@ -// Copyright (c) 2010-2026 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 Combine -import Kingfisher -import OpenHABCore -import os.log -import SafariServices -import SFSafeSymbols -import SwiftUI - -class CurrentViewState: ObservableObject { - @Published var isWebViewActive = false -} - -enum DrawerViewError: Error, CustomDebugStringConvertible { - case noRootURL - - var debugDescription: String { - switch self { - case .noRootURL: - "No root URL" - } - } -} - -// Display the connected URL -struct ConnectionView: View { - @StateObject private var networkTracker = MainActorNetworkTracker.shared - - var body: some View { - HStack { - if let activeConnection = networkTracker.activeConnection { - Image(systemSymbol: .cloudFill) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 20, height: 20) - Text(activeConnection.configuration.url).font(.footnote) - } else { - Image(systemSymbol: .exclamationmarkIcloudFill) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 20, height: 20) - Text("connecting").font(.footnote) - } - } - } -} - -struct DrawerView: View { - @State private var sitemaps: [OpenHABSitemap] = [] - @State private var uiTiles: [OpenHABUiTile] = [] - @State private var selectedSection: Int? - @State private var connectedUrl = "Not connected" // Default label text - - @EnvironmentObject private var networkTracker: MainActorNetworkTracker - @EnvironmentObject private var currentViewState: CurrentViewState - - var onDismiss: (TargetController) -> Void - @Environment(\.dismiss) private var dismiss - - @ScaledMetric var iconWidth = 20.0 - - @State private var sitemapForWatch: String? - - var mainSection: some View { - Section(header: Text("Main")) { - menuEntry(image: Image("openHABIcon").resizable(), goTo: .webview) { - HStack { - Text("Home").accessibilityIdentifier("Home") - if currentViewState.isWebViewActive { - Spacer() - Image(systemSymbol: .arrowClockwise) - .accessibilityHidden(true) - } - } - } - } - } - - var tilesSection: some View { - Section(header: Text("Tiles")) { - ForEach(uiTiles, id: \.url) { tile in - menuEntry( - image: ImageView(url: tile.imageUrl), - goTo: .tile(tile.url) - ) { - Text(tile.name) - } - } - } - } - - var sitemapsSection: some View { - Section(header: Text("Sitemaps")) { - ForEach(sitemaps, id: \.name) { sitemap in - menuEntry( - image: sitemapIcon(for: sitemap), - goTo: .sitemap(sitemap.name) - ) { - HStack { - Text(sitemap.label) - if sitemap.name == sitemapForWatch { - Spacer() - Image(systemSymbol: .applewatchWatchface) - } - } - } - .onTapGesture(count: 2) { toggleWatchSitemap(sitemap) } - } - } - } - - var systemSection: some View { - Section(header: Text("System")) { - systemMenuEntry(image: .gear, text: "settings", goTo: .settings) - if Preferences.shared.getNotificationConnection() != nil, - !Preferences.shared.currentHomePreferences.demomode { - systemMenuEntry(image: .bell, text: "notifications", goTo: .notifications) - } - systemMenuEntry(image: .house, text: "Manage Homes", goTo: .homeSelection) - } - } - - var body: some View { - VStack { - List { - mainSection - sitemapsSection - tilesSection - systemSection - } - .listStyle(.inset) - - Spacer() - ConnectionView() - .padding(.bottom, 5) - } - .listStyle(.inset) - .task { - let activeConnection = networkTracker.activeConnection - await updateSitemapsAndUITiles(activeConnection: activeConnection) - sitemapForWatch = Preferences.shared.currentHomePreferences.sitemapForWatch - } - .onReceive(networkTracker.$activeConnection) { activeConnection in - Task { - await updateSitemapsAndUITiles(activeConnection: activeConnection) - } - } - } - - private func menuEntry(image: Image, text: Text, goTo target: TargetController) -> some View { - Button { - dismiss() - onDismiss(target) - } label: { - HStack { - image - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: iconWidth, height: iconWidth) - text - } - } - .buttonStyle(.plain) - } - - private func menuEntry(image: some View, - goTo target: TargetController, - @ViewBuilder label: () -> some View) -> some View { - Button { - dismiss() - onDismiss(target) - } label: { - HStack { - image - .aspectRatio(contentMode: .fit) - .frame(width: iconWidth, height: iconWidth) - label() - } - .contentShape(Rectangle()) // entire row tappable - } - .buttonStyle(.plain) - } - - func systemMenuEntry(image: SFSymbol, text: String, goTo target: TargetController) -> some View { - menuEntry(image: Image(systemSymbol: image), goTo: target) { - Text(LocalizedStringKey(text)) - .accessibilityLabel(text) - } - } - - func sitemapIcon(for sitemap: OpenHABSitemap) -> some View { - Group { - if sitemap.icon.isEmpty { - Image("openHABIcon").resizable() - } else { - let url = Endpoint.iconForDrawer( - rootUrl: networkTracker.activeConnection?.configuration.url ?? "", - icon: sitemap.icon - ).url - KFImage(url) - .placeholder { Image("openHABIcon").resizable() } - .resizable() - } - } - .aspectRatio(contentMode: .fit) - } - - func toggleWatchSitemap(_ sitemap: OpenHABSitemap) { - Preferences.shared.modifyActiveHome { prefs in - if sitemap.name == sitemapForWatch { - sitemapForWatch = nil - prefs.sitemapForWatch = "" - prefs.sitemapForWatchLabel = "" - } else { - sitemapForWatch = sitemap.name - prefs.sitemapForWatch = sitemap.name - prefs.sitemapForWatchLabel = sitemap.label - } - } - } - - private func updateSitemapsAndUITiles(activeConnection: ConnectionInfo?) async { - guard let activeConnection else { return } - - do { - let openAPIService = try OpenAPIService(connectionConfiguration: activeConnection.configuration) - - do { - sitemaps = try await openAPIService.openHABSitemaps() - if sitemaps.last?.name == "_default", sitemaps.count > 1 { - sitemaps = Array(sitemaps.dropLast()) - } - let sortSitemapsBy = Preferences.shared.currentHomePreferences.sortSitemapsBy - switch SortSitemapsOrder(rawValue: sortSitemapsBy) ?? .label { - case .label: - sitemaps.sort { $0.label < $1.label } - case .name: - sitemaps.sort { $0.name < $1.name } - } - - } catch { - Logger.drawerView.error("Failed to fetch sitemaps: \(error.localizedDescription)") - sitemaps = [] - } - - do { - uiTiles = try await openAPIService.getUITiles() - Logger.drawerView.info("Fetched UI tiles successfully") - } catch { - Logger.drawerView.error("Failed to fetch UI tiles: \(error.localizedDescription)") - uiTiles = [] - } - - } catch { - Logger.drawerView.error("Failed to initialize OpenAPIService: \(error.localizedDescription)") - sitemaps = [] - uiTiles = [] - } - } -} - -#Preview("WebView Active") { - let networkTracker = MainActorNetworkTracker.shared - let currentViewState = CurrentViewState() - currentViewState.isWebViewActive = true - return DrawerView { _ in } - .environmentObject(networkTracker) - .environmentObject(currentViewState) -} - -#Preview("WebView Inactive") { - let networkTracker = MainActorNetworkTracker.shared - let currentViewState = CurrentViewState() - currentViewState.isWebViewActive = false - return DrawerView { _ in } - .environmentObject(networkTracker) - .environmentObject(currentViewState) -} diff --git a/openHAB/SwiftUI/RootView/AppServicesViewModel.swift b/openHAB/SwiftUI/RootView/AppServicesViewModel.swift new file mode 100644 index 000000000..75a0a48bb --- /dev/null +++ b/openHAB/SwiftUI/RootView/AppServicesViewModel.swift @@ -0,0 +1,571 @@ +// Copyright (c) 2010-2026 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 AVFoundation +import Combine +import FirebaseCrashlytics +import Kingfisher +import OpenHABCore +import os.log +import SafariServices +import SwiftUI + +enum TargetController { + case webview + case settings + case sitemap(String) + case notifications + case browser(String) + case tile(String) + case homeSelection +} + +enum NavigationCommand: Equatable { + case switchToWebView(path: String?) + case switchToSitemap(name: String, widgetId: String?) +} + +@MainActor +class AppServicesViewModel: ObservableObject { + // MARK: - Published state + + @Published var certificateAlert: CertificateAlertState? + @Published var crashReportAlert = false + @Published var navigationCommand: NavigationCommand? + + // MARK: - Private state + + private var cancellables = Set() + private var streamTask: Task? + private var apsDeviceToken: String? + private var apsDeviceId: String? + private var apsDeviceName: String? + private var activeConnection: ConnectionInfo? + private let synthesizer = AVSpeechSynthesizer() + + struct CertificateAlertState: Identifiable { + let id = UUID() + let title: String + let message: String + let delegate: HTTPClientDelegate + } + + init() { + setupTracker() + startSSEListening() + setupCrashReportCheck() + setupNotificationHandling() + + NotificationCenter.default.addObserver( + forName: NSNotification.Name("apsRegistered"), + object: nil, + queue: nil + ) { [weak self] note in + let deviceToken = note.userInfo?["deviceToken"] as? String + let deviceId = note.userInfo?["deviceId"] as? String + let deviceName = note.userInfo?["deviceName"] as? String + Task { @MainActor in + self?.handleApsRegistration(deviceToken: deviceToken, deviceId: deviceId, deviceName: deviceName) + } + } + } + + // MARK: - SSE + + private func startSSEListening() { + Task { + await ItemEventStream.startMonitoringNetwork() + } + Logger.viewController.debug("Starting SSE") + streamTask = Task { [weak self] in + guard let self else { return } + for await msg in await ItemEventStream.shared.stream() { + await MainActor.run { self.handleSSEMessage(msg) } + } + } + } + + private func handleSSEMessage(_ msg: StreamOutput) { + switch msg { + case .connected: + Logger.viewController.debug("SSE Connected") + case let .disconnected(err): + Logger.viewController.debug("SSE Disconnected: \(err?.localizedDescription ?? "nil")") + case let .event(sm): + switch sm { + case let .state(item, state): + Logger.viewController.debug("SSE Item \(item): \(state)") + handleNotificationInternal(state) + case let .ready(uuid, _): + Logger.viewController.debug("SSE Session UUID: \(uuid)") + case let .unknown(raw): + Logger.viewController.debug("SSE Unknown: \(raw)") + default: + break + } + } + } + + // MARK: - Network Tracker + + private func setupTracker() { + let serverInfo = Preferences.shared.$currentHomePreferences + + NotificationCenter.default.addObserver( + forName: .evaluateServerTrust, + object: nil, + queue: nil + ) { [weak self] notification in + guard + let summary = notification.userInfo?["summary"] as? String, + let domain = notification.userInfo?["domain"] as? String, + let delegate = notification.object as? HTTPClientDelegate + else { return } + Task { @MainActor in + self?.handleCertificateTrust( + summary: summary, + domain: domain, + delegate: delegate, + messageTemplateKey: "ssl_certificate_invalid" + ) + } + } + + NotificationCenter.default.addObserver( + forName: .evaluateCertificateMismatch, + object: nil, + queue: nil + ) { [weak self] notification in + guard + let summary = notification.userInfo?["summary"] as? String, + let domain = notification.userInfo?["domain"] as? String, + let delegate = notification.object as? HTTPClientDelegate + else { return } + Task { @MainActor in + self?.handleCertificateTrust( + summary: summary, + domain: domain, + delegate: delegate, + messageTemplateKey: "ssl_certificate_no_match" + ) + } + } + + NotificationCenter.default.addObserver( + forName: .acceptedServerCertificatesChanged, + object: nil, + queue: nil + ) { _ in + Task { @MainActor in + await WatchMessageService.singleton.syncPreferencesToWatch() + await NetworkTracker.shared.restartTracking() + } + } + + serverInfo.debounce(for: .milliseconds(500), scheduler: RunLoop.main) + .sink { homeSettings in + let localConnectionConfig = homeSettings.localConnectionConfig + let remoteConnectionConfig = homeSettings.remoteConnectionConfig + let demomode = homeSettings.demomode + let sseCommandItem = homeSettings.sseCommandItem + + Task { + if demomode { + await NetworkTracker.shared.startTracking(connectionConfigurations: [ + ConnectionConfiguration( + url: "https://demo.openhab.org", + username: "", + password: "", + priority: 0 + ) + ]) + } else { + await NetworkTracker.shared.startTracking(connectionConfigurations: [ + localConnectionConfig, + remoteConnectionConfig + ]) + await ItemEventStream.trackItems(sseCommandItem.isEmpty ? [] : [sseCommandItem]) + } + } + } + .store(in: &cancellables) + + MainActorNetworkTracker.shared.$activeConnection + .receive(on: DispatchQueue.main) + .sink { [weak self] activeConnection in + if let activeConnection { + self?.activeConnection = activeConnection + } + } + .store(in: &cancellables) + } + + // MARK: - Certificate Trust + + private func handleCertificateTrust(summary: String, domain: String, delegate: HTTPClientDelegate, messageTemplateKey: String) { + let title = NSLocalizedString("ssl_certificate_warning", comment: "") + let message = String(format: NSLocalizedString(messageTemplateKey, comment: ""), summary, domain) + certificateAlert = CertificateAlertState(title: title, message: message, delegate: delegate) + } + + func certificateAlertAction(_ result: CertificateEvaluateResult) { + certificateAlert?.delegate.completeEvaluation(result) + certificateAlert = nil + } + + // MARK: - Crash Report + + private func setupCrashReportCheck() { + if Crashlytics.crashlytics().didCrashDuringPreviousExecution(), !Preferences.shared.sendCrashReports { + crashReportAlert = true + } + } + + func enableCrashReporting() { + Preferences.shared.sendCrashReports = true + Crashlytics.crashlytics().sendUnsentReports() + } + + func deleteCrashReports() { + Crashlytics.crashlytics().deleteUnsentReports() + } + + // MARK: - APS Registration + + private func handleApsRegistration(deviceToken: String?, deviceId: String?, deviceName: String?) { + Logger.viewController.info("handleApsRegistration") + apsDeviceToken = deviceToken + apsDeviceId = deviceId + apsDeviceName = deviceName + subscribeToOpenhabConnectionChanges() + } + + private func subscribeToOpenhabConnectionChanges() { + struct UuidWithConnection: Hashable, Equatable { + let uuid: UUID + let connection: ConnectionConfiguration + } + + let storedOpenHabConnections = Preferences.shared.$storedHomes + .debounce(for: .seconds(1), scheduler: RunLoop.main) + .map { updatedPreferences in + Set(updatedPreferences.compactMap { storedWithUuid in + let (uuid, homeConfig) = storedWithUuid + guard let connection = Preferences.shared.getNotificationConnection(of: homeConfig) else { return nil } + return UuidWithConnection(uuid: uuid, connection: connection) + }) + } + + let connectionsWithPreviousValues = storedOpenHabConnections + .scan((previous: Set(), current: Set())) { previous, current in + (previous: previous.current, current: current) + } + + let differences = connectionsWithPreviousValues.map { (previous, current) in + (newValues: current.subtracting(previous), deletedValues: previous.subtracting(current)) + } + + let openhabConnectionSubscription = differences.sink { [weak self] diff in + Logger.viewController.info("openhabConnectionSubscription updated") + for newHome in diff.newValues { + Logger.viewController.info("openhabConnectionSubscription uuid \(newHome.uuid) registering for push notifications ") + self?.registerHome(uuid: newHome.uuid, connection: newHome.connection) + } + for deletedHome in diff.deletedValues { + Logger.viewController.warning("APNS Deregistration is missing (wanted to deregister \(deletedHome.connection.url))") + } + } + + cancellables.insert(openhabConnectionSubscription) + } + + private func registerHome(uuid: UUID, connection: ConnectionConfiguration) { + guard let deviceId = apsDeviceId, + let deviceToken = apsDeviceToken, + let deviceName = apsDeviceName else { + Logger.viewController.fault("Cannot register homes for push notifications, no notification registration data available") + return + } + Logger.viewController.info("Registering notifications with \(connection.url)") + _ = registerHome(uuid, connection, deviceToken, deviceId, deviceName) + } + + private func registerHome(_ uuid: UUID, _ config: ConnectionConfiguration, _ deviceToken: String, _ deviceId: String, _ deviceName: String) -> Task { + Task { + do { + let client = HTTPClient(connectionConfiguration: config) + if let cloudUserId = try await client.register(prefsURL: config.url, deviceToken: deviceToken, deviceId: deviceId, deviceName: deviceName) { + Preferences.shared.setCloudUserId(cloudUserId, for: uuid) + Logger.viewController.info("my.openHAB registration succeeded with cloudUserId \(cloudUserId)") + } + Logger.viewController.info("my.openHAB registration succeeded without cloudUserId") + } catch { + Logger.viewController.error("my.openHAB registration failed \(error.localizedDescription)") + } + } + } + + // MARK: - Notification Handling + + private func setupNotificationHandling() { + NotificationCenter.default.addObserver( + forName: .openHABHandleNotificationAction, + object: nil, + queue: nil + ) { [weak self] notification in + let action = notification.userInfo?["action"] as? String + let cloudUserId = notification.userInfo?["cloudUserId"] as? String + Task { @MainActor in + self?.handleNotification(action: action, cloudUserId: cloudUserId) + } + } + } + + func handleNotification(action: String?, cloudUserId: String?) { + guard let action else { return } + + Logger.viewController.info("handleNotification cloudUserId: \(cloudUserId ?? "")") + + Task { + if let cloudUserId, let targetHome = Preferences.shared.storedHome(forCloudUserId: cloudUserId), Preferences.shared.currentHomePreferences.remoteConnectionConfig.cloudUserId != cloudUserId { + await NetworkTracker.shared.stopTracking() + Logger.viewController.info("Switching to home \(targetHome.id)") + Preferences.shared.switchActiveHome(to: targetHome.id) + } + await NetworkTracker.shared.startTracking(connectionConfigurations: + [ + Preferences.shared.currentHomePreferences.localConnectionConfig, + Preferences.shared.currentHomePreferences.remoteConnectionConfig + ] + ) + _ = await NetworkTracker.shared.waitForActiveConnection() + handleNotificationInternal(action) + } + } + + private func handleNotificationInternal(_ action: String?) { + Logger.viewController.info("handleNotificationInternal: \(action ?? "")") + + guard let action else { return } + let actionParts = action.split(separator: ":") + let cmd = actionParts.dropFirst().joined(separator: ":") + + switch actionParts[0] { + case "ui": + uiCommandAction(cmd) + case "command": + sendCommandAction(cmd) + case "http": + httpCommandAction(action) + case "app": + appCommandAction(cmd) + case "rule": + ruleCommandAction(cmd) + case "device": + deviceAction(cmd) + default: + return + } + } + + private func uiCommandAction(_ command: String) { + Logger.viewController.info("navigateCommandAction: \(command)") + let regexPattern = /^(\/basicui\/app\\?.*|\/.*|.*)$/ + if let firstMatch = command.firstMatch(of: regexPattern) { + let path = String(firstMatch.1) + Logger.viewController.info("navigateCommandAction path: \(path)") + if path.starts(with: "/basicui/app?") { + Logger.viewController.info("Navigating to sitemap target") + let defaultSitemap = Preferences.shared.currentHomePreferences.defaultSitemap + guard let urlComponents = URLComponents(string: path) else { + Logger.viewController.warning("No parameters for specifying sitemap or widget to navigate to") + navigationCommand = .switchToSitemap(name: defaultSitemap, widgetId: nil) + return + } + let queryItems = urlComponents.queryItems + let sitemap = queryItems?.first { $0.name == "sitemap" }?.value + let widgetId = queryItems?.first { $0.name == "w" }?.value + navigationCommand = .switchToSitemap(name: sitemap ?? defaultSitemap, widgetId: widgetId) + } else { + Logger.viewController.info("Navigating to webview target") + if path.starts(with: "/") { + navigationCommand = .switchToWebView(path: path) + } else { + navigationCommand = .switchToWebView(path: path) + } + } + } else { + Logger.viewController.error("Invalid regex: \(command)") + } + } + + private func sendCommandAction(_ action: String) { + let components = action.split(separator: ":") + guard components.count == 2 else { return } + + let itemName = String(components[0]) + let itemCommand = String(components[1]) + let deviceId = UIDevice.current.identifierForVendor?.uuidString + Task { + do { + Logger.viewController.info("Sending command") + try await NetworkTracker.shared.send(to: itemName, command: itemCommand, deviceId: deviceId) + } catch NetworkTrackerError.noActiveConnection { + displayErrorNotification("Could not find server") + } catch { + displayErrorNotification("Failed to establish a connection: \(error.localizedDescription)") + Logger.viewController.error("Could not send data \(error.localizedDescription)") + } + } + } + + private func displayErrorNotification(_ message: String) { + let content = UNMutableNotificationContent() + content.title = "Could not send command" + content.body = message + content.sound = UNNotificationSound.default + let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil) + UNUserNotificationCenter.current().add(request) + } + + private func httpCommandAction(_ command: String) { + if let url = URL(string: command) { + let vc = SFSafariViewController(url: url) + UIApplication.shared.firstKeyWindow?.rootViewController?.present(vc, animated: true) + } + } + + private func appCommandAction(_ command: String) { + let pairs = command.split(separator: ",") + for pair in pairs { + let keyValue = pair.split(separator: "=", maxSplits: 1) + if keyValue[0] == "ios" { + if let url = URL(string: String(keyValue[1])) { + Logger.viewController.error("appCommandAction opening \(String(keyValue[0])) \(String(keyValue[1]))") + UIApplication.shared.open(url) + return + } + } + } + } + + private func deviceAction(_ action: String) { + let cmdParts = action.split(separator: ":") + if cmdParts.isEmpty { return } + let command = cmdParts[0].lowercased() + let arg1 = cmdParts.count > 1 ? cmdParts[1].lowercased() : "" + switch command { + case "screensaver": + switch arg1 { + case "activate": + NotificationCenter.default.post(name: .activateScreenSaver, object: nil) + case "disable": + NotificationCenter.default.post(name: .disableScreenSaver, object: nil) + case "wake": + NotificationCenter.default.post(name: .wakeScreenSaver, object: nil) + default: + break + } + case "idletimer": + switch arg1 { + case "enable": + UIApplication.shared.isIdleTimerDisabled = false + case "disable": + UIApplication.shared.isIdleTimerDisabled = true + default: + break + } + case "brightness": + if let value = Double(arg1) { + let target = min(max(value, 0.0), 1.0) + UIScreen.main.brightness = target + } + case "tts": + func normalizeVoiceName(from input: String) -> String { + input + .lowercased() + .components(separatedBy: CharacterSet.alphanumerics.inverted) + .joined() + } + + let utterance = AVSpeechUtterance(string: arg1) + if cmdParts.count > 3 { + Logger.viewController.debug("Filtering voice \(cmdParts[2]) \(cmdParts[3])") + let voice = AVSpeechSynthesisVoice.speechVoices().filter { $0.language.lowercased() == cmdParts[2].lowercased() && normalizeVoiceName(from: $0.name) == normalizeVoiceName(from: String(cmdParts[3])) } + if !voice.isEmpty { + Logger.viewController.debug("Setting custom voice \(voice[0].name)") + utterance.voice = voice[0] + } + } else if cmdParts.count > 2 { + utterance.voice = AVSpeechSynthesisVoice(language: String(cmdParts[2])) + } + synthesizer.speak(utterance) + default: + break + } + } + + private func ruleCommandAction(_ command: String) { + let components = command.split(separator: ":", maxSplits: 2) + guard !components.isEmpty else { + Logger.viewController.warning("No rule to execute found in action") + return + } + + let uuid = String(components[0]) + let propertiesString = if components.count > 1 { String(components[1]) } else { "" } + + let propertyPairs = propertiesString.split(separator: ",") + var properties: [String: String] = [:] + + for pair in propertyPairs { + let keyValue = pair.split(separator: "=", maxSplits: 1) + if keyValue.count == 2 { + let key = String(keyValue[0]) + let value = String(keyValue[1]) + properties[key] = value + } + } + Task { + do { + Logger.viewController.error("Sending command") + try await NetworkTracker.shared.runNow(ruleUID: uuid, payload: properties) + Logger.viewController.info("Request succeeded") + } catch let error as NetworkTrackerError { + displayErrorNotification("\(error.localizedDescription)") + } catch { + Logger.viewController.error("Could not send data \(error.localizedDescription)") + displayErrorNotification("Request to server failed: \(error.localizedDescription)") + } + } + } +} + +// MARK: - Kingfisher authentication + +extension AppServicesViewModel: AuthenticationChallengeResponsible { + nonisolated func downloader(_ downloader: ImageDownloader, + didReceive challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { + await onReceiveSessionChallenge(with: challenge) + } + + nonisolated func downloader(_ downloader: ImageDownloader, + task: URLSessionTask, + didReceive challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { + await onReceiveSessionTaskChallenge(with: challenge) + } +} + +// MARK: - Notification name for routing + +extension Notification.Name { + static let openHABHandleNotificationAction = Notification.Name("openHABHandleNotificationAction") +} diff --git a/openHAB/SwiftUI/RootView/MainWebTab.swift b/openHAB/SwiftUI/RootView/MainWebTab.swift new file mode 100644 index 000000000..d7822b57d --- /dev/null +++ b/openHAB/SwiftUI/RootView/MainWebTab.swift @@ -0,0 +1,23 @@ +// Copyright (c) 2010-2026 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 OpenHABCore +import SwiftUI + +struct MainWebTab: UIViewControllerRepresentable { + let webViewController: OpenHABWebViewController + + func makeUIViewController(context: Context) -> OpenHABWebViewController { + webViewController + } + + func updateUIViewController(_ uiViewController: OpenHABWebViewController, context: Context) {} +} diff --git a/openHAB/SwiftUI/RootView/OpenHABTabRootView.swift b/openHAB/SwiftUI/RootView/OpenHABTabRootView.swift new file mode 100644 index 000000000..9e222e081 --- /dev/null +++ b/openHAB/SwiftUI/RootView/OpenHABTabRootView.swift @@ -0,0 +1,148 @@ +// Copyright (c) 2010-2026 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 Kingfisher +import OpenHABCore +import os.log +import SafariServices +import SFSafeSymbols +import SwiftUI + +enum AppTab: String, CaseIterable, Hashable { + case main + case sitemaps + case tiles + case system +} + +struct OpenHABTabRootView: View { + @StateObject private var appServices = AppServicesViewModel() + @StateObject private var networkTracker = MainActorNetworkTracker.shared + + @State private var selectedTab: AppTab + @State private var isDemoMode: Bool + @State private var sitemapsTab = SitemapsTab() + + private let webViewController = OpenHABWebViewController() + + init() { + let saved = Preferences.shared.currentHomePreferences.lastSelectedTab + _selectedTab = State(initialValue: AppTab(rawValue: saved) ?? .main) + _isDemoMode = State(initialValue: Preferences.shared.currentHomePreferences.demomode) + + #if DEBUG + if ProcessInfo.processInfo.environment["UITest"] != nil { + Preferences.shared.modifyActiveHome { homePreferences in + homePreferences.demomode = true + } + } + #endif + } + + var body: some View { + TabView(selection: $selectedTab) { + Tab("Home", systemImage: "house", value: AppTab.main) { + MainWebTab(webViewController: webViewController) + .ignoresSafeArea() + } + + Tab("Sitemaps", systemImage: "map", value: AppTab.sitemaps) { + sitemapsTab + } + + Tab("Tiles", systemImage: "square.grid.2x2", value: AppTab.tiles) { + TilesTab() + } + + Tab("System", systemImage: "gear", value: AppTab.system) { + SystemTab() + } + } + .environmentObject(networkTracker) + .onChange(of: selectedTab) { oldTab, newTab in + Preferences.shared.modifyActiveHome { prefs in + prefs.lastSelectedTab = newTab.rawValue + } + } + .onAppear { + ImageDownloader.default.authenticationChallengeResponder = appServices + // Switch to sitemaps in demo mode + if Preferences.shared.currentHomePreferences.demomode { + selectedTab = .sitemaps + sitemapsTab.navigateToSitemap(name: "demo", widgetId: nil) + } + } + .onReceive(appServices.$navigationCommand) { command in + guard let command else { return } + handleNavigationCommand(command) + // Reset the command after handling + Task { @MainActor in + appServices.navigationCommand = nil + } + } + // Certificate trust alert + .alert( + appServices.certificateAlert?.title ?? "", + isPresented: Binding( + get: { appServices.certificateAlert != nil }, + set: { if !$0 { appServices.certificateAlertAction(.deny) } } + ) + ) { + Button("Always") { + appServices.certificateAlertAction(.permitAlways) + } + Button("Once") { + appServices.certificateAlertAction(.permitOnce) + } + Button("Deny", role: .cancel) { + appServices.certificateAlertAction(.deny) + } + } message: { + Text(appServices.certificateAlert?.message ?? "") + } + // Crash report alert + .alert( + NSLocalizedString("crash_detected", comment: "").capitalized, + isPresented: $appServices.crashReportAlert + ) { + Button(NSLocalizedString("activate", comment: "")) { + appServices.enableCrashReporting() + } + Button(NSLocalizedString("privacy_policy", comment: "")) { + let vc = SFSafariViewController(url: URL.privacyPolicy) + vc.configuration.barCollapsingEnabled = true + UIApplication.shared.firstKeyWindow?.rootViewController?.present(vc, animated: true) + } + Button(NSLocalizedString("cancel", comment: ""), role: .cancel) { + appServices.deleteCrashReports() + } + } message: { + Text(NSLocalizedString("crash_reporting_info", comment: "")) + } + } + + private func handleNavigationCommand(_ command: NavigationCommand) { + switch command { + case let .switchToWebView(path): + selectedTab = .main + if let path { + if path.starts(with: "/") { + webViewController.loadWebView(force: true, path: path) + } else { + webViewController.navigateCommand(path) + } + } + case let .switchToSitemap(name, widgetId): + selectedTab = .sitemaps + sitemapsTab.navigateToSitemap(name: name, widgetId: widgetId) + } + } +} diff --git a/openHAB/SwiftUI/RootView/SafariView.swift b/openHAB/SwiftUI/RootView/SafariView.swift new file mode 100644 index 000000000..f33a35940 --- /dev/null +++ b/openHAB/SwiftUI/RootView/SafariView.swift @@ -0,0 +1,25 @@ +// Copyright (c) 2010-2026 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 SafariServices +import SwiftUI + +struct SafariView: UIViewControllerRepresentable { + let url: URL + + func makeUIViewController(context: Context) -> SFSafariViewController { + let config = SFSafariViewController.Configuration() + config.entersReaderIfAvailable = true + return SFSafariViewController(url: url, configuration: config) + } + + func updateUIViewController(_ uiViewController: SFSafariViewController, context: Context) {} +} diff --git a/openHAB/SwiftUI/RootView/SitemapsTab.swift b/openHAB/SwiftUI/RootView/SitemapsTab.swift new file mode 100644 index 000000000..ed0303d85 --- /dev/null +++ b/openHAB/SwiftUI/RootView/SitemapsTab.swift @@ -0,0 +1,185 @@ +// Copyright (c) 2010-2026 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 Kingfisher +import OpenHABCore +import os.log +import SFSafeSymbols +import SwiftUI + +struct SitemapsTab: View { + @State private var sitemaps: [OpenHABSitemap] = [] + @State private var selectedSitemap: String? + @State private var sitemapForWatch: String? + @StateObject private var viewModel = SitemapPageViewModel() + + @EnvironmentObject private var networkTracker: MainActorNetworkTracker + + @ScaledMetric private var iconWidth = 24.0 + + var body: some View { + NavigationStack { + Group { + if let selectedSitemap { + SitemapNavigationContent(viewModel: viewModel) + } else { + sitemapList + } + } + .navigationTitle(selectedSitemap != nil ? viewModel.pageTitle : "Sitemaps") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + if selectedSitemap != nil { + ToolbarItem(placement: .navigationBarLeading) { + Button { + self.selectedSitemap = nil + } label: { + HStack(spacing: 4) { + Image(systemSymbol: .chevronBackward) + Text("Sitemaps") + } + } + } + } + } + } + .task { + sitemapForWatch = Preferences.shared.currentHomePreferences.sitemapForWatch + await fetchSitemaps(activeConnection: networkTracker.activeConnection) + autoSelectSitemap() + } + .onReceive(networkTracker.$activeConnection) { activeConnection in + Task { + await fetchSitemaps(activeConnection: activeConnection) + } + } + } + + private var sitemapList: some View { + List { + ForEach(sitemaps, id: \.name) { sitemap in + Button { + selectSitemap(sitemap.name) + } label: { + HStack { + sitemapIcon(for: sitemap) + .frame(width: iconWidth, height: iconWidth) + Text(sitemap.label) + if sitemap.name == sitemapForWatch { + Spacer() + Image(systemSymbol: .applewatchWatchface) + } + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .onTapGesture(count: 2) { toggleWatchSitemap(sitemap) } + } + } + } + + private func selectSitemap(_ name: String) { + selectedSitemap = name + Preferences.shared.modifyActiveHome { preferences in + preferences.defaultSitemap = name + } + Task { + await viewModel.pushSitemap(name: name, path: nil) + } + } + + func navigateToSitemap(name: String, widgetId: String?) { + selectedSitemap = name + Preferences.shared.modifyActiveHome { preferences in + preferences.defaultSitemap = name + } + Task { + await viewModel.pushSitemap(name: name, path: widgetId) + } + } + + private func autoSelectSitemap() { + let defaultSitemap = Preferences.shared.currentHomePreferences.defaultSitemap + if !defaultSitemap.isEmpty, sitemaps.contains(where: { $0.name == defaultSitemap }) { + selectSitemap(defaultSitemap) + } + } + + private func fetchSitemaps(activeConnection: ConnectionInfo?) async { + guard let activeConnection else { return } + do { + let openAPIService = try OpenAPIService(connectionConfiguration: activeConnection.configuration) + var fetched = try await openAPIService.openHABSitemaps() + if fetched.last?.name == "_default", fetched.count > 1 { + fetched = Array(fetched.dropLast()) + } + let sortSitemapsBy = Preferences.shared.currentHomePreferences.sortSitemapsBy + switch SortSitemapsOrder(rawValue: sortSitemapsBy) ?? .label { + case .label: + fetched.sort { $0.label < $1.label } + case .name: + fetched.sort { $0.name < $1.name } + } + sitemaps = fetched + } catch { + Logger.drawerView.error("Failed to fetch sitemaps: \(error.localizedDescription)") + sitemaps = [] + } + } + + private func sitemapIcon(for sitemap: OpenHABSitemap) -> some View { + Group { + if sitemap.icon.isEmpty { + Image("openHABIcon").resizable() + } else { + let url = Endpoint.iconForDrawer( + rootUrl: networkTracker.activeConnection?.configuration.url ?? "", + icon: sitemap.icon + ).url + KFImage(url) + .placeholder { Image("openHABIcon").resizable() } + .resizable() + } + } + .aspectRatio(contentMode: .fit) + } + + private func toggleWatchSitemap(_ sitemap: OpenHABSitemap) { + Preferences.shared.modifyActiveHome { prefs in + if sitemap.name == sitemapForWatch { + sitemapForWatch = nil + prefs.sitemapForWatch = "" + prefs.sitemapForWatchLabel = "" + } else { + sitemapForWatch = sitemap.name + prefs.sitemapForWatch = sitemap.name + prefs.sitemapForWatchLabel = sitemap.label + } + } + } +} + +/// Inner content view that wraps SitemapPageView without its own NavigationStack +private struct SitemapNavigationContent: View { + @ObservedObject var viewModel: SitemapPageViewModel + + var body: some View { + let page = SitemapPageView(viewModel: viewModel) + if viewModel.showSearchField { + page + .searchable(text: $viewModel.searchText, prompt: Text(NSLocalizedString("search_items", comment: ""))) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + } else { + page + } + } +} diff --git a/openHAB/SwiftUI/RootView/SystemTab.swift b/openHAB/SwiftUI/RootView/SystemTab.swift new file mode 100644 index 000000000..975617d05 --- /dev/null +++ b/openHAB/SwiftUI/RootView/SystemTab.swift @@ -0,0 +1,86 @@ +// Copyright (c) 2010-2026 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 OpenHABCore +import SFSafeSymbols +import SwiftUI + +// Display the connected URL +struct ConnectionView: View { + @StateObject private var networkTracker = MainActorNetworkTracker.shared + + var body: some View { + HStack { + if let activeConnection = networkTracker.activeConnection { + Image(systemSymbol: .cloudFill) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 20, height: 20) + Text(activeConnection.configuration.url).font(.footnote) + } else { + Image(systemSymbol: .exclamationmarkIcloudFill) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 20, height: 20) + Text("connecting").font(.footnote) + } + } + } +} + +struct SystemTab: View { + var body: some View { + NavigationStack { + List { + Section { + NavigationLink { + SettingsView() + } label: { + Label { + Text(LocalizedStringKey("settings")) + } icon: { + Image(systemSymbol: .gear) + } + } + + if Preferences.shared.getNotificationConnection() != nil, + !Preferences.shared.currentHomePreferences.demomode { + NavigationLink { + NotificationsView() + } label: { + Label { + Text(LocalizedStringKey("notifications")) + } icon: { + Image(systemSymbol: .bell) + } + } + } + + NavigationLink { + HomeSelectionView() + } label: { + Label { + Text("Manage Homes") + } icon: { + Image(systemSymbol: .house) + } + } + } + } + .navigationTitle("System") + .navigationBarTitleDisplayMode(.inline) + .safeAreaInset(edge: .bottom) { + ConnectionView() + .padding(.bottom, 8) + } + } + } +} diff --git a/openHAB/SwiftUI/RootView/TilesTab.swift b/openHAB/SwiftUI/RootView/TilesTab.swift new file mode 100644 index 000000000..815727a6c --- /dev/null +++ b/openHAB/SwiftUI/RootView/TilesTab.swift @@ -0,0 +1,94 @@ +// Copyright (c) 2010-2026 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 OpenHABCore +import os.log +import SFSafeSymbols +import SwiftUI + +struct TilesTab: View { + @State private var uiTiles: [OpenHABUiTile] = [] + @State private var selectedTileURL: IdentifiableURL? + + @EnvironmentObject private var networkTracker: MainActorNetworkTracker + + @ScaledMetric private var iconWidth = 24.0 + + var body: some View { + NavigationStack { + List { + ForEach(uiTiles, id: \.url) { tile in + Button { + openTile(tile) + } label: { + HStack { + ImageView(url: tile.imageUrl) + .aspectRatio(contentMode: .fit) + .frame(width: iconWidth, height: iconWidth) + Text(tile.name) + } + } + .buttonStyle(.plain) + } + } + .navigationTitle("Tiles") + .navigationBarTitleDisplayMode(.inline) + } + .task { + await fetchTiles(activeConnection: networkTracker.activeConnection) + } + .onReceive(networkTracker.$activeConnection) { activeConnection in + Task { + await fetchTiles(activeConnection: activeConnection) + } + } + .sheet(item: $selectedTileURL) { item in + SafariView(url: item.url) + } + } + + private func openTile(_ tile: OpenHABUiTile) { + let urlString = tile.url + guard !urlString.isEmpty else { return } + + let url: URL? + if urlString.hasPrefix("http") || urlString.hasPrefix("https") { + url = URL(string: urlString) + } else { + guard let rootUrl = networkTracker.activeConnection?.configuration.url else { + Logger.viewController.error("openTileURL failed: no active connection URL") + return + } + url = Endpoint.resource(openHABRootUrl: rootUrl, path: urlString.prepare()).url + } + + if let url { + selectedTileURL = IdentifiableURL(url: url) + } + } + + private func fetchTiles(activeConnection: ConnectionInfo?) async { + guard let activeConnection else { return } + do { + let openAPIService = try OpenAPIService(connectionConfiguration: activeConnection.configuration) + uiTiles = try await openAPIService.getUITiles() + Logger.drawerView.info("Fetched UI tiles successfully") + } catch { + Logger.drawerView.error("Failed to fetch UI tiles: \(error.localizedDescription)") + uiTiles = [] + } + } +} + +private struct IdentifiableURL: Identifiable { + let id = UUID() + let url: URL +} diff --git a/openHAB/SwiftUI/SitemapView/SitemapNavigationView.swift b/openHAB/SwiftUI/SitemapView/SitemapNavigationView.swift index a72fef204..10c2156fa 100644 --- a/openHAB/SwiftUI/SitemapView/SitemapNavigationView.swift +++ b/openHAB/SwiftUI/SitemapView/SitemapNavigationView.swift @@ -18,7 +18,6 @@ struct SitemapNavigationView: View { @StateObject var viewModel = SitemapPageViewModel() @State private var isSearchPresented = false @FocusState private var isLegacySearchFocused: Bool - let onShowSideMenu: () -> Void var body: some View { NavigationStack { @@ -57,14 +56,6 @@ struct SitemapNavigationView: View { } } } - ToolbarItem(placement: .navigationBarTrailing) { - Button { - onShowSideMenu() - } label: { - Image(systemSymbol: .line3Horizontal) - .font(.title) - } - } } if viewModel.showSearchField { @@ -163,14 +154,12 @@ struct SitemapNavigationView: View { return false } - init(viewModel: SitemapPageViewModel, onShowSideMenu: @escaping () -> Void) { + init(viewModel: SitemapPageViewModel) { _viewModel = StateObject(wrappedValue: viewModel) - self.onShowSideMenu = onShowSideMenu } - init(onShowSideMenu: @escaping () -> Void = {}) { + init() { _viewModel = StateObject(wrappedValue: SitemapPageViewModel()) - self.onShowSideMenu = onShowSideMenu } } @@ -179,7 +168,5 @@ struct SitemapNavigationView: View { pageUrl: PreviewConstants.openHABSitemapPage?.link ?? "", title: PreviewConstants.openHABSitemapPage?.title ?? "Preview Page" ) - SitemapNavigationView(viewModel: previewViewModel) { - print("Show side menu tapped") - } + SitemapNavigationView(viewModel: previewViewModel) } diff --git a/openHAB/UIKit/OpenHABRootViewController.swift b/openHAB/UIKit/OpenHABRootViewController.swift deleted file mode 100644 index 1e66f1027..000000000 --- a/openHAB/UIKit/OpenHABRootViewController.swift +++ /dev/null @@ -1,1028 +0,0 @@ -// Copyright (c) 2010-2026 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 AVFoundation -import Combine -import FirebaseCrashlytics -import Foundation -import Kingfisher -import OpenHABCore -import os.log -import SafariServices -import SFSafeSymbols -import SideMenu -import SwiftMessages -import SwiftUI -import UIKit - -enum TargetController { - case webview - case settings - case sitemap(String) - case notifications - case browser(String) - case tile(String) - case homeSelection -} - -protocol ModalHandler: AnyObject { - func modalDismissed(to: TargetController) -} - -class HostingSitemapViewController: UIHostingController, OpenHABViewable { - private let viewModel: SitemapPageViewModel - - private weak var rootViewController: OpenHABRootViewController? - - init() { - let viewModel = SitemapPageViewModel() - self.viewModel = viewModel - super.init(rootView: SitemapNavigationView(viewModel: viewModel) {}) - } - - @available(*, unavailable) - @objc dynamic required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func viewDidLoad() { - super.viewDidLoad() - // Hide UIKit navigation bar since SwiftUI now handles navigation - navigationController?.setNavigationBarHidden(true, animated: false) - } - - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - // Ensure UIKit navigation bar stays hidden when transitioning from other views - navigationController?.setNavigationBarHidden(true, animated: animated) - } - - func setRootViewController(_ rootViewController: OpenHABRootViewController) { - self.rootViewController = rootViewController - // Update the closure after initialization - rootView = SitemapNavigationView(viewModel: viewModel) { [weak self] in - self?.rootViewController?.showSideMenu() - } - } - - func getSitemapTitle() -> String { - viewModel.pageTitle - } - - func viewName() -> String { "sitemap" } - - nonisolated func reloadView() { - Task { @MainActor in - await viewModel.reload() - } - } - - func pushSitemap(name: String, path: String?) async { - // Implement pushing logic into SitemapPageViewModel - await viewModel.pushSitemap(name: name, path: path) - } - - // swiftlint:disable:next function_parameter_count - func showPopupMessage(seconds: Double, - title: String, - message: String, - theme: Theme, - viewTapAction: (() -> Void)?, - buttonTitle: String, - buttonAction: (() -> Void)?) {} - - func hidePopupMessages() {} -} - -// MARK: - Search Controller Delegates - -// swiftlint:disable type_body_length -class OpenHABRootViewController: UIViewController { - var currentView: (any UIViewController & OpenHABViewable)! - var isDemoMode = false - var cancellables = Set() - private let currentViewState = CurrentViewState() - private var streamTask: Task? - - private var apsRegistrationData: [AnyHashable: Any]? - - private var networkStatusButton: UIButton = .init(type: .custom) - - private lazy var webViewController: OpenHABWebViewController = { - let storyboard = UIStoryboard(name: "Main", bundle: Bundle.main) - var viewController = storyboard.instantiateViewController(withIdentifier: "OpenHABWebViewController") as! OpenHABWebViewController - return viewController - }() - - lazy var sitemapViewController: any (UIViewController & OpenHABViewable) = { - let controller = HostingSitemapViewController() - controller.setRootViewController(self) - return controller - }() - - private var activeConnection: ConnectionInfo? - private let synthesizer = AVSpeechSynthesizer() - - override func viewDidLoad() { - super.viewDidLoad() - Logger.viewController.info("OpenHABRootViewController viewDidLoad") - setupSideMenu() - addConnectionStatusIndication() - - NotificationCenter.default.addObserver(self, selector: #selector(OpenHABRootViewController.handleApsRegistration(_:)), name: NSNotification.Name("apsRegistered"), object: nil) - - if Crashlytics.crashlytics().didCrashDuringPreviousExecution(), !Preferences.shared.sendCrashReports { - let alertController = UIAlertController(title: NSLocalizedString("crash_detected", comment: "").capitalized, message: NSLocalizedString("crash_reporting_info", comment: ""), preferredStyle: .alert) - alertController.addAction( - UIAlertAction(title: NSLocalizedString("activate", comment: ""), style: .default) { _ in - Preferences.shared.sendCrashReports = true - Crashlytics.crashlytics().sendUnsentReports() - } - ) - alertController.addAction( - UIAlertAction(title: NSLocalizedString("privacy_policy", comment: ""), style: .default) { [weak self] _ in - let webViewController = SFSafariViewController(url: URL.privacyPolicy) - webViewController.configuration.barCollapsingEnabled = true - self?.present(webViewController, animated: true) - } - ) - alertController.addAction( - UIAlertAction(title: NSLocalizedString("cancel", comment: ""), style: .default) { _ in - Crashlytics.crashlytics().deleteUnsentReports() - } - ) - present(alertController, animated: true) - } - - #if DEBUG - if ProcessInfo.processInfo.environment["UITest"] != nil { - // this is here to continue to make existing tests work, need to look at this later - Preferences.shared.modifyActiveHome { homePreferences in - homePreferences.demomode = true - } - } - // setup accessibilityIdentifiers for UITest - navigationItem.rightBarButtonItem?.accessibilityIdentifier = "HamburgerButton" - #endif - // save this so we know if its changed later - isDemoMode = Preferences.shared.currentHomePreferences.demomode - switchToSavedView() - setupTracker() - startSSEListening() - } - - override func viewWillAppear(_ animated: Bool) { - Logger.viewController.info("OpenHABRootController viewWillAppear") - super.viewWillAppear(animated) - navigationController?.navigationBar.prefersLargeTitles = false - // if we have turned demo mode off/on, reset view - if isDemoMode != Preferences.shared.currentHomePreferences.demomode { - switchToSavedView() - isDemoMode = Preferences.shared.currentHomePreferences.demomode - } - ImageDownloader.default.authenticationChallengeResponder = self - } - - private func startSSEListening() { - Task { - await ItemEventStream.startMonitoringNetwork() - } - Logger.viewController.debug("Starting SSE") - streamTask = Task { [weak self] in - guard let self else { return } - for await msg in await ItemEventStream.shared.stream() { - await MainActor.run { self.handleSSEMessage(msg) } - } - } - } - - private func handleSSEMessage(_ msg: StreamOutput) { - switch msg { - case .connected: - Logger.viewController.debug("SSE Connected") - case let .disconnected(err): - Logger.viewController.debug("SSE Disconnected: \(err?.localizedDescription ?? "nil")") - case let .event(sm): - switch sm { - case let .state(item, state): - Logger.viewController.debug("SSE Item \(item): \(state)") - handleNotificationInternal(state) - case let .ready(uuid, _): - Logger.viewController.debug("SSE Session UUID: \(uuid)") - case let .unknown(raw): - Logger.viewController.debug("SSE Unknown: \(raw)") - default: - break - } - } - } - - private func addConnectionStatusIndication() { - MainActorNetworkTracker.shared.$status - .receive(on: DispatchQueue.main) - .sink { [weak self] status in - guard let self, let currentView else { - return - } - Logger.viewController.info("OpenHABWebViewController tracker status \(status.rawValue)") - let retryButtonTitle: String = NSLocalizedString("retry", comment: "retry connection") - switch status { - case .started: - currentView.showPopupMessage( - seconds: -1, - title: NSLocalizedString("no_connection_will_reconnect", comment: ""), - message: "", - theme: .warning, - viewTapAction: nil, - buttonTitle: retryButtonTitle - ) { - Task { - await NetworkTracker.shared.restartTracking() - } - } - case .connecting: - currentView.showPopupMessage( - seconds: 60, - title: NSLocalizedString("connecting", comment: ""), - message: "", - theme: .info, - viewTapAction: nil, - buttonTitle: "", - buttonAction: nil - ) - case .connected: - currentView.hidePopupMessages() - case .stopped: - let error: String = NSLocalizedString("error", comment: "") - let no_network: String = NSLocalizedString("network_not_available", comment: "") - currentView.showPopupMessage( - seconds: -1, - title: error, - message: no_network, - theme: .error, - viewTapAction: nil, - buttonTitle: retryButtonTitle - ) { - Task { - await NetworkTracker.shared.restartTracking() - } - } - } - } - .store(in: &cancellables) - } - - private func setupTracker() { - let serverInfo = Preferences.shared.$currentHomePreferences - - // Register for certificate trust notifications - NotificationCenter.default.addObserver( - forName: .evaluateServerTrust, - object: nil, - queue: nil - ) { [weak self] notification in - guard - let summary = notification.userInfo?["summary"] as? String, - let domain = notification.userInfo?["domain"] as? String, - let delegate = notification.object as? HTTPClientDelegate - else { - return - } - - Task { @MainActor in - self?.handleCertificateTrust( - summary: summary, - domain: domain, - delegate: delegate, - messageTemplateKey: "ssl_certificate_invalid" - ) - } - } - - NotificationCenter.default.addObserver( - forName: .evaluateCertificateMismatch, - object: nil, - queue: nil - ) { [weak self] notification in - guard - let summary = notification.userInfo?["summary"] as? String, - let domain = notification.userInfo?["domain"] as? String, - let delegate = notification.object as? HTTPClientDelegate - - else { - return - } - - Task { @MainActor in - self?.handleCertificateTrust( - summary: summary, - domain: domain, - delegate: delegate, - messageTemplateKey: "ssl_certificate_no_match" - ) - } - } - - NotificationCenter.default.addObserver( - forName: .acceptedServerCertificatesChanged, - object: nil, - queue: nil - ) { _ in - Task { @MainActor in - await WatchMessageService.singleton.syncPreferencesToWatch() - await NetworkTracker.shared.restartTracking() - } - } - - serverInfo.debounce(for: .milliseconds(500), scheduler: RunLoop.main) // ensures if multiple values are saved, we get called once - .sink { homeSettings in - let localConnectionConfig = homeSettings.localConnectionConfig - let remoteConnectionConfig = homeSettings.remoteConnectionConfig - let demomode = homeSettings.demomode - let sseCommandItem = homeSettings.sseCommandItem - - Task { - if demomode { - await NetworkTracker.shared.startTracking(connectionConfigurations: [ - ConnectionConfiguration( - url: "https://demo.openhab.org", - username: "", - password: "", - priority: 0 - ) - ]) - } else { - await NetworkTracker.shared.startTracking(connectionConfigurations: [ - localConnectionConfig, - remoteConnectionConfig - ]) - await ItemEventStream.trackItems(sseCommandItem.isEmpty ? [] : [sseCommandItem]) - } - } - } - .store(in: &cancellables) - - MainActorNetworkTracker.shared.$activeConnection - .receive(on: DispatchQueue.main) - .sink { [weak self] activeConnection in - if let activeConnection { - self?.activeConnection = activeConnection - } - } - .store(in: &cancellables) - } - - private func setupSideMenu() { - let hamburgerButtonItem: UIBarButtonItem - let imageConfig = UIImage.SymbolConfiguration(textStyle: .largeTitle) - let buttonImage = UIImage(systemSymbol: .line3Horizontal, withConfiguration: imageConfig) - let button = UIButton(type: .custom) - button.setImage(buttonImage, for: .normal) - button.addTarget(self, action: #selector(OpenHABRootViewController.rightDrawerButtonPress(_:)), for: .touchUpInside) - hamburgerButtonItem = UIBarButtonItem(customView: button) - hamburgerButtonItem.customView?.heightAnchor.constraint(equalToConstant: 30).isActive = true - navigationItem.setRightBarButton(hamburgerButtonItem, animated: true) - - // Define the menus - - let presentationStyle: SideMenuPresentationStyle = .viewSlideOutMenuIn - presentationStyle.presentingEndAlpha = 1 - presentationStyle.onTopShadowOpacity = 0.5 - var settings = SideMenuSettings() - settings.presentationStyle = presentationStyle - settings.statusBarEndAlpha = 0 - - SideMenuManager.default.rightMenuNavigationController?.settings = settings - - let networkTracker = MainActorNetworkTracker.shared - let drawerView = DrawerView { mode in - self.handleDismiss(mode: mode) - } - .environmentObject(networkTracker) - .environmentObject(currentViewState) - let hostingController = UIHostingController(rootView: drawerView) - let menu = SideMenuNavigationController(rootViewController: hostingController) - - SideMenuManager.default.rightMenuNavigationController = menu - - // Enable gestures. The left and/or right menus must be set up above for these to work. - // Note that these continue to work on the Navigation Controller independent of the View Controller it displays! - SideMenuManager.default.addPanGestureToPresent(toView: navigationController!.navigationBar) - SideMenuManager.default.addScreenEdgePanGesturesToPresent(toView: navigationController!.view, forMenu: .right) - } - - private func openTileURL(_ urlString: String) { - // Use SFSafariViewController in SwiftUI with UIViewControllerRepresentable - // Dependent on $OPENHAB_CONF/services/runtime.cfg - // Can either be an absolute URL, a path (sometimes malformed) - guard !urlString.isEmpty else { return } - - let url: URL? - if urlString.hasPrefix("http") || urlString.hasPrefix("https") { - url = URL(string: urlString) - } else { - guard let rootUrl = activeConnection?.configuration.url else { - Logger.viewController.error("openTileURL failed: no active connection URL") - return - } - url = Endpoint.resource(openHABRootUrl: rootUrl, path: urlString.prepare()).url - } - openURL(url: url) - } - - private func openURL(url: URL?) { - if let url { - let config = SFSafariViewController.Configuration() - config.entersReaderIfAvailable = true - let vc = SFSafariViewController(url: url, configuration: config) - present(vc, animated: true) - } - } - - private func handleDismiss(mode: TargetController) { - switch mode { - case .webview: - // Handle webview navigation or state update - Logger.viewController.debug("Dismissed to WebView") - SideMenuManager.default.rightMenuNavigationController?.dismiss(animated: true) - switchView(target: .webview) - case .settings: - Logger.viewController.debug("Dismissed to Settings") - SideMenuManager.default.rightMenuNavigationController?.dismiss(animated: true) { - self.modalDismissed(to: .settings) - } - case let .sitemap(sitemap): - SideMenuManager.default.rightMenuNavigationController?.dismiss(animated: true) { - self.modalDismissed(to: .sitemap(sitemap)) - } - case .notifications: - SideMenuManager.default.rightMenuNavigationController?.dismiss(animated: true) { - self.modalDismissed(to: .notifications) - } - case let .browser(urlString): - SideMenuManager.default.rightMenuNavigationController?.dismiss(animated: true) { - self.modalDismissed(to: .browser(urlString)) - } - case let .tile(urlString): - SideMenuManager.default.rightMenuNavigationController?.dismiss(animated: true) { - self.modalDismissed(to: .tile(urlString)) - } - case .homeSelection: - Logger.viewController.debug("Dismissed to Home Selection") - SideMenuManager.default.rightMenuNavigationController?.dismiss(animated: true) { - self.modalDismissed(to: .homeSelection) - } - } - } - - @objc - func rightDrawerButtonPress(_ sender: Any?) { - showSideMenu() - } - - @objc - func handleApsRegistration(_ note: Notification?) { - Logger.viewController.info("handleApsRegistration") - apsRegistrationData = note?.userInfo - subscribeToOpenhabConnectionChanges() - } - - private func subscribeToOpenhabConnectionChanges() { - struct UuidWithConnection: Hashable, Equatable { - let uuid: UUID - let connection: ConnectionConfiguration // not only URL, because auth and certs might be relevant for establishing the connection - } - - let storedOpenHabConnections = Preferences.shared.$storedHomes - .debounce(for: .seconds(1), scheduler: RunLoop.main) // avoid overexcited registrations / deregistrations in batch updates - .map { updatedPreferences in // we want to recognize changes in the OpenHab URLs for any of the homes - Set(updatedPreferences.compactMap { storedWithUuid in - let (uuid, homeConfig) = storedWithUuid - guard let connection = Preferences.shared.getNotificationConnection(of: homeConfig) else { return nil } - return UuidWithConnection(uuid: uuid, connection: connection) - }) - } - - // create a tuple that lets us inspect the previous value - let connectionsWithPreviousValues = storedOpenHabConnections - .scan((previous: Set(), current: Set())) { previous, current in - (previous: previous.current, current: current) - } - - let differences = connectionsWithPreviousValues.map { (previous, current) in // diff set of previous and current OpenHab URLs - (newValues: current.subtracting(previous), deletedValues: previous.subtracting(current)) - } - - let openhabConnectionSubscription = differences.sink { [weak self] diff in - Logger.viewController.info("openhabConnectionSubscription updated") - for newHome in diff.newValues { - Logger.viewController.info("openhabConnectionSubscription uuid \(newHome.uuid) registering for push notifications ") - self?.registerHome(uuid: newHome.uuid, connection: newHome.connection) - } - for deletedHome in diff.deletedValues { - // TODO: implement deregistration - Logger.viewController.warning("APNS Deregistration is missing (wanted to deregister \(deletedHome.connection.url))") - } - } - - cancellables.insert(openhabConnectionSubscription) - } - - private func registerHome(uuid: UUID, connection: ConnectionConfiguration) { - guard let apsRegistrationData else { - Logger.viewController.fault("Cannot register homes for push notifications, no notification registration data available") - return - } - guard let deviceId = apsRegistrationData["deviceId"] as? String, - let deviceToken = apsRegistrationData["deviceToken"] as? String, - let deviceName = apsRegistrationData["deviceName"] as? String else { - return - } - Logger.viewController.info("Registering notifications with \(connection.url)") - _ = registerHome(uuid, connection, deviceToken, deviceId, deviceName) - } - - private func registerHome(_ uuid: UUID, _ config: ConnectionConfiguration, _ deviceToken: String, _ deviceId: String, _ deviceName: String) -> Task { - Task { - do { - let client = HTTPClient(connectionConfiguration: config) - if let cloudUserId = try await client.register(prefsURL: config.url, deviceToken: deviceToken, deviceId: deviceId, deviceName: deviceName) { - Preferences.shared.setCloudUserId(cloudUserId, for: uuid) - Logger.viewController.info("my.openHAB registration succeeded with cloudUserId \(cloudUserId)") - } - Logger.viewController.info("my.openHAB registration succeeded without cloudUserId") - } catch { - Logger.viewController.error("my.openHAB registration failed \(error.localizedDescription)") - } - } - } - - func handleNotification(action: String?, cloudUserId: String?) { - guard let action else { return } - - Logger.viewController.info("handleNotification cloudUserId: \(cloudUserId ?? "")") - - Task { - if let cloudUserId, let targetHome = Preferences.shared.storedHome(forCloudUserId: cloudUserId), Preferences.shared.currentHomePreferences.remoteConnectionConfig.cloudUserId != cloudUserId { - // if we need to switch homes, disconnnect the tracking first, and update preferences - await NetworkTracker.shared.stopTracking() - Logger.viewController.info("Switching to home \(targetHome.id)") - Preferences.shared.switchActiveHome(to: targetHome.id) - } - // if the app was woken from a fully stopped state, network tracking might not be active yet - await NetworkTracker.shared.startTracking(connectionConfigurations: - [ - Preferences.shared.currentHomePreferences.localConnectionConfig, - Preferences.shared.currentHomePreferences.remoteConnectionConfig - ] - ) - _ = await NetworkTracker.shared.waitForActiveConnection() - handleNotificationInternal(action) - } - } - - private func handleNotificationInternal(_ action: String?) { - Logger.viewController.info("handleNotificationInternal: \(action ?? "")") - - guard let action else { return } - let actionParts = action.split(separator: ":") - let cmd = actionParts.dropFirst().joined(separator: ":") - - switch actionParts[0] { - case "ui": - uiCommandAction(cmd) - case "command": - sendCommandAction(cmd) - case "http": - httpCommandAction(action) - case "app": - appCommandAction(cmd) - case "rule": - ruleCommandAction(cmd) - case "device": - deviceAction(cmd) - default: - return - } - } - - // Helper function to safely call the completion handler on the main thread - private func callCompletionHandler(_ completionHandler: (() -> Void)?) { - if let completionHandler { - DispatchQueue.main.async { - completionHandler() - } - } - } - - private func uiCommandAction(_ command: String) { - Logger.viewController.info("navigateCommandAction: \(command)") - let regexPattern = /^(\/basicui\/app\\?.*|\/.*|.*)$/ - if let firstMatch = command.firstMatch(of: regexPattern) { - let path = String(firstMatch.1) - Logger.viewController.info("navigateCommandAction path: \(path)") - if path.starts(with: "/basicui/app?") { - Logger.viewController.info("Navigating to sitemap target") - let defaultSitemap = Preferences.shared.currentHomePreferences.defaultSitemap - guard let urlComponents = URLComponents(string: path) else { - Logger.viewController.warning("No parameters for specifying sitemap or widget to navigate to") - if currentView !== sitemapViewController { - switchView(target: .sitemap(defaultSitemap)) - } - return - } - let queryItems = urlComponents.queryItems - let sitemap = queryItems?.first { $0.name == "sitemap" }?.value - let widgetId = queryItems?.first { $0.name == "w" }?.value - if currentView !== sitemapViewController { - switchView(target: .sitemap(sitemap ?? defaultSitemap)) - } - if let sitemap { - Task { @MainActor in - await (sitemapViewController as? HostingSitemapViewController)?.pushSitemap(name: sitemap, path: widgetId) - } - } - } else { - Logger.viewController.info("Navigating to webview target") - if currentView != webViewController { - switchView(target: .webview) - } - if path.starts(with: "/") { - Task { - // have the webview load this path itself - webViewController.loadWebView(force: true, path: path) - } - } else { - // have the mainUI handle the navigation - webViewController.navigateCommand(path) - } - } - } else { - Logger.viewController.error("Invalid regex: \(command)") - } - } - - private func sendCommandAction(_ action: String) { - let components = action.split(separator: ":") - guard components.count == 2 else { - return - } - - let itemName = String(components[0]) - let itemCommand = String(components[1]) - let deviceId = UIDevice.current.identifierForVendor?.uuidString - Task { - do { - Logger.viewController.info("Sending command") - try await NetworkTracker.shared.send(to: itemName, command: itemCommand, deviceId: deviceId) - } catch NetworkTrackerError.noActiveConnection { - displayErrorNotification("Could not find server") - } catch { - displayErrorNotification("Failed to establish a connection: \(error.localizedDescription)") - Logger.viewController.error("Could not send data \(error.localizedDescription)") - } - } - } - - private func displayErrorNotification(_ message: String) { - let content = UNMutableNotificationContent() - content.title = "Could not send command" - content.body = message - content.sound = UNNotificationSound.default - - // Create the request - let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil) - - // Schedule the request with the notification center - // no error handler because it only printed and tended to crash in swift6 - UNUserNotificationCenter.current().add(request) - } - - private func httpCommandAction(_ command: String) { - if let url = URL(string: command) { - let vc = SFSafariViewController(url: url) - present(vc, animated: true) - } - } - - private func appCommandAction(_ command: String) { - let pairs = command.split(separator: ",") - for pair in pairs { - let keyValue = pair.split(separator: "=", maxSplits: 1) - if keyValue[0] == "ios" { - if let url = URL(string: String(keyValue[1])) { - Logger.viewController.error("appCommandAction opening \(String(keyValue[0])) \(String(keyValue[1]))") - UIApplication.shared.open(url) - return - } - } - } - } - - private func deviceAction(_ action: String) { - let cmdParts = action.split(separator: ":") - if cmdParts.isEmpty { return } - let command = cmdParts[0].lowercased() - let arg1 = cmdParts.count > 1 ? cmdParts[1].lowercased() : "" - switch command { - case "screensaver": - switch arg1 { - case "activate": - NotificationCenter.default.post(name: .activateScreenSaver, object: nil) - case "disable": - NotificationCenter.default.post(name: .disableScreenSaver, object: nil) - case "wake": - NotificationCenter.default.post(name: .wakeScreenSaver, object: nil) - default: - break - } - case "idletimer": - switch arg1 { - case "enable": - UIApplication.shared.isIdleTimerDisabled = false - case "disable": - UIApplication.shared.isIdleTimerDisabled = true - default: - break - } - case "brightness": - if let value = Double(arg1) { - let target = min(max(value, 0.0), 1.0) - UIScreen.main.brightness = target - } - case "tts": - func normalizeVoiceName(from input: String) -> String { - input - .lowercased() - .components(separatedBy: CharacterSet.alphanumerics.inverted) - .joined() - } - - let utterance = AVSpeechUtterance(string: arg1) - if cmdParts.count > 3 { - Logger.viewController.debug("Filtering voice \(cmdParts[2]) \(cmdParts[3])") - let voice = AVSpeechSynthesisVoice.speechVoices().filter { $0.language.lowercased() == cmdParts[2].lowercased() && normalizeVoiceName(from: $0.name) == normalizeVoiceName(from: String(cmdParts[3])) } - if !voice.isEmpty { - Logger.viewController.debug("Setting custom voice \(voice[0].name)") - utterance.voice = voice[0] - } - } else if cmdParts.count > 2 { - utterance.voice = AVSpeechSynthesisVoice(language: String(cmdParts[2])) - } - synthesizer.speak(utterance) - default: - break - } - } - - private func ruleCommandAction(_ command: String) { - let components = command.split(separator: ":", maxSplits: 2) - - guard !components.isEmpty else { - Logger.viewController.warning("No rule to execute found in action") - return - } - - let uuid = String(components[0]) - let propertiesString = if components.count > 1 { String(components[1]) } else { "" } - - let propertyPairs = propertiesString.split(separator: ",") - var properties: [String: String] = [:] - - for pair in propertyPairs { - let keyValue = pair.split(separator: "=", maxSplits: 1) - if keyValue.count == 2 { - let key = String(keyValue[0]) - let value = String(keyValue[1]) - properties[key] = value - } - } - Task { - do { - Logger.viewController.error("Sending command") - try await NetworkTracker.shared.runNow(ruleUID: uuid, payload: properties) - Logger.viewController.info("Request succeeded") - } catch let error as NetworkTrackerError { - displayErrorNotification("\(error.localizedDescription)") - } catch { - Logger.viewController.error("Could not send data \(error.localizedDescription)") - displayErrorNotification("Request to server failed: \(error.localizedDescription)") - } - } - } - - func showSideMenu() { - Logger.viewController.info("OpenHABRootViewController showSideMenu") - if let menu = SideMenuManager.default.rightMenuNavigationController { - // don't try and push an already visible menu less you crash the app - dismiss(animated: false) { - var topMostViewController: UIViewController? = - UIApplication.shared.connectedScenes.flatMap { ($0 as? UIWindowScene)?.windows ?? [] }.last { $0.isKeyWindow }?.rootViewController - - while let presentedViewController = topMostViewController?.presentedViewController { - topMostViewController = presentedViewController - } - - guard let presenter = topMostViewController else { - // swiftformat:disable:next redundantSelf - Logger.viewController.error("No valid view controller found to present side menu") - return - } - - // Avoid trying to present the menu on itself - if presenter == menu { - // swiftformat:disable:next redundantSelf - Logger.viewController.error("Cannot present side menu on itself") - return - } - - presenter.present(menu, animated: true) - } - } - } - - private func addView(viewController: UIViewController) { - addChild(viewController) - view.insertSubview(viewController.view, belowSubview: networkStatusButton) - viewController.view.frame = view.bounds - viewController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] - viewController.didMove(toParent: self) - } - - private func removeView(viewController: UIViewController) { - viewController.willMove(toParent: nil) - viewController.view.removeFromSuperview() - viewController.removeFromParent() - } - - private func switchView(target: TargetController) { - let targetView: any (UIViewController & OpenHABViewable) - - switch target { - case let .sitemap(sitemap): - Preferences.shared.modifyActiveHome { preferences in - preferences.defaultSitemap = sitemap - } - targetView = sitemapViewController - case .webview: - targetView = webViewController - default: - return - } - - if currentView !== targetView { - if let currentView { - removeView(viewController: currentView) - } - addView(viewController: targetView) - currentView = targetView - - // Update webview active state - currentViewState.isWebViewActive = (targetView == webViewController) - - // Don't save our view in demo mode - if !Preferences.shared.currentHomePreferences.demomode { - Preferences.shared.modifyActiveHome { - $0.defaultView = currentView.viewName() - } - } - } else { - // if we hit the menu item again while on the view, trigger a reload - currentView.reloadView() - } - - // Make sure we reset any views that may be pushed - navigationController?.popToRootViewController(animated: true) - } - - private func switchToSavedView() { - if Preferences.shared.currentHomePreferences.demomode { - switchView(target: .sitemap("demo")) - } else { - let defaultView = Preferences.shared.currentHomePreferences.defaultView - let defaultSitemap = Preferences.shared.currentHomePreferences.defaultSitemap - Logger.viewController.info("OpenHABRootViewController switchToSavedView \(defaultView == "sitemap" ? "sitemap/\(defaultSitemap)" : "web")") - switchView(target: defaultView == "sitemap" ? .sitemap(defaultSitemap) : .webview) - } - } - - @MainActor - @objc func handleCertificateTrust(_ notification: Notification, message: String) { - guard let summary = notification.userInfo?["summary"] as? String, - let domain = notification.userInfo?["domain"] as? String, - let delegate = notification.object as? HTTPClientDelegate else { return } - let title = NSLocalizedString("ssl_certificate_warning", comment: "") - let message = String(format: NSLocalizedString(message, comment: ""), summary, domain) - DispatchQueue.main.async { - // Show alert to user - let alert = UIAlertController( - title: title, - message: message, - preferredStyle: .alert - ) - - alert.addAction(UIAlertAction(title: "Always", style: .default) { _ in - delegate.completeEvaluation(.permitAlways) - }) - - alert.addAction(UIAlertAction(title: "Once", style: .default) { _ in - delegate.completeEvaluation(.permitOnce) - }) - - alert.addAction(UIAlertAction(title: "Deny", style: .cancel) { _ in - delegate.completeEvaluation(.deny) - }) - - self.present(alert, animated: true) - } - } - - @MainActor - @objc - func handleCertificateTrust(summary: String, domain: String, delegate: HTTPClientDelegate, messageTemplateKey: String) { - let title = NSLocalizedString("ssl_certificate_warning", comment: "") - let message = String(format: NSLocalizedString(messageTemplateKey, comment: ""), summary, domain) - - let alert = UIAlertController( - title: title, - message: message, - preferredStyle: .alert - ) - - alert.addAction(UIAlertAction(title: "Always", style: .default) { _ in - delegate.completeEvaluation(.permitAlways) - }) - - alert.addAction(UIAlertAction(title: "Once", style: .default) { _ in - delegate.completeEvaluation(.permitOnce) - }) - - alert.addAction(UIAlertAction(title: "Deny", style: .cancel) { _ in - delegate.completeEvaluation(.deny) - }) - - present(alert, animated: true) - } -} - -// swiftlint:enable type_body_length - -// MARK: - UISideMenuNavigationControllerDelegate - -extension OpenHABRootViewController: SideMenuNavigationControllerDelegate { - nonisolated func sideMenuWillAppear(menu: SideMenuNavigationController, animated: Bool) { - Logger.viewController.info("OpenHABRootViewController sideMenuWillAppear") - } -} - -// MARK: - ModalHandler - -extension OpenHABRootViewController: ModalHandler { - nonisolated func modalDismissed(to: TargetController) { - Task { @MainActor in - switch to { - case let .sitemap(sitemapName): - switchView(target: to) - await (sitemapViewController as? HostingSitemapViewController)?.pushSitemap(name: sitemapName, path: nil) - case .settings: - let hostingController = UIHostingController(rootView: NavigationView { SettingsView() }) - present(hostingController, animated: true) - case .notifications: - let hostingController = UIHostingController(rootView: NotificationsView()) - navigationController?.pushViewController(hostingController, animated: true) - case .webview: - switchView(target: to) - case .browser: - break - case let .tile(urlString): - openTileURL(urlString) - case .homeSelection: - let hostingController = UIHostingController(rootView: HomeSelectionView()) - navigationController?.pushViewController(hostingController, animated: true) - } - } - } -} - -// MARK: Kingfisher authentication with URLCredential - -extension OpenHABRootViewController: AuthenticationChallengeResponsible { - func downloader(_ downloader: ImageDownloader, - didReceive challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { - await onReceiveSessionChallenge(with: challenge) - } - - func downloader(_ downloader: ImageDownloader, - task: URLSessionTask, - didReceive challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { - await onReceiveSessionTaskChallenge(with: challenge) - } -} diff --git a/openHAB/UIKit/OpenHABViewController.swift b/openHAB/UIKit/OpenHABViewController.swift index 82d8c01a3..54053bd41 100644 --- a/openHAB/UIKit/OpenHABViewController.swift +++ b/openHAB/UIKit/OpenHABViewController.swift @@ -11,7 +11,6 @@ import Combine import OpenHABCore -import SideMenu import SwiftMessages import UIKit @@ -80,12 +79,6 @@ class OpenHABViewController: UIViewController, OpenHABViewable { SwiftMessages.hideAll() } - func showSideMenu() { - if let rc = parent as? OpenHABRootViewController { - rc.showSideMenu() - } - } - @objc func didEnterBackground(_ notification: Notification?) { UIApplication.shared.isIdleTimerDisabled = false @@ -99,6 +92,9 @@ class OpenHABViewController: UIViewController, OpenHABViewable { } } + // No-op: side menu replaced by tab bar + func showSideMenu() {} + // To be overridden by sub classes func reloadView() {} diff --git a/openHAB/UIKit/OpenHABWebViewController.swift b/openHAB/UIKit/OpenHABWebViewController.swift index 37f4ba29d..bba8194c7 100644 --- a/openHAB/UIKit/OpenHABWebViewController.swift +++ b/openHAB/UIKit/OpenHABWebViewController.swift @@ -13,7 +13,6 @@ import Combine import OpenHABCore import os.log import SafariServices -import SideMenu import SwiftMessages import UIKit import WebKit @@ -490,7 +489,8 @@ extension OpenHABWebViewController: WKScriptMessageHandler { Logger.viewController.info("WKScriptMessage \(callbackName)") switch callbackName { case "exitToApp": - showSideMenu() + // Tab bar is always visible, no side menu to show + break case "goFullscreen": // check to make sure we are actually the top view before hiding the nav button if isViewLoaded, view.window != nil { diff --git a/openHABUITests/OpenHABUITests.swift b/openHABUITests/OpenHABUITests.swift index 77c073d9d..58d32a026 100644 --- a/openHABUITests/OpenHABUITests.swift +++ b/openHABUITests/OpenHABUITests.swift @@ -35,12 +35,12 @@ class OpenHABUITests: XCTestCase { let app = XCUIApplication() app.activate() - let hamburgerButton = app.navigationBars.buttons["HamburgerButton"] - hamburgerButton.tap() - sleep(3) + // Navigate using tab bar instead of hamburger menu + let tabBar = app.tabBars if runWebViewAndSitemap { - app.staticTexts["Home"].tap() + // Home tab (WebView / MainUI) + tabBar.buttons["Home"].tap() sleep(10) snapshot("0_MainUI") @@ -77,9 +77,10 @@ class OpenHABUITests: XCTestCase { webViewsQuery.links.allElementsBoundByIndex[1].tap() sleep(2) - app.webViews.staticTexts["square_arrow_right"].tap() + // Switch to Sitemaps tab + tabBar.buttons["Sitemaps"].tap() + sleep(3) - app.staticTexts["Main Menu"].tap() app.cells.containing(.staticText, identifier: "Widget Overview").firstMatch.tap() sleep(10) snapshot("4_MainSitemap") @@ -90,10 +91,11 @@ class OpenHABUITests: XCTestCase { app.navigationBars.buttons.element(boundBy: 0).tap() sleep(2) - - hamburgerButton.tap() - sleep(3) } + + // Switch to System tab for settings + tabBar.buttons["System"].tap() + sleep(2) app.staticTexts["settings"].tap() sleep(2) snapshot("7_Settings_Demo") From 272ab7a372b9f76562944d25aae36e3b943c8465 Mon Sep 17 00:00:00 2001 From: Tassilo Karge Date: Sat, 14 Feb 2026 10:33:12 +0100 Subject: [PATCH 470/476] Refine TabView: reactive notifications, settings dirty tracking, tab reset, customizable tab bar - Make notification link in System tab reactive via onReceive - Show save/cancel in Settings only after user edits a field - Re-tapping a tab resets it to its root view - Enable tab bar customization via TabViewCustomization API Co-Authored-By: Claude Opus 4.6 Signed-off-by: Tassilo Karge --- openHAB/SettingsView/SettingsView.swift | 87 ++++++++++++++++--- .../SwiftUI/RootView/OpenHABTabRootView.swift | 39 ++++++++- openHAB/SwiftUI/RootView/SitemapsTab.swift | 4 + openHAB/SwiftUI/RootView/SystemTab.swift | 23 ++++- openHAB/SwiftUI/RootView/TilesTab.swift | 4 + 5 files changed, 141 insertions(+), 16 deletions(-) diff --git a/openHAB/SettingsView/SettingsView.swift b/openHAB/SettingsView/SettingsView.swift index 2c606fb4e..f0c045166 100644 --- a/openHAB/SettingsView/SettingsView.swift +++ b/openHAB/SettingsView/SettingsView.swift @@ -14,6 +14,23 @@ import OpenHABCore import os import SwiftUI + private struct SettingsSnapshot: Equatable { + var demomode: Bool + var idleOff: Bool + var realTimeSliders: Bool + var showSearchField: Bool + var sendCrashReports: Bool + var iconType: IconType + var sortSitemapsBy: SortSitemapsOrder + var defaultMainUIPath: String + var alwaysAllowWebRTC: Bool + var sitemapForWatch: String + var localConnectionConfig: ConnectionConfiguration + var remoteConnectionConfig: ConnectionConfiguration + var homeName: String + var sseCommandItem: String + } + struct SettingsView: View { @State private var settingsDemomode = false @State private var settingsIdleOff = true @@ -33,8 +50,30 @@ struct SettingsView: View { @State private var viewAppearedOnce = false @State private var settingsSSECommandItem = "" + @State private var initialSnapshot: SettingsSnapshot? + @State private var isDirty = false + @Environment(\.dismiss) private var dismiss + private var currentSnapshot: SettingsSnapshot { + SettingsSnapshot( + demomode: settingsDemomode, + idleOff: settingsIdleOff, + realTimeSliders: settingsRealTimeSliders, + showSearchField: settingsShowSearchField, + sendCrashReports: settingsSendCrashReports, + iconType: settingsIconType, + sortSitemapsBy: settingsSortSitemapsBy, + defaultMainUIPath: settingsDefaultMainUIPath, + alwaysAllowWebRTC: settingsAlwaysAllowWebRTC, + sitemapForWatch: settingsSitemapForWatch, + localConnectionConfig: settingsLocalConnectionConfiguration, + remoteConnectionConfig: settingsRemoteConnectionConfiguration, + homeName: settingsHomeName, + sseCommandItem: settingsSSECommandItem + ) + } + var body: some View { Form { ConnectionSettingsView( @@ -69,19 +108,25 @@ struct SettingsView: View { AboutSettingsView() } .formStyle(.grouped) - .navigationBarBackButtonHidden(true) + .navigationBarBackButtonHidden(isDirty) .navigationTitle("\(settingsHomeName) Settings") .toolbar { - ToolbarItemGroup(placement: .primaryAction) { - Button("Save") { - saveSettings() - NotificationCenter.default.post(name: NSNotification.Name("org.openhab.preferences.saved"), object: nil) - dismiss() + if isDirty { + ToolbarItem(placement: .confirmationAction) { + Button { + saveSettings() + NotificationCenter.default.post(name: NSNotification.Name("org.openhab.preferences.saved"), object: nil) + dismiss() + } label: { + Image(systemName: "checkmark") + } } - } - ToolbarItemGroup(placement: .cancellationAction) { - Button("Cancel") { - dismiss() + ToolbarItem(placement: .cancellationAction) { + Button { + restoreFromSnapshot() + } label: { + Image(systemName: "xmark") + } } } } @@ -89,10 +134,32 @@ struct SettingsView: View { if !viewAppearedOnce { viewAppearedOnce = true loadSettings() + initialSnapshot = currentSnapshot let activeConfiguration = settingsLocalConnectionConfiguration await updateSitemaps(activeConfiguration: activeConfiguration) } } + .onChange(of: currentSnapshot) { _, newSnapshot in + isDirty = newSnapshot != initialSnapshot + } + } + + private func restoreFromSnapshot() { + guard let snapshot = initialSnapshot else { return } + settingsDemomode = snapshot.demomode + settingsIdleOff = snapshot.idleOff + settingsRealTimeSliders = snapshot.realTimeSliders + settingsShowSearchField = snapshot.showSearchField + settingsSendCrashReports = snapshot.sendCrashReports + settingsIconType = snapshot.iconType + settingsSortSitemapsBy = snapshot.sortSitemapsBy + settingsDefaultMainUIPath = snapshot.defaultMainUIPath + settingsAlwaysAllowWebRTC = snapshot.alwaysAllowWebRTC + settingsSitemapForWatch = snapshot.sitemapForWatch + settingsLocalConnectionConfiguration = snapshot.localConnectionConfig + settingsRemoteConnectionConfiguration = snapshot.remoteConnectionConfig + settingsHomeName = snapshot.homeName + settingsSSECommandItem = snapshot.sseCommandItem } private func updateSitemaps(activeConfiguration: ConnectionConfiguration) async { diff --git a/openHAB/SwiftUI/RootView/OpenHABTabRootView.swift b/openHAB/SwiftUI/RootView/OpenHABTabRootView.swift index 9e222e081..dddd0076d 100644 --- a/openHAB/SwiftUI/RootView/OpenHABTabRootView.swift +++ b/openHAB/SwiftUI/RootView/OpenHABTabRootView.swift @@ -30,9 +30,24 @@ struct OpenHABTabRootView: View { @State private var selectedTab: AppTab @State private var isDemoMode: Bool @State private var sitemapsTab = SitemapsTab() + @State private var tilesTab = TilesTab() + @State private var systemTab = SystemTab() + @State private var tabCustomization = TabViewCustomization() private let webViewController = OpenHABWebViewController() + private var tabSelectionBinding: Binding { + Binding( + get: { selectedTab }, + set: { newTab in + if newTab == selectedTab { + resetTab(newTab) + } + selectedTab = newTab + } + ) + } + init() { let saved = Preferences.shared.currentHomePreferences.lastSelectedTab _selectedTab = State(initialValue: AppTab(rawValue: saved) ?? .main) @@ -48,24 +63,29 @@ struct OpenHABTabRootView: View { } var body: some View { - TabView(selection: $selectedTab) { + TabView(selection: tabSelectionBinding) { Tab("Home", systemImage: "house", value: AppTab.main) { MainWebTab(webViewController: webViewController) .ignoresSafeArea() } + .customizationID("home") Tab("Sitemaps", systemImage: "map", value: AppTab.sitemaps) { sitemapsTab } + .customizationID("sitemaps") Tab("Tiles", systemImage: "square.grid.2x2", value: AppTab.tiles) { - TilesTab() + tilesTab } + .customizationID("tiles") Tab("System", systemImage: "gear", value: AppTab.system) { - SystemTab() + systemTab } + .customizationID("system") } + .tabViewCustomization($tabCustomization) .environmentObject(networkTracker) .onChange(of: selectedTab) { oldTab, newTab in Preferences.shared.modifyActiveHome { prefs in @@ -129,6 +149,19 @@ struct OpenHABTabRootView: View { } } + private func resetTab(_ tab: AppTab) { + switch tab { + case .main: + webViewController.loadWebView(force: true) + case .sitemaps: + sitemapsTab.resetToRoot() + case .tiles: + tilesTab.resetToRoot() + case .system: + systemTab.resetToRoot() + } + } + private func handleNavigationCommand(_ command: NavigationCommand) { switch command { case let .switchToWebView(path): diff --git a/openHAB/SwiftUI/RootView/SitemapsTab.swift b/openHAB/SwiftUI/RootView/SitemapsTab.swift index ed0303d85..3ff51a9c2 100644 --- a/openHAB/SwiftUI/RootView/SitemapsTab.swift +++ b/openHAB/SwiftUI/RootView/SitemapsTab.swift @@ -96,6 +96,10 @@ struct SitemapsTab: View { } } + func resetToRoot() { + selectedSitemap = nil + } + func navigateToSitemap(name: String, widgetId: String?) { selectedSitemap = name Preferences.shared.modifyActiveHome { preferences in diff --git a/openHAB/SwiftUI/RootView/SystemTab.swift b/openHAB/SwiftUI/RootView/SystemTab.swift index 975617d05..209ce0bc0 100644 --- a/openHAB/SwiftUI/RootView/SystemTab.swift +++ b/openHAB/SwiftUI/RootView/SystemTab.swift @@ -37,8 +37,11 @@ struct ConnectionView: View { } struct SystemTab: View { + @State private var showNotifications = false + @State var path = NavigationPath() + var body: some View { - NavigationStack { + NavigationStack(path: $path) { List { Section { NavigationLink { @@ -51,8 +54,7 @@ struct SystemTab: View { } } - if Preferences.shared.getNotificationConnection() != nil, - !Preferences.shared.currentHomePreferences.demomode { + if showNotifications { NavigationLink { NotificationsView() } label: { @@ -82,5 +84,20 @@ struct SystemTab: View { .padding(.bottom, 8) } } + .task { + updateNotificationVisibility() + } + .onReceive(Preferences.shared.$currentHomePreferences) { _ in + updateNotificationVisibility() + } + } + + func resetToRoot() { + path = NavigationPath() + } + + private func updateNotificationVisibility() { + showNotifications = Preferences.shared.getNotificationConnection() != nil + && !Preferences.shared.currentHomePreferences.demomode } } diff --git a/openHAB/SwiftUI/RootView/TilesTab.swift b/openHAB/SwiftUI/RootView/TilesTab.swift index 815727a6c..d04846f46 100644 --- a/openHAB/SwiftUI/RootView/TilesTab.swift +++ b/openHAB/SwiftUI/RootView/TilesTab.swift @@ -55,6 +55,10 @@ struct TilesTab: View { } } + func resetToRoot() { + selectedTileURL = nil + } + private func openTile(_ tile: OpenHABUiTile) { let urlString = tile.url guard !urlString.isEmpty else { return } From 93d3d3d4899ee23e1e178b7e4a0800e092c0a508 Mon Sep 17 00:00:00 2001 From: Tassilo Karge Date: Sat, 14 Feb 2026 11:30:21 +0100 Subject: [PATCH 471/476] Fix tab reset and add tab customization in settings Replace direct method calls on @State tab structs with a trigger pattern for tab re-tap reset, since SwiftUI's internal state management doesn't propagate changes from external method calls. Each tab now observes a reset counter via .onChange. Add tab customization UI in Settings allowing users to reorder tabs and toggle visibility (except System tab which stays always enabled). Tab configuration is stored in ApplicationPreferences with backward- compatible Codable decoding. Tiles now open inline via NavigationStack instead of modal sheets, and settings toolbar transitions are animated. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Tassilo Karge --- .../OpenHABCore/Util/Preferences.swift | 31 ++++++ openHAB.xcodeproj/project.pbxproj | 4 + openHAB/SettingsView/SettingsView.swift | 14 ++- .../TabCustomizationSection.swift | 59 ++++++++++ .../SwiftUI/RootView/OpenHABTabRootView.swift | 101 +++++++++++++----- openHAB/SwiftUI/RootView/SitemapsTab.swift | 41 ++++--- openHAB/SwiftUI/RootView/SystemTab.swift | 11 +- openHAB/SwiftUI/RootView/TilesTab.swift | 33 ++++-- 8 files changed, 232 insertions(+), 62 deletions(-) create mode 100644 openHAB/SettingsView/TabCustomizationSection.swift diff --git a/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift index 4b008d64a..40f4ca172 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift @@ -111,9 +111,40 @@ public struct HomePreferences: Codable, Equatable { } } +public struct TabEntry: Codable, Equatable, Hashable, Sendable { + public var id: String + public var enabled: Bool + + public init(id: String, enabled: Bool) { + self.id = id + self.enabled = enabled + } + + public static let defaultConfiguration: [TabEntry] = [ + TabEntry(id: "main", enabled: true), + TabEntry(id: "sitemaps", enabled: true), + TabEntry(id: "tiles", enabled: true), + TabEntry(id: "system", enabled: true) + ] +} + @MainActor public struct ApplicationPreferences: Codable, Equatable { public var showSearchField = true + public var tabConfiguration: [TabEntry] = TabEntry.defaultConfiguration + + enum CodingKeys: String, CodingKey { + case showSearchField + case tabConfiguration + } + + public init() {} + + public nonisolated init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + showSearchField = try container.decodeIfPresent(Bool.self, forKey: .showSearchField) ?? true + tabConfiguration = try container.decodeIfPresent([TabEntry].self, forKey: .tabConfiguration) ?? TabEntry.defaultConfiguration + } } // MARK: Retrieving preference from user defaults, reacting to preference change diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index 94092479d..5c774b38e 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -119,6 +119,7 @@ DA48001A2D83742A009CF127 /* DebugSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4800192D83742A009CF127 /* DebugSettingsView.swift */; }; DA48001C2D837556009CF127 /* SitemapSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA48001B2D837556009CF127 /* SitemapSettingsView.swift */; }; DA48001E2D837905009CF127 /* ApplicationSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA48001D2D837905009CF127 /* ApplicationSettingsView.swift */; }; + A75A53645D1542CBAC658099 /* TabCustomizationSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3698C230427F48A782B1980B /* TabCustomizationSection.swift */; }; DA4800212D839D3A009CF127 /* AnimatedSecureTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4800202D839D39009CF127 /* AnimatedSecureTextField.swift */; }; DA4D4DB5233F9ACB00B37E37 /* README.md in Resources */ = {isa = PBXBuildFile; fileRef = DA4D4DB4233F9ACB00B37E37 /* README.md */; }; DA50C7BF2B0A65300009F716 /* SliderWithSwitchSupportUITableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA50C7BE2B0A652F0009F716 /* SliderWithSwitchSupportUITableViewCell.swift */; }; @@ -490,6 +491,7 @@ DA4800192D83742A009CF127 /* DebugSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugSettingsView.swift; sourceTree = ""; }; DA48001B2D837556009CF127 /* SitemapSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SitemapSettingsView.swift; sourceTree = ""; }; DA48001D2D837905009CF127 /* ApplicationSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationSettingsView.swift; sourceTree = ""; }; + 3698C230427F48A782B1980B /* TabCustomizationSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabCustomizationSection.swift; sourceTree = ""; }; DA4800202D839D39009CF127 /* AnimatedSecureTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnimatedSecureTextField.swift; sourceTree = ""; }; DA4D4DB4233F9ACB00B37E37 /* README.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; DA4D4E0E2340A00200B37E37 /* Changes.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = Changes.md; sourceTree = ""; }; @@ -983,6 +985,7 @@ 652B81032E2193B500648510 /* ScreenSaverSettingsView.swift */, DA4800172D837221009CF127 /* AboutSettingsView.swift */, DA48001D2D837905009CF127 /* ApplicationSettingsView.swift */, + 3698C230427F48A782B1980B /* TabCustomizationSection.swift */, 2F55E7BC2DEE44A800EC8350 /* ClientCertificatesView.swift */, DA4800132D836892009CF127 /* ConnectionSettingsView.swift */, DA77E19A2D886D9B007CFF0F /* SingleConnectionSettingsView.swift */, @@ -1848,6 +1851,7 @@ DA7E1E4B2233986E002AEFD8 /* PlayerView.swift in Sources */, 65570A7D2476D16A00D524EA /* OpenHABWebViewController.swift in Sources */, DA48001E2D837905009CF127 /* ApplicationSettingsView.swift in Sources */, + A75A53645D1542CBAC658099 /* TabCustomizationSection.swift in Sources */, DAF0A28B2C56E3A300A14A6A /* RollershutterCell.swift in Sources */, DA64ACA62DBEAD5600294F60 /* SitemapPageViewModel.swift in Sources */, DABED17B2E451694000B92EF /* BonjourDiscoverySheet.swift in Sources */, diff --git a/openHAB/SettingsView/SettingsView.swift b/openHAB/SettingsView/SettingsView.swift index f0c045166..e8410beb1 100644 --- a/openHAB/SettingsView/SettingsView.swift +++ b/openHAB/SettingsView/SettingsView.swift @@ -29,6 +29,7 @@ import SwiftUI var remoteConnectionConfig: ConnectionConfiguration var homeName: String var sseCommandItem: String + var tabConfiguration: [TabEntry] } struct SettingsView: View { @@ -49,6 +50,7 @@ struct SettingsView: View { @State private var settingsHomeName = "" @State private var viewAppearedOnce = false @State private var settingsSSECommandItem = "" + @State private var settingsTabConfiguration: [TabEntry] = TabEntry.defaultConfiguration @State private var initialSnapshot: SettingsSnapshot? @State private var isDirty = false @@ -70,7 +72,8 @@ struct SettingsView: View { localConnectionConfig: settingsLocalConnectionConfiguration, remoteConnectionConfig: settingsRemoteConnectionConfiguration, homeName: settingsHomeName, - sseCommandItem: settingsSSECommandItem + sseCommandItem: settingsSSECommandItem, + tabConfiguration: settingsTabConfiguration ) } @@ -87,6 +90,8 @@ struct SettingsView: View { settingsSSECommandItem: $settingsSSECommandItem ) + TabCustomizationSection(tabConfiguration: $settingsTabConfiguration) + MainUISettingsView( settingsAlwaysAllowWebRTC: $settingsAlwaysAllowWebRTC, settingsDefaultMainUIPath: $settingsDefaultMainUIPath @@ -140,7 +145,9 @@ struct SettingsView: View { } } .onChange(of: currentSnapshot) { _, newSnapshot in - isDirty = newSnapshot != initialSnapshot + withAnimation { + isDirty = newSnapshot != initialSnapshot + } } } @@ -160,6 +167,7 @@ struct SettingsView: View { settingsRemoteConnectionConfiguration = snapshot.remoteConnectionConfig settingsHomeName = snapshot.homeName settingsSSECommandItem = snapshot.sseCommandItem + settingsTabConfiguration = snapshot.tabConfiguration } private func updateSitemaps(activeConfiguration: ConnectionConfiguration) async { @@ -200,6 +208,7 @@ struct SettingsView: View { settingsRemoteConnectionConfiguration = Preferences.shared.currentHomePreferences.remoteConnectionConfig settingsHomeName = Preferences.shared.currentHomePreferences.homeName settingsSSECommandItem = Preferences.shared.currentHomePreferences.sseCommandItem + settingsTabConfiguration = Preferences.shared.applicationPreferences.tabConfiguration } func saveSettings() { @@ -221,6 +230,7 @@ struct SettingsView: View { Preferences.shared.modifyApplicationPreferences { @MainActor applicationPreferences in applicationPreferences.showSearchField = settingsShowSearchField + applicationPreferences.tabConfiguration = settingsTabConfiguration } // Apply global UI changes immediately (status bar visibility) diff --git a/openHAB/SettingsView/TabCustomizationSection.swift b/openHAB/SettingsView/TabCustomizationSection.swift new file mode 100644 index 000000000..176e085cc --- /dev/null +++ b/openHAB/SettingsView/TabCustomizationSection.swift @@ -0,0 +1,59 @@ +// Copyright (c) 2010-2026 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 OpenHABCore +import SwiftUI + +struct TabCustomizationSection: View { + @Binding var tabConfiguration: [TabEntry] + + var body: some View { + Section(header: Text("Tabs")) { + ForEach(Array(tabConfiguration.enumerated()), id: \.element.id) { index, entry in + HStack { + Image(systemName: systemImage(for: entry.id)) + .frame(width: 24) + .foregroundStyle(entry.enabled || entry.id == "system" ? .primary : .secondary) + Text(displayName(for: entry.id)) + .foregroundStyle(entry.enabled || entry.id == "system" ? .primary : .secondary) + Spacer() + if entry.id != "system" { + Toggle("", isOn: $tabConfiguration[index].enabled) + .labelsHidden() + } + } + } + .onMove { source, destination in + tabConfiguration.move(fromOffsets: source, toOffset: destination) + } + } + } + + private func displayName(for id: String) -> String { + switch id { + case "main": "Home" + case "sitemaps": "Sitemaps" + case "tiles": "Tiles" + case "system": "System" + default: id.capitalized + } + } + + private func systemImage(for id: String) -> String { + switch id { + case "main": "house" + case "sitemaps": "map" + case "tiles": "square.grid.2x2" + case "system": "gear" + default: "questionmark" + } + } +} diff --git a/openHAB/SwiftUI/RootView/OpenHABTabRootView.swift b/openHAB/SwiftUI/RootView/OpenHABTabRootView.swift index dddd0076d..686eaaa4c 100644 --- a/openHAB/SwiftUI/RootView/OpenHABTabRootView.swift +++ b/openHAB/SwiftUI/RootView/OpenHABTabRootView.swift @@ -21,6 +21,24 @@ enum AppTab: String, CaseIterable, Hashable { case sitemaps case tiles case system + + var title: String { + switch self { + case .main: "Home" + case .sitemaps: "Sitemaps" + case .tiles: "Tiles" + case .system: "System" + } + } + + var systemImage: String { + switch self { + case .main: "house" + case .sitemaps: "map" + case .tiles: "square.grid.2x2" + case .system: "gear" + } + } } struct OpenHABTabRootView: View { @@ -29,10 +47,12 @@ struct OpenHABTabRootView: View { @State private var selectedTab: AppTab @State private var isDemoMode: Bool - @State private var sitemapsTab = SitemapsTab() - @State private var tilesTab = TilesTab() - @State private var systemTab = SystemTab() - @State private var tabCustomization = TabViewCustomization() + @State private var enabledTabs: [AppTab] + + @State private var sitemapsResetTrigger = 0 + @State private var tilesResetTrigger = 0 + @State private var systemResetTrigger = 0 + @State private var sitemapNavigationCommand: SitemapNavigationCommand? private let webViewController = OpenHABWebViewController() @@ -52,6 +72,7 @@ struct OpenHABTabRootView: View { let saved = Preferences.shared.currentHomePreferences.lastSelectedTab _selectedTab = State(initialValue: AppTab(rawValue: saved) ?? .main) _isDemoMode = State(initialValue: Preferences.shared.currentHomePreferences.demomode) + _enabledTabs = State(initialValue: Self.computeEnabledTabs()) #if DEBUG if ProcessInfo.processInfo.environment["UITest"] != nil { @@ -62,42 +83,49 @@ struct OpenHABTabRootView: View { #endif } + private static func computeEnabledTabs() -> [AppTab] { + let config = Preferences.shared.applicationPreferences.tabConfiguration + let tabs = config.compactMap { entry -> AppTab? in + guard entry.enabled || entry.id == AppTab.system.rawValue else { return nil } + return AppTab(rawValue: entry.id) + } + // Ensure system tab is always present + if !tabs.contains(.system) { + return tabs + [.system] + } + return tabs + } + var body: some View { TabView(selection: tabSelectionBinding) { - Tab("Home", systemImage: "house", value: AppTab.main) { - MainWebTab(webViewController: webViewController) - .ignoresSafeArea() - } - .customizationID("home") - - Tab("Sitemaps", systemImage: "map", value: AppTab.sitemaps) { - sitemapsTab - } - .customizationID("sitemaps") - - Tab("Tiles", systemImage: "square.grid.2x2", value: AppTab.tiles) { - tilesTab - } - .customizationID("tiles") - - Tab("System", systemImage: "gear", value: AppTab.system) { - systemTab + ForEach(enabledTabs, id: \.self) { tab in + Tab(tab.title, systemImage: tab.systemImage, value: tab) { + AnyView(tabContentView(for: tab)) + } } - .customizationID("system") } - .tabViewCustomization($tabCustomization) .environmentObject(networkTracker) .onChange(of: selectedTab) { oldTab, newTab in Preferences.shared.modifyActiveHome { prefs in prefs.lastSelectedTab = newTab.rawValue } } + .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("org.openhab.preferences.saved"))) { _ in + let newTabs = Self.computeEnabledTabs() + if enabledTabs != newTabs { + enabledTabs = newTabs + // If current tab was disabled, switch to first available + if !enabledTabs.contains(selectedTab) { + selectedTab = enabledTabs.first ?? .system + } + } + } .onAppear { ImageDownloader.default.authenticationChallengeResponder = appServices // Switch to sitemaps in demo mode if Preferences.shared.currentHomePreferences.demomode { selectedTab = .sitemaps - sitemapsTab.navigateToSitemap(name: "demo", widgetId: nil) + sitemapNavigationCommand = SitemapNavigationCommand(name: "demo", widgetId: nil) } } .onReceive(appServices.$navigationCommand) { command in @@ -149,16 +177,31 @@ struct OpenHABTabRootView: View { } } + @ViewBuilder + private func tabContentView(for tab: AppTab) -> some View { + switch tab { + case .main: + MainWebTab(webViewController: webViewController) + .ignoresSafeArea() + case .sitemaps: + SitemapsTab(resetTrigger: sitemapsResetTrigger, navigationCommand: $sitemapNavigationCommand) + case .tiles: + TilesTab(resetTrigger: tilesResetTrigger) + case .system: + SystemTab(resetTrigger: systemResetTrigger) + } + } + private func resetTab(_ tab: AppTab) { switch tab { case .main: webViewController.loadWebView(force: true) case .sitemaps: - sitemapsTab.resetToRoot() + sitemapsResetTrigger += 1 case .tiles: - tilesTab.resetToRoot() + tilesResetTrigger += 1 case .system: - systemTab.resetToRoot() + systemResetTrigger += 1 } } @@ -175,7 +218,7 @@ struct OpenHABTabRootView: View { } case let .switchToSitemap(name, widgetId): selectedTab = .sitemaps - sitemapsTab.navigateToSitemap(name: name, widgetId: widgetId) + sitemapNavigationCommand = SitemapNavigationCommand(name: name, widgetId: widgetId) } } } diff --git a/openHAB/SwiftUI/RootView/SitemapsTab.swift b/openHAB/SwiftUI/RootView/SitemapsTab.swift index 3ff51a9c2..14e9aafc6 100644 --- a/openHAB/SwiftUI/RootView/SitemapsTab.swift +++ b/openHAB/SwiftUI/RootView/SitemapsTab.swift @@ -15,7 +15,16 @@ import os.log import SFSafeSymbols import SwiftUI +struct SitemapNavigationCommand: Equatable { + let name: String + let widgetId: String? + let id = UUID() +} + struct SitemapsTab: View { + var resetTrigger: Int = 0 + @Binding var navigationCommand: SitemapNavigationCommand? + @State private var sitemaps: [OpenHABSitemap] = [] @State private var selectedSitemap: String? @State private var sitemapForWatch: String? @@ -28,7 +37,7 @@ struct SitemapsTab: View { var body: some View { NavigationStack { Group { - if let selectedSitemap { + if selectedSitemap != nil { SitemapNavigationContent(viewModel: viewModel) } else { sitemapList @@ -61,6 +70,22 @@ struct SitemapsTab: View { await fetchSitemaps(activeConnection: activeConnection) } } + .onChange(of: resetTrigger) { _, _ in + withAnimation { + selectedSitemap = nil + } + } + .onChange(of: navigationCommand) { _, command in + guard let command else { return } + selectedSitemap = command.name + Preferences.shared.modifyActiveHome { preferences in + preferences.defaultSitemap = command.name + } + Task { + await viewModel.pushSitemap(name: command.name, path: command.widgetId) + } + navigationCommand = nil + } } private var sitemapList: some View { @@ -96,20 +121,6 @@ struct SitemapsTab: View { } } - func resetToRoot() { - selectedSitemap = nil - } - - func navigateToSitemap(name: String, widgetId: String?) { - selectedSitemap = name - Preferences.shared.modifyActiveHome { preferences in - preferences.defaultSitemap = name - } - Task { - await viewModel.pushSitemap(name: name, path: widgetId) - } - } - private func autoSelectSitemap() { let defaultSitemap = Preferences.shared.currentHomePreferences.defaultSitemap if !defaultSitemap.isEmpty, sitemaps.contains(where: { $0.name == defaultSitemap }) { diff --git a/openHAB/SwiftUI/RootView/SystemTab.swift b/openHAB/SwiftUI/RootView/SystemTab.swift index 209ce0bc0..1cef01938 100644 --- a/openHAB/SwiftUI/RootView/SystemTab.swift +++ b/openHAB/SwiftUI/RootView/SystemTab.swift @@ -37,8 +37,10 @@ struct ConnectionView: View { } struct SystemTab: View { + var resetTrigger: Int = 0 + @State private var showNotifications = false - @State var path = NavigationPath() + @State private var path = NavigationPath() var body: some View { NavigationStack(path: $path) { @@ -90,10 +92,9 @@ struct SystemTab: View { .onReceive(Preferences.shared.$currentHomePreferences) { _ in updateNotificationVisibility() } - } - - func resetToRoot() { - path = NavigationPath() + .onChange(of: resetTrigger) { _, _ in + path = NavigationPath() + } } private func updateNotificationVisibility() { diff --git a/openHAB/SwiftUI/RootView/TilesTab.swift b/openHAB/SwiftUI/RootView/TilesTab.swift index d04846f46..4ac5bab61 100644 --- a/openHAB/SwiftUI/RootView/TilesTab.swift +++ b/openHAB/SwiftUI/RootView/TilesTab.swift @@ -13,17 +13,20 @@ import OpenHABCore import os.log import SFSafeSymbols import SwiftUI +@preconcurrency import WebKit struct TilesTab: View { + var resetTrigger: Int = 0 + @State private var uiTiles: [OpenHABUiTile] = [] - @State private var selectedTileURL: IdentifiableURL? + @State private var path = NavigationPath() @EnvironmentObject private var networkTracker: MainActorNetworkTracker @ScaledMetric private var iconWidth = 24.0 var body: some View { - NavigationStack { + NavigationStack(path: $path) { List { ForEach(uiTiles, id: \.url) { tile in Button { @@ -41,6 +44,11 @@ struct TilesTab: View { } .navigationTitle("Tiles") .navigationBarTitleDisplayMode(.inline) + .navigationDestination(for: URL.self) { url in + TileWebView(url: url) + .ignoresSafeArea(edges: .bottom) + .navigationBarTitleDisplayMode(.inline) + } } .task { await fetchTiles(activeConnection: networkTracker.activeConnection) @@ -50,15 +58,11 @@ struct TilesTab: View { await fetchTiles(activeConnection: activeConnection) } } - .sheet(item: $selectedTileURL) { item in - SafariView(url: item.url) + .onChange(of: resetTrigger) { _, _ in + path = NavigationPath() } } - func resetToRoot() { - selectedTileURL = nil - } - private func openTile(_ tile: OpenHABUiTile) { let urlString = tile.url guard !urlString.isEmpty else { return } @@ -75,7 +79,7 @@ struct TilesTab: View { } if let url { - selectedTileURL = IdentifiableURL(url: url) + path.append(url) } } @@ -92,7 +96,14 @@ struct TilesTab: View { } } -private struct IdentifiableURL: Identifiable { - let id = UUID() +private struct TileWebView: UIViewRepresentable { let url: URL + + func makeUIView(context: Context) -> WKWebView { + let webView = WKWebView() + webView.load(URLRequest(url: url)) + return webView + } + + func updateUIView(_ webView: WKWebView, context: Context) {} } From b30800fa584bb8942f4334a4b32a8282cccdcea7 Mon Sep 17 00:00:00 2001 From: Tassilo Karge Date: Sat, 14 Feb 2026 11:35:46 +0100 Subject: [PATCH 472/476] Add reorder handles to tab settings and make config per-home Show drag handles in the Tabs section by setting edit mode to active, making it visually clear that rows can be reordered. Move tab configuration from ApplicationPreferences to HomePreferences so each home can have its own tab layout. The property is optional for backward-compatible Codable decoding of existing preferences. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Tassilo Karge --- .../Sources/OpenHABCore/Util/Preferences.swift | 15 +-------------- openHAB/SettingsView/SettingsView.swift | 4 ++-- .../SettingsView/TabCustomizationSection.swift | 1 + openHAB/SwiftUI/RootView/OpenHABTabRootView.swift | 2 +- 4 files changed, 5 insertions(+), 17 deletions(-) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift index 40f4ca172..1c9af72eb 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift @@ -105,6 +105,7 @@ public struct HomePreferences: Codable, Equatable { public var homeName = "Home" public var sseCommandItem = "" public var lastSelectedTab = "main" + public var tabConfiguration: [TabEntry]? fileprivate init(id: UUID) { self.id = id @@ -131,20 +132,6 @@ public struct TabEntry: Codable, Equatable, Hashable, Sendable { @MainActor public struct ApplicationPreferences: Codable, Equatable { public var showSearchField = true - public var tabConfiguration: [TabEntry] = TabEntry.defaultConfiguration - - enum CodingKeys: String, CodingKey { - case showSearchField - case tabConfiguration - } - - public init() {} - - public nonisolated init(from decoder: any Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - showSearchField = try container.decodeIfPresent(Bool.self, forKey: .showSearchField) ?? true - tabConfiguration = try container.decodeIfPresent([TabEntry].self, forKey: .tabConfiguration) ?? TabEntry.defaultConfiguration - } } // MARK: Retrieving preference from user defaults, reacting to preference change diff --git a/openHAB/SettingsView/SettingsView.swift b/openHAB/SettingsView/SettingsView.swift index e8410beb1..9406b3fb2 100644 --- a/openHAB/SettingsView/SettingsView.swift +++ b/openHAB/SettingsView/SettingsView.swift @@ -208,7 +208,7 @@ struct SettingsView: View { settingsRemoteConnectionConfiguration = Preferences.shared.currentHomePreferences.remoteConnectionConfig settingsHomeName = Preferences.shared.currentHomePreferences.homeName settingsSSECommandItem = Preferences.shared.currentHomePreferences.sseCommandItem - settingsTabConfiguration = Preferences.shared.applicationPreferences.tabConfiguration + settingsTabConfiguration = Preferences.shared.currentHomePreferences.tabConfiguration ?? TabEntry.defaultConfiguration } func saveSettings() { @@ -224,13 +224,13 @@ struct SettingsView: View { homePreferences.localConnectionConfig = settingsLocalConnectionConfiguration homePreferences.remoteConnectionConfig = settingsRemoteConnectionConfiguration homePreferences.sseCommandItem = settingsSSECommandItem + homePreferences.tabConfiguration = settingsTabConfiguration } Preferences.shared.idleOff = settingsIdleOff Preferences.shared.sendCrashReports = settingsSendCrashReports Preferences.shared.modifyApplicationPreferences { @MainActor applicationPreferences in applicationPreferences.showSearchField = settingsShowSearchField - applicationPreferences.tabConfiguration = settingsTabConfiguration } // Apply global UI changes immediately (status bar visibility) diff --git a/openHAB/SettingsView/TabCustomizationSection.swift b/openHAB/SettingsView/TabCustomizationSection.swift index 176e085cc..06160cd61 100644 --- a/openHAB/SettingsView/TabCustomizationSection.swift +++ b/openHAB/SettingsView/TabCustomizationSection.swift @@ -35,6 +35,7 @@ struct TabCustomizationSection: View { tabConfiguration.move(fromOffsets: source, toOffset: destination) } } + .environment(\.editMode, .constant(.active)) } private func displayName(for id: String) -> String { diff --git a/openHAB/SwiftUI/RootView/OpenHABTabRootView.swift b/openHAB/SwiftUI/RootView/OpenHABTabRootView.swift index 686eaaa4c..5981fb065 100644 --- a/openHAB/SwiftUI/RootView/OpenHABTabRootView.swift +++ b/openHAB/SwiftUI/RootView/OpenHABTabRootView.swift @@ -84,7 +84,7 @@ struct OpenHABTabRootView: View { } private static func computeEnabledTabs() -> [AppTab] { - let config = Preferences.shared.applicationPreferences.tabConfiguration + let config = Preferences.shared.currentHomePreferences.tabConfiguration ?? TabEntry.defaultConfiguration let tabs = config.compactMap { entry -> AppTab? in guard entry.enabled || entry.id == AppTab.system.rawValue else { return nil } return AppTab(rawValue: entry.id) From fb63406a62bcd65285617baa4accc0d5b51bd6de Mon Sep 17 00:00:00 2001 From: Tassilo Karge Date: Sat, 14 Feb 2026 11:46:51 +0100 Subject: [PATCH 473/476] Add visible drag handles and refresh tabs on home switch Add explicit line.3.horizontal icon to each tab row so reorder capability is always visually indicated. Replace notification-based tab refresh with Preferences.$currentHomePreferences observer so the tab bar updates when switching between homes. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Tassilo Karge --- openHAB/SettingsView/TabCustomizationSection.swift | 3 +++ openHAB/SwiftUI/RootView/OpenHABTabRootView.swift | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/openHAB/SettingsView/TabCustomizationSection.swift b/openHAB/SettingsView/TabCustomizationSection.swift index 06160cd61..2e2bfe9b7 100644 --- a/openHAB/SettingsView/TabCustomizationSection.swift +++ b/openHAB/SettingsView/TabCustomizationSection.swift @@ -19,6 +19,9 @@ struct TabCustomizationSection: View { Section(header: Text("Tabs")) { ForEach(Array(tabConfiguration.enumerated()), id: \.element.id) { index, entry in HStack { + Image(systemName: "line.3.horizontal") + .foregroundStyle(.secondary) + .font(.callout) Image(systemName: systemImage(for: entry.id)) .frame(width: 24) .foregroundStyle(entry.enabled || entry.id == "system" ? .primary : .secondary) diff --git a/openHAB/SwiftUI/RootView/OpenHABTabRootView.swift b/openHAB/SwiftUI/RootView/OpenHABTabRootView.swift index 5981fb065..1b9fb82e2 100644 --- a/openHAB/SwiftUI/RootView/OpenHABTabRootView.swift +++ b/openHAB/SwiftUI/RootView/OpenHABTabRootView.swift @@ -110,7 +110,7 @@ struct OpenHABTabRootView: View { prefs.lastSelectedTab = newTab.rawValue } } - .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("org.openhab.preferences.saved"))) { _ in + .onReceive(Preferences.shared.$currentHomePreferences) { _ in let newTabs = Self.computeEnabledTabs() if enabledTabs != newTabs { enabledTabs = newTabs From bd9302cae6e241968d83f2e6994f46dce16ccf9f Mon Sep 17 00:00:00 2001 From: Tassilo Karge Date: Mon, 16 Feb 2026 01:48:46 +0100 Subject: [PATCH 474/476] Remove @MainActor from Preferences, delete legacy UIKit cells, and reorganize project structure Refactor Preferences to use actor isolation with AsyncChannel (swift-async-algorithms) instead of @MainActor, consolidate individual screensaver/application preferences into dedicated structs with migration support, and add PreferencesObserver @Observable wrapper for SwiftUI. Remove all legacy UIKit table view cells, cell providers, and the UIKit sitemap view controller. Reorganize source files into SwiftUI/ and UIKit/ subdirectories. Bump Swift tools version to 6.2 and platform targets to 26.0. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Tassilo Karge Signed-off-by: Tassilo Karge --- CommonUI/Package.swift | 5 +- OpenHABCore/Package.swift | 10 +- .../OpenHABCore/Util/NWPathMonitoring.swift | 16 +- .../OpenHABCore/Util/Preferences.swift | 419 ++++++-- openHAB.xcodeproj/project.pbxproj | 203 ++-- .../xcshareddata/swiftpm/Package.resolved | 20 +- openHAB/AppDelegate.swift | 129 ++- .../Providers/ColorPickerCellProvider.swift | 31 - .../Providers/DatePickerInputProvider.swift | 30 - .../Cells/Providers/FrameCellProvider.swift | 29 - .../Cells/Providers/GenericCellProvider.swift | 29 - .../Cells/Providers/ImageCellProvider.swift | 33 - .../Cells/Providers/MapViewCellProvider.swift | 29 - .../Providers/RollershutterCellProvider.swift | 29 - .../Providers/SegmentedCellProvider.swift | 29 - .../Providers/SelectionCellProvider.swift | 29 - .../Providers/SetpointCellProvider.swift | 29 - openHAB/Cells/Providers/SliderProvider.swift | 29 - .../Providers/SliderWithSwitchProvider.swift | 29 - .../Cells/Providers/SwitchCellProvider.swift | 29 - .../Cells/Providers/TextInputProvider.swift | 29 - .../Cells/Providers/VideoCellProvider.swift | 29 - .../Cells/Providers/WebViewCellProvider.swift | 29 - openHAB/Cells/WidgetCellProvider.swift | 61 -- openHAB/ColorPickerCell.swift | 70 -- openHAB/ColorPickerViewController.swift | 87 -- openHAB/DatePickerUITableViewCell.swift | 56 - openHAB/FrameUITableViewCell.swift | 36 - openHAB/GenericUITableViewCell.swift | 100 -- openHAB/Main.storyboard | 702 +------------ openHAB/MapViewTableViewCell.swift | 65 -- openHAB/NewImageUITableViewCell.swift | 258 ----- openHAB/NotificationTableViewCell.swift | 31 - openHAB/PlayerView.swift | 36 - openHAB/ReusableView.swift | 25 - openHAB/RollershutterCell.swift | 77 -- openHAB/SegmentedUITableViewCell.swift | 63 -- openHAB/SelectionUITableViewCell.swift | 37 - openHAB/SetpointCell.swift | 88 -- openHAB/SitemapPageViewModel.swift | 16 +- openHAB/SliderUITableViewCell.swift | 110 -- ...iderWithSwitchSupportUITableViewCell.swift | 137 --- openHAB/{ => SwiftUI}/ColorPickerView.swift | 0 .../{ => SwiftUI}/NoIconDisplayableCell.swift | 0 openHAB/SwiftUI/NotificationsView.swift | 2 +- .../RootView/AppServicesViewModel.swift | 206 ++-- .../SwiftUI/RootView/OpenHABTabRootView.swift | 19 +- .../SwiftUI/RootView/REFACTORING_SUMMARY.md | 252 +++++ openHAB/SwiftUI/RootView/SitemapsTab.swift | 4 +- openHAB/SwiftUI/RootView/SystemTab.swift | 16 +- openHAB/SwiftUI/Rows/VideoRowView.swift | 4 + .../ScreenSaverConfiguration.swift | 0 .../ScreenSaver/ScreenSaverManager.swift | 0 .../ScreenSaver/ScreenSaverView.swift | 0 .../SettingsView/AboutSettingsView.swift | 0 .../AnimatedSecureTextField.swift | 0 .../ApplicationSettingsView.swift | 11 +- .../SettingsView/BonjourDiscoverySheet.swift | 0 .../SettingsView/ClientCertificatesView.swift | 0 .../SettingsView/ConnectionSettingsView.swift | 0 .../SettingsView/DebugSettingsView.swift | 4 +- .../SettingsView/ItemSelectionView.swift | 0 .../SettingsView/MainUISettingsView.swift | 0 .../ScreenSaverSettingsView.swift | 70 +- .../SettingsView/ServerCertificatesView.swift | 0 .../SettingsView/SettingsView.swift | 118 ++- .../SingleConnectionSettingsView.swift | 0 .../SettingsView/SitemapSettingsView.swift | 0 .../TabCustomizationSection.swift | 0 .../SwiftUI/SitemapView/SitemapPageView.swift | 10 +- openHAB/{ => SwiftUI}/Throttler.swift | 0 .../{ => SwiftUI}/VideoStreamManager.swift | 0 openHAB/SwitchUITableViewCell.swift | 61 -- openHAB/TextInputUITableViewCell.swift | 26 - .../UIKit/OpenHABNavigationController.swift | 3 +- .../UIKit/OpenHABSitemapViewController.swift | 969 ------------------ openHAB/UIKit/OpenHABViewController.swift | 8 +- openHAB/UIKit/OpenHABWebViewController.swift | 13 +- .../{ => UIKit}/ScaleAspectFitImageView.swift | 0 openHAB/UIKit/SpinnerViewController.swift | 32 - openHAB/{ => UIKit}/UICircleButton.swift | 0 .../{ => UIKit}/UILabel+Localization.swift | 0 openHAB/UIKit/UITableViewCellExtension.swift | 5 + .../UIViewController+Localization.swift | 0 openHAB/{ => UIKit}/URL+Static.swift | 0 openHAB/UITableView.swift | 25 - openHAB/VideoUITableViewCell.swift | 289 ------ openHAB/WatchMessageService.swift | 35 +- openHAB/WebUITableViewCell.swift | 127 --- openHABIntents/OpenHABIntentHelper.swift | 9 +- 90 files changed, 1078 insertions(+), 4568 deletions(-) delete mode 100644 openHAB/Cells/Providers/ColorPickerCellProvider.swift delete mode 100644 openHAB/Cells/Providers/DatePickerInputProvider.swift delete mode 100644 openHAB/Cells/Providers/FrameCellProvider.swift delete mode 100644 openHAB/Cells/Providers/GenericCellProvider.swift delete mode 100644 openHAB/Cells/Providers/ImageCellProvider.swift delete mode 100644 openHAB/Cells/Providers/MapViewCellProvider.swift delete mode 100644 openHAB/Cells/Providers/RollershutterCellProvider.swift delete mode 100644 openHAB/Cells/Providers/SegmentedCellProvider.swift delete mode 100644 openHAB/Cells/Providers/SelectionCellProvider.swift delete mode 100644 openHAB/Cells/Providers/SetpointCellProvider.swift delete mode 100644 openHAB/Cells/Providers/SliderProvider.swift delete mode 100644 openHAB/Cells/Providers/SliderWithSwitchProvider.swift delete mode 100644 openHAB/Cells/Providers/SwitchCellProvider.swift delete mode 100644 openHAB/Cells/Providers/TextInputProvider.swift delete mode 100644 openHAB/Cells/Providers/VideoCellProvider.swift delete mode 100644 openHAB/Cells/Providers/WebViewCellProvider.swift delete mode 100644 openHAB/Cells/WidgetCellProvider.swift delete mode 100644 openHAB/ColorPickerCell.swift delete mode 100644 openHAB/ColorPickerViewController.swift delete mode 100644 openHAB/DatePickerUITableViewCell.swift delete mode 100644 openHAB/FrameUITableViewCell.swift delete mode 100644 openHAB/GenericUITableViewCell.swift delete mode 100644 openHAB/MapViewTableViewCell.swift delete mode 100644 openHAB/NewImageUITableViewCell.swift delete mode 100644 openHAB/NotificationTableViewCell.swift delete mode 100644 openHAB/PlayerView.swift delete mode 100644 openHAB/ReusableView.swift delete mode 100644 openHAB/RollershutterCell.swift delete mode 100644 openHAB/SegmentedUITableViewCell.swift delete mode 100644 openHAB/SelectionUITableViewCell.swift delete mode 100644 openHAB/SetpointCell.swift delete mode 100644 openHAB/SliderUITableViewCell.swift delete mode 100644 openHAB/SliderWithSwitchSupportUITableViewCell.swift rename openHAB/{ => SwiftUI}/ColorPickerView.swift (100%) rename openHAB/{ => SwiftUI}/NoIconDisplayableCell.swift (100%) create mode 100644 openHAB/SwiftUI/RootView/REFACTORING_SUMMARY.md rename openHAB/{ => SwiftUI}/ScreenSaver/ScreenSaverConfiguration.swift (100%) rename openHAB/{ => SwiftUI}/ScreenSaver/ScreenSaverManager.swift (100%) rename openHAB/{ => SwiftUI}/ScreenSaver/ScreenSaverView.swift (100%) rename openHAB/{ => SwiftUI}/SettingsView/AboutSettingsView.swift (100%) rename openHAB/{ => SwiftUI}/SettingsView/AnimatedSecureTextField.swift (100%) rename openHAB/{ => SwiftUI}/SettingsView/ApplicationSettingsView.swift (84%) rename openHAB/{ => SwiftUI}/SettingsView/BonjourDiscoverySheet.swift (100%) rename openHAB/{ => SwiftUI}/SettingsView/ClientCertificatesView.swift (100%) rename openHAB/{ => SwiftUI}/SettingsView/ConnectionSettingsView.swift (100%) rename openHAB/{ => SwiftUI}/SettingsView/DebugSettingsView.swift (93%) rename openHAB/{ => SwiftUI}/SettingsView/ItemSelectionView.swift (100%) rename openHAB/{ => SwiftUI}/SettingsView/MainUISettingsView.swift (100%) rename openHAB/{ => SwiftUI}/SettingsView/ScreenSaverSettingsView.swift (72%) rename openHAB/{ => SwiftUI}/SettingsView/ServerCertificatesView.swift (100%) rename openHAB/{ => SwiftUI}/SettingsView/SettingsView.swift (61%) rename openHAB/{ => SwiftUI}/SettingsView/SingleConnectionSettingsView.swift (100%) rename openHAB/{ => SwiftUI}/SettingsView/SitemapSettingsView.swift (100%) rename openHAB/{ => SwiftUI}/SettingsView/TabCustomizationSection.swift (100%) rename openHAB/{ => SwiftUI}/Throttler.swift (100%) rename openHAB/{ => SwiftUI}/VideoStreamManager.swift (100%) delete mode 100644 openHAB/SwitchUITableViewCell.swift delete mode 100644 openHAB/TextInputUITableViewCell.swift delete mode 100644 openHAB/UIKit/OpenHABSitemapViewController.swift rename openHAB/{ => UIKit}/ScaleAspectFitImageView.swift (100%) delete mode 100644 openHAB/UIKit/SpinnerViewController.swift rename openHAB/{ => UIKit}/UICircleButton.swift (100%) rename openHAB/{ => UIKit}/UILabel+Localization.swift (100%) rename openHAB/{ => UIKit}/UIViewController+Localization.swift (100%) rename openHAB/{ => UIKit}/URL+Static.swift (100%) delete mode 100644 openHAB/UITableView.swift delete mode 100644 openHAB/VideoUITableViewCell.swift delete mode 100644 openHAB/WebUITableViewCell.swift diff --git a/CommonUI/Package.swift b/CommonUI/Package.swift index cc5f485db..f97ec6c42 100644 --- a/CommonUI/Package.swift +++ b/CommonUI/Package.swift @@ -1,12 +1,11 @@ -// swift-tools-version: 6.1 +// swift-tools-version: 6.2 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription let package = Package( name: "CommonUI", - platforms: [.iOS(.v16), .watchOS(.v10), .macOS(.v14)], - + platforms: [.iOS("26.0"), .watchOS("26.0"), .macOS("26.0")], products: [ // Products define the executables and libraries a package produces, making them visible to other packages. .library( diff --git a/OpenHABCore/Package.swift b/OpenHABCore/Package.swift index e986e4632..e7893b109 100644 --- a/OpenHABCore/Package.swift +++ b/OpenHABCore/Package.swift @@ -1,11 +1,11 @@ -// swift-tools-version: 6.1 +// swift-tools-version: 6.2 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription let package = Package( name: "OpenHABCore", - platforms: [.iOS(.v16), .watchOS(.v10), .macOS(.v14)], + platforms: [.iOS("26.0"), .watchOS("26.0"), .macOS("26.0")], products: [ // Products define the executables and libraries a package produces, and make them visible to other packages. .library( @@ -21,7 +21,8 @@ let package = Package( .package(url: "https://github.com/apple/swift-http-types.git", from: "1.5.1"), .package(url: "https://github.com/SDWebImage/SDWebImageSVGCoder.git", from: "1.4.0"), .package(url: "https://github.com/SFSafeSymbols/SFSafeSymbols.git", from: "7.0.0"), - .package(url: "https://github.com/swhitty/swift-timeout.git", from: "0.4.0") + .package(url: "https://github.com/swhitty/swift-timeout.git", from: "0.4.0"), + .package(url: "https://github.com/apple/swift-async-algorithms", from: "1.0.0") ], targets: [ // Targets are the basic building blocks of a package. A target can define a module or a test suite. @@ -35,7 +36,8 @@ let package = Package( .product(name: "HTTPTypes", package: "swift-http-types"), .product(name: "SDWebImageSVGCoder", package: "SDWebImageSVGCoder"), .product(name: "SFSafeSymbols", package: "SFSafeSymbols"), - .product(name: "Timeout", package: "swift-timeout") + .product(name: "Timeout", package: "swift-timeout"), + .product(name: "AsyncAlgorithms", package: "swift-async-algorithms") ], swiftSettings: [ .enableUpcomingFeature("ExistentialAny"), diff --git a/OpenHABCore/Sources/OpenHABCore/Util/NWPathMonitoring.swift b/OpenHABCore/Sources/OpenHABCore/Util/NWPathMonitoring.swift index fc802cb89..1e4caeb12 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/NWPathMonitoring.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/NWPathMonitoring.swift @@ -20,20 +20,14 @@ final class RealPathMonitor: NWPathMonitoring, Sendable { init() { monitor = NWPathMonitor() } - + func startMonitoring(handler: @escaping (Bool) async -> Void) async { - if #available(iOS 17, watchOS 10, *) { - for await path in monitor { - Logger.nwPathMonitoring.debug("Path monitor update: \(path.debugDescription)") - await handler(path.status == .satisfied || path.status == .requiresConnection) - } - } else { - for await path in monitor.paths() { - await handler(path.status == .satisfied || path.status == .requiresConnection) - } + for await path in monitor { + Logger.nwPathMonitoring.debug("Path monitor update: \(path.debugDescription)") + await handler(path.status == .satisfied || path.status == .requiresConnection) } } - + func cancel() { monitor.cancel() } diff --git a/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift index 1c9af72eb..652502cf7 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift @@ -9,33 +9,37 @@ // // SPDX-License-Identifier: EPL-2.0 -@preconcurrency import Combine +import AsyncAlgorithms import os.log -import UIKit +import SwiftUI -@MainActor -private let sharedDefaults = UserDefaults(suiteName: "group.org.openhab.app")! +// Thread-safe access to UserDefaults - still needs main actor due to UserDefaults not being Sendable +private nonisolated(unsafe) let sharedDefaults = UserDefaults(suiteName: "group.org.openhab.app")! -@MainActor @propertyWrapper public struct UserDefault { private let key: String private let defaultValue: T private let isHomeProperty: Bool private let subject: CurrentValueSubject + private let channel: AsyncChannel public var wrappedValue: T { get { PreferencesAccess.getPreference(key: key, defaultValue: defaultValue, encoder: { $0 }, decoder: { $0 as? T }) } set { - PreferencesAccess.preferenceChanged(newValue: newValue, key: key, isHomeProperty: isHomeProperty, subject: subject) { $0 } + PreferencesAccess.preferenceChanged(newValue: newValue, key: key, isHomeProperty: isHomeProperty, subject: subject, channel: channel) { $0 } } } public var projectedValue: AnyPublisher { subject.eraseToAnyPublisher() } + + public var asyncValues: AsyncChannel { + channel + } public init(_ key: String, defaultValue: T, isHomeProperty: Bool = false) { self.key = key @@ -43,16 +47,17 @@ public struct UserDefault { self.isHomeProperty = isHomeProperty let currentValue = PreferencesAccess.getPreference(key: key, defaultValue: defaultValue, encoder: { $0 }, decoder: { $0 as? T }) subject = CurrentValueSubject(currentValue) + channel = AsyncChannel() } } -@MainActor @propertyWrapper public struct UserDefaultObject { private let key: String private let defaultValue: T private let isHomeProperty: Bool private let subject: CurrentValueSubject + private let channel: AsyncChannel private let objectDecoder: (Any) -> (T?) = { guard let data = $0 as? Data else { @@ -68,13 +73,17 @@ public struct UserDefaultObject { PreferencesAccess.getPreference(key: key, defaultValue: defaultValue, encoder: objectEncoder, decoder: objectDecoder) } set { - PreferencesAccess.preferenceChanged(newValue: newValue, key: key, isHomeProperty: isHomeProperty, subject: subject, converter: objectEncoder) + PreferencesAccess.preferenceChanged(newValue: newValue, key: key, isHomeProperty: isHomeProperty, subject: subject, channel: channel, converter: objectEncoder) } } public var projectedValue: AnyPublisher { subject.eraseToAnyPublisher() } + + public var asyncValues: AsyncChannel { + channel + } init(_ key: String, defaultValue: T, isHomeProperty: Bool = false) { self.key = key @@ -84,11 +93,11 @@ public struct UserDefaultObject { // Combine publication let currentValue = PreferencesAccess.getPreference(key: key, defaultValue: defaultValue, encoder: objectEncoder, decoder: objectDecoder) subject = CurrentValueSubject(currentValue) + channel = AsyncChannel() } } -@MainActor -public struct HomePreferences: Codable, Equatable { +public struct HomePreferences: Codable, Equatable, Sendable { public let id: UUID public var defaultView = "web" public var demomode = true @@ -105,7 +114,7 @@ public struct HomePreferences: Codable, Equatable { public var homeName = "Home" public var sseCommandItem = "" public var lastSelectedTab = "main" - public var tabConfiguration: [TabEntry]? + public var tabConfiguration = TabEntry.defaultConfiguration fileprivate init(id: UUID) { self.id = id @@ -129,9 +138,29 @@ public struct TabEntry: Codable, Equatable, Hashable, Sendable { ] } -@MainActor -public struct ApplicationPreferences: Codable, Equatable { +public struct ApplicationPreferences: Codable, Equatable, Sendable { public var showSearchField = true + public var sendCrashReports = false + public var idleOff = false + public var hideStatusBar = false +} + +public struct ScreenSaverPreferences: Codable, Equatable, Sendable { + public var isEnabled = false + public var showsTime = true + public var showsDate = true + public var idleInterval = 120.0 + public var movementInterval = 8.0 + public var fontName = "" + public var timeFontRatio = 0.2 + public var dateFontRatio = 0.4 + public var enableDimming = true + public var dimLevel = 0.3 + public var showsSeconds = false + public var use24Hour = false + public var fadeDuration = 2.0 + public var restoreBrightness = true + public var wakeBrightness = 1.0 } // MARK: Retrieving preference from user defaults, reacting to preference change @@ -147,7 +176,7 @@ public struct ApplicationPreferences: Codable, Equatable { // MARK: !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! private enum PreferencesAccess { - @MainActor fileprivate static func getPreference(key: String, defaultValue: T, encoder: (T) -> (some Sendable)?, decoder: (Any?) -> T?) -> T { + fileprivate static func getPreference(key: String, defaultValue: T, encoder: (T) -> (some Sendable)?, decoder: (Any?) -> T?) -> T { let preferenceValue = sharedDefaults.object(forKey: key) if let preferenceConverted = decoder(preferenceValue) { return preferenceConverted @@ -163,7 +192,7 @@ private enum PreferencesAccess { } } - @MainActor fileprivate static func preferenceChanged(newValue: T, key: String, isHomeProperty: Bool, subject: CurrentValueSubject, sanitize: (T) -> (T?) = { $0 }, converter: (T) -> (some Sendable)?) { + fileprivate static func preferenceChanged(newValue: T, key: String, isHomeProperty: Bool, subject: CurrentValueSubject, channel: AsyncChannel, sanitize: (T) -> (T?) = { $0 }, converter: (T) -> (some Sendable)?) { guard let sanitized = sanitize(newValue) else { Logger.preferences.debug("Preference \(key) new value \(String(describing: newValue), privacy: .private) could not be sanitized, will be ignored") return @@ -177,6 +206,11 @@ private enum PreferencesAccess { sharedDefaults.set(convertedValue, forKey: key) subject.send(sanitized) + + // Send to AsyncChannel + Task { + await channel.send(sanitized) + } } } @@ -189,12 +223,6 @@ public actor Preferences { @UserDefaultObject("currentHomePreferences", defaultValue: HomePreferences(id: defaultHomeId)) public private(set) var currentHomePreferences: HomePreferences - @UserDefault("sendCrashReports", defaultValue: false) - public var sendCrashReports: Bool - - @UserDefault("idleOff", defaultValue: false) - public var idleOff: Bool - @UserDefaultObject( "applicationPreferences", defaultValue: @@ -202,53 +230,12 @@ public actor Preferences { ) public private(set) var applicationPreferences: ApplicationPreferences - @UserDefault("screensaverEnabled", defaultValue: false) - public var screensaverEnabled: Bool - - @UserDefault("screensaverShowsTime", defaultValue: true) - public var screensaverShowsTime: Bool - - @UserDefault("screensaverShowsDate", defaultValue: true) - public var screensaverShowsDate: Bool - - @UserDefault("screensaverIdleInterval", defaultValue: 120.0) - public var screensaverIdleInterval: Double - - @UserDefault("screensaverMovementInterval", defaultValue: 8.0) - public var screensaverMovementInterval: Double - - @UserDefault("screensaverFontName", defaultValue: "") - public var screensaverFontName: String - - @UserDefault("screensaverTimeFontRatio", defaultValue: 0.2) - public var screensaverTimeFontRatio: Double - - @UserDefault("screensaverDateFontRatio", defaultValue: 0.4) - public var screensaverDateFontRatio: Double - - @UserDefault("screensaverEnableDimming", defaultValue: true) - public var screensaverEnableDimming: Bool - - @UserDefault("screensaverDimLevel", defaultValue: 0.3) - public var screensaverDimLevel: Double - - @UserDefault("screensaverShowsSeconds", defaultValue: false) - public var screensaverShowsSeconds: Bool - - @UserDefault("screensaverUse24Hour", defaultValue: false) - public var screensaverUse24Hour: Bool - - @UserDefault("screensaverFadeDuration", defaultValue: 2.0) - public var screensaverFadeDuration: Double - - @UserDefault("screensaverRestoreBrightness", defaultValue: true) - public var screensaverRestoreBrightness: Bool - - @UserDefault("screensaverWakeBrightness", defaultValue: 1.0) - public var screensaverWakeBrightness: Double - - @UserDefault("hideStatusBar", defaultValue: false) - public var hideStatusBar: Bool + @UserDefaultObject( + "screensaverPreferences", + defaultValue: + ScreenSaverPreferences() + ) + public private(set) var screensaverPreferences: ScreenSaverPreferences @UserDefault("currentWebViewPath", defaultValue: "") public var currentWebViewPath: String @@ -267,20 +254,62 @@ public actor Preferences { @UserDefault("didMigrateToMultipleHomes", defaultValue: false) private var didMigrateToMultipleHomes: Bool - @MainActor + @UserDefault("didMigrateScreenSaverPreferences", defaultValue: false) + private var didMigrateScreenSaverPreferences: Bool + + @UserDefault("didMigrateApplicationPreferences", defaultValue: false) + private var didMigrateApplicationPreferences: Bool + private var internalPreferenceChangeOngoing = false - @MainActor private func internalPreferenceChange(_ change: () -> Void) { internalPreferenceChangeOngoing = true change() internalPreferenceChangeOngoing = false } + + // MARK: - AsyncChannel Access + + /// Access the AsyncChannel for currentHomePreferences + public var currentHomePreferencesChannel: AsyncChannel { + _currentHomePreferences.asyncValues + } + + /// Access the AsyncChannel for applicationPreferences + public var applicationPreferencesChannel: AsyncChannel { + _applicationPreferences.asyncValues + } + + /// Access the AsyncChannel for screensaverPreferences + public var screensaverPreferencesChannel: AsyncChannel { + _screensaverPreferences.asyncValues + } + + /// Access the AsyncChannel for storedHomes + public var storedHomesChannel: AsyncChannel<[UUID: HomePreferences]> { + _storedHomes.asyncValues + } + + // Setter methods for actor-isolated properties (used in migration) + func setDidMigrateToSharedDefaults(_ value: Bool) { + didMigrateToSharedDefaults = value + } + + func setDidMigrateToMultipleHomes(_ value: Bool) { + didMigrateToMultipleHomes = value + } + + func setDidMigrateScreenSaverPreferences(_ value: Bool) { + didMigrateScreenSaverPreferences = value + } + + func setDidMigrateApplicationPreferences(_ value: Bool) { + didMigrateApplicationPreferences = value + } } // MARK: Multiple homes -@MainActor public extension Preferences { func listStoredHomes() -> [UUID] { let preferenceIds = storedHomes @@ -362,27 +391,36 @@ public extension Preferences { private func storeActiveHome() { var all = storedHomes - let homeId = Preferences.shared.activeHomeId - all[homeId] = Preferences.shared.currentHomePreferences + let homeId = activeHomeId + all[homeId] = currentHomePreferences storedHomes = all Logger.preferences.debug("Stored preferences for current home \(homeId.uuidString)") } - func modifyActiveHome(modificationFunction: @MainActor (inout HomePreferences) -> Void) { + func modifyActiveHome(modificationFunction: @Sendable (inout HomePreferences) -> Void) { var homePreferences = currentHomePreferences modificationFunction(&homePreferences) currentHomePreferences = homePreferences storeActiveHome() } - func modifyApplicationPreferences(modificationFunction: @MainActor (inout ApplicationPreferences) -> Void) { + func modifyApplicationPreferences(modificationFunction: @Sendable (inout ApplicationPreferences) -> Void) { var applicationPreferences = applicationPreferences modificationFunction(&applicationPreferences) self.applicationPreferences = applicationPreferences } + + func modifyScreenSaverPreferences(modificationFunction: @Sendable (inout ScreenSaverPreferences) -> Void) { + var screensaverPreferences = screensaverPreferences + modificationFunction(&screensaverPreferences) + self.screensaverPreferences = screensaverPreferences + } + + func setCurrentWebViewPath(_ path: String) { + currentWebViewPath = path + } } -@MainActor public extension Preferences { func firstStoredHome(where predicate: (HomePreferences) -> Bool) -> (id: UUID, record: HomePreferences)? { for (uuid, record) in storedHomes { @@ -401,18 +439,19 @@ public extension Preferences { // MARK: Migration -@MainActor public extension Preferences { - static func migratePreferences() { - Preferences.shared.initializeStoredHomes() - migrateToSharedDefaultsIfRequired() - migrateToMultipleHomesIfRequired() + static func migratePreferences() async { + await Preferences.shared.initializeStoredHomes() + await migrateToSharedDefaultsIfRequired() + await migrateToMultipleHomesIfRequired() + await migrateApplicationPreferencesIfRequired() + await migrateScreenSaverPreferencesIfRequired() } - private static func migrateToSharedDefaultsIfRequired() { - guard !Preferences.shared.didMigrateToSharedDefaults else { return } + private static func migrateToSharedDefaultsIfRequired() async { + guard await !Preferences.shared.didMigrateToSharedDefaults else { return } - Preferences.shared.modifyActiveHome { currentHomePreferences in + await Preferences.shared.modifyActiveHome { currentHomePreferences in currentHomePreferences.localConnectionConfig.url = UserDefaults.standard.string(forKey: "localUrl") ?? currentHomePreferences.localConnectionConfig.url currentHomePreferences.localConnectionConfig.alwaysSendBasicAuth = UserDefaults.standard.object(forKey: "alwaysSendCreds") as? Bool ?? currentHomePreferences.localConnectionConfig.alwaysSendBasicAuth currentHomePreferences.localConnectionConfig.ignoreSSL = UserDefaults.standard.object(forKey: "ignoreSSL") as? Bool ?? currentHomePreferences.localConnectionConfig.ignoreSSL @@ -427,18 +466,15 @@ public extension Preferences { currentHomePreferences.defaultSitemap = UserDefaults.standard.string(forKey: "defaultSitemap") ?? currentHomePreferences.defaultSitemap } - Preferences.shared.idleOff = UserDefaults.standard.object(forKey: "idleOff") as? Bool ?? Preferences.shared.idleOff - Preferences.shared.sendCrashReports = UserDefaults.standard.object(forKey: "sendCrashReports") as? Bool ?? Preferences.shared.sendCrashReports - - Preferences.shared.didMigrateToSharedDefaults = true + await Preferences.shared.setDidMigrateToSharedDefaults(true) // this was done implicitly - Preferences.shared.didMigrateToMultipleHomes = true + await Preferences.shared.setDidMigrateToMultipleHomes(true) } - private static func migrateToMultipleHomesIfRequired() { - guard !Preferences.shared.didMigrateToMultipleHomes else { return } + private static func migrateToMultipleHomesIfRequired() async { + guard await !Preferences.shared.didMigrateToMultipleHomes else { return } - migrateToSharedDefaultsIfRequired() + await migrateToSharedDefaultsIfRequired() let oldLocalUrl = sharedDefaults.string(forKey: "localUrl") let oldRemoteUrl = sharedDefaults.string(forKey: "remoteUrl") @@ -447,21 +483,21 @@ public extension Preferences { let oldAlwaysSendCreds = sharedDefaults.object(forKey: "alwaysSendCreds") as? Bool let oldIgnoreSSL = sharedDefaults.object(forKey: "ignoreSSL") as? Bool - // Create new configuration - var newLocalConfiguration = Preferences.shared.currentHomePreferences.localConnectionConfig - newLocalConfiguration.url = oldLocalUrl ?? newLocalConfiguration.url - newLocalConfiguration.alwaysSendBasicAuth = oldAlwaysSendCreds ?? newLocalConfiguration.alwaysSendBasicAuth - newLocalConfiguration.ignoreSSL = oldIgnoreSSL ?? newLocalConfiguration.ignoreSSL - - var newRemoteConfiguration = Preferences.shared.currentHomePreferences.remoteConnectionConfig - newRemoteConfiguration.url = oldRemoteUrl ?? newRemoteConfiguration.url - newRemoteConfiguration.username = oldUsername ?? newRemoteConfiguration.username - newRemoteConfiguration.password = oldPassword ?? newRemoteConfiguration.password - newRemoteConfiguration.alwaysSendBasicAuth = oldAlwaysSendCreds ?? newRemoteConfiguration.alwaysSendBasicAuth - newRemoteConfiguration.ignoreSSL = oldIgnoreSSL ?? newRemoteConfiguration.ignoreSSL - // Save to Preferences - Preferences.shared.modifyActiveHome { currentHomePreferences in + await Preferences.shared.modifyActiveHome { currentHomePreferences in + // Create new configuration inside the closure to avoid capture issues + var newLocalConfiguration = currentHomePreferences.localConnectionConfig + newLocalConfiguration.url = oldLocalUrl ?? newLocalConfiguration.url + newLocalConfiguration.alwaysSendBasicAuth = oldAlwaysSendCreds ?? newLocalConfiguration.alwaysSendBasicAuth + newLocalConfiguration.ignoreSSL = oldIgnoreSSL ?? newLocalConfiguration.ignoreSSL + + var newRemoteConfiguration = currentHomePreferences.remoteConnectionConfig + newRemoteConfiguration.url = oldRemoteUrl ?? newRemoteConfiguration.url + newRemoteConfiguration.username = oldUsername ?? newRemoteConfiguration.username + newRemoteConfiguration.password = oldPassword ?? newRemoteConfiguration.password + newRemoteConfiguration.alwaysSendBasicAuth = oldAlwaysSendCreds ?? newRemoteConfiguration.alwaysSendBasicAuth + newRemoteConfiguration.ignoreSSL = oldIgnoreSSL ?? newRemoteConfiguration.ignoreSSL + currentHomePreferences.defaultView = sharedDefaults.string(forKey: "defaultView") ?? currentHomePreferences.defaultView currentHomePreferences.demomode = sharedDefaults.object(forKey: "demomode") as? Bool ?? currentHomePreferences.demomode currentHomePreferences.realTimeSliders = sharedDefaults.object(forKey: "realTimeSliders") as? Bool ?? currentHomePreferences.realTimeSliders @@ -476,16 +512,105 @@ public extension Preferences { currentHomePreferences.sitemapForWatchLabel = sharedDefaults.string(forKey: "sitemapForWatchLabel") ?? currentHomePreferences.sitemapForWatchLabel } - Preferences.shared.didMigrateToMultipleHomes = true + await Preferences.shared.setDidMigrateToMultipleHomes(true) + } + + private static func migrateApplicationPreferencesIfRequired() async { + guard await !Preferences.shared.didMigrateApplicationPreferences else { return } + + // Check if old preferences exist in UserDefaults + let oldSendCrashReports = sharedDefaults.object(forKey: "sendCrashReports") as? Bool + let oldIdleOff = sharedDefaults.object(forKey: "idleOff") as? Bool + let oldHideStatusBar = sharedDefaults.object(forKey: "hideStatusBar") as? Bool + + // Only migrate if at least one old preference exists + if oldSendCrashReports != nil || oldIdleOff != nil || oldHideStatusBar != nil { + await Preferences.shared.modifyApplicationPreferences { prefs in + if let oldSendCrashReports { prefs.sendCrashReports = oldSendCrashReports } + if let oldIdleOff { prefs.idleOff = oldIdleOff } + if let oldHideStatusBar { prefs.hideStatusBar = oldHideStatusBar } + } + + Logger.preferences.info("Migrated application preferences from individual keys to ApplicationPreferences struct") + + // Clean up old keys + sharedDefaults.removeObject(forKey: "sendCrashReports") + sharedDefaults.removeObject(forKey: "idleOff") + sharedDefaults.removeObject(forKey: "hideStatusBar") + } + + await Preferences.shared.setDidMigrateApplicationPreferences(true) + } + + private static func migrateScreenSaverPreferencesIfRequired() async { + guard await !Preferences.shared.didMigrateScreenSaverPreferences else { return } + + // Check if old preferences exist in UserDefaults + let oldEnabled = sharedDefaults.object(forKey: "screensaverEnabled") as? Bool + let oldShowsTime = sharedDefaults.object(forKey: "screensaverShowsTime") as? Bool + let oldShowsDate = sharedDefaults.object(forKey: "screensaverShowsDate") as? Bool + let oldIdleInterval = sharedDefaults.object(forKey: "screensaverIdleInterval") as? Double + let oldMovementInterval = sharedDefaults.object(forKey: "screensaverMovementInterval") as? Double + let oldFontName = sharedDefaults.string(forKey: "screensaverFontName") + let oldTimeFontRatio = sharedDefaults.object(forKey: "screensaverTimeFontRatio") as? Double + let oldDateFontRatio = sharedDefaults.object(forKey: "screensaverDateFontRatio") as? Double + let oldEnableDimming = sharedDefaults.object(forKey: "screensaverEnableDimming") as? Bool + let oldDimLevel = sharedDefaults.object(forKey: "screensaverDimLevel") as? Double + let oldShowsSeconds = sharedDefaults.object(forKey: "screensaverShowsSeconds") as? Bool + let oldUse24Hour = sharedDefaults.object(forKey: "screensaverUse24Hour") as? Bool + let oldFadeDuration = sharedDefaults.object(forKey: "screensaverFadeDuration") as? Double + let oldRestoreBrightness = sharedDefaults.object(forKey: "screensaverRestoreBrightness") as? Bool + let oldWakeBrightness = sharedDefaults.object(forKey: "screensaverWakeBrightness") as? Double + + // Only migrate if at least one old preference exists + if oldEnabled != nil || oldShowsTime != nil || oldIdleInterval != nil { + await Preferences.shared.modifyScreenSaverPreferences { prefs in + if let oldEnabled { prefs.isEnabled = oldEnabled } + if let oldShowsTime { prefs.showsTime = oldShowsTime } + if let oldShowsDate { prefs.showsDate = oldShowsDate } + if let oldIdleInterval { prefs.idleInterval = oldIdleInterval } + if let oldMovementInterval { prefs.movementInterval = oldMovementInterval } + if let oldFontName { prefs.fontName = oldFontName } + if let oldTimeFontRatio { prefs.timeFontRatio = oldTimeFontRatio } + if let oldDateFontRatio { prefs.dateFontRatio = oldDateFontRatio } + if let oldEnableDimming { prefs.enableDimming = oldEnableDimming } + if let oldDimLevel { prefs.dimLevel = oldDimLevel } + if let oldShowsSeconds { prefs.showsSeconds = oldShowsSeconds } + if let oldUse24Hour { prefs.use24Hour = oldUse24Hour } + if let oldFadeDuration { prefs.fadeDuration = oldFadeDuration } + if let oldRestoreBrightness { prefs.restoreBrightness = oldRestoreBrightness } + if let oldWakeBrightness { prefs.wakeBrightness = oldWakeBrightness } + } + + Logger.preferences.info("Migrated screen saver preferences from individual keys to ScreenSaverPreferences struct") + + // Clean up old keys (optional, but keeps UserDefaults tidy) + sharedDefaults.removeObject(forKey: "screensaverEnabled") + sharedDefaults.removeObject(forKey: "screensaverShowsTime") + sharedDefaults.removeObject(forKey: "screensaverShowsDate") + sharedDefaults.removeObject(forKey: "screensaverIdleInterval") + sharedDefaults.removeObject(forKey: "screensaverMovementInterval") + sharedDefaults.removeObject(forKey: "screensaverFontName") + sharedDefaults.removeObject(forKey: "screensaverTimeFontRatio") + sharedDefaults.removeObject(forKey: "screensaverDateFontRatio") + sharedDefaults.removeObject(forKey: "screensaverEnableDimming") + sharedDefaults.removeObject(forKey: "screensaverDimLevel") + sharedDefaults.removeObject(forKey: "screensaverShowsSeconds") + sharedDefaults.removeObject(forKey: "screensaverUse24Hour") + sharedDefaults.removeObject(forKey: "screensaverFadeDuration") + sharedDefaults.removeObject(forKey: "screensaverRestoreBrightness") + sharedDefaults.removeObject(forKey: "screensaverWakeBrightness") + } + + await Preferences.shared.setDidMigrateScreenSaverPreferences(true) } } // MARK: All connections -@MainActor public extension Preferences { func getNotificationConnection() -> ConnectionConfiguration? { - getNotificationConnection(of: [Preferences.shared.currentHomePreferences.remoteConnectionConfig]) + getNotificationConnection(of: [currentHomePreferences.remoteConnectionConfig]) } func getNotificationConnection(of homeConfig: HomePreferences) -> ConnectionConfiguration? { @@ -525,3 +650,79 @@ public extension ConnectionConfiguration { priority: 1 ) } + +// MARK: - SwiftUI Observable Wrapper + +/// A @MainActor observable wrapper for Preferences that can be used in SwiftUI views +@MainActor +@Observable +public final class PreferencesObserver { + public static let shared = PreferencesObserver() + + public private(set) var currentHomePreferences: HomePreferences + public private(set) var applicationPreferences: ApplicationPreferences + public private(set) var screensaverPreferences: ScreenSaverPreferences + + private var currentHomeTask: Task? + private var applicationTask: Task? + private var screensaverTask: Task? + + private init() { + // Initialize with default values - will be updated immediately by channels + self.currentHomePreferences = HomePreferences(id: UUID()) + self.applicationPreferences = ApplicationPreferences() + self.screensaverPreferences = ScreenSaverPreferences() + + // Bootstrap initial values and start listeners + Task { [weak self] in + guard let self else { return } + // Fetch initial values from the actor + let initialHome = await Preferences.shared.currentHomePreferences + let initialApp = await Preferences.shared.applicationPreferences + let initialScreen = await Preferences.shared.screensaverPreferences + + // Apply initial values on the main actor + self.currentHomePreferences = initialHome + self.applicationPreferences = initialApp + self.screensaverPreferences = initialScreen + + // Start listening to async channels + self.currentHomeTask = Task { [weak self] in + guard let self else { return } + let channel = await Preferences.shared.currentHomePreferencesChannel + for await value in channel { + await MainActor.run { self.currentHomePreferences = value } + } + } + + self.applicationTask = Task { [weak self] in + guard let self else { return } + let channel = await Preferences.shared.applicationPreferencesChannel + for await value in channel { + await MainActor.run { self.applicationPreferences = value } + } + } + + self.screensaverTask = Task { [weak self] in + guard let self else { return } + let channel = await Preferences.shared.screensaverPreferencesChannel + for await value in channel { + await MainActor.run { self.screensaverPreferences = value } + } + } + } + } + + @MainActor + deinit { + currentHomeTask?.cancel() + applicationTask?.cancel() + screensaverTask?.cancel() + } + + /// Get notification connection for current home + public func getNotificationConnection() async -> ConnectionConfiguration? { + await Preferences.shared.getNotificationConnection() + } +} + diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index 5c774b38e..9c5207f88 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -7,14 +7,18 @@ objects = { /* Begin PBXBuildFile section */ + 0C93C5B057F863322A1B9820 /* SystemTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4229903F00160C965E22DE21 /* SystemTab.swift */; }; 10472F7AF99C4940A6144817 /* TextRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 399449C421544C61AD83450C /* TextRow.swift */; }; 1224F78F228A89FD00750965 /* WatchMessageService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1224F78D228A89FC00750965 /* WatchMessageService.swift */; }; + 1CE7AC462008E29FA37F231D /* AppServicesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64BEA2793F07C36BB98D9B8 /* AppServicesViewModel.swift */; }; + 2C9BBB28C068BCC10291A566 /* TilesTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE239F0348D19000408BA8AA /* TilesTab.swift */; }; 2F08AFC72E5FADCF00E70611 /* NotificationCenterDelegateImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F08AFC62E5FADC500E70611 /* NotificationCenterDelegateImpl.swift */; }; + 2F54B56D2F40AC1A00DA1F1A /* REFACTORING_SUMMARY.md in Resources */ = {isa = PBXBuildFile; fileRef = 2F54B56C2F40AC1A00DA1F1A /* REFACTORING_SUMMARY.md */; }; + 2F54B5742F428ACA00DA1F1A /* AsyncAlgorithms in Frameworks */ = {isa = PBXBuildFile; productRef = 2F54B5732F428ACA00DA1F1A /* AsyncAlgorithms */; }; + 2F54B5772F428B0200DA1F1A /* Algorithms in Frameworks */ = {isa = PBXBuildFile; productRef = 2F54B5762F428B0200DA1F1A /* Algorithms */; }; 2F55E7BB2DEE447700EC8350 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F55E7BA2DEE447700EC8350 /* SettingsView.swift */; }; 2F55E7BD2DEE44A800EC8350 /* ClientCertificatesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F55E7BC2DEE44A800EC8350 /* ClientCertificatesView.swift */; }; - 2F6412EE2CE494A80039FB28 /* DatePickerUITableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F6412ED2CE494A80039FB28 /* DatePickerUITableViewCell.swift */; }; 2FBCF58C2DEB0B7700CD5D83 /* HomeSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FBCF58B2DEB0B7700CD5D83 /* HomeSelectionView.swift */; }; - 2FEFD8F62BE7C5BE00E387B9 /* TextInputUITableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FEFD8F52BE7C5BE00E387B9 /* TextInputUITableViewCell.swift */; }; 2FF459362E230C6A00C0B640 /* OpenHABIntentHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FF459352E230C6A00C0B640 /* OpenHABIntentHelper.swift */; }; 4D6470DA2561F935007B03FC /* openHABIntents.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 4D6470D32561F935007B03FC /* openHABIntents.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 652B81042E2193B500648510 /* ScreenSaverSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 652B81032E2193B500648510 /* ScreenSaverSettingsView.swift */; }; @@ -32,6 +36,8 @@ 65C2EF492E244C8500A0C19F /* OpenHABNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65C2EF482E244C8500A0C19F /* OpenHABNavigationController.swift */; }; 65F055442E3D4E41004E98FE /* ItemSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65F055432E3D4E41004E98FE /* ItemSelectionView.swift */; }; 7BFFEA908B9E47FCB5C46E6E /* VideoStreamManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 905A534AB32C4104AC55A75C /* VideoStreamManager.swift */; }; + 801159DCB99C03EFA71F4B9D /* MainWebTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5135184D9630899A34EFBA95 /* MainWebTab.swift */; }; + 911CC92713E7DF11C3295A4C /* SafariView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3C3E3BF4C930ADA95E6E997 /* SafariView.swift */; }; 932602EE2382892B00EAD685 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DAC6608B236F6F4200F4501E /* Assets.xcassets */; }; 933D7F0722E7015100621A03 /* OpenHABUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 933D7F0622E7015000621A03 /* OpenHABUITests.swift */; }; 933D7F0F22E7030600621A03 /* SnapshotHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 933D7F0E22E7030600621A03 /* SnapshotHelper.swift */; }; @@ -71,8 +77,10 @@ 93F8064727AE7A050035A6B0 /* SwiftMessages in Frameworks */ = {isa = PBXBuildFile; productRef = 93F8064627AE7A050035A6B0 /* SwiftMessages */; }; 93F8064A27AE7A2E0035A6B0 /* FlexColorPicker in Frameworks */ = {isa = PBXBuildFile; productRef = 93F8064927AE7A2E0035A6B0 /* FlexColorPicker */; }; A3F4C3A51A49A5940019A09F /* MainLaunchScreen.xib in Resources */ = {isa = PBXBuildFile; fileRef = A3F4C3A41A49A5940019A09F /* MainLaunchScreen.xib */; }; - B7D5ECE121499E55001B0EC6 /* MapViewTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7D5ECE021499E55001B0EC6 /* MapViewTableViewCell.swift */; }; + A75A53645D1542CBAC658099 /* TabCustomizationSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3698C230427F48A782B1980B /* TabCustomizationSection.swift */; }; + BE875E6CE1B6E05E492F29CE /* OpenHABTabRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52411B814C63C9F1CC8CED90 /* OpenHABTabRootView.swift */; }; C4377202F7D642B5A8349008 /* SelectionRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = D86D8BF295C448039B2B85EB /* SelectionRow.swift */; }; + D8C89708AE75DEFCD78566EC /* SitemapsTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13181EE034B26E3A1FF7F4A8 /* SitemapsTab.swift */; }; DA0749DE23E0B5950057FA83 /* ColorPickerRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA0749DD23E0B5950057FA83 /* ColorPickerRow.swift */; }; DA0749E023E0BF510057FA83 /* ColorSelection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA0749DF23E0BF510057FA83 /* ColorSelection.swift */; }; DA07751B2346705F0086C685 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DA07751A2346705F0086C685 /* Assets.xcassets */; }; @@ -91,7 +99,6 @@ DA2741022EA62FA3002FE576 /* SegmentSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2741012EA62FA3002FE576 /* SegmentSelectionView.swift */; }; DA28C362225241DE00AB409C /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DA28C361225241DE00AB409C /* WebKit.framework */; settings = {ATTRIBUTES = (Required, ); }; }; DA2AEB6E2D92BAD800897D80 /* PageLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2AEB6D2D92BAD800897D80 /* PageLoader.swift */; }; - DA2AEB702D92CF3E00897D80 /* UITableViewCellExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2AEB6F2D92CF3E00897D80 /* UITableViewCellExtension.swift */; }; DA2AEBA02D92FB6500897D80 /* NoIconDisplayableCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2AEB9F2D92FB6500897D80 /* NoIconDisplayableCell.swift */; }; DA2C4FD52B4F573300D1C533 /* SDWebImageSVGCoder in Frameworks */ = {isa = PBXBuildFile; productRef = DA2C4FD42B4F573300D1C533 /* SDWebImageSVGCoder */; }; DA2D2F8A2F3943A800EC605A /* WidgetRowViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2D2F892F3943A800EC605A /* WidgetRowViewModel.swift */; }; @@ -119,10 +126,8 @@ DA48001A2D83742A009CF127 /* DebugSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4800192D83742A009CF127 /* DebugSettingsView.swift */; }; DA48001C2D837556009CF127 /* SitemapSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA48001B2D837556009CF127 /* SitemapSettingsView.swift */; }; DA48001E2D837905009CF127 /* ApplicationSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA48001D2D837905009CF127 /* ApplicationSettingsView.swift */; }; - A75A53645D1542CBAC658099 /* TabCustomizationSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3698C230427F48A782B1980B /* TabCustomizationSection.swift */; }; DA4800212D839D3A009CF127 /* AnimatedSecureTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4800202D839D39009CF127 /* AnimatedSecureTextField.swift */; }; DA4D4DB5233F9ACB00B37E37 /* README.md in Resources */ = {isa = PBXBuildFile; fileRef = DA4D4DB4233F9ACB00B37E37 /* README.md */; }; - DA50C7BF2B0A65300009F716 /* SliderWithSwitchSupportUITableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA50C7BE2B0A652F0009F716 /* SliderWithSwitchSupportUITableViewCell.swift */; }; DA5ED9BE2C850955004875E0 /* ClientCertificatesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5ED9BD2C850955004875E0 /* ClientCertificatesViewModel.swift */; }; DA64ACA62DBEAD5600294F60 /* SitemapPageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA64ACA52DBEAD5600294F60 /* SitemapPageViewModel.swift */; }; DA64ACA82DBEAD8300294F60 /* SitemapPageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA64ACA72DBEAD8300294F60 /* SitemapPageView.swift */; }; @@ -134,7 +139,6 @@ DA72E1B8236DEA0900B8EF3A /* AppMessageService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA72E1B5236DEA0900B8EF3A /* AppMessageService.swift */; }; DA77E19B2D886D9B007CFF0F /* SingleConnectionSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA77E19A2D886D9B007CFF0F /* SingleConnectionSettingsView.swift */; }; DA7ACD5F2DC3DB130055CFC7 /* SFSafeSymbols in Frameworks */ = {isa = PBXBuildFile; productRef = DA7ACD5E2DC3DB130055CFC7 /* SFSafeSymbols */; settings = {ATTRIBUTES = (Required, ); }; }; - DA7E1E4B2233986E002AEFD8 /* PlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA7E1E47222EB00B002AEFD8 /* PlayerView.swift */; }; DA7F002D2EB376CF00DE943A /* ServerCertificatesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA7F002C2EB376CF00DE943A /* ServerCertificatesView.swift */; }; DA817E7A234BF39B00C91824 /* CHANGELOG.md in Resources */ = {isa = PBXBuildFile; fileRef = DA817E79234BF39B00C91824 /* CHANGELOG.md */; }; DA88F8C622EC377200B408E5 /* ReleaseNotes.md in Resources */ = {isa = PBXBuildFile; fileRef = DA88F8C522EC377100B408E5 /* ReleaseNotes.md */; }; @@ -143,16 +147,12 @@ DA8B14BA2F3A373A007753FD /* PreviewNavigationContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA8B14B92F3A373A007753FD /* PreviewNavigationContainer.swift */; }; DA8B14BC2F3A3CB5007753FD /* WatchTypography.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA8B14BB2F3A3CB5007753FD /* WatchTypography.swift */; }; DA94AEB42EC896BE003BB3C8 /* SimpleMJPEGPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA94AEB32EC896BE003BB3C8 /* SimpleMJPEGPlayer.swift */; }; - DA95F3352E0F2C1600FE4474 /* OpenHABSitemapViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA95F3342E0F2C1600FE4474 /* OpenHABSitemapViewController.swift */; }; DA96415A2F292EE200CEC181 /* BonjourDiscoveryViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA9641592F292EE200CEC181 /* BonjourDiscoveryViewModelTests.swift */; }; DA96415C2F292F0600CEC181 /* OpenHABEndPoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA96415B2F292F0600CEC181 /* OpenHABEndPoint.swift */; }; DA9721C324E29A8F0092CCFD /* UserDefaultsBacked.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA9721C224E29A8F0092CCFD /* UserDefaultsBacked.swift */; }; DA9A7EFD2D668D5900824156 /* SFSafeSymbols in Frameworks */ = {isa = PBXBuildFile; productRef = DA9A7EFC2D668D5900824156 /* SFSafeSymbols */; }; DA9A7EFF2D66915900824156 /* SFSafeSymbols in Frameworks */ = {isa = PBXBuildFile; productRef = DA9A7EFE2D66915900824156 /* SFSafeSymbols */; }; DA9F81872C85020F00B47B72 /* RTFTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA9F81862C85020F00B47B72 /* RTFTextView.swift */; }; - DAA42BA821DC97E000244B2A /* NotificationTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAA42BA721DC97DF00244B2A /* NotificationTableViewCell.swift */; }; - DAA42BAA21DC983B00244B2A /* VideoUITableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAA42BA921DC983B00244B2A /* VideoUITableViewCell.swift */; }; - DAA42BAC21DC984A00244B2A /* WebUITableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAA42BAB21DC984A00244B2A /* WebUITableViewCell.swift */; }; DAA599B82EAC0FE7003A8726 /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = DAA599B72EAC0FE7003A8726 /* AppIcon.icon */; }; DAA599B92EAC0FE7003A8726 /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = DAA599B72EAC0FE7003A8726 /* AppIcon.icon */; }; DABB5E332D98972F009A4B8A /* SDWebImageSVGCoder in Frameworks */ = {isa = PBXBuildFile; productRef = DABB5E322D98972F009A4B8A /* SDWebImageSVGCoder */; }; @@ -160,7 +160,6 @@ DABED17D2E4516B4000B92EF /* BonjourDiscoveryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DABED17C2E4516B4000B92EF /* BonjourDiscoveryViewModel.swift */; }; DAC131112DA3213100075AE2 /* SetSwitchStateIntentHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC131102DA3213100075AE2 /* SetSwitchStateIntentHandlerTests.swift */; }; DAC131122DA32F5D00075AE2 /* Intents.intentdefinition in Resources */ = {isa = PBXBuildFile; fileRef = 935D340A257B7DC00020A404 /* Intents.intentdefinition */; }; - DAC65FC7236EDF3900F4501E /* SpinnerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC65FC6236EDF3900F4501E /* SpinnerViewController.swift */; }; DAC6608D236F771600F4501E /* PreferencesSwiftUIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC6608C236F771600F4501E /* PreferencesSwiftUIView.swift */; }; DAC9395522B00E7600C5F423 /* XCTestCaseExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC9395422B00E7600C5F423 /* XCTestCaseExtension.swift */; }; DAC949FA2E219F0D007E67B7 /* CommonUI in Frameworks */ = {isa = PBXBuildFile; productRef = DAC949F92E219F0D007E67B7 /* CommonUI */; }; @@ -179,12 +178,7 @@ DAEA21DC2DBF47DA00D54342 /* SliderRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAEA21DB2DBF47DA00D54342 /* SliderRowView.swift */; }; DAEA21DE2DBF481300D54342 /* TextRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAEA21DD2DBF481300D54342 /* TextRowView.swift */; }; DAEA21E02DBF483E00D54342 /* GenericRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAEA21DF2DBF483E00D54342 /* GenericRowView.swift */; }; - DAEAA89D21E6B06400267EA3 /* ReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAEAA89C21E6B06300267EA3 /* ReusableView.swift */; }; - DAEAA89F21E6B16600267EA3 /* UITableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAEAA89E21E6B16600267EA3 /* UITableView.swift */; }; DAEE35072E224F6000B4A7F1 /* ColorTemperaturePickerRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAEE35062E224F6000B4A7F1 /* ColorTemperaturePickerRowView.swift */; }; - DAF0A28B2C56E3A300A14A6A /* RollershutterCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF0A28A2C56E3A300A14A6A /* RollershutterCell.swift */; }; - DAF0A28D2C56EF8900A14A6A /* SetpointCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF0A28C2C56EF8900A14A6A /* SetpointCell.swift */; }; - DAF0A28F2C56F1EE00A14A6A /* ColorPickerCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF0A28E2C56F1EE00A14A6A /* ColorPickerCell.swift */; }; DAF231D227BB6EEA00AB916C /* OpenHABSVGTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF231D127BB6EEA00AB916C /* OpenHABSVGTests.swift */; }; DAF231D827BB702500AB916C /* valid_xmlns.svg in Resources */ = {isa = PBXBuildFile; fileRef = DAF231D527BB702400AB916C /* valid_xmlns.svg */; }; DAF231D927BB702500AB916C /* invalid_xmlns.svg in Resources */ = {isa = PBXBuildFile; fileRef = DAF231D627BB702500AB916C /* invalid_xmlns.svg */; }; @@ -199,16 +193,8 @@ DAF4581623DC48400018B495 /* GenericRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF4581523DC483F0018B495 /* GenericRow.swift */; }; DAF4581823DC4A050018B495 /* ImageRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF4581723DC4A050018B495 /* ImageRow.swift */; }; DAF4581E23DC60020018B495 /* ImageRawRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF4581D23DC60020018B495 /* ImageRawRow.swift */; }; - DAF4F6C0222734D300C24876 /* NewImageUITableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF4F6BF222734D200C24876 /* NewImageUITableViewCell.swift */; }; DAF5AA682E4F3A39004F18D7 /* EmbeddingRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF5AA672E4F3A38004F18D7 /* EmbeddingRowView.swift */; }; DAFF80982E4F47830084513E /* SDWebImage in Frameworks */ = {isa = PBXBuildFile; productRef = DAFF80972E4F47830084513E /* SDWebImage */; }; - DF05FF231896BD2D00FF2F9B /* SelectionUITableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF05FF221896BD2D00FF2F9B /* SelectionUITableViewCell.swift */; }; - DF06F1FC18FEC2020011E7B9 /* ColorPickerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF06F1FB18FEC2020011E7B9 /* ColorPickerViewController.swift */; }; - DF4B84131886DAC400F34902 /* FrameUITableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF4B84121886DAC400F34902 /* FrameUITableViewCell.swift */; }; - DF4B84161886EACA00F34902 /* GenericUITableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF4B84151886EACA00F34902 /* GenericUITableViewCell.swift */; }; - DFA13CB418872EBD006355C3 /* SwitchUITableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFA13CB318872EBD006355C3 /* SwitchUITableViewCell.swift */; }; - DFA16EBB18883DE500EDB0BB /* SliderUITableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFA16EBA18883DE500EDB0BB /* SliderUITableViewCell.swift */; }; - DFA16EC118898A8400EDB0BB /* SegmentedUITableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFA16EC018898A8400EDB0BB /* SegmentedUITableViewCell.swift */; }; DFB2622B18830A3600D3244D /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DFB2622A18830A3600D3244D /* Foundation.framework */; }; DFB2622D18830A3600D3244D /* CoreGraphics.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DFB2622C18830A3600D3244D /* CoreGraphics.framework */; }; DFB2622F18830A3600D3244D /* UIKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DFB2622E18830A3600D3244D /* UIKit.framework */; }; @@ -218,13 +204,6 @@ DFDF45311932042B00A6E581 /* legal.rtf in Resources */ = {isa = PBXBuildFile; fileRef = DFDF45301932042B00A6E581 /* legal.rtf */; }; DFE10414197415F900D94943 /* Security.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DFE10413197415F900D94943 /* Security.framework */; }; DFFD8FD118EDBD4F003B502A /* UICircleButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFFD8FD018EDBD4F003B502A /* UICircleButton.swift */; }; - 1CE7AC462008E29FA37F231D /* AppServicesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64BEA2793F07C36BB98D9B8 /* AppServicesViewModel.swift */; }; - BE875E6CE1B6E05E492F29CE /* OpenHABTabRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52411B814C63C9F1CC8CED90 /* OpenHABTabRootView.swift */; }; - 801159DCB99C03EFA71F4B9D /* MainWebTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5135184D9630899A34EFBA95 /* MainWebTab.swift */; }; - D8C89708AE75DEFCD78566EC /* SitemapsTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13181EE034B26E3A1FF7F4A8 /* SitemapsTab.swift */; }; - 2C9BBB28C068BCC10291A566 /* TilesTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE239F0348D19000408BA8AA /* TilesTab.swift */; }; - 0C93C5B057F863322A1B9820 /* SystemTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4229903F00160C965E22DE21 /* SystemTab.swift */; }; - 911CC92713E7DF11C3295A4C /* SafariView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3C3E3BF4C930ADA95E6E997 /* SafariView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -334,14 +313,16 @@ /* Begin PBXFileReference section */ 1224F78D228A89FC00750965 /* WatchMessageService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WatchMessageService.swift; sourceTree = ""; }; + 13181EE034B26E3A1FF7F4A8 /* SitemapsTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SitemapsTab.swift; sourceTree = ""; }; 2F08AFC62E5FADC500E70611 /* NotificationCenterDelegateImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationCenterDelegateImpl.swift; sourceTree = ""; }; + 2F54B56C2F40AC1A00DA1F1A /* REFACTORING_SUMMARY.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = REFACTORING_SUMMARY.md; sourceTree = ""; }; 2F55E7BA2DEE447700EC8350 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; 2F55E7BC2DEE44A800EC8350 /* ClientCertificatesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientCertificatesView.swift; sourceTree = ""; }; - 2F6412ED2CE494A80039FB28 /* DatePickerUITableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatePickerUITableViewCell.swift; sourceTree = ""; }; 2FBCF58B2DEB0B7700CD5D83 /* HomeSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeSelectionView.swift; sourceTree = ""; }; - 2FEFD8F52BE7C5BE00E387B9 /* TextInputUITableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextInputUITableViewCell.swift; sourceTree = ""; }; 2FF459352E230C6A00C0B640 /* OpenHABIntentHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenHABIntentHelper.swift; sourceTree = ""; }; + 3698C230427F48A782B1980B /* TabCustomizationSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabCustomizationSection.swift; sourceTree = ""; }; 399449C421544C61AD83450C /* TextRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextRow.swift; sourceTree = ""; }; + 4229903F00160C965E22DE21 /* SystemTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemTab.swift; sourceTree = ""; }; 4D38D951256897490039DA6E /* SetNumberValueIntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetNumberValueIntentHandler.swift; sourceTree = ""; }; 4D38D959256897770039DA6E /* SetStringValueIntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetStringValueIntentHandler.swift; sourceTree = ""; }; 4D38D9612568978E0039DA6E /* SetColorValueIntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetColorValueIntentHandler.swift; sourceTree = ""; }; @@ -353,6 +334,8 @@ 4D64720D256315D9007B03FC /* openHABIntents.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = openHABIntents.entitlements; sourceTree = ""; }; 4D647220256331B9007B03FC /* SetSwitchStateIntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetSwitchStateIntentHandler.swift; sourceTree = ""; }; 4D64724C256346BD007B03FC /* SetDimmerRollerValueIntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetDimmerRollerValueIntentHandler.swift; sourceTree = ""; }; + 5135184D9630899A34EFBA95 /* MainWebTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainWebTab.swift; sourceTree = ""; }; + 52411B814C63C9F1CC8CED90 /* OpenHABTabRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenHABTabRootView.swift; sourceTree = ""; }; 652B81032E2193B500648510 /* ScreenSaverSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenSaverSettingsView.swift; sourceTree = ""; }; 652B81052E2193DA00648510 /* ScreenSaverConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenSaverConfiguration.swift; sourceTree = ""; }; 652B81062E2193DA00648510 /* ScreenSaverManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenSaverManager.swift; sourceTree = ""; }; @@ -404,8 +387,9 @@ 938BF9CF24EFCCC000E6B52F /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/InfoPlist.strings; sourceTree = ""; }; 938BF9D224EFD0B700E6B52F /* UIViewController+Localization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+Localization.swift"; sourceTree = ""; }; 938EDCE022C4FEB800661CA1 /* ScaleAspectFitImageView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScaleAspectFitImageView.swift; sourceTree = ""; }; + A3C3E3BF4C930ADA95E6E997 /* SafariView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafariView.swift; sourceTree = ""; }; A3F4C3A41A49A5940019A09F /* MainLaunchScreen.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; name = MainLaunchScreen.xib; path = ../MainLaunchScreen.xib; sourceTree = ""; }; - B7D5ECE021499E55001B0EC6 /* MapViewTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapViewTableViewCell.swift; sourceTree = ""; }; + D64BEA2793F07C36BB98D9B8 /* AppServicesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppServicesViewModel.swift; sourceTree = ""; }; D86D8BF295C448039B2B85EB /* SelectionRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectionRow.swift; sourceTree = ""; }; DA0749DD23E0B5950057FA83 /* ColorPickerRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorPickerRow.swift; sourceTree = ""; }; DA0749DF23E0BF510057FA83 /* ColorSelection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorSelection.swift; sourceTree = ""; }; @@ -462,7 +446,6 @@ DA2741012EA62FA3002FE576 /* SegmentSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SegmentSelectionView.swift; sourceTree = ""; }; DA28C361225241DE00AB409C /* WebKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WebKit.framework; path = System/Library/Frameworks/WebKit.framework; sourceTree = SDKROOT; }; DA2AEB6D2D92BAD800897D80 /* PageLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageLoader.swift; sourceTree = ""; }; - DA2AEB6F2D92CF3E00897D80 /* UITableViewCellExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITableViewCellExtension.swift; sourceTree = ""; }; DA2AEB9F2D92FB6500897D80 /* NoIconDisplayableCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoIconDisplayableCell.swift; sourceTree = ""; }; DA2D2F892F3943A800EC605A /* WidgetRowViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetRowViewModel.swift; sourceTree = ""; }; DA2DC22F21F2736C00830730 /* openHABTestsSwift.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = openHABTestsSwift.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -491,11 +474,9 @@ DA4800192D83742A009CF127 /* DebugSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugSettingsView.swift; sourceTree = ""; }; DA48001B2D837556009CF127 /* SitemapSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SitemapSettingsView.swift; sourceTree = ""; }; DA48001D2D837905009CF127 /* ApplicationSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationSettingsView.swift; sourceTree = ""; }; - 3698C230427F48A782B1980B /* TabCustomizationSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabCustomizationSection.swift; sourceTree = ""; }; DA4800202D839D39009CF127 /* AnimatedSecureTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnimatedSecureTextField.swift; sourceTree = ""; }; DA4D4DB4233F9ACB00B37E37 /* README.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; DA4D4E0E2340A00200B37E37 /* Changes.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = Changes.md; sourceTree = ""; }; - DA50C7BE2B0A652F0009F716 /* SliderWithSwitchSupportUITableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SliderWithSwitchSupportUITableViewCell.swift; sourceTree = ""; }; DA5ED9BD2C850955004875E0 /* ClientCertificatesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientCertificatesViewModel.swift; sourceTree = ""; }; DA64ACA52DBEAD5600294F60 /* SitemapPageViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SitemapPageViewModel.swift; sourceTree = ""; }; DA64ACA72DBEAD8300294F60 /* SitemapPageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SitemapPageView.swift; sourceTree = ""; }; @@ -506,7 +487,6 @@ DA6B2EF62C8B92E800DF77CF /* SelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectionView.swift; sourceTree = ""; }; DA72E1B5236DEA0900B8EF3A /* AppMessageService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppMessageService.swift; sourceTree = ""; }; DA77E19A2D886D9B007CFF0F /* SingleConnectionSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SingleConnectionSettingsView.swift; sourceTree = ""; }; - DA7E1E47222EB00B002AEFD8 /* PlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerView.swift; sourceTree = ""; }; DA7F002C2EB376CF00DE943A /* ServerCertificatesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerCertificatesView.swift; sourceTree = ""; }; DA817E79234BF39B00C91824 /* CHANGELOG.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = CHANGELOG.md; sourceTree = ""; }; DA88F8C522EC377100B408E5 /* ReleaseNotes.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = ReleaseNotes.md; sourceTree = ""; }; @@ -516,20 +496,14 @@ DA8B14BB2F3A3CB5007753FD /* WatchTypography.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchTypography.swift; sourceTree = ""; }; DA8B15512F3BB74B007753FD /* openHABWatchTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = openHABWatchTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; DA94AEB32EC896BE003BB3C8 /* SimpleMJPEGPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleMJPEGPlayer.swift; sourceTree = ""; }; - DA94AF432EC8DE41003BB3C8 /* VideoStreamManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoStreamManager.swift; sourceTree = ""; }; - DA95F3342E0F2C1600FE4474 /* OpenHABSitemapViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenHABSitemapViewController.swift; sourceTree = ""; }; DA9641592F292EE200CEC181 /* BonjourDiscoveryViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BonjourDiscoveryViewModelTests.swift; sourceTree = ""; }; DA96415B2F292F0600CEC181 /* OpenHABEndPoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenHABEndPoint.swift; sourceTree = ""; }; DA9721C224E29A8F0092CCFD /* UserDefaultsBacked.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaultsBacked.swift; sourceTree = ""; }; DA9F81862C85020F00B47B72 /* RTFTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RTFTextView.swift; sourceTree = ""; }; - DAA42BA721DC97DF00244B2A /* NotificationTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NotificationTableViewCell.swift; sourceTree = ""; }; - DAA42BA921DC983B00244B2A /* VideoUITableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VideoUITableViewCell.swift; sourceTree = ""; }; - DAA42BAB21DC984A00244B2A /* WebUITableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WebUITableViewCell.swift; sourceTree = ""; }; DAA599B72EAC0FE7003A8726 /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = AppIcon.icon; sourceTree = ""; }; DABED17A2E451694000B92EF /* BonjourDiscoverySheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BonjourDiscoverySheet.swift; sourceTree = ""; }; DABED17C2E4516B4000B92EF /* BonjourDiscoveryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BonjourDiscoveryViewModel.swift; sourceTree = ""; }; DAC131102DA3213100075AE2 /* SetSwitchStateIntentHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetSwitchStateIntentHandlerTests.swift; sourceTree = ""; }; - DAC65FC6236EDF3900F4501E /* SpinnerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpinnerViewController.swift; sourceTree = ""; }; DAC6608B236F6F4200F4501E /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; DAC6608C236F771600F4501E /* PreferencesSwiftUIView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesSwiftUIView.swift; sourceTree = ""; }; DAC9395422B00E7600C5F423 /* XCTestCaseExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCTestCaseExtension.swift; sourceTree = ""; }; @@ -554,12 +528,7 @@ DAEA21DB2DBF47DA00D54342 /* SliderRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SliderRowView.swift; sourceTree = ""; }; DAEA21DD2DBF481300D54342 /* TextRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextRowView.swift; sourceTree = ""; }; DAEA21DF2DBF483E00D54342 /* GenericRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GenericRowView.swift; sourceTree = ""; }; - DAEAA89C21E6B06300267EA3 /* ReusableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReusableView.swift; sourceTree = ""; }; - DAEAA89E21E6B16600267EA3 /* UITableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITableView.swift; sourceTree = ""; }; DAEE35062E224F6000B4A7F1 /* ColorTemperaturePickerRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorTemperaturePickerRowView.swift; sourceTree = ""; }; - DAF0A28A2C56E3A300A14A6A /* RollershutterCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RollershutterCell.swift; sourceTree = ""; }; - DAF0A28C2C56EF8900A14A6A /* SetpointCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetpointCell.swift; sourceTree = ""; }; - DAF0A28E2C56F1EE00A14A6A /* ColorPickerCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorPickerCell.swift; sourceTree = ""; }; DAF231D127BB6EEA00AB916C /* OpenHABSVGTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenHABSVGTests.swift; sourceTree = ""; }; DAF231D527BB702400AB916C /* valid_xmlns.svg */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xml; path = valid_xmlns.svg; sourceTree = ""; }; DAF231D627BB702500AB916C /* invalid_xmlns.svg */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xml; path = invalid_xmlns.svg; sourceTree = ""; }; @@ -574,17 +543,9 @@ DAF4581523DC483F0018B495 /* GenericRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GenericRow.swift; sourceTree = ""; }; DAF4581723DC4A050018B495 /* ImageRow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageRow.swift; sourceTree = ""; }; DAF4581D23DC60020018B495 /* ImageRawRow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageRawRow.swift; sourceTree = ""; }; - DAF4F6BF222734D200C24876 /* NewImageUITableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewImageUITableViewCell.swift; sourceTree = ""; }; DAF5AA672E4F3A38004F18D7 /* EmbeddingRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmbeddingRowView.swift; sourceTree = ""; }; DAF6F4112C67E83B0083883E /* openapiCorrected.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = openapiCorrected.json; sourceTree = ""; }; DAFD2FE62E0D96700059A1EB /* OsLogRewriter */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = OsLogRewriter; sourceTree = ""; }; - DF05FF221896BD2D00FF2F9B /* SelectionUITableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SelectionUITableViewCell.swift; sourceTree = ""; }; - DF06F1FB18FEC2020011E7B9 /* ColorPickerViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ColorPickerViewController.swift; sourceTree = ""; }; - DF4B84121886DAC400F34902 /* FrameUITableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FrameUITableViewCell.swift; sourceTree = ""; }; - DF4B84151886EACA00F34902 /* GenericUITableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GenericUITableViewCell.swift; sourceTree = ""; }; - DFA13CB318872EBD006355C3 /* SwitchUITableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SwitchUITableViewCell.swift; sourceTree = ""; }; - DFA16EBA18883DE500EDB0BB /* SliderUITableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SliderUITableViewCell.swift; sourceTree = ""; }; - DFA16EC018898A8400EDB0BB /* SegmentedUITableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SegmentedUITableViewCell.swift; sourceTree = ""; }; DFB2622718830A3600D3244D /* openHAB.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = openHAB.app; sourceTree = BUILT_PRODUCTS_DIR; }; DFB2622A18830A3600D3244D /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = System/Library/Frameworks/Foundation.framework; sourceTree = SDKROOT; }; DFB2622C18830A3600D3244D /* CoreGraphics.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreGraphics.framework; path = System/Library/Frameworks/CoreGraphics.framework; sourceTree = SDKROOT; }; @@ -597,17 +558,10 @@ DFDF45301932042B00A6E581 /* legal.rtf */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.rtf; path = legal.rtf; sourceTree = ""; }; DFE10413197415F900D94943 /* Security.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Security.framework; path = System/Library/Frameworks/Security.framework; sourceTree = SDKROOT; }; DFFD8FD018EDBD4F003B502A /* UICircleButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UICircleButton.swift; sourceTree = ""; }; - D64BEA2793F07C36BB98D9B8 /* AppServicesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppServicesViewModel.swift; sourceTree = ""; }; - 52411B814C63C9F1CC8CED90 /* OpenHABTabRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenHABTabRootView.swift; sourceTree = ""; }; - 5135184D9630899A34EFBA95 /* MainWebTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainWebTab.swift; sourceTree = ""; }; - 13181EE034B26E3A1FF7F4A8 /* SitemapsTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SitemapsTab.swift; sourceTree = ""; }; EE239F0348D19000408BA8AA /* TilesTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TilesTab.swift; sourceTree = ""; }; - 4229903F00160C965E22DE21 /* SystemTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemTab.swift; sourceTree = ""; }; - A3C3E3BF4C930ADA95E6E997 /* SafariView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafariView.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ - DA2AEB752D92D32000897D80 /* Cells */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Cells; sourceTree = ""; }; DA8B15522F3BB74B007753FD /* openHABWatchTests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = openHABWatchTests; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ @@ -697,6 +651,8 @@ DA28C362225241DE00AB409C /* WebKit.framework in Frameworks */, DAC949FA2E219F0D007E67B7 /* CommonUI in Frameworks */, DFE10414197415F900D94943 /* Security.framework in Frameworks */, + 2F54B5742F428ACA00DA1F1A /* AsyncAlgorithms in Frameworks */, + 2F54B5772F428B0200DA1F1A /* Algorithms in Frameworks */, 93F8064727AE7A050035A6B0 /* SwiftMessages in Frameworks */, DFB2622D18830A3600D3244D /* CoreGraphics.framework in Frameworks */, 93F8063527AE6C620035A6B0 /* FirebaseCrashlytics in Frameworks */, @@ -740,9 +696,7 @@ 65C2EF482E244C8500A0C19F /* OpenHABNavigationController.swift */, 653B54C1285E714900298ECD /* OpenHABViewController.swift */, 65570A7C2476D16A00D524EA /* OpenHABWebViewController.swift */, - DA95F3342E0F2C1600FE4474 /* OpenHABSitemapViewController.swift */, - DAC65FC6236EDF3900F4501E /* SpinnerViewController.swift */, - DA2AEB6F2D92CF3E00897D80 /* UITableViewCellExtension.swift */, + DFFD8FCE18EDBD30003B502A /* Util */, ); path = UIKit; sourceTree = ""; @@ -757,6 +711,7 @@ EE239F0348D19000408BA8AA /* TilesTab.swift */, 4229903F00160C965E22DE21 /* SystemTab.swift */, A3C3E3BF4C930ADA95E6E997 /* SafariView.swift */, + 2F54B56C2F40AC1A00DA1F1A /* REFACTORING_SUMMARY.md */, ); path = RootView; sourceTree = ""; @@ -973,6 +928,9 @@ 2F7D1D592EFEC12A004A786D /* RootView */, 2F7D1D5A2EFEC137004A786D /* SitemapView */, DAC949FF2E21A473007E67B7 /* Rows */, + DA48001F2D837CD8009CF127 /* SettingsView */, + 652B81082E2193DA00648510 /* ScreenSaver */, + DF4B84101886DA9900F34902 /* Widgets */, ); path = SwiftUI; sourceTree = ""; @@ -1129,17 +1087,10 @@ isa = PBXGroup; children = ( 2F7D1D572EFEBABA004A786D /* UIKit */, - DA35E2B12E1EEA58003987BB /* SwiftUI */, - 652B81082E2193DA00648510 /* ScreenSaver */, - DA2AEB752D92D32000897D80 /* Cells */, DABED17C2E4516B4000B92EF /* BonjourDiscoveryViewModel.swift */, - DA94AF432EC8DE41003BB3C8 /* VideoStreamManager.swift */, DA94AEB32EC896BE003BB3C8 /* SimpleMJPEGPlayer.swift */, DA2AEB6D2D92BAD800897D80 /* PageLoader.swift */, - DA48001F2D837CD8009CF127 /* SettingsView */, 1224F78B228A89E300750965 /* Watch */, - DF4B84101886DA9900F34902 /* Widgets */, - DFFD8FCE18EDBD30003B502A /* Util */, DA35E2B12E1EEA58003987BB /* SwiftUI */, ); name = UI; @@ -1157,28 +1108,7 @@ DF4B84101886DA9900F34902 /* Widgets */ = { isa = PBXGroup; children = ( - 2F6412ED2CE494A80039FB28 /* DatePickerUITableViewCell.swift */, - DAF0A28E2C56F1EE00A14A6A /* ColorPickerCell.swift */, - DF06F1FB18FEC2020011E7B9 /* ColorPickerViewController.swift */, - DF4B84121886DAC400F34902 /* FrameUITableViewCell.swift */, - DF4B84151886EACA00F34902 /* GenericUITableViewCell.swift */, - DAF4F6BF222734D200C24876 /* NewImageUITableViewCell.swift */, - B7D5ECE021499E55001B0EC6 /* MapViewTableViewCell.swift */, - DAA42BA721DC97DF00244B2A /* NotificationTableViewCell.swift */, - DAF0A28A2C56E3A300A14A6A /* RollershutterCell.swift */, - DFA16EC018898A8400EDB0BB /* SegmentedUITableViewCell.swift */, - DF05FF221896BD2D00FF2F9B /* SelectionUITableViewCell.swift */, - DAF0A28C2C56EF8900A14A6A /* SetpointCell.swift */, - DFA16EBA18883DE500EDB0BB /* SliderUITableViewCell.swift */, - DA50C7BE2B0A652F0009F716 /* SliderWithSwitchSupportUITableViewCell.swift */, - DFA13CB318872EBD006355C3 /* SwitchUITableViewCell.swift */, - 2FEFD8F52BE7C5BE00E387B9 /* TextInputUITableViewCell.swift */, - DAA42BA921DC983B00244B2A /* VideoUITableViewCell.swift */, 905A534AB32C4104AC55A75C /* VideoStreamManager.swift */, - DAA42BAB21DC984A00244B2A /* WebUITableViewCell.swift */, - DAEAA89C21E6B06300267EA3 /* ReusableView.swift */, - DAEAA89E21E6B16600267EA3 /* UITableView.swift */, - DA7E1E47222EB00B002AEFD8 /* PlayerView.swift */, DA21EAE12339621C001AB415 /* Throttler.swift */, DA6B2EF42C89F8F200DF77CF /* ColorPickerView.swift */, DA2AEB9F2D92FB6500897D80 /* NoIconDisplayableCell.swift */, @@ -1480,9 +1410,6 @@ 4D6470D92561F935007B03FC /* PBXTargetDependency */, 657144542C1E438700C8A1F3 /* PBXTargetDependency */, ); - fileSystemSynchronizedGroups = ( - DA2AEB752D92D32000897D80 /* Cells */, - ); name = openHAB; packageProductDependencies = ( 937E4470270B36D000A98C26 /* OpenHABCore */, @@ -1494,6 +1421,8 @@ 6557AF912C039D140094D0C8 /* FirebaseMessaging */, DA9A7EFE2D66915900824156 /* SFSafeSymbols */, DABB5E322D98972F009A4B8A /* SDWebImageSVGCoder */, + 2F54B5732F428ACA00DA1F1A /* AsyncAlgorithms */, + 2F54B5762F428B0200DA1F1A /* Algorithms */, ); productName = openHAB; productReference = DFB2622718830A3600D3244D /* openHAB.app */; @@ -1589,6 +1518,8 @@ DA3B75AC2C59729200E219AB /* XCRemoteSwiftPackageReference "SFSafeSymbols" */, DA2C4FD32B4F573300D1C533 /* XCRemoteSwiftPackageReference "SDWebImageSVGCoder" */, DADC42082E7AB899004E866F /* XCRemoteSwiftPackageReference "SDWebImage" */, + 2F54B5722F428ACA00DA1F1A /* XCRemoteSwiftPackageReference "swift-async-algorithms" */, + 2F54B5752F428B0200DA1F1A /* XCRemoteSwiftPackageReference "swift-algorithms" */, ); productRefGroup = DFB2622818830A3600D3244D /* Products */; projectDirPath = ""; @@ -1684,6 +1615,7 @@ 6557AF8F2C0241C10094D0C8 /* PrivacyInfo.xcprivacy in Resources */, 93685A7A2ADE755C0077A9A6 /* openHABTests.xctestplan in Resources */, 938BF9D124EFCCC000E6B52F /* InfoPlist.strings in Resources */, + 2F54B56D2F40AC1A00DA1F1A /* REFACTORING_SUMMARY.md in Resources */, 938BF9C424EFCB9F00E6B52F /* Main.storyboard in Resources */, DA817E7A234BF39B00C91824 /* CHANGELOG.md in Resources */, DA4D4DB5233F9ACB00B37E37 /* README.md in Resources */, @@ -1848,22 +1780,15 @@ 2C9BBB28C068BCC10291A566 /* TilesTab.swift in Sources */, 0C93C5B057F863322A1B9820 /* SystemTab.swift in Sources */, 911CC92713E7DF11C3295A4C /* SafariView.swift in Sources */, - DA7E1E4B2233986E002AEFD8 /* PlayerView.swift in Sources */, 65570A7D2476D16A00D524EA /* OpenHABWebViewController.swift in Sources */, DA48001E2D837905009CF127 /* ApplicationSettingsView.swift in Sources */, A75A53645D1542CBAC658099 /* TabCustomizationSection.swift in Sources */, - DAF0A28B2C56E3A300A14A6A /* RollershutterCell.swift in Sources */, DA64ACA62DBEAD5600294F60 /* SitemapPageViewModel.swift in Sources */, DABED17B2E451694000B92EF /* BonjourDiscoverySheet.swift in Sources */, - DF06F1FC18FEC2020011E7B9 /* ColorPickerViewController.swift in Sources */, 1224F78F228A89FD00750965 /* WatchMessageService.swift in Sources */, 2FBCF58C2DEB0B7700CD5D83 /* HomeSelectionView.swift in Sources */, - DAA42BAC21DC984A00244B2A /* WebUITableViewCell.swift in Sources */, - DF4B84131886DAC400F34902 /* FrameUITableViewCell.swift in Sources */, - DF4B84161886EACA00F34902 /* GenericUITableViewCell.swift in Sources */, 935B484625342B8E00E44CF0 /* URL+Static.swift in Sources */, DAEA21DA2DBF477E00D54342 /* SwitchRowView.swift in Sources */, - B7D5ECE121499E55001B0EC6 /* MapViewTableViewCell.swift in Sources */, DAEA21DC2DBF47DA00D54342 /* SliderRowView.swift in Sources */, DA77E19B2D886D9B007CFF0F /* SingleConnectionSettingsView.swift in Sources */, DA35E2B02E1EDB86003987BB /* SetpointRowView.swift in Sources */, @@ -1871,10 +1796,7 @@ DAEE35072E224F6000B4A7F1 /* ColorTemperaturePickerRowView.swift in Sources */, DA6B2EF52C89F8F200DF77CF /* ColorPickerView.swift in Sources */, DA4800142D836892009CF127 /* ConnectionSettingsView.swift in Sources */, - 2F6412EE2CE494A80039FB28 /* DatePickerUITableViewCell.swift in Sources */, DA7F002D2EB376CF00DE943A /* ServerCertificatesView.swift in Sources */, - DA95F3352E0F2C1600FE4474 /* OpenHABSitemapViewController.swift in Sources */, - DAA42BAA21DC983B00244B2A /* VideoUITableViewCell.swift in Sources */, 7BFFEA908B9E47FCB5C46E6E /* VideoStreamManager.swift in Sources */, DFB2623B18830A3600D3244D /* AppDelegate.swift in Sources */, 652B81092E2193DA00648510 /* ScreenSaverManager.swift in Sources */, @@ -1897,32 +1819,24 @@ DA35E2C62E1EEA9D003987BB /* SegmentedRowView.swift in Sources */, DA35E2C72E1EEA9D003987BB /* VideoRowView.swift in Sources */, DA35E2CD2E1F96CA003987BB /* IconView.swift in Sources */, - DAC65FC7236EDF3900F4501E /* SpinnerViewController.swift in Sources */, DA9F81872C85020F00B47B72 /* RTFTextView.swift in Sources */, DA6B2EF12C87B59000DF77CF /* NotificationsView.swift in Sources */, - DA50C7BF2B0A65300009F716 /* SliderWithSwitchSupportUITableViewCell.swift in Sources */, DAE7B4A72E26927C00B9FE99 /* ButtonGridRowView.swift in Sources */, DA5ED9BE2C850955004875E0 /* ClientCertificatesViewModel.swift in Sources */, DAEA21D82DBF472D00D54342 /* RowViewFactory.swift in Sources */, DA21EAE22339621C001AB415 /* Throttler.swift in Sources */, DAE2800A2E35F5590028EE24 /* IconURLView.swift in Sources */, - DAF4F6C0222734D300C24876 /* NewImageUITableViewCell.swift in Sources */, DABED17D2E4516B4000B92EF /* BonjourDiscoveryViewModel.swift in Sources */, DA94AEB42EC896BE003BB3C8 /* SimpleMJPEGPlayer.swift in Sources */, DA48001A2D83742A009CF127 /* DebugSettingsView.swift in Sources */, 938BF9D324EFD0B700E6B52F /* UIViewController+Localization.swift in Sources */, - DAA42BA821DC97E000244B2A /* NotificationTableViewCell.swift in Sources */, 2F08AFC72E5FADCF00E70611 /* NotificationCenterDelegateImpl.swift in Sources */, - DAF0A28F2C56F1EE00A14A6A /* ColorPickerCell.swift in Sources */, - 2FEFD8F62BE7C5BE00E387B9 /* TextInputUITableViewCell.swift in Sources */, 938EDCE122C4FEB800661CA1 /* ScaleAspectFitImageView.swift in Sources */, DA2AEBA02D92FB6500897D80 /* NoIconDisplayableCell.swift in Sources */, - DA2AEB702D92CF3E00897D80 /* UITableViewCellExtension.swift in Sources */, DA4800162D836EF0009CF127 /* MainUISettingsView.swift in Sources */, DAEA21DE2DBF481300D54342 /* TextRowView.swift in Sources */, 652B81042E2193B500648510 /* ScreenSaverSettingsView.swift in Sources */, DA3563D92E5096BE00BC0138 /* ScreenSaverView.swift in Sources */, - DAEAA89F21E6B16600267EA3 /* UITableView.swift in Sources */, 2F55E7BB2DEE447700EC8350 /* SettingsView.swift in Sources */, DA4800182D837221009CF127 /* AboutSettingsView.swift in Sources */, DAEA21E02DBF483E00D54342 /* GenericRowView.swift in Sources */, @@ -1930,13 +1844,7 @@ 65C2EF492E244C8500A0C19F /* OpenHABNavigationController.swift in Sources */, DA2AEB6E2D92BAD800897D80 /* PageLoader.swift in Sources */, 65F055442E3D4E41004E98FE /* ItemSelectionView.swift in Sources */, - DFA16EC118898A8400EDB0BB /* SegmentedUITableViewCell.swift in Sources */, - DAF0A28D2C56EF8900A14A6A /* SetpointCell.swift in Sources */, - DAEAA89D21E6B06400267EA3 /* ReusableView.swift in Sources */, - DF05FF231896BD2D00FF2F9B /* SelectionUITableViewCell.swift in Sources */, - DFA16EBB18883DE500EDB0BB /* SliderUITableViewCell.swift in Sources */, DAF5AA682E4F3A39004F18D7 /* EmbeddingRowView.swift in Sources */, - DFA13CB418872EBD006355C3 /* SwitchUITableViewCell.swift in Sources */, DFFD8FD118EDBD4F003B502A /* UICircleButton.swift in Sources */, DA48001C2D837556009CF127 /* SitemapSettingsView.swift in Sources */, 938BF9C624EFCC0700E6B52F /* UILabel+Localization.swift in Sources */, @@ -2059,7 +1967,6 @@ GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; INFOPLIST_FILE = openHABIntents/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -2095,7 +2002,6 @@ GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; INFOPLIST_FILE = openHABIntents/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -2133,7 +2039,6 @@ INFOPLIST_FILE = NotificationService/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = NotificationService; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024 openHAB e.V. All rights reserved."; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -2174,7 +2079,6 @@ INFOPLIST_FILE = NotificationService/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = NotificationService; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024 openHAB e.V. All rights reserved."; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -2208,7 +2112,6 @@ GCC_C_LANGUAGE_STANDARD = gnu11; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; INFOPLIST_FILE = openHABUITests/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -2227,7 +2130,6 @@ SWIFT_PRECOMPILE_BRIDGING_HEADER = NO; TARGETED_DEVICE_FAMILY = "1,2"; TEST_TARGET_NAME = openHAB; - WATCHOS_DEPLOYMENT_TARGET = 26.2; }; name = Debug; }; @@ -2249,7 +2151,6 @@ GCC_C_LANGUAGE_STANDARD = gnu11; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; INFOPLIST_FILE = openHABUITests/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -2296,7 +2197,6 @@ INFOPLIST_KEY_CLKComplicationPrincipalClass = openHABWatch.ComplicationController; INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; INFOPLIST_KEY_WKCompanionAppBundleIdentifier = org.openhab.app; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "@executable_path/Frameworks", "@executable_path/../../Frameworks", @@ -2312,7 +2212,6 @@ SWIFT_EMIT_LOC_STRINGS = YES; TARGETED_DEVICE_FAMILY = 4; VERSIONING_SYSTEM = "apple-generic"; - WATCHOS_DEPLOYMENT_TARGET = 26.2; }; name = Debug; }; @@ -2343,7 +2242,6 @@ INFOPLIST_KEY_CLKComplicationPrincipalClass = openHABWatch.ComplicationController; INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; INFOPLIST_KEY_WKCompanionAppBundleIdentifier = org.openhab.app; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "@executable_path/Frameworks", "@executable_path/../../Frameworks", @@ -2360,7 +2258,6 @@ SWIFT_EMIT_LOC_STRINGS = YES; TARGETED_DEVICE_FAMILY = 4; VERSIONING_SYSTEM = "apple-generic"; - WATCHOS_DEPLOYMENT_TARGET = 26.2; }; name = Release; }; @@ -2380,7 +2277,6 @@ GCC_C_LANGUAGE_STANDARD = gnu11; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; INFOPLIST_FILE = openHABTestsSwift/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -2418,7 +2314,6 @@ GCC_C_LANGUAGE_STANDARD = gnu11; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; INFOPLIST_FILE = openHABTestsSwift/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -2473,7 +2368,6 @@ SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 4; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/openHABWatch.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/openHABWatch"; - WATCHOS_DEPLOYMENT_TARGET = 26.2; }; name = Debug; }; @@ -2511,7 +2405,6 @@ SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 4; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/openHABWatch.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/openHABWatch"; - WATCHOS_DEPLOYMENT_TARGET = 26.2; }; name = Release; }; @@ -2543,7 +2436,6 @@ SWIFT_EMIT_LOC_STRINGS = NO; TARGETED_DEVICE_FAMILY = 4; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/openHABWatch.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/openHABWatch"; - WATCHOS_DEPLOYMENT_TARGET = 26.2; }; name = Debug; }; @@ -2577,7 +2469,6 @@ SWIFT_EMIT_LOC_STRINGS = NO; TARGETED_DEVICE_FAMILY = 4; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/openHABWatch.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/openHABWatch"; - WATCHOS_DEPLOYMENT_TARGET = 26.2; }; name = Release; }; @@ -2608,7 +2499,6 @@ SWIFT_EMIT_LOC_STRINGS = NO; TARGETED_DEVICE_FAMILY = 4; TEST_TARGET_NAME = openHABWatch; - WATCHOS_DEPLOYMENT_TARGET = 26.2; }; name = Debug; }; @@ -2641,7 +2531,6 @@ SWIFT_EMIT_LOC_STRINGS = NO; TARGETED_DEVICE_FAMILY = 4; TEST_TARGET_NAME = openHABWatch; - WATCHOS_DEPLOYMENT_TARGET = 26.2; }; name = Release; }; @@ -2698,7 +2587,6 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; IPHONEOS_DEPLOYMENT_TARGET = 26.2; MARKETING_VERSION = 3.1.1; ONLY_ACTIVE_ARCH = YES; @@ -2774,7 +2662,6 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; IPHONEOS_DEPLOYMENT_TARGET = 26.2; MARKETING_VERSION = 3.1.1; ONLY_ACTIVE_ARCH = NO; @@ -2820,7 +2707,6 @@ INFOPLIST_FILE = "openHAB/openHAB-Info.plist"; INFOPLIST_KEY_CFBundleDisplayName = openHAB; INFOPLIST_KEY_WKCompanionAppBundleIdentifier = ""; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -2868,7 +2754,6 @@ INFOPLIST_FILE = "openHAB/openHAB-Info.plist"; INFOPLIST_KEY_CFBundleDisplayName = openHAB; INFOPLIST_KEY_WKCompanionAppBundleIdentifier = ""; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -2995,6 +2880,22 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ + 2F54B5722F428ACA00DA1F1A /* XCRemoteSwiftPackageReference "swift-async-algorithms" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/apple/swift-async-algorithms.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.1.2; + }; + }; + 2F54B5752F428B0200DA1F1A /* XCRemoteSwiftPackageReference "swift-algorithms" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/apple/swift-algorithms.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.2.1; + }; + }; 937E4483270B379900A98C26 /* XCRemoteSwiftPackageReference "DeviceKit" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/devicekit/DeviceKit.git"; @@ -3062,6 +2963,16 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + 2F54B5732F428ACA00DA1F1A /* AsyncAlgorithms */ = { + isa = XCSwiftPackageProductDependency; + package = 2F54B5722F428ACA00DA1F1A /* XCRemoteSwiftPackageReference "swift-async-algorithms" */; + productName = AsyncAlgorithms; + }; + 2F54B5762F428B0200DA1F1A /* Algorithms */ = { + isa = XCSwiftPackageProductDependency; + package = 2F54B5752F428B0200DA1F1A /* XCRemoteSwiftPackageReference "swift-algorithms" */; + productName = Algorithms; + }; 6557AF912C039D140094D0C8 /* FirebaseMessaging */ = { isa = XCSwiftPackageProductDependency; package = 93F8063327AE6C620035A6B0 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; diff --git a/openHAB.xcworkspace/xcshareddata/swiftpm/Package.resolved b/openHAB.xcworkspace/xcshareddata/swiftpm/Package.resolved index 2b8858e1f..2bea53cfe 100644 --- a/openHAB.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/openHAB.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "35128bd183a1e426d366a14b50e7fee6ab4c3c11da0a5f47b4b833d0ce7e95d9", + "originHash" : "83f21c0111d9078178f4dfa8fc448732dea814a1e407c8e6224198feff68ce09", "pins" : [ { "identity" : "abseil-cpp-binary", @@ -172,6 +172,24 @@ "version" : "7.0.0" } }, + { + "identity" : "swift-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-algorithms.git", + "state" : { + "revision" : "87e50f483c54e6efd60e885f7f5aa946cee68023", + "version" : "1.2.1" + } + }, + { + "identity" : "swift-async-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-async-algorithms.git", + "state" : { + "revision" : "2971dd5d9f6e0515664b01044826bcea16e59fac", + "version" : "1.1.2" + } + }, { "identity" : "swift-collections", "kind" : "remoteSourceControl", diff --git a/openHAB/AppDelegate.swift b/openHAB/AppDelegate.swift index f1e05cbed..751a3a413 100644 --- a/openHAB/AppDelegate.swift +++ b/openHAB/AppDelegate.swift @@ -10,7 +10,6 @@ // SPDX-License-Identifier: EPL-2.0 import AVFoundation -import Combine import Firebase import FirebaseMessaging import Kingfisher @@ -22,6 +21,7 @@ import SwiftUI import UIKit @preconcurrency import UserNotifications import WatchConnectivity +import AsyncAlgorithms @main class AppDelegate: UIResponder, UIApplicationDelegate { @@ -29,7 +29,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { var window: UIWindow? - private var crashlyticsSubscriber: AnyCancellable? + private var crashlyticsTask: Task? private let notificationDelegate = NotificationCenterDelegateImpl() @@ -53,28 +53,32 @@ class AppDelegate: UIResponder, UIApplicationDelegate { AppDelegate.appDelegate = self } + deinit { + crashlyticsTask?.cancel() + } + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { Logger.appDelegate.info("didFinishLaunchingWithOptions started") // Only essential setup here - defer everything else to show UI faster let appDefaults = ["CacheDataAgressively": NSNumber(value: true)] UserDefaults.standard.register(defaults: appDefaults) - - Preferences.migratePreferences() - - UNUserNotificationCenter.current().delegate = notificationDelegate - - // Replace storyboard root with SwiftUI TabView - let rootView = OpenHABTabRootView() - let hostingController = UIHostingController(rootView: rootView) - window = UIWindow(frame: UIScreen.main.bounds) - window?.rootViewController = hostingController - window?.makeKeyAndVisible() - - Logger.appDelegate.info("didFinishLaunchingWithOptions ended") - - // Defer non-essential initialization to after first frame renders + Task { @MainActor in + await Preferences.migratePreferences() + + UNUserNotificationCenter.current().delegate = notificationDelegate + + // Replace storyboard root with SwiftUI TabView + let rootView = OpenHABTabRootView() + let hostingController = UIHostingController(rootView: rootView) + window = UIWindow(frame: UIScreen.main.bounds) + window?.rootViewController = hostingController + window?.makeKeyAndVisible() + + Logger.appDelegate.info("didFinishLaunchingWithOptions ended") + + // Defer non-essential initialization to after first frame renders // Small delay to ensure UI has appeared try? await Task.sleep(for: .milliseconds(100)) performDeferredSetup() @@ -104,24 +108,27 @@ class AppDelegate: UIResponder, UIApplicationDelegate { configureImageCoders() /// load and start the screensaver - if let keyWindow = UIApplication.shared.firstKeyWindow { - var config = ScreenSaverConfiguration() - config.isEnabled = Preferences.shared.screensaverEnabled - config.showsTime = Preferences.shared.screensaverShowsTime - config.showsDate = Preferences.shared.screensaverShowsDate - config.idleInterval = Preferences.shared.screensaverIdleInterval - config.movementInterval = Preferences.shared.screensaverMovementInterval - config.fontName = Preferences.shared.screensaverFontName.isEmpty ? nil : Preferences.shared.screensaverFontName - config.timeFontSizeRatio = CGFloat(Preferences.shared.screensaverTimeFontRatio) - config.dateFontRelativeSize = CGFloat(Preferences.shared.screensaverDateFontRatio) - config.enablesAutoDimming = Preferences.shared.screensaverEnableDimming - config.dimLevel = CGFloat(Preferences.shared.screensaverDimLevel) - config.wakeBrightnessLevel = CGFloat(Preferences.shared.screensaverWakeBrightness) - config.showsSeconds = Preferences.shared.screensaverShowsSeconds - config.uses24HourTime = Preferences.shared.screensaverUse24Hour - config.restoresBrightness = Preferences.shared.screensaverRestoreBrightness - - ScreenSaverManager.shared.startMonitoring(window: keyWindow, configuration: config) + Task { @MainActor in + if let keyWindow = UIApplication.shared.firstKeyWindow { + let prefs = await Preferences.shared.screensaverPreferences + var config = ScreenSaverConfiguration() + config.isEnabled = prefs.isEnabled + config.showsTime = prefs.showsTime + config.showsDate = prefs.showsDate + config.idleInterval = prefs.idleInterval + config.movementInterval = prefs.movementInterval + config.fontName = prefs.fontName.isEmpty ? nil : prefs.fontName + config.timeFontSizeRatio = CGFloat(prefs.timeFontRatio) + config.dateFontRelativeSize = CGFloat(prefs.dateFontRatio) + config.enablesAutoDimming = prefs.enableDimming + config.dimLevel = CGFloat(prefs.dimLevel) + config.wakeBrightnessLevel = CGFloat(prefs.wakeBrightness) + config.showsSeconds = prefs.showsSeconds + config.uses24HourTime = prefs.use24Hour + config.restoresBrightness = prefs.restoreBrightness + + ScreenSaverManager.shared.startMonitoring(window: keyWindow, configuration: config) + } } } @@ -136,10 +143,17 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // init Firebase crash reporting FirebaseApp.configure() FirebaseApp.app()?.isDataCollectionDefaultEnabled = false - crashlyticsSubscriber = Preferences.shared.$sendCrashReports.sink { - Crashlytics.crashlytics().setCrashlyticsCollectionEnabled($0) - Logger.appDelegate.debug("setCrashlyticsCollectionEnabled to \($0)") + + // Use AsyncAlgorithms to observe application preferences changes + crashlyticsTask = Task { + let channel = await Preferences.shared.applicationPreferencesChannel + for await applicationProperties in channel { + let sendCrashReports = applicationProperties.sendCrashReports + Crashlytics.crashlytics().setCrashlyticsCollectionEnabled(sendCrashReports) + Logger.appDelegate.debug("setCrashlyticsCollectionEnabled to \(sendCrashReports)") + } } + Messaging.messaging().delegate = self } @@ -259,24 +273,27 @@ extension AppDelegate { func applicationDidBecomeActive(_ application: UIApplication) { // Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface. - if let keyWindow = UIApplication.shared.firstKeyWindow { - var config = ScreenSaverConfiguration() - config.isEnabled = Preferences.shared.screensaverEnabled - config.showsTime = Preferences.shared.screensaverShowsTime - config.showsDate = Preferences.shared.screensaverShowsDate - config.idleInterval = Preferences.shared.screensaverIdleInterval - config.movementInterval = Preferences.shared.screensaverMovementInterval - config.fontName = Preferences.shared.screensaverFontName.isEmpty ? nil : Preferences.shared.screensaverFontName - config.timeFontSizeRatio = CGFloat(Preferences.shared.screensaverTimeFontRatio) - config.dateFontRelativeSize = CGFloat(Preferences.shared.screensaverDateFontRatio) - config.enablesAutoDimming = Preferences.shared.screensaverEnableDimming - config.dimLevel = CGFloat(Preferences.shared.screensaverDimLevel) - config.wakeBrightnessLevel = CGFloat(Preferences.shared.screensaverWakeBrightness) - config.showsSeconds = Preferences.shared.screensaverShowsSeconds - config.uses24HourTime = Preferences.shared.screensaverUse24Hour - config.restoresBrightness = Preferences.shared.screensaverRestoreBrightness - - ScreenSaverManager.shared.startMonitoring(window: keyWindow, configuration: config) + Task { @MainActor in + if let keyWindow = UIApplication.shared.firstKeyWindow { + let prefs = await Preferences.shared.screensaverPreferences + var config = ScreenSaverConfiguration() + config.isEnabled = prefs.isEnabled + config.showsTime = prefs.showsTime + config.showsDate = prefs.showsDate + config.idleInterval = prefs.idleInterval + config.movementInterval = prefs.movementInterval + config.fontName = prefs.fontName.isEmpty ? nil : prefs.fontName + config.timeFontSizeRatio = CGFloat(prefs.timeFontRatio) + config.dateFontRelativeSize = CGFloat(prefs.dateFontRatio) + config.enablesAutoDimming = prefs.enableDimming + config.dimLevel = CGFloat(prefs.dimLevel) + config.wakeBrightnessLevel = CGFloat(prefs.wakeBrightness) + config.showsSeconds = prefs.showsSeconds + config.uses24HourTime = prefs.use24Hour + config.restoresBrightness = prefs.restoreBrightness + + ScreenSaverManager.shared.startMonitoring(window: keyWindow, configuration: config) + } } } diff --git a/openHAB/Cells/Providers/ColorPickerCellProvider.swift b/openHAB/Cells/Providers/ColorPickerCellProvider.swift deleted file mode 100644 index e44478267..000000000 --- a/openHAB/Cells/Providers/ColorPickerCellProvider.swift +++ /dev/null @@ -1,31 +0,0 @@ -// Copyright (c) 2010-2026 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 Foundation -import OpenHABCore -import UIKit - -struct ColorPickerCellProvider: WidgetCellProvider { - var reuseIdentifier: String { "ColorPickerCell" } - - func dequeue(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { - tableView.dequeueReusableCell(for: indexPath) as ColorPickerCell - } - - @MainActor - func configure(cell: UITableViewCell, for widget: OpenHABWidget, controller: OpenHABSitemapViewController) { - guard let cell = cell as? ColorPickerCell else { return } - cell.delegate = controller - cell.widget = widget - cell.displayWidget() - cell.touchEventDelegate = controller - } -} diff --git a/openHAB/Cells/Providers/DatePickerInputProvider.swift b/openHAB/Cells/Providers/DatePickerInputProvider.swift deleted file mode 100644 index a6e13358f..000000000 --- a/openHAB/Cells/Providers/DatePickerInputProvider.swift +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright (c) 2010-2026 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 Foundation -import OpenHABCore -import UIKit - -struct DatePickerInputProvider: WidgetCellProvider { - var reuseIdentifier: String { "DatePickerUITableViewCell" } - - func dequeue(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { - tableView.dequeueReusableCell(for: indexPath) as DatePickerUITableViewCell - } - - func configure(cell: UITableViewCell, for widget: OpenHABWidget, controller: OpenHABSitemapViewController) { - guard let cell = cell as? DatePickerUITableViewCell else { return } - cell.controller = controller - cell.widget = widget - cell.displayWidget() - cell.touchEventDelegate = controller - } -} diff --git a/openHAB/Cells/Providers/FrameCellProvider.swift b/openHAB/Cells/Providers/FrameCellProvider.swift deleted file mode 100644 index dd712c784..000000000 --- a/openHAB/Cells/Providers/FrameCellProvider.swift +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright (c) 2010-2026 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 Foundation -import OpenHABCore -import UIKit - -struct FrameCellProvider: WidgetCellProvider { - var reuseIdentifier: String { "FrameUITableViewCell" } - - func dequeue(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { - tableView.dequeueReusableCell(for: indexPath) as FrameUITableViewCell - } - - func configure(cell: UITableViewCell, for widget: OpenHABWidget, controller: OpenHABSitemapViewController) { - guard let cell = cell as? FrameUITableViewCell else { return } - cell.widget = widget - cell.displayWidget() - cell.touchEventDelegate = controller - } -} diff --git a/openHAB/Cells/Providers/GenericCellProvider.swift b/openHAB/Cells/Providers/GenericCellProvider.swift deleted file mode 100644 index 35f0beba6..000000000 --- a/openHAB/Cells/Providers/GenericCellProvider.swift +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright (c) 2010-2026 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 Foundation -import OpenHABCore -import UIKit - -struct GenericCellProvider: WidgetCellProvider { - var reuseIdentifier: String { "GenericUITableViewCell" } - - func dequeue(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { - tableView.dequeueReusableCell(for: indexPath) as GenericUITableViewCell - } - - func configure(cell: UITableViewCell, for widget: OpenHABWidget, controller: OpenHABSitemapViewController) { - guard let cell = cell as? GenericUITableViewCell else { return } - cell.widget = widget - cell.displayWidget() - cell.touchEventDelegate = controller - } -} diff --git a/openHAB/Cells/Providers/ImageCellProvider.swift b/openHAB/Cells/Providers/ImageCellProvider.swift deleted file mode 100644 index 9444cadfe..000000000 --- a/openHAB/Cells/Providers/ImageCellProvider.swift +++ /dev/null @@ -1,33 +0,0 @@ -// Copyright (c) 2010-2026 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 Foundation -import OpenHABCore -import UIKit - -struct ImageCellProvider: WidgetCellProvider { - var reuseIdentifier: String { "NewImageUITableViewCell" } - - func dequeue(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { - tableView.dequeueReusableCell(for: indexPath) as NewImageUITableViewCell - } - - func configure(cell: UITableViewCell, for widget: OpenHABWidget, controller: OpenHABSitemapViewController) { - guard let cell = cell as? NewImageUITableViewCell else { return } - cell.didLoad = { [weak controller] in - controller?.updateWidgetTableView() - } - cell.openHABRootUrl = controller.openHABRootUrl - cell.widget = widget - cell.displayWidget() - cell.touchEventDelegate = controller - } -} diff --git a/openHAB/Cells/Providers/MapViewCellProvider.swift b/openHAB/Cells/Providers/MapViewCellProvider.swift deleted file mode 100644 index a05d965db..000000000 --- a/openHAB/Cells/Providers/MapViewCellProvider.swift +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright (c) 2010-2026 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 Foundation -import OpenHABCore -import UIKit - -struct MapViewCellProvider: WidgetCellProvider { - var reuseIdentifier: String { "MapViewTableViewCell" } - - func dequeue(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { - tableView.dequeueReusableCell(for: indexPath) as MapViewTableViewCell - } - - func configure(cell: UITableViewCell, for widget: OpenHABWidget, controller: OpenHABSitemapViewController) { - guard let cell = cell as? MapViewTableViewCell else { return } - cell.widget = widget - cell.displayWidget() - cell.touchEventDelegate = controller - } -} diff --git a/openHAB/Cells/Providers/RollershutterCellProvider.swift b/openHAB/Cells/Providers/RollershutterCellProvider.swift deleted file mode 100644 index fa5726eed..000000000 --- a/openHAB/Cells/Providers/RollershutterCellProvider.swift +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright (c) 2010-2026 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 Foundation -import OpenHABCore -import UIKit - -struct RollershutterCellProvider: WidgetCellProvider { - var reuseIdentifier: String { "RollerShutterTableViewCell" } - - func dequeue(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { - tableView.dequeueReusableCell(for: indexPath) as RollershutterCell - } - - func configure(cell: UITableViewCell, for widget: OpenHABWidget, controller: OpenHABSitemapViewController) { - guard let cell = cell as? RollershutterCell else { return } - cell.widget = widget - cell.displayWidget() - cell.touchEventDelegate = controller - } -} diff --git a/openHAB/Cells/Providers/SegmentedCellProvider.swift b/openHAB/Cells/Providers/SegmentedCellProvider.swift deleted file mode 100644 index 66203ecfb..000000000 --- a/openHAB/Cells/Providers/SegmentedCellProvider.swift +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright (c) 2010-2026 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 Foundation -import OpenHABCore -import UIKit - -struct SegmentedCellProvider: WidgetCellProvider { - var reuseIdentifier: String { "SegmentedUITableViewCell" } - - func dequeue(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { - tableView.dequeueReusableCell(for: indexPath) as SegmentedUITableViewCell - } - - func configure(cell: UITableViewCell, for widget: OpenHABWidget, controller: OpenHABSitemapViewController) { - guard let cell = cell as? SegmentedUITableViewCell else { return } - cell.widget = widget - cell.displayWidget() - cell.touchEventDelegate = controller - } -} diff --git a/openHAB/Cells/Providers/SelectionCellProvider.swift b/openHAB/Cells/Providers/SelectionCellProvider.swift deleted file mode 100644 index b6ef5aee0..000000000 --- a/openHAB/Cells/Providers/SelectionCellProvider.swift +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright (c) 2010-2026 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 Foundation -import OpenHABCore -import UIKit - -struct SelectionCellProvider: WidgetCellProvider { - var reuseIdentifier: String { "SelectionUITableViewCell" } - - func dequeue(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { - tableView.dequeueReusableCell(for: indexPath) as SelectionUITableViewCell - } - - func configure(cell: UITableViewCell, for widget: OpenHABWidget, controller: OpenHABSitemapViewController) { - guard let cell = cell as? SelectionUITableViewCell else { return } - cell.widget = widget - cell.displayWidget() - cell.touchEventDelegate = controller - } -} diff --git a/openHAB/Cells/Providers/SetpointCellProvider.swift b/openHAB/Cells/Providers/SetpointCellProvider.swift deleted file mode 100644 index d1d28fa20..000000000 --- a/openHAB/Cells/Providers/SetpointCellProvider.swift +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright (c) 2010-2026 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 Foundation -import OpenHABCore -import UIKit - -struct SetpointCellProvider: WidgetCellProvider { - var reuseIdentifier: String { "SetpointCell" } - - func dequeue(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { - tableView.dequeueReusableCell(for: indexPath) as SetpointCell - } - - func configure(cell: UITableViewCell, for widget: OpenHABWidget, controller: OpenHABSitemapViewController) { - guard let cell = cell as? SetpointCell else { return } - cell.widget = widget - cell.displayWidget() - cell.touchEventDelegate = controller - } -} diff --git a/openHAB/Cells/Providers/SliderProvider.swift b/openHAB/Cells/Providers/SliderProvider.swift deleted file mode 100644 index 44e9c1650..000000000 --- a/openHAB/Cells/Providers/SliderProvider.swift +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright (c) 2010-2026 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 Foundation -import OpenHABCore -import UIKit - -struct SliderProvider: WidgetCellProvider { - var reuseIdentifier: String { "SliderUITableViewCell" } - - func dequeue(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { - tableView.dequeueReusableCell(for: indexPath) as SliderUITableViewCell - } - - func configure(cell: UITableViewCell, for widget: OpenHABWidget, controller: OpenHABSitemapViewController) { - guard let cell = cell as? SliderUITableViewCell else { return } - cell.widget = widget - cell.displayWidget() - cell.touchEventDelegate = controller - } -} diff --git a/openHAB/Cells/Providers/SliderWithSwitchProvider.swift b/openHAB/Cells/Providers/SliderWithSwitchProvider.swift deleted file mode 100644 index 6c16d75ed..000000000 --- a/openHAB/Cells/Providers/SliderWithSwitchProvider.swift +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright (c) 2010-2026 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 Foundation -import OpenHABCore -import UIKit - -struct SliderWithSwitchProvider: WidgetCellProvider { - var reuseIdentifier: String { "SliderWithSwitchSupportUITableViewCell" } - - func dequeue(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { - tableView.dequeueReusableCell(for: indexPath) as SliderWithSwitchSupportUITableViewCell - } - - func configure(cell: UITableViewCell, for widget: OpenHABWidget, controller: OpenHABSitemapViewController) { - guard let cell = cell as? SliderWithSwitchSupportUITableViewCell else { return } - cell.widget = widget - cell.displayWidget() - cell.touchEventDelegate = controller - } -} diff --git a/openHAB/Cells/Providers/SwitchCellProvider.swift b/openHAB/Cells/Providers/SwitchCellProvider.swift deleted file mode 100644 index 4f11a8910..000000000 --- a/openHAB/Cells/Providers/SwitchCellProvider.swift +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright (c) 2010-2026 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 Foundation -import OpenHABCore -import UIKit - -struct SwitchCellProvider: WidgetCellProvider { - var reuseIdentifier: String { "SwitchUITableViewCell" } - - func dequeue(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { - tableView.dequeueReusableCell(for: indexPath) as SwitchUITableViewCell - } - - func configure(cell: UITableViewCell, for widget: OpenHABWidget, controller: OpenHABSitemapViewController) { - guard let cell = cell as? SwitchUITableViewCell else { return } - cell.widget = widget - cell.displayWidget() - cell.touchEventDelegate = controller - } -} diff --git a/openHAB/Cells/Providers/TextInputProvider.swift b/openHAB/Cells/Providers/TextInputProvider.swift deleted file mode 100644 index 37d3c4b63..000000000 --- a/openHAB/Cells/Providers/TextInputProvider.swift +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright (c) 2010-2026 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 Foundation -import OpenHABCore -import UIKit - -struct TextInputProvider: WidgetCellProvider { - var reuseIdentifier: String { "TextInputUITableViewCell" } - - func dequeue(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { - tableView.dequeueReusableCell(for: indexPath) as TextInputUITableViewCell - } - - func configure(cell: UITableViewCell, for widget: OpenHABWidget, controller: OpenHABSitemapViewController) { - guard let cell = cell as? TextInputUITableViewCell else { return } - cell.widget = widget - cell.displayWidget() - cell.touchEventDelegate = controller - } -} diff --git a/openHAB/Cells/Providers/VideoCellProvider.swift b/openHAB/Cells/Providers/VideoCellProvider.swift deleted file mode 100644 index 1fa4e03f2..000000000 --- a/openHAB/Cells/Providers/VideoCellProvider.swift +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright (c) 2010-2026 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 Foundation -import OpenHABCore -import UIKit - -struct VideoCellProvider: WidgetCellProvider { - var reuseIdentifier: String { "VideoUITableViewCell" } - - func dequeue(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { - tableView.dequeueReusableCell(for: indexPath) as VideoUITableViewCell - } - - func configure(cell: UITableViewCell, for widget: OpenHABWidget, controller: OpenHABSitemapViewController) { - guard let cell = cell as? VideoUITableViewCell else { return } - cell.widget = widget - cell.displayWidget() - cell.touchEventDelegate = controller - } -} diff --git a/openHAB/Cells/Providers/WebViewCellProvider.swift b/openHAB/Cells/Providers/WebViewCellProvider.swift deleted file mode 100644 index b3dcd5840..000000000 --- a/openHAB/Cells/Providers/WebViewCellProvider.swift +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright (c) 2010-2026 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 Foundation -import OpenHABCore -import UIKit - -struct WebViewCellProvider: WidgetCellProvider { - var reuseIdentifier: String { "WebUITableViewCell" } - - func dequeue(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { - tableView.dequeueReusableCell(for: indexPath) as WebUITableViewCell - } - - func configure(cell: UITableViewCell, for widget: OpenHABWidget, controller: OpenHABSitemapViewController) { - guard let cell = cell as? WebUITableViewCell else { return } - cell.widget = widget - cell.displayWidget() - cell.touchEventDelegate = controller - } -} diff --git a/openHAB/Cells/WidgetCellProvider.swift b/openHAB/Cells/WidgetCellProvider.swift deleted file mode 100644 index a5061a0b2..000000000 --- a/openHAB/Cells/WidgetCellProvider.swift +++ /dev/null @@ -1,61 +0,0 @@ -// Copyright (c) 2010-2026 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 Foundation -import OpenHABCore -import UIKit - -enum WidgetCellFactory { - static func provider(for widget: OpenHABWidget) -> any WidgetCellProvider { - switch widget.type { - case .switchWidget: - if !widget.mappings.isEmpty { - SegmentedCellProvider() - } else if widget.item?.isOfTypeOrGroupType(.switchItem) ?? false { - SwitchCellProvider() - } else if widget.item?.isOfTypeOrGroupType(.rollershutter) ?? false { - RollershutterCellProvider() - } else if !widget.mappingsOrItemOptions.isEmpty { - SegmentedCellProvider() - } else { - SwitchCellProvider() - } - case .slider: - if widget.switchSupport { - SliderWithSwitchProvider() - } else { - SliderProvider() - } - case .input: - if [.date, .time, .dateTime].contains(widget.inputHint) { - DatePickerInputProvider() - } else { - TextInputProvider() - } - case .frame: FrameCellProvider() - case .setpoint: SetpointCellProvider() - case .selection: SelectionCellProvider() - case .colorpicker: ColorPickerCellProvider() - case .image, .chart: ImageCellProvider() - case .video: VideoCellProvider() - case .webview: WebViewCellProvider() - case .mapview: MapViewCellProvider() - case .group, .text, .defaultWidget, .colortemperaturepicker, .buttongrid, .button, .unknown: - GenericCellProvider() - } - } -} - -protocol WidgetCellProvider { - var reuseIdentifier: String { get } - @MainActor func dequeue(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell - @MainActor func configure(cell: UITableViewCell, for widget: OpenHABWidget, controller: OpenHABSitemapViewController) -} diff --git a/openHAB/ColorPickerCell.swift b/openHAB/ColorPickerCell.swift deleted file mode 100644 index c8d4bbdf2..000000000 --- a/openHAB/ColorPickerCell.swift +++ /dev/null @@ -1,70 +0,0 @@ -// Copyright (c) 2010-2026 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 OpenHABCore -import os.log -import UIKit - -protocol ColorPickerCellDelegate: NSObjectProtocol { - func didPressColorButton(_ cell: ColorPickerCell?) -} - -class ColorPickerCell: GenericUITableViewCell { - weak var delegate: (any ColorPickerCellDelegate)? - - @IBOutlet private var downButton: UIButton! - @IBOutlet private var upButton: UIButton! - @IBOutlet private var colorButton: UICircleButton! - - required init?(coder: NSCoder) { - Logger.widgets.info("ColorPickerCell initWithCoder") - - super.init(coder: coder) - - selectionStyle = .none - separatorInset = .zero - } - - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - - selectionStyle = .none - separatorInset = .zero - } - - @IBAction private func colorButtonPressed(_ sender: Any) { - delegate?.didPressColorButton(self) - } - - override func displayWidget() { - customTextLabel?.text = widget.labelText - colorButton?.backgroundColor = widget.item?.stateAsUIColor() - upButton?.addTarget(self, action: .upButtonPressed, for: .touchUpInside) - downButton?.addTarget(self, action: .downButtonPressed, for: .touchUpInside) - } - - @objc - func upButtonPressed() { - Logger.widgets.info("ON button pressed") - widget.sendCommand("ON") - } - - @objc - func downButtonPressed() { - Logger.widgets.info("OFF button pressed") - widget.sendCommand("OFF") - } -} - -private extension Selector { - static let upButtonPressed = #selector(ColorPickerCell.upButtonPressed) - static let downButtonPressed = #selector(ColorPickerCell.downButtonPressed) -} diff --git a/openHAB/ColorPickerViewController.swift b/openHAB/ColorPickerViewController.swift deleted file mode 100644 index 2989c56ca..000000000 --- a/openHAB/ColorPickerViewController.swift +++ /dev/null @@ -1,87 +0,0 @@ -// Copyright (c) 2010-2026 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 FlexColorPicker -import OpenHABCore -import os.log -import UIKit - -class ColorPickerViewController: DefaultColorPickerViewController { - var widget: OpenHABWidget? - - /// Throttle engine - private var throttler: Throttler? - - /// Throttling interval - var throttlingInterval: TimeInterval? = 0 { - didSet { - guard let interval = throttlingInterval else { - throttler = nil - return - } - throttler = Throttler(maxInterval: interval) - } - } - - required init?(coder: NSCoder) { - Logger.widgets.info("ColorPickerViewController initWithCoder") - super.init(coder: coder) - } - - override func viewDidLoad() { - Logger.widgets.info("ColorPickerViewController viewDidLoad") - - if let color = widget?.item?.stateAsUIColor() { - selectedColor = color - } - - delegate = self - - if #available(iOS 13.0, *) { - // if nothing is set DefaultColorPickerViewController will fall back to .white - // if we set this manually DefaultColorPickerViewController will go with that - view.backgroundColor = .ohSystemBackground - } else { - // do nothing - DefaultColorPickerViewController will handle this - } - - super.viewDidLoad() - throttlingInterval = 0.3 - } - - func sendColorUpdate(color: UIColor) { - // swiftlint:disable:next large_tuple - var (hue, saturation, brightness, alpha): (CGFloat, CGFloat, CGFloat, CGFloat) = (0.0, 0.0, 0.0, 0.0) - color.getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: &alpha) - - hue *= 360 - saturation *= 100 - brightness *= 100 - - Logger.widgets.info("Color changed to HSB(\(hue), \(saturation), \(brightness)).") - - widget?.sendCommand("\(hue),\(saturation),\(brightness)") - } -} - -extension ColorPickerViewController: @preconcurrency ColorPickerDelegate { - func colorPicker(_ colorPicker: ColorPickerController, selectedColor: UIColor, usingControl: any ColorControl) { - if let throttler { - throttler.throttle { DispatchQueue.main.async { self.sendColorUpdate(color: selectedColor) } } - } else { - sendColorUpdate(color: selectedColor) - } - } - - func colorPicker(_ colorPicker: ColorPickerController, confirmedColor: UIColor, usingControl: any ColorControl) { - sendColorUpdate(color: confirmedColor) - } -} diff --git a/openHAB/DatePickerUITableViewCell.swift b/openHAB/DatePickerUITableViewCell.swift deleted file mode 100644 index f2262b059..000000000 --- a/openHAB/DatePickerUITableViewCell.swift +++ /dev/null @@ -1,56 +0,0 @@ -// Copyright (c) 2010-2026 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 OpenHABCore -import UIKit - -class DatePickerUITableViewCell: GenericUITableViewCell { - static let dateFormatter = { - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss" - return dateFormatter - }() - - override var widget: OpenHABWidget! { - get { - super.widget - } - set(widget) { - super.widget = widget - switch widget.inputHint { - case .date: - datePicker.datePickerMode = .date - case .time: - datePicker.datePickerMode = .time - case .dateTime: - datePicker.datePickerMode = .dateAndTime - default: - fatalError("Must not use this cell for input other than date and time") - } - guard let date = widget.item?.state else { - datePicker.date = Date() - return - } - datePicker.date = DateFormatter.iso8601Full.date(from: date) ?? Date.now - } - } - - weak var controller: OpenHABSitemapViewController! - - @IBOutlet private(set) var datePicker: UIDatePicker! { - didSet { - datePicker.addAction(UIAction { [weak self] _ in - guard let self else { return } - controller?.sendCommand(widget.item, commandToSend: DateFormatter.iso8601Full.string(from: datePicker.date)) - }, for: .valueChanged) - } - } -} diff --git a/openHAB/FrameUITableViewCell.swift b/openHAB/FrameUITableViewCell.swift deleted file mode 100644 index a52efb6d6..000000000 --- a/openHAB/FrameUITableViewCell.swift +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright (c) 2010-2026 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 OpenHABCore -import UIKit - -class FrameUITableViewCell: GenericUITableViewCell, NoIconDisplayableCell { - required init?(coder: NSCoder) { - super.init(coder: coder) - - selectionStyle = .none - separatorInset = .zero - } - - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - - selectionStyle = .none - separatorInset = .zero - } - - override func displayWidget() { - textLabel?.textColor = .ohSecondaryLabel - textLabel?.font = .preferredFont(forTextStyle: .callout) - textLabel?.text = widget.label.uppercased() - contentView.sizeToFit() - } -} diff --git a/openHAB/GenericUITableViewCell.swift b/openHAB/GenericUITableViewCell.swift deleted file mode 100644 index 2b9f91b8e..000000000 --- a/openHAB/GenericUITableViewCell.swift +++ /dev/null @@ -1,100 +0,0 @@ -// Copyright (c) 2010-2026 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 Kingfisher -import OpenHABCore -import UIKit - -protocol GenericCellCacheProtocol: UITableViewCell { - func invalidateCache() -} - -@MainActor -protocol GenericUITableViewCellTouchEventDelegate: AnyObject { - func touchDown() - func touchUp() -} - -class GenericUITableViewCell: UITableViewCell { - private var _widget: OpenHABWidget! - - // optional event callback if table cells neeed to notify on touch up or down events - weak var touchEventDelegate: (any GenericUITableViewCellTouchEventDelegate)? - - var widget: OpenHABWidget! { - get { - _widget - } - set(widget) { - _widget = widget - - if _widget.linkedPage != nil { - accessoryType = .disclosureIndicator - selectionStyle = .blue - // self.userInteractionEnabled = YES; - } else { - accessoryType = .none - selectionStyle = .none - // self.userInteractionEnabled = NO; - } - - customTextLabel?.textColor = !(_widget.labelcolor.isEmpty) ? UIColor(fromString: _widget.labelcolor) : .ohLabel - customDetailTextLabel?.textColor = !(_widget.valuecolor.isEmpty) ? UIColor(fromString: _widget.valuecolor) : .ohSecondaryLabel - } - } - - @IBOutlet private(set) var customTextLabel: UILabel! - @IBOutlet private(set) var customDetailTextLabel: UILabel! - @IBOutlet private(set) var customDetailTextLabelConstraint: NSLayoutConstraint! - - required init?(coder: NSCoder) { - super.init(coder: coder) - initialize() - } - - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - initialize() - } - - func initialize() { - selectionStyle = .none - separatorInset = .zero - } - - // This is to fix possible different sizes of user icons - we fix size and position of UITableViewCell icons - override func layoutSubviews() { - super.layoutSubviews() - imageView?.frame = CGRect(x: 13, y: 5, width: 32, height: 32) - } - - func displayWidget() { - customTextLabel?.text = widget?.labelText - customDetailTextLabel?.text = widget?.labelValue ?? "" - customDetailTextLabel?.sizeToFit() - - if customDetailTextLabel != nil, customDetailTextLabelConstraint != nil { - if accessoryType == .none { - // If accessory is disabled, set detailTextLabel (widget value) constraint 20px to the right for padding to the right side of table view - customDetailTextLabelConstraint.constant = 20.0 - } else { - // If accessory is enabled, set detailTextLabel (widget value) constraint 5px to the right - customDetailTextLabelConstraint.constant = 5.0 - } - } - } - - override func prepareForReuse() { - super.prepareForReuse() - imageView?.kf.cancelDownloadTask() - imageView?.image = nil - } -} diff --git a/openHAB/Main.storyboard b/openHAB/Main.storyboard index 46c53046d..1ae4c2444 100644 --- a/openHAB/Main.storyboard +++ b/openHAB/Main.storyboard @@ -1,682 +1,12 @@ - + - - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -700,7 +30,7 @@ - + @@ -714,31 +44,13 @@ - - - - - - - - - - - - - - - - - - - + @@ -771,12 +83,6 @@ - - - - - - diff --git a/openHAB/MapViewTableViewCell.swift b/openHAB/MapViewTableViewCell.swift deleted file mode 100644 index 6c20ae739..000000000 --- a/openHAB/MapViewTableViewCell.swift +++ /dev/null @@ -1,65 +0,0 @@ -// Copyright (c) 2010-2026 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 MapKit -import OpenHABCore - -class MapViewTableViewCell: GenericUITableViewCell { - private var mapView: MKMapView! - - override var widget: OpenHABWidget! { - get { - super.widget - } - set(widget) { - let oldLocationCoordinate: CLLocationCoordinate2D? = self.widget?.coordinate - let oldLocationTitle = self.widget?.labelText ?? "" - let newLocationCoordinate: CLLocationCoordinate2D? = widget?.coordinate - let newLocationTitle = widget?.labelText - - super.widget = widget - - if !(oldLocationCoordinate?.latitude == newLocationCoordinate?.latitude && oldLocationCoordinate?.longitude == newLocationCoordinate?.longitude && (oldLocationTitle == newLocationTitle)) { - mapView.removeAnnotations(mapView.annotations) - - if widget?.item?.stateAsLocation() != nil { - if let widget { - mapView.addAnnotation(widget) - } - mapView.setRegion(MKCoordinateRegion(center: (widget?.coordinate)!, latitudinalMeters: 1000.0, longitudinalMeters: 1000.0), animated: false) - } - } - } - } - - required init?(coder: NSCoder) { - super.init(coder: coder) - mapView = MKMapView(frame: CGRect.zero) - mapView.layer.cornerRadius = 4.0 - mapView.layer.masksToBounds = true - contentView.addSubview(mapView) - } - - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - - mapView = MKMapView(frame: CGRect.zero) - mapView.layer.cornerRadius = 4.0 - mapView.layer.masksToBounds = true - contentView.addSubview(mapView) - } - - override func layoutSubviews() { - super.layoutSubviews() - - mapView.frame = contentView.bounds.insetBy(dx: 13.0, dy: 8.0) - } -} diff --git a/openHAB/NewImageUITableViewCell.swift b/openHAB/NewImageUITableViewCell.swift deleted file mode 100644 index 3dc622818..000000000 --- a/openHAB/NewImageUITableViewCell.swift +++ /dev/null @@ -1,258 +0,0 @@ -// Copyright (c) 2010-2026 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 Combine -import OpenHABCore -import os.log -import UIKit - -class NewImageUITableViewCell: GenericUITableViewCell, NoIconDisplayableCell { - // Shared image cache across all cells - keyed by widget ID - // Using NSCache for thread-safety and automatic memory management - private static let sharedImageCache = NSCache() - - private var mainImageView: ScaleAspectFitImageView! - private var refreshTimer: Timer? - private var currentRefreshInterval: TimeInterval = 0 - private var chartStyle: ChartStyle = .light - private var activeTask: Task? - private var displayedWidgetId: String? // Track which widget this cell is currently displaying - - var didLoad: (() -> Void)? - - var openHABRootUrl: String? - - private var shouldCache: Bool { - widget?.refresh == 0 - } - - private var widgetPayload: ImageType { - guard let widget else { return .empty } - - switch widget.type { - case .chart: - guard let openHABRootUrl else { - Logger.widgets.error("Missing openHABRootUrl in NewImageUITableViewCell") - return .empty - } - return .link(url: Endpoint.chart( - rootUrl: openHABRootUrl, - period: widget.period, - type: widget.item?.type, - service: widget.service, - name: widget.item?.name, - legend: widget.legend, - theme: chartStyle, - forceAsItem: widget.forceAsItem, - yAxisDecimalPattern: widget.yAxisDecimalPattern - ).url) - case .image: - if let item = widget.item { - return widgetPayload(fromItem: item) - } - return .link(url: URL(string: widget.url)) - default: - return .empty - } - } - - @available(*, unavailable) - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - - mainImageView = ScaleAspectFitImageView() - - contentView.addSubview(mainImageView) - - let positionGuide = contentView // contentView.layoutMarginsGuide if more margin would be appreciated - - mainImageView.translatesAutoresizingMaskIntoConstraints = false // enable autolayout - - NSLayoutConstraint.activate([ - mainImageView.leftAnchor.constraint(equalTo: positionGuide.leftAnchor), - mainImageView.rightAnchor.constraint(equalTo: positionGuide.rightAnchor), - mainImageView.topAnchor.constraint(equalTo: positionGuide.topAnchor), - mainImageView.bottomAnchor.constraint(equalTo: positionGuide.bottomAnchor) - ]) - - chartStyle = OHInterfaceStyle.current == .light ? ChartStyle.light : ChartStyle.dark - } - - override func willMove(toSuperview newSuperview: UIView?) { - super.willMove(toSuperview: newSuperview) - - if newSuperview == nil { - refreshTimer?.invalidate() - } - } - - override func prepareForReuse() { - super.prepareForReuse() - - // Cancel any active image loading task to prevent race conditions where a task - // started for a previous widget updates the shared cache or UI for a newly assigned widget - activeTask?.cancel() - activeTask = nil - - // Invalidate timer to prevent refreshes for wrong widget - refreshTimer?.invalidate() - refreshTimer = nil - currentRefreshInterval = 0 - - // Clear displayed widget ID to ensure clean state - displayedWidgetId = nil - - // Reset chart style - chartStyle = OHInterfaceStyle.current == .light ? ChartStyle.light : ChartStyle.dark - } - - override func displayWidget() { - let widgetId = widget?.id ?? "" - // Set displayedWidgetId before cache check to ensure consistency - displayedWidgetId = widgetId - - // Check shared cache for this widget's image - if let cachedImage = Self.sharedImageCache.object(forKey: widgetId as NSString) { - // Found in shared cache - use it immediately without reloading - mainImageView?.image = cachedImage - } else { - // Not in cache - need to load - mainImageView?.image = nil - loadImage() - } - - // Handle refresh timer - only update if the interval has changed - let newRefreshInterval = widget.refresh != 0 ? TimeInterval(Double(widget.refresh) / 1000) : 0 - - if newRefreshInterval != currentRefreshInterval { - // Refresh interval changed, update the timer - refreshTimer?.invalidate() - refreshTimer = nil - currentRefreshInterval = newRefreshInterval - - if newRefreshInterval > 0.09 { - Logger.widgets.info("Scheduling image refresh every \(newRefreshInterval) seconds") - refreshTimer = Timer.scheduledTimer( - timeInterval: newRefreshInterval, - target: self, - selector: #selector(NewImageUITableViewCell.refreshImage(_:)), - userInfo: nil, - repeats: true - ) - } - } - } - - func loadImage() { - let widgetId = widget?.id ?? "" - switch widgetPayload { - case let .embedded(image): - if let image { - // Only update cache and UI if still displaying the same widget - if displayedWidgetId == widgetId { - Self.sharedImageCache.setObject(image, forKey: widgetId as NSString) - mainImageView.image = image - didLoad?() - } - } - case let .link(url): - guard let url else { return } - loadRemoteImage(withURL: url) - default: - Logger.widgets.debug("Failed to determine widget payload.") - } - } - - private func widgetPayload(fromItem item: OpenHABItem) -> ImageType { - switch item.type { - case .image: - Logger.widgets.debug("Image base64Encoded.") - guard let data = item.state?.components(separatedBy: ",")[safe: 1], let decodedData = Data(base64Encoded: data, options: .ignoreUnknownCharacters) else { - return .empty - } - return .embedded(image: UIImage(data: decodedData)) - case .stringItem: - return .link(url: URL(string: item.state ?? "")) - default: - return .empty - } - } - - private func loadRemoteImage(withURL url: URL) { - let widgetId = widget?.id ?? "" - Logger.widgets.debug("Image URL: \(url.absoluteString)") - - if activeTask != nil { - activeTask?.cancel() - activeTask = nil - } - - activeTask = Task { - do { - guard let config = await NetworkTracker.shared.activeConnection?.configuration else { - Logger.widgets.warning("No openHAB connection found.") - throw HTTPClientError.noConfiguration - } - let client = HTTPClient(connectionConfiguration: config) - let (data, _): (Data, URLResponse) = try await client.doRequest(baseURL: url, timeout: 10.0, type: .data, cacheingPolicy: !shouldCache ? .reloadIgnoringCacheData : .useProtocolCachePolicy) - await MainActor.run { - guard let image = UIImage(data: data) else { return } - // Only store in cache and update UI if this cell is still displaying the same widget - if self.displayedWidgetId == widgetId { - Self.sharedImageCache.setObject(image, forKey: widgetId as NSString) - self.mainImageView?.image = image - self.didLoad?() - } - } - } catch { - Logger.widgets.info("Downloading image failed: \(error.localizedDescription)") - } - } - } - - @objc - func refreshImage(_ timer: Timer?) { - // swiftformat:disable:next redundantSelf - Logger.widgets.info("Refreshing image on \(Double(self.widget.refresh) / 1000) seconds schedule") - loadImage() - } - - override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { - super.traitCollectionDidChange(previousTraitCollection) - - chartStyle = OHInterfaceStyle.current == .light ? ChartStyle.light : ChartStyle.dark - if widget.type == .chart { - loadImage() - } - } -} - -extension NewImageUITableViewCell: GenericCellCacheProtocol { - /// Clears the entire shared image cache (call when sitemap changes, etc.) - static func clearSharedCache() { - sharedImageCache.removeAllObjects() - } - - func invalidateCache() { - refreshTimer?.invalidate() - refreshTimer = nil - currentRefreshInterval = 0 - // Clear this widget from shared cache - if let widgetId = displayedWidgetId { - Self.sharedImageCache.removeObject(forKey: widgetId as NSString) - } - displayedWidgetId = nil - } -} diff --git a/openHAB/NotificationTableViewCell.swift b/openHAB/NotificationTableViewCell.swift deleted file mode 100644 index 302128f0f..000000000 --- a/openHAB/NotificationTableViewCell.swift +++ /dev/null @@ -1,31 +0,0 @@ -// Copyright (c) 2010-2026 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 OpenHABCore -import os.log -import UIKit - -class NotificationTableViewCell: UITableViewCell { - @IBOutlet private(set) var customTextLabel: UILabel! - @IBOutlet private(set) var customDetailTextLabel: UILabel! - - required init?(coder: NSCoder) { - Logger.widgets.info("NotificationTableViewCell initWithCoder") - super.init(coder: coder) - separatorInset = .zero - } - - // This is to fix possible different sizes of user icons - we fix size and position of UITableViewCell icons - override func layoutSubviews() { - super.layoutSubviews() - imageView?.frame = CGRect(x: 14, y: 6, width: 30, height: 30) - } -} diff --git a/openHAB/PlayerView.swift b/openHAB/PlayerView.swift deleted file mode 100644 index 5aa64af34..000000000 --- a/openHAB/PlayerView.swift +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright (c) 2010-2026 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 - -// See https://developer.apple.com/documentation/avfoundation/avplayerlayer -// A convenient way of using AVPlayerLayer as the backing layer for a UIView - -import AVFoundation -import AVKit - -class PlayerView: UIView { - // Override UIView property - override static var layerClass: AnyClass { - AVPlayerLayer.self - } - - var player: AVPlayer? { - get { - playerLayer.player - } - set { - playerLayer.player = newValue - } - } - - var playerLayer: AVPlayerLayer { - layer as! AVPlayerLayer - } -} diff --git a/openHAB/ReusableView.swift b/openHAB/ReusableView.swift deleted file mode 100644 index c50908411..000000000 --- a/openHAB/ReusableView.swift +++ /dev/null @@ -1,25 +0,0 @@ -// Copyright (c) 2010-2026 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 Foundation -import UIKit - -protocol ReusableView { - static var reuseIdentifier: String { get } -} - -extension ReusableView { - static var reuseIdentifier: String { - String(describing: self) - } -} - -extension UITableViewCell: ReusableView {} diff --git a/openHAB/RollershutterCell.swift b/openHAB/RollershutterCell.swift deleted file mode 100644 index 6ea900216..000000000 --- a/openHAB/RollershutterCell.swift +++ /dev/null @@ -1,77 +0,0 @@ -// Copyright (c) 2010-2026 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 OpenHABCore -import os.log -import UIKit - -class RollershutterCell: GenericUITableViewCell { - private let feedbackGenerator = UIImpactFeedbackGenerator(style: .light) - - @IBOutlet private var upButton: UIButton! - @IBOutlet private var stopButton: UIButton! - @IBOutlet private var downButton: UIButton! - @IBOutlet private var customDetailText: UILabel! - - required init?(coder: NSCoder) { - Logger.widgets.info("RollershutterCell initWithCoder") - super.init(coder: coder) - initialize() - } - - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - Logger.widgets.info("RollershutterCell initWithStyle") - super.init(style: style, reuseIdentifier: reuseIdentifier) - initialize() - } - - override func initialize() { - selectionStyle = .none - separatorInset = .zero - } - - override func displayWidget() { - customTextLabel?.text = widget.labelText - customDetailText?.text = widget.labelValue ?? "" - upButton?.addTarget(self, action: .upButtonPressed, for: .touchUpInside) - stopButton?.addTarget(self, action: .stopButtonPressed, for: .touchUpInside) - downButton?.addTarget(self, action: .downButtonPressed, for: .touchUpInside) - } - - @objc - func upButtonPressed() { - Logger.widgets.info("up button pressed") - widget.sendCommand("UP") - feedbackGenerator.impactOccurred() - } - - @objc - func stopButtonPressed() { - Logger.widgets.info("stop button pressed") - widget.sendCommand("STOP") - feedbackGenerator.impactOccurred() - } - - @objc - func downButtonPressed() { - Logger.widgets.info("down button pressed") - widget.sendCommand("DOWN") - feedbackGenerator.impactOccurred() - } -} - -// inspired by: Selectors in swift: A better approach using extensions -// https://medium.com/@abhimuralidharan/selectors-in-swift-a-better-approach-using-extensions-aa6b0416e850 -private extension Selector { - static let upButtonPressed = #selector(RollershutterCell.upButtonPressed) - static let stopButtonPressed = #selector(RollershutterCell.stopButtonPressed) - static let downButtonPressed = #selector(RollershutterCell.downButtonPressed) -} diff --git a/openHAB/SegmentedUITableViewCell.swift b/openHAB/SegmentedUITableViewCell.swift deleted file mode 100644 index a1773d459..000000000 --- a/openHAB/SegmentedUITableViewCell.swift +++ /dev/null @@ -1,63 +0,0 @@ -// Copyright (c) 2010-2026 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 OpenHABCore -import os.log -import UIKit - -class SegmentedUITableViewCell: GenericUITableViewCell { - private let feedbackGenerator = UIImpactFeedbackGenerator(style: .light) - - // @IBOutlet private var customTextLabel: UILabel! - @IBOutlet private var widgetSegmentControl: UISegmentedControl! - - required init?(coder: NSCoder) { - super.init(coder: coder) - - selectionStyle = .none - separatorInset = .zero - } - - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - - selectionStyle = .none - separatorInset = .zero - } - - override func displayWidget() { - customTextLabel?.text = widget.labelText - customDetailTextLabel?.text = widget.labelValue ?? "" - - widgetSegmentControl.apportionsSegmentWidthsByContent = true - widgetSegmentControl.removeAllSegments() - widgetSegmentControl.apportionsSegmentWidthsByContent = true - - for (index, mapping) in (widget?.mappingsOrItemOptions ?? []).enumerated() { - widgetSegmentControl.insertSegment(withTitle: mapping.label, at: index, animated: false) - } - - widgetSegmentControl.isMomentary = widget.mappingsOrItemOptions.count == 1 || widget.item?.state == "NULL" - widgetSegmentControl.selectedSegmentIndex = widgetSegmentControl.isMomentary ? -1 : Int(widget.mappingIndex(byCommand: widget.item?.state) ?? -1) - widgetSegmentControl.addTarget(self, action: #selector(SegmentedUITableViewCell.pickOne(_:)), for: .valueChanged) - } - - @objc - func pickOne(_ sender: Any?) { - guard let segmentedControl = sender as? UISegmentedControl, let mapping = widget.mappingsOrItemOptions[safe: segmentedControl.selectedSegmentIndex] else { - return - } - - Logger.widgets.info("Segment pressed \(segmentedControl.selectedSegmentIndex)") - widget.sendCommand(mapping.command) - feedbackGenerator.impactOccurred() - } -} diff --git a/openHAB/SelectionUITableViewCell.swift b/openHAB/SelectionUITableViewCell.swift deleted file mode 100644 index f9aad025b..000000000 --- a/openHAB/SelectionUITableViewCell.swift +++ /dev/null @@ -1,37 +0,0 @@ -// Copyright (c) 2010-2026 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 OpenHABCore -import UIKit - -class SelectionUITableViewCell: GenericUITableViewCell { - override var widget: OpenHABWidget! { - get { - super.widget - } - set(widget) { - super.widget = widget - accessoryType = .disclosureIndicator - selectionStyle = .blue - } - } - - override func displayWidget() { - super.customTextLabel?.text = widget.labelText - if let selectedMapping = widget.mappingIndex(byCommand: widget.item?.state) { - if let widgetMapping = widget?.mappingsOrItemOptions[Int(selectedMapping)] { - customDetailTextLabel?.text = widgetMapping.label - } - } else { - customDetailTextLabel?.text = "" - } - } -} diff --git a/openHAB/SetpointCell.swift b/openHAB/SetpointCell.swift deleted file mode 100644 index 6c34bf552..000000000 --- a/openHAB/SetpointCell.swift +++ /dev/null @@ -1,88 +0,0 @@ -// Copyright (c) 2010-2026 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 OpenHABCore -import os.log -import UIKit - -class SetpointCell: GenericUITableViewCell { - private let setpointService: SetPointService - - @IBOutlet private var downButton: UIButton! - @IBOutlet private var upButton: UIButton! - required init?(coder: NSCoder) { - setpointService = SetPointService() - - super.init(coder: coder) - - selectionStyle = .none - separatorInset = .zero - } - - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - setpointService = SetPointService() - super.init(style: style, reuseIdentifier: reuseIdentifier) - } - - override func awakeFromNib() { - super.awakeFromNib() - Task { @MainActor in - customDetailTextLabel.font = UIFont.monospacedDigitSystemFont( - ofSize: customDetailTextLabel.font.pointSize, - weight: .regular - ) - } - } - - override func displayWidget() { - downButton.addTarget(self, action: #selector(SetpointCell.decreaseValue), for: .touchUpInside) - upButton.addTarget(self, action: #selector(SetpointCell.increaseValue), for: .touchUpInside) - - super.displayWidget() - } - - private func handleUpDown(down: Bool) { - var numberState = widget?.stateValueAsNumberState - let currentValue = numberState?.value ?? widget.minValue - - let limitedNewValue = setpointService.calculateNewValue( - currentValue: currentValue, - step: widget.step, - minValue: widget.minValue, - maxValue: widget.maxValue, - isDecreasing: down - ) - - guard limitedNewValue != currentValue else { - // nothing to update, skip sending value - return - } - - if numberState != nil { - numberState?.value = limitedNewValue - } else { - // Use widget's unit as fallback when creating NumberState - numberState = NumberState(value: limitedNewValue, unit: widget.unit) - } - - widget.sendItemUpdate(state: numberState) - } - - @objc - func decreaseValue(_ sender: Any?) { - handleUpDown(down: true) - } - - @objc - func increaseValue(_ sender: Any?) { - handleUpDown(down: false) - } -} diff --git a/openHAB/SitemapPageViewModel.swift b/openHAB/SitemapPageViewModel.swift index a69e672ed..3734074db 100644 --- a/openHAB/SitemapPageViewModel.swift +++ b/openHAB/SitemapPageViewModel.swift @@ -182,8 +182,10 @@ class SitemapPageViewModel: ObservableObject { @MainActor extension SitemapPageViewModel { func loadSettings() { - defaultSitemap = Preferences.shared.currentHomePreferences.defaultSitemap - showSearchField = Preferences.shared.applicationPreferences.showSearchField + Task { + defaultSitemap = await Preferences.shared.currentHomePreferences.defaultSitemap + showSearchField = await Preferences.shared.applicationPreferences.showSearchField + } } func stopPageHandling() { @@ -449,27 +451,29 @@ extension SitemapPageViewModel { // Filter out _default sitemap if there are multiple sitemaps available let filteredSitemaps = sitemaps.count > 1 ? sitemaps.filter { $0.name != "_default" } : sitemaps + let defaultSitemap = defaultSitemap + switch filteredSitemaps.count { case 1: // Auto-select the only available sitemap - defaultSitemap = filteredSitemaps[0].name + self.defaultSitemap = filteredSitemaps[0].name defaultSitemapLabel = filteredSitemaps[0].label // swiftformat:disable:next redundantSelf logger.info("Auto-selected single sitemap: \(self.defaultSitemap)") // Save as default for future launches - Preferences.shared.modifyActiveHome { homePreferences in + await Preferences.shared.modifyActiveHome { homePreferences in homePreferences.defaultSitemap = defaultSitemap } case 2...: // Multiple sitemaps available - select the first one - defaultSitemap = filteredSitemaps[0].name + self.defaultSitemap = filteredSitemaps[0].name defaultSitemapLabel = filteredSitemaps[0].label // swiftformat:disable:next redundantSelf logger.info("Auto-selected first sitemap from \(filteredSitemaps.count) available: \(self.defaultSitemap)") // Save as default for future launches - Preferences.shared.modifyActiveHome { homePreferences in + await Preferences.shared.modifyActiveHome { homePreferences in homePreferences.defaultSitemap = defaultSitemap } default: diff --git a/openHAB/SliderUITableViewCell.swift b/openHAB/SliderUITableViewCell.swift deleted file mode 100644 index 4aa378295..000000000 --- a/openHAB/SliderUITableViewCell.swift +++ /dev/null @@ -1,110 +0,0 @@ -// Copyright (c) 2010-2026 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 OpenHABCore -import os.log -import UIKit - -class SliderUITableViewCell: GenericUITableViewCell { - private var step: Float = 1.0 - - private var widgetValue: Double { - adj(Double(widgetSlider?.value ?? Float(widget.minValue))) - } - - @IBOutlet private var widgetSlider: UISlider! - @IBOutlet private var customDetailText: UILabel! - - required init?(coder: NSCoder) { - super.init(coder: coder) - initialize() - } - - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - initialize() - } - - // swiftlint:disable:next type_contents_order - override func initialize() { - selectionStyle = .none - separatorInset = .zero - if let widget { - step = Float(widget.step) - } else { - step = 1.0 - } - } - - @IBAction private func sliderValueChanged(_ sender: UISlider) { - customDetailText?.text = widgetValue.valueText(step: widget.step) - // Calling sliderDidChange leads to interference with other cells. - // sliderDidChange(toValue: widgetValue) - } - - @IBAction private func sliderTouchUp(_ sender: UISlider) { - sliderDidChange(toValue: widgetValue) - touchEventDelegate?.touchUp() - } - - @IBAction private func sliderTouchDown(_ sender: UISlider) { - touchEventDelegate?.touchDown() - } - - @IBAction private func sliderTouchOutside(_ sender: UISlider) { - sliderTouchUp(sender) - } - - private func adj(_ raw: Double) -> Double { - var valueAdjustedToStep = Double(floor(Float(((raw - widget.minValue))) / step) * step) - valueAdjustedToStep += widget.minValue - return valueAdjustedToStep.clamped(to: widget.minValue ... widget.maxValue) - } - - private func adjustedValue() -> Double { - if let item = widget.item { - adj(item.stateAsDouble()) - } else { - widget.minValue - } - } - - override func displayWidget() { - // guard !isInTransition else { return } - - if let item = widget.item, item.isOfTypeOrGroupType(.color) { - widgetSlider?.minimumValue = 0.0 - widgetSlider?.maximumValue = 100.0 - step = 1.0 - widgetSlider.value = Float(item.state?.parseAsBrightness() ?? 0) - } else { - // Fix "The stepSize must be 0, or a factor of the valueFrom-valueTo range" exception - widgetSlider?.minimumValue = Float(widget.minValue) - widgetSlider?.maximumValue = Float(widget.maxValue) - let widgetValue = adjustedValue() - widgetSlider?.value = Float(widgetValue) - step = Float(widget.step) - - // if there is a formatted value in widget label, take it. Otherwise display local value - if let labelValue = widget?.labelValue { - customDetailText?.text = labelValue - } else { - customDetailText?.text = widgetValue.valueText(step: Double(step)) - } - } - customTextLabel?.text = widget.labelText - } - - private func sliderDidChange(toValue value: Double) { - Logger.widgets.info("Slider new value = \(value)") - widget.sendCommand(value.valueText(step: Double(step))) - } -} diff --git a/openHAB/SliderWithSwitchSupportUITableViewCell.swift b/openHAB/SliderWithSwitchSupportUITableViewCell.swift deleted file mode 100644 index ccc480166..000000000 --- a/openHAB/SliderWithSwitchSupportUITableViewCell.swift +++ /dev/null @@ -1,137 +0,0 @@ -// Copyright (c) 2010-2026 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 AVFoundation -import AVKit -import Combine -import OpenHABCore -import os.log - -class SliderWithSwitchSupportUITableViewCell: GenericUITableViewCell { - private var step: Float = 1.0 - - private var widgetValue: Double { - adj(Double(widgetSlider?.value ?? Float(widget.minValue))) - } - - @IBOutlet private var widgetSlider: UISlider! - @IBOutlet private var widgetSwitch: UISwitch! - @IBOutlet private var customDetailText: UILabel! - - required init?(coder: NSCoder) { - super.init(coder: coder) - initialize() - } - - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - initialize() - } - - @IBAction private func sliderValueChanged(_ sender: UISlider) { - customDetailText?.text = widgetValue.valueText(step: widget.step) - // Calling sliderDidChange leads to interference with other cells. - // sliderDidChange(toValue: widgetValue) - } - - @IBAction private func sliderTouchUp(_ sender: UISlider) { - sliderDidChange(toValue: widgetValue) - touchEventDelegate?.touchUp() - } - - @IBAction private func sliderTouchDown(_ sender: UISlider) { - touchEventDelegate?.touchDown() - } - - @IBAction private func sliderTouchOutside(_ sender: UISlider) { - sliderTouchUp(sender) - } - - override func initialize() { - selectionStyle = .none - separatorInset = .zero - if let widget { - step = Float(widget.step) - } else { - step = 1.0 - } - } - - private func adj(_ raw: Double) -> Double { - var valueAdjustedToStep = Double(floor(Float(((raw - widget.minValue))) / step) * step) - valueAdjustedToStep += widget.minValue - return valueAdjustedToStep.clamped(to: widget.minValue ... widget.maxValue) - } - - private func adjustedValue() -> Double { - if let item = widget.item { - adj(item.stateAsDouble()) - } else { - widget.minValue - } - } - - override func displayWidget() { - // guard !isInTransition else { return } - - customTextLabel?.text = widget.labelText - var state = widget.state - // if state is nil or empty using the item state ( OH 1.x compatability ) - if state.isEmpty { - state = (widget.item?.state) ?? "" - } - widgetSwitch?.isOn = state.parseAsBool() - widgetSwitch?.addTarget(self, action: .switchChange, for: .valueChanged) - super.displayWidget() - - if let item = widget.item, item.isOfTypeOrGroupType(.color) { - widgetSlider?.minimumValue = 0.0 - widgetSlider?.maximumValue = 100.0 - step = 1.0 - widgetSlider.value = Float(item.state?.parseAsBrightness() ?? 0) - } else { - // Fix "The stepSize must be 0, or a factor of the valueFrom-valueTo range" exception - widgetSlider?.minimumValue = Float(widget.minValue) - widgetSlider?.maximumValue = Float(widget.maxValue) - let widgetValue = adjustedValue() - widgetSlider?.value = Float(widgetValue) - step = Float(widget.step) - - // if there is a formatted value in widget label, take it. Otherwise display local value - if let labelValue = widget?.labelValue { - customDetailText?.text = labelValue - } else { - customDetailText?.text = widgetValue.valueText(step: Double(step)) - } - } - customTextLabel?.text = widget.labelText - } - - private func sliderDidChange(toValue value: Double) { - Logger.widgets.info("Slider new value = \(value)") - widget.sendCommand(value.valueText(step: Double(step))) - } - - @objc - func switchChange() { - if (widgetSwitch?.isOn)! { - Logger.widgets.info("Switch to ON") - widget.sendCommand("ON") - } else { - Logger.widgets.info("Switch to OFF") - widget.sendCommand("OFF") - } - } -} - -private extension Selector { - static let switchChange = #selector(SwitchUITableViewCell.switchChange) -} diff --git a/openHAB/ColorPickerView.swift b/openHAB/SwiftUI/ColorPickerView.swift similarity index 100% rename from openHAB/ColorPickerView.swift rename to openHAB/SwiftUI/ColorPickerView.swift diff --git a/openHAB/NoIconDisplayableCell.swift b/openHAB/SwiftUI/NoIconDisplayableCell.swift similarity index 100% rename from openHAB/NoIconDisplayableCell.swift rename to openHAB/SwiftUI/NoIconDisplayableCell.swift diff --git a/openHAB/SwiftUI/NotificationsView.swift b/openHAB/SwiftUI/NotificationsView.swift index 6d16ac0cb..85e57128d 100644 --- a/openHAB/SwiftUI/NotificationsView.swift +++ b/openHAB/SwiftUI/NotificationsView.swift @@ -139,7 +139,7 @@ extension NotificationsView where Tracker == MainActorNetworkTracker { _notifications = State(initialValue: notifications) loadNotifications = { do { - guard let config = Preferences.shared.getNotificationConnection() else { + guard let config = await Preferences.shared.getNotificationConnection() else { Logger.notificationService.warning("No openHAB configuration found.") return [] } diff --git a/openHAB/SwiftUI/RootView/AppServicesViewModel.swift b/openHAB/SwiftUI/RootView/AppServicesViewModel.swift index 75a0a48bb..58e78580e 100644 --- a/openHAB/SwiftUI/RootView/AppServicesViewModel.swift +++ b/openHAB/SwiftUI/RootView/AppServicesViewModel.swift @@ -9,6 +9,7 @@ // // SPDX-License-Identifier: EPL-2.0 +import AsyncAlgorithms import AVFoundation import Combine import FirebaseCrashlytics @@ -117,8 +118,6 @@ class AppServicesViewModel: ObservableObject { // MARK: - Network Tracker private func setupTracker() { - let serverInfo = Preferences.shared.$currentHomePreferences - NotificationCenter.default.addObserver( forName: .evaluateServerTrust, object: nil, @@ -170,33 +169,64 @@ class AppServicesViewModel: ObservableObject { } } - serverInfo.debounce(for: .milliseconds(500), scheduler: RunLoop.main) - .sink { homeSettings in + // Subscribe to home preferences changes using AsyncChannel + let trackerTask = Task { @MainActor [weak self] in + guard let self else { return } + + // Get the AsyncChannel for currentHomePreferences from the actor + let preferencesChannel = await Preferences.shared.currentHomePreferencesChannel + + // Process initial value + let initialSettings = await Preferences.shared.currentHomePreferences + let localConnectionConfig = initialSettings.localConnectionConfig + let remoteConnectionConfig = initialSettings.remoteConnectionConfig + let demomode = initialSettings.demomode + let sseCommandItem = initialSettings.sseCommandItem + + if demomode { + await NetworkTracker.shared.startTracking(connectionConfigurations: [ + ConnectionConfiguration( + url: "https://demo.openhab.org", + username: "", + password: "", + priority: 0 + ) + ]) + } else { + await NetworkTracker.shared.startTracking(connectionConfigurations: [ + localConnectionConfig, + remoteConnectionConfig + ]) + await ItemEventStream.trackItems(sseCommandItem.isEmpty ? [] : [sseCommandItem]) + } + + // Listen for changes with debouncing + for await homeSettings in preferencesChannel.debounce(for: .milliseconds(500)) { let localConnectionConfig = homeSettings.localConnectionConfig let remoteConnectionConfig = homeSettings.remoteConnectionConfig let demomode = homeSettings.demomode let sseCommandItem = homeSettings.sseCommandItem - - Task { - if demomode { - await NetworkTracker.shared.startTracking(connectionConfigurations: [ - ConnectionConfiguration( - url: "https://demo.openhab.org", - username: "", - password: "", - priority: 0 - ) - ]) - } else { - await NetworkTracker.shared.startTracking(connectionConfigurations: [ - localConnectionConfig, - remoteConnectionConfig - ]) - await ItemEventStream.trackItems(sseCommandItem.isEmpty ? [] : [sseCommandItem]) - } + + if demomode { + await NetworkTracker.shared.startTracking(connectionConfigurations: [ + ConnectionConfiguration( + url: "https://demo.openhab.org", + username: "", + password: "", + priority: 0 + ) + ]) + } else { + await NetworkTracker.shared.startTracking(connectionConfigurations: [ + localConnectionConfig, + remoteConnectionConfig + ]) + await ItemEventStream.trackItems(sseCommandItem.isEmpty ? [] : [sseCommandItem]) } } - .store(in: &cancellables) + } + + cancellables.insert(AnyCancellable { trackerTask.cancel() }) MainActorNetworkTracker.shared.$activeConnection .receive(on: DispatchQueue.main) @@ -224,14 +254,20 @@ class AppServicesViewModel: ObservableObject { // MARK: - Crash Report private func setupCrashReportCheck() { - if Crashlytics.crashlytics().didCrashDuringPreviousExecution(), !Preferences.shared.sendCrashReports { - crashReportAlert = true + Task { + if Crashlytics.crashlytics().didCrashDuringPreviousExecution(), !(await Preferences.shared.applicationPreferences.sendCrashReports) { + crashReportAlert = true + } } } func enableCrashReporting() { - Preferences.shared.sendCrashReports = true - Crashlytics.crashlytics().sendUnsentReports() + Task { + await Preferences.shared.modifyApplicationPreferences(modificationFunction: { applicationPreferences in + applicationPreferences.sendCrashReports = true + }) + Crashlytics.crashlytics().sendUnsentReports() + } } func deleteCrashReports() { @@ -254,37 +290,65 @@ class AppServicesViewModel: ObservableObject { let connection: ConnectionConfiguration } - let storedOpenHabConnections = Preferences.shared.$storedHomes - .debounce(for: .seconds(1), scheduler: RunLoop.main) - .map { updatedPreferences in - Set(updatedPreferences.compactMap { storedWithUuid in - let (uuid, homeConfig) = storedWithUuid - guard let connection = Preferences.shared.getNotificationConnection(of: homeConfig) else { return nil } - return UuidWithConnection(uuid: uuid, connection: connection) - }) - } - - let connectionsWithPreviousValues = storedOpenHabConnections - .scan((previous: Set(), current: Set())) { previous, current in - (previous: previous.current, current: current) + // Cancel any existing subscription task + let subscriptionTask = Task { @MainActor [weak self] in + guard let self else { return } + + // Get the AsyncChannel for storedHomes from the actor + let storedHomesChannel = await Preferences.shared.storedHomesChannel + + var previousConnections = Set() + + // Process initial value + let initialHomes = await Preferences.shared.storedHomes + var initialConnections = Set() + for (uuid, homeConfig) in initialHomes { + if let connection = await Preferences.shared.getNotificationConnection(of: homeConfig) { + initialConnections.insert(UuidWithConnection(uuid: uuid, connection: connection)) + } } - - let differences = connectionsWithPreviousValues.map { (previous, current) in - (newValues: current.subtracting(previous), deletedValues: previous.subtracting(current)) - } - - let openhabConnectionSubscription = differences.sink { [weak self] diff in - Logger.viewController.info("openhabConnectionSubscription updated") - for newHome in diff.newValues { - Logger.viewController.info("openhabConnectionSubscription uuid \(newHome.uuid) registering for push notifications ") - self?.registerHome(uuid: newHome.uuid, connection: newHome.connection) + + // Register initial homes + for newHome in initialConnections { + Logger.viewController.info("openhabConnectionSubscription uuid \(newHome.uuid) registering for push notifications (initial)") + self.registerHome(uuid: newHome.uuid, connection: newHome.connection) } - for deletedHome in diff.deletedValues { - Logger.viewController.warning("APNS Deregistration is missing (wanted to deregister \(deletedHome.connection.url))") + previousConnections = initialConnections + + // Listen for changes with debouncing + for await updatedHomes in storedHomesChannel.debounce(for: .seconds(1)) { + Logger.viewController.info("openhabConnectionSubscription updated") + + // Map to connections using manual iteration for async calls + var currentConnections = Set() + for (uuid, homeConfig) in updatedHomes { + if let connection = await Preferences.shared.getNotificationConnection(of: homeConfig) { + currentConnections.insert(UuidWithConnection(uuid: uuid, connection: connection)) + } + } + + // Calculate differences + let newValues = currentConnections.subtracting(previousConnections) + let deletedValues = previousConnections.subtracting(currentConnections) + + // Register new homes + for newHome in newValues { + Logger.viewController.info("openhabConnectionSubscription uuid \(newHome.uuid) registering for push notifications") + self.registerHome(uuid: newHome.uuid, connection: newHome.connection) + } + + // Log deleted homes (deregistration not implemented) + for deletedHome in deletedValues { + Logger.viewController.warning("APNS Deregistration is missing (wanted to deregister \(deletedHome.connection.url))") + } + + previousConnections = currentConnections } } - - cancellables.insert(openhabConnectionSubscription) + + // Store the task in cancellables for proper cleanup + // We can wrap it in an AnyCancellable for compatibility + cancellables.insert(AnyCancellable { subscriptionTask.cancel() }) } private func registerHome(uuid: UUID, connection: ConnectionConfiguration) { @@ -303,7 +367,7 @@ class AppServicesViewModel: ObservableObject { do { let client = HTTPClient(connectionConfiguration: config) if let cloudUserId = try await client.register(prefsURL: config.url, deviceToken: deviceToken, deviceId: deviceId, deviceName: deviceName) { - Preferences.shared.setCloudUserId(cloudUserId, for: uuid) + await Preferences.shared.setCloudUserId(cloudUserId, for: uuid) Logger.viewController.info("my.openHAB registration succeeded with cloudUserId \(cloudUserId)") } Logger.viewController.info("my.openHAB registration succeeded without cloudUserId") @@ -335,15 +399,19 @@ class AppServicesViewModel: ObservableObject { Logger.viewController.info("handleNotification cloudUserId: \(cloudUserId ?? "")") Task { - if let cloudUserId, let targetHome = Preferences.shared.storedHome(forCloudUserId: cloudUserId), Preferences.shared.currentHomePreferences.remoteConnectionConfig.cloudUserId != cloudUserId { + if let cloudUserId, + let targetHome = await Preferences.shared.storedHome(forCloudUserId: cloudUserId), + await Preferences.shared.currentHomePreferences.remoteConnectionConfig.cloudUserId != cloudUserId { await NetworkTracker.shared.stopTracking() Logger.viewController.info("Switching to home \(targetHome.id)") - Preferences.shared.switchActiveHome(to: targetHome.id) + await Preferences.shared.switchActiveHome(to: targetHome.id) } + + let currentPreferences = await Preferences.shared.currentHomePreferences await NetworkTracker.shared.startTracking(connectionConfigurations: [ - Preferences.shared.currentHomePreferences.localConnectionConfig, - Preferences.shared.currentHomePreferences.remoteConnectionConfig + currentPreferences.localConnectionConfig, + currentPreferences.remoteConnectionConfig ] ) _ = await NetworkTracker.shared.waitForActiveConnection() @@ -384,16 +452,18 @@ class AppServicesViewModel: ObservableObject { Logger.viewController.info("navigateCommandAction path: \(path)") if path.starts(with: "/basicui/app?") { Logger.viewController.info("Navigating to sitemap target") - let defaultSitemap = Preferences.shared.currentHomePreferences.defaultSitemap - guard let urlComponents = URLComponents(string: path) else { - Logger.viewController.warning("No parameters for specifying sitemap or widget to navigate to") - navigationCommand = .switchToSitemap(name: defaultSitemap, widgetId: nil) - return + Task { @MainActor in + let defaultSitemap = await Preferences.shared.currentHomePreferences.defaultSitemap + guard let urlComponents = URLComponents(string: path) else { + Logger.viewController.warning("No parameters for specifying sitemap or widget to navigate to") + navigationCommand = .switchToSitemap(name: defaultSitemap, widgetId: nil) + return + } + let queryItems = urlComponents.queryItems + let sitemap = queryItems?.first { $0.name == "sitemap" }?.value + let widgetId = queryItems?.first { $0.name == "w" }?.value + navigationCommand = .switchToSitemap(name: sitemap ?? defaultSitemap, widgetId: widgetId) } - let queryItems = urlComponents.queryItems - let sitemap = queryItems?.first { $0.name == "sitemap" }?.value - let widgetId = queryItems?.first { $0.name == "w" }?.value - navigationCommand = .switchToSitemap(name: sitemap ?? defaultSitemap, widgetId: widgetId) } else { Logger.viewController.info("Navigating to webview target") if path.starts(with: "/") { diff --git a/openHAB/SwiftUI/RootView/OpenHABTabRootView.swift b/openHAB/SwiftUI/RootView/OpenHABTabRootView.swift index 1b9fb82e2..e49a4c457 100644 --- a/openHAB/SwiftUI/RootView/OpenHABTabRootView.swift +++ b/openHAB/SwiftUI/RootView/OpenHABTabRootView.swift @@ -72,7 +72,7 @@ struct OpenHABTabRootView: View { let saved = Preferences.shared.currentHomePreferences.lastSelectedTab _selectedTab = State(initialValue: AppTab(rawValue: saved) ?? .main) _isDemoMode = State(initialValue: Preferences.shared.currentHomePreferences.demomode) - _enabledTabs = State(initialValue: Self.computeEnabledTabs()) + _enabledTabs = State(initialValue: Self.computeEnabledTabs(config: Preferences.shared.currentHomePreferences.tabConfiguration)) #if DEBUG if ProcessInfo.processInfo.environment["UITest"] != nil { @@ -83,8 +83,7 @@ struct OpenHABTabRootView: View { #endif } - private static func computeEnabledTabs() -> [AppTab] { - let config = Preferences.shared.currentHomePreferences.tabConfiguration ?? TabEntry.defaultConfiguration + private static func computeEnabledTabs(config: [TabEntry]) -> [AppTab] { let tabs = config.compactMap { entry -> AppTab? in guard entry.enabled || entry.id == AppTab.system.rawValue else { return nil } return AppTab(rawValue: entry.id) @@ -111,12 +110,14 @@ struct OpenHABTabRootView: View { } } .onReceive(Preferences.shared.$currentHomePreferences) { _ in - let newTabs = Self.computeEnabledTabs() - if enabledTabs != newTabs { - enabledTabs = newTabs - // If current tab was disabled, switch to first available - if !enabledTabs.contains(selectedTab) { - selectedTab = enabledTabs.first ?? .system + Task { + let newTabs = Self.computeEnabledTabs(config: await Preferences.shared.currentHomePreferences.tabConfiguration) + if enabledTabs != newTabs { + enabledTabs = newTabs + // If current tab was disabled, switch to first available + if !enabledTabs.contains(selectedTab) { + selectedTab = enabledTabs.first ?? .system + } } } } diff --git a/openHAB/SwiftUI/RootView/REFACTORING_SUMMARY.md b/openHAB/SwiftUI/RootView/REFACTORING_SUMMARY.md new file mode 100644 index 000000000..6ae8c3c2d --- /dev/null +++ b/openHAB/SwiftUI/RootView/REFACTORING_SUMMARY.md @@ -0,0 +1,252 @@ +# Preferences Concurrency Refactoring Summary + +## Overview +This refactoring eliminates unnecessary `@MainActor` annotations from the `Preferences` actor and provides a clean `@MainActor` observable wrapper (`PreferencesObserver`) for SwiftUI views. + +## Key Changes + +### 1. **Removed `@MainActor` from Data Structures** +- ✅ `HomePreferences` → Now `Sendable` struct (no longer `@MainActor`) +- ✅ `ApplicationPreferences` → Now `Sendable` struct (no longer `@MainActor`) +- ✅ `TabEntry` → Was already `Sendable` + +**Benefit**: These are now truly thread-safe value types that can be passed between actors without isolation concerns. + +### 2. **Removed `@MainActor` from Property Wrappers** +- ✅ `@UserDefault` property wrapper +- ✅ `@UserDefaultObject` property wrapper +- ✅ `PreferencesAccess` enum methods + +**Rationale**: Since `Preferences` is an actor, property access is already isolated by the actor. The `@MainActor` annotations were creating unnecessary constraints and concurrency conflicts. + +**Note**: `sharedDefaults` (UserDefaults) is marked as `nonisolated(unsafe)` because `UserDefaults` is not `Sendable`, but in practice it's thread-safe for read/write operations. + +### 3. **Removed `@MainActor` from Preferences Extensions** +All extension methods on `Preferences` are now properly actor-isolated: +- ✅ `listStoredHomes()` +- ✅ `createAndLoadNewStoredSettings()` +- ✅ `renameHome()` +- ✅ `setCloudUserId()` +- ✅ `deleteStoredHome()` +- ✅ `switchActiveHome()` +- ✅ `modifyActiveHome()` - No longer requires `@MainActor` closure +- ✅ `modifyApplicationPreferences()` - No longer requires `@MainActor` closure +- ✅ `firstStoredHome()` +- ✅ `storedHome(forCloudUserId:)` +- ✅ `getNotificationConnection()` + +### 4. **Updated Migration Methods** +- ✅ `migratePreferences()` → Now `async` and `nonisolated` +- ✅ `migrateToSharedDefaultsIfRequired()` → Now `async` +- ✅ `migrateToMultipleHomesIfRequired()` → Now `async` + +**Benefit**: Migration can now be called from any context and properly awaits actor-isolated operations. + +### 5. **Created `PreferencesObserver` for SwiftUI** + +**New class**: `PreferencesObserver` - A `@MainActor` observable wrapper + +```swift +@MainActor +@Observable +public final class PreferencesObserver { + public static let shared = PreferencesObserver() + + public private(set) var currentHomePreferences: HomePreferences + public private(set) var applicationPreferences: ApplicationPreferences + + // ... implementation +} +``` + +**Purpose**: +- Provides a `@MainActor`-isolated observable object for SwiftUI views +- Automatically syncs with the `Preferences` actor +- Eliminates the need for `Task` wrappers in SwiftUI code +- Uses Combine publishers to receive updates from the actor + +### 6. **Updated `SystemTab.swift`** + +**Before**: +```swift +.onReceive(Preferences.shared.$currentHomePreferences) { _ in + Task { + updateNotificationVisibility() + } +} + +private func updateNotificationVisibility() { + showNotifications = Preferences.shared.getNotificationConnection() != nil + && !Preferences.shared.currentHomePreferences.demomode +} +``` + +**After**: +```swift +@State private var preferencesObserver = PreferencesObserver.shared + +.onChange(of: preferencesObserver.currentHomePreferences) { _, _ in + Task { + await updateNotificationVisibility() + } +} + +private func updateNotificationVisibility() async { + let notificationConnection = await preferencesObserver.getNotificationConnection() + showNotifications = notificationConnection != nil + && !preferencesObserver.currentHomePreferences.demomode +} +``` + +**Benefits**: +- ✅ No more awkward `onReceive` + `Task` combination +- ✅ Clear async/await pattern +- ✅ Type-safe with `@Observable` and SwiftUI's `onChange` +- ✅ Preferences operations run on actor, UI updates on main actor + +## Architecture Benefits + +### Before +``` +┌─────────────────────┐ +│ Preferences actor │ +│ with @MainActor │ ← Forced everything onto main thread +│ property wrappers │ +└─────────────────────┘ + │ + ▼ +┌─────────────────────┐ +│ SwiftUI Views │ +│ with Task {} │ ← Awkward concurrency gymnastics +└─────────────────────┘ +``` + +### After +``` +┌─────────────────────┐ +│ Preferences actor │ +│ (background-safe) │ ← Can run on any thread +└─────────────────────┘ + │ + ▼ (publishers) +┌─────────────────────┐ +│ PreferencesObserver │ +│ @MainActor │ ← Clean bridge to UI +└─────────────────────┘ + │ + ▼ +┌─────────────────────┐ +│ SwiftUI Views │ +│ (clean code) │ ← Simple, synchronous access +└─────────────────────┘ +``` + +## Usage Patterns + +### ✅ In SwiftUI Views +```swift +@State private var preferencesObserver = PreferencesObserver.shared + +var body: some View { + Text(preferencesObserver.currentHomePreferences.homeName) + .onChange(of: preferencesObserver.currentHomePreferences) { _, newValue in + // React to changes + } +} +``` + +### ✅ In Background Tasks +```swift +Task { + await Preferences.shared.modifyActiveHome { home in + home.demomode = false + } +} +``` + +### ✅ Direct Actor Access +```swift +func updateSettings() async { + let demoMode = await Preferences.shared.currentHomePreferences.demomode + // Use demoMode... +} +``` + +## Migration Guide for Other Files + +If you have other files using `Preferences`, update them as follows: + +### Pattern 1: SwiftUI Views observing preferences +**Before**: +```swift +.onReceive(Preferences.shared.$someProperty) { newValue in + Task { + // Do something + } +} +``` + +**After**: +```swift +@State private var preferencesObserver = PreferencesObserver.shared + +.onChange(of: preferencesObserver.currentHomePreferences) { _, newValue in + Task { + await updateSomething() + } +} +``` + +### Pattern 2: Accessing preferences in async context +**Before**: +```swift +Task { @MainActor in + let value = Preferences.shared.someProperty +} +``` + +**After**: +```swift +Task { + let value = await Preferences.shared.someProperty +} +``` + +### Pattern 3: Modifying preferences +**Before**: +```swift +Task { @MainActor in + Preferences.shared.modifyActiveHome { home in + home.demomode = false + } +} +``` + +**After**: +```swift +Task { + await Preferences.shared.modifyActiveHome { home in + home.demomode = false + } +} +``` + +## Testing Considerations + +- All preference access is now properly actor-isolated +- Tests may need to be updated to use `await` when accessing `Preferences.shared` +- `PreferencesObserver` should be tested separately for its reactive behavior +- Migration methods are now `async` and should be awaited in tests + +## Potential Issues to Watch For + +1. **Other files** may still have `@MainActor` requirements that conflict with the new actor-based approach +2. **Published values**: The `$currentHomePreferences` publisher still works but now bridges from actor to main actor through `PreferencesObserver` +3. **Migration calls**: Update all calls to `Preferences.migratePreferences()` to use `await` + +## Next Steps + +1. Search codebase for other uses of `Preferences.shared` that may need updating +2. Update any tests that access preferences +3. Consider adding more properties to `PreferencesObserver` if needed by SwiftUI views +4. Monitor for any concurrency warnings in Xcode diff --git a/openHAB/SwiftUI/RootView/SitemapsTab.swift b/openHAB/SwiftUI/RootView/SitemapsTab.swift index 14e9aafc6..404076ca1 100644 --- a/openHAB/SwiftUI/RootView/SitemapsTab.swift +++ b/openHAB/SwiftUI/RootView/SitemapsTab.swift @@ -61,7 +61,7 @@ struct SitemapsTab: View { } } .task { - sitemapForWatch = Preferences.shared.currentHomePreferences.sitemapForWatch + sitemapForWatch = await Preferences.shared.currentHomePreferences.sitemapForWatch await fetchSitemaps(activeConnection: networkTracker.activeConnection) autoSelectSitemap() } @@ -136,7 +136,7 @@ struct SitemapsTab: View { if fetched.last?.name == "_default", fetched.count > 1 { fetched = Array(fetched.dropLast()) } - let sortSitemapsBy = Preferences.shared.currentHomePreferences.sortSitemapsBy + let sortSitemapsBy = await Preferences.shared.currentHomePreferences.sortSitemapsBy switch SortSitemapsOrder(rawValue: sortSitemapsBy) ?? .label { case .label: fetched.sort { $0.label < $1.label } diff --git a/openHAB/SwiftUI/RootView/SystemTab.swift b/openHAB/SwiftUI/RootView/SystemTab.swift index 1cef01938..ec480e343 100644 --- a/openHAB/SwiftUI/RootView/SystemTab.swift +++ b/openHAB/SwiftUI/RootView/SystemTab.swift @@ -41,6 +41,7 @@ struct SystemTab: View { @State private var showNotifications = false @State private var path = NavigationPath() + @State private var preferencesObserver = PreferencesObserver.shared var body: some View { NavigationStack(path: $path) { @@ -87,18 +88,21 @@ struct SystemTab: View { } } .task { - updateNotificationVisibility() + await updateNotificationVisibility() } - .onReceive(Preferences.shared.$currentHomePreferences) { _ in - updateNotificationVisibility() + .onChange(of: preferencesObserver.currentHomePreferences) { _, _ in + Task { + await updateNotificationVisibility() + } } .onChange(of: resetTrigger) { _, _ in path = NavigationPath() } } - private func updateNotificationVisibility() { - showNotifications = Preferences.shared.getNotificationConnection() != nil - && !Preferences.shared.currentHomePreferences.demomode + private func updateNotificationVisibility() async { + let notificationConnection = await preferencesObserver.getNotificationConnection() + showNotifications = notificationConnection != nil + && !preferencesObserver.currentHomePreferences.demomode } } diff --git a/openHAB/SwiftUI/Rows/VideoRowView.swift b/openHAB/SwiftUI/Rows/VideoRowView.swift index 72e61bf0a..6b1a9319b 100644 --- a/openHAB/SwiftUI/Rows/VideoRowView.swift +++ b/openHAB/SwiftUI/Rows/VideoRowView.swift @@ -16,6 +16,10 @@ import os.log import SwiftUI import UIKit +enum VideoEncoding: String { + case hls, mjpeg +} + struct VideoRowView: View { @ObservedObject var widget: OpenHABWidget @State private var player: AVPlayer? diff --git a/openHAB/ScreenSaver/ScreenSaverConfiguration.swift b/openHAB/SwiftUI/ScreenSaver/ScreenSaverConfiguration.swift similarity index 100% rename from openHAB/ScreenSaver/ScreenSaverConfiguration.swift rename to openHAB/SwiftUI/ScreenSaver/ScreenSaverConfiguration.swift diff --git a/openHAB/ScreenSaver/ScreenSaverManager.swift b/openHAB/SwiftUI/ScreenSaver/ScreenSaverManager.swift similarity index 100% rename from openHAB/ScreenSaver/ScreenSaverManager.swift rename to openHAB/SwiftUI/ScreenSaver/ScreenSaverManager.swift diff --git a/openHAB/ScreenSaver/ScreenSaverView.swift b/openHAB/SwiftUI/ScreenSaver/ScreenSaverView.swift similarity index 100% rename from openHAB/ScreenSaver/ScreenSaverView.swift rename to openHAB/SwiftUI/ScreenSaver/ScreenSaverView.swift diff --git a/openHAB/SettingsView/AboutSettingsView.swift b/openHAB/SwiftUI/SettingsView/AboutSettingsView.swift similarity index 100% rename from openHAB/SettingsView/AboutSettingsView.swift rename to openHAB/SwiftUI/SettingsView/AboutSettingsView.swift diff --git a/openHAB/SettingsView/AnimatedSecureTextField.swift b/openHAB/SwiftUI/SettingsView/AnimatedSecureTextField.swift similarity index 100% rename from openHAB/SettingsView/AnimatedSecureTextField.swift rename to openHAB/SwiftUI/SettingsView/AnimatedSecureTextField.swift diff --git a/openHAB/SettingsView/ApplicationSettingsView.swift b/openHAB/SwiftUI/SettingsView/ApplicationSettingsView.swift similarity index 84% rename from openHAB/SettingsView/ApplicationSettingsView.swift rename to openHAB/SwiftUI/SettingsView/ApplicationSettingsView.swift index 110c4cf2e..894f27ad6 100644 --- a/openHAB/SettingsView/ApplicationSettingsView.swift +++ b/openHAB/SwiftUI/SettingsView/ApplicationSettingsView.swift @@ -16,6 +16,7 @@ import UIKit struct ApplicationSettingsView: View { @Binding var settingsIdleOff: Bool + @Binding var settingsHideStatusBar: Bool @Binding var settingsSSECommandItem: String @State private var selectedItemName: String? @@ -28,10 +29,10 @@ struct ApplicationSettingsView: View { ScreenSaverSettingsView() } - Toggle("Hide Status Bar", isOn: Binding( - get: { Preferences.shared.hideStatusBar }, - set: { Preferences.shared.hideStatusBar = $0; UIApplication.shared.keyWindowActiveScene?.rootViewController?.setNeedsStatusBarAppearanceUpdate() } - )) + Toggle("Hide Status Bar", isOn: $settingsHideStatusBar) + .onChange(of: settingsHideStatusBar) { _ in + UIApplication.shared.keyWindowActiveScene?.rootViewController?.setNeedsStatusBarAppearanceUpdate() + } NavigationLink("Client Certificates") { ClientCertificatesView() @@ -67,11 +68,13 @@ struct ApplicationSettingsView: View { #Preview { struct PreviewWrapper: View { @State private var idleOff = false + @State private var hideStatusBar = false @State private var sseCommandItem = "" var body: some View { Form { ApplicationSettingsView( settingsIdleOff: $idleOff, + settingsHideStatusBar: $hideStatusBar, settingsSSECommandItem: $sseCommandItem ) } diff --git a/openHAB/SettingsView/BonjourDiscoverySheet.swift b/openHAB/SwiftUI/SettingsView/BonjourDiscoverySheet.swift similarity index 100% rename from openHAB/SettingsView/BonjourDiscoverySheet.swift rename to openHAB/SwiftUI/SettingsView/BonjourDiscoverySheet.swift diff --git a/openHAB/SettingsView/ClientCertificatesView.swift b/openHAB/SwiftUI/SettingsView/ClientCertificatesView.swift similarity index 100% rename from openHAB/SettingsView/ClientCertificatesView.swift rename to openHAB/SwiftUI/SettingsView/ClientCertificatesView.swift diff --git a/openHAB/SettingsView/ConnectionSettingsView.swift b/openHAB/SwiftUI/SettingsView/ConnectionSettingsView.swift similarity index 100% rename from openHAB/SettingsView/ConnectionSettingsView.swift rename to openHAB/SwiftUI/SettingsView/ConnectionSettingsView.swift diff --git a/openHAB/SettingsView/DebugSettingsView.swift b/openHAB/SwiftUI/SettingsView/DebugSettingsView.swift similarity index 93% rename from openHAB/SettingsView/DebugSettingsView.swift rename to openHAB/SwiftUI/SettingsView/DebugSettingsView.swift index c3478a547..e2ec7b16e 100644 --- a/openHAB/SettingsView/DebugSettingsView.swift +++ b/openHAB/SwiftUI/SettingsView/DebugSettingsView.swift @@ -25,9 +25,9 @@ struct DebugSettingsView: View { var body: some View { Toggle("Crash Reporting", isOn: $settingsSendCrashReports) .task { @MainActor in - updateSettingsSendCrashReports(Preferences.shared.sendCrashReports) + await updateSettingsSendCrashReports(Preferences.shared.applicationPreferences.sendCrashReports) } - .onChange(of: settingsSendCrashReports) { newValue in + .onChange(of: settingsSendCrashReports) { oldValue, newValue in #if !DEBUG Logger.settingsView.debug("Detected change on settingsSendCrashReports") #endif diff --git a/openHAB/SettingsView/ItemSelectionView.swift b/openHAB/SwiftUI/SettingsView/ItemSelectionView.swift similarity index 100% rename from openHAB/SettingsView/ItemSelectionView.swift rename to openHAB/SwiftUI/SettingsView/ItemSelectionView.swift diff --git a/openHAB/SettingsView/MainUISettingsView.swift b/openHAB/SwiftUI/SettingsView/MainUISettingsView.swift similarity index 100% rename from openHAB/SettingsView/MainUISettingsView.swift rename to openHAB/SwiftUI/SettingsView/MainUISettingsView.swift diff --git a/openHAB/SettingsView/ScreenSaverSettingsView.swift b/openHAB/SwiftUI/SettingsView/ScreenSaverSettingsView.swift similarity index 72% rename from openHAB/SettingsView/ScreenSaverSettingsView.swift rename to openHAB/SwiftUI/SettingsView/ScreenSaverSettingsView.swift index 5b92ba4cb..c969d5b6f 100644 --- a/openHAB/SettingsView/ScreenSaverSettingsView.swift +++ b/openHAB/SwiftUI/SettingsView/ScreenSaverSettingsView.swift @@ -29,41 +29,47 @@ struct ScreenSaverSettingsView: View { } .navigationTitle("Screen Saver") .onDisappear { - ScreenSaverManager.shared.updateConfiguration(config) - // Persist to Preferences - Preferences.shared.screensaverEnabled = config.isEnabled - Preferences.shared.screensaverShowsTime = config.showsTime - Preferences.shared.screensaverShowsDate = config.showsDate - Preferences.shared.screensaverIdleInterval = config.idleInterval - Preferences.shared.screensaverMovementInterval = config.movementInterval - Preferences.shared.screensaverFontName = config.fontName ?? "" - Preferences.shared.screensaverTimeFontRatio = Double(config.timeFontSizeRatio) - Preferences.shared.screensaverDateFontRatio = Double(config.dateFontRelativeSize) - Preferences.shared.screensaverEnableDimming = config.enablesAutoDimming - Preferences.shared.screensaverDimLevel = Double(config.dimLevel) - Preferences.shared.screensaverWakeBrightness = Double(config.wakeBrightnessLevel) - Preferences.shared.screensaverShowsSeconds = config.showsSeconds - Preferences.shared.screensaverUse24Hour = config.uses24HourTime - Preferences.shared.screensaverFadeDuration = config.fadeDuration - Preferences.shared.screensaverRestoreBrightness = config.restoresBrightness + let config = config //copy to make isolated var sendable + Task { @MainActor in + ScreenSaverManager.shared.updateConfiguration(config) + // Persist to Preferences + await Preferences.shared.modifyScreenSaverPreferences { prefs in + prefs.isEnabled = config.isEnabled + prefs.showsTime = config.showsTime + prefs.showsDate = config.showsDate + prefs.idleInterval = config.idleInterval + prefs.movementInterval = config.movementInterval + prefs.fontName = config.fontName ?? "" + prefs.timeFontRatio = Double(config.timeFontSizeRatio) + prefs.dateFontRatio = Double(config.dateFontRelativeSize) + prefs.enableDimming = config.enablesAutoDimming + prefs.dimLevel = Double(config.dimLevel) + prefs.wakeBrightness = Double(config.wakeBrightnessLevel) + prefs.showsSeconds = config.showsSeconds + prefs.use24Hour = config.uses24HourTime + prefs.fadeDuration = config.fadeDuration + prefs.restoreBrightness = config.restoresBrightness + } + } } .task { @MainActor in + let prefs = await Preferences.shared.screensaverPreferences var config = ScreenSaverConfiguration() - config.isEnabled = Preferences.shared.screensaverEnabled - config.showsTime = Preferences.shared.screensaverShowsTime - config.showsDate = Preferences.shared.screensaverShowsDate - config.idleInterval = Preferences.shared.screensaverIdleInterval - config.movementInterval = Preferences.shared.screensaverMovementInterval - config.fontName = Preferences.shared.screensaverFontName.isEmpty ? nil : Preferences.shared.screensaverFontName - config.timeFontSizeRatio = CGFloat(Preferences.shared.screensaverTimeFontRatio) - config.dateFontRelativeSize = CGFloat(Preferences.shared.screensaverDateFontRatio) - config.enablesAutoDimming = Preferences.shared.screensaverEnableDimming - config.dimLevel = CGFloat(Preferences.shared.screensaverDimLevel) - config.wakeBrightnessLevel = CGFloat(Preferences.shared.screensaverWakeBrightness) - config.showsSeconds = Preferences.shared.screensaverShowsSeconds - config.uses24HourTime = Preferences.shared.screensaverUse24Hour - config.fadeDuration = Preferences.shared.screensaverFadeDuration - config.restoresBrightness = Preferences.shared.screensaverRestoreBrightness + config.isEnabled = prefs.isEnabled + config.showsTime = prefs.showsTime + config.showsDate = prefs.showsDate + config.idleInterval = prefs.idleInterval + config.movementInterval = prefs.movementInterval + config.fontName = prefs.fontName.isEmpty ? nil : prefs.fontName + config.timeFontSizeRatio = CGFloat(prefs.timeFontRatio) + config.dateFontRelativeSize = CGFloat(prefs.dateFontRatio) + config.enablesAutoDimming = prefs.enableDimming + config.dimLevel = CGFloat(prefs.dimLevel) + config.wakeBrightnessLevel = CGFloat(prefs.wakeBrightness) + config.showsSeconds = prefs.showsSeconds + config.uses24HourTime = prefs.use24Hour + config.fadeDuration = prefs.fadeDuration + config.restoresBrightness = prefs.restoreBrightness changeConfig(config) } } diff --git a/openHAB/SettingsView/ServerCertificatesView.swift b/openHAB/SwiftUI/SettingsView/ServerCertificatesView.swift similarity index 100% rename from openHAB/SettingsView/ServerCertificatesView.swift rename to openHAB/SwiftUI/SettingsView/ServerCertificatesView.swift diff --git a/openHAB/SettingsView/SettingsView.swift b/openHAB/SwiftUI/SettingsView/SettingsView.swift similarity index 61% rename from openHAB/SettingsView/SettingsView.swift rename to openHAB/SwiftUI/SettingsView/SettingsView.swift index 9406b3fb2..f779a5812 100644 --- a/openHAB/SettingsView/SettingsView.swift +++ b/openHAB/SwiftUI/SettingsView/SettingsView.swift @@ -17,6 +17,7 @@ import SwiftUI private struct SettingsSnapshot: Equatable { var demomode: Bool var idleOff: Bool + var hideStatusBar: Bool var realTimeSliders: Bool var showSearchField: Bool var sendCrashReports: Bool @@ -35,6 +36,7 @@ import SwiftUI struct SettingsView: View { @State private var settingsDemomode = false @State private var settingsIdleOff = true + @State private var settingsHideStatusBar = false @State private var settingsRealTimeSliders = true @State private var settingsShowSearchField = true @State private var settingsSendCrashReports = false @@ -61,6 +63,7 @@ struct SettingsView: View { SettingsSnapshot( demomode: settingsDemomode, idleOff: settingsIdleOff, + hideStatusBar: settingsHideStatusBar, realTimeSliders: settingsRealTimeSliders, showSearchField: settingsShowSearchField, sendCrashReports: settingsSendCrashReports, @@ -87,6 +90,7 @@ struct SettingsView: View { ApplicationSettingsView( settingsIdleOff: $settingsIdleOff, + settingsHideStatusBar: $settingsHideStatusBar, settingsSSECommandItem: $settingsSSECommandItem ) @@ -120,7 +124,6 @@ struct SettingsView: View { ToolbarItem(placement: .confirmationAction) { Button { saveSettings() - NotificationCenter.default.post(name: NSNotification.Name("org.openhab.preferences.saved"), object: nil) dismiss() } label: { Image(systemName: "checkmark") @@ -138,7 +141,7 @@ struct SettingsView: View { .task { if !viewAppearedOnce { viewAppearedOnce = true - loadSettings() + await loadSettings() initialSnapshot = currentSnapshot let activeConfiguration = settingsLocalConnectionConfiguration await updateSitemaps(activeConfiguration: activeConfiguration) @@ -155,6 +158,7 @@ struct SettingsView: View { guard let snapshot = initialSnapshot else { return } settingsDemomode = snapshot.demomode settingsIdleOff = snapshot.idleOff + settingsHideStatusBar = snapshot.hideStatusBar settingsRealTimeSliders = snapshot.realTimeSliders settingsShowSearchField = snapshot.showSearchField settingsSendCrashReports = snapshot.sendCrashReports @@ -180,7 +184,7 @@ struct SettingsView: View { } // Sort the sitemaps according to Settings selection. - switch SortSitemapsOrder(rawValue: Preferences.shared.currentHomePreferences.sortSitemapsBy) ?? .label { + switch await SortSitemapsOrder(rawValue: Preferences.shared.currentHomePreferences.sortSitemapsBy) ?? .label { case .label: sitemaps.sort { $0.label < $1.label } case .name: sitemaps.sort { $0.name < $1.name } } @@ -190,55 +194,79 @@ struct SettingsView: View { } } - private func loadSettings() { + private func loadSettings() async { #if !DEBUG Logger.settingsView.debug("Loading Settings") #endif - settingsDemomode = Preferences.shared.currentHomePreferences.demomode - settingsIdleOff = Preferences.shared.idleOff - settingsRealTimeSliders = Preferences.shared.currentHomePreferences.realTimeSliders - settingsShowSearchField = Preferences.shared.applicationPreferences.showSearchField - settingsSendCrashReports = Preferences.shared.sendCrashReports - settingsIconType = IconType(rawValue: Preferences.shared.currentHomePreferences.iconType) ?? .svg - settingsSortSitemapsBy = SortSitemapsOrder(rawValue: Preferences.shared.currentHomePreferences.sortSitemapsBy) ?? .label - settingsDefaultMainUIPath = Preferences.shared.currentHomePreferences.defaultMainUIPath - settingsAlwaysAllowWebRTC = Preferences.shared.currentHomePreferences.alwaysAllowWebRTC - settingsSitemapForWatch = Preferences.shared.currentHomePreferences.sitemapForWatch - settingsLocalConnectionConfiguration = Preferences.shared.currentHomePreferences.localConnectionConfig - settingsRemoteConnectionConfiguration = Preferences.shared.currentHomePreferences.remoteConnectionConfig - settingsHomeName = Preferences.shared.currentHomePreferences.homeName - settingsSSECommandItem = Preferences.shared.currentHomePreferences.sseCommandItem - settingsTabConfiguration = Preferences.shared.currentHomePreferences.tabConfiguration ?? TabEntry.defaultConfiguration + let appPrefs = await Preferences.shared.applicationPreferences + settingsDemomode = await Preferences.shared.currentHomePreferences.demomode + settingsIdleOff = appPrefs.idleOff + settingsHideStatusBar = appPrefs.hideStatusBar + settingsRealTimeSliders = await Preferences.shared.currentHomePreferences.realTimeSliders + settingsShowSearchField = appPrefs.showSearchField + settingsSendCrashReports = appPrefs.sendCrashReports + settingsIconType = IconType(rawValue: await Preferences.shared.currentHomePreferences.iconType) ?? .svg + settingsSortSitemapsBy = SortSitemapsOrder(rawValue: await Preferences.shared.currentHomePreferences.sortSitemapsBy) ?? .label + settingsDefaultMainUIPath = await Preferences.shared.currentHomePreferences.defaultMainUIPath + settingsAlwaysAllowWebRTC = await Preferences.shared.currentHomePreferences.alwaysAllowWebRTC + settingsSitemapForWatch = await Preferences.shared.currentHomePreferences.sitemapForWatch + settingsLocalConnectionConfiguration = await Preferences.shared.currentHomePreferences.localConnectionConfig + settingsRemoteConnectionConfiguration = await Preferences.shared.currentHomePreferences.remoteConnectionConfig + settingsHomeName = await Preferences.shared.currentHomePreferences.homeName + settingsSSECommandItem = await Preferences.shared.currentHomePreferences.sseCommandItem + settingsTabConfiguration = await Preferences.shared.currentHomePreferences.tabConfiguration } func saveSettings() { - Preferences.shared.modifyActiveHome { @MainActor homePreferences in - homePreferences.demomode = settingsDemomode - homePreferences.realTimeSliders = settingsRealTimeSliders - homePreferences.iconType = settingsIconType.rawValue - homePreferences.sortSitemapsBy = settingsSortSitemapsBy.rawValue - homePreferences.defaultMainUIPath = settingsDefaultMainUIPath - homePreferences.alwaysAllowWebRTC = settingsAlwaysAllowWebRTC - homePreferences.sitemapForWatch = settingsSitemapForWatch - homePreferences.sitemapForWatchLabel = sitemaps.first { $0.name == settingsSitemapForWatch }?.label ?? "unknown" - homePreferences.localConnectionConfig = settingsLocalConnectionConfiguration - homePreferences.remoteConnectionConfig = settingsRemoteConnectionConfiguration - homePreferences.sseCommandItem = settingsSSECommandItem - homePreferences.tabConfiguration = settingsTabConfiguration - } - Preferences.shared.idleOff = settingsIdleOff - Preferences.shared.sendCrashReports = settingsSendCrashReports - - Preferences.shared.modifyApplicationPreferences { @MainActor applicationPreferences in - applicationPreferences.showSearchField = settingsShowSearchField + let settingsDemomode = settingsDemomode + let settingsRealTimeSliders = settingsRealTimeSliders + let settingsIconType = settingsIconType.rawValue + let settingsSortSitemapsBy = settingsSortSitemapsBy.rawValue + let settingsDefaultMainUIPath = settingsDefaultMainUIPath + let settingsAlwaysAllowWebRTC = settingsAlwaysAllowWebRTC + let settingsSitemapForWatch = settingsSitemapForWatch + let sitemapForWatchLabel = sitemaps.first { $0.name == settingsSitemapForWatch }?.label ?? "unknown" + let settingsLocalConnectionConfiguration = settingsLocalConnectionConfiguration + let settingsRemoteConnectionConfiguration = settingsRemoteConnectionConfiguration + let settingsSSECommandItem = settingsSSECommandItem + let settingsTabConfiguration = settingsTabConfiguration + let settingsIdleOff = settingsIdleOff + let settingsHideStatusBar = settingsHideStatusBar + let settingsSendCrashReports = settingsSendCrashReports + let settingsShowSearchField = settingsShowSearchField + + Task { @MainActor in + await Preferences.shared.modifyActiveHome { homePreferences in + homePreferences.demomode = settingsDemomode + homePreferences.realTimeSliders = settingsRealTimeSliders + homePreferences.iconType = settingsIconType + homePreferences.sortSitemapsBy = settingsSortSitemapsBy + homePreferences.defaultMainUIPath = settingsDefaultMainUIPath + homePreferences.alwaysAllowWebRTC = settingsAlwaysAllowWebRTC + homePreferences.sitemapForWatch = settingsSitemapForWatch + homePreferences.sitemapForWatchLabel = sitemapForWatchLabel + homePreferences.localConnectionConfig = settingsLocalConnectionConfiguration + homePreferences.remoteConnectionConfig = settingsRemoteConnectionConfiguration + homePreferences.sseCommandItem = settingsSSECommandItem + homePreferences.tabConfiguration = settingsTabConfiguration + } + + await Preferences.shared.modifyApplicationPreferences { applicationPreferences in + applicationPreferences.idleOff = settingsIdleOff + applicationPreferences.hideStatusBar = settingsHideStatusBar + applicationPreferences.sendCrashReports = settingsSendCrashReports + applicationPreferences.showSearchField = settingsShowSearchField + } + + // Apply global UI changes immediately (status bar visibility) + UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap(\.windows) + .first?.rootViewController? + .setNeedsStatusBarAppearanceUpdate() + + NotificationCenter.default.post(name: NSNotification.Name("org.openhab.preferences.saved"), object: nil) } - - // Apply global UI changes immediately (status bar visibility) - UIApplication.shared.connectedScenes - .compactMap { $0 as? UIWindowScene } - .flatMap(\.windows) - .first?.rootViewController? - .setNeedsStatusBarAppearanceUpdate() } } diff --git a/openHAB/SettingsView/SingleConnectionSettingsView.swift b/openHAB/SwiftUI/SettingsView/SingleConnectionSettingsView.swift similarity index 100% rename from openHAB/SettingsView/SingleConnectionSettingsView.swift rename to openHAB/SwiftUI/SettingsView/SingleConnectionSettingsView.swift diff --git a/openHAB/SettingsView/SitemapSettingsView.swift b/openHAB/SwiftUI/SettingsView/SitemapSettingsView.swift similarity index 100% rename from openHAB/SettingsView/SitemapSettingsView.swift rename to openHAB/SwiftUI/SettingsView/SitemapSettingsView.swift diff --git a/openHAB/SettingsView/TabCustomizationSection.swift b/openHAB/SwiftUI/SettingsView/TabCustomizationSection.swift similarity index 100% rename from openHAB/SettingsView/TabCustomizationSection.swift rename to openHAB/SwiftUI/SettingsView/TabCustomizationSection.swift diff --git a/openHAB/SwiftUI/SitemapView/SitemapPageView.swift b/openHAB/SwiftUI/SitemapView/SitemapPageView.swift index 5419c2759..5f7fb753e 100644 --- a/openHAB/SwiftUI/SitemapView/SitemapPageView.swift +++ b/openHAB/SwiftUI/SitemapView/SitemapPageView.swift @@ -49,10 +49,12 @@ struct SitemapPageView: View { viewModel.startPageHandling() } .onAppear { - // Disable idle timer if configured in settings - if Preferences.shared.idleOff { - UIApplication.shared.isIdleTimerDisabled = true - idleTimerDisabled = true + Task { + // Disable idle timer if configured in settings + if await Preferences.shared.applicationPreferences.idleOff { + UIApplication.shared.isIdleTimerDisabled = true + idleTimerDisabled = true + } } } .onDisappear { diff --git a/openHAB/Throttler.swift b/openHAB/SwiftUI/Throttler.swift similarity index 100% rename from openHAB/Throttler.swift rename to openHAB/SwiftUI/Throttler.swift diff --git a/openHAB/VideoStreamManager.swift b/openHAB/SwiftUI/VideoStreamManager.swift similarity index 100% rename from openHAB/VideoStreamManager.swift rename to openHAB/SwiftUI/VideoStreamManager.swift diff --git a/openHAB/SwitchUITableViewCell.swift b/openHAB/SwitchUITableViewCell.swift deleted file mode 100644 index 891af5283..000000000 --- a/openHAB/SwitchUITableViewCell.swift +++ /dev/null @@ -1,61 +0,0 @@ -// Copyright (c) 2010-2026 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 OpenHABCore -import os.log -import UIKit - -class SwitchUITableViewCell: GenericUITableViewCell { - @IBOutlet private var widgetSwitch: UISwitch! - - required init?(coder: NSCoder) { - super.init(coder: coder) - initialize() - } - - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - initialize() - } - - override func initialize() { - selectionStyle = .none - separatorInset = .zero - } - - override func displayWidget() { - customTextLabel?.text = widget.labelText - var state = widget.state - // if state is nil or empty using the item state ( OH 1.x compatability ) - if state.isEmpty { - state = (widget.item?.state) ?? "" - } - customDetailTextLabel?.text = widget.labelValue ?? "" - widgetSwitch?.isOn = state.parseAsBool() - widgetSwitch?.addTarget(self, action: .switchChange, for: .valueChanged) - super.displayWidget() - } - - @objc - func switchChange() { - if (widgetSwitch?.isOn)! { - Logger.widgets.info("Switch to ON") - widget.sendCommand("ON") - } else { - Logger.widgets.info("Switch to OFF") - widget.sendCommand("OFF") - } - } -} - -private extension Selector { - static let switchChange = #selector(SwitchUITableViewCell.switchChange) -} diff --git a/openHAB/TextInputUITableViewCell.swift b/openHAB/TextInputUITableViewCell.swift deleted file mode 100644 index 07e7e8a35..000000000 --- a/openHAB/TextInputUITableViewCell.swift +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright (c) 2010-2026 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 OpenHABCore -import UIKit - -class TextInputUITableViewCell: GenericUITableViewCell { - override var widget: OpenHABWidget! { - get { - super.widget - } - set(widget) { - super.widget = widget - accessoryType = .disclosureIndicator - selectionStyle = .blue - } - } -} diff --git a/openHAB/UIKit/OpenHABNavigationController.swift b/openHAB/UIKit/OpenHABNavigationController.swift index 0e07a83f4..dccdcb242 100644 --- a/openHAB/UIKit/OpenHABNavigationController.swift +++ b/openHAB/UIKit/OpenHABNavigationController.swift @@ -25,11 +25,12 @@ import UIKit /// This is a wrapper around UINavigationController that allows the status bar to be hidden or shown. /// It is used to control the status bar for the entire app and is loaded from the Main storyboard entry point. +@MainActor class OpenHABNavigationController: UINavigationController { override var childForStatusBarHidden: UIViewController? { nil } override var prefersStatusBarHidden: Bool { - Preferences.shared.hideStatusBar + PreferencesObserver.shared.applicationPreferences.hideStatusBar } override var preferredStatusBarUpdateAnimation: UIStatusBarAnimation { .fade } diff --git a/openHAB/UIKit/OpenHABSitemapViewController.swift b/openHAB/UIKit/OpenHABSitemapViewController.swift deleted file mode 100644 index d2818cbde..000000000 --- a/openHAB/UIKit/OpenHABSitemapViewController.swift +++ /dev/null @@ -1,969 +0,0 @@ -// Copyright (c) 2010-2026 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 AVFoundation -import AVKit -import Combine -import Foundation -import Kingfisher -import OpenAPIRuntime -import OpenHABCore -import os.log -import SafariServices -import SFSafeSymbols -import SwiftMessages -import SwiftUI -import UIKit - - -class OpenHABSitemapViewController: OpenHABViewController, UISearchControllerDelegate { - var pageUrl = "" - private var iconType: IconType = .svg - var openHABRootUrl = "" - - private var activeConnectionInfo: ConnectionInfo? - - private var defaultSitemap = "" - private var pageId = "" - private var idleOff = false - private var showSearchField = false - private var sitemaps: [OpenHABSitemap] = [] - private var currentPage: OpenHABPage? - private var pageNetworkStatus: NetworkStatus? - private var pageNetworkStatusAvailable = false - private var refreshControl: UIRefreshControl? - private var filteredPage: OpenHABPage? - private let searchController = UISearchController(searchResultsController: nil) - private var isUserInteracting = false - private var isWaitingToReload = false - private var isNavigatingToSelection = false - private var isNavigatingToLinkedPage = false - - private var pageHandlingTask: Task? - - private var pageLoader: PageLoader? - - var relevantPage: OpenHABPage? { - if isFiltering { - filteredPage - } else { - currentPage - } - } - - var sitemapViewController: OpenHABSitemapViewController? - - // MARK: - Private instance methods - - var searchBarIsEmpty: Bool { - // Returns true if the text is empty or nil - searchController.searchBar.text?.isEmpty ?? true - } - - var isFiltering: Bool { - searchController.isActive && !searchBarIsEmpty - } - - private var openAPIService: OpenAPIService? - - @IBOutlet private var widgetTableView: UITableView! - - override func viewDidLoad() { - super.viewDidLoad() - Logger.sitemapViewController.info("OpenHABSitemapViewController viewDidLoad") - - registerTableViewCells() - configureTableView() - widgetTableView.tableFooterView = UIView() - - refreshControl = UIRefreshControl() - refreshControl?.addTarget(self, action: #selector(handleRefresh(_:)), for: .valueChanged) - widgetTableView.refreshControl = refreshControl - - // Load showSearchField settings - showSearchField = Preferences.shared.applicationPreferences.showSearchField - - if showSearchField { - // Setup search controller - searchController.searchResultsUpdater = self - searchController.obscuresBackgroundDuringPresentation = false - searchController.searchBar.autocapitalizationType = .none - searchController.searchBar.delegate = self - searchController.delegate = self - searchController.searchBar.placeholder = NSLocalizedString("search_items", comment: "") - definesPresentationContext = true - - // Assign to navigation item (must be in navigation stack) - navigationItem.searchController = searchController - navigationItem.hidesSearchBarWhenScrolling = false - } else { - navigationItem.searchController = nil - } - - // Setup active connection - guard let config = activeConnectionInfo?.configuration else { return } - do { - openAPIService = try OpenAPIService(connectionConfiguration: config) - } catch { - Logger.sitemapViewController.error("Failed to create OpenAPIService: \(error.localizedDescription)") - } - - if let service = openAPIService { - pageLoader = PageLoader(service: service, pageId: "", defaultSitemap: "") - } - - #if DEBUG - widgetTableView.accessibilityIdentifier = "OpenHABSitemapViewControllerWidgetTableView" - #endif - } - - override func viewDidAppear(_ animated: Bool) { - Logger.sitemapViewController.info("OpenHABSitemapViewController viewDidAppear") - super.viewDidAppear(animated) - - // Load showSearchField settings - showSearchField = Preferences.shared.applicationPreferences.showSearchField - - if showSearchField { - if parent?.navigationItem.searchController !== searchController { - parent?.navigationItem.searchController = searchController - parent?.navigationItem.hidesSearchBarWhenScrolling = true - } - } else { - parent?.navigationItem.searchController = nil - } - } - - override func viewWillAppear(_ animated: Bool) { - Logger.sitemapViewController.info("OpenHABSitemapViewController viewWillAppear") - super.viewWillAppear(animated) - - navigationController?.navigationBar.prefersLargeTitles = true - - // Load settings into local properties - loadSettings() - // Disable idle timeout if configured in settings - if idleOff { - UIApplication.shared.isIdleTimerDisabled = true - } - - // if pageUrl is empty, it means we are the first opened OpenHABSitemapViewController - if pageUrl.isEmpty { - sitemapViewController = self -// if navigationController?.viewControllers.first == self { - // This is the first sitemap opened - if currentPage != nil { - currentPage?.widgets = [] - widgetTableView.reloadData() - // Clear shared image cache when sitemap changes - NewImageUITableViewCell.clearSharedCache() - } - Logger.sitemapViewController.info("OpenHABSitemapViewController pageUrl is empty, this is first launch") - startWatchingActiveServer() - } else { - // Skip restarting if polling task is still active (e.g., returning from SelectionView) - if let task = pageHandlingTask, !task.isCancelled { - Logger.sitemapViewController.info("OpenHABSitemapViewController polling still active, skipping restart") - } else if !pageNetworkStatusChanged() || !pageId.isEmpty { - // swiftformat:disable:next redundantSelf - Logger.sitemapViewController.info("OpenHABSitemapViewController pageUrl \(self.pageUrl)") - startPageHandling() - startWatchingActiveServer() - } else { - Logger.sitemapViewController.info("OpenHABSitemapViewController network status changed while it was not appearing") - restart() - } - } - - ImageDownloader.default.authenticationChallengeResponder = self - } - - override func viewWillDisappear(_ animated: Bool) { - Logger.sitemapViewController.info("OpenHABSitemapViewController viewWillDisappear") - - trackerCancellables.removeAll() - // Keep polling alive when pushing to SelectionView or LinkedPage to preserve scroll position - if !isNavigatingToSelection, !isNavigatingToLinkedPage { - stopAllTasks() - } - isNavigatingToSelection = false - isNavigatingToLinkedPage = false - - super.viewWillDisappear(animated) - - if #unavailable(iOS 13.0) { - if animated, !searchController.isActive, !searchController.isEditing, navigationController.map({ $0.viewControllers.last != self }) ?? false, - let searchBarSuperview = searchController.searchBar.superview, - let searchBarHeightConstraint = searchBarSuperview.constraints.first(where: { - $0.firstAttribute == .height - && $0.secondItem == nil - && $0.secondAttribute == .notAnAttribute - && $0.constant > 0 - }) { - UIView.performWithoutAnimation { - searchBarHeightConstraint.constant = 0 - searchBarSuperview.superview?.layoutIfNeeded() - } - } - } - parent?.navigationItem.searchController = nil - } - - @objc - override func didEnterBackground(_ notification: Notification?) { - super.didEnterBackground(notification) - Logger.sitemapViewController.info("OpenHABSitemapViewController didEnterBackground") - } - - @objc - override func didBecomeActive(_ notification: Notification?) { - super.didBecomeActive(notification) - Logger.sitemapViewController.info("OpenHABSitemapViewController didBecomeActive") - if isViewLoaded, view.window != nil, !pageUrl.isEmpty { - if !pageNetworkStatusChanged() { - Logger.sitemapViewController.info("OpenHABSitemapViewController isViewLoaded, restarting network activity") - startPageHandling() - } else { - Logger.sitemapViewController.info("OpenHABSitemapViewController network status changed while it was inactive") - restart() - } - } - } - - override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { - super.traitCollectionDidChange(previousTraitCollection) - - widgetTableView.reloadData() - } - - func startWatchingActiveServer() { - let task = Task { - for await activeConnection in MainActorNetworkTracker.shared.$activeConnection.values { - if let activeConnection { - await MainActor.run { - Logger.sitemapViewController.info("OpenHABSitemapViewController tracker URL \(activeConnection.configuration.url)") - self.openHABRootUrl = activeConnection.configuration.url - self.activeConnectionInfo = activeConnection - self.selectSitemap() - } - break - } - } - } - activeTasks.insert(task) // Store the task for cancellation - } - - func stopAllTasks() { - for task in activeTasks { - task.cancel() - } - activeTasks.removeAll() - pageHandlingTask?.cancel() - pageHandlingTask = nil - } - - override func reloadView() { - defaultSitemap = Preferences.shared.currentHomePreferences.defaultSitemap - Logger.sitemapViewController.debug("Reload view") - selectSitemap() - } - - override func viewName() -> String { - "sitemap" - } -} - -extension OpenHABSitemapViewController: GenericUITableViewCellTouchEventDelegate { - func touchDown() { - isUserInteracting = true - } - - func touchUp() { - isUserInteracting = false - if isWaitingToReload { - widgetTableView.reloadData() - refreshControl?.endRefreshing() - } - isWaitingToReload = false - } -} - -extension OpenHABSitemapViewController { - func configureTableView() { - widgetTableView.dataSource = self - widgetTableView.delegate = self - } - - func registerTableViewCells() { - widgetTableView.register(cellType: MapViewTableViewCell.self) - widgetTableView.register(cellType: NewImageUITableViewCell.self) - widgetTableView.register(cellType: VideoUITableViewCell.self) - } - - @objc - func handleRefresh(_ refreshControl: UIRefreshControl?) { - startPageHandling() - widgetTableView.reloadData() - widgetTableView.layoutIfNeeded() - } - - func restart() { - if sitemapViewController == self { - Logger.sitemapViewController.info("I am a rootViewController!") - - } else { - sitemapViewController?.pageUrl = "" - navigationController?.popToRootViewController(animated: true) - } - } - - func relevantWidget(indexPath: IndexPath) -> OpenHABWidget? { - relevantPage?.widgets[safe: indexPath.row] - } - - func updateWidgetTableView() { - UIView.performWithoutAnimation { - widgetTableView.beginUpdates() - widgetTableView.endUpdates() - } - } - - func updateUI(with page: OpenHABPage) { - currentPage = page - - if showSearchField, isFiltering { - filterContentForSearchText(searchController.searchBar.text) - } - - currentPage?.sendCommand = { [weak self] item, command in - self?.sendCommand(item, commandToSend: command) - } - - // isUserInteracting fixes https://github.com/openhab/openhab-ios/issues/646 where reloading while the user is interacting can have unintended consequences - if !isUserInteracting { - widgetTableView.reloadData() - refreshControl?.endRefreshing() - } else { - isWaitingToReload = true - } - - let pageTitle = currentPage?.title.components(separatedBy: "[")[0] - if let pageTitle, !pageTitle.isEmpty { - parent?.navigationItem.title = pageTitle - } else if !defaultSitemap.isEmpty { - parent?.navigationItem.title = defaultSitemap - } else { - parent?.navigationItem.title = "Sitemap" - } - } - - // Select sitemap - func selectSitemap() { - Task { - do { - guard let activeConnection = MainActorNetworkTracker.shared.activeConnection else { - throw OpenHABSitemapError.noActiveConnection - } - Logger.sitemapViewController.debug("Running selectSitemap for URL: \(activeConnection.configuration.url)") - - openAPIService = try OpenAPIService(connectionConfiguration: activeConnection.configuration) - sitemaps = try await openAPIService?.openHABSitemaps() ?? [] - - guard let openAPIService else { - Logger.sitemapViewController.error("Failed to load openAPIService") - return - } - await pageLoader?.updateAPIService(newService: openAPIService) - - switch sitemaps.count { - case 2...: - if !self.defaultSitemap.isEmpty { - if let sitemapToOpen = sitemap(byName: self.defaultSitemap) { - if self.currentPage?.pageId != sitemapToOpen.name { - self.currentPage?.widgets.removeAll() // NOTE: remove all widgets to ensure cells get invalidated - } - pageUrl = sitemapToOpen.homepageLink - startPageHandling() - } else { - showSideMenu() - } - } else { - showSideMenu() - } - case 1: - pageUrl = sitemaps[0].homepageLink - startPageHandling() - case ...0: - showPopupMessage(seconds: 5, title: NSLocalizedString("warning", comment: ""), message: NSLocalizedString("empty_sitemap", comment: ""), theme: .warning) - showSideMenu() - default: break - } - } catch _ as OpenAPIServiceError { - Logger.sitemapViewController.debug("OpenAPIService Error on OpenHABSitemapViewController") - } catch let error as OpenHABSitemapError { - Logger.sitemapViewController.error("OpenHABSitemap Error: \(error.localizedDescription)") - DispatchQueue.main.async { - self.showPopupMessage( - seconds: 5, - title: NSLocalizedString("error", comment: ""), - message: error.localizedDescription, - theme: .error - ) - } - } catch { - Logger.sitemapViewController.error("\(error.localizedDescription)") - DispatchQueue.main.async { - if let urlError = error as? URLError, urlError.code == .clientCertificateRejected { - self.showPopupMessage( - seconds: 5, - title: NSLocalizedString("error", comment: ""), - message: NSLocalizedString("ssl_certificate_error", comment: ""), - theme: .error - ) - } else { - self.showPopupMessage( - seconds: 5, - title: NSLocalizedString("error", comment: ""), - message: error.localizedDescription, - theme: .error - ) - } - } - } - } - } - - // This is mainly used for navigating to a specific sitemap and path from notifications - func pushSitemap(name: String, path: String?) async { - guard let activeConnection = await NetworkTracker.shared.waitForActiveConnection() else { - Logger.sitemapViewController.error("pushSitemap: No active connection available") - return - } - - guard name != pageId || path != nil else { - Logger.sitemapViewController.info("pushSitemap: Already at the required sitemap") - return - } - - Logger.sitemapViewController.info("pushSitemap: pushing page") - - guard let baseUrl = URL(string: activeConnection.configuration.url) else { - Logger.sitemapViewController.error("pushSitemap: Invalid base URL") - return - } - - var url = baseUrl.appendingPathComponent("rest") - .appendingPathComponent("sitemaps") - .appendingPathComponent(name) - - if let subpath = path { - url.appendPathComponent(subpath) - } - - guard let newViewController = storyboard?.instantiateViewController(withIdentifier: "OpenHABPageViewController") as? OpenHABSitemapViewController else { - Logger.sitemapViewController.error("pushSitemap: Failed to instantiate OpenHABSitemapViewController") - return - } - - if let pageId = path { - newViewController.pageId = pageId - } - newViewController.pageUrl = url.absoluteString - newViewController.openHABRootUrl = activeConnection.configuration.url - navigationController?.pushViewController(newViewController, animated: true) - } - - func startPageHandling() { - pageHandlingTask?.cancel() - - guard !pageUrl.isEmpty else { - Logger.sitemapViewController.error("startPageHandling: Cannot run with empty pageUrl") - return - } - - Logger.sitemapViewController.info("🚀 Starting page load and long polling flow...") - - pageHandlingTask = Task { - do { - // Initial page load - - guard let configuration = MainActorNetworkTracker.shared.activeConnection?.configuration else { - throw NetworkTrackerError.noActiveConnection - } - - if openAPIService == nil { - openAPIService = try OpenAPIService(connectionConfiguration: configuration) - } - - let initialPage = try await openAPIService?.pollDataForPage( - sitemapname: defaultSitemap, - pageId: pageId, - longPolling: false - ) - - // Alternative 2 to be tested. - // await pageLoader?.updatePageConfig(newPageId: pageId, newSitemap: defaultSitemap) - // guard let page = try await pageLoader?.fetchPage(longPolling: true) else { return } - // - try Task.checkCancellation() - if let page = initialPage { - await MainActor.run { - self.updateUI(with: page) - } - } - - // Start long polling loop - while !Task.isCancelled { - let page = try await openAPIService?.pollDataForPage( - sitemapname: defaultSitemap, - pageId: pageId, - longPolling: true - ) - try Task.checkCancellation() - - if let page { - await MainActor.run { - self.updateUI(with: page) - } - } - } - } catch is CancellationError { - Logger.sitemapViewController.info("🔁 pageHandlingTask was cancelled") - } catch let error as DecodingError { - Logger.sitemapViewController.error("DecodingError \(error.localizedDescription)") - } catch let error as ClientError { - if let urlError = error.underlyingError as? URLError { - switch urlError.code { - case .cancelled: - Logger.sitemapViewController.info("Task was cancelled - URLError code: .cancelled") - case .timedOut: - Logger.sitemapViewController.info("Task timed out - URLError code: .timedOut") - default: - Logger.sitemapViewController.info("URLError: \(urlError.localizedDescription)") - } - } else { - Logger.sitemapViewController.error("\(error.localizedDescription)") - } - } catch let openAPIError as OpenAPIServiceError { - Logger.sitemapViewController.info("On pageHandling \(openAPIError)") - } catch { - Logger.sitemapViewController.error("❌ pageHandlingTask error: \(error.localizedDescription)") - await MainActor.run { - self.showPopupMessage( - seconds: 5, - title: NSLocalizedString("error", comment: ""), - message: error.localizedDescription, - theme: .error - ) - } - } - } - } - - // load settings into local properties - func loadSettings() { - defaultSitemap = Preferences.shared.currentHomePreferences.defaultSitemap - idleOff = Preferences.shared.idleOff - iconType = IconType(rawValue: Preferences.shared.currentHomePreferences.iconType) ?? .svg - #if DEBUG - // always use demo sitemap for UITest - if ProcessInfo.processInfo.environment["UITest"] != nil { - defaultSitemap = "demo" - iconType = .svg - } - #endif - } - - // Find and return sitemap by it's name if any - func sitemap(byName sitemapName: String?) -> OpenHABSitemap? { - for sitemap in sitemaps where sitemap.name == sitemapName { - return sitemap - } - return nil - } - - @discardableResult - func pageNetworkStatusChanged() -> Bool { - Logger.sitemapViewController.info("OpenHABSitemapViewController pageNetworkStatusChange") - - guard !pageUrl.isEmpty else { return false } - - let currentStatus = MainActorNetworkTracker.shared.status - - // First run - if !pageNetworkStatusAvailable { - pageNetworkStatus = currentStatus - pageNetworkStatusAvailable = true - return false - } - - if pageNetworkStatus == currentStatus { - return false - } else { - pageNetworkStatus = currentStatus - return true - } - } - - func filterContentForSearchText(_ searchText: String?, scope: String = "All") { - guard let searchText else { return } - - filteredPage = currentPage?.filter { - $0.label.lowercased().contains(searchText.lowercased()) && $0.type != .frame - } - - filteredPage?.sendCommand = { [weak self] item, command in - self?.sendCommand(item, commandToSend: command) - } - - UIView.performWithoutAnimation { - widgetTableView.reloadData() - } - } - - func sendCommand(_ item: OpenHABItem?, commandToSend command: String?) { - if let item, let command { - sendCommand(itemname: item.name, command: command) - } - } - - func sendCommand(itemname: String, command: String) { - let sourcePrefix = sitemapSourcePrefix() - let deviceId = UIDevice.current.identifierForVendor?.uuidString - Task { - try await openAPIService?.sendItemCommand( - itemname: itemname, - command: command, - sourcePrefix: sourcePrefix, - deviceId: deviceId - ) - } - } - - private func sitemapSourcePrefix() -> String? { - guard !defaultSitemap.isEmpty else { return nil } - let suffix = pageId.isEmpty ? "" : ":\(pageId)" - return "org.openhab.ui.basic$\(defaultSitemap)\(suffix)" - } -} - -// MARK: - UISearchResultsUpdating - -extension OpenHABSitemapViewController: UISearchResultsUpdating { - func updateSearchResults(for searchController: UISearchController) { - Logger.sitemapViewController.info("Search updated: \(searchController.searchBar.text ?? "")") - filterContentForSearchText(searchController.searchBar.text) - } -} - -// MARK: - UISearchBarDelegate - -extension OpenHABSitemapViewController: UISearchBarDelegate { - func searchBarSearchButtonClicked(_ searchBar: UISearchBar) { - filterContentForSearchText(searchBar.text) - searchBar.resignFirstResponder() - } -} - -// MARK: - ColorPickerCellDelegate - -extension OpenHABSitemapViewController: @preconcurrency ColorPickerCellDelegate { - func didPressColorButton(_ cell: ColorPickerCell?) { - let colorPickerViewController = storyboard?.instantiateViewController(withIdentifier: "ColorPickerViewController") as? ColorPickerViewController - if let cell { - let widget = relevantPage?.widgets[widgetTableView.indexPath(for: cell)?.row ?? 0] - colorPickerViewController?.title = widget?.labelText - colorPickerViewController?.widget = widget - } - if let colorPickerViewController { - navigationController?.pushViewController(colorPickerViewController, animated: true) - } - } -} - -// MARK: - UITableViewDelegate, UITableViewDataSource - -extension OpenHABSitemapViewController: UITableViewDelegate, UITableViewDataSource { - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - relevantPage?.widgets.count ?? 0 - } - - func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { - 44.0 - } - - func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - let widget: OpenHABWidget? = relevantPage?.widgets[indexPath.row] - switch widget?.type { - case .frame: - return widget?.label.count ?? 0 > 0 ? 35.0 : 0 - case .image, .chart, .video: - return UITableView.automaticDimension - case .webview, .mapview: - if let height = widget?.height { - // calculate webview/mapview height and return it. Limited to UIScreen.main.bounds.height - let heightValue = height * 44 - Logger.sitemapViewController.info("Webview/Mapview height would be \(heightValue)") - return min(UIScreen.main.bounds.height, CGFloat(heightValue)) - } else { - // return default height for webview/mapview as 8 rows - return 44.0 * 8 - } - default: return 44.0 - } - } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - guard let widget: OpenHABWidget = relevantWidget(indexPath: indexPath) else { - // this should never be the case - let cell = tableView.dequeueReusableCell(for: indexPath) as GenericUITableViewCell - cell.displayWidget() - cell.touchEventDelegate = self - cell.separatorInset = UIEdgeInsets(top: 0, left: 60, bottom: 0, right: 0) - return cell - } - - let provider = WidgetCellFactory.provider(for: widget) - let cell = provider.dequeue(from: tableView, at: indexPath) - provider.configure(cell: cell, for: widget, controller: self) - - let logicColor = !(widget.iconColor.isEmpty) ? UIColor(fromString: widget.iconColor) : .ohBlack - let iconColor = logicColor.semanticColorToHex() ?? "#000000" - // No icon will be displazed for cells that conform to NoIconDisplayableCell protocol - if !(cell is any NoIconDisplayableCell) { - if !widget.icon.isEmpty { - if let urlc = Endpoint.icon( - rootUrl: openHABRootUrl, - version: MainActorNetworkTracker.shared.activeConnection?.version ?? 2, - icon: widget.icon, - state: widget.iconState(), - iconType: iconType, - iconColor: iconColor, - staticIcon: widget.staticIcon - )?.url { - Logger.sitemapViewController.info("URL for icon: \(urlc.absoluteString, privacy: .public)") - // Only apply color preprocessing for non-iconify icons - let processorIconColor = urlc.host == "api.iconify.design" ? nil : iconColor - cell.imageView?.kf.setImage( - with: KF.ImageResource(downloadURL: urlc), // , cacheKey: urlc.path + (urlc.query ?? "")), - placeholder: nil, - options: [.processor(OpenHABImageProcessor(iconColor: processorIconColor))] - ) { result in - switch result { - case .success: - DispatchQueue.main.async { - cell.setNeedsLayout() - } - case let .failure(error): - Logger.sitemapViewController.error("Image loading failed for widget \(widget.label, privacy: .public) : \(error.localizedDescription, privacy: .public)") - } - } - } - } - } - - if cell is FrameUITableViewCell { - cell.backgroundColor = .ohSystemGroupedBackground - } else { - cell.backgroundColor = .ohSecondarySystemGroupedBackground - } - - if let cell = cell as? GenericUITableViewCell { - cell.widget = widget - cell.displayWidget() - cell.touchEventDelegate = self - } - - // Check if this is not the last row in the widgets list - if indexPath.row < (relevantPage?.widgets.count ?? 1) - 1 { - let nextWidget: OpenHABWidget? = relevantPage?.widgets[indexPath.row + 1] - if let type = nextWidget?.type, type.isAny(of: .frame, .image, .video, .webview, .chart) { - cell.separatorInset = UIEdgeInsets.zero - } else if !(widget.type == .frame) { - cell.separatorInset = UIEdgeInsets(top: 0, left: 60, bottom: 0, right: 0) - } - } - - return cell - } - - func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { - // Prevent the cell from inheriting the Table View's margin settings - cell.preservesSuperviewLayoutMargins = false - - // Explictly set your cell's layout margins - cell.layoutMargins = .zero - - (cell as? VideoUITableViewCell)?.play() - } - - func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - if let index = widgetTableView.indexPathForSelectedRow { - widgetTableView.deselectRow(at: index, animated: false) - } - - guard let widget: OpenHABWidget = relevantWidget(indexPath: indexPath) else { return } - - if let linkedPage = widget.linkedPage { - Logger.sitemapViewController.info("Selected linked page: \(linkedPage.link)") - let newViewController = (storyboard?.instantiateViewController(withIdentifier: "OpenHABPageViewController") as? OpenHABSitemapViewController)! - newViewController.title = linkedPage.title.components(separatedBy: "[")[0] - newViewController.pageId = linkedPage.pageId - newViewController.pageUrl = linkedPage.link - newViewController.openHABRootUrl = openHABRootUrl - isNavigatingToLinkedPage = true - navigationController?.pushViewController(newViewController, animated: true) - } else if widget.type == .selection { - let selectionItemState = widget.item?.state - Logger.sitemapViewController.info("Selected selection widget in status: \(selectionItemState ?? "unknown")") - let hostingController = UIHostingController( - rootView: SelectionView( - labelText: widget.labelText, - mappings: widget.mappingsOrItemOptions, - selectionItemState: selectionItemState, - valuecolor: widget.valuecolor - ) { selectedMappingIndex in - let selectedMapping: OpenHABWidgetMapping = widget.mappingsOrItemOptions[selectedMappingIndex] - self.sendCommand(widget.item, commandToSend: selectedMapping.command) - } onDismiss: { [weak self] in - self?.navigationController?.popViewController(animated: true) - } - ) - hostingController.title = widget.labelText - isNavigatingToSelection = true - navigationController?.pushViewController(hostingController, animated: true) - } else if widget.type == .input { - let hint = widget.inputHint - let textExtractor: ((UIAlertController) -> String?)? - let textFieldAdder: ((UITextField) -> Void)? - - switch hint { - case .date, .time, .dateTime: - // value setting is handeled by the cell itself - textExtractor = nil - textFieldAdder = nil - case .number: - textFieldAdder = { textField in - textField.text = widget.state - textField.clearButtonMode = .always - textField.delegate = self - textField.keyboardType = .numbersAndPunctuation - } - // replace expected decimal separator - textExtractor = { $0.textFields?[0].text?.replacingOccurrences(of: NSLocale.current.decimalSeparator ?? "", with: ".") } - case .text: - textFieldAdder = { textField in - textField.text = widget.state - textField.clearButtonMode = .always - textField.keyboardType = .default - } - textExtractor = { $0.textFields?[0].text } - case .unknown: - textExtractor = nil - textFieldAdder = nil - } - guard let textExtractor, let textFieldAdder else { - return - } - - // TODO: proper texts instead of hardcoded values - let alert = UIAlertController( - title: "Enter new value", - message: "Current value for \((widget.labelText.orEmpty.isEmpty ? "Unknown" : widget.labelText.orEmpty)) is \((widget.labelValue.orEmpty.isEmpty ? "Unknown" : widget.labelValue.orEmpty))", - preferredStyle: .alert - ) - alert.addTextField(configurationHandler: textFieldAdder) - let sendAction = UIAlertAction(title: "Set value", style: .destructive) { [weak self] _ in - if let input = textExtractor(alert), !input.isEmpty { - self?.sendCommand(widget.item, commandToSend: input) - } - } - alert.addAction(sendAction) - alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) - alert.preferredAction = sendAction - present(alert, animated: true) - } - } - - func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { - if let cell = cell as? any GenericCellCacheProtocol { - // invalidate cache only if the cell is not visible or the datasource is empty (eg. sitemap change) - if tableView.indexPathsForVisibleRows == nil || !tableView.indexPathsForVisibleRows!.contains(indexPath) || currentPage == nil || currentPage!.widgets.isEmpty { - cell.invalidateCache() - } - } - } - - func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { - if let cell = tableView.cellForRow(at: indexPath) as? GenericUITableViewCell, cell.widget.type == .text, let text = cell.widget?.labelValue ?? cell.widget?.labelText, !text.isEmpty { - return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { _ in - let copy = UIAction(title: NSLocalizedString("copy_label", comment: ""), image: UIImage(systemSymbol: .squareAndArrowUp)) { _ in - UIPasteboard.general.string = text - } - - return UIMenu(title: "", children: [copy]) - } - } - - return nil - } -} - -extension OpenHABSitemapViewController: UITextFieldDelegate { - func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { - let decimalSeparator = NSLocale.current.decimalSeparator ?? "" - let oldString = (textField.text ?? "") - let wholeNumberRegex = /^-?[0-9]*$/ - - // check for deletion - return string.isEmpty - // check for new negative sign - || ( - !string.starts(with: "-") // new string does not add negative sign - || range.location == 0 // new string adds negative sign to beginning - && ( - !oldString.starts(with: "-") // old string does not contain negative sign - || range.length > 0 - ) - ) // new string replaces negative sign in old string - // check for old negative sign - && ( - oldString.isEmpty - || !oldString.starts(with: "-") // old string does not start with negative sign - || range.location > 0 // new string starts after negative sign in old string - || range.length > 0 - ) // new string replaces negative sign in old string - // check for decimal signs - && ( - string.firstRange(of: wholeNumberRegex) != nil // new string is whole number - || ( - string.replacing(decimalSeparator, with: "", maxReplacements: 1) - .firstRange(of: wholeNumberRegex) != nil // new string is valid decimal number - && !(oldString as NSString).replacingCharacters(in: range, with: "").contains(decimalSeparator) - ) - ) // old string without replaced range not yet contains decimal separator - } -} - -// MARK: Kingfisher authentication with NSURLCredential - -extension OpenHABSitemapViewController: AuthenticationChallengeResponsible { - func downloader(_ downloader: ImageDownloader, - didReceive challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { - await onReceiveSessionChallenge(with: challenge) - } - - func downloader(_ downloader: ImageDownloader, - task: URLSessionTask, - didReceive challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { - await onReceiveSessionTaskChallenge(with: challenge) - } -} diff --git a/openHAB/UIKit/OpenHABViewController.swift b/openHAB/UIKit/OpenHABViewController.swift index 54053bd41..f4b04e328 100644 --- a/openHAB/UIKit/OpenHABViewController.swift +++ b/openHAB/UIKit/OpenHABViewController.swift @@ -86,9 +86,11 @@ class OpenHABViewController: UIViewController, OpenHABViewable { @objc func didBecomeActive(_ notification: Notification?) { - // re disable idle off timer - if Preferences.shared.idleOff { - UIApplication.shared.isIdleTimerDisabled = true + Task { + // re disable idle off timer + if await Preferences.shared.applicationPreferences.idleOff { + UIApplication.shared.isIdleTimerDisabled = true + } } } diff --git a/openHAB/UIKit/OpenHABWebViewController.swift b/openHAB/UIKit/OpenHABWebViewController.swift index bba8194c7..f26653233 100644 --- a/openHAB/UIKit/OpenHABWebViewController.swift +++ b/openHAB/UIKit/OpenHABWebViewController.swift @@ -195,7 +195,7 @@ class OpenHABWebViewController: OpenHABViewController { // TODO: remove this check once iOS 16 is dropped let isCloudConnection = activeConfig.isCloudConnection // create new (or resuse existing) - let newWebview = webView(for: Preferences.shared.currentHomePreferences.id, isCloudConnection: isCloudConnection) + let newWebview = await webView(for: Preferences.shared.currentHomePreferences.id, isCloudConnection: isCloudConnection) if newWebview != webView { // Detach old instance webView.stopLoading() @@ -481,8 +481,8 @@ extension OpenHABWebViewController: WKScriptMessageHandler { Logger.viewController.info("WKScriptMessage \(message.name)") if message.name == "pathChanged", let newPath = message.body as? String { Logger.viewController.debug("Path changed to: \(newPath)") - Task { @MainActor in - Preferences.shared.currentWebViewPath = newPath + Task { + await Preferences.shared.setCurrentWebViewPath(newPath) } } if message.name == "mainUi", let callbackName = message.body as? String { @@ -574,8 +574,9 @@ extension OpenHABWebViewController: WKNavigationDelegate { if let path = url?.path { let string = openHABTrackedRootUrl Logger.viewController.info("navigation change base: \(string) path: \(path)") - Task { @MainActor in - Preferences.shared.currentWebViewPath = path.hasSuffix("/") ? path : path + "/" + Task { + let normalizedPath = path.hasSuffix("/") ? path : path + "/" + await Preferences.shared.setCurrentWebViewPath(normalizedPath) } } } @@ -634,6 +635,6 @@ extension OpenHABWebViewController: WKUIDelegate { decideMediaCapturePermissionsFor origin: WKSecurityOrigin, initiatedBy frame: WKFrameInfo, type: WKMediaCaptureType) async -> WKPermissionDecision { - Preferences.shared.currentHomePreferences.alwaysAllowWebRTC ? .grant : .prompt + await Preferences.shared.currentHomePreferences.alwaysAllowWebRTC ? .grant : .prompt } } diff --git a/openHAB/ScaleAspectFitImageView.swift b/openHAB/UIKit/ScaleAspectFitImageView.swift similarity index 100% rename from openHAB/ScaleAspectFitImageView.swift rename to openHAB/UIKit/ScaleAspectFitImageView.swift diff --git a/openHAB/UIKit/SpinnerViewController.swift b/openHAB/UIKit/SpinnerViewController.swift deleted file mode 100644 index db86de35e..000000000 --- a/openHAB/UIKit/SpinnerViewController.swift +++ /dev/null @@ -1,32 +0,0 @@ -// Copyright (c) 2010-2026 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 UIKit - -class SpinnerViewController: UIViewController { - private var spinner: UIActivityIndicatorView = if #available(iOS 13.0, *) { - .init(style: .large) - } else { - .init(style: .gray) - } - - override func loadView() { - view = UIView() - view.backgroundColor = UIColor(white: 0, alpha: 0.7) - - spinner.translatesAutoresizingMaskIntoConstraints = false - spinner.startAnimating() - view.addSubview(spinner) - - spinner.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true - spinner.centerYAnchor.constraint(equalTo: view.centerYAnchor).isActive = true - } -} diff --git a/openHAB/UICircleButton.swift b/openHAB/UIKit/UICircleButton.swift similarity index 100% rename from openHAB/UICircleButton.swift rename to openHAB/UIKit/UICircleButton.swift diff --git a/openHAB/UILabel+Localization.swift b/openHAB/UIKit/UILabel+Localization.swift similarity index 100% rename from openHAB/UILabel+Localization.swift rename to openHAB/UIKit/UILabel+Localization.swift diff --git a/openHAB/UIKit/UITableViewCellExtension.swift b/openHAB/UIKit/UITableViewCellExtension.swift index 808276af5..a9575899c 100644 --- a/openHAB/UIKit/UITableViewCellExtension.swift +++ b/openHAB/UIKit/UITableViewCellExtension.swift @@ -9,6 +9,11 @@ // // SPDX-License-Identifier: EPL-2.0 +// DEPRECATED: This file is part of the legacy UIKit OpenHABSitemapViewController +// implementation that has been replaced by SwiftUI views (SitemapNavigationView, SitemapPageView). +// This entire file can be safely deleted. +// See DELETION_CHECKLIST.md for complete list of files to remove. + import Foundation import Kingfisher import OpenHABCore diff --git a/openHAB/UIViewController+Localization.swift b/openHAB/UIKit/UIViewController+Localization.swift similarity index 100% rename from openHAB/UIViewController+Localization.swift rename to openHAB/UIKit/UIViewController+Localization.swift diff --git a/openHAB/URL+Static.swift b/openHAB/UIKit/URL+Static.swift similarity index 100% rename from openHAB/URL+Static.swift rename to openHAB/UIKit/URL+Static.swift diff --git a/openHAB/UITableView.swift b/openHAB/UITableView.swift deleted file mode 100644 index 01e10ff26..000000000 --- a/openHAB/UITableView.swift +++ /dev/null @@ -1,25 +0,0 @@ -// Copyright (c) 2010-2026 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 UIKit - -extension UITableView { - final func dequeueReusableCell(for indexPath: IndexPath, cellType: T.Type = T.self) -> T { - guard let cell = dequeueReusableCell(withIdentifier: cellType.reuseIdentifier, for: indexPath) as? T else { - fatalError("Unable to Dequeue Reusable Table View Cell") - } - return cell - } - - final func register(cellType: (some UITableViewCell).Type) { - register(cellType.self, forCellReuseIdentifier: cellType.reuseIdentifier) - } -} diff --git a/openHAB/VideoUITableViewCell.swift b/openHAB/VideoUITableViewCell.swift deleted file mode 100644 index 1d85ac93c..000000000 --- a/openHAB/VideoUITableViewCell.swift +++ /dev/null @@ -1,289 +0,0 @@ -// Copyright (c) 2010-2026 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 AVFoundation -import AVKit -import OpenHABCore -import os.log - -enum VideoEncoding: String { - case hls, mjpeg -} - -class VideoUITableViewCell: GenericUITableViewCell, NoIconDisplayableCell { - private var activityIndicator: UIActivityIndicatorView = if #available(iOS 13.0, *) { - .init(style: .medium) - } else { - .init(style: .gray) - } - - var didLoad: (() -> Void)? - - private var url: URL? { - didSet { - guard oldValue?.absoluteString != url?.absoluteString else { return } - prepareToPlay() - } - } - - private var playerView: PlayerView! - private var mainImageView: UIImageView! - private var playerObserver: NSKeyValueObservation? - private var aspectRatioConstraint: NSLayoutConstraint? - private var mjpegPlayer: SimpleMJPEGPlayer? - private var currentAspectRatio: CGFloat? - private var currentStreamUrl: URL? - - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - - activityIndicator.hidesWhenStopped = true - playerView = PlayerView() - playerView.isHidden = true // Start hidden, will be shown when needed - contentView.addSubview(playerView) - mainImageView = UIImageView() - mainImageView.contentMode = .scaleAspectFit - mainImageView.isHidden = true // Start hidden, will be shown when needed - contentView.addSubview(mainImageView) - contentView.addSubview(activityIndicator) - - activityIndicator.translatesAutoresizingMaskIntoConstraints = false // enable autolayout - playerView.translatesAutoresizingMaskIntoConstraints = false // enable autolayout - playerView.contentMode = .scaleAspectFit - - let marginGuide = contentView // contentView.layoutMarginsGuide if more margin would be appreciated - NSLayoutConstraint.activate([ - playerView.leftAnchor.constraint(equalTo: marginGuide.leftAnchor), - playerView.rightAnchor.constraint(equalTo: marginGuide.rightAnchor), - playerView.topAnchor.constraint(equalTo: marginGuide.topAnchor), - playerView.bottomAnchor.constraint(equalTo: marginGuide.bottomAnchor) - ]) - - mainImageView.translatesAutoresizingMaskIntoConstraints = false // enable autolayout - NSLayoutConstraint.activate([ - mainImageView.leftAnchor.constraint(equalTo: marginGuide.leftAnchor), - mainImageView.rightAnchor.constraint(equalTo: marginGuide.rightAnchor), - mainImageView.topAnchor.constraint(equalTo: marginGuide.topAnchor), - mainImageView.bottomAnchor.constraint(equalTo: marginGuide.bottomAnchor) - ]) - - let bottomSpacingConstraint = activityIndicator.bottomAnchor.constraint(greaterThanOrEqualTo: marginGuide.bottomAnchor, constant: 15) - bottomSpacingConstraint.priority = UILayoutPriority.defaultHigh - NSLayoutConstraint.activate([ - activityIndicator.centerXAnchor.constraint(equalTo: marginGuide.centerXAnchor), - activityIndicator.centerYAnchor.constraint(equalTo: marginGuide.centerYAnchor), - activityIndicator.topAnchor.constraint(greaterThanOrEqualTo: marginGuide.topAnchor, constant: 15), - bottomSpacingConstraint - ]) - - NotificationCenter.default.addObserver(self, selector: #selector(stopPlayback), name: UIApplication.didEnterBackgroundNotification, object: nil) - } - - @available(*, unavailable) - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func willMove(toSuperview newSuperview: UIView?) { - super.willMove(toSuperview: newSuperview) - - if newSuperview == nil { - stopPlayback() - // Release stream reference when cell is removed - if let currentStreamUrl { - VideoStreamManager.shared.releaseStream(for: currentStreamUrl) - self.currentStreamUrl = nil - } - } - } - - override func prepareForReuse() { - super.prepareForReuse() - - // Clean up any previous state - stopPlayback() - if let currentStreamUrl { - VideoStreamManager.shared.releaseStream(for: currentStreamUrl) - self.currentStreamUrl = nil - } - mjpegPlayer = nil - currentAspectRatio = nil - - // Reset view states - mainImageView.image = nil - mainImageView.isHidden = true - playerView.isHidden = true - activityIndicator.stopAnimating() - activityIndicator.isHidden = true - - // Remove aspect ratio constraint - if let aspectRatioConstraint { - aspectRatioConstraint.isActive = false - self.aspectRatioConstraint = nil - } - } - - override func displayWidget() { - let newUrl = URL(string: widget.url) - - // Handle MJPEG streams with VideoStreamManager - if widget.encoding.lowercased() == VideoEncoding.mjpeg.rawValue { - // Stop any HLS playback and hide player view - playerView?.playerLayer.player = nil - playerView.isHidden = true - mainImageView.isHidden = false - - // Only process if URL has changed, similar to HLS handling - if currentStreamUrl?.absoluteString != newUrl?.absoluteString { - // Release previous stream if URL changed - if let currentStreamUrl { - VideoStreamManager.shared.releaseStream(for: currentStreamUrl) - } - - if let newUrl { - currentStreamUrl = newUrl - mjpegPlayer = VideoStreamManager.shared.getOrCreateStream( - for: newUrl, - imageView: mainImageView, - onFirstFrame: { [weak self] aspectRatio in - guard let self else { return } - activityIndicator.isHidden = true - if currentAspectRatio != aspectRatio { - updateAspectRatio(forView: mainImageView, aspectRatio: aspectRatio) - currentAspectRatio = aspectRatio - didLoad?() - } - }, - onError: { [weak self] error in - guard let self else { return } - Logger.widgets.error("Failed to start MJPEG stream: \(error.localizedDescription)") - activityIndicator.isHidden = true - activityIndicator.stopAnimating() - } - ) - - // Set initial aspect ratio for MJPEG - updateAspectRatio(forView: mainImageView, aspectRatio: 16.0 / 9.0) - - // Start activity indicator - bringSubviewToFront(activityIndicator) - activityIndicator.isHidden = false - activityIndicator.startAnimating() - bringSubviewToFront(mainImageView) - } else { - currentStreamUrl = nil - } - } - } else { - // Handle HLS and other video formats - // Clear any MJPEG stream and hide image view - if let currentStreamUrl { - VideoStreamManager.shared.releaseStream(for: currentStreamUrl) - self.currentStreamUrl = nil - } - mjpegPlayer = nil - mainImageView.image = nil - mainImageView.isHidden = true - playerView.isHidden = false - - if url?.absoluteString != newUrl?.absoluteString { - url = newUrl - } - let targetView = playerView! - updateAspectRatio(forView: targetView, aspectRatio: 16.0 / 9.0) - } - } - - func play() { - switch widget.encoding.lowercased() { - case VideoEncoding.mjpeg.rawValue: - // MJPEG streams are already managed by VideoStreamManager in displayWidget() - break - default: - playerView.player?.play() - } - } - - private func prepareToPlay() { - bringSubviewToFront(activityIndicator) - activityIndicator.isHidden = false - activityIndicator.startAnimating() - stopPlayback(andResetUrl: false) - - guard let url else { - stopPlayback() - return - } - - if widget.encoding.lowercased() != VideoEncoding.mjpeg.rawValue { - Logger.videoProcessing.info("Loading HLS video from: \(url.absoluteString)") - bringSubviewToFront(playerView) - let playerItem = AVPlayerItem(asset: AVAsset(url: url)) - playerObserver = playerItem.observe(\.status, options: [.new, .old]) { [weak self] playerItem, _ in - guard let self else { return } - - switch playerItem.status { - case .failed: - Logger.widgets.debug("Failed to load video with URL: \(url.absoluteString)") - Task { @MainActor in - self.url = nil - } - case .readyToPlay: - Logger.widgets.debug("Loaded video with URL: \(url.absoluteString)") - default: return - } - Task { @MainActor in - self.activityIndicator.isHidden = true - if playerItem.status == .readyToPlay, playerItem.presentationSize != .zero { - let aspectRatio = playerItem.presentationSize.width / playerItem.presentationSize.height - self.updateAspectRatio(forView: self.playerView, aspectRatio: aspectRatio) - self.didLoad?() - } - } - } - playerView?.playerLayer.player = AVPlayer(playerItem: playerItem) - } - } - - // Add or update the aspect ratio constraint for the given view - private func updateAspectRatio(forView view: UIView, aspectRatio: CGFloat) { - // Remove the old aspect ratio constraint if it exists - if let oldConstraint = aspectRatioConstraint { - oldConstraint.isActive = false - aspectRatioConstraint = nil - } - - // Force layout to process constraint removal before adding new one - view.layoutIfNeeded() - - // Add a new aspect ratio constraint - let constraint = view.widthAnchor.constraint(equalTo: view.heightAnchor, multiplier: aspectRatio) - constraint.priority = UILayoutPriority(rawValue: 998) // Lower than UIImageView's 999 - constraint.isActive = true - aspectRatioConstraint = constraint - } - - @objc - private func stopPlayback(andResetUrl reset: Bool = true) { - // For MJPEG streams, don't stop the shared stream - just clear our reference - if widget?.encoding.lowercased() == VideoEncoding.mjpeg.rawValue { - mjpegPlayer = nil - } else { - // For HLS and other formats, stop as usual - if reset { - url = nil - } - playerObserver = nil - playerView?.playerLayer.player = nil - } - currentAspectRatio = nil - } -} diff --git a/openHAB/WatchMessageService.swift b/openHAB/WatchMessageService.swift index 0bd488e23..0684f3272 100644 --- a/openHAB/WatchMessageService.swift +++ b/openHAB/WatchMessageService.swift @@ -9,7 +9,7 @@ // // SPDX-License-Identifier: EPL-2.0 -import Combine +import AsyncAlgorithms import Foundation import OpenHABCore import os.log @@ -24,7 +24,7 @@ class WatchMessageService: NSObject, WCSessionDelegate { private var cachedWatchPreferences: [String: Data] = [:] private let lock = NSLock() - private var preferencesSubscription: AnyCancellable? + private var preferencesTask: Task? // This method gets called when the watch requests the data // ⚠️ This is called off the main thread. Do NOT touch @MainActor stuff. @@ -68,14 +68,29 @@ class WatchMessageService: NSObject, WCSessionDelegate { // MARK: - Sync Preferences @MainActor - func subscribeToPreferences() async { - preferencesSubscription = Preferences.shared.$currentHomePreferences - .debounce(for: .seconds(1), scheduler: RunLoop.main) - .sink { _ in } receiveValue: { homeSettings in - Task { @MainActor in - await self.syncPreferencesToWatch(homeSettings) - } + func subscribeToPreferences() { + // Cancel any existing subscription + preferencesTask?.cancel() + + preferencesTask = Task { @MainActor in + // Get the AsyncChannel for currentHomePreferences from the actor + let preferencesChannel = await Preferences.shared.currentHomePreferencesChannel + + // Sync initial value immediately + let initialValue = await Preferences.shared.currentHomePreferences + await syncPreferencesToWatch(initialValue) + + // Listen for changes from the AsyncChannel with debouncing + for await homeSettings in preferencesChannel.debounce(for: .seconds(1)) { + await syncPreferencesToWatch(homeSettings) } + } + } + + @MainActor + func stopSubscription() { + preferencesTask?.cancel() + preferencesTask = nil } @MainActor @@ -84,7 +99,7 @@ class WatchMessageService: NSObject, WCSessionDelegate { Logger.preferences.warning("WCSession not activated; skipping sync.") return } - let settings = homeSettings ?? Preferences.shared.currentHomePreferences + let settings = if let homeSettings { homeSettings } else { await Preferences.shared.currentHomePreferences } let prefs = WatchPreferences(fromPreferences: settings) let context = prefs.encodedWatchPreferences() diff --git a/openHAB/WebUITableViewCell.swift b/openHAB/WebUITableViewCell.swift deleted file mode 100644 index 8db4a8b44..000000000 --- a/openHAB/WebUITableViewCell.swift +++ /dev/null @@ -1,127 +0,0 @@ -// Copyright (c) 2010-2026 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 OpenHABCore -import os.log -import WebKit - -class WebUITableViewCell: GenericUITableViewCell, NoIconDisplayableCell { - private var url: URL? - - private var widgetWebView: WKWebView! - - required init?(coder: NSCoder) { - super.init(coder: coder) - - selectionStyle = .none - separatorInset = .zero - - let configuration = WKWebViewConfiguration() - configuration.allowsInlineMediaPlayback = true - configuration.mediaTypesRequiringUserActionForPlayback = [] - widgetWebView = WKWebView(frame: contentView.frame, configuration: configuration) - contentView.addSubview(widgetWebView) - - widgetWebView.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - widgetWebView.leftAnchor.constraint(equalTo: contentView.leftAnchor), - widgetWebView.rightAnchor.constraint(equalTo: contentView.rightAnchor), - widgetWebView.topAnchor.constraint(equalTo: contentView.topAnchor), - widgetWebView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor) - ]) - } - - override func awakeFromNib() { - super.awakeFromNib() - MainActor.assumeIsolated { // See explanation https://www.massicotte.org/awakefromnib - widgetWebView.navigationDelegate = self - widgetWebView.uiDelegate = self - } - } - - override func displayWidget() { - // swiftformat:disable redundantSelf - Logger.widgets.info("webview loading url \(self.widget.url)") - // swiftformat:enable redundantSelf - let urlString = widget.url.lowercased().hasPrefix("http://") || widget.url.lowercased().hasPrefix("https://") ? widget.url : Preferences.shared.currentHomePreferences.localConnectionConfig.url + widget.url - os_log("webview final URL: %{PUBLIC}@", log: .default, type: .info, urlString) - guard url?.absoluteString != urlString else { - Logger.widgets.info("webview URL has not changed, abort loading") - return - } - - if let url = URL(string: urlString) { - self.url = url - let request = URLRequest(url: url) - widgetWebView?.scrollView.isScrollEnabled = false - widgetWebView?.scrollView.bounces = false - widgetWebView?.load(request) - } - } - - func setFrame(_ frame: CGRect) { - Logger.widgets.info("setFrame") - super.frame = frame - widgetWebView?.reload() - } -} - -extension WebUITableViewCell: GenericCellCacheProtocol { - func invalidateCache() { - url = nil - widgetWebView?.stopLoading() - } -} - -extension WebUITableViewCell: WKNavigationDelegate { - func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) { - // swiftformat:disable:next redundantSelf - Logger.widgets.info("webview started loading with URL: \(self.widget.url)") - } - - func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { - // swiftformat:disable:next redundantSelf - Logger.widgets.info("webview finished load with URL: \(self.widget.url)") - } - - func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse) async -> WKNavigationResponsePolicy { - if let response = navigationResponse.response as? HTTPURLResponse, response.statusCode >= 400 { - Logger.widgets.debug("webview failed with status code: \(response.statusCode)") - url = nil - } - return .allow - } - - func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: any Error) { - Logger.widgets.debug("webview failed with error: \(error.localizedDescription)") - url = nil - } - - func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: any Error) { - Logger.widgets.debug("webview failed with error: \(error.localizedDescription)") - url = nil - } - - // Signature changed on transfer from completion handler to async / from didRecieve to respondTo - func webView(_ webView: WKWebView, - respondTo challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { - await onReceiveSessionChallenge(with: challenge) - } -} - -extension WebUITableViewCell: WKUIDelegate { - func webView(_ webView: WKWebView, - decideMediaCapturePermissionsFor origin: WKSecurityOrigin, - initiatedBy frame: WKFrameInfo, - type: WKMediaCaptureType) async -> WKPermissionDecision { - .grant - } -} diff --git a/openHABIntents/OpenHABIntentHelper.swift b/openHABIntents/OpenHABIntentHelper.swift index 61096da55..a93069d2b 100644 --- a/openHABIntents/OpenHABIntentHelper.swift +++ b/openHABIntents/OpenHABIntentHelper.swift @@ -19,7 +19,7 @@ public enum OpenHABIntentHelper { if let home, let homeId = home.uuid { // TODO: fuzzy matching / account for potential renaming? // TODO: accept potential mismatches if item name is unique - let homePrefs = Preferences.shared.storedHomes.first { $0.key == homeId } + let homePrefs = await Preferences.shared.storedHomes.first { $0.key == homeId } if homePrefs != nil { return .success(with: home) } else { @@ -31,8 +31,9 @@ public enum OpenHABIntentHelper { let homeIdsWithMatchingItems = allItems.map(\.key).filter { uuid in allItems[uuid]?.filtered(by: item).isEmpty != true } + let storedHomes = await Preferences.shared.storedHomes let potentialHomes = homeIdsWithMatchingItems - .compactMap { Preferences.shared.storedHomes[$0] } + .compactMap { storedHomes[$0] } .map { OpenHABHome(homeId: $0.id, homeName: $0.homeName) } if potentialHomes.count == 1 { return .success(with: potentialHomes[0]) @@ -44,8 +45,8 @@ public enum OpenHABIntentHelper { } } - static func getHomeOptions() -> INObjectCollection { - INObjectCollection(items: Preferences.shared.storedHomes.map { OpenHABHome(homeId: $0.value.id, homeName: $0.value.homeName) }) + static func getHomeOptions() async -> INObjectCollection { + await INObjectCollection(items: Preferences.shared.storedHomes.map { OpenHABHome(homeId: $0.value.id, homeName: $0.value.homeName) }) } static func getItemOptions(home: OpenHABHome?, searchTerm: String? = nil, itemTypes: [OpenHABItem.ItemType]? = nil) async -> INObjectCollection { From c2c5bbaaee128f0ad2c7a0291440d7fb10abb14b Mon Sep 17 00:00:00 2001 From: Tassilo Karge Date: Mon, 16 Feb 2026 23:23:38 +0100 Subject: [PATCH 475/476] Refactor sitemap navigation and adopt async Preferences API Refactor SitemapsTab to use navigationDestination(item:) for proper NavigationStack-based sitemap drill-down, replacing the manual view-switching approach. Remove redundant inner NavigationStack from SitemapNavigationView. Adopt sidebarAdaptable tab style with scroll-to-minimize behavior. Migrate several Preferences.shared call sites to use async/await pattern. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Tassilo Karge --- openHAB/SwiftUI/HomeSelectionView.swift | 30 +++-- .../SwiftUI/RootView/OpenHABTabRootView.swift | 8 +- openHAB/SwiftUI/RootView/SitemapsTab.swift | 105 +++++++----------- .../SitemapView/SitemapNavigationView.swift | 12 +- openHAB/UIKit/OpenHABWebViewController.swift | 12 +- 5 files changed, 74 insertions(+), 93 deletions(-) diff --git a/openHAB/SwiftUI/HomeSelectionView.swift b/openHAB/SwiftUI/HomeSelectionView.swift index 5d14aaecb..69f40ca18 100644 --- a/openHAB/SwiftUI/HomeSelectionView.swift +++ b/openHAB/SwiftUI/HomeSelectionView.swift @@ -132,7 +132,7 @@ struct HomeSelectionView: View { .tint(.blue) } } - .onAppear(perform: loadHomesList) + .task(loadHomesList) .navigationBarTitle("Manage Homes") .toolbar { if showEditOptions { @@ -174,21 +174,25 @@ struct HomeSelectionView: View { } private func select(home: UUID) { - Preferences.shared.switchActiveHome(to: home) - dismiss() + Task { + await Preferences.shared.switchActiveHome(to: home) + dismiss() + } } - private func loadHomesList() { - homes = Preferences.shared.listStoredHomes() + private func loadHomesList() async { + await homes = Preferences.shared.listStoredHomes() } private func delete(home toDelete: UUID?) { guard let toDelete else { return } - Logger.selectionView.info("delete home settings for \(toDelete.uuidString)") - Preferences.shared.deleteStoredHome(toDelete) - loadHomesList() + Task { + Logger.selectionView.info("delete home settings for \(toDelete.uuidString)") + await Preferences.shared.deleteStoredHome(toDelete) + await loadHomesList() + } } private func rename(home toRename: UUID?) { @@ -197,12 +201,16 @@ struct HomeSelectionView: View { } let newName = newHomeName Logger.selectionView.info("rename home \(toRename.uuidString) to \(newName)") - Preferences.shared.renameHome(toRename, newHomeName: newName) + Task { + await Preferences.shared.renameHome(toRename, newHomeName: newName) + } } private func addHome() { - Preferences.shared.createAndLoadNewStoredSettings(homeName: newHomeName) - loadHomesList() + Task { + await Preferences.shared.createAndLoadNewStoredSettings(homeName: newHomeName) + await loadHomesList() + } } } diff --git a/openHAB/SwiftUI/RootView/OpenHABTabRootView.swift b/openHAB/SwiftUI/RootView/OpenHABTabRootView.swift index e49a4c457..80a8259cd 100644 --- a/openHAB/SwiftUI/RootView/OpenHABTabRootView.swift +++ b/openHAB/SwiftUI/RootView/OpenHABTabRootView.swift @@ -103,10 +103,14 @@ struct OpenHABTabRootView: View { } } } + .tabViewStyle(.sidebarAdaptable) + .tabBarMinimizeBehavior(.onScrollDown) .environmentObject(networkTracker) .onChange(of: selectedTab) { oldTab, newTab in - Preferences.shared.modifyActiveHome { prefs in - prefs.lastSelectedTab = newTab.rawValue + Task { + await Preferences.shared.modifyActiveHome { prefs in + prefs.lastSelectedTab = newTab.rawValue + } } } .onReceive(Preferences.shared.$currentHomePreferences) { _ in diff --git a/openHAB/SwiftUI/RootView/SitemapsTab.swift b/openHAB/SwiftUI/RootView/SitemapsTab.swift index 404076ca1..0db368bfc 100644 --- a/openHAB/SwiftUI/RootView/SitemapsTab.swift +++ b/openHAB/SwiftUI/RootView/SitemapsTab.swift @@ -26,9 +26,14 @@ struct SitemapsTab: View { @Binding var navigationCommand: SitemapNavigationCommand? @State private var sitemaps: [OpenHABSitemap] = [] - @State private var selectedSitemap: String? + @State private var selectedSitemap: SelectedSitemapIdentifier? @State private var sitemapForWatch: String? @StateObject private var viewModel = SitemapPageViewModel() + + private struct SelectedSitemapIdentifier: Identifiable, Hashable { + let id = UUID() + let name: String + } @EnvironmentObject private var networkTracker: MainActorNetworkTracker @@ -36,34 +41,20 @@ struct SitemapsTab: View { var body: some View { NavigationStack { - Group { - if selectedSitemap != nil { - SitemapNavigationContent(viewModel: viewModel) - } else { - sitemapList + sitemapList + .navigationTitle("Sitemaps") + .navigationBarTitleDisplayMode(.inline) + .navigationDestination(item: $selectedSitemap) { sitemapName in + SitemapNavigationView(viewModel: viewModel) } - } - .navigationTitle(selectedSitemap != nil ? viewModel.pageTitle : "Sitemaps") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - if selectedSitemap != nil { - ToolbarItem(placement: .navigationBarLeading) { - Button { - self.selectedSitemap = nil - } label: { - HStack(spacing: 4) { - Image(systemSymbol: .chevronBackward) - Text("Sitemaps") - } - } - } - } - } } .task { sitemapForWatch = await Preferences.shared.currentHomePreferences.sitemapForWatch await fetchSitemaps(activeConnection: networkTracker.activeConnection) - autoSelectSitemap() + let defaultSitemap = await Preferences.shared.currentHomePreferences.defaultSitemap + if !defaultSitemap.isEmpty, sitemaps.contains(where: { $0.name == defaultSitemap }) { + selectSitemap(defaultSitemap) + } } .onReceive(networkTracker.$activeConnection) { activeConnection in Task { @@ -71,17 +62,15 @@ struct SitemapsTab: View { } } .onChange(of: resetTrigger) { _, _ in - withAnimation { - selectedSitemap = nil - } + selectedSitemap = nil } .onChange(of: navigationCommand) { _, command in guard let command else { return } - selectedSitemap = command.name - Preferences.shared.modifyActiveHome { preferences in - preferences.defaultSitemap = command.name - } + selectedSitemap = SelectedSitemapIdentifier(name: command.name) Task { + await Preferences.shared.modifyActiveHome { preferences in + preferences.defaultSitemap = command.name + } await viewModel.pushSitemap(name: command.name, path: command.widgetId) } navigationCommand = nil @@ -112,22 +101,15 @@ struct SitemapsTab: View { } private func selectSitemap(_ name: String) { - selectedSitemap = name - Preferences.shared.modifyActiveHome { preferences in - preferences.defaultSitemap = name - } + selectedSitemap = SelectedSitemapIdentifier(name: name) Task { + await Preferences.shared.modifyActiveHome { preferences in + preferences.defaultSitemap = name + } await viewModel.pushSitemap(name: name, path: nil) } } - private func autoSelectSitemap() { - let defaultSitemap = Preferences.shared.currentHomePreferences.defaultSitemap - if !defaultSitemap.isEmpty, sitemaps.contains(where: { $0.name == defaultSitemap }) { - selectSitemap(defaultSitemap) - } - } - private func fetchSitemaps(activeConnection: ConnectionInfo?) async { guard let activeConnection else { return } do { @@ -168,33 +150,22 @@ struct SitemapsTab: View { } private func toggleWatchSitemap(_ sitemap: OpenHABSitemap) { - Preferences.shared.modifyActiveHome { prefs in - if sitemap.name == sitemapForWatch { - sitemapForWatch = nil - prefs.sitemapForWatch = "" - prefs.sitemapForWatchLabel = "" - } else { - sitemapForWatch = sitemap.name - prefs.sitemapForWatch = sitemap.name - prefs.sitemapForWatchLabel = sitemap.label + let sitemapForWatchName, sitemapForWatchLabel: String + if sitemap.name == sitemapForWatch { + sitemapForWatch = nil + sitemapForWatchName = "" + sitemapForWatchLabel = "" + } else { + sitemapForWatch = sitemap.name + sitemapForWatchName = sitemap.name + sitemapForWatchLabel = sitemap.label + } + Task { + await Preferences.shared.modifyActiveHome { prefs in + prefs.sitemapForWatch = sitemapForWatchName + prefs.sitemapForWatchLabel = sitemapForWatchLabel } } } } -/// Inner content view that wraps SitemapPageView without its own NavigationStack -private struct SitemapNavigationContent: View { - @ObservedObject var viewModel: SitemapPageViewModel - - var body: some View { - let page = SitemapPageView(viewModel: viewModel) - if viewModel.showSearchField { - page - .searchable(text: $viewModel.searchText, prompt: Text(NSLocalizedString("search_items", comment: ""))) - .autocorrectionDisabled() - .textInputAutocapitalization(.never) - } else { - page - } - } -} diff --git a/openHAB/SwiftUI/SitemapView/SitemapNavigationView.swift b/openHAB/SwiftUI/SitemapView/SitemapNavigationView.swift index 10c2156fa..c9ba3ac2a 100644 --- a/openHAB/SwiftUI/SitemapView/SitemapNavigationView.swift +++ b/openHAB/SwiftUI/SitemapView/SitemapNavigationView.swift @@ -20,16 +20,14 @@ struct SitemapNavigationView: View { @FocusState private var isLegacySearchFocused: Bool var body: some View { - NavigationStack { - sitemapContent - } + sitemapContent } @ViewBuilder private var sitemapContent: some View { let page = SitemapPageView(viewModel: viewModel) .navigationTitle(viewModel.pageTitle) - .navigationBarTitleDisplayMode(.automatic) + .navigationBarTitleDisplayMode(.inline) .toolbar { if !isCommandLifecycleIdle { ToolbarItem(placement: .navigationBarLeading) { @@ -38,14 +36,14 @@ struct SitemapNavigationView: View { } if viewModel.showSearchField { ToolbarItem(placement: .navigationBarTrailing) { - if #available(iOS 17.0, *) { + //if #available(iOS 17.0, *) { Button { isSearchPresented = true } label: { Image(systemSymbol: .magnifyingglass) } .accessibilityLabel("Search") - } else { + /*} else { Button { isSearchPresented = true isLegacySearchFocused = true @@ -53,7 +51,7 @@ struct SitemapNavigationView: View { Image(systemSymbol: .magnifyingglass) } .accessibilityLabel("Search") - } + }*/ } } } diff --git a/openHAB/UIKit/OpenHABWebViewController.swift b/openHAB/UIKit/OpenHABWebViewController.swift index f26653233..6f567f88e 100644 --- a/openHAB/UIKit/OpenHABWebViewController.swift +++ b/openHAB/UIKit/OpenHABWebViewController.swift @@ -174,7 +174,7 @@ class OpenHABWebViewController: OpenHABViewController { currentTarget = newTarget let url = URL(string: activeConfig.url) - if let modifiedUrl = modifyUrl(orig: url, path: path) { + if let modifiedUrl = await modifyUrl(orig: url, path: path) { acceptsCommands = false var request = URLRequest(url: modifiedUrl) @@ -216,7 +216,7 @@ class OpenHABWebViewController: OpenHABViewController { private func loadWebViewWithETagCheck(newTarget: String, path: String?) async { guard let activeConfig, let url = URL(string: activeConfig.url), - let fullURL = modifyUrl(orig: url, path: path) else { + let fullURL = await modifyUrl(orig: url, path: path) else { Logger.viewController.info("ETag check skipped: invalid configuration") await performLoadWebView(newTarget: newTarget, path: path, force: false) return @@ -297,7 +297,7 @@ class OpenHABWebViewController: OpenHABViewController { return normalized } - func modifyUrl(orig: URL?, path: String? = nil) -> URL? { + func modifyUrl(orig: URL?, path: String? = nil) async -> URL? { // better way to clone/copy ? guard let urlString = orig?.absoluteString, var url = URL(string: urlString) else { return orig } // Use cloud proxy URL if available (resolved from /api/v1/proxyurl) @@ -306,8 +306,8 @@ class OpenHABWebViewController: OpenHABViewController { } if let path { url = appendPathToURL(baseURL: url, path: path) ?? url - } else if !Preferences.shared.currentHomePreferences.defaultMainUIPath.isEmpty { - url = appendPathToURL(baseURL: url, path: Preferences.shared.currentHomePreferences.defaultMainUIPath) ?? url + } else if await !Preferences.shared.currentHomePreferences.defaultMainUIPath.isEmpty { + url = appendPathToURL(baseURL: url, path: await Preferences.shared.currentHomePreferences.defaultMainUIPath) ?? url } return url } @@ -585,7 +585,7 @@ extension OpenHABWebViewController: WKNavigationDelegate { func webView(_ webView: WKWebView, respondTo challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { Logger.viewController.info("Challenge.protectionSpace.authenticationMethod: \(String(describing: challenge.protectionSpace.authenticationMethod))") - if let url = modifyUrl(orig: URL(string: openHABTrackedRootUrl)), challenge.protectionSpace.host == url.host { + if let url = await modifyUrl(orig: URL(string: openHABTrackedRootUrl)), challenge.protectionSpace.host == url.host { if challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust { guard let serverTrust = challenge.protectionSpace.serverTrust else { return (.performDefaultHandling, nil) From 6d89e35a70adfc38b31e52da37971dc32fb45576 Mon Sep 17 00:00:00 2001 From: Tassilo Karge Date: Tue, 17 Feb 2026 00:44:53 +0100 Subject: [PATCH 476/476] Adopt SwiftUI App lifecycle, remove Main.storyboard, and delete legacy UIKit views Replace UIKit-based app launch with a SwiftUI @main App struct using @UIApplicationDelegateAdaptor. This eliminates UIHostingController and UIWindow management from AppDelegate entirely. A lightweight SplashView is shown during async preference migration before swapping to the main tab root view. Also removes legacy UIKit view controllers (OpenHABViewController, OpenHABWebViewController, etc.), fixes a concurrency issue in IdleTimerService (use AsyncChannel instead of non-Sendable AnyPublisher across actor boundary), and fixes false-positive edit detection in SettingsView by guarding onChange until preferences finish loading. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Tassilo Karge --- openHAB.xcodeproj/project.pbxproj | 74 +- openHAB/AppDelegate.swift | 88 +-- openHAB/CertificateManagementService.swift | 241 +++++++ openHAB/IdleTimerService.swift | 80 +++ openHAB/Main.storyboard | 90 --- openHAB/OpenHABApp.swift | 47 ++ openHAB/SwiftUI/RootView/MainWebTab.swift | 12 +- .../SwiftUI/RootView/OpenHABTabRootView.swift | 22 +- openHAB/SwiftUI/Rows/WebRowView.swift | 50 +- .../SwiftUI/SettingsView/SettingsView.swift | 1 + .../SwiftUI/SitemapView/OpenHABWebView.swift | 418 ++++++++++++ openHAB/SwiftUI/SplashView.swift | 26 + openHAB/UIKIT_MIGRATION_ANALYSIS.md | 277 ++++++++ openHAB/UIKIT_REMOVAL_GUIDE.md | 230 +++++++ .../UIKit/OpenHABNavigationController.swift | 37 - openHAB/UIKit/OpenHABViewController.swift | 243 ------- openHAB/UIKit/OpenHABWebViewController.swift | 640 ------------------ openHAB/UIKit/ScaleAspectFitImageView.swift | 71 -- openHAB/UIKit/UICircleButton.swift | 24 - openHAB/UIKit/UILabel+Localization.swift | 24 - .../UIKit/UIViewController+Localization.swift | 25 - openHAB/{UIKit => }/URL+Static.swift | 0 openHAB/openHAB-Info.plist | 4 - 23 files changed, 1401 insertions(+), 1323 deletions(-) create mode 100644 openHAB/CertificateManagementService.swift create mode 100644 openHAB/IdleTimerService.swift delete mode 100644 openHAB/Main.storyboard create mode 100644 openHAB/OpenHABApp.swift create mode 100644 openHAB/SwiftUI/SitemapView/OpenHABWebView.swift create mode 100644 openHAB/SwiftUI/SplashView.swift create mode 100644 openHAB/UIKIT_MIGRATION_ANALYSIS.md create mode 100644 openHAB/UIKIT_REMOVAL_GUIDE.md delete mode 100644 openHAB/UIKit/OpenHABNavigationController.swift delete mode 100644 openHAB/UIKit/OpenHABViewController.swift delete mode 100644 openHAB/UIKit/OpenHABWebViewController.swift delete mode 100644 openHAB/UIKit/ScaleAspectFitImageView.swift delete mode 100644 openHAB/UIKit/UICircleButton.swift delete mode 100644 openHAB/UIKit/UILabel+Localization.swift delete mode 100644 openHAB/UIKit/UIViewController+Localization.swift rename openHAB/{UIKit => }/URL+Static.swift (100%) diff --git a/openHAB.xcodeproj/project.pbxproj b/openHAB.xcodeproj/project.pbxproj index 9c5207f88..c34fe7e5e 100644 --- a/openHAB.xcodeproj/project.pbxproj +++ b/openHAB.xcodeproj/project.pbxproj @@ -7,6 +7,8 @@ objects = { /* Begin PBXBuildFile section */ + AA0001022F5A000000000001 /* SplashView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0001012F5A000000000001 /* SplashView.swift */; }; + AA0001042F5A000000000002 /* OpenHABApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0001032F5A000000000002 /* OpenHABApp.swift */; }; 0C93C5B057F863322A1B9820 /* SystemTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4229903F00160C965E22DE21 /* SystemTab.swift */; }; 10472F7AF99C4940A6144817 /* TextRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 399449C421544C61AD83450C /* TextRow.swift */; }; 1224F78F228A89FD00750965 /* WatchMessageService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1224F78D228A89FC00750965 /* WatchMessageService.swift */; }; @@ -18,14 +20,18 @@ 2F54B5772F428B0200DA1F1A /* Algorithms in Frameworks */ = {isa = PBXBuildFile; productRef = 2F54B5762F428B0200DA1F1A /* Algorithms */; }; 2F55E7BB2DEE447700EC8350 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F55E7BA2DEE447700EC8350 /* SettingsView.swift */; }; 2F55E7BD2DEE44A800EC8350 /* ClientCertificatesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F55E7BC2DEE44A800EC8350 /* ClientCertificatesView.swift */; }; + 2F77DF3C2F43D3D700BE3744 /* OpenHABWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F77DF3B2F43D3D700BE3744 /* OpenHABWebView.swift */; }; + 2F77DF3E2F43D42E00BE3744 /* WEBVIEW_MIGRATION.md in Resources */ = {isa = PBXBuildFile; fileRef = 2F77DF3D2F43D42E00BE3744 /* WEBVIEW_MIGRATION.md */; }; + 2F77DF402F43D94C00BE3744 /* UIKIT_MIGRATION_ANALYSIS.md in Resources */ = {isa = PBXBuildFile; fileRef = 2F77DF3F2F43D94C00BE3744 /* UIKIT_MIGRATION_ANALYSIS.md */; }; + 2F77DF422F43D96800BE3744 /* CertificateManagementService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F77DF412F43D96800BE3744 /* CertificateManagementService.swift */; }; + 2F77DF442F43D97400BE3744 /* IdleTimerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F77DF432F43D97400BE3744 /* IdleTimerService.swift */; }; + 2F77DF462F43D9A400BE3744 /* UIKIT_REMOVAL_GUIDE.md in Resources */ = {isa = PBXBuildFile; fileRef = 2F77DF452F43D9A400BE3744 /* UIKIT_REMOVAL_GUIDE.md */; }; 2FBCF58C2DEB0B7700CD5D83 /* HomeSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FBCF58B2DEB0B7700CD5D83 /* HomeSelectionView.swift */; }; 2FF459362E230C6A00C0B640 /* OpenHABIntentHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FF459352E230C6A00C0B640 /* OpenHABIntentHelper.swift */; }; 4D6470DA2561F935007B03FC /* openHABIntents.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 4D6470D32561F935007B03FC /* openHABIntents.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 652B81042E2193B500648510 /* ScreenSaverSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 652B81032E2193B500648510 /* ScreenSaverSettingsView.swift */; }; 652B81092E2193DA00648510 /* ScreenSaverManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 652B81062E2193DA00648510 /* ScreenSaverManager.swift */; }; 652B810A2E2193DA00648510 /* ScreenSaverConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 652B81052E2193DA00648510 /* ScreenSaverConfiguration.swift */; }; - 653B54C2285E714900298ECD /* OpenHABViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 653B54C1285E714900298ECD /* OpenHABViewController.swift */; }; - 65570A7D2476D16A00D524EA /* OpenHABWebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65570A7C2476D16A00D524EA /* OpenHABWebViewController.swift */; }; 6557AF8F2C0241C10094D0C8 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 6557AF8E2C0241C10094D0C8 /* PrivacyInfo.xcprivacy */; }; 6557AF902C0241C10094D0C8 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 6557AF8E2C0241C10094D0C8 /* PrivacyInfo.xcprivacy */; }; 6557AF922C039D140094D0C8 /* FirebaseMessaging in Frameworks */ = {isa = PBXBuildFile; productRef = 6557AF912C039D140094D0C8 /* FirebaseMessaging */; }; @@ -33,7 +39,6 @@ 657144512C1E438700C8A1F3 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 657144502C1E438700C8A1F3 /* NotificationService.swift */; }; 657144552C1E438700C8A1F3 /* NotificationService.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 6571444E2C1E438700C8A1F3 /* NotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 657144962C30A16700C8A1F3 /* OpenHABCore in Frameworks */ = {isa = PBXBuildFile; productRef = 657144952C30A16700C8A1F3 /* OpenHABCore */; }; - 65C2EF492E244C8500A0C19F /* OpenHABNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65C2EF482E244C8500A0C19F /* OpenHABNavigationController.swift */; }; 65F055442E3D4E41004E98FE /* ItemSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65F055432E3D4E41004E98FE /* ItemSelectionView.swift */; }; 7BFFEA908B9E47FCB5C46E6E /* VideoStreamManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 905A534AB32C4104AC55A75C /* VideoStreamManager.swift */; }; 801159DCB99C03EFA71F4B9D /* MainWebTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5135184D9630899A34EFBA95 /* MainWebTab.swift */; }; @@ -57,13 +62,9 @@ 937E44E2270B393C00A98C26 /* OpenHABCore in Frameworks */ = {isa = PBXBuildFile; productRef = 937E44E1270B393C00A98C26 /* OpenHABCore */; }; 938620C0257E223C00A63200 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 938BF9C824EFCCC000E6B52F /* Localizable.strings */; }; 938BF89624EFBC5400E6B52F /* LocalizationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 938BF89524EFBC5400E6B52F /* LocalizationTests.swift */; }; - 938BF9C424EFCB9F00E6B52F /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 938BF9C324EFCB9F00E6B52F /* Main.storyboard */; }; - 938BF9C624EFCC0700E6B52F /* UILabel+Localization.swift in Sources */ = {isa = PBXBuildFile; fileRef = 938BF9C524EFCC0700E6B52F /* UILabel+Localization.swift */; }; 938BF9D024EFCCC000E6B52F /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 938BF9C824EFCCC000E6B52F /* Localizable.strings */; }; 938BF9D124EFCCC000E6B52F /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 938BF9CA24EFCCC000E6B52F /* InfoPlist.strings */; }; - 938BF9D324EFD0B700E6B52F /* UIViewController+Localization.swift in Sources */ = {isa = PBXBuildFile; fileRef = 938BF9D224EFD0B700E6B52F /* UIViewController+Localization.swift */; }; 938BF9D524EFD5B100E6B52F /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 938BF9C824EFCCC000E6B52F /* Localizable.strings */; }; - 938EDCE122C4FEB800661CA1 /* ScaleAspectFitImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 938EDCE022C4FEB800661CA1 /* ScaleAspectFitImageView.swift */; }; 9397EDEC2587837000F266E1 /* openHABWatch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = DA0775152346705D0086C685 /* openHABWatch.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 93AEE42427D9D76B008EB207 /* GetItemStateIntentHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D6471112561FDA7007B03FC /* GetItemStateIntentHandler.swift */; }; 93AEE42527D9D76E008EB207 /* IntentHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D6470D52561F935007B03FC /* IntentHandler.swift */; }; @@ -203,7 +204,6 @@ DFDA3CEA193CADB200888039 /* ping.wav in Resources */ = {isa = PBXBuildFile; fileRef = DFDA3CE9193CADB200888039 /* ping.wav */; }; DFDF45311932042B00A6E581 /* legal.rtf in Resources */ = {isa = PBXBuildFile; fileRef = DFDF45301932042B00A6E581 /* legal.rtf */; }; DFE10414197415F900D94943 /* Security.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DFE10413197415F900D94943 /* Security.framework */; }; - DFFD8FD118EDBD4F003B502A /* UICircleButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFFD8FD018EDBD4F003B502A /* UICircleButton.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -312,12 +312,20 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + AA0001012F5A000000000001 /* SplashView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplashView.swift; sourceTree = ""; }; + AA0001032F5A000000000002 /* OpenHABApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenHABApp.swift; sourceTree = ""; }; 1224F78D228A89FC00750965 /* WatchMessageService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WatchMessageService.swift; sourceTree = ""; }; 13181EE034B26E3A1FF7F4A8 /* SitemapsTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SitemapsTab.swift; sourceTree = ""; }; 2F08AFC62E5FADC500E70611 /* NotificationCenterDelegateImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationCenterDelegateImpl.swift; sourceTree = ""; }; 2F54B56C2F40AC1A00DA1F1A /* REFACTORING_SUMMARY.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = REFACTORING_SUMMARY.md; sourceTree = ""; }; 2F55E7BA2DEE447700EC8350 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; 2F55E7BC2DEE44A800EC8350 /* ClientCertificatesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientCertificatesView.swift; sourceTree = ""; }; + 2F77DF3B2F43D3D700BE3744 /* OpenHABWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenHABWebView.swift; sourceTree = ""; }; + 2F77DF3D2F43D42E00BE3744 /* WEBVIEW_MIGRATION.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = WEBVIEW_MIGRATION.md; sourceTree = ""; }; + 2F77DF3F2F43D94C00BE3744 /* UIKIT_MIGRATION_ANALYSIS.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = UIKIT_MIGRATION_ANALYSIS.md; sourceTree = ""; }; + 2F77DF412F43D96800BE3744 /* CertificateManagementService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CertificateManagementService.swift; sourceTree = ""; }; + 2F77DF432F43D97400BE3744 /* IdleTimerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdleTimerService.swift; sourceTree = ""; }; + 2F77DF452F43D9A400BE3744 /* UIKIT_REMOVAL_GUIDE.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = UIKIT_REMOVAL_GUIDE.md; sourceTree = ""; }; 2FBCF58B2DEB0B7700CD5D83 /* HomeSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeSelectionView.swift; sourceTree = ""; }; 2FF459352E230C6A00C0B640 /* OpenHABIntentHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenHABIntentHelper.swift; sourceTree = ""; }; 3698C230427F48A782B1980B /* TabCustomizationSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabCustomizationSection.swift; sourceTree = ""; }; @@ -339,16 +347,13 @@ 652B81032E2193B500648510 /* ScreenSaverSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenSaverSettingsView.swift; sourceTree = ""; }; 652B81052E2193DA00648510 /* ScreenSaverConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenSaverConfiguration.swift; sourceTree = ""; }; 652B81062E2193DA00648510 /* ScreenSaverManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenSaverManager.swift; sourceTree = ""; }; - 653B54C1285E714900298ECD /* OpenHABViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenHABViewController.swift; sourceTree = ""; }; 653C09D41EAD691A00BA4C4A /* openHAB.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = openHAB.entitlements; sourceTree = ""; }; - 65570A7C2476D16A00D524EA /* OpenHABWebViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenHABWebViewController.swift; sourceTree = ""; }; 6557AF8E2C0241C10094D0C8 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; 656916D81FCB82BC00667B2A /* GoogleService-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = "GoogleService-Info.plist"; path = "openHAB/GoogleService-Info.plist"; sourceTree = SOURCE_ROOT; }; 6571444E2C1E438700C8A1F3 /* NotificationService.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = NotificationService.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 657144502C1E438700C8A1F3 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; 657144522C1E438700C8A1F3 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 657144972C30A3E300C8A1F3 /* NotificationService.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = NotificationService.entitlements; sourceTree = ""; }; - 65C2EF482E244C8500A0C19F /* OpenHABNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenHABNavigationController.swift; sourceTree = ""; }; 65F055432E3D4E41004E98FE /* ItemSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemSelectionView.swift; sourceTree = ""; }; 905A534AB32C4104AC55A75C /* VideoStreamManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VideoStreamManager.swift; sourceTree = ""; }; 931384B324F259BC00A73AB5 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = ""; }; @@ -377,16 +382,12 @@ 935D3451257B7EA60020A404 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = Resources/es.lproj/Intents.strings; sourceTree = ""; }; 93685A792ADE755C0077A9A6 /* openHABTests.xctestplan */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = openHABTests.xctestplan; sourceTree = ""; }; 938BF89524EFBC5400E6B52F /* LocalizationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizationTests.swift; sourceTree = ""; }; - 938BF9C324EFCB9F00E6B52F /* Main.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; path = Main.storyboard; sourceTree = ""; }; - 938BF9C524EFCC0700E6B52F /* UILabel+Localization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UILabel+Localization.swift"; sourceTree = ""; }; 938BF9C924EFCCC000E6B52F /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = ""; }; 938BF9CB24EFCCC000E6B52F /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/InfoPlist.strings; sourceTree = ""; }; 938BF9CC24EFCCC000E6B52F /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; 938BF9CD24EFCCC000E6B52F /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = ""; }; 938BF9CE24EFCCC000E6B52F /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/Localizable.strings; sourceTree = ""; }; 938BF9CF24EFCCC000E6B52F /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/InfoPlist.strings; sourceTree = ""; }; - 938BF9D224EFD0B700E6B52F /* UIViewController+Localization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+Localization.swift"; sourceTree = ""; }; - 938EDCE022C4FEB800661CA1 /* ScaleAspectFitImageView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScaleAspectFitImageView.swift; sourceTree = ""; }; A3C3E3BF4C930ADA95E6E997 /* SafariView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafariView.swift; sourceTree = ""; }; A3F4C3A41A49A5940019A09F /* MainLaunchScreen.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; name = MainLaunchScreen.xib; path = ../MainLaunchScreen.xib; sourceTree = ""; }; D64BEA2793F07C36BB98D9B8 /* AppServicesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppServicesViewModel.swift; sourceTree = ""; }; @@ -557,7 +558,6 @@ DFDA3CE9193CADB200888039 /* ping.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; path = ping.wav; sourceTree = ""; }; DFDF45301932042B00A6E581 /* legal.rtf */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.rtf; path = legal.rtf; sourceTree = ""; }; DFE10413197415F900D94943 /* Security.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Security.framework; path = System/Library/Frameworks/Security.framework; sourceTree = SDKROOT; }; - DFFD8FD018EDBD4F003B502A /* UICircleButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UICircleButton.swift; sourceTree = ""; }; EE239F0348D19000408BA8AA /* TilesTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TilesTab.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -690,17 +690,6 @@ path = openHABWatch/External; sourceTree = SOURCE_ROOT; }; - 2F7D1D572EFEBABA004A786D /* UIKit */ = { - isa = PBXGroup; - children = ( - 65C2EF482E244C8500A0C19F /* OpenHABNavigationController.swift */, - 653B54C1285E714900298ECD /* OpenHABViewController.swift */, - 65570A7C2476D16A00D524EA /* OpenHABWebViewController.swift */, - DFFD8FCE18EDBD30003B502A /* Util */, - ); - path = UIKit; - sourceTree = ""; - }; 2F7D1D592EFEC12A004A786D /* RootView */ = { isa = PBXGroup; children = ( @@ -728,6 +717,8 @@ DAEA21D72DBF472D00D54342 /* RowViewFactory.swift */, DA64ACA72DBEAD8300294F60 /* SitemapPageView.swift */, DA64ACA92DBEAD9000294F60 /* SitemapNavigationView.swift */, + 2F77DF3B2F43D3D700BE3744 /* OpenHABWebView.swift */, + 2F77DF3D2F43D42E00BE3744 /* WEBVIEW_MIGRATION.md */, ); path = SitemapView; sourceTree = ""; @@ -925,6 +916,7 @@ children = ( 2FBCF58B2DEB0B7700CD5D83 /* HomeSelectionView.swift */, DA6B2EF02C87B59000DF77CF /* NotificationsView.swift */, + AA0001012F5A000000000001 /* SplashView.swift */, 2F7D1D592EFEC12A004A786D /* RootView */, 2F7D1D5A2EFEC137004A786D /* SitemapView */, DAC949FF2E21A473007E67B7 /* Rows */, @@ -1086,7 +1078,7 @@ DF4B83FD18857FA100F34902 /* UI */ = { isa = PBXGroup; children = ( - 2F7D1D572EFEBABA004A786D /* UIKit */, + DFFD8FCE18EDBD30003B502A /* Util */, DABED17C2E4516B4000B92EF /* BonjourDiscoveryViewModel.swift */, DA94AEB32EC896BE003BB3C8 /* SimpleMJPEGPlayer.swift */, DA2AEB6D2D92BAD800897D80 /* PageLoader.swift */, @@ -1176,8 +1168,8 @@ isa = PBXGroup; children = ( DACE66522C63B2070069E514 /* openapitest */, - 938BF9C324EFCB9F00E6B52F /* Main.storyboard */, A3F4C3A41A49A5940019A09F /* MainLaunchScreen.xib */, + AA0001032F5A000000000002 /* OpenHABApp.swift */, DFB2623A18830A3600D3244D /* AppDelegate.swift */, 2F08AFC62E5FADC500E70611 /* NotificationCenterDelegateImpl.swift */, DFDEE3FE1883228C008B26AC /* Models */, @@ -1185,6 +1177,8 @@ DF4B83FD18857FA100F34902 /* UI */, DFB2623118830A3600D3244D /* Supporting Files */, 938BF9C724EFCCC000E6B52F /* Resources */, + 2F77DF3F2F43D94C00BE3744 /* UIKIT_MIGRATION_ANALYSIS.md */, + 2F77DF452F43D9A400BE3744 /* UIKIT_REMOVAL_GUIDE.md */, ); path = openHAB; sourceTree = ""; @@ -1215,10 +1209,8 @@ DFFD8FCE18EDBD30003B502A /* Util */ = { isa = PBXGroup; children = ( - 938EDCE022C4FEB800661CA1 /* ScaleAspectFitImageView.swift */, - DFFD8FD018EDBD4F003B502A /* UICircleButton.swift */, - 938BF9C524EFCC0700E6B52F /* UILabel+Localization.swift */, - 938BF9D224EFD0B700E6B52F /* UIViewController+Localization.swift */, + 2F77DF412F43D96800BE3744 /* CertificateManagementService.swift */, + 2F77DF432F43D97400BE3744 /* IdleTimerService.swift */, 935B484525342B8E00E44CF0 /* URL+Static.swift */, ); name = Util; @@ -1611,16 +1603,18 @@ files = ( DAC131122DA32F5D00075AE2 /* Intents.intentdefinition in Resources */, 938BF9D024EFCCC000E6B52F /* Localizable.strings in Resources */, + 2F77DF402F43D94C00BE3744 /* UIKIT_MIGRATION_ANALYSIS.md in Resources */, DFB2624618830A3600D3244D /* Images.xcassets in Resources */, 6557AF8F2C0241C10094D0C8 /* PrivacyInfo.xcprivacy in Resources */, + 2F77DF462F43D9A400BE3744 /* UIKIT_REMOVAL_GUIDE.md in Resources */, 93685A7A2ADE755C0077A9A6 /* openHABTests.xctestplan in Resources */, 938BF9D124EFCCC000E6B52F /* InfoPlist.strings in Resources */, 2F54B56D2F40AC1A00DA1F1A /* REFACTORING_SUMMARY.md in Resources */, - 938BF9C424EFCB9F00E6B52F /* Main.storyboard in Resources */, DA817E7A234BF39B00C91824 /* CHANGELOG.md in Resources */, DA4D4DB5233F9ACB00B37E37 /* README.md in Resources */, DA88F8C622EC377200B408E5 /* ReleaseNotes.md in Resources */, DFDF45311932042B00A6E581 /* legal.rtf in Resources */, + 2F77DF3E2F43D42E00BE3744 /* WEBVIEW_MIGRATION.md in Resources */, 656916D91FCB82BC00667B2A /* GoogleService-Info.plist in Resources */, A3F4C3A51A49A5940019A09F /* MainLaunchScreen.xib in Resources */, DFDA3CEA193CADB200888039 /* ping.wav in Resources */, @@ -1774,13 +1768,15 @@ buildActionMask = 2147483647; files = ( 1CE7AC462008E29FA37F231D /* AppServicesViewModel.swift in Sources */, + AA0001022F5A000000000001 /* SplashView.swift in Sources */, + AA0001042F5A000000000002 /* OpenHABApp.swift in Sources */, BE875E6CE1B6E05E492F29CE /* OpenHABTabRootView.swift in Sources */, 801159DCB99C03EFA71F4B9D /* MainWebTab.swift in Sources */, D8C89708AE75DEFCD78566EC /* SitemapsTab.swift in Sources */, 2C9BBB28C068BCC10291A566 /* TilesTab.swift in Sources */, + 2F77DF3C2F43D3D700BE3744 /* OpenHABWebView.swift in Sources */, 0C93C5B057F863322A1B9820 /* SystemTab.swift in Sources */, 911CC92713E7DF11C3295A4C /* SafariView.swift in Sources */, - 65570A7D2476D16A00D524EA /* OpenHABWebViewController.swift in Sources */, DA48001E2D837905009CF127 /* ApplicationSettingsView.swift in Sources */, A75A53645D1542CBAC658099 /* TabCustomizationSection.swift in Sources */, DA64ACA62DBEAD5600294F60 /* SitemapPageViewModel.swift in Sources */, @@ -1791,9 +1787,11 @@ DAEA21DA2DBF477E00D54342 /* SwitchRowView.swift in Sources */, DAEA21DC2DBF47DA00D54342 /* SliderRowView.swift in Sources */, DA77E19B2D886D9B007CFF0F /* SingleConnectionSettingsView.swift in Sources */, + 2F77DF422F43D96800BE3744 /* CertificateManagementService.swift in Sources */, DA35E2B02E1EDB86003987BB /* SetpointRowView.swift in Sources */, DA35E2CB2E1F93AD003987BB /* ImageView.swift in Sources */, DAEE35072E224F6000B4A7F1 /* ColorTemperaturePickerRowView.swift in Sources */, + 2F77DF442F43D97400BE3744 /* IdleTimerService.swift in Sources */, DA6B2EF52C89F8F200DF77CF /* ColorPickerView.swift in Sources */, DA4800142D836892009CF127 /* ConnectionSettingsView.swift in Sources */, DA7F002D2EB376CF00DE943A /* ServerCertificatesView.swift in Sources */, @@ -1829,9 +1827,7 @@ DABED17D2E4516B4000B92EF /* BonjourDiscoveryViewModel.swift in Sources */, DA94AEB42EC896BE003BB3C8 /* SimpleMJPEGPlayer.swift in Sources */, DA48001A2D83742A009CF127 /* DebugSettingsView.swift in Sources */, - 938BF9D324EFD0B700E6B52F /* UIViewController+Localization.swift in Sources */, 2F08AFC72E5FADCF00E70611 /* NotificationCenterDelegateImpl.swift in Sources */, - 938EDCE122C4FEB800661CA1 /* ScaleAspectFitImageView.swift in Sources */, DA2AEBA02D92FB6500897D80 /* NoIconDisplayableCell.swift in Sources */, DA4800162D836EF0009CF127 /* MainUISettingsView.swift in Sources */, DAEA21DE2DBF481300D54342 /* TextRowView.swift in Sources */, @@ -1840,14 +1836,10 @@ 2F55E7BB2DEE447700EC8350 /* SettingsView.swift in Sources */, DA4800182D837221009CF127 /* AboutSettingsView.swift in Sources */, DAEA21E02DBF483E00D54342 /* GenericRowView.swift in Sources */, - 653B54C2285E714900298ECD /* OpenHABViewController.swift in Sources */, - 65C2EF492E244C8500A0C19F /* OpenHABNavigationController.swift in Sources */, DA2AEB6E2D92BAD800897D80 /* PageLoader.swift in Sources */, 65F055442E3D4E41004E98FE /* ItemSelectionView.swift in Sources */, DAF5AA682E4F3A39004F18D7 /* EmbeddingRowView.swift in Sources */, - DFFD8FD118EDBD4F003B502A /* UICircleButton.swift in Sources */, DA48001C2D837556009CF127 /* SitemapSettingsView.swift in Sources */, - 938BF9C624EFCC0700E6B52F /* UILabel+Localization.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/openHAB/AppDelegate.swift b/openHAB/AppDelegate.swift index 751a3a413..d8e5bc89a 100644 --- a/openHAB/AppDelegate.swift +++ b/openHAB/AppDelegate.swift @@ -23,12 +23,7 @@ import UIKit import WatchConnectivity import AsyncAlgorithms -@main class AppDelegate: UIResponder, UIApplicationDelegate { - static var appDelegate: AppDelegate! - - var window: UIWindow? - private var crashlyticsTask: Task? private let notificationDelegate = NotificationCenterDelegateImpl() @@ -48,11 +43,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } } - override init() { - super.init() - AppDelegate.appDelegate = self - } - deinit { crashlyticsTask?.cancel() } @@ -60,36 +50,18 @@ class AppDelegate: UIResponder, UIApplicationDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { Logger.appDelegate.info("didFinishLaunchingWithOptions started") - // Only essential setup here - defer everything else to show UI faster let appDefaults = ["CacheDataAgressively": NSNumber(value: true)] UserDefaults.standard.register(defaults: appDefaults) - - Task { @MainActor in - await Preferences.migratePreferences() - - UNUserNotificationCenter.current().delegate = notificationDelegate - - // Replace storyboard root with SwiftUI TabView - let rootView = OpenHABTabRootView() - let hostingController = UIHostingController(rootView: rootView) - window = UIWindow(frame: UIScreen.main.bounds) - window?.rootViewController = hostingController - window?.makeKeyAndVisible() - - Logger.appDelegate.info("didFinishLaunchingWithOptions ended") - - // Defer non-essential initialization to after first frame renders - // Small delay to ensure UI has appeared - try? await Task.sleep(for: .milliseconds(100)) - performDeferredSetup() - } + UNUserNotificationCenter.current().delegate = notificationDelegate + + Logger.appDelegate.info("didFinishLaunchingWithOptions ended") return true } /// Setup that can be deferred until after the UI appears @MainActor - private func performDeferredSetup() { + func performDeferredSetup() { setupFirebase() registerForPushNotifications() @@ -107,7 +79,11 @@ class AppDelegate: UIResponder, UIApplicationDelegate { configureImageCoders() - /// load and start the screensaver + startScreenSaverMonitoring() + } + + @MainActor + func startScreenSaverMonitoring() { Task { @MainActor in if let keyWindow = UIApplication.shared.firstKeyWindow { let prefs = await Preferences.shared.screensaverPreferences @@ -255,52 +231,6 @@ extension Notification.Name { static let openHABDidReceiveNotification = Notification.Name("openHABDidReceiveNotification") } -extension AppDelegate { - func applicationWillResignActive(_ application: UIApplication) { - // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state. - // Use this method to pause ongoing tasks, disable timers, and throttle down OpenGL ES frame rates. Games should use this method to pause the game. - NotificationCenter.default.post(name: .disableScreenSaver, object: nil) - } - - func applicationDidEnterBackground(_ application: UIApplication) { - // Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later. - // If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits. - } - - func applicationWillEnterForeground(_ application: UIApplication) { - // Called as part of the transition from the background to the inactive state; here you can undo many of the changes made on entering the background. - } - - func applicationDidBecomeActive(_ application: UIApplication) { - // Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface. - Task { @MainActor in - if let keyWindow = UIApplication.shared.firstKeyWindow { - let prefs = await Preferences.shared.screensaverPreferences - var config = ScreenSaverConfiguration() - config.isEnabled = prefs.isEnabled - config.showsTime = prefs.showsTime - config.showsDate = prefs.showsDate - config.idleInterval = prefs.idleInterval - config.movementInterval = prefs.movementInterval - config.fontName = prefs.fontName.isEmpty ? nil : prefs.fontName - config.timeFontSizeRatio = CGFloat(prefs.timeFontRatio) - config.dateFontRelativeSize = CGFloat(prefs.dateFontRatio) - config.enablesAutoDimming = prefs.enableDimming - config.dimLevel = CGFloat(prefs.dimLevel) - config.wakeBrightnessLevel = CGFloat(prefs.wakeBrightness) - config.showsSeconds = prefs.showsSeconds - config.uses24HourTime = prefs.use24Hour - config.restoresBrightness = prefs.restoreBrightness - - ScreenSaverManager.shared.startMonitoring(window: keyWindow, configuration: config) - } - } - } - - func applicationWillTerminate(_ application: UIApplication) { - // Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:. - } -} extension AppDelegate: MessagingDelegate { nonisolated func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) { diff --git a/openHAB/CertificateManagementService.swift b/openHAB/CertificateManagementService.swift new file mode 100644 index 000000000..4f6fc9ae5 --- /dev/null +++ b/openHAB/CertificateManagementService.swift @@ -0,0 +1,241 @@ +// Copyright (c) 2010-2026 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 Foundation +import OpenHABCore +import SwiftUI + +/// Service for managing certificate-related alerts and user interactions in SwiftUI +@MainActor +@Observable +class CertificateManagementService { + static let shared = CertificateManagementService() + + // MARK: - Alert Types + + struct ServerCertificateAlert: Identifiable { + let id = UUID() + let title: String + let message: String + let completion: (ServerCertificateManager.EvaluateResult) -> Void + } + + struct ClientCertificateImportAlert: Identifiable { + let id = UUID() + let title: String + let message: String + let completion: (Bool) -> Void + } + + struct ClientCertificatePasswordAlert: Identifiable { + let id = UUID() + let title: String + let message: String + var password: String = "" + let completion: (String?) -> Void + } + + struct ClientCertificateErrorAlert: Identifiable { + let id = UUID() + let title: String + let message: String + } + + // MARK: - Published Alert States + + var serverCertificateAlert: ServerCertificateAlert? + var clientCertificateImportAlert: ClientCertificateImportAlert? + var clientCertificatePasswordAlert: ClientCertificatePasswordAlert? + var clientCertificateErrorAlert: ClientCertificateErrorAlert? + + private init() { + setupCertificateManagers() + } + + private func setupCertificateManagers() { + CertificateManagers.clientCertificateManager.delegate = self + CertificateManagers.serverCertificateManager.delegate = self + } +} + +// MARK: - ServerCertificateManagerDelegate + +extension CertificateManagementService: ServerCertificateManagerDelegate { + func evaluateServerTrust(summary certificateSummary: String?, forDomain domain: String?) async -> ServerCertificateManager.EvaluateResult { + await withCheckedContinuation { continuation in + let title = NSLocalizedString("ssl_certificate_warning", comment: "") + let message = String(format: NSLocalizedString("ssl_certificate_invalid", comment: ""), + certificateSummary ?? "", domain ?? "") + + serverCertificateAlert = ServerCertificateAlert( + title: title, + message: message + ) { result in + continuation.resume(returning: result) + self.serverCertificateAlert = nil + } + } + } + + func evaluateCertificateMismatch(summary certificateSummary: String?, forDomain domain: String?) async -> ServerCertificateManager.EvaluateResult { + await withCheckedContinuation { continuation in + let title = NSLocalizedString("ssl_certificate_warning", comment: "") + let message = String(format: NSLocalizedString("ssl_certificate_no_match", comment: ""), + certificateSummary ?? "", domain ?? "") + + serverCertificateAlert = ServerCertificateAlert( + title: title, + message: message + ) { result in + continuation.resume(returning: result) + self.serverCertificateAlert = nil + } + } + } + + func acceptedServerCertificatesChanged() { + // User's decision about trusting server certificates has changed + // Send updates to the paired watch + Task { + await WatchMessageService.singleton.syncPreferencesToWatch() + } + } +} + +// MARK: - ClientCertificateManagerDelegate + +extension CertificateManagementService: ClientCertificateManagerDelegate { + func askForClientCertificateImport(_ clientCertificateManager: ClientCertificateManager?) async -> Bool { + await withCheckedContinuation { continuation in + let title = NSLocalizedString("certificate_import_title", comment: "") + let message = NSLocalizedString("certificate_import_text", comment: "") + + clientCertificateImportAlert = ClientCertificateImportAlert( + title: title, + message: message + ) { shouldImport in + if shouldImport { + Task { + await clientCertificateManager?.clientCertificateAccepted(password: nil) + } + } else { + clientCertificateManager?.clientCertificateRejected() + } + continuation.resume(returning: shouldImport) + self.clientCertificateImportAlert = nil + } + } + } + + func askForCertificatePassword(_ clientCertificateManager: ClientCertificateManager?) async -> String? { + await withCheckedContinuation { continuation in + let title = NSLocalizedString("certificate_import_title", comment: "") + let message = NSLocalizedString("certificate_import_password", comment: "") + + clientCertificatePasswordAlert = ClientCertificatePasswordAlert( + title: title, + message: message + ) { password in + continuation.resume(returning: password) + self.clientCertificatePasswordAlert = nil + } + } + } + + func alertClientCertificateError(_ clientCertificateManager: ClientCertificateManager?, errMsg: String) async { + let title = NSLocalizedString("certificate_import_title", comment: "") + clientCertificateErrorAlert = ClientCertificateErrorAlert( + title: title, + message: errMsg + ) + } +} + +// MARK: - SwiftUI Alert View Modifiers + +extension View { + /// Adds certificate management alerts to a view + func certificateManagementAlerts() -> some View { + modifier(CertificateManagementAlertsModifier()) + } +} + +struct CertificateManagementAlertsModifier: ViewModifier { + @State private var certificateService = CertificateManagementService.shared + + func body(content: Content) -> some View { + content + // Server Certificate Alert + .alert(item: $certificateService.serverCertificateAlert) { alert in + Alert( + title: Text(alert.title), + message: Text(alert.message), + primaryButton: .default(Text(NSLocalizedString("always", comment: ""))) { + alert.completion(.permitAlways) + }, + secondaryButton: .cancel(Text(NSLocalizedString("abort", comment: ""))) { + alert.completion(.deny) + } + ) + } + // Client Certificate Import Alert + .alert(item: $certificateService.clientCertificateImportAlert) { alert in + Alert( + title: Text(alert.title), + message: Text(alert.message), + primaryButton: .default(Text(NSLocalizedString("okay", comment: ""))) { + alert.completion(true) + }, + secondaryButton: .cancel(Text(NSLocalizedString("cancel", comment: ""))) { + alert.completion(false) + } + ) + } + // Client Certificate Password Alert + .alert( + certificateService.clientCertificatePasswordAlert?.title ?? "", + isPresented: Binding( + get: { certificateService.clientCertificatePasswordAlert != nil }, + set: { if !$0 { + certificateService.clientCertificatePasswordAlert?.completion(nil) + certificateService.clientCertificatePasswordAlert = nil + }} + ) + ) { + if let alert = certificateService.clientCertificatePasswordAlert { + SecureField(NSLocalizedString("password", comment: ""), text: Binding( + get: { alert.password }, + set: { certificateService.clientCertificatePasswordAlert?.password = $0 } + )) + + Button(NSLocalizedString("okay", comment: "")) { + alert.completion(alert.password.isEmpty ? nil : alert.password) + } + + Button(NSLocalizedString("cancel", comment: ""), role: .cancel) { + alert.completion(nil) + } + } + } message: { + if let alert = certificateService.clientCertificatePasswordAlert { + Text(alert.message) + } + } + // Client Certificate Error Alert + .alert(item: $certificateService.clientCertificateErrorAlert) { alert in + Alert( + title: Text(alert.title), + message: Text(alert.message), + dismissButton: .default(Text(NSLocalizedString("okay", comment: ""))) + ) + } + } +} diff --git a/openHAB/IdleTimerService.swift b/openHAB/IdleTimerService.swift new file mode 100644 index 000000000..ff8bef9d0 --- /dev/null +++ b/openHAB/IdleTimerService.swift @@ -0,0 +1,80 @@ +// Copyright (c) 2010-2026 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 Combine +import OpenHABCore +import SwiftUI +import UIKit +import AsyncAlgorithms + +/// Service for managing the device's idle timer based on user preferences +@MainActor +class IdleTimerService { + static let shared = IdleTimerService() + + private var cancellables = Set() + + private init() { + observePreferences() + observeAppLifecycle() + } + + private func observePreferences() { + // Observe changes to idle timer preference + Task { + let channel = await Preferences.shared.applicationPreferencesChannel + for await preferences in channel { + configure(idleOff: preferences.idleOff) + } + } + } + + private func observeAppLifecycle() { + // Disable idle timer when entering background + NotificationCenter.default.publisher(for: UIApplication.didEnterBackgroundNotification) + .sink { [weak self] _ in + self?.disableIdleTimer() + } + .store(in: &cancellables) + + // Re-enable based on preferences when becoming active + NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification) + .sink { [weak self] _ in + Task { @MainActor in + let preferences = await Preferences.shared.applicationPreferences + self?.configure(idleOff: preferences.idleOff) + } + } + .store(in: &cancellables) + } + + /// Configure the idle timer based on user preference + func configure(idleOff: Bool) { + UIApplication.shared.isIdleTimerDisabled = idleOff + } + + /// Disable the idle timer (used when entering background) + func disableIdleTimer() { + UIApplication.shared.isIdleTimerDisabled = false + } +} + +// MARK: - SwiftUI View Extension + +extension View { + /// Automatically manages the idle timer based on user preferences + func idleTimerManagement() -> some View { + self.onAppear { + // Initialize the service + _ = IdleTimerService.shared + } + } +} diff --git a/openHAB/Main.storyboard b/openHAB/Main.storyboard deleted file mode 100644 index 1ae4c2444..000000000 --- a/openHAB/Main.storyboard +++ /dev/null @@ -1,90 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/openHAB/OpenHABApp.swift b/openHAB/OpenHABApp.swift new file mode 100644 index 000000000..051905f0e --- /dev/null +++ b/openHAB/OpenHABApp.swift @@ -0,0 +1,47 @@ +// Copyright (c) 2010-2026 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 OpenHABCore +import SwiftUI + +@main +struct OpenHABApp: App { + @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate + @Environment(\.scenePhase) private var scenePhase + @State private var isMigrationComplete = false + + var body: some Scene { + WindowGroup { + if isMigrationComplete { + OpenHABTabRootView() + } else { + SplashView() + .task { + await Preferences.migratePreferences() + isMigrationComplete = true + // Defer non-essential initialization to after first frame renders + try? await Task.sleep(for: .milliseconds(100)) + appDelegate.performDeferredSetup() + } + } + } + .onChange(of: scenePhase) { _, newPhase in + switch newPhase { + case .inactive: + NotificationCenter.default.post(name: .disableScreenSaver, object: nil) + case .active: + appDelegate.startScreenSaverMonitoring() + default: + break + } + } + } +} diff --git a/openHAB/SwiftUI/RootView/MainWebTab.swift b/openHAB/SwiftUI/RootView/MainWebTab.swift index d7822b57d..c91760502 100644 --- a/openHAB/SwiftUI/RootView/MainWebTab.swift +++ b/openHAB/SwiftUI/RootView/MainWebTab.swift @@ -12,12 +12,10 @@ import OpenHABCore import SwiftUI -struct MainWebTab: UIViewControllerRepresentable { - let webViewController: OpenHABWebViewController - - func makeUIViewController(context: Context) -> OpenHABWebViewController { - webViewController +struct MainWebTab: View { + let viewModel: OpenHABWebViewModel + + var body: some View { + OpenHABWebView(viewModel: viewModel) } - - func updateUIViewController(_ uiViewController: OpenHABWebViewController, context: Context) {} } diff --git a/openHAB/SwiftUI/RootView/OpenHABTabRootView.swift b/openHAB/SwiftUI/RootView/OpenHABTabRootView.swift index 80a8259cd..7d028535c 100644 --- a/openHAB/SwiftUI/RootView/OpenHABTabRootView.swift +++ b/openHAB/SwiftUI/RootView/OpenHABTabRootView.swift @@ -54,7 +54,7 @@ struct OpenHABTabRootView: View { @State private var systemResetTrigger = 0 @State private var sitemapNavigationCommand: SitemapNavigationCommand? - private let webViewController = OpenHABWebViewController() + private let webViewModel = OpenHABWebViewModel() private var tabSelectionBinding: Binding { Binding( @@ -180,14 +180,16 @@ struct OpenHABTabRootView: View { } message: { Text(NSLocalizedString("crash_reporting_info", comment: "")) } + .certificateManagementAlerts() // Handles certificates + .idleTimerManagement() // Manages idle timer + .statusBar(hidden: Preferences.shared.applicationPreferences.hideStatusBar) // Replace navigation controller } @ViewBuilder private func tabContentView(for tab: AppTab) -> some View { switch tab { case .main: - MainWebTab(webViewController: webViewController) - .ignoresSafeArea() + MainWebTab(viewModel: webViewModel) case .sitemaps: SitemapsTab(resetTrigger: sitemapsResetTrigger, navigationCommand: $sitemapNavigationCommand) case .tiles: @@ -200,7 +202,9 @@ struct OpenHABTabRootView: View { private func resetTab(_ tab: AppTab) { switch tab { case .main: - webViewController.loadWebView(force: true) + Task { + await webViewModel.loadWebView(force: true) + } case .sitemaps: sitemapsResetTrigger += 1 case .tiles: @@ -215,10 +219,12 @@ struct OpenHABTabRootView: View { case let .switchToWebView(path): selectedTab = .main if let path { - if path.starts(with: "/") { - webViewController.loadWebView(force: true, path: path) - } else { - webViewController.navigateCommand(path) + Task { + if path.starts(with: "/") { + await webViewModel.loadWebView(force: true, path: path) + } else { + webViewModel.navigateCommand(path) + } } } case let .switchToSitemap(name, widgetId): diff --git a/openHAB/SwiftUI/Rows/WebRowView.swift b/openHAB/SwiftUI/Rows/WebRowView.swift index 0380dd099..65405f639 100644 --- a/openHAB/SwiftUI/Rows/WebRowView.swift +++ b/openHAB/SwiftUI/Rows/WebRowView.swift @@ -41,42 +41,32 @@ struct WidgetWebViewContainer: View { } } -struct WebRowView: UIViewRepresentable { - class Coordinator: NSObject, WKNavigationDelegate { - private let logger = Logger(subsystem: "org.openhab", category: "WebRowViewCoordinator") - - func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: any Error) { - logger.debug("WebView failed to load: \(error.localizedDescription)") - } - - func webView(_ webView: WKWebView, - respondTo challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { - await onReceiveSessionChallenge(with: challenge) - } - } - +struct WebRowView: View { @ObservedObject var widget: OpenHABWidget @EnvironmentObject var viewModel: SitemapPageViewModel - + @State private var page = WebPage() + private var webURL: URL? { guard !widget.url.isEmpty else { return nil } return URL(string: widget.url) } - func makeUIView(context: Context) -> WKWebView { - let webView = WKWebView() - webView.navigationDelegate = context.coordinator - return webView - } - - func updateUIView(_ webView: WKWebView, context: Context) { - if let webURL { - let request = URLRequest(url: webURL) - webView.load(request) - } - } - - func makeCoordinator() -> Coordinator { - Coordinator() + var body: some View { + WebView(page) + .webViewBackForwardNavigationGestures(.disabled) + .webViewMagnificationGestures(.enabled) + .webViewTextSelection(.enabled) + .onAppear { + if let webURL { + let request = URLRequest(url: webURL) + let _ = page.load(request) + } + } + .onChange(of: widget.url) { _, newURL in + if let url = URL(string: newURL) { + let request = URLRequest(url: url) + let _ = page.load(request) + } + } } } diff --git a/openHAB/SwiftUI/SettingsView/SettingsView.swift b/openHAB/SwiftUI/SettingsView/SettingsView.swift index f779a5812..254878026 100644 --- a/openHAB/SwiftUI/SettingsView/SettingsView.swift +++ b/openHAB/SwiftUI/SettingsView/SettingsView.swift @@ -148,6 +148,7 @@ struct SettingsView: View { } } .onChange(of: currentSnapshot) { _, newSnapshot in + guard let initialSnapshot else { return } withAnimation { isDirty = newSnapshot != initialSnapshot } diff --git a/openHAB/SwiftUI/SitemapView/OpenHABWebView.swift b/openHAB/SwiftUI/SitemapView/OpenHABWebView.swift new file mode 100644 index 000000000..eaa9b36f9 --- /dev/null +++ b/openHAB/SwiftUI/SitemapView/OpenHABWebView.swift @@ -0,0 +1,418 @@ +// Copyright (c) 2010-2026 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 Combine +import OpenHABCore +import os.log +import SafariServices +import SwiftUI +import SwiftMessages +import WebKit + +@MainActor +@Observable +class OpenHABWebViewModel { + private var currentTarget = "" + private var openHABTrackedRootUrl = "" + private var activeConnectionInfo: ConnectionInfo? + private var activeConfig: ConnectionConfiguration? { activeConnectionInfo?.configuration } + + var page = WebPage() + var pageConfiguration = WebPage.Configuration() + var hideNavigationBar = false + var isLoading = false + var commandQueue: [String] = [] + var acceptsCommands = false + var etagChecker: ETagChecker? + var etagCheckerConfigURL: String? + var lastLoadedURL: String? + var currentPath: String = "" + + private var trackerCancellables = Set() + private var sseTimer: Timer? + + private var js = """ + (function() { + // Main UI Callbacks + window.OHApp = { + exitToApp : function(){ + window.webkit.messageHandlers.mainUi.postMessage('exitToApp'); + }, + goFullscreen : function(){ + window.webkit.messageHandlers.mainUi.postMessage('goFullscreen'); + }, + sseConnected : function(connected) { + window.webkit.messageHandlers.mainUi.postMessage('sseConnected-' + connected); + }, + ready : function() { + window.webkit.messageHandlers.mainUi.postMessage('ready'); + }, + } + + // Detect Path changes in SPA + function notifyPathChange() { + window.webkit.messageHandlers.pathChanged.postMessage(window.location.pathname); + } + + const originalPushState = history.pushState; + history.pushState = function() { + originalPushState.apply(this, arguments); + notifyPathChange(); + }; + + const originalReplaceState = history.replaceState; + history.replaceState = function() { + originalReplaceState.apply(this, arguments); + notifyPathChange(); + }; + + window.addEventListener('popstate', notifyPathChange); + + // Notify initial path on load + notifyPathChange(); + })(); + """ + + init() { + setupWebPage() + observeNetworkChanges() + observeAppLifecycle() + } + + private func setupWebPage() { + pageConfiguration.loadsSubresources = true + pageConfiguration.defaultNavigationPreferences.allowsContentJavaScript = true + + // Use default data store for persistence + pageConfiguration.websiteDataStore = .default() + + page = WebPage(configuration: pageConfiguration, navigationDecider: WebNavigationDecider(viewModel: self)) + + // Set custom user agent for iPad + if UIDevice.current.userInterfaceIdiom == .pad { + page.customUserAgent = "Mozilla/5.0 (iPad; CPU OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1" + } + } + + private func observeNetworkChanges() { + MainActorNetworkTracker.shared.$activeConnection + .receive(on: DispatchQueue.main) + .sink { [weak self] activeConnection in + guard let self else { return } + if let activeConnection { + let activeConfiguration = activeConnection.configuration + Logger.viewController.info("OpenHABWebView openHAB URL = \(activeConfiguration.url)") + self.openHABTrackedRootUrl = activeConfiguration.url + self.activeConnectionInfo = activeConnection + Task { + await self.loadWebView(force: false) + } + } + } + .store(in: &trackerCancellables) + } + + private func observeAppLifecycle() { + NotificationCenter.default.addObserver( + forName: UIApplication.didBecomeActiveNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Logger.viewController.info("App became active, checking for content updates") + Task { + await self?.loadWebView(force: false) + } + } + } + + func loadWebView(force: Bool = false, path: String? = nil) async { + Logger.viewController.info("loadWebView tracked URL: \(self.activeConfig?.url ?? "") forced \(force ? "true" : "false")") + guard let activeConfig else { return } + + let authStr = "\(activeConfig.username):\(activeConfig.password)" + let newTarget = "\(activeConfig.url):\(authStr)" + + if force { + await performLoadWebView(newTarget: newTarget, path: path, force: true) + return + } + + await loadWebViewWithETagCheck(newTarget: newTarget, path: path) + } + + private func performLoadWebView(newTarget: String, path: String?, force: Bool) async { + guard let activeConfig else { return } + currentTarget = newTarget + + guard let url = URL(string: activeConfig.url), + let modifiedUrl = await modifyUrl(orig: url, path: path) else { return } + + acceptsCommands = false + var request = URLRequest(url: modifiedUrl) + + if force { + // Clear cache for force reload + let dataStore = pageConfiguration.websiteDataStore + let websiteDataTypes: Set = [WKWebsiteDataTypeDiskCache, WKWebsiteDataTypeMemoryCache] + let date = Date(timeIntervalSince1970: 0) + + Logger.viewController.info("Force reload: clearing WebView cache") + await dataStore.removeData(ofTypes: websiteDataTypes, modifiedSince: date) + request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData + } + + Logger.viewController.info("Loading URL: \(modifiedUrl)") + isLoading = true + let _ = page.load(request) + } + + private func loadWebViewWithETagCheck(newTarget: String, path: String?) async { + guard let activeConfig, + let url = URL(string: activeConfig.url), + let fullURL = await modifyUrl(orig: url, path: path) else { + Logger.viewController.info("ETag check skipped: invalid configuration") + await performLoadWebView(newTarget: newTarget, path: path, force: false) + return + } + + let configKey = "\(activeConfig.url):\(activeConfig.username)" + if etagChecker == nil || etagCheckerConfigURL != configKey { + let httpClient = HTTPClient(baseURL: nil, connectionConfiguration: activeConfig) + etagChecker = ETagChecker(httpClient: httpClient) + etagCheckerConfigURL = configKey + Logger.viewController.debug("Created new ETagChecker for config: \(configKey)") + } + + guard let checker = etagChecker else { + await performLoadWebView(newTarget: newTarget, path: path, force: false) + return + } + + let result = await checker.checkIfChanged(url: fullURL) + + switch result { + case .unchanged: + let normalizedTarget = normalizeURLForComparison(fullURL.absoluteString, includeBasePath: false) + let normalizedLoaded = normalizeURLForComparison(lastLoadedURL, includeBasePath: false) + + Logger.viewController.debug("ETag unchanged - comparing base URLs: loaded=\(normalizedLoaded ?? "nil") vs target=\(normalizedTarget ?? "nil")") + + if let normalizedTarget, let normalizedLoaded, normalizedLoaded == normalizedTarget { + Logger.viewController.info("ETag unchanged and same base URL, skipping load") + currentTarget = newTarget + isLoading = false + } else { + Logger.viewController.info("ETag unchanged but different base URL, loading \(fullURL.absoluteString)") + await performLoadWebView(newTarget: newTarget, path: path, force: false) + } + + case .changed: + Logger.viewController.info("ETag changed, loading \(fullURL.absoluteString)") + await performLoadWebView(newTarget: newTarget, path: path, force: false) + + case let .failed(error): + Logger.viewController.info("ETag check failed: \(error.localizedDescription), loading anyway") + await performLoadWebView(newTarget: newTarget, path: path, force: false) + } + } + + private func normalizeURLForComparison(_ urlString: String?, includeBasePath: Bool = false) -> String? { + guard let urlString, let url = URL(string: urlString) else { return nil } + + var components = URLComponents(url: url, resolvingAgainstBaseURL: false) + components?.fragment = nil + + if !includeBasePath { + components?.path = "" + components?.query = nil + } + + guard var normalized = components?.url?.absoluteString else { return nil } + + if normalized.hasSuffix("/") { + normalized = String(normalized.dropLast()) + } + + return normalized + } + + func modifyUrl(orig: URL?, path: String? = nil) async -> URL? { + guard let urlString = orig?.absoluteString, var url = URL(string: urlString) else { return orig } + + if let proxyURL = activeConnectionInfo?.proxyURL { + url = proxyURL + } + + if let path { + url = appendPathToURL(baseURL: url, path: path) ?? url + } else if await !Preferences.shared.currentHomePreferences.defaultMainUIPath.isEmpty { + url = appendPathToURL(baseURL: url, path: await Preferences.shared.currentHomePreferences.defaultMainUIPath) ?? url + } + + return url + } + + func appendPathToURL(baseURL: URL, path: String) -> URL? { + guard var urlComponents = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) else { + return nil + } + + if let questionMarkRange = path.range(of: "?") { + let pathComponent = String(path[.. WebPage.NavigationPreferences? { + guard let url = navigationAction.request.url else { return nil } + Logger.viewController.info("decidePolicyFor - url: \(url.absoluteString)") + + // Handle link activation (open in Safari) + if navigationAction.navigationType == .linkActivated { + await UIApplication.shared.open(url) + return nil // Cancel navigation in WebView + } + + // Allow navigation with JavaScript enabled + var preferences = WebPage.NavigationPreferences() + preferences.allowsContentJavaScript = true + return preferences + } + + @MainActor + func decidePolicyFor(navigationResponse: WebPage.NavigationResponse) async -> Bool { + if let response = navigationResponse.response as? HTTPURLResponse { + Logger.viewController.info("navigationResponse: \(response.statusCode)") + return response.statusCode < 400 + } + return true + } +} + +struct OpenHABWebView: View { + @State var viewModel: OpenHABWebViewModel + + var body: some View { + ZStack { + WebView(viewModel.page) + .webViewBackForwardNavigationGestures(.disabled) + .webViewMagnificationGestures(.enabled) + .webViewTextSelection(.enabled) + //.webViewContentBackground(.color(.systemBackground)) + + if viewModel.isLoading { + ProgressView() + .controlSize(.large) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.black.opacity(0.1)) + } + } + .toolbar(viewModel.hideNavigationBar ? .hidden : .visible, for: .navigationBar) + .onChange(of: viewModel.page.isLoading) { _, newValue in + handleLoadingStateChange(newValue) + } + .onAppear { + Task { + await viewModel.loadWebView(force: false) + } + } + } + + private func handleLoadingStateChange(_ isLoading: Bool) { + viewModel.isLoading = isLoading + + if isLoading { + viewModel.hideNavigationBar = false + } else { + viewModel.hideNavigationBar = true + viewModel.acceptsCommands = true + + // Track the loaded URL + if let url = viewModel.page.url { + viewModel.lastLoadedURL = url.absoluteString + viewModel.handlePathChanged(url.path) + } + } + } +} diff --git a/openHAB/SwiftUI/SplashView.swift b/openHAB/SwiftUI/SplashView.swift new file mode 100644 index 000000000..017f03d8a --- /dev/null +++ b/openHAB/SwiftUI/SplashView.swift @@ -0,0 +1,26 @@ +// Copyright (c) 2010-2026 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 SwiftUI + +/// Lightweight splash screen shown while preferences migration runs, +/// matching the launch screen appearance. +struct SplashView: View { + var body: some View { + Image("launchImage") + .resizable() + .aspectRatio(contentMode: .fit) + .padding() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.white) + .ignoresSafeArea() + } +} diff --git a/openHAB/UIKIT_MIGRATION_ANALYSIS.md b/openHAB/UIKIT_MIGRATION_ANALYSIS.md new file mode 100644 index 000000000..07c69a39e --- /dev/null +++ b/openHAB/UIKIT_MIGRATION_ANALYSIS.md @@ -0,0 +1,277 @@ +# UIKit to SwiftUI Migration Analysis + +## Current State + +The openHAB iOS app has already undergone significant SwiftUI migration: + +### ✅ Already Migrated to SwiftUI +1. **Root View** - `OpenHABTabRootView` is now the app's root view (set in `AppDelegate`) +2. **Web Views** - Migrated from `OpenHABWebViewController` to `OpenHABWebView` (SwiftUI) +3. **Tab Navigation** - Using SwiftUI `TabView` with `.sidebarAdaptable` style + +### 📋 Remaining UIKit Components + +#### 1. `OpenHABViewController` (OpenHABViewController.swift) +**Status**: Can be removed with refactoring + +**Current Purpose**: +- Base class providing common functionality +- Certificate management delegation +- Popup message display (SwiftMessages) +- Idle timer management +- Protocol: `OpenHABViewable` + +**Dependencies**: None (no longer has subclasses after WebView migration) + +**Migration Path**: +- ✅ Certificate management → Move to SwiftUI environment or dedicated service +- ✅ Popup messages → Replace with SwiftUI alerts/toasts +- ✅ Idle timer → Move to app-level service +- ✅ Protocol methods → Can be replaced with SwiftUI patterns + +#### 2. `OpenHABNavigationController` (OpenHABNavigationController.swift) +**Status**: Can be removed + +**Current Purpose**: +- Wrapper around `UINavigationController` +- Controls status bar visibility based on preferences + +**Why It Can Be Removed**: +- The app now uses SwiftUI's `NavigationStack` and `TabView` +- No longer instantiated in `AppDelegate` (replaced with `UIHostingController`) +- Status bar control can be done via SwiftUI modifiers + +**Migration Path**: +- Use SwiftUI's `.statusBar(hidden:)` modifier +- Observe preferences and apply modifier at root level + +## Recommended Migration Plan + +### Phase 1: Extract Shared Services (Immediate) + +Create SwiftUI-compatible services to replace `OpenHABViewController` functionality: + +#### 1. **CertificateManagementService** +```swift +@MainActor +@Observable +class CertificateManagementService { + static let shared = CertificateManagementService() + + var serverCertificateAlert: CertificateAlert? + var clientCertificateAlert: CertificateAlert? + + init() { + CertificateManagers.clientCertificateManager.delegate = self + CertificateManagers.serverCertificateManager.delegate = self + } +} + +// Use with SwiftUI alerts +.alert(item: $certificateService.serverCertificateAlert) { alert in + // Display alert +} +``` + +#### 2. **PopupMessageService** +```swift +@MainActor +@Observable +class PopupMessageService { + static let shared = PopupMessageService() + + var currentMessage: PopupMessage? + + func show(title: String, message: String, duration: TimeInterval) { + currentMessage = PopupMessage(title: title, message: message, duration: duration) + } +} + +// Use with SwiftUI overlays +.overlay { + if let message = messageService.currentMessage { + PopupMessageView(message: message) + } +} +``` + +#### 3. **IdleTimerService** +```swift +@MainActor +class IdleTimerService { + static let shared = IdleTimerService() + + func configure(idleOff: Bool) { + UIApplication.shared.isIdleTimerDisabled = idleOff + } +} +``` + +### Phase 2: Remove UIKit Components + +#### Step 1: Remove `OpenHABNavigationController` +- Already unused in the app +- Safe to delete immediately + +**Files to delete:** +- `OpenHABNavigationController.swift` + +**Add to root SwiftUI view:** +```swift +OpenHABTabRootView() + .statusBar(hidden: Preferences.shared.applicationPreferences.hideStatusBar) +``` + +#### Step 2: Migrate Certificate Management +- Move delegate implementations to new `CertificateManagementService` +- Use SwiftUI `.alert()` modifiers instead of UIAlertController +- Add to `OpenHABTabRootView` environment + +#### Step 3: Replace Popup Messages +- Option A: Create SwiftUI toast/banner view +- Option B: Keep SwiftMessages but wrap in a SwiftUI-friendly service +- Option C: Use native SwiftUI alerts/sheets + +#### Step 4: Remove `OpenHABViewController` +- After migrating all functionality to services +- Remove `OpenHABViewable` protocol + +**Files to delete:** +- `OpenHABViewController.swift` + +### Phase 3: Status Bar Handling + +Replace `OpenHABNavigationController` status bar logic with SwiftUI: + +```swift +// In OpenHABTabRootView or App root +@StateObject private var preferences = PreferencesObserver.shared + +var body: some View { + TabView { + // ... tabs + } + .statusBar(hidden: preferences.applicationPreferences.hideStatusBar) + .statusBarAnimation(.fade) +} +``` + +## Implementation Example + +Here's how the certificate management could look in pure SwiftUI: + +```swift +// CertificateManagementService.swift +@MainActor +@Observable +class CertificateManagementService { + static let shared = CertificateManagementService() + + struct CertificateAlert: Identifiable { + let id = UUID() + let title: String + let message: String + let completion: (EvaluateResult) -> Void + } + + var currentAlert: CertificateAlert? + + private init() { + CertificateManagers.serverCertificateManager.delegate = self + CertificateManagers.clientCertificateManager.delegate = self + } +} + +extension CertificateManagementService: ServerCertificateManagerDelegate { + func evaluateServerTrust(summary certificateSummary: String?, forDomain domain: String?) async -> ServerCertificateManager.EvaluateResult { + await withCheckedContinuation { continuation in + let title = NSLocalizedString("ssl_certificate_warning", comment: "") + let message = String(format: NSLocalizedString("ssl_certificate_invalid", comment: ""), + certificateSummary ?? "", domain ?? "") + + currentAlert = CertificateAlert(title: title, message: message) { result in + continuation.resume(returning: result) + } + } + } + + // ... other delegate methods +} + +// In OpenHABTabRootView: +.alert(item: $certificateService.currentAlert) { alert in + Alert( + title: Text(alert.title), + message: Text(alert.message), + primaryButton: .default(Text("Always")) { + alert.completion(.permitAlways) + }, + secondaryButton: .cancel(Text("Deny")) { + alert.completion(.deny) + } + ) +} +``` + +## Benefits of Complete Migration + +1. **Simplified Codebase** + - Remove ~200 lines of UIKit boilerplate + - No more UIKit bridging + - Unified SwiftUI architecture + +2. **Better Maintainability** + - Single UI paradigm throughout app + - Easier to understand and modify + - Modern Swift patterns + +3. **Future-Proof** + - Built on Apple's latest frameworks + - Better platform integration + - Easier to adopt new iOS features + +4. **Performance** + - Native SwiftUI rendering + - Reduced bridging overhead + - Better memory management + +## Testing Checklist + +After migration, test: + +- ✅ Server certificate warnings display correctly +- ✅ Client certificate import flow works +- ✅ Status bar shows/hides based on preferences +- ✅ Idle timer behavior is correct +- ✅ Popup messages display properly +- ✅ App lifecycle events (background/foreground) work +- ✅ Watch connectivity still functions + +## Files to Delete + +Once migration is complete: + +1. `OpenHABViewController.swift` +2. `OpenHABNavigationController.swift` +3. `OpenHABWebViewController.swift` (already removed) + +## Estimated Effort + +- **Phase 1 (Services)**: 2-3 hours +- **Phase 2 (Remove UIKit)**: 1-2 hours +- **Phase 3 (Status Bar)**: 30 minutes +- **Testing**: 2-3 hours + +**Total**: ~6-9 hours + +## Conclusion + +**Yes, we can remove both `OpenHABViewController` and `OpenHABNavigationController`!** + +The migration is straightforward because: +1. No active subclasses of `OpenHABViewController` remain +2. `OpenHABNavigationController` is already unused +3. All functionality can be migrated to SwiftUI-native patterns +4. The app is already primarily SwiftUI-based + +The main work involves creating service classes to handle certificate management, popup messages, and idle timer functionality in a SwiftUI-compatible way. diff --git a/openHAB/UIKIT_REMOVAL_GUIDE.md b/openHAB/UIKIT_REMOVAL_GUIDE.md new file mode 100644 index 000000000..f23080692 --- /dev/null +++ b/openHAB/UIKIT_REMOVAL_GUIDE.md @@ -0,0 +1,230 @@ +# Complete UIKit Removal - Implementation Guide + +## Overview + +This guide shows how to complete the migration from UIKit to pure SwiftUI by removing `OpenHABViewController` and `OpenHABNavigationController`. + +## What's Been Created + +### 1. **CertificateManagementService.swift** ✅ +A complete SwiftUI-native service that handles all certificate management: +- Server certificate trust evaluation +- Client certificate import +- Password prompts for PKCS#12 +- Error alerts +- SwiftUI alert modifiers + +### 2. **IdleTimerService.swift** ✅ +A service that manages the device idle timer: +- Observes user preferences +- Handles app lifecycle events +- Provides SwiftUI view modifier + +## Implementation Steps + +### Step 1: Update OpenHABTabRootView + +Add the certificate management alerts and idle timer management to your root view: + +```swift +// In OpenHABTabRootView.swift + +var body: some View { + TabView(selection: tabSelectionBinding) { + // ... existing tab content + } + .tabViewStyle(.sidebarAdaptable) + .tabBarMinimizeBehavior(.onScrollDown) + .environmentObject(networkTracker) + // ADD THESE MODIFIERS: + .certificateManagementAlerts() // Handles all certificate alerts + .idleTimerManagement() // Manages idle timer + .statusBar(hidden: Preferences.shared.applicationPreferences.hideStatusBar) // Replace navigation controller status bar logic + // ... rest of your modifiers +} +``` + +### Step 2: Remove Old Files + +Once Step 1 is complete and tested, safely delete: + +1. **OpenHABViewController.swift** + - All functionality now in services + - No subclasses remain after WebView migration + +2. **OpenHABNavigationController.swift** + - Already unused in AppDelegate + - Status bar control moved to SwiftUI modifier + +3. **OpenHABWebViewController.swift** + - Already removed in WebView migration + +### Step 3: Update Any Remaining References + +Search your project for any lingering references: + +```bash +# Search for OpenHABViewController references +grep -r "OpenHABViewController" . + +# Search for OpenHABNavigationController references +grep -r "OpenHABNavigationController" . + +# Search for OpenHABViewable protocol +grep -r "OpenHABViewable" . +``` + +Remove or update any found references. + +## Testing Checklist + +After implementation, verify: + +### Certificate Management +- [ ] Server certificate warnings appear correctly +- [ ] Can choose "Always", "Once", or "Deny" for server certificates +- [ ] Certificate mismatch warnings display properly +- [ ] Client certificate import prompt works +- [ ] Password prompt for PKCS#12 certificates works +- [ ] Certificate import errors display correctly + +### Idle Timer +- [ ] Screen stays awake when preference is enabled +- [ ] Screen sleeps normally when preference is disabled +- [ ] Idle timer disables when app enters background +- [ ] Idle timer re-enables based on preference when app becomes active + +### Status Bar +- [ ] Status bar hides when preference is enabled +- [ ] Status bar shows when preference is disabled +- [ ] Status bar animates smoothly + +### General +- [ ] App launches successfully +- [ ] All tabs work correctly +- [ ] Navigation works as expected +- [ ] No crashes or memory leaks + +## Code Changes Summary + +### Before (UIKit-based): +```swift +// OpenHABViewController.swift - ~200 lines +class OpenHABViewController: UIViewController, OpenHABViewable { + func reloadView() {} + func showPopupMessage(...) {} + func evaluateServerTrust(...) async -> Result {} + func askForClientCertificateImport(...) async -> Bool {} + // etc... +} + +// OpenHABNavigationController.swift - ~40 lines +class OpenHABNavigationController: UINavigationController { + override var prefersStatusBarHidden: Bool { ... } +} +``` + +### After (SwiftUI-based): +```swift +// CertificateManagementService.swift +@Observable class CertificateManagementService { + var serverCertificateAlert: ServerCertificateAlert? + // Handles all certificate interactions via SwiftUI alerts +} + +// IdleTimerService.swift +class IdleTimerService { + func configure(idleOff: Bool) + // Manages idle timer lifecycle +} + +// In OpenHABTabRootView +.certificateManagementAlerts() +.idleTimerManagement() +.statusBar(hidden: ...) +``` + +## Benefits + +1. **Simpler Architecture** + - No UIViewController inheritance hierarchy + - Direct SwiftUI patterns + - Clear separation of concerns + +2. **Less Code** + - ~240 lines of UIKit code removed + - ~200 lines of SwiftUI service code added + - Net reduction in complexity + +3. **Better Testability** + - Services are isolated and testable + - No UIViewController dependencies + - Observable state for easy mocking + +4. **Modern Swift** + - Swift Concurrency throughout + - @Observable macro + - SwiftUI-native patterns + +5. **Future-Proof** + - Pure SwiftUI app + - Easier to adopt new features + - Better performance + +## Troubleshooting + +### Issue: Certificate alerts not showing +**Solution**: Ensure `.certificateManagementAlerts()` is added to your root view + +### Issue: Idle timer not working +**Solution**: Verify `.idleTimerManagement()` is in your view hierarchy and preferences are set correctly + +### Issue: Status bar not hiding +**Solution**: Check that `.statusBar(hidden:)` modifier is present and preference value is correct + +### Issue: Crash on certificate operation +**Solution**: Verify `CertificateManagementService.shared` is initialized early (it auto-initializes on first access) + +## Migration Rollback Plan + +If issues arise, you can temporarily revert: + +1. Keep the old UIKit files in the project +2. Comment out the new service modifiers +3. Re-enable old code paths +4. Debug and fix issues +5. Re-apply migration + +However, with the provided services and testing checklist, this shouldn't be necessary. + +## Additional Notes + +### SwiftMessages Integration +The old `showPopupMessage` method used SwiftMessages for temporary notifications. If you still need this functionality: + +**Option 1**: Create a SwiftUI toast view +**Option 2**: Use native SwiftUI alerts/sheets +**Option 3**: Keep SwiftMessages and wrap it in a service (similar to certificate service) + +### Navigation Bar Hiding +The WebView handles its own navigation bar hiding via the `hideNavigationBar` property. This is managed in `OpenHABWebView` and doesn't conflict with the root status bar setting. + +## Next Steps + +1. Implement Step 1 (add modifiers to OpenHABTabRootView) +2. Test thoroughly using the checklist +3. Execute Step 2 (delete old files) +4. Search for and clean up any remaining references (Step 3) +5. Celebrate having a pure SwiftUI app! 🎉 + +## Questions? + +If you encounter any issues during migration: +1. Check the testing checklist +2. Review the troubleshooting section +3. Verify all code changes were applied correctly +4. Check console logs for any errors + +--- + +**Estimated Time**: 1-2 hours for implementation + 2-3 hours for testing = 3-5 hours total diff --git a/openHAB/UIKit/OpenHABNavigationController.swift b/openHAB/UIKit/OpenHABNavigationController.swift deleted file mode 100644 index dccdcb242..000000000 --- a/openHAB/UIKit/OpenHABNavigationController.swift +++ /dev/null @@ -1,37 +0,0 @@ -// Copyright (c) 2010-2026 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 - -// Copyright (c) 2010-2025 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 OpenHABCore -import UIKit - -/// This is a wrapper around UINavigationController that allows the status bar to be hidden or shown. -/// It is used to control the status bar for the entire app and is loaded from the Main storyboard entry point. -@MainActor -class OpenHABNavigationController: UINavigationController { - override var childForStatusBarHidden: UIViewController? { nil } - - override var prefersStatusBarHidden: Bool { - PreferencesObserver.shared.applicationPreferences.hideStatusBar - } - - override var preferredStatusBarUpdateAnimation: UIStatusBarAnimation { .fade } -} diff --git a/openHAB/UIKit/OpenHABViewController.swift b/openHAB/UIKit/OpenHABViewController.swift deleted file mode 100644 index f4b04e328..000000000 --- a/openHAB/UIKit/OpenHABViewController.swift +++ /dev/null @@ -1,243 +0,0 @@ -// Copyright (c) 2010-2026 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 Combine -import OpenHABCore -import SwiftMessages -import UIKit - -@MainActor -protocol OpenHABViewable: AnyObject { - func reloadView() - func viewName() -> String - // swiftlint:disable:next function_parameter_count - func showPopupMessage(seconds: Double, - title: String, - message: String, - theme: Theme, - viewTapAction: (() -> Void)?, - buttonTitle: String, - buttonAction: (() -> Void)?) - func hidePopupMessages() -} - -class OpenHABViewController: UIViewController, OpenHABViewable { - var trackerCancellables = Set() - - var activeTasks = Set>() - - override func viewDidLoad() { - super.viewDidLoad() - NotificationCenter.default.addObserver(self, selector: #selector(OpenHABViewController.didEnterBackground(_:)), name: UIApplication.didEnterBackgroundNotification, object: nil) - NotificationCenter.default.addObserver(self, selector: #selector(OpenHABViewController.didBecomeActive(_:)), name: UIApplication.didBecomeActiveNotification, object: nil) - CertificateManagers.clientCertificateManager.delegate = self - CertificateManagers.serverCertificateManager.delegate = self - } - - func showPopupMessage(seconds: Double, - title: String, - message: String, - theme: Theme, - viewTapAction: (() -> Void)? = nil, - buttonTitle: String = NSLocalizedString("dismiss", comment: ""), - buttonAction: (() -> Void)? = nil) { - var config = SwiftMessages.Config() - if seconds >= 0 { - config.duration = .seconds(seconds: seconds) - } else { - config.duration = .forever - } - config.presentationStyle = .bottom - config.presentationContext = .view(view) - SwiftMessages.hideAll() - SwiftMessages.show(config: config) { - let view = MessageView.viewFromNib(layout: .cardView) - // ... configure the view - view.configureTheme(theme) - view.configureContent(title: title, body: message) - view.button?.setTitle(buttonTitle, for: .normal) - view.buttonTapHandler = { _ in - SwiftMessages.hide() - buttonAction?() - } - view.tapHandler = { _ in - viewTapAction?() - } - return view - } - } - - func hidePopupMessages() { - SwiftMessages.hideAll() - } - - @objc - func didEnterBackground(_ notification: Notification?) { - UIApplication.shared.isIdleTimerDisabled = false - } - - @objc - func didBecomeActive(_ notification: Notification?) { - Task { - // re disable idle off timer - if await Preferences.shared.applicationPreferences.idleOff { - UIApplication.shared.isIdleTimerDisabled = true - } - } - } - - // No-op: side menu replaced by tab bar - func showSideMenu() {} - - // To be overridden by sub classes - - func reloadView() {} - - func viewName() -> String { - "default" - } -} - -// MARK: - ServerCertificateManagerDelegate - -extension OpenHABViewController: ServerCertificateManagerDelegate { - // delegate should ask user for a decision on what to do with invalid certificate - @MainActor - func evaluateServerTrust(summary certificateSummary: String?, forDomain domain: String?) async -> ServerCertificateManager.EvaluateResult { - await withCheckedContinuation { continuation in - let title = NSLocalizedString("ssl_certificate_warning", comment: "") - let message = String(format: NSLocalizedString("ssl_certificate_invalid", comment: ""), certificateSummary ?? "", domain ?? "") - let alertView = UIAlertController(title: title, message: message, preferredStyle: .alert) - - alertView.addAction(UIAlertAction(title: NSLocalizedString("abort", comment: ""), style: .default) { _ in - continuation.resume(returning: .deny) - }) - alertView.addAction(UIAlertAction(title: NSLocalizedString("once", comment: ""), style: .default) { _ in - continuation.resume(returning: .permitOnce) - }) - alertView.addAction(UIAlertAction(title: NSLocalizedString("always", comment: ""), style: .default) { _ in - continuation.resume(returning: .permitAlways) - }) - - self.present(alertView, animated: true, completion: nil) - } - } - - // certificate received from openHAB doesn't match our record, ask user for a decision - @MainActor - func evaluateCertificateMismatch(summary certificateSummary: String?, forDomain domain: String?) async -> OpenHABCore.ServerCertificateManager.EvaluateResult { - await withCheckedContinuation { continuation in - let title = NSLocalizedString("ssl_certificate_warning", comment: "") - let message = String(format: NSLocalizedString("ssl_certificate_no_match", comment: ""), certificateSummary ?? "", domain ?? "") - let alertView = UIAlertController(title: title, message: message, preferredStyle: .alert) - - alertView.addAction(UIAlertAction(title: NSLocalizedString("abort", comment: ""), style: .default) { _ in - continuation.resume(returning: .deny) - }) - alertView.addAction(UIAlertAction(title: NSLocalizedString("once", comment: ""), style: .default) { _ in - continuation.resume(returning: .permitOnce) - }) - alertView.addAction(UIAlertAction(title: NSLocalizedString("always", comment: ""), style: .default) { _ in - continuation.resume(returning: .permitAlways) - }) - - self.present(alertView, animated: true, completion: nil) - } - } - - @MainActor - func acceptedServerCertificatesChanged() { - // User's decision about trusting server certificates has changed. Send updates to the paired watch. - Task { - await WatchMessageService.singleton.syncPreferencesToWatch() - } - } -} - -// MARK: - ClientCertificateManagerDelegate - -@MainActor -extension OpenHABViewController: ClientCertificateManagerDelegate { - // Ask user whether to import the certificate - func askForClientCertificateImport(_ clientCertificateManager: ClientCertificateManager?) async -> Bool { - let shouldImport = await withCheckedContinuation { continuation in - let alertController = UIAlertController( - title: NSLocalizedString("certificate_import_title", comment: ""), - message: NSLocalizedString("certificate_import_text", comment: ""), - preferredStyle: .alert - ) - - let okay = UIAlertAction(title: NSLocalizedString("okay", comment: ""), style: .default) { _ in - continuation.resume(returning: true) - } - - let cancel = UIAlertAction(title: NSLocalizedString("cancel", comment: ""), style: .cancel) { _ in - continuation.resume(returning: false) - } - - alertController.addAction(okay) - alertController.addAction(cancel) - - self.present(alertController, animated: true) - } - if shouldImport { - await clientCertificateManager!.clientCertificateAccepted(password: nil) - return true - } else { - clientCertificateManager!.clientCertificateRejected() - return false - } - } - - // Ask user for password to decode PKCS#12 - func askForCertificatePassword(_ clientCertificateManager: ClientCertificateManager?) async -> String? { - await withCheckedContinuation { continuation in - let alertController = UIAlertController( - title: NSLocalizedString("certificate_import_title", comment: ""), - message: NSLocalizedString("certificate_import_password", comment: ""), - preferredStyle: .alert - ) - - alertController.addTextField { textField in - textField.placeholder = NSLocalizedString("password", comment: "") - textField.isSecureTextEntry = true - } - - let okay = UIAlertAction(title: NSLocalizedString("okay", comment: ""), style: .default) { _ in - let password = alertController.textFields?.first?.text - continuation.resume(returning: password) - } - - let cancel = UIAlertAction(title: NSLocalizedString("cancel", comment: ""), style: .cancel) { _ in - continuation.resume(returning: nil) - } - - alertController.addAction(okay) - alertController.addAction(cancel) - - self.present(alertController, animated: true) - } - } - - // Show alert if certificate import failed - func alertClientCertificateError(_ clientCertificateManager: ClientCertificateManager?, errMsg: String) async { - let alertController = UIAlertController( - title: NSLocalizedString("certificate_import_title", comment: ""), - message: errMsg, - preferredStyle: .alert - ) - - let okay = UIAlertAction(title: NSLocalizedString("okay", comment: ""), style: .default) - alertController.addAction(okay) - - present(alertController, animated: true) - } -} diff --git a/openHAB/UIKit/OpenHABWebViewController.swift b/openHAB/UIKit/OpenHABWebViewController.swift deleted file mode 100644 index 6f567f88e..000000000 --- a/openHAB/UIKit/OpenHABWebViewController.swift +++ /dev/null @@ -1,640 +0,0 @@ -// Copyright (c) 2010-2026 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 Combine -import OpenHABCore -import os.log -import SafariServices -import SwiftMessages -import UIKit -import WebKit - -class OpenHABWebViewController: OpenHABViewController { - private var currentTarget = "" - private var openHABTrackedRootUrl = "" - private var activeConnectionInfo: ConnectionInfo? - private var activeConfig: ConnectionConfiguration? { activeConnectionInfo?.configuration } - private var hideNavigationBar = false - private var activityIndicator: UIActivityIndicatorView! - private var sseTimer: Timer? - private var commandQueue: [String] = [] - private var acceptsCommands = false - private var views: [UUID: WKWebView] = [:] - // TODO: remove myOhViews when we drop iOS 16 support - private var myOhViews: [UUID: WKWebView] = [:] - private var etagChecker: ETagChecker? - private var etagCheckerConfigURL: String? // Track which config the checker was created for - private var lastLoadedURL: String? // Track the last successfully loaded URL from didFinish - - private var js = """ - (function() { - // Main UI Callbacks - window.OHApp = { - exitToApp : function(){ - window.webkit.messageHandlers.mainUi.postMessage('exitToApp'); - }, - goFullscreen : function(){ - window.webkit.messageHandlers.mainUi.postMessage('goFullscreen'); - }, - sseConnected : function(connected) { - window.webkit.messageHandlers.mainUi.postMessage('sseConnected-' + connected); - }, - ready : function() { - window.webkit.messageHandlers.mainUi.postMessage('ready'); - }, - } - - // Detect Path changes in SPA - function notifyPathChange() { - window.webkit.messageHandlers.pathChanged.postMessage(window.location.pathname); - } - - const originalPushState = history.pushState; - history.pushState = function() { - originalPushState.apply(this, arguments); - notifyPathChange(); - }; - - const originalReplaceState = history.replaceState; - history.replaceState = function() { - originalReplaceState.apply(this, arguments); - notifyPathChange(); - }; - - window.addEventListener('popstate', notifyPathChange); - - // Notify initial path on load - notifyPathChange(); - })(); - """ - - override open var shouldAutorotate: Bool { - true - } - - private var webView: WKWebView = .init(frame: .zero) - - override func viewDidLoad() { - super.viewDidLoad() - navigationController?.interactivePopGestureRecognizer?.isEnabled = true - attachWebViewToLayout(webView) - activityIndicator = UIActivityIndicatorView() - activityIndicator.center = view.center - activityIndicator.hidesWhenStopped = true - activityIndicator.style = UIActivityIndicatorView.Style.large - - view.addSubview(activityIndicator) - } - - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - setHideNavigationBar(shouldHide: hideNavigationBar, animated: animated) - navigationController?.navigationBar.prefersLargeTitles = false - parent?.navigationItem.title = "Main View" - MainActorNetworkTracker.shared.$activeConnection - .receive(on: DispatchQueue.main) - .sink { activeConnection in - if let activeConnection { - let activeConfiguration = activeConnection.configuration - Logger.viewController.info("OpenHABWebViewController openHAB URL = \(activeConfiguration.url)") - self.openHABTrackedRootUrl = activeConfiguration.url - self.activeConnectionInfo = activeConnection - self.loadWebView(force: false) - } - } - .store(in: &trackerCancellables) - - // Listen for app becoming active to check for content updates - NotificationCenter.default.addObserver( - self, - selector: #selector(applicationDidBecomeActive), - name: UIApplication.didBecomeActiveNotification, - object: nil - ) - - startTracker() - } - - override func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) - // Show the navigation bar on other view controllers - // do not change the "navigationBarHidden" flag to restore on reappearing - navigationController?.setNavigationBarHidden(false, animated: animated) - navigationController?.navigationBar.prefersLargeTitles = true - trackerCancellables.removeAll() - - NotificationCenter.default.removeObserver(self, name: UIApplication.didBecomeActiveNotification, object: nil) - } - - func startTracker() { - if currentTarget.isEmpty { - showActivityIndicator(show: true) - } - } - - @objc private func applicationDidBecomeActive() { - // When app returns from background, check if content has changed - Logger.viewController.info("App became active, checking for content updates") - loadWebView(force: false) - } - - @MainActor - func loadWebView(force: Bool = false, path: String? = nil) { - Logger.viewController.info("loadWebView tracked URL: \(self.activeConfig?.url ?? "") forced \(force ? "true" : "false")") - guard let activeConfig else { return } - // TODO: Check whether credentials are truly put into newTarget - let authStr = "\(activeConfig.username):\(activeConfig.password)" - let newTarget = "\(activeConfig.url):\(authStr)" - - // If force reload, skip ETag check and always reload - if force { - Task { - await performLoadWebView(newTarget: newTarget, path: path, force: true) - } - return - } - - // Check ETag before loading (even if target hasn't changed - content might have updated) - Task { - await loadWebViewWithETagCheck(newTarget: newTarget, path: path) - } - } - - @MainActor - private func performLoadWebView(newTarget: String, path: String?, force: Bool) async { - guard let activeConfig else { return } - currentTarget = newTarget - let url = URL(string: activeConfig.url) - - if let modifiedUrl = await modifyUrl(orig: url, path: path) { - acceptsCommands = false - var request = URLRequest(url: modifiedUrl) - - // When force reloading, bypass ALL caches (both URLRequest and WKWebView) - if force { - // Clear WKWebView's internal cache - let dataStore = webView.configuration.websiteDataStore - let websiteDataTypes: Set = [WKWebsiteDataTypeDiskCache, WKWebsiteDataTypeMemoryCache] - let date = Date(timeIntervalSince1970: 0) - - Logger.viewController.info("Force reload: clearing WKWebView cache") - await dataStore.removeData(ofTypes: websiteDataTypes, modifiedSince: date) - - // Set aggressive cache policy for URLRequest - request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData - } - - // TODO: remove this check once iOS 16 is dropped - let isCloudConnection = activeConfig.isCloudConnection - // create new (or resuse existing) - let newWebview = await webView(for: Preferences.shared.currentHomePreferences.id, isCloudConnection: isCloudConnection) - if newWebview != webView { - // Detach old instance - webView.stopLoading() - webView.navigationDelegate = nil - webView.uiDelegate = nil - webView.removeFromSuperview() - newWebview.navigationDelegate = self - newWebview.uiDelegate = self - webView = newWebview - attachWebViewToLayout(newWebview) - } - Logger.viewController.info("Loading URL: \(modifiedUrl)") - webView.load(request) - } - } - - @MainActor - private func loadWebViewWithETagCheck(newTarget: String, path: String?) async { - guard let activeConfig, - let url = URL(string: activeConfig.url), - let fullURL = await modifyUrl(orig: url, path: path) else { - Logger.viewController.info("ETag check skipped: invalid configuration") - await performLoadWebView(newTarget: newTarget, path: path, force: false) - return - } - - // Create checker if needed (lazy initialization) or if config changed - let configKey = "\(activeConfig.url):\(activeConfig.username)" - if etagChecker == nil || etagCheckerConfigURL != configKey { - let httpClient = HTTPClient(baseURL: nil, connectionConfiguration: activeConfig) - etagChecker = ETagChecker(httpClient: httpClient) - etagCheckerConfigURL = configKey - Logger.viewController.debug("Created new ETagChecker for config: \(configKey)") - } - - guard let checker = etagChecker else { - await performLoadWebView(newTarget: newTarget, path: path, force: false) - return - } - - // Check if content changed - let result = await checker.checkIfChanged(url: fullURL) - - switch result { - case .unchanged: - // When ETag is unchanged, the base resource (HTML/JS) hasn't changed - // Compare base URLs (origin) only, since paths are handled by client-side routing - let normalizedTarget = normalizeURLForComparison(fullURL.absoluteString, includeBasePath: false) - let normalizedLoaded = normalizeURLForComparison(lastLoadedURL, includeBasePath: false) - - Logger.viewController.debug("ETag unchanged - comparing base URLs: loaded=\(normalizedLoaded ?? "nil") vs target=\(normalizedTarget ?? "nil")") - - if let normalizedTarget, let normalizedLoaded, normalizedLoaded == normalizedTarget { - Logger.viewController.info("ETag unchanged and same base URL, skipping load") - currentTarget = newTarget - showActivityIndicator(show: false) - // Don't load - same server, same content version, already displayed - } else { - Logger.viewController.info("ETag unchanged but different base URL, loading \(fullURL.absoluteString)") - await performLoadWebView(newTarget: newTarget, path: path, force: false) - } - - case .changed: - Logger.viewController.info("ETag changed, loading \(fullURL.absoluteString)") - await performLoadWebView(newTarget: newTarget, path: path, force: false) - - case let .failed(error): - Logger.viewController.info("ETag check failed: \(error.localizedDescription), loading anyway") - await performLoadWebView(newTarget: newTarget, path: path, force: false) - } - } - - /// Normalizes URLs for comparison - /// - Parameters: - /// - urlString: The URL string to normalize - /// - includeBasePath: If true, includes the path component; if false, returns only the base URL (origin) - /// - Returns: Normalized URL string - private func normalizeURLForComparison(_ urlString: String?, includeBasePath: Bool = false) -> String? { - guard let urlString, let url = URL(string: urlString) else { return nil } - - var components = URLComponents(url: url, resolvingAgainstBaseURL: false) - - // Always remove fragment (everything after #) - components?.fragment = nil - - // For base URL comparison (when includeBasePath == false), remove the path entirely - if !includeBasePath { - components?.path = "" - components?.query = nil - } - - guard var normalized = components?.url?.absoluteString else { return nil } - - // Remove trailing slash for consistent comparison - if normalized.hasSuffix("/") { - normalized = String(normalized.dropLast()) - } - - return normalized - } - - func modifyUrl(orig: URL?, path: String? = nil) async -> URL? { - // better way to clone/copy ? - guard let urlString = orig?.absoluteString, var url = URL(string: urlString) else { return orig } - // Use cloud proxy URL if available (resolved from /api/v1/proxyurl) - if let proxyURL = activeConnectionInfo?.proxyURL { - url = proxyURL - } - if let path { - url = appendPathToURL(baseURL: url, path: path) ?? url - } else if await !Preferences.shared.currentHomePreferences.defaultMainUIPath.isEmpty { - url = appendPathToURL(baseURL: url, path: await Preferences.shared.currentHomePreferences.defaultMainUIPath) ?? url - } - return url - } - - // swift really makes you work to construct simple URLs, uhg..... - func appendPathToURL(baseURL: URL, path: String) -> URL? { - guard var urlComponents = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) else { - return nil - } - // Split the user path into path and query components - if let questionMarkRange = path.range(of: "?") { - // Separate path and query - let pathComponent = String(path[.. String { - "web" - } - - func navigateCommand(_ command: String) { - if acceptsCommands { - navigateCommandInternal(command) - } else { - commandQueue.append(command) - } - } - - private func navigateCommandInternal(_ command: String) { - // let jsCode = "window.OHApp.navigate === 'function' && window.OHApp.navigate('\(command)')" - let jsCode = "window.MainUI.handleCommand('\(command)')" - webView.evaluateJavaScript(jsCode) { (_, error) in - if let error { - Logger.viewController.error("navigateCommandInternal failed \(error.localizedDescription)") - } else { - Logger.viewController.info("navigateCommandInternal Success") - } - } - } - - private func executeQueuedCommands() { - while !commandQueue.isEmpty { - let command = commandQueue.removeFirst() - navigateCommandInternal(command) - } - } - - func webView(for id: UUID, isCloudConnection: Bool) -> WKWebView { - // TODO: remove all iOS < 17 code when we drop iOS 16 support - if #unavailable(iOS 17) { - if isCloudConnection, let myExsiting = myOhViews[id] { - Logger.viewController.info("Reusing cloud webview for id:\(id.uuidString)") - return myExsiting - } - } - if let existing = views[id] { - Logger.viewController.info("Reusing webview for id:\(id.uuidString)") - return existing - } - let config = WKWebViewConfiguration() - config.allowsInlineMediaPlayback = true - config.mediaTypesRequiringUserActionForPlayback = [] - // adds: window.webkit.messageHandlers.xxxx.postMessage to JS env - config.userContentController.add(self, name: "mainUi") - config.userContentController.add(self, name: "pathChanged") - config.userContentController.addUserScript(WKUserScript(source: js, injectionTime: .atDocumentStart, forMainFrameOnly: false)) - - // iOS 17 allows Sandboxed profiles, which is fantastic, iOS 16 does not and agressively caches everything - if #available(iOS 17, *) { - config.websiteDataStore = WKWebsiteDataStore(forIdentifier: id) - } else if isCloudConnection { - // for cloud connections, create an instance that does not persist or share states (private) - config.websiteDataStore = .nonPersistent() - } - - let webview = WKWebView(frame: .zero, configuration: config) - webview.navigationDelegate = self - webview.uiDelegate = self - webview.scrollView.bounces = false - // support dark mode and avoid white flashing when loading - webview.isOpaque = false - webview.backgroundColor = UIColor.clear - if UIDevice.current.userInterfaceIdiom == .pad { - // since ios 13 Safari sets the user agent to desktop mode on iPads so the view renders correctly with larger screens - webview.customUserAgent = "Mozilla/5.0 (iPad; CPU OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1" - } - if #available(iOS 16.4, *) { - webview.isInspectable = true - } - - // Avoid safe-area content insets which can leave a small gap at the bottom on iPad until a reload. - webview.scrollView.contentInsetAdjustmentBehavior = .never - webview.scrollView.contentInset = .zero - webview.scrollView.scrollIndicatorInsets = .zero - - if #unavailable(iOS 17) { - if isCloudConnection { - myOhViews[id] = webview - return webview - } - } - views[id] = webview - return webview - } - - func attachWebViewToLayout(_ webView: WKWebView) { - if webView.superview !== view { - view.addSubview(webView) - } - webView.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - webView.topAnchor.constraint(equalTo: view.topAnchor), - webView.bottomAnchor.constraint(equalTo: view.bottomAnchor), - webView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - webView.trailingAnchor.constraint(equalTo: view.trailingAnchor) - ]) - view.setNeedsLayout() - view.layoutIfNeeded() - } -} - -extension OpenHABWebViewController: WKScriptMessageHandler { - @MainActor - func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { - Logger.viewController.info("WKScriptMessage \(message.name)") - if message.name == "pathChanged", let newPath = message.body as? String { - Logger.viewController.debug("Path changed to: \(newPath)") - Task { - await Preferences.shared.setCurrentWebViewPath(newPath) - } - } - if message.name == "mainUi", let callbackName = message.body as? String { - Logger.viewController.info("WKScriptMessage \(callbackName)") - switch callbackName { - case "exitToApp": - // Tab bar is always visible, no side menu to show - break - case "goFullscreen": - // check to make sure we are actually the top view before hiding the nav button - if isViewLoaded, view.window != nil { - setHideNavigationBar(shouldHide: true) - } - case "sseConnected-true": - Logger.viewController.info("WKScriptMessage sseConnected is true") - hidePopupMessages() - sseTimer?.invalidate() - acceptsCommands = true - executeQueuedCommands() - case "sseConnected-false": - Logger.viewController.info("WKScriptMessage sseConnected is false") - sseTimer?.invalidate() - sseTimer = Timer.scheduledTimer(withTimeInterval: 10.0, repeats: false) { [weak self] _ in - guard let self else { return } - Task { @MainActor in - self.showPopupMessage(seconds: 20, title: NSLocalizedString("connecting", comment: ""), message: "", theme: .info) - self.acceptsCommands = false - } - } - default: break - } - } - } -} - -extension OpenHABWebViewController: WKNavigationDelegate { - func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction) async -> WKNavigationActionPolicy { - guard let url = navigationAction.request.url else { return .allow } - Logger.viewController.info("decidePolicyFor - url: \(url.absoluteString)") - - if navigationAction.navigationType == .linkActivated { - await UIApplication.shared.open(url) - return .cancel // Stop in WebView - } - return .allow - } - - func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse) async -> WKNavigationResponsePolicy { - if let response = navigationResponse.response as? HTTPURLResponse { - Logger.viewController.info("navigationResponse: \(response.statusCode)") - - if response.statusCode >= 400 { - pageLoadError(message: "\(response.statusCode)") - return .cancel - } - } - return .allow - } - - func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation?) { - Logger.viewController.info("didStartProvisionalNavigation - webView.url: \(String(describing: webView.url?.description))") - showActivityIndicator(show: true) - } - - func webView(_ webView: WKWebView, didFail navigation: WKNavigation?, withError error: any Error) { - Logger.viewController.error("didFail - webView.url: \(String(describing: webView.url?.description))") - - setHideNavigationBar(shouldHide: false) - if let urlError = error as? URLError, urlError.code == .cancelled { - return // Ignore cancelled requests - } - - pageLoadError(message: error.localizedDescription) - } - - func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { - Logger.viewController.info("didFinish - webView.url: \(String(describing: webView.url?.description))") - - // Track the successfully loaded URL for ETag comparison - lastLoadedURL = webView.url?.absoluteString - - setHideNavigationBar(shouldHide: true) - showActivityIndicator(show: false) - hidePopupMessages() - acceptsCommands = true - // watch for URL changes so we can store the last visited path - if let webviewURL = webView.url { - let url = URL(string: webviewURL.path, relativeTo: URL(string: openHABTrackedRootUrl)) - if let path = url?.path { - let string = openHABTrackedRootUrl - Logger.viewController.info("navigation change base: \(string) path: \(path)") - Task { - let normalizedPath = path.hasSuffix("/") ? path : path + "/" - await Preferences.shared.setCurrentWebViewPath(normalizedPath) - } - } - } - } - - func webView(_ webView: WKWebView, respondTo challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { - Logger.viewController.info("Challenge.protectionSpace.authenticationMethod: \(String(describing: challenge.protectionSpace.authenticationMethod))") - - if let url = await modifyUrl(orig: URL(string: openHABTrackedRootUrl)), challenge.protectionSpace.host == url.host { - if challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust { - guard let serverTrust = challenge.protectionSpace.serverTrust else { - return (.performDefaultHandling, nil) - } - let credential = URLCredential(trust: serverTrust) - return (.useCredential, credential) - } else { - if challenge.protectionSpace.authenticationMethod.isAny(of: NSURLAuthenticationMethodHTTPBasic, NSURLAuthenticationMethodDefault) { - return await onReceiveSessionTaskChallenge(with: challenge) - } else { - return await onReceiveSessionChallenge(with: challenge) - } - } - } - return (.performDefaultHandling, nil) - } - - func webViewWebContentProcessDidTerminate(_ webView: WKWebView) { - Logger.viewController.warning("webViewWebContentProcessDidTerminate - reloading view") - reloadView() - } - - func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: any Error) { - setHideNavigationBar(shouldHide: false) - reloadView() - } -} - -extension OpenHABWebViewController: WKUIDelegate { - func webView(_ webView: WKWebView, - createWebViewWith configuration: WKWebViewConfiguration, - for navigationAction: WKNavigationAction, - windowFeatures: WKWindowFeatures) -> WKWebView? { - let schemes = ["http", "https"] - if navigationAction.targetFrame == nil, - let url = navigationAction.request.url, - let scheme = url.scheme, - schemes.contains(scheme) { - let svc = SFSafariViewController(url: url) - present(svc, animated: true, completion: nil) - } - - return nil - } - - func webView(_ webView: WKWebView, - decideMediaCapturePermissionsFor origin: WKSecurityOrigin, - initiatedBy frame: WKFrameInfo, - type: WKMediaCaptureType) async -> WKPermissionDecision { - await Preferences.shared.currentHomePreferences.alwaysAllowWebRTC ? .grant : .prompt - } -} diff --git a/openHAB/UIKit/ScaleAspectFitImageView.swift b/openHAB/UIKit/ScaleAspectFitImageView.swift deleted file mode 100644 index e48391342..000000000 --- a/openHAB/UIKit/ScaleAspectFitImageView.swift +++ /dev/null @@ -1,71 +0,0 @@ -// Copyright (c) 2010-2026 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 UIKit - -class ScaleAspectFitImageView: UIImageView { - private var aspectRatioConstraint: NSLayoutConstraint? - override var image: UIImage? { - didSet { - updateAspectRatioConstraint() - } - } - - required init?(coder aDecoder: NSCoder) { - super.init(coder: aDecoder) - setup() - } - - override init(frame: CGRect) { - super.init(frame: frame) - setup() - } - - override init(image: UIImage!) { - super.init(image: image) - setup() - } - - override init(image: UIImage!, highlightedImage: UIImage?) { - super.init(image: image, highlightedImage: highlightedImage) - setup() - } - - private func setup() { - contentMode = .scaleAspectFit - updateAspectRatioConstraint() - } - - /// Removes any pre-existing aspect ratio constraint, and adds a new one based on the current image - private func updateAspectRatioConstraint() { - if let constraint = aspectRatioConstraint { - removeConstraint(constraint) - } - aspectRatioConstraint = nil - - if let imageSize = image?.size, imageSize.height != 0 { - let aspectRatio = imageSize.width / imageSize.height - let constraint = NSLayoutConstraint( - item: self, - attribute: .width, - relatedBy: .equal, - toItem: self, - attribute: .height, - multiplier: aspectRatio, - constant: 0 - ) - - constraint.priority = UILayoutPriority(rawValue: 999) - addConstraint(constraint) - aspectRatioConstraint = constraint - } - } -} diff --git a/openHAB/UIKit/UICircleButton.swift b/openHAB/UIKit/UICircleButton.swift deleted file mode 100644 index 61ab35346..000000000 --- a/openHAB/UIKit/UICircleButton.swift +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright (c) 2010-2026 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 os.log -import UIKit - -class UICircleButton: UIButton { - required init?(coder: NSCoder) { - super.init(coder: coder) - - layer.borderWidth = 2 - layer.borderColor = UIColor(white: 0, alpha: 0.05).cgColor - - layer.cornerRadius = bounds.size.width / 2.0 - } -} diff --git a/openHAB/UIKit/UILabel+Localization.swift b/openHAB/UIKit/UILabel+Localization.swift deleted file mode 100644 index b5dd1fc5f..000000000 --- a/openHAB/UIKit/UILabel+Localization.swift +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright (c) 2010-2026 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 Foundation -import UIKit - -extension UILabel { - @IBInspectable var localizationKey: String { - get { - "" - } - set { - text = NSLocalizedString(newValue, comment: "") - } - } -} diff --git a/openHAB/UIKit/UIViewController+Localization.swift b/openHAB/UIKit/UIViewController+Localization.swift deleted file mode 100644 index 4dde36526..000000000 --- a/openHAB/UIKit/UIViewController+Localization.swift +++ /dev/null @@ -1,25 +0,0 @@ -// Copyright (c) 2010-2026 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 Combine -import Foundation -import UIKit - -extension UIViewController { - @IBInspectable var localizationKey: String { - get { - "" - } - set { - title = NSLocalizedString(newValue, comment: "").uppercased() - } - } -} diff --git a/openHAB/UIKit/URL+Static.swift b/openHAB/URL+Static.swift similarity index 100% rename from openHAB/UIKit/URL+Static.swift rename to openHAB/URL+Static.swift diff --git a/openHAB/openHAB-Info.plist b/openHAB/openHAB-Info.plist index c20d06688..cd3a3297d 100644 --- a/openHAB/openHAB-Info.plist +++ b/openHAB/openHAB-Info.plist @@ -93,10 +93,6 @@ UILaunchStoryboardName MainLaunchScreen - UIMainStoryboardFile - Main - UIMainStoryboardFile~ipad - Main UIRequiredDeviceCapabilities armv7

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∈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 }uORpNP{+ws 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 z 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°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