diff --git a/CommonUI/Sources/CommonUI/LogsViewer.swift b/CommonUI/Sources/CommonUI/LogsViewer.swift index 131f2eea6..7778481de 100644 --- a/CommonUI/Sources/CommonUI/LogsViewer.swift +++ b/CommonUI/Sources/CommonUI/LogsViewer.swift @@ -20,6 +20,7 @@ public struct LogsViewer: View { "(subsystem BEGINSWITH $PREFIX)") @State private var text = String(localized: "Loading…") + @State private var exportURL: URL? let myFont = Font .system(size: 10) @@ -31,8 +32,20 @@ public struct LogsViewer: View { .font(myFont) .padding() } + .navigationTitle("Logs") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + if let exportURL { + ShareLink(item: exportURL) { + Image(systemName: "square.and.arrow.up") + } + .accessibilityLabel("Share Logs") + } + } + } .task { text = await fetchLogs() + exportURL = makeExportURL(for: text) } } @@ -63,6 +76,21 @@ public struct LogsViewer: View { return error.localizedDescription } } + + private func makeExportURL(for text: String) -> URL? { + let fileName = "openhab-logs-\(Date.now.formatted(.iso8601.year().month().day().time(includingFractionalSeconds: false)))" + .replacingOccurrences(of: ":", with: "-") + let url = FileManager.default.temporaryDirectory + .appendingPathComponent(fileName) + .appendingPathExtension("log") + + do { + try text.write(to: url, atomically: true, encoding: .utf8) + return url + } catch { + return nil + } + } } private extension OSLogEntryLog.Level { diff --git a/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift index 5e5ecc9e0..2e55abd85 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift @@ -113,6 +113,26 @@ public struct HomePreferences: Codable, Equatable { @MainActor public struct ApplicationPreferences: Codable, Equatable { public var showSearchField = true + public var sitemapDiagnosticsLogging = false + + public init( + showSearchField: Bool = true, + sitemapDiagnosticsLogging: Bool = false + ) { + self.showSearchField = showSearchField + self.sitemapDiagnosticsLogging = sitemapDiagnosticsLogging + } + + enum CodingKeys: String, CodingKey { + case showSearchField + case sitemapDiagnosticsLogging + } + + nonisolated public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + showSearchField = try container.decodeIfPresent(Bool.self, forKey: .showSearchField) ?? true + sitemapDiagnosticsLogging = try container.decodeIfPresent(Bool.self, forKey: .sitemapDiagnosticsLogging) ?? false + } } // MARK: Retrieving preference from user defaults, reacting to preference change @@ -146,15 +166,15 @@ private enum PreferencesAccess { @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") + Logger.preferences.debug("Preference \(key, privacy: .public) value of type \(String(describing: T.self), privacy: .public) 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.") + Logger.preferences.debug("Preference \(key, privacy: .public) conversion failed for value type \(String(describing: T.self), privacy: .public)") return } - Logger.preferences.debug("Preference \(key) will be changed to value \(String(describing: newValue), privacy: .private)") + Logger.preferences.debug("Preference \(key, privacy: .public) will be changed for value type \(String(describing: T.self), privacy: .public)") sharedDefaults.set(convertedValue, forKey: key) subject.send(sanitized) diff --git a/openHAB/Models/SitemapPageViewModel+SupportTypes.swift b/openHAB/Models/SitemapPageViewModel+SupportTypes.swift index 9db4d1d50..d40f4daa3 100644 --- a/openHAB/Models/SitemapPageViewModel+SupportTypes.swift +++ b/openHAB/Models/SitemapPageViewModel+SupportTypes.swift @@ -26,180 +26,3 @@ struct QueuedCommand { let version: Int } -// swiftlint:disable:next file_types_order -struct WidgetRenderKey: Equatable { - let label: String - let icon: String - let state: String - let iconColor: String - let labelColor: String - let valueColor: String - let url: String - let period: String - let service: String - let legend: Bool? - let refresh: Int - let height: Double? - let forceAsItem: Bool? - let visibility: Bool - let staticIcon: Bool? - let switchSupport: Bool - let minValue: Double - let maxValue: Double - let step: Double - let pattern: String? - let unit: String? - let type: OpenHABWidget.WidgetType - let linkedPageLink: String? - let linkedPageTitle: String? - let mappings: [WidgetMappingKey] - let item: WidgetItemKey? - let childWidgetIDs: [String] - - static func from(widget: OpenHABWidget) -> WidgetRenderKey { - WidgetRenderKey( - label: widget.label, - icon: widget.icon, - state: widget.state, - iconColor: widget.iconColor, - labelColor: widget.labelcolor, - valueColor: widget.valuecolor, - url: widget.url, - period: widget.period, - service: widget.service, - legend: widget.legend, - refresh: widget.refresh, - height: widget.height, - forceAsItem: widget.forceAsItem, - visibility: widget.visibility, - staticIcon: widget.staticIcon, - switchSupport: widget.switchSupport, - minValue: widget.minValue, - maxValue: widget.maxValue, - step: widget.step, - pattern: widget.pattern, - unit: widget.unit, - type: widget.type, - linkedPageLink: widget.linkedPage?.link, - linkedPageTitle: widget.linkedPage?.title, - mappings: widget.mappings.map(WidgetMappingKey.init), - item: WidgetItemKey.from(item: widget.item), - childWidgetIDs: widget.widgets.map(\.widgetId) - ) - } - - // Could be synthesized automatically by compiler. But this takes too long - static func == (lhs: WidgetRenderKey, rhs: WidgetRenderKey) -> Bool { - lhs.label == rhs.label && - lhs.icon == rhs.icon && - lhs.state == rhs.state && - lhs.iconColor == rhs.iconColor && - lhs.labelColor == rhs.labelColor && - lhs.valueColor == rhs.valueColor && - lhs.url == rhs.url && - lhs.period == rhs.period && - lhs.service == rhs.service && - lhs.legend == rhs.legend && - lhs.refresh == rhs.refresh && - lhs.height == rhs.height && - lhs.forceAsItem == rhs.forceAsItem && - lhs.visibility == rhs.visibility && - lhs.staticIcon == rhs.staticIcon && - lhs.switchSupport == rhs.switchSupport && - lhs.minValue == rhs.minValue && - lhs.maxValue == rhs.maxValue && - lhs.step == rhs.step && - lhs.pattern == rhs.pattern && - lhs.unit == rhs.unit && - lhs.type == rhs.type && - lhs.linkedPageLink == rhs.linkedPageLink && - lhs.linkedPageTitle == rhs.linkedPageTitle && - lhs.mappings == rhs.mappings && - lhs.item == rhs.item && - lhs.childWidgetIDs == rhs.childWidgetIDs - } -} - -struct WidgetMappingKey: Equatable { - let command: String - let label: String - let row: Int? - let column: Int? - let icon: String? - let releaseCommand: String? - - init(_ mapping: OpenHABWidgetMapping) { - command = mapping.command - label = mapping.label - row = mapping.row - column = mapping.column - icon = mapping.icon - releaseCommand = mapping.releaseCommand - } -} - -struct WidgetItemKey: Equatable { - let name: String - let state: String? - let link: String - let label: String - let type: OpenHABItem.ItemType? - let groupType: OpenHABItem.ItemType? - let stateDescription: WidgetStateDescriptionKey? - let commandOptions: [WidgetCommandOptionKey] - - static func from(item: OpenHABItem?) -> WidgetItemKey? { - guard let item else { return nil } - return WidgetItemKey( - name: item.name, - state: item.state, - link: item.link, - label: item.label, - type: item.type, - groupType: item.groupType, - stateDescription: WidgetStateDescriptionKey.from(stateDescription: item.stateDescription), - commandOptions: item.commandDescription?.commandOptions.map(WidgetCommandOptionKey.init) ?? [] - ) - } -} - -struct WidgetStateDescriptionKey: Equatable { - let minimum: Double - let maximum: Double - let step: Double - let readOnly: Bool - let numberPattern: String? - let options: [WidgetOptionKey] - - static func from(stateDescription: OpenHABStateDescription?) -> WidgetStateDescriptionKey? { - guard let stateDescription else { return nil } - return WidgetStateDescriptionKey( - minimum: stateDescription.minimum, - maximum: stateDescription.maximum, - step: stateDescription.step, - readOnly: stateDescription.readOnly, - numberPattern: stateDescription.numberPattern, - options: stateDescription.options.map(WidgetOptionKey.init) - ) - } -} - -struct WidgetOptionKey: Equatable { - let value: String - let label: String - - init(_ option: OpenHABOptions) { - value = option.value - label = option.label - } -} - -struct WidgetCommandOptionKey: Equatable { - let command: String - let label: String? - - init(_ option: OpenHABCommandOptions) { - command = option.command - label = option.label - } -} diff --git a/openHAB/Models/SitemapPageViewModel.swift b/openHAB/Models/SitemapPageViewModel.swift index 875a9c606..27c1d3161 100644 --- a/openHAB/Models/SitemapPageViewModel.swift +++ b/openHAB/Models/SitemapPageViewModel.swift @@ -59,7 +59,7 @@ enum RowInteractionState: Equatable { @MainActor class SitemapPageViewModel: ObservableObject { - @Published var currentPage: OpenHABPage? + var currentPage: OpenHABPage? @Published var searchText = "" { didSet { rebuildRowInputs() @@ -69,6 +69,7 @@ class SitemapPageViewModel: ObservableObject { @Published var error: (any LocalizedError)? @Published var isLoading = true @Published var isUpdating = false + @Published private(set) var pageTitle = "" @Published var openHABRootUrl: String? @Published var showSearchField = false @Published private(set) var commandStates: [String: WidgetCommandLifecycleState] = [:] @@ -87,8 +88,7 @@ class SitemapPageViewModel: ObservableObject { private var defaultSitemap = "" private var defaultSitemapLabel = "" private var fallbackTitle = "" - @Published var pageId = "" - private var isLinkedPage = false + var pageId = "" private var pageNetworkStatus: NetworkStatus? private var pageNetworkStatusAvailable = false private var activePageHandlingKey: String? @@ -96,9 +96,6 @@ class SitemapPageViewModel: ObservableObject { private var commandStateResetTasks: [String: Task] = [:] private var commandStateVersions: [String: Int] = [:] private var queuedCommands: [String: QueuedCommand] = [:] - private var rowWidgetIndex: [RowID: OpenHABWidget] = [:] - private var sliderValueOverrides: [String: Double] = [:] - private var sliderOverrideResetTasks: [String: Task] = [:] private var lastForegroundRefreshAt: Date = .distantPast var relevantWidgets: [OpenHABWidget] { @@ -109,7 +106,7 @@ class SitemapPageViewModel: ObservableObject { } } - var pageTitle: String { + private func computePageTitle() -> String { // Strip bracket content from title (e.g., "Living Room[2]" becomes "Living Room") let title = currentPage?.title.components(separatedBy: "[")[0].trimmingCharacters(in: .whitespacesAndNewlines) ?? "" if !title.isEmpty { @@ -124,8 +121,21 @@ class SitemapPageViewModel: ObservableObject { } } - var isLinked: Bool { - isLinkedPage + private func refreshPageTitle() { + let newTitle = computePageTitle() + if pageTitle != newTitle { + pageTitle = newTitle + } + } + + var sitemapIconConnection: SitemapIconConnection { + guard let activeConnectionInfo else { + return .none + } + return SitemapIconConnection( + rootUrl: activeConnectionInfo.configuration.url, + version: activeConnectionInfo.version + ) } var commandLifecycleSummary: CommandLifecycleSummary { @@ -184,7 +194,6 @@ class SitemapPageViewModel: ObservableObject { init(pageUrl: String, title: String, pageId: String = "") { loadSettings() startObservers() - isLinkedPage = true fallbackTitle = title defaultSitemapLabel = title @@ -202,11 +211,11 @@ class SitemapPageViewModel: ObservableObject { } else { self.pageId = pageId } + refreshPageTitle() } /// 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( @@ -217,6 +226,7 @@ class SitemapPageViewModel: ObservableObject { widgets: widgets, icon: "" ) + refreshPageTitle() rebuildRowInputs() } @@ -267,50 +277,37 @@ class SitemapPageViewModel: ObservableObject { func rebuildRowInputs() { let pageKey = "\(defaultSitemap)|\(pageId)" - var occurrenceByWidgetID: [String: Int] = [:] - var inputs: [SitemapRowInput] = [] - var index: [RowID: OpenHABWidget] = [:] - inputs.reserveCapacity(relevantWidgets.count) - index.reserveCapacity(relevantWidgets.count) - - for widget in relevantWidgets { - let identityWidgetID = SitemapRowInputMapper.rowIdentityWidgetID(for: widget) - occurrenceByWidgetID[identityWidgetID, default: 0] += 1 - let occurrence = occurrenceByWidgetID[identityWidgetID]! - let rowID = RowID(pageKey: pageKey, widgetId: identityWidgetID, occurrence: occurrence) - let input = SitemapRowInputMapper.map(widget: widget, rowID: rowID) - inputs.append(input) - index[rowID] = widget - } - - rowWidgetIndex = index - rowInputs = inputs - } - - func widget(for rowID: RowID) -> OpenHABWidget? { - rowWidgetIndex[rowID] - } - - func widgetUpdateVersion(for widgetId: String) -> Int { - widgetUpdateVersions[widgetId] ?? 0 - } - - func sliderOverrideValue(for itemname: String?) -> Double? { - guard let itemname, !itemname.isEmpty else { return nil } - return sliderValueOverrides[itemname] + let inputs = SitemapRowInputMapper.map(pageKey: pageKey, widgets: relevantWidgets) + if inputs != rowInputs { + rowInputs = inputs + } } - func setSliderOverrideValue(_ value: Double, for itemname: String?) { - guard let itemname, !itemname.isEmpty else { return } - sliderOverrideResetTasks[itemname]?.cancel() - sliderOverrideResetTasks[itemname] = nil - objectWillChange.send() - sliderValueOverrides[itemname] = value + /// Increments `widgetUpdateVersions` for each row whose content differs between `oldInputs` + /// and `newInputs`, keyed by full row identity. + func bumpWidgetVersions(from oldInputs: [SitemapRowInput], to newInputs: [SitemapRowInput]) { + let changedKinds = SitemapDiagnostics.changedRowKinds(from: oldInputs, to: newInputs) + let changedRowCount: Int + if newInputs.count == oldInputs.count { + changedRowCount = zip(newInputs, oldInputs).reduce(into: 0) { count, pair in + if pair.0 != pair.1 { + count += 1 + } + } + for (new, old) in zip(newInputs, oldInputs) where new != old { + widgetUpdateVersions[new.rowID.rawValue, default: 0] += 1 + } + } else { + changedRowCount = newInputs.count + for input in newInputs { + widgetUpdateVersions[input.rowID.rawValue, default: 0] += 1 + } + } + SitemapDiagnostics.logWidgetVersions(changedRowCount: changedRowCount, changedRowKinds: changedKinds) } - @discardableResult - func syncSliderOverridesWithServerState(for widgets: [OpenHABWidget]) -> Int { - clearSyncedSliderOverrides(using: widgets) + func widgetUpdateVersion(for rowID: RowID) -> Int { + widgetUpdateVersions[rowID.rawValue] ?? 0 } deinit { @@ -320,8 +317,6 @@ class SitemapPageViewModel: ObservableObject { foregroundRefreshTask?.cancel() commandStateResetTasks.values.forEach { $0.cancel() } commandStateResetTasks.removeAll() - sliderOverrideResetTasks.values.forEach { $0.cancel() } - sliderOverrideResetTasks.removeAll() } } @@ -528,69 +523,63 @@ extension SitemapPageViewModel { @MainActor private func updateUI(with page: OpenHABPage, origin: PageUpdateOrigin) { logger.debug("Incoming sitemap update origin=\(origin.rawValue, privacy: .public), widgets=\(page.widgets.count)") - 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 } - let reconciledWidgets = reconcileWidgets(newWidgets, with: currentWidgets) - - // Only replace currentPage when structure or title changed - if structureChanged || currentPage?.title != page.title || currentPage == nil { - page.widgets = reconciledWidgets - injectSendCommand(for: reconciledWidgets) - currentPage = page - } else { - currentPage?.widgets = reconciledWidgets - // Inject sendCommand into existing widgets without replacing the page - injectSendCommand(for: reconciledWidgets) - } - - trackWidgetUpdates(in: reconciledWidgets) - _ = clearSyncedSliderOverrides(using: reconciledWidgets) - rebuildRowInputs() - } - - private func trackWidgetUpdates(in widgets: [OpenHABWidget]) { - for widget in widgets { - widgetUpdateVersions[widget.widgetId, default: 0] += 1 - } - } + let mapStart = Date() - private func clearSyncedSliderOverrides(using widgets: [OpenHABWidget]) -> Int { - guard !sliderValueOverrides.isEmpty else { return 0 } - var cleared = 0 + let pageKey = "\(defaultSitemap)|\(pageId)" - for widget in widgets { - guard let item = widget.item else { - cleared += clearSyncedSliderOverrides(using: widget.widgets) - continue + // Snapshot new inputs from fresh page data — no widget object mutation. + let incomingFiltered: [OpenHABWidget] + if searchText.isEmpty { + incomingFiltered = page.widgets + } else { + incomingFiltered = page.widgets.filter { + $0.label.lowercased().contains(searchText.lowercased()) && $0.type != .frame } - - let itemname = item.name - guard let overrideValue = sliderValueOverrides[itemname] else { - cleared += clearSyncedSliderOverrides(using: widget.widgets) - continue + } + let previewInputs = SitemapRowInputMapper.map(pageKey: pageKey, widgets: incomingFiltered) + let mapDurationMs = Int((Date().timeIntervalSince(mapStart) * 1000).rounded()) + + let diffStart = Date() + let titleChanged = currentPage == nil || currentPage?.title != page.title + let inputsChanged = previewInputs != rowInputs + let changedRowCount: Int + if previewInputs.count == rowInputs.count { + changedRowCount = zip(previewInputs, rowInputs).reduce(into: 0) { count, pair in + if pair.0 != pair.1 { + count += 1 + } } + } else { + changedRowCount = previewInputs.count + } + let changedRowKinds = SitemapDiagnostics.changedRowKinds(from: rowInputs, to: previewInputs) + let diffDurationMs = Int((Date().timeIntervalSince(diffStart) * 1000).rounded()) + SitemapDiagnostics.logUpdate( + origin: origin, + widgetCount: page.widgets.count, + rowCount: previewInputs.count, + inputsChanged: inputsChanged, + titleChanged: titleChanged, + changedRowCount: changedRowCount, + changedRowKinds: changedRowKinds, + mapDurationMs: mapDurationMs, + diffDurationMs: diffDurationMs + ) - let serverValue = item.state?.parseAsNumber(format: item.stateDescription?.numberPattern).value ?? .nan - guard serverValue.isFinite else { - cleared += clearSyncedSliderOverrides(using: widget.widgets) - continue - } + // When search is empty and nothing the UI renders has changed, skip the update entirely. + if searchText.isEmpty, !inputsChanged, !titleChanged { + return + } - let threshold = max(widget.step, 0.001) - if abs(serverValue - overrideValue) <= threshold { - clearSliderOverride(for: itemname) - cleared += 1 - logger.debug("Cleared slider override for \(itemname, privacy: .public) (server=\(serverValue), override=\(overrideValue))") - } + // Replace currentPage wholesale — no in-place widget reconciliation. + currentPage = page + refreshPageTitle() - cleared += clearSyncedSliderOverrides(using: widget.widgets) + if inputsChanged { + bumpWidgetVersions(from: rowInputs, to: previewInputs) + rowInputs = previewInputs.map { $0.applyingWidgetVersions(widgetUpdateVersions) } + SitemapDiagnostics.logPublishedRows(rowCount: rowInputs.count, changedRowCount: changedRowCount) } - - return cleared } func reload() async { @@ -628,80 +617,11 @@ extension SitemapPageViewModel { throw SitemapPageError.noData } - injectSendCommand(for: page.widgets) currentPage = page + refreshPageTitle() rebuildRowInputs() } - private func reconcileWidgets(_ newWidgets: [OpenHABWidget], with currentWidgets: [OpenHABWidget]) -> [OpenHABWidget] { - var buckets: [String: [OpenHABWidget]] = [:] - for widget in currentWidgets { - buckets[widget.widgetId, default: []].append(widget) - } - - var reconciled: [OpenHABWidget] = [] - reconciled.reserveCapacity(newWidgets.count) - - for newWidget in newWidgets { - if var candidates = buckets[newWidget.widgetId], !candidates.isEmpty { - let existing = candidates.removeFirst() - buckets[newWidget.widgetId] = candidates - - // Always copy server properties to avoid missing updates when - // non-keyed fields change (for example group summary/state rows). - let previousChildren = existing.widgets - copyWidgetProperties(from: newWidget, to: existing) - existing.widgets = reconcileWidgets(newWidget.widgets, with: previousChildren) - - reconciled.append(existing) - } else { - reconciled.append(newWidget) - } - } - - return reconciled - } - - private func copyWidgetProperties(from source: OpenHABWidget, to target: OpenHABWidget) { - target.label = source.label - target.icon = source.icon - target.state = source.state - target.type = source.type - target.isLeaf = source.isLeaf - target.item = source.item - target.iconColor = source.iconColor - target.labelcolor = source.labelcolor - target.valuecolor = source.valuecolor - target.url = source.url - target.period = source.period - target.service = source.service - target.legend = source.legend - target.refresh = source.refresh - target.height = source.height - target.forceAsItem = source.forceAsItem - target.minValue = source.minValue - target.maxValue = source.maxValue - target.step = source.step - target.pattern = source.pattern - target.unit = source.unit - target.switchSupport = source.switchSupport - target.mappings = source.mappings - target.linkedPage = source.linkedPage - target.visibility = source.visibility - target.staticIcon = source.staticIcon - target.text = source.text - target.inputHint = source.inputHint - target.encoding = source.encoding - target.labelSource = source.labelSource - target.releaseOnly = source.releaseOnly - target.row = source.row - target.column = source.column - target.releaseCommand = source.releaseCommand - target.command = source.command - target.stateless = source.stateless - target.yAxisDecimalPattern = source.yAxisDecimalPattern - } - private func shouldRetryLongPolling(after error: any Error) -> Bool { if let urlError = OpenAPIErrorInspector.underlyingURLError(from: error) { switch urlError.code { @@ -724,22 +644,12 @@ extension SitemapPageViewModel { return false } - 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) - } - } - @MainActor func pushSitemap(name: String, path: String?) async { defaultSitemap = name defaultSitemapLabel = "" // Clear old label so it gets fetched for the new sitemap pageId = path ?? "" + refreshPageTitle() error = nil // Clear any previous errors when switching sitemaps startPageHandling(forceRestart: true, reason: "push-sitemap") } @@ -756,6 +666,7 @@ extension SitemapPageViewModel { // Find the sitemap matching our defaultSitemap name and get its label if let sitemap = sitemaps.first(where: { $0.name == defaultSitemap }) { defaultSitemapLabel = sitemap.label + refreshPageTitle() // swiftformat:disable:next redundantSelf logger.info("Found label '\(self.defaultSitemapLabel)' for sitemap '\(self.defaultSitemap)'") } else { @@ -786,6 +697,7 @@ extension SitemapPageViewModel { // Auto-select the only available sitemap defaultSitemap = filteredSitemaps[0].name defaultSitemapLabel = filteredSitemaps[0].label + refreshPageTitle() // swiftformat:disable:next redundantSelf logger.info("Auto-selected single sitemap: \(self.defaultSitemap)") @@ -797,6 +709,7 @@ extension SitemapPageViewModel { // Multiple sitemaps available - select the first one defaultSitemap = filteredSitemaps[0].name defaultSitemapLabel = filteredSitemaps[0].label + refreshPageTitle() // swiftformat:disable:next redundantSelf logger.info("Auto-selected first sitemap from \(filteredSitemaps.count) available: \(self.defaultSitemap)") @@ -883,11 +796,12 @@ extension SitemapPageViewModel { } private func makeSitemapService(for connection: ConnectionInfo) throws -> OpenAPIService { - // Keep sitemap polling fresh after foreground transitions or long inactivity. - // Long-term config disables URL cache and aligns with watchOS behavior. + // Preserve normal URL caching on iOS so a recreated sitemap view can + // render the last page snapshot immediately while the foreground refresh + // fetches updated data in the background. try OpenAPIService( connectionConfiguration: connection.configuration, - serviceConfiguration: .longTerm + serviceConfiguration: .asDefault ) } @@ -921,7 +835,6 @@ extension SitemapPageViewModel { commandDispatcher.cancelPending(for: itemname, key: key) if key == nil { queuedCommands.removeValue(forKey: itemname) - clearSliderOverride(for: itemname) if case .queued = commandStates[itemname] { setCommandState(.idle, for: itemname) } @@ -1121,12 +1034,10 @@ private extension SitemapPageViewModel { func handleCommandSuccess(for itemname: String, version: Int) { guard commandStateVersions[itemname] == version else { return } scheduleCommandStateReset(for: itemname, version: version, after: .milliseconds(450)) - scheduleSliderOverrideResetFallback(for: itemname, version: version, after: .seconds(5)) } func handleCommandFailure(for itemname: String, version: Int, errorDescription: String) { guard commandStateVersions[itemname] == version else { return } - clearSliderOverride(for: itemname) setCommandState(.failed(message: errorDescription), for: itemname) } @@ -1140,23 +1051,6 @@ private extension SitemapPageViewModel { } } - func scheduleSliderOverrideResetFallback(for itemname: String, version: Int, after delay: Duration) { - sliderOverrideResetTasks[itemname]?.cancel() - sliderOverrideResetTasks[itemname] = Task { @MainActor [weak self] in - try? await Task.sleep(for: delay) - guard let self else { return } - guard commandStateVersions[itemname] == version else { return } - clearSliderOverride(for: itemname) - } - } - - func clearSliderOverride(for itemname: String) { - guard sliderValueOverrides[itemname] != nil else { return } - sliderOverrideResetTasks[itemname]?.cancel() - sliderOverrideResetTasks[itemname] = nil - objectWillChange.send() - sliderValueOverrides.removeValue(forKey: itemname) - } } extension Published.Publisher where Output: Sendable { diff --git a/openHAB/Supporting Files/Localizable.xcstrings b/openHAB/Supporting Files/Localizable.xcstrings index 8057478e7..04186adfa 100644 --- a/openHAB/Supporting Files/Localizable.xcstrings +++ b/openHAB/Supporting Files/Localizable.xcstrings @@ -11734,6 +11734,10 @@ } } }, + "Sitemap Diagnostics Logging" : { + "comment" : "A toggle that enables or disables logging of sitemap diagnostics.", + "isCommentAutoGenerated" : true + }, "Sitemap for Apple Watch" : { "comment" : "A label for selecting a sitemap to be used with an Apple Watch app.", "localizations" : { diff --git a/openHAB/UI/SettingsView/DebugSettingsView.swift b/openHAB/UI/SettingsView/DebugSettingsView.swift index c3478a547..cad96b5a8 100644 --- a/openHAB/UI/SettingsView/DebugSettingsView.swift +++ b/openHAB/UI/SettingsView/DebugSettingsView.swift @@ -18,6 +18,7 @@ import SwiftUI struct DebugSettingsView: View { @Binding var settingsSendCrashReports: Bool + @Binding var settingsSitemapDiagnosticsLogging: Bool @State private var hasBeenLoaded = false @State var showCrashReportingAlert = false @@ -57,6 +58,7 @@ struct DebugSettingsView: View { Text(LocalizedStringKey("crash_reporting_info")) } Section(header: Text(LocalizedStringKey("debug"))) { + Toggle("Sitemap Diagnostics Logging", isOn: $settingsSitemapDiagnosticsLogging) NavigationLink { LogsViewer() } label: { @@ -80,11 +82,13 @@ struct DebugSettingsView: View { @State private var ignoreSSL = true @State private var idleOff = false @State private var sendCrashReports = false + @State private var sitemapDiagnosticsLogging = false var body: some View { Form { DebugSettingsView( - settingsSendCrashReports: $sendCrashReports + settingsSendCrashReports: $sendCrashReports, + settingsSitemapDiagnosticsLogging: $sitemapDiagnosticsLogging ) } } diff --git a/openHAB/UI/SettingsView/SettingsView.swift b/openHAB/UI/SettingsView/SettingsView.swift index 2c606fb4e..1abbb41b6 100644 --- a/openHAB/UI/SettingsView/SettingsView.swift +++ b/openHAB/UI/SettingsView/SettingsView.swift @@ -20,6 +20,7 @@ struct SettingsView: View { @State private var settingsRealTimeSliders = true @State private var settingsShowSearchField = true @State private var settingsSendCrashReports = false + @State private var settingsSitemapDiagnosticsLogging = false @State private var settingsIconType: IconType = .svg @State private var settingsSortSitemapsBy: SortSitemapsOrder = .label @State private var settingsDefaultMainUIPath = "" @@ -63,7 +64,8 @@ struct SettingsView: View { ) DebugSettingsView( - settingsSendCrashReports: $settingsSendCrashReports + settingsSendCrashReports: $settingsSendCrashReports, + settingsSitemapDiagnosticsLogging: $settingsSitemapDiagnosticsLogging ) AboutSettingsView() @@ -123,6 +125,7 @@ struct SettingsView: View { settingsIdleOff = Preferences.shared.idleOff settingsRealTimeSliders = Preferences.shared.currentHomePreferences.realTimeSliders settingsShowSearchField = Preferences.shared.applicationPreferences.showSearchField + settingsSitemapDiagnosticsLogging = Preferences.shared.applicationPreferences.sitemapDiagnosticsLogging settingsSendCrashReports = Preferences.shared.sendCrashReports settingsIconType = IconType(rawValue: Preferences.shared.currentHomePreferences.iconType) ?? .svg settingsSortSitemapsBy = SortSitemapsOrder(rawValue: Preferences.shared.currentHomePreferences.sortSitemapsBy) ?? .label @@ -154,6 +157,7 @@ struct SettingsView: View { Preferences.shared.modifyApplicationPreferences { @MainActor applicationPreferences in applicationPreferences.showSearchField = settingsShowSearchField + applicationPreferences.sitemapDiagnosticsLogging = settingsSitemapDiagnosticsLogging } // Apply global UI changes immediately (status bar visibility) diff --git a/openHAB/UI/SwiftUI/EmbeddingRowInputView.swift b/openHAB/UI/SwiftUI/EmbeddingRowInputView.swift index 8aada8a2a..f59d7b792 100644 --- a/openHAB/UI/SwiftUI/EmbeddingRowInputView.swift +++ b/openHAB/UI/SwiftUI/EmbeddingRowInputView.swift @@ -81,11 +81,7 @@ private struct LinkedPageRowInputView: View { let input: LinkedPageRowInput var body: some View { - NavigationLink( - destination: SitemapPageView( - viewModel: SitemapPageViewModel(pageUrl: input.linkedPageLink, title: input.linkedPageTitle) - ) - ) { + NavigationLink(value: input.destination) { LinkedPageRowContent(input: input) } .buttonStyle(.plain) @@ -96,9 +92,10 @@ private struct LinkedPageRowContent: View { let input: LinkedPageRowInput var body: some View { + let _ = SitemapDiagnostics.logRender(kind: "linked", identity: input.destination.pageUrl) let displayState = input.displayState HStack { - IconInputView(input: input.icon, rowIdentity: input.widgetId, size: CGSize(width: 32, height: 32)) + IconInputView(input: input.icon, rowIdentity: input.destination.pageUrl, size: CGSize(width: 32, height: 32)) Text(displayState.labelText) .ohTextToken(.rowLabel) @@ -119,6 +116,11 @@ private struct LinkedPageRowContent: View { struct EmbeddingRowInputView: View { let rowInput: SitemapRowInput + // Subscribes to the view model so SwiftUI re-evaluates this view when + // rowInputs change — without this, rows that scroll into view after a + // foreground refresh may render stale data from a cached render. + @EnvironmentObject private var viewModel: SitemapPageViewModel + private var regularRowBackground: Color { Color(UIColor.ohSecondarySystemGroupedBackground) } diff --git a/openHAB/UI/SwiftUI/IconView.swift b/openHAB/UI/SwiftUI/IconView.swift index 9d7bc38a1..32e67c4a4 100644 --- a/openHAB/UI/SwiftUI/IconView.swift +++ b/openHAB/UI/SwiftUI/IconView.swift @@ -40,14 +40,40 @@ actor IconCacheTracker { } } +private struct SitemapPageIdentityKey: EnvironmentKey { + static let defaultValue = "" +} + +private struct SitemapIconConnectionKey: EnvironmentKey { + static let defaultValue = SitemapIconConnection.none +} + +struct SitemapIconConnection: Equatable, Sendable { + let rootUrl: String + let version: Int + + static let none = SitemapIconConnection(rootUrl: "", version: 0) +} + +extension EnvironmentValues { + var sitemapPageIdentity: String { + get { self[SitemapPageIdentityKey.self] } + set { self[SitemapPageIdentityKey.self] = newValue } + } + + var sitemapIconConnection: SitemapIconConnection { + get { self[SitemapIconConnectionKey.self] } + set { self[SitemapIconConnectionKey.self] = newValue } + } +} + struct IconInputView: View { let input: RowIconInput let rowIdentity: String let fallbackSymbol: SFSymbol? - @ObservedObject private var networkTracker = MainActorNetworkTracker.shared @Environment(\.colorScheme) private var colorScheme - @EnvironmentObject var viewModel: SitemapPageViewModel - + @Environment(\.sitemapPageIdentity) private var pageIdentity + @Environment(\.sitemapIconConnection) private var iconConnection let size: CGSize let iconType: IconType = .svg @@ -62,17 +88,14 @@ struct IconInputView: View { private var iconURL: URL? { guard input.showIcon, !input.icon.isEmpty else { return nil } - - guard - let activeConnection = networkTracker.activeConnection, - !activeConnection.configuration.url.isEmpty else { + guard !iconConnection.rootUrl.isEmpty else { logger.debug("No active connection to fetch icon") return nil } return Endpoint.icon( - rootUrl: activeConnection.configuration.url, - version: activeConnection.version, + rootUrl: iconConnection.rootUrl, + version: iconConnection.version, icon: input.icon, state: input.iconState, iconType: iconType, @@ -82,6 +105,7 @@ struct IconInputView: View { } var body: some View { + let _ = SitemapDiagnostics.logRender(kind: "iconInput", identity: "\(pageIdentity)|\(rowIdentity)", detail: input.icon) ZStack { if let fallbackSymbol, currentImage == nil { Image(systemSymbol: fallbackSymbol) @@ -119,7 +143,7 @@ struct IconInputView: View { .cancelOnDisappear(true) .aspectRatio(contentMode: .fit) .frame(width: size.width, height: size.height) - .id("\(viewModel.pageId)-\(rowIdentity)-\(colorScheme)") + .id("\(pageIdentity)-\(rowIdentity)-\(colorScheme)") } } } @@ -140,10 +164,9 @@ struct IconInputView: View { /// A SwiftUI view that displays widget icons with openHAB-specific styling and caching struct IconView: View { @ObservedObject var widget: OpenHABWidget - @ObservedObject private var networkTracker = MainActorNetworkTracker.shared @Environment(\.colorScheme) private var colorScheme - @EnvironmentObject var viewModel: SitemapPageViewModel - + @Environment(\.sitemapPageIdentity) private var pageIdentity + @Environment(\.sitemapIconConnection) private var iconConnection let size: CGSize let iconType: IconType = .svg /// Optional SF Symbol to show as fallback when network icon is unavailable (useful for previews) @@ -161,17 +184,14 @@ struct IconView: View { private var iconURL: URL? { guard !widget.icon.isEmpty else { return nil } - - guard - let activeConnection = networkTracker.activeConnection, - !activeConnection.configuration.url.isEmpty else { + guard !iconConnection.rootUrl.isEmpty else { logger.debug("No active connection to fetch icon") return nil } return Endpoint.icon( - rootUrl: activeConnection.configuration.url, - version: activeConnection.version, + rootUrl: iconConnection.rootUrl, + version: iconConnection.version, icon: widget.icon, state: widget.iconState(), iconType: iconType, @@ -181,6 +201,7 @@ struct IconView: View { } var body: some View { + let _ = SitemapDiagnostics.logRender(kind: "iconWidget", identity: "\(pageIdentity)|\(widget.id)", detail: widget.icon) ZStack { // No icon URL - show fallback symbol if available if let fallbackSymbol { @@ -220,7 +241,7 @@ struct IconView: View { .cancelOnDisappear(true) .aspectRatio(contentMode: .fit) .frame(width: size.width, height: size.height) - .id("\(viewModel.pageId)-\(widget.id)-\(colorScheme)") + .id("\(pageIdentity)-\(widget.id)-\(colorScheme)") } } } diff --git a/openHAB/UI/SwiftUI/RowViewWithIcon.swift b/openHAB/UI/SwiftUI/RowViewWithIcon.swift index 0e8779572..0aa24672f 100644 --- a/openHAB/UI/SwiftUI/RowViewWithIcon.swift +++ b/openHAB/UI/SwiftUI/RowViewWithIcon.swift @@ -16,6 +16,7 @@ import SwiftUI /// caller-provided content inside an `HStack`. struct RowViewWithIcon: View { let input: any RowWithIconInput + let rowIdentity: String let fallbackSymbol: SFSymbol? let alignment: VerticalAlignment let spacing: CGFloat? @@ -24,18 +25,20 @@ struct RowViewWithIcon: View { var body: some View { HStack(alignment: alignment, spacing: spacing) { if input.icon.showIcon { - IconInputView(input: input.icon, rowIdentity: input.widgetId, size: CGSize(width: 32, height: 32), fallbackSymbol: fallbackSymbol) + IconInputView(input: input.icon, rowIdentity: rowIdentity, size: CGSize(width: 32, height: 32), fallbackSymbol: fallbackSymbol) } content() } } init(input: any RowWithIconInput, + rowIdentity: String? = nil, fallbackSymbol: SFSymbol? = .questionmark, alignment: VerticalAlignment = .center, spacing: CGFloat? = nil, @ViewBuilder content: @escaping () -> Content) { self.input = input + self.rowIdentity = rowIdentity ?? input.widgetId self.fallbackSymbol = fallbackSymbol self.alignment = alignment self.spacing = spacing diff --git a/openHAB/UI/SwiftUI/Rows/FrameRowView.swift b/openHAB/UI/SwiftUI/Rows/FrameRowView.swift index 8401ccf5d..f808ded97 100644 --- a/openHAB/UI/SwiftUI/Rows/FrameRowView.swift +++ b/openHAB/UI/SwiftUI/Rows/FrameRowView.swift @@ -54,6 +54,5 @@ struct FrameRowView: View { List([widget]) { widget in FrameRowView(input: FrameRowInput.from(widget: widget)) } - .environmentObject(SitemapPageViewModel()) } #endif diff --git a/openHAB/UI/SwiftUI/Rows/GenericRowView.swift b/openHAB/UI/SwiftUI/Rows/GenericRowView.swift index 5e21c7415..a3e1d3eb1 100644 --- a/openHAB/UI/SwiftUI/Rows/GenericRowView.swift +++ b/openHAB/UI/SwiftUI/Rows/GenericRowView.swift @@ -13,15 +13,6 @@ import CommonUI import OpenHABCore import SwiftUI -private struct GenericRowConfig { - let input: GenericRowInput -} - -@MainActor -private func makeGenericRowContent(_ config: GenericRowConfig) -> GenericRowContent { - GenericRowContent(input: config.input) -} - private struct GenericRowContent: View { let input: GenericRowInput @@ -46,7 +37,7 @@ private struct GenericRowContent: View { struct GenericRowView: View { let input: GenericRowInput var body: some View { - makeGenericRowContent(GenericRowConfig(input: input)) + GenericRowContent(input: input) } } @@ -56,6 +47,5 @@ struct GenericRowView: View { List([widget]) { widget in GenericRowView(input: GenericRowInput.from(widget: widget)) } - .environmentObject(SitemapPageViewModel()) } #endif diff --git a/openHAB/UI/SwiftUI/Rows/ImageRowView.swift b/openHAB/UI/SwiftUI/Rows/ImageRowView.swift index 140c6a8d4..6f99791c1 100644 --- a/openHAB/UI/SwiftUI/Rows/ImageRowView.swift +++ b/openHAB/UI/SwiftUI/Rows/ImageRowView.swift @@ -17,12 +17,12 @@ import SwiftUI private struct ImageRowConfig { let input: MediaRowInput - let viewModel: SitemapPageViewModel + let rootUrl: String } @MainActor private func makeImageRowContent(_ config: ImageRowConfig) -> ImageRowContent { - ImageRowContent(input: config.input, viewModel: config.viewModel) + ImageRowContent(input: config.input, rootUrl: config.rootUrl) } private struct ImageRowContent: View { @@ -32,7 +32,7 @@ private struct ImageRowContent: View { } let input: MediaRowInput - @ObservedObject var viewModel: SitemapPageViewModel + let rootUrl: String @Environment(\.colorScheme) var colorScheme @State private var refreshTimer: Timer? @State private var forceRefreshKey = UUID() @@ -54,7 +54,7 @@ private struct ImageRowContent: View { } private var chartWidgetVersion: Int { - viewModel.widgetUpdateVersion(for: input.widgetId) + input.widgetVersion } private var chartSyncToken: String { @@ -62,11 +62,16 @@ private struct ImageRowContent: View { case .dark: "dark" case .light: "light" } - let rootKey = viewModel.openHABRootUrl ?? "" + let rootKey = rootUrl return "\(isChartByMediaKind)|\(input.widgetId)|\(input.imageDescriptor.period)|\(input.url)|\(rootKey)|\(themeKey)|\(chartWidgetVersion)" } var body: some View { + let _ = SitemapDiagnostics.logRender( + kind: isChartByMediaKind ? "chartImage" : "mediaImage", + identity: input.rowID.rawValue, + detail: "refresh=\(input.refresh)" + ) let displayState = input.displayState VStack(alignment: .leading, spacing: 8) { if !displayState.labelText.isEmpty, input.labelSourceRawValue == OpenHABWidget.LabelSource.sitemapDefinition.rawValue { @@ -141,7 +146,7 @@ private struct ImageRowContent: View { @ViewBuilder private var regularImageView: some View { - switch input.imageDescriptor.resolveImagePayload(rootUrl: viewModel.openHABRootUrl ?? "", chartStyle: chartStyle) { + switch input.imageDescriptor.resolveImagePayload(rootUrl: rootUrl, chartStyle: chartStyle) { case let .embedded(data: data): let cacheKey = if shouldCache { "\(input.widgetId)-\(data.hashValue)" @@ -214,7 +219,7 @@ private struct ImageRowContent: View { } let currentChartKey = makeChartDisplayKey() - switch input.imageDescriptor.resolveImagePayload(rootUrl: viewModel.openHABRootUrl ?? "", chartStyle: chartStyle) { + switch input.imageDescriptor.resolveImagePayload(rootUrl: rootUrl, chartStyle: chartStyle) { case let .link(url): chartDisplayState = ChartDisplayState(key: currentChartKey, url: url) case .embedded, .empty: @@ -227,20 +232,20 @@ private struct ImageRowContent: View { case .dark: "dark" case .light: "light" } - let rootKey = viewModel.openHABRootUrl ?? "" + let rootKey = rootUrl return "\(input.widgetId)|\(input.imageDescriptor.period)|\(themeKey)|\(rootKey)" } } struct ImageRowView: View { let input: MediaRowInput - @EnvironmentObject var viewModel: SitemapPageViewModel + @Environment(\.sitemapIconConnection) private var iconConnection var body: some View { makeImageRowContent( ImageRowConfig( input: input, - viewModel: viewModel + rootUrl: iconConnection.rootUrl ) ) } diff --git a/openHAB/UI/SwiftUI/Rows/MediaRowView.swift b/openHAB/UI/SwiftUI/Rows/MediaRowView.swift index b2389f904..12e889e61 100644 --- a/openHAB/UI/SwiftUI/Rows/MediaRowView.swift +++ b/openHAB/UI/SwiftUI/Rows/MediaRowView.swift @@ -18,6 +18,7 @@ struct MediaRowView: View { private var genericFallbackInput: GenericRowInput { GenericRowInput( widgetId: input.widgetId, + widgetVersion: input.widgetVersion, displayState: input.displayState, labelColor: input.labelColor, valueColor: input.valueColor, diff --git a/openHAB/UI/SwiftUI/Rows/SegmentedRowView.swift b/openHAB/UI/SwiftUI/Rows/SegmentedRowView.swift index 6671a0a0f..91facc2ed 100644 --- a/openHAB/UI/SwiftUI/Rows/SegmentedRowView.swift +++ b/openHAB/UI/SwiftUI/Rows/SegmentedRowView.swift @@ -34,7 +34,7 @@ private struct SegmentedRowContent: View { var body: some View { let selectedIndex = effectiveSelectedIndex(displayState: input.displayState, mappings: input.mappings) - RowViewWithIcon(input: input, fallbackSymbol: fallbackSymbol, spacing: 0) { + RowViewWithIcon(input: input, rowIdentity: input.rowID.rawValue, fallbackSymbol: fallbackSymbol, spacing: 0) { if !input.displayState.labelText.isEmpty { let labelText = input.displayState.labelText Text(labelText) @@ -338,17 +338,16 @@ private struct SegmentedRowContent: View { struct SegmentedRowView: View { let input: SegmentedRowInput var fallbackSymbol: SFSymbol? - - @EnvironmentObject var viewModel: SitemapPageViewModel + @Environment(\.sitemapRowActions) private var rowActions var body: some View { SegmentedRowContent( input: input, - widgetVersion: viewModel.widgetUpdateVersion(for: input.widgetId), + widgetVersion: input.widgetVersion, fallbackSymbol: fallbackSymbol ) { command, policy, phase in guard let itemName = input.itemName else { return } - viewModel.sendCommand(command, for: itemName, policy: policy, phase: phase) + rowActions.sendCommand(itemName, command, policy, phase) } } } @@ -377,13 +376,16 @@ private extension SegmentedRowView { icon: String = "switch", mappings: [OpenHABWidgetMapping], selectedState: String? = nil) -> SegmentedRowInput { - SegmentedRowInput.from(widget: PreviewWidgetFactory.segmented( - label: label, - mappings: mappings, - selectedState: selectedState, - icon: icon, - valueText: detailLabel - )) + SegmentedRowInput.from( + widget: PreviewWidgetFactory.segmented( + label: label, + mappings: mappings, + selectedState: selectedState, + icon: icon, + valueText: detailLabel + ), + rowID: RowID(pageKey: "preview", widgetId: UUID().uuidString, occurrence: 1) + ) } } @@ -577,7 +579,10 @@ private extension SegmentedRowView { #Preview("From PreviewConstants") { PreviewList { SegmentedRowView( - input: SegmentedRowInput.from(widget: PreviewConstants.openHABSitemapPage!.widgets[4]) + input: SegmentedRowInput.from( + widget: PreviewConstants.openHABSitemapPage!.widgets[4], + rowID: RowID(pageKey: "preview", widgetId: UUID().uuidString, occurrence: 1) + ) ) } } diff --git a/openHAB/UI/SwiftUI/Rows/SelectionRowView.swift b/openHAB/UI/SwiftUI/Rows/SelectionRowView.swift index a52897c2c..aeaeb493f 100644 --- a/openHAB/UI/SwiftUI/Rows/SelectionRowView.swift +++ b/openHAB/UI/SwiftUI/Rows/SelectionRowView.swift @@ -58,7 +58,7 @@ private struct SelectionRowContent: View { @ViewBuilder private func rowContent(displayedCommand: String) -> some View { - RowViewWithIcon(input: input) { + RowViewWithIcon(input: input, rowIdentity: input.rowID.rawValue) { if !input.displayState.labelText.isEmpty { let labelText = input.displayState.labelText Text(labelText) @@ -172,16 +172,15 @@ private struct SelectionRowContent: View { struct SelectionRowView: View { let input: SelectionRowInput - - @EnvironmentObject var viewModel: SitemapPageViewModel + @Environment(\.sitemapRowActions) private var rowActions var body: some View { SelectionRowContent( input: input, - widgetVersion: viewModel.widgetUpdateVersion(for: input.widgetId) + widgetVersion: input.widgetVersion ) { command in guard let itemName = input.itemName else { return } - viewModel.sendCommand(command, for: itemName) + rowActions.sendCommand(itemName, command, .immediate, .change) } } } diff --git a/openHAB/UI/SwiftUI/Rows/SliderRowView.swift b/openHAB/UI/SwiftUI/Rows/SliderRowView.swift index 1b058508d..5afb8a9d5 100644 --- a/openHAB/UI/SwiftUI/Rows/SliderRowView.swift +++ b/openHAB/UI/SwiftUI/Rows/SliderRowView.swift @@ -16,36 +16,35 @@ import SwiftUI private struct SliderRowConfig { let input: SliderRowInput - let widgetVersion: Int - let overrideValue: Double? let fallbackSymbol: SFSymbol? - let viewModel: SitemapPageViewModel + let rowActions: SitemapRowActions } @MainActor private func makeSliderRowContent(_ config: SliderRowConfig) -> SliderRowContent { SliderRowContent( input: config.input, - widgetVersion: config.widgetVersion, - overrideValue: config.overrideValue, + widgetVersion: config.input.widgetVersion, fallbackSymbol: config.fallbackSymbol, onToggleSwitch: { command in guard let itemName = config.input.itemName else { return } - config.viewModel.sendCommand(command, for: itemName) + config.rowActions.sendCommand(itemName, command, .immediate, .change) }, onCancelPending: { key in guard let itemName = config.input.itemName else { return } - config.viewModel.cancelPendingCommand(for: itemName, key: key) + config.rowActions.cancelPendingCommand(itemName, key) }, onSendValue: { value, policy, phase, key in guard let itemName = config.input.itemName else { return } - config.viewModel.setSliderOverrideValue(value, for: itemName) - let numberState = NumberState( - value: value, - unit: config.input.unit, - format: config.input.numberPattern + config.rowActions.sendNumericUpdate( + itemName, + value, + config.input.unit, + config.input.numberPattern, + policy, + phase, + key ) - config.viewModel.sendToUpdate(itemname: itemName, state: numberState, policy: policy, phase: phase, key: key) } ) } @@ -53,7 +52,6 @@ private func makeSliderRowContent(_ config: SliderRowConfig) -> SliderRowContent private struct SliderRowContent: View { let input: SliderRowInput let widgetVersion: Int - let overrideValue: Double? let fallbackSymbol: SFSymbol? let onToggleSwitch: (String) -> Void let onCancelPending: (String?) -> Void @@ -64,6 +62,7 @@ private struct SliderRowContent: View { @State private var isEditing = false @State private var dragStartVersion: Int? @State private var dragWidgetId: String? + @State private var optimisticValue: Double? var body: some View { let displayedSliderValue = effectiveValue(state: input) @@ -97,6 +96,7 @@ private struct SliderRowContent: View { .release, input.sliderCommandKey ) + optimisticValue = displayedSliderValue dragValue = nil dragStartVersion = nil dragWidgetId = nil @@ -108,14 +108,22 @@ private struct SliderRowContent: View { dragValue = nil dragStartVersion = nil dragWidgetId = nil + optimisticValue = nil } .onChange(of: input.widgetId) { _ in isEditing = false dragValue = nil dragStartVersion = nil dragWidgetId = nil + optimisticValue = nil } .onChange(of: widgetVersion) { _ in + let threshold = max(input.step, 0.001) + if !isEditing, optimisticValue != nil { + optimisticValue = nil + return + } + guard isEditing else { return } // If server refresh advanced while dragging, only cancel when the server value diverges // meaningfully from the local drag value. This avoids jarring cancels on polling echoes. @@ -128,7 +136,6 @@ private struct SliderRowContent: View { } if widgetVersion != dragStartVersion { - let threshold = max(input.step, 0.001) let currentDragValue = dragValue ?? input.serverValue let hasMeaningfulServerChange = abs(input.serverValue - currentDragValue) > threshold if hasMeaningfulServerChange { @@ -148,7 +155,7 @@ private struct SliderRowContent: View { private func labelContent(state: SliderRowInput) -> some View { let displayedValue = effectiveValue(state: state) let currentValueText = currentValueText(state: state, value: displayedValue) - RowViewWithIcon(input: state, fallbackSymbol: fallbackSymbol) { + RowViewWithIcon(input: state, rowIdentity: state.rowID.rawValue, fallbackSymbol: fallbackSymbol) { if !state.displayState.labelText.isEmpty { let labelText = state.displayState.labelText Text(labelText) @@ -190,7 +197,10 @@ private struct SliderRowContent: View { if isEditing { return dragValue ?? state.serverValue } - return overrideValue ?? state.serverValue + if let optimisticValue { + return optimisticValue + } + return state.serverValue } private func currentValueText(state: SliderRowInput, value: Double) -> String { @@ -211,17 +221,14 @@ private struct SliderRowContent: View { struct SliderRowView: View { let input: SliderRowInput var fallbackSymbol: SFSymbol? - - @EnvironmentObject var viewModel: SitemapPageViewModel + @Environment(\.sitemapRowActions) private var rowActions var body: some View { makeSliderRowContent( SliderRowConfig( input: input, - widgetVersion: viewModel.widgetUpdateVersion(for: input.widgetId), - overrideValue: viewModel.sliderOverrideValue(for: input.itemName), fallbackSymbol: fallbackSymbol, - viewModel: viewModel + rowActions: rowActions ) ) } @@ -245,7 +252,7 @@ private extension SliderRowView { step: step, icon: icon, switchSupport: switchSupport - )) + ), rowID: RowID(pageKey: "preview", widgetId: UUID().uuidString, occurrence: 1)) } } @@ -335,7 +342,10 @@ private extension SliderRowView { #Preview("From PreviewConstants") { PreviewList { SliderRowView( - input: SliderRowInput.from(widget: PreviewConstants.openHABSitemapPage!.widgets[3]), + input: SliderRowInput.from( + widget: PreviewConstants.openHABSitemapPage!.widgets[3], + rowID: RowID(pageKey: "preview", widgetId: UUID().uuidString, occurrence: 1) + ), fallbackSymbol: .sliderHorizontal3 ) } diff --git a/openHAB/UI/SwiftUI/Rows/TextRowView.swift b/openHAB/UI/SwiftUI/Rows/TextRowView.swift index 31c168977..57b96f20c 100644 --- a/openHAB/UI/SwiftUI/Rows/TextRowView.swift +++ b/openHAB/UI/SwiftUI/Rows/TextRowView.swift @@ -15,15 +15,6 @@ import OpenHABCore import SFSafeSymbols import SwiftUI -private struct TextRowConfig { - let input: TextRowInput -} - -@MainActor -private func makeTextRowContent(_ config: TextRowConfig) -> TextRowContent { - TextRowContent(input: config.input) -} - private struct TextRowContent: View { let input: TextRowInput @@ -66,7 +57,7 @@ struct TextRowView: View { let input: TextRowInput var body: some View { - makeTextRowContent(TextRowConfig(input: input)) + TextRowContent(input: input) } } @@ -77,6 +68,5 @@ struct TextRowView: View { TextRowView(input: TextRowInput.from(widget: widget)) Spacer() } - .environmentObject(SitemapPageViewModel()) } #endif diff --git a/openHAB/UI/SwiftUI/Rows/VideoRowView.swift b/openHAB/UI/SwiftUI/Rows/VideoRowView.swift index 23274516d..ce2235364 100644 --- a/openHAB/UI/SwiftUI/Rows/VideoRowView.swift +++ b/openHAB/UI/SwiftUI/Rows/VideoRowView.swift @@ -40,6 +40,7 @@ private struct VideoRowContent: View { @State private var currentStreamUrl: URL? @State private var imageObservationTimer: Timer? @State private var playerObserver: NSKeyValueObservation? + @State private var cleanupTask: Task? private let logger = Logger(subsystem: "org.openhab", category: "VideoRowView") @@ -98,12 +99,14 @@ private struct VideoRowContent: View { } .frame(maxWidth: .infinity) .onAppear { + cancelScheduledCleanup() setupVideo(url: videoURL) } .onDisappear { - cleanup() + scheduleCleanup() } .onChange(of: input.url) { newValue in + cancelScheduledCleanup() if !newValue.isEmpty, let newURL = URL(string: newValue) { setupVideo(url: newURL) } else { @@ -132,6 +135,8 @@ private struct VideoRowContent: View { @MainActor private func setupVideo(url: URL) { + cancelScheduledCleanup() + // Avoid redundant setup if URL hasn't changed if currentStreamUrl?.absoluteString == url.absoluteString { return @@ -149,6 +154,20 @@ private struct VideoRowContent: View { } } + private func scheduleCleanup() { + cleanupTask?.cancel() + cleanupTask = Task { @MainActor in + try? await Task.sleep(for: .seconds(1)) + guard !Task.isCancelled else { return } + cleanup() + } + } + + private func cancelScheduledCleanup() { + cleanupTask?.cancel() + cleanupTask = nil + } + @MainActor private func setupMJPEG(url: URL) { // Create a dummy UIImageView for the SimpleMJPEGPlayer @@ -223,6 +242,8 @@ private struct VideoRowContent: View { } private func cleanup() { + cancelScheduledCleanup() + // Clean up timer imageObservationTimer?.invalidate() imageObservationTimer = nil diff --git a/openHAB/UI/SwiftUI/SitemapDiagnostics.swift b/openHAB/UI/SwiftUI/SitemapDiagnostics.swift new file mode 100644 index 000000000..e6819a821 --- /dev/null +++ b/openHAB/UI/SwiftUI/SitemapDiagnostics.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 Foundation +import OpenHABCore +import os.log + +@MainActor +enum SitemapDiagnostics { + private static let logger = Logger(subsystem: "org.openhab", category: "SitemapDiagnostics") + + static var isEnabled: Bool { + Preferences.shared.applicationPreferences.sitemapDiagnosticsLogging + } + + static func logUpdate( + origin: PageUpdateOrigin, + widgetCount: Int, + rowCount: Int, + inputsChanged: Bool, + titleChanged: Bool, + changedRowCount: Int, + changedRowKinds: String, + mapDurationMs: Int, + diffDurationMs: Int + ) { + guard isEnabled else { return } + logger.info( + """ + update origin=\(origin.rawValue, privacy: .public) widgets=\(widgetCount, privacy: .public) rows=\(rowCount, privacy: .public) \ + inputsChanged=\(inputsChanged, privacy: .public) titleChanged=\(titleChanged, privacy: .public) \ + changedRows=\(changedRowCount, privacy: .public) changedKinds=\(changedRowKinds, privacy: .public) \ + mapMs=\(mapDurationMs, privacy: .public) diffMs=\(diffDurationMs, privacy: .public) + """ + ) + } + + static func logPublishedRows(rowCount: Int, changedRowCount: Int) { + guard isEnabled else { return } + logger.info("rowInputs published rows=\(rowCount, privacy: .public) changedRows=\(changedRowCount, privacy: .public)") + } + + static func logWidgetVersions(changedRowCount: Int, changedRowKinds: String) { + guard isEnabled else { return } + logger.info("widgetVersions changedRows=\(changedRowCount, privacy: .public) changedKinds=\(changedRowKinds, privacy: .public)") + } + + static func logRender(kind: String, identity: String, detail: String = "") { + guard isEnabled else { return } + logger.debug("render kind=\(kind, privacy: .public) identity=\(identity, privacy: .private(mask: .hash)) detail=\(detail, privacy: .public)") + } + + static func changedRowKinds(from oldInputs: [SitemapRowInput], to newInputs: [SitemapRowInput]) -> String { + let changedKinds: [String] + if newInputs.count == oldInputs.count { + changedKinds = zip(newInputs, oldInputs) + .compactMap { newInput, oldInput in + newInput != oldInput ? rowKind(for: newInput) : nil + } + } else { + changedKinds = newInputs.map(rowKind(for:)) + } + + guard !changedKinds.isEmpty else { return "none" } + + let counts = changedKinds.reduce(into: [String: Int]()) { counts, kind in + counts[kind, default: 0] += 1 + } + + return counts.keys.sorted().map { kind in + "\(kind):\(counts[kind, default: 0])" + }.joined(separator: ",") + } + + static func rowKind(for input: SitemapRowInput) -> String { + switch input { + case .frame: + "frame" + case .linked: + "linked" + case .text: + "text" + case .slider: + "slider" + case .selection: + "selection" + case .segmented: + "segmented" + case .setpoint: + "setpoint" + case .rollershutter: + "rollershutter" + case .toggle: + "toggle" + case .input: + "input" + case .colorPicker: + "colorPicker" + case .media: + "media" + case .colorTemperature: + "colorTemperature" + case .buttonGrid: + "buttonGrid" + case .generic: + "generic" + } + } +} diff --git a/openHAB/UI/SwiftUI/SitemapNavigationView.swift b/openHAB/UI/SwiftUI/SitemapNavigationView.swift index 5861fa830..c2b315dd1 100644 --- a/openHAB/UI/SwiftUI/SitemapNavigationView.swift +++ b/openHAB/UI/SwiftUI/SitemapNavigationView.swift @@ -44,8 +44,6 @@ struct SitemapNavigationView: View { @ViewBuilder private var sitemapContent: some View { let page = SitemapPageView(viewModel: viewModel) - .navigationTitle(viewModel.pageTitle) - .navigationBarTitleDisplayMode(.automatic) .toolbar { if !isInteractionIdle { ToolbarItem(placement: .navigationBarLeading) { diff --git a/openHAB/UI/SwiftUI/SitemapPageView.swift b/openHAB/UI/SwiftUI/SitemapPageView.swift index 121629c18..062d788ec 100644 --- a/openHAB/UI/SwiftUI/SitemapPageView.swift +++ b/openHAB/UI/SwiftUI/SitemapPageView.swift @@ -18,10 +18,6 @@ struct SitemapPageView: View { @StateObject var viewModel = SitemapPageViewModel() @State private var idleTimerDisabled = false - private var isLinkedPage: Bool { - viewModel.isLinked - } - var body: some View { Group { if viewModel.isLoading, viewModel.rowInputs.isEmpty { @@ -40,6 +36,24 @@ struct SitemapPageView: View { } } .environmentObject(viewModel) + .environment(\.sitemapPageIdentity, viewModel.pageId) + .environment(\.sitemapIconConnection, viewModel.sitemapIconConnection) + .environment(\.sitemapRowActions, SitemapRowActions( + sendCommand: { itemName, command, policy, phase in + viewModel.sendCommand(command, for: itemName, policy: policy, phase: phase) + }, + cancelPendingCommand: { itemName, key in + viewModel.cancelPendingCommand(for: itemName, key: key) + }, + sendNumericUpdate: { itemName, value, unit, format, policy, phase, key in + let state = NumberState( + value: value, + unit: unit, + format: format + ) + viewModel.sendToUpdate(itemname: itemName, state: state, policy: policy, phase: phase, key: key) + } + )) .listStyle(.plain) .listRowSpacing(0) .environment(\.defaultMinListRowHeight, 32) @@ -64,6 +78,14 @@ struct SitemapPageView: View { UIApplication.shared.isIdleTimerDisabled = false } } + .navigationDestination(for: LinkedPageDestination.self) { destination in + SitemapPageView( + viewModel: SitemapPageViewModel( + pageUrl: destination.pageUrl, + title: destination.title + ) + ) + } .navigationTitle(viewModel.pageTitle) .navigationBarTitleDisplayMode(.large) .alert("Error", isPresented: Binding( diff --git a/openHAB/UI/SwiftUI/SitemapRowActions.swift b/openHAB/UI/SwiftUI/SitemapRowActions.swift new file mode 100644 index 000000000..1934f9164 --- /dev/null +++ b/openHAB/UI/SwiftUI/SitemapRowActions.swift @@ -0,0 +1,36 @@ +// 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 SitemapRowActions: Sendable { + var sendCommand: @MainActor @Sendable (_ itemName: String, _ command: String, _ policy: WidgetCommandPolicy, _ phase: WidgetCommandPhase) -> Void + var cancelPendingCommand: @MainActor @Sendable (_ itemName: String, _ key: String?) -> Void + var sendNumericUpdate: @MainActor @Sendable (_ itemName: String, _ value: Double, _ unit: String?, _ format: String?, _ policy: WidgetCommandPolicy, _ phase: WidgetCommandPhase, _ key: String?) -> Void + + static let noop = SitemapRowActions( + sendCommand: { _, _, _, _ in }, + cancelPendingCommand: { _, _ in }, + sendNumericUpdate: { _, _, _, _, _, _, _ in } + ) +} + +private struct SitemapRowActionsKey: EnvironmentKey { + static let defaultValue = SitemapRowActions.noop +} + +extension EnvironmentValues { + var sitemapRowActions: SitemapRowActions { + get { self[SitemapRowActionsKey.self] } + set { self[SitemapRowActionsKey.self] = newValue } + } +} diff --git a/openHAB/UI/SwiftUI/SitemapRowInput.swift b/openHAB/UI/SwiftUI/SitemapRowInput.swift index 6607939c4..43eb84533 100644 --- a/openHAB/UI/SwiftUI/SitemapRowInput.swift +++ b/openHAB/UI/SwiftUI/SitemapRowInput.swift @@ -68,3 +68,35 @@ enum SitemapRowInput: Identifiable, Equatable, Sendable { } } } + +extension SitemapRowInput { + func applyingWidgetVersions(_ versions: [String: Int]) -> SitemapRowInput { + let version = versions[rowID.rawValue] ?? 0 + + switch self { + case let .slider(rowID, input): + return .slider(rowID, input.withWidgetVersion(version)) + case let .selection(rowID, input): + return .selection(rowID, input.withWidgetVersion(version)) + case let .segmented(rowID, input): + return .segmented(rowID, input.withWidgetVersion(version)) + case let .media(rowID, input): + return .media(rowID, input.withWidgetVersion(version)) + case let .text(rowID, input): + return .text(rowID, input.withWidgetVersion(version)) + case let .linked(rowID, input): + return .linked(rowID, input.withWidgetVersion(version)) + case let .generic(rowID, input): + return .generic(rowID, input.withWidgetVersion(version)) + case .frame, + .setpoint, + .rollershutter, + .toggle, + .input, + .colorPicker, + .colorTemperature, + .buttonGrid: + return self + } + } +} diff --git a/openHAB/UI/SwiftUI/SitemapRowInputMapper.swift b/openHAB/UI/SwiftUI/SitemapRowInputMapper.swift index 051e6c4b8..ddfd08ca1 100644 --- a/openHAB/UI/SwiftUI/SitemapRowInputMapper.swift +++ b/openHAB/UI/SwiftUI/SitemapRowInputMapper.swift @@ -37,11 +37,11 @@ enum SitemapRowInputMapper { switch widget.renderingKind { case .slider: - return SitemapRowInput.slider(rowID, SliderRowInput.from(widget: widget)) + return SitemapRowInput.slider(rowID, SliderRowInput.from(widget: widget, rowID: rowID)) case .selection: - return SitemapRowInput.selection(rowID, SelectionRowInput.from(widget: widget)) + return SitemapRowInput.selection(rowID, SelectionRowInput.from(widget: widget, rowID: rowID)) case .segmentedSwitch: - return SitemapRowInput.segmented(rowID, SegmentedRowInput.from(widget: widget)) + return SitemapRowInput.segmented(rowID, SegmentedRowInput.from(widget: widget, rowID: rowID)) case .frame: return SitemapRowInput.frame(rowID, FrameRowInput.from(widget: widget)) case .text: @@ -57,7 +57,7 @@ enum SitemapRowInputMapper { case .colorPicker: return SitemapRowInput.colorPicker(rowID, ColorPickerRowInput.from(widget: widget)) case .image, .chart, .video, .webview, .mapview: - return SitemapRowInput.media(rowID, MediaRowInput.from(widget: widget)) + return SitemapRowInput.media(rowID, MediaRowInput.from(widget: widget, rowID: rowID)) case .colorTemperaturePicker: return SitemapRowInput.colorTemperature(rowID, ColorTemperatureRowInput.from(widget: widget)) case .buttonGrid: diff --git a/openHAB/UI/SwiftUI/WidgetRowInputs.swift b/openHAB/UI/SwiftUI/WidgetRowInputs.swift index e6f8fc64a..62ca08b84 100644 --- a/openHAB/UI/SwiftUI/WidgetRowInputs.swift +++ b/openHAB/UI/SwiftUI/WidgetRowInputs.swift @@ -16,6 +16,11 @@ protocol RowWithIconInput { var icon: RowIconInput { get } } +struct LinkedPageDestination: Hashable, Sendable { + let pageUrl: String + let title: String +} + struct RowIconInput: Equatable, Sendable { let icon: String let iconColor: String @@ -44,6 +49,8 @@ struct RowIconInput: Equatable, Sendable { } struct SelectionRowInput: Equatable, RowWithIconInput { + let rowID: RowID + let widgetVersion: Int let displayState: WidgetDisplayState let mappings: [OpenHABWidgetMapping] let labelColor: String @@ -53,9 +60,11 @@ struct SelectionRowInput: Equatable, RowWithIconInput { let icon: RowIconInput let itemName: String? - static func from(widget: OpenHABWidget) -> SelectionRowInput { + static func from(widget: OpenHABWidget, rowID: RowID) -> SelectionRowInput { let displayState = widget.displayState return SelectionRowInput( + rowID: rowID, + widgetVersion: 0, displayState: displayState, mappings: displayState.mappings, labelColor: widget.labelcolor, @@ -66,9 +75,26 @@ struct SelectionRowInput: Equatable, RowWithIconInput { itemName: widget.item?.name ) } + + func withWidgetVersion(_ widgetVersion: Int) -> SelectionRowInput { + SelectionRowInput( + rowID: rowID, + widgetVersion: widgetVersion, + displayState: displayState, + mappings: mappings, + labelColor: labelColor, + valueColor: valueColor, + readOnly: readOnly, + widgetId: widgetId, + icon: icon, + itemName: itemName + ) + } } struct SegmentedRowInput: Equatable, RowWithIconInput { + let rowID: RowID + let widgetVersion: Int let displayState: WidgetDisplayState let mappings: [OpenHABWidgetMapping] let labelColor: String @@ -77,9 +103,11 @@ struct SegmentedRowInput: Equatable, RowWithIconInput { let icon: RowIconInput let itemName: String? - static func from(widget: OpenHABWidget) -> SegmentedRowInput { + static func from(widget: OpenHABWidget, rowID: RowID) -> SegmentedRowInput { let displayState = widget.displayState return SegmentedRowInput( + rowID: rowID, + widgetVersion: 0, displayState: displayState, mappings: displayState.mappings, labelColor: widget.labelcolor, @@ -89,6 +117,20 @@ struct SegmentedRowInput: Equatable, RowWithIconInput { itemName: widget.item?.name ) } + + func withWidgetVersion(_ widgetVersion: Int) -> SegmentedRowInput { + SegmentedRowInput( + rowID: rowID, + widgetVersion: widgetVersion, + displayState: displayState, + mappings: mappings, + labelColor: labelColor, + valueColor: valueColor, + widgetId: widgetId, + icon: icon, + itemName: itemName + ) + } } struct SetpointRowInput: Equatable, RowWithIconInput { @@ -318,6 +360,7 @@ struct ButtonGridRowInput: Equatable, RowWithIconInput { struct GenericRowInput: Equatable, RowWithIconInput { let widgetId: String + let widgetVersion: Int let displayState: WidgetDisplayState let labelColor: String let valueColor: String @@ -326,37 +369,65 @@ struct GenericRowInput: Equatable, RowWithIconInput { static func from(widget: OpenHABWidget) -> GenericRowInput { GenericRowInput( widgetId: widget.widgetId, + widgetVersion: 0, displayState: widget.displayState, labelColor: widget.labelcolor, valueColor: widget.valuecolor, icon: RowIconInput.from(widget: widget) ) } + + func withWidgetVersion(_ widgetVersion: Int) -> GenericRowInput { + GenericRowInput( + widgetId: widgetId, + widgetVersion: widgetVersion, + displayState: displayState, + labelColor: labelColor, + valueColor: valueColor, + icon: icon + ) + } } struct LinkedPageRowInput: Equatable, RowWithIconInput { let widgetId: String + let widgetVersion: Int let displayState: WidgetDisplayState let labelColor: String let valueColor: String let icon: RowIconInput - let linkedPageLink: String - let linkedPageTitle: String + let destination: LinkedPageDestination let isFrame: Bool static func from(widget: OpenHABWidget) -> LinkedPageRowInput? { guard let linkedPage = widget.linkedPage else { return nil } return LinkedPageRowInput( widgetId: widget.widgetId, + widgetVersion: 0, displayState: widget.displayState, labelColor: widget.labelcolor, valueColor: widget.valuecolor, icon: RowIconInput.from(widget: widget), - linkedPageLink: linkedPage.link, - linkedPageTitle: linkedPage.title, + destination: LinkedPageDestination( + pageUrl: linkedPage.link, + title: linkedPage.title + ), isFrame: widget.type == .frame ) } + + func withWidgetVersion(_ widgetVersion: Int) -> LinkedPageRowInput { + LinkedPageRowInput( + widgetId: widgetId, + widgetVersion: widgetVersion, + displayState: displayState, + labelColor: labelColor, + valueColor: valueColor, + icon: icon, + destination: destination, + isFrame: isFrame + ) + } } struct ColorTemperatureRowInput: Equatable, RowWithIconInput { @@ -408,6 +479,8 @@ struct ColorTemperatureRowInput: Equatable, RowWithIconInput { } struct MediaRowInput: Equatable { + let rowID: RowID + let widgetVersion: Int let widgetId: String let renderingKind: WidgetRenderingKind let displayState: WidgetDisplayState @@ -423,12 +496,14 @@ struct MediaRowInput: Equatable { let coordinateLatitude: Double? let coordinateLongitude: Double? - static func from(widget: OpenHABWidget) -> MediaRowInput { + static func from(widget: OpenHABWidget, rowID: RowID) -> MediaRowInput { let coordinate = widget.coordinate let hasValidCoordinate = (-90.0 ... 90.0).contains(coordinate.latitude) && (-180.0 ... 180.0).contains(coordinate.longitude) return MediaRowInput( + rowID: rowID, + widgetVersion: 0, widgetId: widget.widgetId, renderingKind: widget.renderingKind, displayState: widget.displayState, @@ -445,6 +520,27 @@ struct MediaRowInput: Equatable { coordinateLongitude: hasValidCoordinate ? coordinate.longitude : nil ) } + + func withWidgetVersion(_ widgetVersion: Int) -> MediaRowInput { + MediaRowInput( + rowID: rowID, + widgetVersion: widgetVersion, + widgetId: widgetId, + renderingKind: renderingKind, + displayState: displayState, + imageDescriptor: imageDescriptor, + labelColor: labelColor, + valueColor: valueColor, + readOnly: readOnly, + refresh: refresh, + url: url, + encoding: encoding, + labelSourceRawValue: labelSourceRawValue, + preferredRowHeight: preferredRowHeight, + coordinateLatitude: coordinateLatitude, + coordinateLongitude: coordinateLongitude + ) + } } struct FrameRowInput: Equatable { @@ -461,6 +557,7 @@ struct FrameRowInput: Equatable { struct TextRowInput: Equatable, RowWithIconInput { let widgetId: String + let widgetVersion: Int let displayState: WidgetDisplayState let labelColor: String let valueColor: String @@ -469,15 +566,29 @@ struct TextRowInput: Equatable, RowWithIconInput { static func from(widget: OpenHABWidget) -> TextRowInput { TextRowInput( widgetId: widget.widgetId, + widgetVersion: 0, displayState: widget.displayState, labelColor: widget.labelcolor, valueColor: widget.valuecolor, icon: RowIconInput.from(widget: widget) ) } + + func withWidgetVersion(_ widgetVersion: Int) -> TextRowInput { + TextRowInput( + widgetId: widgetId, + widgetVersion: widgetVersion, + displayState: displayState, + labelColor: labelColor, + valueColor: valueColor, + icon: icon + ) + } } struct SliderRowInput: Equatable, RowWithIconInput { + let rowID: RowID + let widgetVersion: Int let widgetId: String let displayState: WidgetDisplayState let numberPattern: String? @@ -500,7 +611,7 @@ struct SliderRowInput: Equatable, RowWithIconInput { displayState.minValue ... displayState.maxValue } - static func from(widget: OpenHABWidget) -> SliderRowInput { + static func from(widget: OpenHABWidget, rowID: RowID) -> SliderRowInput { let displayState = widget.displayState let numberPattern = if let pattern = widget.pattern, !pattern.isEmpty { pattern @@ -515,6 +626,8 @@ struct SliderRowInput: Equatable, RowWithIconInput { } return SliderRowInput( + rowID: rowID, + widgetVersion: 0, widgetId: widget.widgetId, displayState: displayState, numberPattern: numberPattern, @@ -531,6 +644,26 @@ struct SliderRowInput: Equatable, RowWithIconInput { ) } + func withWidgetVersion(_ widgetVersion: Int) -> SliderRowInput { + SliderRowInput( + rowID: rowID, + widgetVersion: widgetVersion, + widgetId: widgetId, + displayState: displayState, + numberPattern: numberPattern, + unit: unit, + readOnly: readOnly, + switchSupport: switchSupport, + step: step, + labelColor: labelColor, + valueColor: valueColor, + shouldSendUpdatesDuringMove: shouldSendUpdatesDuringMove, + serverValue: serverValue, + icon: icon, + itemName: itemName + ) + } + private static func resolvedSliderUnit(from widget: OpenHABWidget) -> String? { if let explicitUnit = widget.unit, !explicitUnit.isEmpty { return explicitUnit diff --git a/openHABTestsSwift/SitemapPageViewModelDiffingTests.swift b/openHABTestsSwift/SitemapPageViewModelDiffingTests.swift new file mode 100644 index 000000000..647c6264a --- /dev/null +++ b/openHABTestsSwift/SitemapPageViewModelDiffingTests.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 + +@testable import openHAB +import Combine +import OpenHABCore +import Testing + +/// Tests that the row-input and widget-version pipelines avoid unnecessary +/// @Published writes when incoming data is unchanged. +@MainActor +@Suite +struct SitemapPageViewModelDiffingTests { + // MARK: rowInputs diffing + + @Test + func rebuildRowInputsProducesStableOutputForUnchangedWidgets() { + let widgets = [makeTextWidget(widgetID: "w1", label: "Light"), makeTextWidget(widgetID: "w2", label: "Temp")] + let viewModel = SitemapPageViewModel(title: "Test", widgets: widgets) + + let first = viewModel.rowInputs + viewModel.rebuildRowInputs() + let second = viewModel.rowInputs + + #expect(first == second) + } + + @Test + func rebuildRowInputsDoesNotFireObjectWillChangeWhenUnchanged() { + let widgets = [makeTextWidget(widgetID: "w1", label: "Light")] + let viewModel = SitemapPageViewModel(title: "Test", widgets: widgets) + + var changeCount = 0 + let cancellable = viewModel.objectWillChange.sink { changeCount += 1 } + + let countBefore = changeCount + // Second rebuild with identical widgets — guard should suppress the assignment. + viewModel.rebuildRowInputs() + #expect(changeCount == countBefore) + + withExtendedLifetime(cancellable) {} + } + + @Test + func rebuildRowInputsFiresObjectWillChangeWhenWidgetsChange() { + let widget = makeTextWidget(widgetID: "w1", label: "Light") + let viewModel = SitemapPageViewModel(title: "Test", widgets: [widget]) + + var changeCount = 0 + let cancellable = viewModel.objectWillChange.sink { changeCount += 1 } + + let countBefore = changeCount + + // Swap in a differently-labelled widget and rebuild. + let changed = makeTextWidget(widgetID: "w1", label: "Updated") + viewModel.currentPage = makePage(widgets: [changed]) + viewModel.rebuildRowInputs() + + #expect(changeCount > countBefore) + + withExtendedLifetime(cancellable) {} + } + + @Test + func rebuildRowInputsHandlesStructureChange() { + let widgets = [makeTextWidget(widgetID: "w1", label: "One"), makeTextWidget(widgetID: "w2", label: "Two")] + let viewModel = SitemapPageViewModel(title: "Test", widgets: widgets) + + #expect(viewModel.rowInputs.count == 2) + + viewModel.currentPage = makePage(widgets: [makeTextWidget(widgetID: "w1", label: "One")]) + viewModel.rebuildRowInputs() + + #expect(viewModel.rowInputs.count == 1) + } + + // MARK: bumpWidgetVersions + + @Test + func bumpWidgetVersionsDoesNotIncrementForIdenticalInputs() { + let viewModel = SitemapPageViewModel(title: "Test", widgets: [makeTextWidget(widgetID: "w1", label: "Temp")]) + let inputs = viewModel.rowInputs + let before = viewModel.widgetUpdateVersion(for: inputs[0].rowID) + + viewModel.bumpWidgetVersions(from: inputs, to: inputs) + + #expect(viewModel.widgetUpdateVersion(for: inputs[0].rowID) == before) + } + + @Test + func bumpWidgetVersionsIncrementsOnlyForChangedRow() { + let w1 = makeTextWidget(widgetID: "w1", label: "Stable") + let w2 = makeTextWidget(widgetID: "w2", label: "Changing") + let viewModel = SitemapPageViewModel(title: "Test", widgets: [w1, w2]) + + let oldInputs = viewModel.rowInputs + + // Rebuild with w2 changed. + viewModel.currentPage = makePage(widgets: [w1, makeTextWidget(widgetID: "w2", label: "Updated")]) + viewModel.rebuildRowInputs() + let newInputs = viewModel.rowInputs + + let v1Before = viewModel.widgetUpdateVersion(for: oldInputs[0].rowID) + let v2Before = viewModel.widgetUpdateVersion(for: oldInputs[1].rowID) + + viewModel.bumpWidgetVersions(from: oldInputs, to: newInputs) + + #expect(viewModel.widgetUpdateVersion(for: newInputs[0].rowID) == v1Before, "Stable widget version must not change") + #expect(viewModel.widgetUpdateVersion(for: newInputs[1].rowID) > v2Before, "Changed widget version must increment") + } + + @Test + func bumpWidgetVersionsIncrementsAllRowsOnStructureChange() { + let viewModel = SitemapPageViewModel( + title: "Test", + widgets: [makeTextWidget(widgetID: "w1", label: "One")] + ) + let oldInputs = viewModel.rowInputs + + viewModel.currentPage = makePage(widgets: [ + makeTextWidget(widgetID: "w1", label: "One"), + makeTextWidget(widgetID: "w2", label: "Two") + ]) + viewModel.rebuildRowInputs() + let newInputs = viewModel.rowInputs + + let v1Before = viewModel.widgetUpdateVersion(for: oldInputs[0].rowID) + let v2Before = 0 + + viewModel.bumpWidgetVersions(from: oldInputs, to: newInputs) + + // Count changed — all new rows should get a bump. + #expect(viewModel.widgetUpdateVersion(for: newInputs[0].rowID) > v1Before) + #expect(viewModel.widgetUpdateVersion(for: newInputs[1].rowID) > v2Before) + } + + @Test + func bumpWidgetVersionsHandlesRepeatedWidgetIDsIndependently() { + let oldWidgets = [ + makeTextWidget(widgetID: "dup", label: "One"), + makeTextWidget(widgetID: "dup", label: "Two") + ] + let viewModel = SitemapPageViewModel(title: "Test", widgets: oldWidgets) + let oldInputs = viewModel.rowInputs + + viewModel.currentPage = makePage(widgets: [ + makeTextWidget(widgetID: "dup", label: "One"), + makeTextWidget(widgetID: "dup", label: "Updated") + ]) + viewModel.rebuildRowInputs() + let newInputs = viewModel.rowInputs + + let firstBefore = viewModel.widgetUpdateVersion(for: oldInputs[0].rowID) + let secondBefore = viewModel.widgetUpdateVersion(for: oldInputs[1].rowID) + + viewModel.bumpWidgetVersions(from: oldInputs, to: newInputs) + + #expect(viewModel.widgetUpdateVersion(for: newInputs[0].rowID) == firstBefore) + #expect(viewModel.widgetUpdateVersion(for: newInputs[1].rowID) > secondBefore) + } +} + +// MARK: - Helpers + +private extension SitemapPageViewModelDiffingTests { + func makeTextWidget(widgetID: String, label: String) -> OpenHABWidget { + let widget = OpenHABWidget() + widget.widgetId = widgetID + widget.type = .text + widget.label = label + return widget + } + + func makePage(widgets: [OpenHABWidget]) -> OpenHABPage { + OpenHABPage(pageId: "p1", title: "Test", link: "", leaf: false, widgets: widgets, icon: "") + } +} diff --git a/openHABTestsSwift/SitemapRowInputMapperTests.swift b/openHABTestsSwift/SitemapRowInputMapperTests.swift index a94fd4eae..f5f61ff00 100644 --- a/openHABTestsSwift/SitemapRowInputMapperTests.swift +++ b/openHABTestsSwift/SitemapRowInputMapperTests.swift @@ -42,8 +42,8 @@ struct SitemapRowInputMapperTests { } #expect(mappedRowID == rowID) #expect(input.widgetId == widget.widgetId) - #expect(input.linkedPageLink == "https://example.invalid/linked") - #expect(input.linkedPageTitle == "Linked") + #expect(input.destination.pageUrl == "https://example.invalid/linked") + #expect(input.destination.title == "Linked") } } diff --git a/openHABTestsSwift/SliderOverrideSyncTests.swift b/openHABTestsSwift/SliderOverrideSyncTests.swift deleted file mode 100644 index 3aed8ba5d..000000000 --- a/openHABTestsSwift/SliderOverrideSyncTests.swift +++ /dev/null @@ -1,67 +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 - -@testable import openHAB -import OpenHABCore -import Testing - -@MainActor -@Suite -struct SliderOverrideSyncTests { - @Test - func clearsSliderOverrideWhenServerCatchesUp() { - let viewModel = SitemapPageViewModel(pageUrl: "", title: "Preview", pageId: "", widgets: []) - let itemName = "test_LEDLight_Brightness" - viewModel.setSliderOverrideValue(86, for: itemName) - - let widget = makeSliderWidget(widgetID: "00", itemName: itemName, itemState: "86.0", step: 1.0) - let cleared = viewModel.syncSliderOverridesWithServerState(for: [widget]) - - #expect(cleared == 1) - #expect(viewModel.sliderOverrideValue(for: itemName) == nil) - } - - @Test - func keepsSliderOverrideWhenServerHasNotCaughtUp() { - let viewModel = SitemapPageViewModel(pageUrl: "", title: "Preview", pageId: "", widgets: []) - let itemName = "test_LEDLight_Brightness" - viewModel.setSliderOverrideValue(86, for: itemName) - - let widget = makeSliderWidget(widgetID: "00", itemName: itemName, itemState: "79.0", step: 1.0) - let cleared = viewModel.syncSliderOverridesWithServerState(for: [widget]) - - #expect(cleared == 0) - #expect(viewModel.sliderOverrideValue(for: itemName) == 86) - } -} - -private extension SliderOverrideSyncTests { - func makeSliderWidget(widgetID: String, itemName: String, itemState: String, step: Double) -> OpenHABWidget { - let widget = OpenHABWidget() - widget.widgetId = widgetID - widget.type = .slider - widget.step = step - widget.item = OpenHABItem( - name: itemName, - type: "Dimmer", - state: itemState, - link: "", - label: nil, - groupType: nil, - stateDescription: nil, - commandDescription: nil, - members: [], - category: nil, - options: nil - ) - return widget - } -}