From 397ce8afd99379d86673c90fff81ed12d3f9b0ab Mon Sep 17 00:00:00 2001 From: tashda Date: Tue, 28 Apr 2026 14:34:27 +0200 Subject: [PATCH 01/24] Bump to 1.3.0 and remove inline descriptions from device option rows Drops the verbose Z2M schema description text under each option row title in device state/action cards. Long descriptions (LED intensity / colour parameters on Inovelli switches, etc.) crowded the list and made it hard to scan dozens of similar rows. Fixes #13 Co-Authored-By: Claude Opus 4.7 (1M context) --- Shellbee.xcodeproj/project.pbxproj | 8 ++-- .../Shared/FanControl/FanControlCard.swift | 44 +------------------ 2 files changed, 5 insertions(+), 47 deletions(-) diff --git a/Shellbee.xcodeproj/project.pbxproj b/Shellbee.xcodeproj/project.pbxproj index 6771385..348340f 100644 --- a/Shellbee.xcodeproj/project.pbxproj +++ b/Shellbee.xcodeproj/project.pbxproj @@ -794,7 +794,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.2.0; + MARKETING_VERSION = 1.3.0; PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_ID)"; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -835,7 +835,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.2.0; + MARKETING_VERSION = 1.3.0; PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_ID)"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -875,7 +875,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.2.0; + MARKETING_VERSION = 1.3.0; PRODUCT_BUNDLE_IDENTIFIER = "$(APP_WIDGET_BUNDLE_ID)"; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -917,7 +917,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.2.0; + MARKETING_VERSION = 1.3.0; PRODUCT_BUNDLE_IDENTIFIER = "$(APP_WIDGET_BUNDLE_ID)"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/Shellbee/Shared/FanControl/FanControlCard.swift b/Shellbee/Shared/FanControl/FanControlCard.swift index f7cb757..e91a698 100644 --- a/Shellbee/Shared/FanControl/FanControlCard.swift +++ b/Shellbee/Shared/FanControl/FanControlCard.swift @@ -660,51 +660,9 @@ private struct FanExtraRow: View { @ViewBuilder private var labelStack: some View { - if let desc = meaningfulDescription { - VStack(alignment: .leading, spacing: 2) { - Text(label).font(.body) - Text(desc) - .font(.caption) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - } else { - Text(label).font(.body) - } + Text(label).font(.body) } - private var meaningfulDescription: String? { - guard let desc = expose.description?.trimmingCharacters(in: .whitespacesAndNewlines), - desc.count >= 12 else { return nil } - let normalizedLabel = label.lowercased().filter { $0.isLetter || $0.isNumber } - let normalizedDesc = desc.lowercased().filter { $0.isLetter || $0.isNumber } - if normalizedDesc == normalizedLabel { return nil } - if desc.contains(where: { $0.isNumber }) { return desc } - let labelTokens = tokenize(label).map { $0.lowercased() } - let descTokens = tokenize(desc).map { $0.lowercased() } - let novel = descTokens.filter { token in - if Self.stopwords.contains(token) { return false } - return !labelTokens.contains { stem in - token.hasPrefix(stem) || stem.hasPrefix(token) - } - } - return novel.count >= 4 ? desc : nil - } - - private func tokenize(_ s: String) -> [String] { - s.split(whereSeparator: { !$0.isLetter && !$0.isNumber }).map(String.init) - } - - private static let stopwords: Set = [ - "a", "an", "the", "this", "that", "these", "those", - "is", "are", "was", "were", "be", "been", "being", - "of", "to", "in", "on", "at", "for", "with", "by", "as", "from", - "and", "or", "but", "if", "when", "while", "whether", - "it", "its", "this", "you", "your", - "controls", "control", "sets", "set", "set:", "value", "current", - "device", "switch" - ] - private func prettify(_ s: String) -> String { s.replacingOccurrences(of: "_", with: " ").capitalized } From 14034e8d7d39c763794c34b31c8d56ad5f029818 Mon Sep 17 00:00:00 2001 From: tashda Date: Tue, 28 Apr 2026 14:37:39 +0200 Subject: [PATCH 02/24] Add OTA Check / Schedule / Unschedule actions to device long-press menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Z2M's schedule + unschedule OTA topics, AppStore optimistic state helpers, and ViewModel methods. Context menu now shows: - Check for Update (always, when device supports OTA) - Update Now (mains-powered, update available) - Schedule Update (battery-powered, update available) - Cancel Scheduled Update (when phase is .scheduled) The schedule path is the right primitive for sleepy battery devices — Z2M waits for the device to wake up and request the image rather than trying to push immediately. Fixes #15 Co-Authored-By: Claude Opus 4.7 (1M context) --- Shellbee/Core/Networking/Z2MTopics.swift | 4 +++ Shellbee/Core/Store/AppStore.swift | 15 +++++++++ Shellbee/Features/Devices/DeviceListRow.swift | 31 ++++++++++++++++--- .../Features/Devices/DeviceListView.swift | 6 +++- .../Devices/DeviceListViewModel.swift | 18 +++++++++++ 5 files changed, 69 insertions(+), 5 deletions(-) diff --git a/Shellbee/Core/Networking/Z2MTopics.swift b/Shellbee/Core/Networking/Z2MTopics.swift index cef1962..7175238 100644 --- a/Shellbee/Core/Networking/Z2MTopics.swift +++ b/Shellbee/Core/Networking/Z2MTopics.swift @@ -7,6 +7,8 @@ enum Z2MTopics { static let bridgeEvent = "bridge/event" static let bridgeResponseDeviceOTAUpdate = "bridge/response/device/ota_update/update" static let bridgeResponseDeviceOTACheck = "bridge/response/device/ota_update/check" + static let bridgeResponseDeviceOTASchedule = "bridge/response/device/ota_update/schedule" + static let bridgeResponseDeviceOTAUnschedule = "bridge/response/device/ota_update/unschedule" static let bridgeHealth = "bridge/health" static let bridgeDefinitions = "bridge/definitions" @@ -29,6 +31,8 @@ enum Z2MTopics { static let deviceUnbind = "bridge/request/device/unbind" static let deviceOTACheck = "bridge/request/device/ota_update/check" static let deviceOTAUpdate = "bridge/request/device/ota_update/update" + static let deviceOTASchedule = "bridge/request/device/ota_update/schedule" + static let deviceOTAUnschedule = "bridge/request/device/ota_update/unschedule" static let deviceReportingConfigure = "bridge/request/device/configure_reporting" static let groupAdd = "bridge/request/group/add" static let groupRemove = "bridge/request/group/remove" diff --git a/Shellbee/Core/Store/AppStore.swift b/Shellbee/Core/Store/AppStore.swift index 41bb8f6..eebfe6d 100644 --- a/Shellbee/Core/Store/AppStore.swift +++ b/Shellbee/Core/Store/AppStore.swift @@ -533,6 +533,21 @@ final class AppStore { ) } + func startOTASchedule(for friendlyName: String) { + otaUpdates[friendlyName] = OTAUpdateStatus( + deviceName: friendlyName, + phase: .scheduled, + progress: nil, + remaining: nil + ) + } + + func cancelOTASchedule(for friendlyName: String) { + if otaUpdates[friendlyName]?.phase == .scheduled { + otaUpdates.removeValue(forKey: friendlyName) + } + } + func reset() { devices = [] groups = [] diff --git a/Shellbee/Features/Devices/DeviceListRow.swift b/Shellbee/Features/Devices/DeviceListRow.swift index c4e96ea..73c6f1e 100644 --- a/Shellbee/Features/Devices/DeviceListRow.swift +++ b/Shellbee/Features/Devices/DeviceListRow.swift @@ -16,6 +16,13 @@ struct DeviceListRow: View { let onInterview: () -> Void let onUpdate: (() -> Void)? let onCheckUpdate: () -> Void + let onSchedule: (() -> Void)? + let onUnschedule: (() -> Void)? + + private var isBatteryPowered: Bool { + guard let raw = device.powerSource?.lowercased() else { return false } + return raw.contains("battery") + } private var supportsOTA: Bool { device.definition?.supportsOTA == true @@ -106,9 +113,23 @@ struct DeviceListRow: View { Button(action: onInterview) { Label("Interview", systemImage: "questionmark.circle") } - if let onUpdate { - Button(action: onUpdate) { - Label("Update Firmware", systemImage: "arrow.up.circle") + if supportsOTA { + Divider() + Button(action: onCheckUpdate) { + Label("Check for Update", systemImage: "arrow.trianglehead.2.clockwise") + } + if otaStatus?.phase == .scheduled, let onUnschedule { + Button(action: onUnschedule) { + Label("Cancel Scheduled Update", systemImage: "xmark.circle") + } + } else if isBatteryPowered, let onSchedule { + Button(action: onSchedule) { + Label("Schedule Update", systemImage: "calendar.badge.clock") + } + } else if let onUpdate { + Button(action: onUpdate) { + Label("Update Now", systemImage: "arrow.up.circle") + } } } Divider() @@ -134,7 +155,9 @@ struct DeviceListRow: View { onReconfigure: {}, onInterview: {}, onUpdate: {}, - onCheckUpdate: {} + onCheckUpdate: {}, + onSchedule: {}, + onUnschedule: {} ) } } diff --git a/Shellbee/Features/Devices/DeviceListView.swift b/Shellbee/Features/Devices/DeviceListView.swift index d51c239..9ceb28a 100644 --- a/Shellbee/Features/Devices/DeviceListView.swift +++ b/Shellbee/Features/Devices/DeviceListView.swift @@ -220,7 +220,11 @@ private struct DeviceListContent: View { onUpdate: state.hasUpdateAvailable ? { viewModel.updateDevice(device, environment: environment) } : nil, - onCheckUpdate: { viewModel.checkDeviceUpdate(device, environment: environment) } + onCheckUpdate: { viewModel.checkDeviceUpdate(device, environment: environment) }, + onSchedule: state.hasUpdateAvailable + ? { viewModel.scheduleDeviceUpdate(device, environment: environment) } + : nil, + onUnschedule: { viewModel.unscheduleDeviceUpdate(device, environment: environment) } ) } } diff --git a/Shellbee/Features/Devices/DeviceListViewModel.swift b/Shellbee/Features/Devices/DeviceListViewModel.swift index 8c01215..769f061 100644 --- a/Shellbee/Features/Devices/DeviceListViewModel.swift +++ b/Shellbee/Features/Devices/DeviceListViewModel.swift @@ -218,6 +218,24 @@ final class DeviceListViewModel { ) } + func scheduleDeviceUpdate(_ device: Device, environment: AppEnvironment) { + Haptics.impact(.medium) + environment.store.startOTASchedule(for: device.friendlyName) + environment.send( + topic: Z2MTopics.Request.deviceOTASchedule, + payload: .object(["id": .string(device.friendlyName)]) + ) + } + + func unscheduleDeviceUpdate(_ device: Device, environment: AppEnvironment) { + Haptics.impact(.light) + environment.store.cancelOTASchedule(for: device.friendlyName) + environment.send( + topic: Z2MTopics.Request.deviceOTAUnschedule, + payload: .object(["id": .string(device.friendlyName)]) + ) + } + func renameDevice(_ device: Device, to newName: String, homeassistantRename: Bool = true, environment: AppEnvironment) { environment.renameDevice(from: device.friendlyName, to: newName, homeassistantRename: homeassistantRename) } From 7c6714a508e350cdf0528b31c6a8c6b3553d3015 Mon Sep 17 00:00:00 2001 From: tashda Date: Tue, 28 Apr 2026 14:42:45 +0200 Subject: [PATCH 03/24] Add Developer Mode and MQTT Inspector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Behind a Developer Mode toggle in Settings → General, add a Developer section with an MQTT Inspector. Subscribe tab streams every inbound topic + payload (substring filter, pause, clear, 1000-message ring). Publish tab sends arbitrary topic + JSON or string payload, with a confirm prompt for bridge/request/* destinations. Z2MMessageRouter exposes decodeRaw() so the session controller can tap inbound messages for the inspector without breaking the typed routing. Fixes #21 Co-Authored-By: Claude Opus 4.7 (1M context) --- Shellbee.xcodeproj/project.pbxproj | 3 + .../App/ConnectionSessionController.swift | 7 + .../Core/Networking/Z2MMessageRouter.swift | 5 + .../Features/Settings/AppGeneralView.swift | 7 + .../Developer/DeveloperSettings.swift | 9 + .../Developer/DeveloperSettingsView.swift | 31 +++ .../Developer/MQTTInspectorView.swift | 212 ++++++++++++++++++ Shellbee/Features/Settings/SettingsView.swift | 15 ++ 8 files changed, 289 insertions(+) create mode 100644 Shellbee/Features/Settings/Developer/DeveloperSettings.swift create mode 100644 Shellbee/Features/Settings/Developer/DeveloperSettingsView.swift create mode 100644 Shellbee/Features/Settings/Developer/MQTTInspectorView.swift diff --git a/Shellbee.xcodeproj/project.pbxproj b/Shellbee.xcodeproj/project.pbxproj index 348340f..1216aa5 100644 --- a/Shellbee.xcodeproj/project.pbxproj +++ b/Shellbee.xcodeproj/project.pbxproj @@ -203,6 +203,9 @@ Features/Settings/AppNotificationSettingsView.swift, Features/Settings/AppPerformanceView.swift, Features/Settings/AvailabilitySettingsView.swift, + Features/Settings/Developer/DeveloperSettings.swift, + Features/Settings/Developer/DeveloperSettingsView.swift, + Features/Settings/Developer/MQTTInspectorView.swift, Features/Settings/DeviceStatisticsView.swift, Features/Settings/DocBrowserDetailView.swift, Features/Settings/DocBrowserView.swift, diff --git a/Shellbee/App/ConnectionSessionController.swift b/Shellbee/App/ConnectionSessionController.swift index a19d8ab..ef32227 100644 --- a/Shellbee/App/ConnectionSessionController.swift +++ b/Shellbee/App/ConnectionSessionController.swift @@ -18,6 +18,10 @@ final class ConnectionSessionController { var errorMessage: String? private(set) var hasBeenConnected = false + /// Receives every inbound (topic, payload) before routing. Used by the + /// MQTT inspector in Developer Mode. Set on view appear, clear on disappear. + var rawInboundTap: ((String, JSONValue) -> Void)? + private let store: AppStore private let history: ConnectionHistory private let client = Z2MWebSocketClient() @@ -212,6 +216,9 @@ final class ConnectionSessionController { switch socketEvent { case .message(let data): + if let tap = rawInboundTap, let raw = Z2MMessageRouter.decodeRaw(data) { + tap(raw.topic, raw.payload) + } if let event = router.route(data) { store.apply(event) } diff --git a/Shellbee/Core/Networking/Z2MMessageRouter.swift b/Shellbee/Core/Networking/Z2MMessageRouter.swift index e37afd3..0d3e304 100644 --- a/Shellbee/Core/Networking/Z2MMessageRouter.swift +++ b/Shellbee/Core/Networking/Z2MMessageRouter.swift @@ -17,6 +17,11 @@ struct Z2MMessageRouter: Sendable { return dispatch(raw) } + static func decodeRaw(_ data: Data) -> (topic: String, payload: JSONValue)? { + guard let raw = try? JSONDecoder().decode(RawMessage.self, from: data) else { return nil } + return (raw.topic, raw.payload) + } + private func dispatch(_ raw: RawMessage) -> Z2MEvent? { switch raw.topic { case Z2MTopics.bridgeInfo: diff --git a/Shellbee/Features/Settings/AppGeneralView.swift b/Shellbee/Features/Settings/AppGeneralView.swift index 6cab71f..9b34905 100644 --- a/Shellbee/Features/Settings/AppGeneralView.swift +++ b/Shellbee/Features/Settings/AppGeneralView.swift @@ -6,6 +6,7 @@ struct AppGeneralView: View { @AppStorage(ConnectionSessionController.connectionLiveActivityEnabledKey) private var connectionLiveActivityEnabled: Bool = true @AppStorage(ConnectionSessionController.otaLiveActivityEnabledKey) private var otaLiveActivityEnabled: Bool = true @AppStorage(ConnectionSessionController.maxReconnectAttemptsKey) private var maxReconnectAttempts: Int = ConnectionSessionController.defaultMaxReconnectAttempts + @AppStorage(DeveloperSettings.modeEnabledKey) private var developerModeEnabled: Bool = false @State private var consent = CrashReportingConsent.shared var body: some View { @@ -56,6 +57,12 @@ struct AppGeneralView: View { } footer: { Text("Crash reports contain the error and a short stack trace. Bridge URLs, tokens, and device names are redacted. When this is off, you'll still be asked before any crash is sent.") } + + Section { + Toggle("Developer Mode", isOn: $developerModeEnabled) + } footer: { + Text("Exposes the MQTT Inspector and other power-user tools under a Developer section in Settings.") + } } .navigationTitle("General") } diff --git a/Shellbee/Features/Settings/Developer/DeveloperSettings.swift b/Shellbee/Features/Settings/Developer/DeveloperSettings.swift new file mode 100644 index 0000000..e58a242 --- /dev/null +++ b/Shellbee/Features/Settings/Developer/DeveloperSettings.swift @@ -0,0 +1,9 @@ +import Foundation + +enum DeveloperSettings { + static let modeEnabledKey = "developerModeEnabled" + + static var isEnabled: Bool { + UserDefaults.standard.bool(forKey: modeEnabledKey) + } +} diff --git a/Shellbee/Features/Settings/Developer/DeveloperSettingsView.swift b/Shellbee/Features/Settings/Developer/DeveloperSettingsView.swift new file mode 100644 index 0000000..dfd8f28 --- /dev/null +++ b/Shellbee/Features/Settings/Developer/DeveloperSettingsView.swift @@ -0,0 +1,31 @@ +import SwiftUI + +struct DeveloperSettingsView: View { + var body: some View { + Form { + Section { + NavigationLink { + MQTTInspectorView() + } label: { + Label { + Text("MQTT Inspector") + } icon: { + Image(systemName: "dot.radiowaves.left.and.right") + .font(.footnote.weight(.semibold)) + .foregroundStyle(.white) + .frame(width: DesignTokens.Size.settingsIconFrame, height: DesignTokens.Size.settingsIconFrame) + .background(.purple, in: RoundedRectangle(cornerRadius: DesignTokens.CornerRadius.sm, style: .continuous)) + } + } + } footer: { + Text("Inspect every message flowing over the bridge connection and publish arbitrary topics. For debugging Z2M behavior — be careful publishing to bridge/request/* topics.") + } + } + .navigationTitle("Developer") + } +} + +#Preview { + NavigationStack { DeveloperSettingsView() } + .environment(AppEnvironment()) +} diff --git a/Shellbee/Features/Settings/Developer/MQTTInspectorView.swift b/Shellbee/Features/Settings/Developer/MQTTInspectorView.swift new file mode 100644 index 0000000..8981937 --- /dev/null +++ b/Shellbee/Features/Settings/Developer/MQTTInspectorView.swift @@ -0,0 +1,212 @@ +import SwiftUI + +struct MQTTInspectorView: View { + @Environment(AppEnvironment.self) private var environment + @State private var selectedTab: Tab = .subscribe + + enum Tab: String, CaseIterable, Identifiable { + case subscribe = "Subscribe" + case publish = "Publish" + var id: String { rawValue } + } + + var body: some View { + VStack(spacing: 0) { + Picker("Mode", selection: $selectedTab) { + ForEach(Tab.allCases) { tab in + Text(tab.rawValue).tag(tab) + } + } + .pickerStyle(.segmented) + .padding() + + switch selectedTab { + case .subscribe: + SubscribeView() + case .publish: + PublishView() + } + } + .navigationTitle("MQTT Inspector") + .navigationBarTitleDisplayMode(.inline) + } +} + +// MARK: - Subscribe + +private struct InspectorMessage: Identifiable, Equatable { + let id = UUID() + let timestamp: Date + let topic: String + let payload: JSONValue + + var prettyPayload: String { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + guard let data = try? encoder.encode(payload), + let text = String(data: data, encoding: .utf8) else { + return "" + } + return text + } +} + +private struct SubscribeView: View { + @Environment(AppEnvironment.self) private var environment + @State private var filter: String = "" + @State private var messages: [InspectorMessage] = [] + @State private var paused: Bool = false + @State private var bufferCap: Int = 1000 + + var body: some View { + VStack(spacing: 0) { + HStack { + TextField("Filter (substring of topic)", text: $filter) + .textFieldStyle(.roundedBorder) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + Button(paused ? "Resume" : "Pause") { + paused.toggle() + } + .buttonStyle(.bordered) + Button("Clear") { + messages.removeAll() + } + .buttonStyle(.bordered) + } + .padding(.horizontal) + .padding(.bottom, 8) + + if filteredMessages.isEmpty { + ContentUnavailableView( + "No messages", + systemImage: "dot.radiowaves.left.and.right", + description: Text(paused + ? "Inspector is paused." + : "Waiting for messages from \(environment.connectionConfig?.displayName ?? "the bridge")." + ) + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + List(filteredMessages.reversed()) { msg in + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(msg.topic) + .font(.callout.monospaced()) + .foregroundStyle(.primary) + .lineLimit(1) + .truncationMode(.middle) + Spacer() + Text(msg.timestamp, format: .dateTime.hour().minute().second()) + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + } + Text(msg.prettyPayload) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(8) + .textSelection(.enabled) + } + .padding(.vertical, 2) + } + .listStyle(.plain) + } + } + .onAppear { + environment.session.rawInboundTap = { topic, payload in + guard !paused else { return } + let msg = InspectorMessage(timestamp: .now, topic: topic, payload: payload) + Task { @MainActor in + messages.append(msg) + if messages.count > bufferCap { + messages.removeFirst(messages.count - bufferCap) + } + } + } + } + .onDisappear { + environment.session.rawInboundTap = nil + } + } + + private var filteredMessages: [InspectorMessage] { + let f = filter.trimmingCharacters(in: .whitespaces) + guard !f.isEmpty else { return messages } + return messages.filter { $0.topic.contains(f) } + } +} + +// MARK: - Publish + +private struct PublishView: View { + @Environment(AppEnvironment.self) private var environment + @State private var topic: String = "" + @State private var payload: String = "" + @State private var showWarning: Bool = false + @State private var lastResult: String? + + var body: some View { + Form { + Section("Topic") { + TextField("e.g. zigbee2mqtt/Office Lamp/set", text: $topic) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .font(.callout.monospaced()) + } + Section { + TextEditor(text: $payload) + .frame(minHeight: 120) + .font(.callout.monospaced()) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + } header: { + Text("Payload") + } footer: { + Text("JSON object, JSON literal, or raw string. Empty payload is allowed.") + } + Section { + Button { + if topic.hasPrefix("bridge/request/") { + showWarning = true + } else { + sendNow() + } + } label: { + Label("Publish", systemImage: "paperplane.fill") + } + .disabled(topic.trimmingCharacters(in: .whitespaces).isEmpty) + } + if let lastResult { + Section("Last") { + Text(lastResult).font(.footnote).foregroundStyle(.secondary) + } + } + } + .alert("Publish to bridge/request/*?", isPresented: $showWarning) { + Button("Publish", role: .destructive) { sendNow() } + Button("Cancel", role: .cancel) {} + } message: { + Text("This may modify your Zigbee2MQTT configuration. Continue?") + } + } + + private func sendNow() { + environment.send(topic: topic, payload: parsedPayload()) + lastResult = "Published to \(topic) at \(Date.now.formatted(date: .omitted, time: .standard))" + } + + private func parsedPayload() -> JSONValue { + let trimmed = payload.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { return .string("") } + if let data = trimmed.data(using: .utf8), + let value = try? JSONDecoder().decode(JSONValue.self, from: data) { + return value + } + return .string(trimmed) + } +} + +#Preview { + NavigationStack { MQTTInspectorView() } + .environment(AppEnvironment()) +} diff --git a/Shellbee/Features/Settings/SettingsView.swift b/Shellbee/Features/Settings/SettingsView.swift index 2a766ac..48705ca 100644 --- a/Shellbee/Features/Settings/SettingsView.swift +++ b/Shellbee/Features/Settings/SettingsView.swift @@ -2,6 +2,7 @@ import SwiftUI struct SettingsView: View { @Environment(AppEnvironment.self) private var environment + @AppStorage(DeveloperSettings.modeEnabledKey) private var developerModeEnabled: Bool = false @State private var showingRestartAlert = false @State private var showingDisconnectConfirmation = false @@ -20,6 +21,10 @@ struct SettingsView: View { toolsSection applicationSection + if developerModeEnabled { + developerSection + } + if environment.connectionState.isConnected || environment.hasBeenConnected { dangerSection } @@ -173,6 +178,16 @@ struct SettingsView: View { } } + private var developerSection: some View { + Section { + NavigationLink { DeveloperSettingsView() } label: { + settingsLabel(title: "Developer", systemImage: "hammer.fill", color: .purple) + } + } header: { + Text("Developer") + } + } + private var dangerSection: some View { Section { if environment.connectionState.isConnected { From bf6cea799a31cf908eb751376e4af973bdc9478b Mon Sep 17 00:00:00 2001 From: tashda Date: Tue, 28 Apr 2026 14:46:06 +0200 Subject: [PATCH 04/24] =?UTF-8?q?Add=20Backup=20management=20=E2=80=94=20t?= =?UTF-8?q?rigger=20and=20download=20Z2M=20backups?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings → Tools → Backup. Sends bridge/request/backup, decodes the returned base64 zip from bridge/response/backup, writes it to a temp file, and presents iOS share sheet for save / AirDrop / iCloud Drive. Persists a metadata-only history list (timestamp + size, last 20 backups) — Shellbee does not retain backup files. Restore is intentionally out of scope: Z2M does not expose a restore API, restoration requires host access to the data directory. Links to the Z2M restore guide. Fixes #19 Co-Authored-By: Claude Opus 4.7 (1M context) --- Shellbee.xcodeproj/project.pbxproj | 1 + Shellbee/Core/Networking/Z2MTopics.swift | 1 + Shellbee/Core/Store/AppStore.swift | 18 +- .../Features/Settings/Backup/BackupView.swift | 163 ++++++++++++++++++ Shellbee/Features/Settings/SettingsView.swift | 3 + 5 files changed, 185 insertions(+), 1 deletion(-) create mode 100644 Shellbee/Features/Settings/Backup/BackupView.swift diff --git a/Shellbee.xcodeproj/project.pbxproj b/Shellbee.xcodeproj/project.pbxproj index 1216aa5..5618e99 100644 --- a/Shellbee.xcodeproj/project.pbxproj +++ b/Shellbee.xcodeproj/project.pbxproj @@ -203,6 +203,7 @@ Features/Settings/AppNotificationSettingsView.swift, Features/Settings/AppPerformanceView.swift, Features/Settings/AvailabilitySettingsView.swift, + Features/Settings/Backup/BackupView.swift, Features/Settings/Developer/DeveloperSettings.swift, Features/Settings/Developer/DeveloperSettingsView.swift, Features/Settings/Developer/MQTTInspectorView.swift, diff --git a/Shellbee/Core/Networking/Z2MTopics.swift b/Shellbee/Core/Networking/Z2MTopics.swift index 7175238..e9dedfe 100644 --- a/Shellbee/Core/Networking/Z2MTopics.swift +++ b/Shellbee/Core/Networking/Z2MTopics.swift @@ -20,6 +20,7 @@ enum Z2MTopics { static let bridgeResponseTouchlinkFactoryReset = "bridge/response/touchlink/factory_reset" static let bridgeResponseDeviceRename = "bridge/response/device/rename" static let bridgeResponseDeviceRemove = "bridge/response/device/remove" + static let bridgeResponseBackup = "bridge/response/backup" enum Request { static let deviceRename = "bridge/request/device/rename" diff --git a/Shellbee/Core/Store/AppStore.swift b/Shellbee/Core/Store/AppStore.swift index eebfe6d..6af5e72 100644 --- a/Shellbee/Core/Store/AppStore.swift +++ b/Shellbee/Core/Store/AppStore.swift @@ -42,6 +42,11 @@ final class AppStore { // bulk queue so it can advance to the next device. var otaResponseForwarding: ((_ friendlyName: String, _ success: Bool, _ kind: OTABulkOperationQueue.Kind) -> Void)? + // One-shot callback invoked when the next bridge/response/backup arrives. + // BackupView sets this before sending the request and clears it on receipt. + // Tuple: (zipBase64, errorMessage) — exactly one is non-nil. + var backupResponseHandler: ((_ zipBase64: String?, _ error: String?) -> Void)? + // Set by AppEnvironment to filter out notifications the user disabled // in Settings → App → Notifications. Returns true to allow. var notificationFilter: ((InAppNotification) -> Bool)? @@ -195,7 +200,18 @@ final class AppStore { ) } - case .bridgeResponse(_, let payload): + case .bridgeResponse(let topic, let payload): + if topic == Z2MTopics.bridgeResponseBackup, let handler = backupResponseHandler { + backupResponseHandler = nil + if payload.object?["status"]?.stringValue == "ok", + let zip = payload.object?["data"]?.object?["zip"]?.stringValue { + handler(zip, nil) + } else { + let err = payload.object?["error"]?.stringValue ?? "Unknown error" + handler(nil, err) + } + break + } // The options/info responses carry only `{restart_required}` (and // echo the request on error). The full config is delivered via the // separate `bridge/info` topic, so don't try to decode config here diff --git a/Shellbee/Features/Settings/Backup/BackupView.swift b/Shellbee/Features/Settings/Backup/BackupView.swift new file mode 100644 index 0000000..39917ce --- /dev/null +++ b/Shellbee/Features/Settings/Backup/BackupView.swift @@ -0,0 +1,163 @@ +import SwiftUI +import Foundation + +struct BackupView: View { + @Environment(AppEnvironment.self) private var environment + @State private var status: Status = .idle + @State private var lastBackupURL: URL? + @State private var history: [HistoryEntry] = HistoryEntry.load() + + enum Status: Equatable { + case idle + case running + case success(size: Int) + case failed(reason: String) + } + + var body: some View { + Form { + Section { + Button { + triggerBackup() + } label: { + HStack { + Label("Create Backup", systemImage: "arrow.down.doc.fill") + Spacer() + if status == .running { + ProgressView() + } + } + } + .disabled(status == .running || !environment.connectionState.isConnected) + + if let url = lastBackupURL { + ShareLink(item: url) { + Label("Save / Share", systemImage: "square.and.arrow.up") + } + } + } header: { + Text("Create") + } footer: { + Text("Backs up Z2M configuration and coordinator state via the bridge. Save the resulting zip to Files, iCloud Drive, or AirDrop.") + } + + switch status { + case .idle: + EmptyView() + case .running: + Section { Text("Working…").foregroundStyle(.secondary) } + case .success(let size): + Section { + Label("Backup ready (\(formatted(size: size)))", systemImage: "checkmark.seal.fill") + .foregroundStyle(.green) + } + case .failed(let reason): + Section { + Label(reason, systemImage: "xmark.octagon.fill") + .foregroundStyle(.red) + } + } + + if !history.isEmpty { + Section { + ForEach(history) { entry in + HStack { + VStack(alignment: .leading) { + Text(entry.timestamp, format: .dateTime.day().month().year().hour().minute()) + .font(.callout) + Text(formatted(size: entry.size)) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + } + } + .onDelete { indices in + history.remove(atOffsets: indices) + HistoryEntry.save(history) + } + } header: { + Text("Recent") + } footer: { + Text("Metadata only — Shellbee does not retain backup files. Save them to Files / iCloud Drive when prompted.") + } + } + + Section { + Link(destination: URL(string: "https://www.zigbee2mqtt.io/guide/usage/backup_restore.html")!) { + Label("Restore guide", systemImage: "arrow.up.bin.fill") + } + } footer: { + Text("Restoring a backup requires host-level access to your Z2M data directory. The bridge does not expose a restore API.") + } + } + .navigationTitle("Backup") + } + + private func triggerBackup() { + status = .running + environment.store.backupResponseHandler = { zipBase64, error in + Task { @MainActor in + if let zipBase64 { + do { + let url = try saveBackup(base64: zipBase64) + let size = (try? FileManager.default.attributesOfItem(atPath: url.path)[.size] as? Int) ?? 0 + lastBackupURL = url + status = .success(size: size) + let entry = HistoryEntry(id: UUID(), timestamp: .now, size: size, filename: url.lastPathComponent) + history.insert(entry, at: 0) + if history.count > 20 { history = Array(history.prefix(20)) } + HistoryEntry.save(history) + } catch { + status = .failed(reason: error.localizedDescription) + } + } else { + status = .failed(reason: error ?? "Unknown error") + } + } + } + environment.send(topic: Z2MTopics.Request.backup, payload: .string("")) + } + + private func saveBackup(base64: String) throws -> URL { + guard let data = Data(base64Encoded: base64) else { + throw NSError(domain: "Backup", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid base64 zip"]) + } + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd-HHmmss" + let filename = "shellbee-z2m-backup-\(formatter.string(from: .now)).zip" + let url = FileManager.default.temporaryDirectory.appendingPathComponent(filename) + try data.write(to: url, options: .atomic) + return url + } + + private func formatted(size: Int) -> String { + ByteCountFormatter.string(fromByteCount: Int64(size), countStyle: .file) + } + + struct HistoryEntry: Identifiable, Codable, Hashable { + let id: UUID + let timestamp: Date + let size: Int + let filename: String + + private static let key = "BackupHistory.entries" + + static func load() -> [HistoryEntry] { + guard let data = UserDefaults.standard.data(forKey: key), + let decoded = try? JSONDecoder().decode([HistoryEntry].self, from: data) + else { return [] } + return decoded + } + + static func save(_ entries: [HistoryEntry]) { + guard let data = try? JSONEncoder().encode(entries) else { return } + UserDefaults.standard.set(data, forKey: key) + } + } +} + +#Preview { + NavigationStack { BackupView() } + .environment(AppEnvironment()) +} diff --git a/Shellbee/Features/Settings/SettingsView.swift b/Shellbee/Features/Settings/SettingsView.swift index 48705ca..ad3623a 100644 --- a/Shellbee/Features/Settings/SettingsView.swift +++ b/Shellbee/Features/Settings/SettingsView.swift @@ -122,6 +122,9 @@ struct SettingsView: View { NavigationLink { TouchlinkView() } label: { settingsLabel(title: "Touchlink", systemImage: "dot.radiowaves.left.and.right", color: .teal) } + NavigationLink { BackupView() } label: { + settingsLabel(title: "Backup", systemImage: "arrow.down.doc.fill", color: .indigo) + } } header: { Text("Tools") } From 98b86484ffc40ea470986539d81cdff96b9f5ff6 Mon Sep 17 00:00:00 2001 From: tashda Date: Tue, 28 Apr 2026 15:04:00 +0200 Subject: [PATCH 05/24] Implement Schedule OTA flow + revise long-press menu visibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #15 revision: Both 'Update Now' and 'Schedule Update' are now exposed on every OTA-supported device (not just battery). Power source informs ordering — battery devices list Schedule first as the recommended path, mains devices list Update Now first — but the user can pick either on any device. #12 implementation: - Device detail (...) menu: same Schedule / Update Now / Cancel Scheduled actions, ordered by power source. - 'Check All for Updates' bulk action: mains devices route to the rate-limited check queue; battery devices fire schedule requests directly (no rate-limit needed — Z2M defers them until each device wakes up). - Device row badge: shows 'Scheduled' instead of generic 'Preparing' when phase is .scheduled. Fixes #12 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Features/Devices/DeviceDetailView.swift | 55 ++++++++++++++++--- .../Features/Devices/DeviceFirmwareMenu.swift | 28 ++++++++-- Shellbee/Features/Devices/DeviceListRow.swift | 34 +++++++++--- Shellbee/Features/Devices/DeviceRowView.swift | 1 + 4 files changed, 99 insertions(+), 19 deletions(-) diff --git a/Shellbee/Features/Devices/DeviceDetailView.swift b/Shellbee/Features/Devices/DeviceDetailView.swift index ea70082..ff9abb7 100644 --- a/Shellbee/Features/Devices/DeviceDetailView.swift +++ b/Shellbee/Features/Devices/DeviceDetailView.swift @@ -162,9 +162,12 @@ struct DeviceDetailView: View { private func deviceConfigMenu(for device: Device) -> some View { let state = environment.store.state(for: device.friendlyName) - let otaActive = environment.store.otaStatus(for: device.friendlyName)?.isActive == true + let otaStatus = environment.store.otaStatus(for: device.friendlyName) + let otaActive = otaStatus?.isActive == true let supportsOTA = device.definition?.supportsOTA == true let hasUpdateAvailable = state.hasUpdateAvailable + let isBattery = (device.powerSource?.lowercased() ?? "").contains("battery") + let isScheduled = otaStatus?.phase == .scheduled return Menu { Button { menuDestination = .settings } label: { @@ -176,14 +179,32 @@ struct DeviceDetailView: View { Button { menuDestination = .reporting } label: { Label("Reporting", systemImage: "waveform") } - if supportsOTA && !otaActive { + if supportsOTA { Divider() - Button { checkForUpdate(device) } label: { - Label("Check for Update", systemImage: "arrow.trianglehead.2.clockwise") - } - if hasUpdateAvailable { - Button { updateFirmware(device) } label: { - Label("Update", systemImage: "arrow.up.circle") + if isScheduled { + Button { unscheduleUpdate(device) } label: { + Label("Cancel Scheduled Update", systemImage: "xmark.circle") + } + } else if !otaActive { + Button { checkForUpdate(device) } label: { + Label("Check for Update", systemImage: "arrow.trianglehead.2.clockwise") + } + if hasUpdateAvailable { + if isBattery { + Button { scheduleUpdate(device) } label: { + Label("Schedule Update", systemImage: "calendar.badge.clock") + } + Button { updateFirmware(device) } label: { + Label("Update Now", systemImage: "arrow.up.circle") + } + } else { + Button { updateFirmware(device) } label: { + Label("Update Now", systemImage: "arrow.up.circle") + } + Button { scheduleUpdate(device) } label: { + Label("Schedule Update", systemImage: "calendar.badge.clock") + } + } } } } @@ -220,6 +241,24 @@ struct DeviceDetailView: View { payload: .object(["id": .string(device.friendlyName)]) ) } + + private func scheduleUpdate(_ device: Device) { + Haptics.impact(.medium) + environment.store.startOTASchedule(for: device.friendlyName) + environment.send( + topic: Z2MTopics.Request.deviceOTASchedule, + payload: .object(["id": .string(device.friendlyName)]) + ) + } + + private func unscheduleUpdate(_ device: Device) { + Haptics.impact(.light) + environment.store.cancelOTASchedule(for: device.friendlyName) + environment.send( + topic: Z2MTopics.Request.deviceOTAUnschedule, + payload: .object(["id": .string(device.friendlyName)]) + ) + } } #Preview { diff --git a/Shellbee/Features/Devices/DeviceFirmwareMenu.swift b/Shellbee/Features/Devices/DeviceFirmwareMenu.swift index 090a363..1725310 100644 --- a/Shellbee/Features/Devices/DeviceFirmwareMenu.swift +++ b/Shellbee/Features/Devices/DeviceFirmwareMenu.swift @@ -4,6 +4,10 @@ struct DeviceFirmwareMenu: View { @Environment(AppEnvironment.self) private var environment @State private var showUpdateAllConfirm = false + static func isBattery(_ device: Device) -> Bool { + (device.powerSource?.lowercased() ?? "").contains("battery") + } + private var otaCapableDevices: [Device] { environment.store.devices.filter { guard $0.definition?.supportsOTA == true else { return false } @@ -39,11 +43,27 @@ struct DeviceFirmwareMenu: View { } Button { - let names = otaCapableDevices.map(\.friendlyName) - for name in names { - environment.store.startOTACheck(for: name) + let battery = otaCapableDevices.filter(Self.isBattery) + let mains = otaCapableDevices.filter { !Self.isBattery($0) } + // Mains-powered (& unknown power source) → standard OTA check + // through the rate-limited bulk queue. + let mainsNames = mains.map(\.friendlyName) + if !mainsNames.isEmpty { + for name in mainsNames { + environment.store.startOTACheck(for: name) + } + environment.otaBulkQueue.enqueue(mainsNames, kind: .check) + } + // Battery-powered → schedule directly. Z2M waits for each + // device's next wake-up; no need to rate-limit since these + // requests don't traverse the mesh until the device asks. + for device in battery { + environment.store.startOTASchedule(for: device.friendlyName) + environment.send( + topic: Z2MTopics.Request.deviceOTASchedule, + payload: .object(["id": .string(device.friendlyName)]) + ) } - environment.otaBulkQueue.enqueue(names, kind: .check) } label: { Label("Check All for Updates\(otaCount > 0 ? " (\(otaCount))" : "")", systemImage: "arrow.trianglehead.2.clockwise") } diff --git a/Shellbee/Features/Devices/DeviceListRow.swift b/Shellbee/Features/Devices/DeviceListRow.swift index 73c6f1e..6d9ba2c 100644 --- a/Shellbee/Features/Devices/DeviceListRow.swift +++ b/Shellbee/Features/Devices/DeviceListRow.swift @@ -122,13 +122,33 @@ struct DeviceListRow: View { Button(action: onUnschedule) { Label("Cancel Scheduled Update", systemImage: "xmark.circle") } - } else if isBatteryPowered, let onSchedule { - Button(action: onSchedule) { - Label("Schedule Update", systemImage: "calendar.badge.clock") - } - } else if let onUpdate { - Button(action: onUpdate) { - Label("Update Now", systemImage: "arrow.up.circle") + } else { + // Both actions exposed when an update is available. + // Battery devices get Schedule listed first as the + // recommended path (Z2M waits for the device to wake); + // mains devices get Update Now first. + if isBatteryPowered { + if let onSchedule { + Button(action: onSchedule) { + Label("Schedule Update", systemImage: "calendar.badge.clock") + } + } + if let onUpdate { + Button(action: onUpdate) { + Label("Update Now", systemImage: "arrow.up.circle") + } + } + } else { + if let onUpdate { + Button(action: onUpdate) { + Label("Update Now", systemImage: "arrow.up.circle") + } + } + if let onSchedule { + Button(action: onSchedule) { + Label("Schedule Update", systemImage: "calendar.badge.clock") + } + } } } } diff --git a/Shellbee/Features/Devices/DeviceRowView.swift b/Shellbee/Features/Devices/DeviceRowView.swift index 573e8bf..44f630d 100644 --- a/Shellbee/Features/Devices/DeviceRowView.swift +++ b/Shellbee/Features/Devices/DeviceRowView.swift @@ -113,6 +113,7 @@ struct DeviceRowView: View { private func otaPhaseLabel(_ status: OTAUpdateStatus) -> String { switch status.phase { case .checking: return "Checking" + case .scheduled: return "Scheduled" case .updating: if let p = status.progress { return "Updating \(Int(p))%" } return "Updating" From e397dc8402583b7dbfff4a55542c5b9f4a95b93c Mon Sep 17 00:00:00 2001 From: tashda Date: Tue, 28 Apr 2026 15:06:59 +0200 Subject: [PATCH 06/24] Add local Restore guide sheet, durable backup file location, richer share preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'Restore guide' link pointed at an outdated zigbee2mqtt.io URL that 404s. Replaced with an in-app sheet (RestoreGuideSheet) that explains why Shellbee can't restore (no MQTT API for it, requires host access) and walks through the steps to do it from the Z2M host. No external links for the core content. Backup files now write to Documents/Backups/ instead of temporaryDirectory. The temp directory's sandboxed URLs caused the iOS share sheet to fall back to a minimal set of receivers ("Save to Files" only — no AirDrop, Mail, Messages, third-party apps). Documents/Backups/ is durable and exposes the full receiver list. ShareLink also gets an explicit SharePreview so the receiver shows a proper file label and zip glyph. Co-Authored-By: Claude Opus 4.7 (1M context) --- Shellbee.xcodeproj/project.pbxproj | 1 + .../Features/Settings/Backup/BackupView.swift | 28 ++++- .../Settings/Backup/RestoreGuideSheet.swift | 118 ++++++++++++++++++ 3 files changed, 142 insertions(+), 5 deletions(-) create mode 100644 Shellbee/Features/Settings/Backup/RestoreGuideSheet.swift diff --git a/Shellbee.xcodeproj/project.pbxproj b/Shellbee.xcodeproj/project.pbxproj index 5618e99..02fb6aa 100644 --- a/Shellbee.xcodeproj/project.pbxproj +++ b/Shellbee.xcodeproj/project.pbxproj @@ -204,6 +204,7 @@ Features/Settings/AppPerformanceView.swift, Features/Settings/AvailabilitySettingsView.swift, Features/Settings/Backup/BackupView.swift, + Features/Settings/Backup/RestoreGuideSheet.swift, Features/Settings/Developer/DeveloperSettings.swift, Features/Settings/Developer/DeveloperSettingsView.swift, Features/Settings/Developer/MQTTInspectorView.swift, diff --git a/Shellbee/Features/Settings/Backup/BackupView.swift b/Shellbee/Features/Settings/Backup/BackupView.swift index 39917ce..5f2574a 100644 --- a/Shellbee/Features/Settings/Backup/BackupView.swift +++ b/Shellbee/Features/Settings/Backup/BackupView.swift @@ -6,6 +6,7 @@ struct BackupView: View { @State private var status: Status = .idle @State private var lastBackupURL: URL? @State private var history: [HistoryEntry] = HistoryEntry.load() + @State private var showRestoreGuide = false enum Status: Equatable { case idle @@ -31,7 +32,10 @@ struct BackupView: View { .disabled(status == .running || !environment.connectionState.isConnected) if let url = lastBackupURL { - ShareLink(item: url) { + ShareLink( + item: url, + preview: SharePreview(url.lastPathComponent, image: Image(systemName: "doc.zipper")) + ) { Label("Save / Share", systemImage: "square.and.arrow.up") } } @@ -84,14 +88,19 @@ struct BackupView: View { } Section { - Link(destination: URL(string: "https://www.zigbee2mqtt.io/guide/usage/backup_restore.html")!) { - Label("Restore guide", systemImage: "arrow.up.bin.fill") + Button { + showRestoreGuide = true + } label: { + Label("Restore Guide", systemImage: "arrow.up.bin.fill") } } footer: { - Text("Restoring a backup requires host-level access to your Z2M data directory. The bridge does not expose a restore API.") + Text("Restoring a backup requires host-level access to your Z2M data directory. Shellbee can't perform the restore — open the guide for the steps.") } } .navigationTitle("Backup") + .sheet(isPresented: $showRestoreGuide) { + RestoreGuideSheet() + } } private func triggerBackup() { @@ -126,7 +135,16 @@ struct BackupView: View { let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM-dd-HHmmss" let filename = "shellbee-z2m-backup-\(formatter.string(from: .now)).zip" - let url = FileManager.default.temporaryDirectory.appendingPathComponent(filename) + // Documents/Backups/ — durable enough for the share sheet to expose + // the full set of receivers (AirDrop, Mail, Messages, third-party apps). + // temporaryDirectory works for ShareLink in theory but receivers see + // a sandboxed URL and many fall back to "Save to Files" only. + let docs = try FileManager.default.url( + for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true + ) + let dir = docs.appendingPathComponent("Backups", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + let url = dir.appendingPathComponent(filename) try data.write(to: url, options: .atomic) return url } diff --git a/Shellbee/Features/Settings/Backup/RestoreGuideSheet.swift b/Shellbee/Features/Settings/Backup/RestoreGuideSheet.swift new file mode 100644 index 0000000..48af29a --- /dev/null +++ b/Shellbee/Features/Settings/Backup/RestoreGuideSheet.swift @@ -0,0 +1,118 @@ +import SwiftUI + +struct RestoreGuideSheet: View { + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: DesignTokens.Spacing.lg) { + header + + section( + title: "Why Shellbee can't restore", + body: "Z2M's MQTT API exposes a backup endpoint but no restore endpoint. Restoring means replacing the running Z2M's data directory and bringing the bridge back up — operations that need filesystem and process control on the Z2M host. Mobile apps don't have that, and exposing it over MQTT would be a way to wipe your network with one wrong tap. Use the steps below from a machine that has shell access to your Z2M host." + ) + + section( + title: "What's in the backup", + body: "The zip Shellbee produces contains everything Z2M needs to come back online with the same network: \u{2022} configuration.yaml \u{2022} coordinator_backup.json \u{2022} state.json \u{2022} database.db \u{2022} log files (optional, can be deleted before restoring)." + ) + + stepsSection + notesSection + linksSection + } + .padding() + } + .navigationTitle("Restoring a Backup") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { dismiss() } + } + } + } + } + + private var header: some View { + VStack(alignment: .leading, spacing: 6) { + Label("Host-only operation", systemImage: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + .font(.subheadline.weight(.semibold)) + Text("This guide covers restoring Zigbee2MQTT from a backup. Shellbee cannot perform the restore — it has to be done on the machine running Z2M.") + .font(.callout) + .foregroundStyle(.secondary) + } + } + + private func section(title: String, body: String) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text(title) + .font(.headline) + Text(body) + .font(.callout) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + + private var stepsSection: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Steps").font(.headline) + stepRow(n: 1, title: "Stop Zigbee2MQTT", body: "On your host: `systemctl stop zigbee2mqtt`, `docker compose stop zigbee2mqtt`, or your equivalent. The bridge must not be running while you restore.") + stepRow(n: 2, title: "Back up the current data folder", body: "Move (don't delete) Z2M's existing data directory to a side location, e.g. `mv data data.before-restore`. If something goes wrong you can swap back.") + stepRow(n: 3, title: "Unzip the backup into the data folder", body: "Place the contents of the Shellbee-produced zip where the original `data/` directory was. Permissions should match the user that runs Z2M.") + stepRow(n: 4, title: "Start Zigbee2MQTT", body: "Bring Z2M back up. Watch the logs — coordinator backup mismatches will be reported on first start.") + stepRow(n: 5, title: "Verify in Shellbee", body: "Reconnect Shellbee. Confirm the device list is intact and devices report state. If devices don't respond, they may need re-pairing — but that's rare unless the coordinator firmware was also reflashed.") + } + } + + private func stepRow(n: Int, title: String, body: String) -> some View { + HStack(alignment: .top, spacing: 12) { + Text("\(n)") + .font(.subheadline.weight(.bold)) + .foregroundStyle(.white) + .frame(width: 24, height: 24) + .background(.indigo, in: Circle()) + VStack(alignment: .leading, spacing: 4) { + Text(title).font(.subheadline.weight(.semibold)) + Text(body).font(.callout).foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + + private var notesSection: some View { + VStack(alignment: .leading, spacing: 6) { + Text("Notes").font(.headline) + bullet("If you're moving Z2M to a new host, install the same Z2M version that produced the backup before restoring. Cross-version restores can fail on schema migrations.") + bullet("If the coordinator stick was reflashed or replaced, the network key may differ from what's in the backup. Re-pair affected devices or restore the coordinator firmware too.") + bullet("On Home Assistant OS, the Z2M add-on stores data in the add-on's persistent volume. Use the add-on's own snapshot/restore — don't try to overlay files manually.") + } + } + + private func bullet(_ text: String) -> some View { + HStack(alignment: .top, spacing: 8) { + Text("\u{2022}").font(.callout).foregroundStyle(.secondary) + Text(text).font(.callout).foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + + private var linksSection: some View { + VStack(alignment: .leading, spacing: 6) { + Text("Further reading").font(.headline) + Link(destination: URL(string: "https://www.zigbee2mqtt.io/guide/installation/")!) { + Label("Zigbee2MQTT installation guide", systemImage: "arrow.up.right.square") + } + Link(destination: URL(string: "https://www.zigbee2mqtt.io/guide/usage/")!) { + Label("Zigbee2MQTT usage docs", systemImage: "arrow.up.right.square") + } + } + } +} + +#Preview { + RestoreGuideSheet() +} From 7c441ff6721a3305a7c42736e53e9463b94735e0 Mon Sep 17 00:00:00 2001 From: tashda Date: Tue, 28 Apr 2026 15:16:10 +0200 Subject: [PATCH 07/24] =?UTF-8?q?Redesign=20MQTT=20Inspector=20=E2=80=94?= =?UTF-8?q?=20cleaner=20chrome,=20persistent=20state,=20prominent=20CTA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes: - State loss on tab switch. Subscribe/Publish were sibling subviews inside a switch — toggling destroyed the active view and lost the message buffer. Lifted into an @Observable SubscribeStore owned by MQTTInspectorView so the buffer persists for the inspector lifetime. - Native chrome. Segmented control moved into the navigation toolbar (principal placement). Filter is now a .searchable bar. Pause / Clear moved to topBarTrailing icon buttons. List uses .plain style with a ContentUnavailableView for the empty state. Each message row gets a payload card with a tertiary fill background and a Show more / less toggle when content exceeds six lines. - Publish button. Was a plain Form row; now a borderedProminent large-control CTA pinned below the form on a .bar background, full width with a paperplane glyph. Topic field has submitLabel(.next) and hands focus to the payload editor on return. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Developer/MQTTInspectorView.swift | 320 ++++++++++++------ 1 file changed, 208 insertions(+), 112 deletions(-) diff --git a/Shellbee/Features/Settings/Developer/MQTTInspectorView.swift b/Shellbee/Features/Settings/Developer/MQTTInspectorView.swift index 8981937..0aed247 100644 --- a/Shellbee/Features/Settings/Developer/MQTTInspectorView.swift +++ b/Shellbee/Features/Settings/Developer/MQTTInspectorView.swift @@ -3,38 +3,80 @@ import SwiftUI struct MQTTInspectorView: View { @Environment(AppEnvironment.self) private var environment @State private var selectedTab: Tab = .subscribe + @State private var store = SubscribeStore() - enum Tab: String, CaseIterable, Identifiable { + enum Tab: String, CaseIterable, Identifiable, Hashable { case subscribe = "Subscribe" case publish = "Publish" var id: String { rawValue } } var body: some View { - VStack(spacing: 0) { - Picker("Mode", selection: $selectedTab) { - ForEach(Tab.allCases) { tab in - Text(tab.rawValue).tag(tab) - } - } - .pickerStyle(.segmented) - .padding() - + ZStack { switch selectedTab { case .subscribe: - SubscribeView() + SubscribeView(store: store) case .publish: PublishView() } } .navigationTitle("MQTT Inspector") .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .principal) { + Picker("Mode", selection: $selectedTab) { + ForEach(Tab.allCases) { tab in + Text(tab.rawValue).tag(tab) + } + } + .pickerStyle(.segmented) + .frame(maxWidth: 280) + } + } + .onAppear { store.attach(session: environment.session) } + .onDisappear { store.detach(session: environment.session) } } } -// MARK: - Subscribe +// MARK: - Model + +@Observable +final class SubscribeStore { + var messages: [InspectorMessage] = [] + var paused: Bool = false + var filter: String = "" + let bufferCap: Int = 1000 + + func attach(session: ConnectionSessionController) { + session.rawInboundTap = { [weak self] topic, payload in + guard let self, !self.paused else { return } + let msg = InspectorMessage(timestamp: .now, topic: topic, payload: payload) + Task { @MainActor [weak self] in + guard let self else { return } + self.messages.append(msg) + if self.messages.count > self.bufferCap { + self.messages.removeFirst(self.messages.count - self.bufferCap) + } + } + } + } + + func detach(session: ConnectionSessionController) { + session.rawInboundTap = nil + } -private struct InspectorMessage: Identifiable, Equatable { + func clear() { + messages.removeAll() + } + + var filtered: [InspectorMessage] { + let f = filter.trimmingCharacters(in: .whitespaces) + guard !f.isEmpty else { return messages } + return messages.filter { $0.topic.localizedCaseInsensitiveContains(f) } + } +} + +struct InspectorMessage: Identifiable, Equatable { let id = UUID() let timestamp: Date let topic: String @@ -51,88 +93,112 @@ private struct InspectorMessage: Identifiable, Equatable { } } +// MARK: - Subscribe + private struct SubscribeView: View { - @Environment(AppEnvironment.self) private var environment - @State private var filter: String = "" - @State private var messages: [InspectorMessage] = [] - @State private var paused: Bool = false - @State private var bufferCap: Int = 1000 + @Bindable var store: SubscribeStore var body: some View { - VStack(spacing: 0) { - HStack { - TextField("Filter (substring of topic)", text: $filter) - .textFieldStyle(.roundedBorder) - .autocorrectionDisabled() - .textInputAutocapitalization(.never) - Button(paused ? "Resume" : "Pause") { - paused.toggle() - } - .buttonStyle(.bordered) - Button("Clear") { - messages.removeAll() + List { + if store.filtered.isEmpty { + ContentUnavailableView { + Label("No messages", systemImage: "dot.radiowaves.left.and.right") + } description: { + Text(emptyDescription) } - .buttonStyle(.bordered) - } - .padding(.horizontal) - .padding(.bottom, 8) - - if filteredMessages.isEmpty { - ContentUnavailableView( - "No messages", - systemImage: "dot.radiowaves.left.and.right", - description: Text(paused - ? "Inspector is paused." - : "Waiting for messages from \(environment.connectionConfig?.displayName ?? "the bridge")." - ) - ) - .frame(maxWidth: .infinity, maxHeight: .infinity) + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) } else { - List(filteredMessages.reversed()) { msg in - VStack(alignment: .leading, spacing: 4) { - HStack { - Text(msg.topic) - .font(.callout.monospaced()) - .foregroundStyle(.primary) - .lineLimit(1) - .truncationMode(.middle) - Spacer() - Text(msg.timestamp, format: .dateTime.hour().minute().second()) - .font(.caption.monospacedDigit()) - .foregroundStyle(.secondary) - } - Text(msg.prettyPayload) - .font(.caption.monospaced()) - .foregroundStyle(.secondary) - .lineLimit(8) - .textSelection(.enabled) - } - .padding(.vertical, 2) + ForEach(store.filtered.reversed()) { msg in + MessageRow(message: msg) + .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) } - .listStyle(.plain) } } - .onAppear { - environment.session.rawInboundTap = { topic, payload in - guard !paused else { return } - let msg = InspectorMessage(timestamp: .now, topic: topic, payload: payload) - Task { @MainActor in - messages.append(msg) - if messages.count > bufferCap { - messages.removeFirst(messages.count - bufferCap) - } + .listStyle(.plain) + .searchable(text: $store.filter, prompt: "Filter topics") + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .toolbar { + ToolbarItemGroup(placement: .topBarTrailing) { + Button { + store.paused.toggle() + } label: { + Image(systemName: store.paused ? "play.fill" : "pause.fill") } + .accessibilityLabel(store.paused ? "Resume" : "Pause") + Button { + store.clear() + } label: { + Image(systemName: "trash") + } + .accessibilityLabel("Clear") + .disabled(store.messages.isEmpty) } } - .onDisappear { - environment.session.rawInboundTap = nil + .overlay(alignment: .bottom) { + if store.paused { + Text("Paused") + .font(.caption.weight(.semibold)) + .foregroundStyle(.white) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(.orange.opacity(0.9), in: Capsule()) + .padding(.bottom, 12) + } } } - private var filteredMessages: [InspectorMessage] { - let f = filter.trimmingCharacters(in: .whitespaces) - guard !f.isEmpty else { return messages } - return messages.filter { $0.topic.contains(f) } + private var emptyDescription: String { + if store.paused { + return "Inspector is paused. Resume to continue capturing." + } + if !store.filter.isEmpty { + return "No topic matches \"\(store.filter)\"." + } + return "Waiting for the next bridge message." + } +} + +private struct MessageRow: View { + let message: InspectorMessage + @State private var expanded: Bool = false + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .firstTextBaseline) { + Text(message.topic) + .font(.subheadline.monospaced().weight(.semibold)) + .lineLimit(2) + .truncationMode(.middle) + .textSelection(.enabled) + Spacer(minLength: 8) + Text(message.timestamp, format: .dateTime.hour().minute().second()) + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + } + Text(message.prettyPayload) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(expanded ? nil : 6) + .textSelection(.enabled) + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(Color(.tertiarySystemFill)) + ) + if message.prettyPayload.components(separatedBy: "\n").count > 6 { + Button { + withAnimation(.easeInOut(duration: 0.15)) { expanded.toggle() } + } label: { + Text(expanded ? "Show less" : "Show more") + .font(.caption.weight(.medium)) + } + .buttonStyle(.borderless) + } + } + .padding(.vertical, 2) } } @@ -144,43 +210,72 @@ private struct PublishView: View { @State private var payload: String = "" @State private var showWarning: Bool = false @State private var lastResult: String? + @FocusState private var focusedField: Field? + + enum Field: Hashable { case topic, payload } + + private var isValid: Bool { + !topic.trimmingCharacters(in: .whitespaces).isEmpty + } var body: some View { - Form { - Section("Topic") { - TextField("e.g. zigbee2mqtt/Office Lamp/set", text: $topic) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - .font(.callout.monospaced()) - } - Section { - TextEditor(text: $payload) - .frame(minHeight: 120) - .font(.callout.monospaced()) - .autocorrectionDisabled() - .textInputAutocapitalization(.never) - } header: { - Text("Payload") - } footer: { - Text("JSON object, JSON literal, or raw string. Empty payload is allowed.") - } - Section { - Button { - if topic.hasPrefix("bridge/request/") { - showWarning = true - } else { - sendNow() + VStack(spacing: 0) { + Form { + Section { + TextField("e.g. zigbee2mqtt/Office Lamp/set", text: $topic) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .font(.callout.monospaced()) + .focused($focusedField, equals: .topic) + .submitLabel(.next) + .onSubmit { focusedField = .payload } + } header: { + Text("Topic") + } + + Section { + TextEditor(text: $payload) + .frame(minHeight: 140) + .font(.callout.monospaced()) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .focused($focusedField, equals: .payload) + } header: { + Text("Payload") + } footer: { + Text("JSON object, JSON literal, or raw string. Empty payload is allowed.") + } + + if let lastResult { + Section { + Label(lastResult, systemImage: "checkmark.circle.fill") + .font(.footnote) + .foregroundStyle(.green) } - } label: { - Label("Publish", systemImage: "paperplane.fill") } - .disabled(topic.trimmingCharacters(in: .whitespaces).isEmpty) } - if let lastResult { - Section("Last") { - Text(lastResult).font(.footnote).foregroundStyle(.secondary) + + Divider() + + Button { + if topic.hasPrefix("bridge/request/") { + showWarning = true + } else { + sendNow() } + } label: { + HStack { + Image(systemName: "paperplane.fill") + Text("Publish") + .fontWeight(.semibold) + } + .frame(maxWidth: .infinity) } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .disabled(!isValid) + .padding() + .background(.bar) } .alert("Publish to bridge/request/*?", isPresented: $showWarning) { Button("Publish", role: .destructive) { sendNow() } @@ -192,7 +287,8 @@ private struct PublishView: View { private func sendNow() { environment.send(topic: topic, payload: parsedPayload()) - lastResult = "Published to \(topic) at \(Date.now.formatted(date: .omitted, time: .standard))" + lastResult = "Published at \(Date.now.formatted(date: .omitted, time: .standard))" + Haptics.impact(.light) } private func parsedPayload() -> JSONValue { From e4da53736e2530717a08225958d6a65151b958e9 Mon Sep 17 00:00:00 2001 From: tashda Date: Tue, 28 Apr 2026 15:27:49 +0200 Subject: [PATCH 08/24] =?UTF-8?q?Polish=20MQTT=20Inspector=20=E2=80=94=20J?= =?UTF-8?q?SON=20colors,=20stable=20picker,=20unified=20Publish?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes for the inspector chrome: - JSON syntax highlighting on subscribe payloads. Keys blue, string values green, numbers orange, booleans/null purple, structure muted secondary. Topic row also picks up the bridge/logging level color + glyph (red/yellow/blue/gray) when the message is a log entry, matching the raw logs view treatment. - Stable picker position. Switched from .frame(maxWidth:) to a fixed 220pt width on the segmented control, and consolidated trailing toolbar items to one per tab. Subscribe gets a single Menu (Pause + Clear inside); Publish gets a single Reset Form button. Picker stays in the same spot regardless of tab. - Unified Publish layout. Dropped the separate .bar bottom strip — the Publish button is now a borderedProminent CTA inside the Form's last Section with a clear row background, so the page reads as one continuous form. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Features/Devices/DeviceDetailView.swift | 21 +- .../Developer/MQTTInspectorView.swift | 240 +++++++++++++----- .../Shared/FanControl/FanControlCard.swift | 93 ++++++- 3 files changed, 280 insertions(+), 74 deletions(-) diff --git a/Shellbee/Features/Devices/DeviceDetailView.swift b/Shellbee/Features/Devices/DeviceDetailView.swift index ff9abb7..97f6709 100644 --- a/Shellbee/Features/Devices/DeviceDetailView.swift +++ b/Shellbee/Features/Devices/DeviceDetailView.swift @@ -32,12 +32,25 @@ struct DeviceDetailView: View { .listRowBackground(Color.clear) .listRowSeparator(.hidden) - Section { - ExposeCardView(device: device, state: state, mode: .interactive) { payload in + if device.category == .fan, let fanCtx = FanControlContext(device: device, state: state) { + Section { + FanControlCard(context: fanCtx, mode: .interactive, onSend: { payload in + environment.sendDeviceState(device.friendlyName, payload: payload) + }, rendersSectionsInline: false) + .listRowInsets(EdgeInsets()) + .listRowBackground(Color.clear) + } + FanFeatureSections(context: fanCtx, mode: .interactive) { payload in environment.sendDeviceState(device.friendlyName, payload: payload) } - .listRowInsets(EdgeInsets()) - .listRowBackground(Color.clear) + } else { + Section { + ExposeCardView(device: device, state: state, mode: .interactive) { payload in + environment.sendDeviceState(device.friendlyName, payload: payload) + } + .listRowInsets(EdgeInsets()) + .listRowBackground(Color.clear) + } } if device.definition != nil { diff --git a/Shellbee/Features/Settings/Developer/MQTTInspectorView.swift b/Shellbee/Features/Settings/Developer/MQTTInspectorView.swift index 0aed247..29f3a2c 100644 --- a/Shellbee/Features/Settings/Developer/MQTTInspectorView.swift +++ b/Shellbee/Features/Settings/Developer/MQTTInspectorView.swift @@ -23,6 +23,8 @@ struct MQTTInspectorView: View { .navigationTitle("MQTT Inspector") .navigationBarTitleDisplayMode(.inline) .toolbar { + // Fixed-width principal keeps the segmented picker in the same + // place regardless of how many trailing items the active tab has. ToolbarItem(placement: .principal) { Picker("Mode", selection: $selectedTab) { ForEach(Tab.allCases) { tab in @@ -30,7 +32,7 @@ struct MQTTInspectorView: View { } } .pickerStyle(.segmented) - .frame(maxWidth: 280) + .frame(width: 220) } } .onAppear { store.attach(session: environment.session) } @@ -91,6 +93,86 @@ struct InspectorMessage: Identifiable, Equatable { } return text } + + /// Z2M log messages on `bridge/logging` carry a `level` field — surface + /// that color on the row icon to match the raw logs view. + var logLevelColor: Color { + guard topic == Z2MTopics.bridgeLogging, + let level = payload.object?["level"]?.stringValue, + let parsed = LogLevel(rawValue: level.lowercased()) else { + return .secondary + } + return parsed.color + } + + var logLevelIcon: String { + guard topic == Z2MTopics.bridgeLogging, + let level = payload.object?["level"]?.stringValue, + let parsed = LogLevel(rawValue: level.lowercased()) else { + return "dot.radiowaves.up.forward" + } + return parsed.systemImage + } +} + +// MARK: - JSON syntax highlighting + +enum JSONHighlighter { + static func attributed(_ source: String) -> AttributedString { + var out = AttributedString(source) + out.font = .caption.monospaced() + out.foregroundColor = .secondary + + // Keys: "" : → blue + if let regex = try? Regex<(Substring, Substring)>("\"([^\"\\\\]+)\"\\s*:") { + for match in source.matches(of: regex) { + let r = match.range + if let lower = AttributedString.Index(r.lowerBound, within: out), + let upper = AttributedString.Index(r.upperBound, within: out) { + out[lower..(":\\s*(\"[^\"\\\\]*\")") { + for match in source.matches(of: regex) { + let inner = match.output.1 + let r = inner.startIndex..("(?("\\b\(word)\\b") { + for match in source.matches(of: regex) { + let r = match.range + if let lower = AttributedString.Index(r.lowerBound, within: out), + let upper = AttributedString.Index(r.upperBound, within: out) { + out[lower.. 6 { Button { withAnimation(.easeInOut(duration: 0.15)) { expanded.toggle() } @@ -196,6 +288,7 @@ private struct MessageRow: View { .font(.caption.weight(.medium)) } .buttonStyle(.borderless) + .padding(.leading, 22) } } .padding(.vertical, 2) @@ -219,63 +312,74 @@ private struct PublishView: View { } var body: some View { - VStack(spacing: 0) { - Form { - Section { - TextField("e.g. zigbee2mqtt/Office Lamp/set", text: $topic) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - .font(.callout.monospaced()) - .focused($focusedField, equals: .topic) - .submitLabel(.next) - .onSubmit { focusedField = .payload } - } header: { - Text("Topic") - } + Form { + Section { + TextField("e.g. zigbee2mqtt/Office Lamp/set", text: $topic) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .font(.callout.monospaced()) + .focused($focusedField, equals: .topic) + .submitLabel(.next) + .onSubmit { focusedField = .payload } + } header: { + Text("Topic") + } - Section { - TextEditor(text: $payload) - .frame(minHeight: 140) - .font(.callout.monospaced()) - .autocorrectionDisabled() - .textInputAutocapitalization(.never) - .focused($focusedField, equals: .payload) - } header: { - Text("Payload") - } footer: { - Text("JSON object, JSON literal, or raw string. Empty payload is allowed.") - } + Section { + TextEditor(text: $payload) + .frame(minHeight: 140) + .font(.callout.monospaced()) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .focused($focusedField, equals: .payload) + } header: { + Text("Payload") + } footer: { + Text("JSON object, JSON literal, or raw string. Empty payload is allowed.") + } - if let lastResult { - Section { - Label(lastResult, systemImage: "checkmark.circle.fill") - .font(.footnote) - .foregroundStyle(.green) + Section { + Button { + if topic.hasPrefix("bridge/request/") { + showWarning = true + } else { + sendNow() + } + } label: { + HStack { + Spacer() + Label("Publish", systemImage: "paperplane.fill") + .font(.body.weight(.semibold)) + Spacer() } } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .disabled(!isValid) + .listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16)) + .listRowBackground(Color.clear) } - Divider() - - Button { - if topic.hasPrefix("bridge/request/") { - showWarning = true - } else { - sendNow() + if let lastResult { + Section { + Label(lastResult, systemImage: "checkmark.circle.fill") + .font(.footnote) + .foregroundStyle(.green) } - } label: { - HStack { - Image(systemName: "paperplane.fill") - Text("Publish") - .fontWeight(.semibold) + } + } + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + topic = "" + payload = "" + lastResult = nil + } label: { + Image(systemName: "arrow.counterclockwise") } - .frame(maxWidth: .infinity) + .accessibilityLabel("Reset form") + .disabled(topic.isEmpty && payload.isEmpty && lastResult == nil) } - .buttonStyle(.borderedProminent) - .controlSize(.large) - .disabled(!isValid) - .padding() - .background(.bar) } .alert("Publish to bridge/request/*?", isPresented: $showWarning) { Button("Publish", role: .destructive) { sendNow() } diff --git a/Shellbee/Shared/FanControl/FanControlCard.swift b/Shellbee/Shared/FanControl/FanControlCard.swift index e91a698..33bb84f 100644 --- a/Shellbee/Shared/FanControl/FanControlCard.swift +++ b/Shellbee/Shared/FanControl/FanControlCard.swift @@ -4,6 +4,11 @@ struct FanControlCard: View { let context: FanControlContext let mode: CardDisplayMode let onSend: (JSONValue) -> Void + /// When `false`, the feature sections (Behaviour / Indicators / etc.) are + /// suppressed so the caller can render them as native `List` sections. + /// Defaults to `true` to preserve inline rendering for snapshot contexts + /// (e.g. LogDetailView) that aren't backed by a List. + var rendersSectionsInline: Bool = true @State private var speedDraft: Double = 0 @State private var presentedGroup: IndexedGroup? @@ -16,8 +21,10 @@ struct FanControlCard: View { VStack(spacing: DesignTokens.Spacing.lg) { heroCard if hasFilterSection { filterCard } - ForEach(sections) { section in - sectionView(section) + if rendersSectionsInline { + ForEach(sections) { section in + sectionView(section) + } } } .sheet(item: $presentedGroup) { group in @@ -470,6 +477,88 @@ struct FanControlCard: View { } } +// MARK: - Native list sections + +/// Renders the Fan device's feature sections (Behaviour, Indicators, etc.) as +/// native `List` sections. Place inside a `List` whose `.listStyle` is grouped +/// or inset-grouped. The hero / filter cards are still rendered by +/// `FanControlCard` (with `rendersSectionsInline: false`). +struct FanFeatureSections: View { + let context: FanControlContext + let mode: CardDisplayMode + let onSend: (JSONValue) -> Void + + @State private var presentedGroup: IndexedGroup? + + private let filterProps: Set = ["replace_filter", "filter_age", "device_age"] + private let rowIconWidth: CGFloat = 22 + + private var eligibleExtras: [Expose] { + let claimed: Set = Set(["pm25", "air_quality"]).union(filterProps) + return context.extras.filter { e in + guard let prop = e.property else { return false } + return !claimed.contains(prop) + } + } + + private var sections: [LayoutSection] { FeatureLayout.sections(from: eligibleExtras) } + + var body: some View { + ForEach(sections) { section in + Section(section.title) { + ForEach(section.items, id: \.id) { item in + rowFor(item) + } + } + } + .sheet(item: $presentedGroup) { group in + FeatureDetailSheet(title: group.label) { + ForEach(Array(group.members.enumerated()), id: \.element.property) { idx, e in + if idx > 0 { + Divider().padding(.leading, DesignTokens.Spacing.lg + rowIconWidth + DesignTokens.Spacing.md) + } + FanExtraRow(expose: e, state: context.state, mode: mode, + horizontalPadding: DesignTokens.Spacing.lg, + verticalPadding: DesignTokens.Spacing.md, + iconWidth: rowIconWidth, + onSend: onSend) + } + } + } + } + + @ViewBuilder + private func rowFor(_ item: LayoutItem) -> some View { + switch item { + case .row(let expose): + FanExtraRow(expose: expose, state: context.state, mode: mode, + horizontalPadding: 0, + verticalPadding: 0, + iconWidth: rowIconWidth, + onSend: onSend) + case .indexedGroup(let group): + Button { + presentedGroup = group + } label: { + HStack(spacing: DesignTokens.Spacing.md) { + Image(systemName: group.symbol) + .font(.system(size: 16, weight: .medium)) + .symbolRenderingMode(.hierarchical) + .foregroundStyle(.secondary) + .frame(width: rowIconWidth) + Text(group.label).foregroundStyle(.primary) + Spacer() + Text("\(group.members.count)").foregroundStyle(.secondary) + Image(systemName: "chevron.right") + .font(.footnote.weight(.semibold)) + .foregroundStyle(.tertiary) + } + } + .buttonStyle(.plain) + } + } +} + // MARK: - Disclosure row (monochrome, local to fan card) private struct DisclosureRow: View { From e621c30bccae3d261bb30cebb990af5b71d99a1f Mon Sep 17 00:00:00 2001 From: tashda Date: Tue, 28 Apr 2026 20:31:55 +0200 Subject: [PATCH 09/24] Reword negated settings toggles and inline parenthesised units MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OTA: "Disable Automatic Checks" → "Enable Automatic Checks" (positive toggle, inverted binding; underlying disable_automatic_update_check flag still written negated). - OTA Transfer Timing: shorten labels so they no longer truncate ("Transfer Request Timeout" → "Request Timeout", "Delay Between Blocks" → "Block Delay"). - MQTT: "Disable Message Retain" → "Retain Messages" (same negated-flag pattern, inverted binding); footer copy updated. - MQTT: "Max Packet Size (bytes)" now uses InlineIntField so the unit renders alongside the value, matching every other numeric row. - Add UI tests for each label change. Fixes #28 --- .../Features/Settings/MQTTSettingsView.swift | 13 +++--- .../Features/Settings/OTASettingsView.swift | 9 ++-- .../Settings/SettingsUITests.swift | 45 +++++++++++++++++++ 3 files changed, 57 insertions(+), 10 deletions(-) diff --git a/Shellbee/Features/Settings/MQTTSettingsView.swift b/Shellbee/Features/Settings/MQTTSettingsView.swift index 026a82e..16c22c8 100644 --- a/Shellbee/Features/Settings/MQTTSettingsView.swift +++ b/Shellbee/Features/Settings/MQTTSettingsView.swift @@ -88,7 +88,10 @@ struct MQTTSettingsView: View { Section { Toggle("Include Device Metadata", isOn: $includeDeviceInformation) - Toggle("Disable Message Retain", isOn: $forceDisableRetain) + Toggle("Retain Messages", isOn: Binding( + get: { !forceDisableRetain }, + set: { forceDisableRetain = !$0 } + )) Picker("QoS Level", selection: $qos) { Text("QoS 0 — At most once").tag(0) @@ -96,15 +99,11 @@ struct MQTTSettingsView: View { Text("QoS 2 — Exactly once").tag(2) } - LabeledContent("Max Packet Size (bytes)") { - TextField("1048576", value: $maximumPacketSize, format: .number.grouping(.never)) - .multilineTextAlignment(.trailing) - .keyboardType(.numberPad) - } + InlineIntField("Max Packet Size", value: $maximumPacketSize, unit: "bytes", range: 1024...10485760) } header: { Text("Advanced") } footer: { - Text("Include Device Metadata adds model and vendor info to every state message. Disabling retain means the broker won't store the last state for new subscribers.") + Text("Include Device Metadata adds model and vendor info to every state message. Turning off Retain Messages means the broker won't store the last state for new subscribers.") } } .navigationTitle("MQTT") diff --git a/Shellbee/Features/Settings/OTASettingsView.swift b/Shellbee/Features/Settings/OTASettingsView.swift index 07fa1ab..3d3a675 100644 --- a/Shellbee/Features/Settings/OTASettingsView.swift +++ b/Shellbee/Features/Settings/OTASettingsView.swift @@ -26,7 +26,10 @@ struct OTASettingsView: View { var body: some View { Form { Section { - Toggle("Disable Automatic Checks", isOn: $disableAutomaticUpdateCheck) + Toggle("Enable Automatic Checks", isOn: Binding( + get: { !disableAutomaticUpdateCheck }, + set: { disableAutomaticUpdateCheck = !$0 } + )) if !disableAutomaticUpdateCheck { InlineIntField("Check Interval", value: $updateCheckInterval, unit: "min", range: 60...43200) } @@ -50,8 +53,8 @@ struct OTASettingsView: View { } Section { - InlineIntField("Transfer Request Timeout", value: $imageBlockRequestTimeout, unit: "ms", range: 10000...600000) - InlineIntField("Delay Between Blocks", value: $imageBlockResponseDelay, unit: "ms", range: 0...5000) + InlineIntField("Request Timeout", value: $imageBlockRequestTimeout, unit: "ms", range: 10000...600000) + InlineIntField("Block Delay", value: $imageBlockResponseDelay, unit: "ms", range: 0...5000) InlineIntField("Block Size", value: $defaultMaximumDataSize, unit: "bytes", range: 10...100) } header: { Text("Transfer Timing") diff --git a/ShellbeeUITests/Settings/SettingsUITests.swift b/ShellbeeUITests/Settings/SettingsUITests.swift index ee42b24..0b69471 100644 --- a/ShellbeeUITests/Settings/SettingsUITests.swift +++ b/ShellbeeUITests/Settings/SettingsUITests.swift @@ -150,6 +150,51 @@ final class SettingsUITests: ShellbeeUITestCase { _ = app.navigationBars.element(boundBy: 1).waitForExistence(timeout: 5) } + // Behavior: Automatic Checks is presented as a positive toggle + // ("Enable Automatic Checks") rather than the negated Z2M flag + // ("Disable Automatic Checks"). Verifies the user-facing label. + func testOTAAutomaticChecksLabelIsPositive() { + openSettingsScreen("OTA Updates") + let positive = app.staticTexts["Enable Automatic Checks"] + XCTAssertTrue(positive.waitForExistence(timeout: 5), + "OTA settings should show 'Enable Automatic Checks', not the negated Z2M flag") + XCTAssertFalse(app.staticTexts["Disable Automatic Checks"].exists, + "Negated label 'Disable Automatic Checks' should no longer be shown") + } + + // Behavior: Transfer Timing labels must fit within their row + // (no truncation). The shortened labels are visible verbatim. + func testOTATransferTimingLabelsVisible() { + openSettingsScreen("OTA Updates") + app.swipeUp() + XCTAssertTrue(app.staticTexts["Request Timeout"].waitForExistence(timeout: 5)) + XCTAssertTrue(app.staticTexts["Block Delay"].exists) + XCTAssertTrue(app.staticTexts["Block Size"].exists) + } + + // Behavior: MQTT retain is presented as a positive toggle + // ("Retain Messages") rather than the negated Z2M flag. + func testMQTTRetainLabelIsPositive() { + openSettingsScreen("MQTT") + app.swipeUp() + app.swipeUp() + XCTAssertTrue(app.staticTexts["Retain Messages"].waitForExistence(timeout: 5), + "MQTT settings should show 'Retain Messages', not 'Disable Message Retain'") + XCTAssertFalse(app.staticTexts["Disable Message Retain"].exists) + } + + // Behavior: numeric units belong with the value (via InlineIntField), + // never parenthesised in the label. Catches regressions like + // "Max Packet Size (bytes)". + func testMQTTMaxPacketSizeLabelHasNoParenthesisedUnit() { + openSettingsScreen("MQTT") + app.swipeUp() + app.swipeUp() + XCTAssertTrue(app.staticTexts["Max Packet Size"].waitForExistence(timeout: 5)) + XCTAssertFalse(app.staticTexts["Max Packet Size (bytes)"].exists, + "Unit should be rendered alongside the value, not in the label") + } + // MARK: - Health func testHealthSettingsOpens() { From 81a7926a0020447d42747e6a2aac2038a0f7cb23 Mon Sep 17 00:00:00 2001 From: tashda Date: Tue, 28 Apr 2026 20:43:52 +0200 Subject: [PATCH 10/24] Tighten settings labels for iOS-style consistency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops verb prefixes, removes redundant unit/qualifier words, fixes sentence-case stragglers, flips one more negated toggle: - AppGeneralView: "Recent Events on Home" → "Recent Events" (section header already says Home); "Reconnect Attempts" → "Reconnect Limit" (unit "attempts" was repeated); "Automatically share crash reports" → Title Case. - AppPerformanceView: "Concurrent Requests" → "Concurrency" (same unit-repeat). - AvailabilitySettingsView: both "Offline Timeout" rows → "Timeout" (sections disambiguate); "Pause After Retries" → "Pause After". - HealthSettingsView: section "Health Check Interval" → "Interval" (row inside is "Check Interval"). - HomeAssistantSettingsView: drop "Use" prefix on toggles ("Use Legacy Action Sensor" → "Legacy Action Sensor", same for Event Entities). - NetworkSettingsView: section "Hardware Tuning" → "Adapter Tuning". - SerialSettingsView: "Disable Adapter LED" → "Adapter LED" with inverted binding (default ON, user disables); section "Adapter" (containing Adapter Type / Baud Rate / RTS-CTS) → "Connection". Adds UI tests for each rename. Fixes #28 --- .../Features/Settings/AppGeneralView.swift | 6 +-- .../Settings/AppPerformanceView.swift | 2 +- .../Settings/AvailabilitySettingsView.swift | 6 +-- .../Settings/HealthSettingsView.swift | 2 +- .../Settings/HomeAssistantSettingsView.swift | 4 +- .../Settings/NetworkSettingsView.swift | 2 +- .../Settings/SerialSettingsView.swift | 9 ++-- .../Settings/SettingsUITests.swift | 50 +++++++++++++++++++ 8 files changed, 67 insertions(+), 14 deletions(-) diff --git a/Shellbee/Features/Settings/AppGeneralView.swift b/Shellbee/Features/Settings/AppGeneralView.swift index 9b34905..47870cb 100644 --- a/Shellbee/Features/Settings/AppGeneralView.swift +++ b/Shellbee/Features/Settings/AppGeneralView.swift @@ -21,7 +21,7 @@ struct AppGeneralView: View { } Section { - Picker("Recent Events on Home", selection: $recentEventsCount) { + Picker("Recent Events", selection: $recentEventsCount) { ForEach(HomeSettings.recentEventsOptions, id: \.self) { n in Text("\(n)").tag(n) } @@ -36,7 +36,7 @@ struct AppGeneralView: View { Toggle("Connection Live Activity", isOn: $connectionLiveActivityEnabled) Toggle("OTA Live Activity", isOn: $otaLiveActivityEnabled) InlineIntField( - "Reconnect Attempts", + "Reconnect Limit", value: $maxReconnectAttempts, unit: "attempts", range: ConnectionSessionController.maxReconnectAttemptsRange @@ -48,7 +48,7 @@ struct AppGeneralView: View { } Section { - Toggle("Automatically share crash reports", isOn: Binding( + Toggle("Automatically Share Crash Reports", isOn: Binding( get: { consent.alwaysShare }, set: { consent.alwaysShare = $0 } )) diff --git a/Shellbee/Features/Settings/AppPerformanceView.swift b/Shellbee/Features/Settings/AppPerformanceView.swift index 7969dc8..4f3c27d 100644 --- a/Shellbee/Features/Settings/AppPerformanceView.swift +++ b/Shellbee/Features/Settings/AppPerformanceView.swift @@ -8,7 +8,7 @@ struct AppPerformanceView: View { Form { Section { InlineIntField( - "Concurrent Requests", + "Concurrency", value: $concurrency, unit: "requests", range: OTABulkOperationQueue.concurrencyRange diff --git a/Shellbee/Features/Settings/AvailabilitySettingsView.swift b/Shellbee/Features/Settings/AvailabilitySettingsView.swift index b07c486..4425ca6 100644 --- a/Shellbee/Features/Settings/AvailabilitySettingsView.swift +++ b/Shellbee/Features/Settings/AvailabilitySettingsView.swift @@ -35,10 +35,10 @@ struct AvailabilitySettingsView: View { if enabled { Section { - InlineIntField("Offline Timeout", value: $activeTimeout, unit: "min", range: 1...60) + InlineIntField("Timeout", value: $activeTimeout, unit: "min", range: 1...60) Toggle("Retry with Backoff", isOn: $activeBackoff) if activeBackoff { - InlineIntField("Pause After Retries", value: $activePauseOnBackoffGt, unit: "retries", range: 0...20) + InlineIntField("Pause After", value: $activePauseOnBackoffGt, unit: "retries", range: 0...20) } InlineIntField("Max Jitter", value: $activeMaxJitter, unit: "ms", range: 0...60000) } header: { @@ -48,7 +48,7 @@ struct AvailabilitySettingsView: View { } Section { - InlineIntField("Offline Timeout", value: $passiveTimeout, unit: "min", range: 60...10000) + InlineIntField("Timeout", value: $passiveTimeout, unit: "min", range: 60...10000) } header: { Text("Battery-Powered Devices") } footer: { diff --git a/Shellbee/Features/Settings/HealthSettingsView.swift b/Shellbee/Features/Settings/HealthSettingsView.swift index c222116..55d21b9 100644 --- a/Shellbee/Features/Settings/HealthSettingsView.swift +++ b/Shellbee/Features/Settings/HealthSettingsView.swift @@ -19,7 +19,7 @@ struct HealthSettingsView: View { Section { InlineIntField("Check Interval", value: $interval, unit: "min", range: 0...120) } header: { - Text("Health Check Interval") + Text("Interval") } footer: { Text("How often the bridge checks its own health. Set to 0 to disable health checks entirely.") } diff --git a/Shellbee/Features/Settings/HomeAssistantSettingsView.swift b/Shellbee/Features/Settings/HomeAssistantSettingsView.swift index 6adf6d8..cc2350f 100644 --- a/Shellbee/Features/Settings/HomeAssistantSettingsView.swift +++ b/Shellbee/Features/Settings/HomeAssistantSettingsView.swift @@ -40,8 +40,8 @@ struct HomeAssistantSettingsView: View { } Section { - Toggle("Use Legacy Action Sensor", isOn: $legacyActionSensor) - Toggle("Use Event Entities", isOn: $experimentalEventEntities) + Toggle("Legacy Action Sensor", isOn: $legacyActionSensor) + Toggle("Event Entities", isOn: $experimentalEventEntities) } header: { Text("Compatibility") } footer: { diff --git a/Shellbee/Features/Settings/NetworkSettingsView.swift b/Shellbee/Features/Settings/NetworkSettingsView.swift index 236ace1..4f23e19 100644 --- a/Shellbee/Features/Settings/NetworkSettingsView.swift +++ b/Shellbee/Features/Settings/NetworkSettingsView.swift @@ -37,7 +37,7 @@ struct NetworkSettingsView: View { numericField("Concurrency", text: $adapterConcurrent, placeholder: "Default", unit: "threads") numericField("Message Delay", text: $adapterDelay, placeholder: "Default", unit: "ms") } header: { - Text("Hardware Tuning") + Text("Adapter Tuning") } footer: { Text("Leave blank to use bridge defaults. Transmit power affects range. Concurrency and message delay affect how fast commands are sent to the adapter.") } diff --git a/Shellbee/Features/Settings/SerialSettingsView.swift b/Shellbee/Features/Settings/SerialSettingsView.swift index 991321c..18cdc91 100644 --- a/Shellbee/Features/Settings/SerialSettingsView.swift +++ b/Shellbee/Features/Settings/SerialSettingsView.swift @@ -50,17 +50,20 @@ struct SerialSettingsView: View { } Toggle("RTS/CTS Flow Control", isOn: $rtscts) } header: { - Text("Adapter") + Text("Connection") } footer: { Text("The serial port is read-only and can only be changed in Zigbee2MQTT directly.") } Section { - Toggle("Disable Adapter LED", isOn: $disableLed) + Toggle("Adapter LED", isOn: Binding( + get: { !disableLed }, + set: { disableLed = !$0 } + )) } header: { Text("Hardware") } footer: { - Text("Disables the LED on the Zigbee adapter if supported.") + Text("Turns off the indicator LED on the Zigbee adapter, if supported.") } } .navigationTitle("Adapter") diff --git a/ShellbeeUITests/Settings/SettingsUITests.swift b/ShellbeeUITests/Settings/SettingsUITests.swift index 0b69471..b8dbd4f 100644 --- a/ShellbeeUITests/Settings/SettingsUITests.swift +++ b/ShellbeeUITests/Settings/SettingsUITests.swift @@ -195,6 +195,56 @@ final class SettingsUITests: ShellbeeUITestCase { "Unit should be rendered alongside the value, not in the label") } + // Behavior: Home Assistant toggles drop the "Use" verb prefix — + // iOS toggle labels are nouns, not imperatives. + func testHomeAssistantTogglesAreNouns() { + openSettingsScreen("Home Assistant") + // The toggles are inside the conditional "Compatibility" section, + // only visible when HA is enabled. We just assert the negative — + // the verb-prefixed labels must not exist anywhere on the screen. + XCTAssertFalse(app.staticTexts["Use Legacy Action Sensor"].exists) + XCTAssertFalse(app.staticTexts["Use Event Entities"].exists) + } + + // Behavior: Adapter LED is presented as a positive toggle (default ON), + // not the negated Z2M flag "Disable Adapter LED". + func testAdapterLEDLabelIsPositive() { + openSettingsScreen("Adapter") + app.swipeUp() + XCTAssertTrue(app.staticTexts["Adapter LED"].waitForExistence(timeout: 5), + "Adapter settings should show 'Adapter LED', not 'Disable Adapter LED'") + XCTAssertFalse(app.staticTexts["Disable Adapter LED"].exists) + } + + // Behavior: numeric labels never duplicate their unit + // ("5 attempts attempts" / "5 requests requests" / "3 retries retries"). + func testNumericLabelsDoNotRepeatUnit() { + // App General — Reconnect Limit + openSettingsScreen("General") + app.swipeUp() + XCTAssertFalse(app.staticTexts["Reconnect Attempts"].exists, + "Should be 'Reconnect Limit' to avoid 'attempts attempts'") + // Performance — Concurrency + app.navigationBars.buttons.firstMatch.tap() + openSettingsScreen("Performance") + XCTAssertTrue(app.staticTexts["Concurrency"].waitForExistence(timeout: 5)) + XCTAssertFalse(app.staticTexts["Concurrent Requests"].exists) + } + + // Behavior: when the section header already disambiguates, the row + // label drops the redundant qualifier ("Mains-Powered Devices" → "Timeout", + // not "Offline Timeout"). + func testAvailabilityTimeoutRowsAreUnqualified() { + openSettingsScreen("Availability") + // Enable tracking so the timeout sections appear. + let toggle = app.switches.firstMatch + if toggle.waitForExistence(timeout: 5), toggle.value as? String == "0" { + toggle.tap() + } + // Two "Timeout" rows are expected (one per section); legacy label gone. + XCTAssertFalse(app.staticTexts["Offline Timeout"].exists) + } + // MARK: - Health func testHealthSettingsOpens() { From 478cb6b2a8f0e4b505c7076928b7e826f7cae824 Mon Sep 17 00:00:00 2001 From: tashda Date: Tue, 28 Apr 2026 21:15:03 +0200 Subject: [PATCH 11/24] =?UTF-8?q?Restructure=20App=20settings=20=E2=80=94?= =?UTF-8?q?=20split=20Live=20Activities,=20slim=20General,=20rename=20Perf?= =?UTF-8?q?ormance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit App → General was doing three unrelated jobs under a misleading "Connection" header (Live Activity toggles + scheduled-OTA opt-in + reconnect retry limit) with a four-line footer trying to cover them all. Splits it apart into focused pages. - New AppLiveActivitiesView (linked from Application section between General and Notifications): three toggles "Connection", "OTA Updates", "Scheduled OTAs" across two sections, each with a one-line footer instead of the previous wall of text. - AppGeneralView slimmed: Appearance / Home / Connection (Reconnect Limit only) / Diagnostics / Advanced (Developer Mode now has a section header instead of floating). - AppPerformanceView renamed to "Bulk OTA" — the page only ever contained that one feature; the broad "Performance" title overpromised. Removed the redundant section header. Settings root link label updated to match. - Added UI tests covering the new navigation and the absence of the legacy labels. Fixes #29 --- Shellbee.xcodeproj/project.pbxproj | 1 + .../Features/Settings/AppGeneralView.swift | 8 ++-- .../Settings/AppLiveActivitiesView.swift | 35 +++++++++++++++ .../Settings/AppPerformanceView.swift | 4 +- .../Developer/MQTTInspectorView.swift | 23 +++++----- Shellbee/Features/Settings/SettingsView.swift | 5 ++- .../Settings/SettingsUITests.swift | 43 +++++++++++++++++++ 7 files changed, 98 insertions(+), 21 deletions(-) create mode 100644 Shellbee/Features/Settings/AppLiveActivitiesView.swift diff --git a/Shellbee.xcodeproj/project.pbxproj b/Shellbee.xcodeproj/project.pbxproj index 02fb6aa..976264d 100644 --- a/Shellbee.xcodeproj/project.pbxproj +++ b/Shellbee.xcodeproj/project.pbxproj @@ -200,6 +200,7 @@ Features/Settings/AboutView.swift, Features/Settings/AcknowledgementsView.swift, Features/Settings/AppGeneralView.swift, + Features/Settings/AppLiveActivitiesView.swift, Features/Settings/AppNotificationSettingsView.swift, Features/Settings/AppPerformanceView.swift, Features/Settings/AvailabilitySettingsView.swift, diff --git a/Shellbee/Features/Settings/AppGeneralView.swift b/Shellbee/Features/Settings/AppGeneralView.swift index 47870cb..4b11e6f 100644 --- a/Shellbee/Features/Settings/AppGeneralView.swift +++ b/Shellbee/Features/Settings/AppGeneralView.swift @@ -3,8 +3,6 @@ import SwiftUI struct AppGeneralView: View { @AppStorage("appearanceMode") private var appearanceMode: AppearanceMode = .system @AppStorage(HomeSettings.recentEventsCountKey) private var recentEventsCount: Int = HomeSettings.recentEventsCountDefault - @AppStorage(ConnectionSessionController.connectionLiveActivityEnabledKey) private var connectionLiveActivityEnabled: Bool = true - @AppStorage(ConnectionSessionController.otaLiveActivityEnabledKey) private var otaLiveActivityEnabled: Bool = true @AppStorage(ConnectionSessionController.maxReconnectAttemptsKey) private var maxReconnectAttempts: Int = ConnectionSessionController.defaultMaxReconnectAttempts @AppStorage(DeveloperSettings.modeEnabledKey) private var developerModeEnabled: Bool = false @State private var consent = CrashReportingConsent.shared @@ -33,8 +31,6 @@ struct AppGeneralView: View { } Section { - Toggle("Connection Live Activity", isOn: $connectionLiveActivityEnabled) - Toggle("OTA Live Activity", isOn: $otaLiveActivityEnabled) InlineIntField( "Reconnect Limit", value: $maxReconnectAttempts, @@ -44,7 +40,7 @@ struct AppGeneralView: View { } header: { Text("Connection") } footer: { - Text("Live Activities show connection and OTA progress on the Lock Screen and Dynamic Island. The reconnect limit caps how many times Shellbee retries before giving up; opening the app always tries again immediately.") + Text("How many times Shellbee retries before giving up. Opening the app always tries again.") } Section { @@ -60,6 +56,8 @@ struct AppGeneralView: View { Section { Toggle("Developer Mode", isOn: $developerModeEnabled) + } header: { + Text("Advanced") } footer: { Text("Exposes the MQTT Inspector and other power-user tools under a Developer section in Settings.") } diff --git a/Shellbee/Features/Settings/AppLiveActivitiesView.swift b/Shellbee/Features/Settings/AppLiveActivitiesView.swift new file mode 100644 index 0000000..89a5d5a --- /dev/null +++ b/Shellbee/Features/Settings/AppLiveActivitiesView.swift @@ -0,0 +1,35 @@ +import SwiftUI + +struct AppLiveActivitiesView: View { + @AppStorage(ConnectionSessionController.connectionLiveActivityEnabledKey) private var connectionLiveActivityEnabled: Bool = true + @AppStorage(ConnectionSessionController.otaLiveActivityEnabledKey) private var otaLiveActivityEnabled: Bool = true + @AppStorage(ConnectionSessionController.otaScheduledLiveActivityEnabledKey) private var otaScheduledLiveActivityEnabled: Bool = false + + var body: some View { + Form { + Section { + Toggle("Connection", isOn: $connectionLiveActivityEnabled) + Toggle("OTA Updates", isOn: $otaLiveActivityEnabled) + } header: { + Text("On Lock Screen") + } footer: { + Text("Show progress on the Lock Screen and Dynamic Island.") + } + + Section { + Toggle("Scheduled OTAs", isOn: $otaScheduledLiveActivityEnabled) + .disabled(!otaLiveActivityEnabled) + } footer: { + Text("Off by default — scheduled updates can sit pending for hours waiting for the device to wake up.") + } + } + .navigationTitle("Live Activities") + .navigationBarTitleDisplayMode(.inline) + } +} + +#Preview { + NavigationStack { + AppLiveActivitiesView() + } +} diff --git a/Shellbee/Features/Settings/AppPerformanceView.swift b/Shellbee/Features/Settings/AppPerformanceView.swift index 4f3c27d..5cf1b14 100644 --- a/Shellbee/Features/Settings/AppPerformanceView.swift +++ b/Shellbee/Features/Settings/AppPerformanceView.swift @@ -19,13 +19,11 @@ struct AppPerformanceView: View { unit: "s", range: OTABulkOperationQueue.checkTimeoutRange ) - } header: { - Text("Bulk OTA Checks") } footer: { Text("Controls how Shellbee paces \"Check All for Updates\". Higher concurrency finishes faster but can flood the Zigbee coordinator.") } } - .navigationTitle("Performance") + .navigationTitle("Bulk OTA") .navigationBarTitleDisplayMode(.inline) } } diff --git a/Shellbee/Features/Settings/Developer/MQTTInspectorView.swift b/Shellbee/Features/Settings/Developer/MQTTInspectorView.swift index 29f3a2c..d12f7e0 100644 --- a/Shellbee/Features/Settings/Developer/MQTTInspectorView.swift +++ b/Shellbee/Features/Settings/Developer/MQTTInspectorView.swift @@ -218,8 +218,12 @@ private struct SubscribeView: View { } .disabled(store.messages.isEmpty) } label: { - Image(systemName: store.paused ? "pause.circle.fill" : "ellipsis.circle") - .foregroundStyle(store.paused ? .orange : .blue) + if store.paused { + Image(systemName: "pause.circle.fill") + .foregroundStyle(.orange) + } else { + Image(systemName: "ellipsis") + } } .accessibilityLabel("Inspector actions") } @@ -339,22 +343,17 @@ private struct PublishView: View { } Section { - Button { + Button("Publish") { if topic.hasPrefix("bridge/request/") { showWarning = true } else { sendNow() } - } label: { - HStack { - Spacer() - Label("Publish", systemImage: "paperplane.fill") - .font(.body.weight(.semibold)) - Spacer() - } } .buttonStyle(.borderedProminent) .controlSize(.large) + .fontWeight(.semibold) + .frame(maxWidth: .infinity) .disabled(!isValid) .listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16)) .listRowBackground(Color.clear) @@ -375,9 +374,9 @@ private struct PublishView: View { payload = "" lastResult = nil } label: { - Image(systemName: "arrow.counterclockwise") + Image(systemName: "trash") } - .accessibilityLabel("Reset form") + .accessibilityLabel("Clear form") .disabled(topic.isEmpty && payload.isEmpty && lastResult == nil) } } diff --git a/Shellbee/Features/Settings/SettingsView.swift b/Shellbee/Features/Settings/SettingsView.swift index ad3623a..26f195a 100644 --- a/Shellbee/Features/Settings/SettingsView.swift +++ b/Shellbee/Features/Settings/SettingsView.swift @@ -167,11 +167,14 @@ struct SettingsView: View { NavigationLink { AppGeneralView() } label: { settingsLabel(title: "General", systemImage: "gearshape.fill", color: .gray) } + NavigationLink { AppLiveActivitiesView() } label: { + settingsLabel(title: "Live Activities", systemImage: "rectangle.inset.filled.and.person.filled", color: .pink) + } NavigationLink { AppNotificationSettingsView() } label: { settingsLabel(title: "Notifications", systemImage: "bell.badge.fill", color: .red) } NavigationLink { AppPerformanceView() } label: { - settingsLabel(title: "Performance", systemImage: "speedometer", color: .blue) + settingsLabel(title: "Bulk OTA", systemImage: "arrow.down.circle.dotted", color: .blue) } NavigationLink { AboutView() } label: { settingsLabel(title: "About", systemImage: "info.circle.fill", color: Color(.systemGray2)) diff --git a/ShellbeeUITests/Settings/SettingsUITests.swift b/ShellbeeUITests/Settings/SettingsUITests.swift index b8dbd4f..cee2c76 100644 --- a/ShellbeeUITests/Settings/SettingsUITests.swift +++ b/ShellbeeUITests/Settings/SettingsUITests.swift @@ -231,6 +231,49 @@ final class SettingsUITests: ShellbeeUITestCase { XCTAssertFalse(app.staticTexts["Concurrent Requests"].exists) } + // Behavior: Live Activities have their own subpage under Application; + // they no longer live on App → General. The new page exposes all three + // toggles and is reachable via a dedicated nav link. + func testLiveActivitiesHasOwnPage() { + // Reach the new link in the Application section. + openSettingsScreen("Live Activities") + XCTAssertTrue( + app.navigationBars["Live Activities"].firstMatch.waitForExistence(timeout: 5), + "Live Activities page did not open" + ) + XCTAssertTrue(app.staticTexts["Connection"].waitForExistence(timeout: 3)) + XCTAssertTrue(app.staticTexts["OTA Updates"].exists) + XCTAssertTrue(app.staticTexts["Scheduled OTAs"].exists) + } + + // Behavior: App → General no longer hosts the Live Activity toggles — + // they moved to their own page. + func testGeneralNoLongerHostsLiveActivities() { + openSettingsScreen("General") + XCTAssertFalse(app.staticTexts["Connection Live Activity"].exists) + XCTAssertFalse(app.staticTexts["OTA Live Activity"].exists) + XCTAssertFalse(app.staticTexts["Show Scheduled OTAs"].exists) + // Reconnect Limit stays on General. + XCTAssertTrue(app.staticTexts["Reconnect Limit"].waitForExistence(timeout: 3)) + } + + // Behavior: the Performance page was renamed to "Bulk OTA" since + // that was its only content. The link label and page title both update. + func testBulkOTAReplacesPerformance() { + // The settings root should expose "Bulk OTA", not "Performance". + let bulkOTARow = app.cells.containing(.staticText, identifier: "Bulk OTA").firstMatch + if !bulkOTARow.waitForExistence(timeout: 3) { + app.swipeUp() + } + XCTAssertTrue(bulkOTARow.waitForExistence(timeout: 5)) + XCTAssertFalse(app.cells.containing(.staticText, identifier: "Performance").firstMatch.exists) + bulkOTARow.tap() + XCTAssertTrue( + app.navigationBars["Bulk OTA"].firstMatch.waitForExistence(timeout: 5), + "Bulk OTA page did not open" + ) + } + // Behavior: when the section header already disambiguates, the row // label drops the redundant qualifier ("Mains-Powered Devices" → "Timeout", // not "Offline Timeout"). From 0cabd6a87197fbf23b584a8559207991d18975e2 Mon Sep 17 00:00:00 2001 From: tashda Date: Tue, 28 Apr 2026 21:21:53 +0200 Subject: [PATCH 12/24] Live Activities: single section so Scheduled OTAs reads as a sub-mode of OTA Updates Two sections made the three toggles look like two unrelated features. Collapses to one section with a combined footer; the disabled state on Scheduled OTAs (when OTA Updates is off) already communicates the parent/child relationship. Refs #29 --- Shellbee/Features/Settings/AppLiveActivitiesView.swift | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/Shellbee/Features/Settings/AppLiveActivitiesView.swift b/Shellbee/Features/Settings/AppLiveActivitiesView.swift index 89a5d5a..863b0c4 100644 --- a/Shellbee/Features/Settings/AppLiveActivitiesView.swift +++ b/Shellbee/Features/Settings/AppLiveActivitiesView.swift @@ -10,17 +10,10 @@ struct AppLiveActivitiesView: View { Section { Toggle("Connection", isOn: $connectionLiveActivityEnabled) Toggle("OTA Updates", isOn: $otaLiveActivityEnabled) - } header: { - Text("On Lock Screen") - } footer: { - Text("Show progress on the Lock Screen and Dynamic Island.") - } - - Section { Toggle("Scheduled OTAs", isOn: $otaScheduledLiveActivityEnabled) .disabled(!otaLiveActivityEnabled) } footer: { - Text("Off by default — scheduled updates can sit pending for hours waiting for the device to wake up.") + Text("Show progress on the Lock Screen and Dynamic Island. Scheduled OTAs are off by default — they can sit pending for hours waiting for the device to wake up.") } } .navigationTitle("Live Activities") From f2d4dff88c52a68a168aca398c0f7bb36ce29edf Mon Sep 17 00:00:00 2001 From: tashda Date: Tue, 28 Apr 2026 21:24:24 +0200 Subject: [PATCH 13/24] About: lift app info + nav links to top, add Sentry to Acknowledgements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reorders the About page so the section the user actually came for — which version am I running, where are the device stats / credits — is the first thing they see. Bridge / Zigbee Network details follow. - New top "Shellbee" section: Version, Build, Device Statistics link, Acknowledgements link. Drops the previous unnamed footer-section that hosted the two nav links. - Acknowledgements: add Sentry Cocoa SDK (MIT). It's the only third-party SDK shipped with the app (per PRIVACY.md), and it belongs alongside the Z2M / zigbee2mqtt.io entries. Refs #29 --- Shellbee/Features/Settings/AboutView.swift | 33 ++++++++++++------- .../Settings/AcknowledgementsView.swift | 8 ++++- 2 files changed, 29 insertions(+), 12 deletions(-) diff --git a/Shellbee/Features/Settings/AboutView.swift b/Shellbee/Features/Settings/AboutView.swift index ca2b491..a7d4bec 100644 --- a/Shellbee/Features/Settings/AboutView.swift +++ b/Shellbee/Features/Settings/AboutView.swift @@ -6,16 +6,37 @@ struct AboutView: View { private var info: BridgeInfo? { environment.store.bridgeInfo } private var stats: HomeStatsSnapshot { HomeStatsSnapshot(devices: environment.store.devices) } + private var appVersion: String { + Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "—" + } + + private var appBuild: String { + Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "—" + } + var body: some View { Form { + shellbeeSection bridgeSection networkSection - moreSection } .navigationTitle("About") .navigationBarTitleDisplayMode(.inline) } + private var shellbeeSection: some View { + Section("Shellbee") { + CopyableRow(label: "Version", value: appVersion) + CopyableRow(label: "Build", value: appBuild) + NavigationLink { DeviceStatisticsView() } label: { + Text("Device Statistics") + } + NavigationLink { AcknowledgementsView() } label: { + Text("Acknowledgements") + } + } + } + @ViewBuilder private var bridgeSection: some View { Section("Bridge") { @@ -54,16 +75,6 @@ struct AboutView: View { } } - private var moreSection: some View { - Section { - NavigationLink { DeviceStatisticsView() } label: { - Text("Device Statistics") - } - NavigationLink { AcknowledgementsView() } label: { - Text("Acknowledgements") - } - } - } } #Preview { diff --git a/Shellbee/Features/Settings/AcknowledgementsView.swift b/Shellbee/Features/Settings/AcknowledgementsView.swift index 1789273..77a4ca2 100644 --- a/Shellbee/Features/Settings/AcknowledgementsView.swift +++ b/Shellbee/Features/Settings/AcknowledgementsView.swift @@ -16,10 +16,16 @@ struct AcknowledgementsView: View { badge: "GPL-3.0", url: URL(string: "https://github.com/Koenkk/zigbee2mqtt.io")! ) + acknowledgementRow( + title: "Sentry Cocoa SDK", + subtitle: "Powers opt-in crash reporting (off by default)", + badge: "MIT", + url: URL(string: "https://github.com/getsentry/sentry-cocoa")! + ) } header: { Text("Open Source") } footer: { - Text("Shellbee uses documentation and data from the zigbee2mqtt.io project, which is licensed under GPL-3.0.") + Text("Shellbee uses documentation and data from the zigbee2mqtt.io project (GPL-3.0). Crash reporting, when enabled, is powered by the Sentry Cocoa SDK (MIT).") } Section("Support") { From 48cdb8d95126d011301446a544e5a18a01bd48b5 Mon Sep 17 00:00:00 2001 From: tashda Date: Tue, 28 Apr 2026 21:28:34 +0200 Subject: [PATCH 14/24] About: add Rate / GitHub links; fix three stale footers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit About → new "Connect" section between Shellbee and Bridge, with two iOS-native rows: "Rate Shellbee" (star, pink) and "View on GitHub" (code chevron, label colour). Each uses the Settings-style coloured icon tile + trailing arrow.up.right indicator. App Store URL is a TBD placeholder until the App ID is assigned at first TestFlight. Footer copy fixes that became stale after recent label flips: - SerialSettingsView: footer was "Turns off the indicator LED…" but the toggle is now "Adapter LED" (default ON, user disables). Rewritten as "Controls the indicator LED on the Zigbee adapter, if supported." - AvailabilitySettingsView: said "Shellbee tracks…" but the bridge does the tracking; corrected to "the bridge tracks…". - OTASettingsView Transfer Timing: referenced the old "Delay" label; updated to match the renamed "Block Delay". Refs #29 --- Shellbee/Features/Settings/AboutView.swift | 41 +++++++++++++++++++ .../Settings/AvailabilitySettingsView.swift | 2 +- .../Features/Settings/OTASettingsView.swift | 2 +- .../Settings/SerialSettingsView.swift | 2 +- 4 files changed, 44 insertions(+), 3 deletions(-) diff --git a/Shellbee/Features/Settings/AboutView.swift b/Shellbee/Features/Settings/AboutView.swift index a7d4bec..95b06df 100644 --- a/Shellbee/Features/Settings/AboutView.swift +++ b/Shellbee/Features/Settings/AboutView.swift @@ -14,9 +14,15 @@ struct AboutView: View { Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "—" } + // App Store ID is assigned at first TestFlight upload. Until then, the + // Rate row links to a search; replace `idTBD` once known. + private static let appStoreReviewURL = URL(string: "https://apps.apple.com/app/idTBD?action=write-review")! + private static let githubURL = URL(string: "https://github.com/tashda/Shellbee")! + var body: some View { Form { shellbeeSection + connectSection bridgeSection networkSection } @@ -37,6 +43,41 @@ struct AboutView: View { } } + private var connectSection: some View { + Section { + externalLinkRow( + title: "Rate Shellbee", + systemImage: "star.fill", + color: .pink, + url: Self.appStoreReviewURL + ) + externalLinkRow( + title: "View on GitHub", + systemImage: "chevron.left.forwardslash.chevron.right", + color: Color(.label), + url: Self.githubURL + ) + } + } + + private func externalLinkRow(title: String, systemImage: String, color: Color, url: URL) -> some View { + Link(destination: url) { + HStack(spacing: DesignTokens.Spacing.md) { + Image(systemName: systemImage) + .font(.footnote.weight(.semibold)) + .foregroundStyle(.white) + .frame(width: DesignTokens.Size.settingsIconFrame, height: DesignTokens.Size.settingsIconFrame) + .background(color, in: RoundedRectangle(cornerRadius: DesignTokens.CornerRadius.sm, style: .continuous)) + Text(title) + .foregroundStyle(.primary) + Spacer() + Image(systemName: "arrow.up.right") + .font(.footnote.weight(.semibold)) + .foregroundStyle(.tertiary) + } + } + } + @ViewBuilder private var bridgeSection: some View { Section("Bridge") { diff --git a/Shellbee/Features/Settings/AvailabilitySettingsView.swift b/Shellbee/Features/Settings/AvailabilitySettingsView.swift index 4425ca6..01c33da 100644 --- a/Shellbee/Features/Settings/AvailabilitySettingsView.swift +++ b/Shellbee/Features/Settings/AvailabilitySettingsView.swift @@ -30,7 +30,7 @@ struct AvailabilitySettingsView: View { Section { Toggle("Track Device Availability", isOn: $enabled) } footer: { - Text("When enabled, Shellbee tracks whether each device is online or offline. Mains-powered devices use a short timeout; battery-powered devices use a longer one.") + Text("When enabled, the bridge tracks whether each device is online or offline. Mains-powered devices use a short timeout; battery-powered devices use a longer one.") } if enabled { diff --git a/Shellbee/Features/Settings/OTASettingsView.swift b/Shellbee/Features/Settings/OTASettingsView.swift index 3d3a675..68ed930 100644 --- a/Shellbee/Features/Settings/OTASettingsView.swift +++ b/Shellbee/Features/Settings/OTASettingsView.swift @@ -59,7 +59,7 @@ struct OTASettingsView: View { } header: { Text("Transfer Timing") } footer: { - Text("Advanced transfer settings. Request Timeout is how long to wait for each block response (default 150,000 ms). Delay adds a pause between blocks to reduce load. Block Size controls how many bytes are sent per block (default 50).") + Text("Advanced transfer settings. Request Timeout is how long to wait for each block response (default 150,000 ms). Block Delay adds a pause between blocks to reduce load. Block Size controls how many bytes are sent per block (default 50).") } } .navigationTitle("OTA Updates") diff --git a/Shellbee/Features/Settings/SerialSettingsView.swift b/Shellbee/Features/Settings/SerialSettingsView.swift index 18cdc91..93b2f5e 100644 --- a/Shellbee/Features/Settings/SerialSettingsView.swift +++ b/Shellbee/Features/Settings/SerialSettingsView.swift @@ -63,7 +63,7 @@ struct SerialSettingsView: View { } header: { Text("Hardware") } footer: { - Text("Turns off the indicator LED on the Zigbee adapter, if supported.") + Text("Controls the indicator LED on the Zigbee adapter, if supported.") } } .navigationTitle("Adapter") From 8dd4bf567a980291efa4b050d54f7bacea4ee222 Mon Sep 17 00:00:00 2001 From: tashda Date: Tue, 28 Apr 2026 21:29:40 +0200 Subject: [PATCH 15/24] About: wire Rate row to live App Store ID 6763139074 --- Shellbee/Features/Settings/AboutView.swift | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/Shellbee/Features/Settings/AboutView.swift b/Shellbee/Features/Settings/AboutView.swift index 95b06df..38cbb38 100644 --- a/Shellbee/Features/Settings/AboutView.swift +++ b/Shellbee/Features/Settings/AboutView.swift @@ -14,9 +14,7 @@ struct AboutView: View { Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "—" } - // App Store ID is assigned at first TestFlight upload. Until then, the - // Rate row links to a search; replace `idTBD` once known. - private static let appStoreReviewURL = URL(string: "https://apps.apple.com/app/idTBD?action=write-review")! + private static let appStoreReviewURL = URL(string: "https://apps.apple.com/app/id6763139074?action=write-review")! private static let githubURL = URL(string: "https://github.com/tashda/Shellbee")! var body: some View { From 31372f7eff8d4bea1397331fbea27199483eb745 Mon Sep 17 00:00:00 2001 From: tashda Date: Tue, 28 Apr 2026 21:51:50 +0200 Subject: [PATCH 16/24] Fix Fast CI build: declare missing otaScheduledLiveActivityEnabledKey Commit 478cb6b added AppLiveActivitiesView referencing ConnectionSessionController.otaScheduledLiveActivityEnabledKey but never landed the matching `static let` declaration, breaking Fast CI since. Adds the constant. Also restores a paste-corrupted extension declaration in InterviewActivityWidget that the same broken state would have masked behind the earlier compile failure. Co-Authored-By: Claude Opus 4.7 (1M context) --- Shellbee/App/ConnectionSessionController.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Shellbee/App/ConnectionSessionController.swift b/Shellbee/App/ConnectionSessionController.swift index ef32227..4441c0c 100644 --- a/Shellbee/App/ConnectionSessionController.swift +++ b/Shellbee/App/ConnectionSessionController.swift @@ -37,6 +37,7 @@ final class ConnectionSessionController { static let maxReconnectAttemptsKey = "connectionMaxReconnectAttempts" static let connectionLiveActivityEnabledKey = "connectionLiveActivityEnabled" static let otaLiveActivityEnabledKey = "otaLiveActivityEnabled" + static let otaScheduledLiveActivityEnabledKey = "otaScheduledLiveActivityEnabled" static let defaultMaxReconnectAttempts: Int = 3 static let maxReconnectAttemptsRange: ClosedRange = 1...20 private static let baseReconnectDelay: Double = 1 From 6677b7f986cb966fd1dee0a436794cb257123153 Mon Sep 17 00:00:00 2001 From: tashda Date: Tue, 28 Apr 2026 21:52:19 +0200 Subject: [PATCH 17/24] Networking: detect early auth rejection, raise frame cap to 64 MB MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related Z2M WebSocket robustness changes. Early auth rejection — Z2M completes the WS handshake first, then either streams the cached bridge state or closes the socket with 1008/policy-violation when the auth token is wrong. Without waiting for the first inbound frame we'd report "connected" and only fail on the next send. Now waits up to 5s for the first message, surfaces a clear "Server rejected the connection. Check the auth token." on 1008 closes, and replays the validated message into the stream so the session controller still sees it. Frame size — `bridge/response/backup` carries the full Z2M data folder as a base64 blob inside one JSON frame. The default 1 MB URLSessionWebSocketTask cap aborts on populated installs. Raises to 64 MB, enough headroom for typical mesh configs. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Core/Networking/Z2MWebSocketClient.swift | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/Shellbee/Core/Networking/Z2MWebSocketClient.swift b/Shellbee/Core/Networking/Z2MWebSocketClient.swift index d4be857..ab6d4c7 100644 --- a/Shellbee/Core/Networking/Z2MWebSocketClient.swift +++ b/Shellbee/Core/Networking/Z2MWebSocketClient.swift @@ -3,6 +3,19 @@ import Foundation actor Z2MWebSocketClient { private static let connectionTimeout: TimeInterval = 10 + /// After the WS handshake succeeds we wait for the *first inbound message* + /// before declaring the connection valid. Z2M accepts the HTTP 101 upgrade + /// and only then either (a) immediately publishes the cached bridge state / + /// device list, or (b) closes the socket with a policy-violation if the + /// auth token is missing/invalid. Both arrive over the WS — receive() will + /// return data on success and throw on close. If neither happens within + /// this timeout, the bridge is unreachable. + private static let firstMessageTimeout: TimeInterval = 5 + /// Default URLSessionWebSocketTask frame limit is 1 MB. Z2M `bridge/response/backup` + /// payloads carry the entire data folder as a base64 string inside JSON — a populated + /// install with many devices and rotated config backups can produce 5–10 MB frames. + /// Anything beyond this cap aborts the receive loop and disconnects. + static let maximumFrameSize = 64 * 1024 * 1024 private let delegate: Z2MWebSocketSessionDelegate private let session: URLSession @@ -37,12 +50,15 @@ actor Z2MWebSocketClient { state = .connecting let wsTask = session.webSocketTask(with: url) + wsTask.maximumMessageSize = Self.maximumFrameSize task = wsTask delegate.setExpectedTask(wsTask) wsTask.resume() + let firstMessage: URLSessionWebSocketTask.Message do { try await delegate.waitForOpen(timeout: Self.connectionTimeout) + firstMessage = try await receiveFirstMessage(wsTask) } catch { wsTask.cancel(with: .normalClosure, reason: nil) task = nil @@ -53,6 +69,11 @@ actor Z2MWebSocketClient { } state = .connected + // Replay the validated first message into the stream before starting + // the regular receive loop, so the session controller sees it. + if let data = try? Self.extractData(firstMessage) { + continuation.yield(.message(data)) + } receiveLoopTask = Task { await self.receiveLoop(wsTask, continuation: continuation) } return stream } @@ -90,6 +111,51 @@ actor Z2MWebSocketClient { } } + /// Wait for the first inbound message on the freshly-opened WS, racing + /// against a timeout. A close frame from the server (e.g. auth rejection) + /// surfaces as a thrown error from receive(); we re-throw it as a + /// requestFailed with a clear, user-facing reason. + private func receiveFirstMessage(_ wsTask: URLSessionWebSocketTask) async throws -> URLSessionWebSocketTask.Message { + try await withThrowingTaskGroup(of: URLSessionWebSocketTask.Message.self) { group in + group.addTask { + do { + return try await wsTask.receive() + } catch { + throw Z2MError.requestFailed(Self.describeEarlyClose(error, task: wsTask)) + } + } + group.addTask { + try await Task.sleep(for: .seconds(Self.firstMessageTimeout)) + throw Z2MError.timeout + } + guard let result = try await group.next() else { + throw Z2MError.timeout + } + group.cancelAll() + return result + } + } + + private static func describeEarlyClose(_ error: Error, task: URLSessionWebSocketTask) -> String { + let code = task.closeCode + let reason = task.closeReason.flatMap { String(data: $0, encoding: .utf8) }? + .trimmingCharacters(in: .whitespacesAndNewlines) + + // Code 1008 (policy violation) is what Z2M uses for auth rejection. + // Surface a clear, actionable message instead of the raw close reason. + if code == .policyViolation { + if let reason, reason.localizedCaseInsensitiveContains("token") { + return "Authentication failed: \(reason). Check the auth token." + } + return "Server rejected the connection. Check the auth token." + } + + if let reason, !reason.isEmpty { + return "Server closed the connection: \(reason)" + } + return Z2MError.interpret(error) + } + private static func extractData(_ message: URLSessionWebSocketTask.Message) throws -> Data { switch message { case .data(let d): return d From 81433a0c951c41e751ce7b1b8bc5300f680b1519 Mon Sep 17 00:00:00 2001 From: tashda Date: Tue, 28 Apr 2026 21:52:31 +0200 Subject: [PATCH 18/24] Backup: extract BackupPayload, verify zip integrity, polish share UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pulls the base64-decode + zip integrity check out of BackupView into a nonisolated BackupPayload enum so it's unit-testable without SwiftUI. Verifies the decoded file starts with the PK\\x03\\x04 zip magic before declaring success — base64 decoding is lenient enough that an HTML error page or truncated payload would silently produce a non-zip "backup". A failed verification deletes the bogus file and surfaces the failure to the user. UI: consolidates Create + Share into one section, shows the backup size next to "Share Backup" instead of in a status row, and uses a LabeledContent layout for history entries. Restore Guide row gets a chevron affordance and the share sheet routes through UIActivityViewController so the user can save to Files / iCloud / AirDrop without ShareLink's preview quirks on iPad. Co-Authored-By: Claude Opus 4.7 (1M context) --- Shellbee.xcodeproj/project.pbxproj | 1 + .../Settings/Backup/BackupPayload.swift | 47 +++++++ .../Features/Settings/Backup/BackupView.swift | 110 +++++++++------ .../Settings/Backup/RestoreGuideSheet.swift | 131 ++++++++---------- ShellbeeTests/Unit/BackupPayloadTests.swift | 66 +++++++++ 5 files changed, 239 insertions(+), 116 deletions(-) create mode 100644 Shellbee/Features/Settings/Backup/BackupPayload.swift create mode 100644 ShellbeeTests/Unit/BackupPayloadTests.swift diff --git a/Shellbee.xcodeproj/project.pbxproj b/Shellbee.xcodeproj/project.pbxproj index 976264d..0ee9b4d 100644 --- a/Shellbee.xcodeproj/project.pbxproj +++ b/Shellbee.xcodeproj/project.pbxproj @@ -204,6 +204,7 @@ Features/Settings/AppNotificationSettingsView.swift, Features/Settings/AppPerformanceView.swift, Features/Settings/AvailabilitySettingsView.swift, + Features/Settings/Backup/BackupPayload.swift, Features/Settings/Backup/BackupView.swift, Features/Settings/Backup/RestoreGuideSheet.swift, Features/Settings/Developer/DeveloperSettings.swift, diff --git a/Shellbee/Features/Settings/Backup/BackupPayload.swift b/Shellbee/Features/Settings/Backup/BackupPayload.swift new file mode 100644 index 0000000..ac89ab9 --- /dev/null +++ b/Shellbee/Features/Settings/Backup/BackupPayload.swift @@ -0,0 +1,47 @@ +import Foundation + +/// Decoding + integrity checks for the zip payload returned by Z2M's +/// `bridge/response/backup`. Pulled out of `BackupView` so it's unit-testable +/// without spinning up SwiftUI state. +nonisolated enum BackupPayload { + + enum Failure: LocalizedError { + case invalidBase64 + case empty + case notAZipFile + + var errorDescription: String? { + switch self { + case .invalidBase64: return "Backup data was not valid base64." + case .empty: return "Backup file was empty." + case .notAZipFile: return "Backup file is not a valid zip archive." + } + } + } + + /// First four bytes of every zip local file header. + static let zipMagic: [UInt8] = [0x50, 0x4B, 0x03, 0x04] + + /// Decode tolerantly — `.ignoreUnknownCharacters` so embedded whitespace or + /// newlines from upstream serialisers don't cause a silent decode failure. + static func decode(base64: String) throws -> Data { + guard let data = Data(base64Encoded: base64, options: .ignoreUnknownCharacters) else { + throw Failure.invalidBase64 + } + guard !data.isEmpty else { throw Failure.empty } + return data + } + + /// Confirm the file we just wrote is a real zip — not zero bytes and not + /// some HTML error page or truncated payload that base64-decoded cleanly. + static func verifyZip(at url: URL) throws { + let attrs = try FileManager.default.attributesOfItem(atPath: url.path) + let size = (attrs[.size] as? Int) ?? 0 + guard size >= zipMagic.count else { throw Failure.empty } + + let handle = try FileHandle(forReadingFrom: url) + defer { try? handle.close() } + let head = try handle.read(upToCount: zipMagic.count) ?? Data() + guard Array(head) == zipMagic else { throw Failure.notAZipFile } + } +} diff --git a/Shellbee/Features/Settings/Backup/BackupView.swift b/Shellbee/Features/Settings/Backup/BackupView.swift index 5f2574a..20e465e 100644 --- a/Shellbee/Features/Settings/Backup/BackupView.swift +++ b/Shellbee/Features/Settings/Backup/BackupView.swift @@ -5,8 +5,15 @@ struct BackupView: View { @Environment(AppEnvironment.self) private var environment @State private var status: Status = .idle @State private var lastBackupURL: URL? + @State private var lastBackupSize: Int? @State private var history: [HistoryEntry] = HistoryEntry.load() @State private var showRestoreGuide = false + @State private var shareItem: ShareItem? + + private struct ShareItem: Identifiable { + let url: URL + var id: URL { url } + } enum Status: Equatable { case idle @@ -22,7 +29,7 @@ struct BackupView: View { triggerBackup() } label: { HStack { - Label("Create Backup", systemImage: "arrow.down.doc.fill") + Text("Create Backup") Spacer() if status == .running { ProgressView() @@ -31,49 +38,25 @@ struct BackupView: View { } .disabled(status == .running || !environment.connectionState.isConnected) - if let url = lastBackupURL { - ShareLink( - item: url, - preview: SharePreview(url.lastPathComponent, image: Image(systemName: "doc.zipper")) - ) { - Label("Save / Share", systemImage: "square.and.arrow.up") + if let url = lastBackupURL, let size = lastBackupSize { + Button { + shareItem = ShareItem(url: url) + } label: { + LabeledContent("Share Backup", value: formatted(size: size)) } } - } header: { - Text("Create") } footer: { - Text("Backs up Z2M configuration and coordinator state via the bridge. Save the resulting zip to Files, iCloud Drive, or AirDrop.") - } - - switch status { - case .idle: - EmptyView() - case .running: - Section { Text("Working…").foregroundStyle(.secondary) } - case .success(let size): - Section { - Label("Backup ready (\(formatted(size: size)))", systemImage: "checkmark.seal.fill") - .foregroundStyle(.green) - } - case .failed(let reason): - Section { - Label(reason, systemImage: "xmark.octagon.fill") - .foregroundStyle(.red) - } + statusFooter } if !history.isEmpty { Section { ForEach(history) { entry in - HStack { - VStack(alignment: .leading) { - Text(entry.timestamp, format: .dateTime.day().month().year().hour().minute()) - .font(.callout) - Text(formatted(size: entry.size)) - .font(.caption) - .foregroundStyle(.secondary) - } - Spacer() + LabeledContent { + Text(formatted(size: entry.size)) + .foregroundStyle(.secondary) + } label: { + Text(entry.timestamp, format: .dateTime.day().month().year().hour().minute()) } } .onDelete { indices in @@ -81,9 +64,9 @@ struct BackupView: View { HistoryEntry.save(history) } } header: { - Text("Recent") + Text("Recent Backups") } footer: { - Text("Metadata only — Shellbee does not retain backup files. Save them to Files / iCloud Drive when prompted.") + Text("Shellbee does not retain backup files — save them to Files or iCloud Drive when prompted.") } } @@ -91,16 +74,42 @@ struct BackupView: View { Button { showRestoreGuide = true } label: { - Label("Restore Guide", systemImage: "arrow.up.bin.fill") + HStack { + Text("Restore Guide") + .foregroundStyle(Color.primary) + Spacer() + Image(systemName: "chevron.right") + .font(.footnote.weight(.semibold)) + .foregroundStyle(.tertiary) + } } } footer: { - Text("Restoring a backup requires host-level access to your Z2M data directory. Shellbee can't perform the restore — open the guide for the steps.") + Text("Restoring requires host-level access to your Z2M data directory. Shellbee can't perform the restore.") } } .navigationTitle("Backup") .sheet(isPresented: $showRestoreGuide) { RestoreGuideSheet() } + .sheet(item: $shareItem) { item in + ActivityViewController(activityItems: [item.url]) + .ignoresSafeArea() + } + } + + @ViewBuilder + private var statusFooter: some View { + switch status { + case .idle: + Text("Backs up Z2M configuration and coordinator state via the bridge. Save the resulting zip to Files, iCloud Drive, or AirDrop.") + case .running: + Text("Working…") + case .success: + Text("Backup ready. Use Share Backup to save it.") + case .failed(let reason): + Text(reason) + .foregroundStyle(.red) + } } private func triggerBackup() { @@ -112,6 +121,7 @@ struct BackupView: View { let url = try saveBackup(base64: zipBase64) let size = (try? FileManager.default.attributesOfItem(atPath: url.path)[.size] as? Int) ?? 0 lastBackupURL = url + lastBackupSize = size status = .success(size: size) let entry = HistoryEntry(id: UUID(), timestamp: .now, size: size, filename: url.lastPathComponent) history.insert(entry, at: 0) @@ -129,9 +139,7 @@ struct BackupView: View { } private func saveBackup(base64: String) throws -> URL { - guard let data = Data(base64Encoded: base64) else { - throw NSError(domain: "Backup", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid base64 zip"]) - } + let data = try BackupPayload.decode(base64: base64) let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM-dd-HHmmss" let filename = "shellbee-z2m-backup-\(formatter.string(from: .now)).zip" @@ -146,6 +154,12 @@ struct BackupView: View { try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) let url = dir.appendingPathComponent(filename) try data.write(to: url, options: .atomic) + do { + try BackupPayload.verifyZip(at: url) + } catch { + try? FileManager.default.removeItem(at: url) + throw error + } return url } @@ -175,6 +189,16 @@ struct BackupView: View { } } +private struct ActivityViewController: UIViewControllerRepresentable { + let activityItems: [Any] + + func makeUIViewController(context: Context) -> UIActivityViewController { + UIActivityViewController(activityItems: activityItems, applicationActivities: nil) + } + + func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {} +} + #Preview { NavigationStack { BackupView() } .environment(AppEnvironment()) diff --git a/Shellbee/Features/Settings/Backup/RestoreGuideSheet.swift b/Shellbee/Features/Settings/Backup/RestoreGuideSheet.swift index 48af29a..0a58b75 100644 --- a/Shellbee/Features/Settings/Backup/RestoreGuideSheet.swift +++ b/Shellbee/Features/Settings/Backup/RestoreGuideSheet.swift @@ -5,25 +5,61 @@ struct RestoreGuideSheet: View { var body: some View { NavigationStack { - ScrollView { - VStack(alignment: .leading, spacing: DesignTokens.Spacing.lg) { - header + List { + Section { + Label { + VStack(alignment: .leading, spacing: 4) { + Text("Host-only operation") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.orange) + Text("Shellbee cannot perform the restore. Run these steps on the machine that runs Zigbee2MQTT.") + .font(.footnote) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } icon: { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + } + } + + Section("What's in the backup") { + bulletRow("configuration.yaml") + bulletRow("coordinator_backup.json") + bulletRow("state.json") + bulletRow("database.db") + bulletRow("log files (optional, can be deleted before restoring)") + } - section( - title: "Why Shellbee can't restore", - body: "Z2M's MQTT API exposes a backup endpoint but no restore endpoint. Restoring means replacing the running Z2M's data directory and bringing the bridge back up — operations that need filesystem and process control on the Z2M host. Mobile apps don't have that, and exposing it over MQTT would be a way to wipe your network with one wrong tap. Use the steps below from a machine that has shell access to your Z2M host." - ) + Section("Steps") { + stepRow(n: 1, title: "Stop Zigbee2MQTT", body: "On your host: `systemctl stop zigbee2mqtt`, `docker compose stop zigbee2mqtt`, or your equivalent. The bridge must not be running while you restore.") + stepRow(n: 2, title: "Back up the current data folder", body: "Move (don't delete) Z2M's existing data directory to a side location, e.g. `mv data data.before-restore`. If something goes wrong you can swap back.") + stepRow(n: 3, title: "Unzip the backup into the data folder", body: "Place the contents of the Shellbee-produced zip where the original `data/` directory was. Permissions should match the user that runs Z2M.") + stepRow(n: 4, title: "Start Zigbee2MQTT", body: "Bring Z2M back up. Watch the logs — coordinator backup mismatches will be reported on first start.") + stepRow(n: 5, title: "Verify in Shellbee", body: "Reconnect Shellbee. Confirm the device list is intact and devices report state. If devices don't respond, they may need re-pairing — but that's rare unless the coordinator firmware was also reflashed.") + } - section( - title: "What's in the backup", - body: "The zip Shellbee produces contains everything Z2M needs to come back online with the same network: \u{2022} configuration.yaml \u{2022} coordinator_backup.json \u{2022} state.json \u{2022} database.db \u{2022} log files (optional, can be deleted before restoring)." - ) + Section("Notes") { + bulletRow("If you're moving Z2M to a new host, install the same Z2M version that produced the backup before restoring. Cross-version restores can fail on schema migrations.") + bulletRow("If the coordinator stick was reflashed or replaced, the network key may differ from what's in the backup. Re-pair affected devices or restore the coordinator firmware too.") + bulletRow("On Home Assistant OS, the Z2M add-on stores data in the add-on's persistent volume. Use the add-on's own snapshot/restore — don't try to overlay files manually.") + } + + Section("Further reading") { + Link(destination: URL(string: "https://www.zigbee2mqtt.io/guide/installation/")!) { + Label("Zigbee2MQTT installation guide", systemImage: "arrow.up.right.square") + } + Link(destination: URL(string: "https://www.zigbee2mqtt.io/guide/usage/")!) { + Label("Zigbee2MQTT usage docs", systemImage: "arrow.up.right.square") + } + } - stepsSection - notesSection - linksSection + Section("Why Shellbee can't restore") { + Text("Z2M's MQTT API exposes a backup endpoint but no restore endpoint. Restoring means replacing the running Z2M's data directory and bringing the bridge back up — operations that need filesystem and process control on the Z2M host. Mobile apps don't have that, and exposing it over MQTT would be a way to wipe your network with one wrong tap.") + .font(.footnote) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) } - .padding() } .navigationTitle("Restoring a Backup") .navigationBarTitleDisplayMode(.inline) @@ -35,39 +71,6 @@ struct RestoreGuideSheet: View { } } - private var header: some View { - VStack(alignment: .leading, spacing: 6) { - Label("Host-only operation", systemImage: "exclamationmark.triangle.fill") - .foregroundStyle(.orange) - .font(.subheadline.weight(.semibold)) - Text("This guide covers restoring Zigbee2MQTT from a backup. Shellbee cannot perform the restore — it has to be done on the machine running Z2M.") - .font(.callout) - .foregroundStyle(.secondary) - } - } - - private func section(title: String, body: String) -> some View { - VStack(alignment: .leading, spacing: 6) { - Text(title) - .font(.headline) - Text(body) - .font(.callout) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - } - - private var stepsSection: some View { - VStack(alignment: .leading, spacing: 12) { - Text("Steps").font(.headline) - stepRow(n: 1, title: "Stop Zigbee2MQTT", body: "On your host: `systemctl stop zigbee2mqtt`, `docker compose stop zigbee2mqtt`, or your equivalent. The bridge must not be running while you restore.") - stepRow(n: 2, title: "Back up the current data folder", body: "Move (don't delete) Z2M's existing data directory to a side location, e.g. `mv data data.before-restore`. If something goes wrong you can swap back.") - stepRow(n: 3, title: "Unzip the backup into the data folder", body: "Place the contents of the Shellbee-produced zip where the original `data/` directory was. Permissions should match the user that runs Z2M.") - stepRow(n: 4, title: "Start Zigbee2MQTT", body: "Bring Z2M back up. Watch the logs — coordinator backup mismatches will be reported on first start.") - stepRow(n: 5, title: "Verify in Shellbee", body: "Reconnect Shellbee. Confirm the device list is intact and devices report state. If devices don't respond, they may need re-pairing — but that's rare unless the coordinator firmware was also reflashed.") - } - } - private func stepRow(n: Int, title: String, body: String) -> some View { HStack(alignment: .top, spacing: 12) { Text("\(n)") @@ -77,39 +80,21 @@ struct RestoreGuideSheet: View { .background(.indigo, in: Circle()) VStack(alignment: .leading, spacing: 4) { Text(title).font(.subheadline.weight(.semibold)) - Text(body).font(.callout).foregroundStyle(.secondary) + Text(body).font(.footnote).foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } } + .padding(.vertical, 2) } - private var notesSection: some View { - VStack(alignment: .leading, spacing: 6) { - Text("Notes").font(.headline) - bullet("If you're moving Z2M to a new host, install the same Z2M version that produced the backup before restoring. Cross-version restores can fail on schema migrations.") - bullet("If the coordinator stick was reflashed or replaced, the network key may differ from what's in the backup. Re-pair affected devices or restore the coordinator firmware too.") - bullet("On Home Assistant OS, the Z2M add-on stores data in the add-on's persistent volume. Use the add-on's own snapshot/restore — don't try to overlay files manually.") - } - } - - private func bullet(_ text: String) -> some View { + private func bulletRow(_ text: String) -> some View { HStack(alignment: .top, spacing: 8) { - Text("\u{2022}").font(.callout).foregroundStyle(.secondary) - Text(text).font(.callout).foregroundStyle(.secondary) + Text("\u{2022}") + .foregroundStyle(.secondary) + Text(text) .fixedSize(horizontal: false, vertical: true) } - } - - private var linksSection: some View { - VStack(alignment: .leading, spacing: 6) { - Text("Further reading").font(.headline) - Link(destination: URL(string: "https://www.zigbee2mqtt.io/guide/installation/")!) { - Label("Zigbee2MQTT installation guide", systemImage: "arrow.up.right.square") - } - Link(destination: URL(string: "https://www.zigbee2mqtt.io/guide/usage/")!) { - Label("Zigbee2MQTT usage docs", systemImage: "arrow.up.right.square") - } - } + .font(.footnote) } } diff --git a/ShellbeeTests/Unit/BackupPayloadTests.swift b/ShellbeeTests/Unit/BackupPayloadTests.swift new file mode 100644 index 0000000..0eb7772 --- /dev/null +++ b/ShellbeeTests/Unit/BackupPayloadTests.swift @@ -0,0 +1,66 @@ +import XCTest +@testable import Shellbee + +final class BackupPayloadTests: XCTestCase { + + private static let zipBytes: [UInt8] = [0x50, 0x4B, 0x03, 0x04, 0x14, 0x00, 0x00, 0x00] + + func testDecodeAcceptsCleanBase64() throws { + let base64 = Data(Self.zipBytes).base64EncodedString() + let decoded = try BackupPayload.decode(base64: base64) + XCTAssertEqual(Array(decoded), Self.zipBytes) + } + + func testDecodeAcceptsBase64WithEmbeddedNewlinesAndWhitespace() throws { + // Some MQTT/WS serializers wrap long base64 strings at column boundaries. + // Default Data(base64Encoded:) options would reject this. + let raw = Data(Self.zipBytes).base64EncodedString() + let chunked = raw.enumerated() + .map { index, char in (index > 0 && index % 4 == 0) ? "\n\(char)" : "\(char)" } + .joined() + let withSpaces = " \(chunked)\t\n" + let decoded = try BackupPayload.decode(base64: withSpaces) + XCTAssertEqual(Array(decoded), Self.zipBytes) + } + + func testDecodeRejectsInvalidBase64() { + XCTAssertThrowsError(try BackupPayload.decode(base64: "!!!not-base64!!!")) { error in + XCTAssertEqual(error as? BackupPayload.Failure, .invalidBase64) + } + } + + func testDecodeRejectsEmptyPayload() { + XCTAssertThrowsError(try BackupPayload.decode(base64: "")) { error in + XCTAssertEqual(error as? BackupPayload.Failure, .empty) + } + } + + func testVerifyZipAcceptsFileWithZipMagic() throws { + let url = try writeTempFile(bytes: Self.zipBytes) + defer { try? FileManager.default.removeItem(at: url) } + XCTAssertNoThrow(try BackupPayload.verifyZip(at: url)) + } + + func testVerifyZipRejectsWrongMagicBytes() throws { + let url = try writeTempFile(bytes: [0x3C, 0x68, 0x74, 0x6D, 0x6C, 0x3E]) // "" + defer { try? FileManager.default.removeItem(at: url) } + XCTAssertThrowsError(try BackupPayload.verifyZip(at: url)) { error in + XCTAssertEqual(error as? BackupPayload.Failure, .notAZipFile) + } + } + + func testVerifyZipRejectsTooSmallFile() throws { + let url = try writeTempFile(bytes: [0x50, 0x4B]) // truncated + defer { try? FileManager.default.removeItem(at: url) } + XCTAssertThrowsError(try BackupPayload.verifyZip(at: url)) { error in + XCTAssertEqual(error as? BackupPayload.Failure, .empty) + } + } + + private func writeTempFile(bytes: [UInt8]) throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("backup-test-\(UUID().uuidString).zip") + try Data(bytes).write(to: url) + return url + } +} From 9eedad78b4d1572d84881b65c2e2c3b5541c4013 Mon Sep 17 00:00:00 2001 From: tashda Date: Tue, 28 Apr 2026 21:52:51 +0200 Subject: [PATCH 19/24] OTA: surface scheduled phase across filters, swipes, badges, Home MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Z2M's "scheduled" OTA phase (parked, waiting for the device to wake) was effectively invisible across the app — devices fell out of the Updates filter the moment they were scheduled, the badge ring went indeterminate, and Home alerts didn't differentiate scheduled vs. in-progress. - DeviceCondition.updatesAvailable now also matches scheduled / requested / updating phases via an optional otaStatus argument, so scheduled devices stay in the filter. - DeviceListRow swipe actions: a scheduled row exposes "Cancel" (calls unschedule). Battery devices get Schedule before Update; mains devices keep Update before Schedule. - DeviceUpgradeBadgeView renders a static ring + clock.badge glyph for scheduled — no spinner, since the device is parked. - DeviceListViewModel + DeviceDetailView re-issue an OTA check after unschedule. Z2M leaves update.state at "idle" otherwise, which drops the device out of the Updates filter entirely. - DeviceFirmwareMenu: drops the battery/mains split for the bulk "Check All" button. Z2M only offers a synchronous OTA check; routing battery devices through schedule was a workaround. Now every device goes through the rate-limited bulk queue, and sleepy devices that don't respond surface the standard error like windfront. Empty-state label flips to "No Updates" with a checkmark glyph. - HomeSnapshot adds scheduledUpdateDevices + updatingDevices counts; HomeDevicesCard renders dedicated alert rows for each. - OTAUpdateLiveActivityCoordinator gains an isScheduledEnabled flag (UserDefault, off by default) — scheduled OTAs can sit pending for hours, and most users don't want a Lock Screen surface for that. AppLiveActivitiesView's "Scheduled OTAs" toggle drives it. Co-Authored-By: Claude Opus 4.7 (1M context) --- Shellbee/Core/Models/NetworkAnalysis.swift | 9 ++++- .../Features/Devices/DeviceDetailView.swift | 7 ++++ .../Features/Devices/DeviceFirmwareMenu.swift | 39 +++++++------------ Shellbee/Features/Devices/DeviceListRow.swift | 36 ++++++++++++++--- .../Devices/DeviceListViewModel.swift | 13 ++++++- .../Devices/DeviceUpgradeBadgeView.swift | 10 +++++ Shellbee/Features/Home/HomeDevicesCard.swift | 22 ++++++++++- Shellbee/Features/Home/HomeSnapshot.swift | 12 ++++++ Shellbee/Features/Home/HomeView.swift | 1 + .../OTAUpdateLiveActivityCoordinator.swift | 10 +++++ 10 files changed, 124 insertions(+), 35 deletions(-) diff --git a/Shellbee/Core/Models/NetworkAnalysis.swift b/Shellbee/Core/Models/NetworkAnalysis.swift index 5c4a013..7086060 100644 --- a/Shellbee/Core/Models/NetworkAnalysis.swift +++ b/Shellbee/Core/Models/NetworkAnalysis.swift @@ -21,9 +21,14 @@ enum DeviceCondition: String, CaseIterable, Sendable { } } - func matches(device: Device, state: [String: JSONValue], isAvailable: Bool) -> Bool { + func matches(device: Device, state: [String: JSONValue], isAvailable: Bool, otaStatus: OTAUpdateStatus? = nil) -> Bool { switch self { - case .updatesAvailable: return state.hasUpdateAvailable || state.isUpdating + case .updatesAvailable: + if state.hasUpdateAvailable || state.isUpdating { return true } + switch otaStatus?.phase { + case .scheduled, .requested, .updating: return true + default: return false + } case .online: return isAvailable case .offline: return !isAvailable case .batteryLow: return (state.battery ?? 100) < DesignTokens.Threshold.lowBattery diff --git a/Shellbee/Features/Devices/DeviceDetailView.swift b/Shellbee/Features/Devices/DeviceDetailView.swift index 97f6709..3257f78 100644 --- a/Shellbee/Features/Devices/DeviceDetailView.swift +++ b/Shellbee/Features/Devices/DeviceDetailView.swift @@ -271,6 +271,13 @@ struct DeviceDetailView: View { topic: Z2MTopics.Request.deviceOTAUnschedule, payload: .object(["id": .string(device.friendlyName)]) ) + // Z2M leaves update.state at "idle" after unschedule — re-check so + // the device returns to "available" and stays in the Updates filter. + environment.store.startOTACheck(for: device.friendlyName) + environment.send( + topic: Z2MTopics.Request.deviceOTACheck, + payload: .object(["id": .string(device.friendlyName)]) + ) } } diff --git a/Shellbee/Features/Devices/DeviceFirmwareMenu.swift b/Shellbee/Features/Devices/DeviceFirmwareMenu.swift index 1725310..fc81fb6 100644 --- a/Shellbee/Features/Devices/DeviceFirmwareMenu.swift +++ b/Shellbee/Features/Devices/DeviceFirmwareMenu.swift @@ -4,10 +4,6 @@ struct DeviceFirmwareMenu: View { @Environment(AppEnvironment.self) private var environment @State private var showUpdateAllConfirm = false - static func isBattery(_ device: Device) -> Bool { - (device.powerSource?.lowercased() ?? "").contains("battery") - } - private var otaCapableDevices: [Device] { environment.store.devices.filter { guard $0.definition?.supportsOTA == true else { return false } @@ -43,27 +39,17 @@ struct DeviceFirmwareMenu: View { } Button { - let battery = otaCapableDevices.filter(Self.isBattery) - let mains = otaCapableDevices.filter { !Self.isBattery($0) } - // Mains-powered (& unknown power source) → standard OTA check - // through the rate-limited bulk queue. - let mainsNames = mains.map(\.friendlyName) - if !mainsNames.isEmpty { - for name in mainsNames { - environment.store.startOTACheck(for: name) - } - environment.otaBulkQueue.enqueue(mainsNames, kind: .check) - } - // Battery-powered → schedule directly. Z2M waits for each - // device's next wake-up; no need to rate-limit since these - // requests don't traverse the mesh until the device asks. - for device in battery { - environment.store.startOTASchedule(for: device.friendlyName) - environment.send( - topic: Z2MTopics.Request.deviceOTASchedule, - payload: .object(["id": .string(device.friendlyName)]) - ) + // Z2M only offers a synchronous OTA check; there is no + // "scheduled check". Route every OTA-capable device through + // the rate-limited bulk queue regardless of power source — + // sleepy battery devices that happen to be awake succeed, + // ones that don't respond surface the standard "Device + // didn't respond to OTA" error, same as windfront. + let names = otaCapableDevices.map(\.friendlyName) + for name in names { + environment.store.startOTACheck(for: name) } + environment.otaBulkQueue.enqueue(names, kind: .check) } label: { Label("Check All for Updates\(otaCount > 0 ? " (\(otaCount))" : "")", systemImage: "arrow.trianglehead.2.clockwise") } @@ -72,7 +58,10 @@ struct DeviceFirmwareMenu: View { Button { showUpdateAllConfirm = true } label: { - Label("Update All Available\(updateCount > 0 ? " (\(updateCount))" : "")", systemImage: "arrow.up.circle") + Label( + updateCount > 0 ? "Update All Available (\(updateCount))" : "No Updates", + systemImage: updateCount > 0 ? "arrow.up.circle" : "checkmark.circle" + ) } .disabled(updateCount == 0 || bulkActive) } label: { diff --git a/Shellbee/Features/Devices/DeviceListRow.swift b/Shellbee/Features/Devices/DeviceListRow.swift index 6d9ba2c..516fb5b 100644 --- a/Shellbee/Features/Devices/DeviceListRow.swift +++ b/Shellbee/Features/Devices/DeviceListRow.swift @@ -62,7 +62,12 @@ struct DeviceListRow: View { ) } .swipeActions(edge: .leading, allowsFullSwipe: true) { - if let rejection = rejectionMessage { + if otaStatus?.phase == .scheduled, let onUnschedule { + Button(action: onUnschedule) { + Label("Cancel", systemImage: "xmark.circle") + } + .tint(.orange) + } else if let rejection = rejectionMessage { Button(action: rejectSwipe) { Label(rejection.text, systemImage: rejection.icon) } @@ -72,11 +77,32 @@ struct DeviceListRow: View { Label("Check", systemImage: "arrow.trianglehead.2.clockwise") } .tint(.blue) - if let onUpdate { - Button(action: onUpdate) { - Label("Update", systemImage: "arrow.up.circle") + if isBatteryPowered { + if let onSchedule { + Button(action: onSchedule) { + Label("Schedule", systemImage: "calendar.badge.clock") + } + .tint(.indigo) + } + if let onUpdate { + Button(action: onUpdate) { + Label("Update", systemImage: "arrow.up.circle") + } + .tint(.green) + } + } else { + if let onUpdate { + Button(action: onUpdate) { + Label("Update", systemImage: "arrow.up.circle") + } + .tint(.green) + } + if let onSchedule { + Button(action: onSchedule) { + Label("Schedule", systemImage: "calendar.badge.clock") + } + .tint(.indigo) } - .tint(.green) } } } diff --git a/Shellbee/Features/Devices/DeviceListViewModel.swift b/Shellbee/Features/Devices/DeviceListViewModel.swift index 769f061..4d4cb79 100644 --- a/Shellbee/Features/Devices/DeviceListViewModel.swift +++ b/Shellbee/Features/Devices/DeviceListViewModel.swift @@ -164,7 +164,8 @@ final class DeviceListViewModel { condition.matches( device: $0, state: store.state(for: $0.friendlyName), - isAvailable: store.isAvailable($0.friendlyName) + isAvailable: store.isAvailable($0.friendlyName), + otaStatus: store.otaStatus(for: $0.friendlyName) ) }.count } @@ -234,6 +235,13 @@ final class DeviceListViewModel { topic: Z2MTopics.Request.deviceOTAUnschedule, payload: .object(["id": .string(device.friendlyName)]) ) + // Z2M leaves update.state at "idle" after unschedule — re-check so + // the device returns to "available" and stays in the Updates filter. + environment.store.startOTACheck(for: device.friendlyName) + environment.send( + topic: Z2MTopics.Request.deviceOTACheck, + payload: .object(["id": .string(device.friendlyName)]) + ) } func renameDevice(_ device: Device, to newName: String, homeassistantRename: Bool = true, environment: AppEnvironment) { @@ -281,7 +289,8 @@ final class DeviceListViewModel { condition.matches( device: $0, state: store.state(for: $0.friendlyName), - isAvailable: store.isAvailable($0.friendlyName) + isAvailable: store.isAvailable($0.friendlyName), + otaStatus: store.otaStatus(for: $0.friendlyName) ) } } diff --git a/Shellbee/Features/Devices/DeviceUpgradeBadgeView.swift b/Shellbee/Features/Devices/DeviceUpgradeBadgeView.swift index 1852604..40aa5b1 100644 --- a/Shellbee/Features/Devices/DeviceUpgradeBadgeView.swift +++ b/Shellbee/Features/Devices/DeviceUpgradeBadgeView.swift @@ -71,6 +71,15 @@ struct DeviceUpgradeBadgeView: View { .rotationEffect(.degrees(-90)) .padding(DesignTokens.Size.badgeStroke * 2) .animation(.spring(response: 0.4, dampingFraction: 0.7), value: progress) + } else if status.phase == .scheduled { + // Static ring — scheduled is parked, waiting for the device + // to wake. No animation conveys "queued, idle". + Circle() + .stroke( + Color.blue.opacity(DesignTokens.Opacity.subtleFill * 2), + style: StrokeStyle(lineWidth: DesignTokens.Size.badgeStroke * 3, lineCap: .round) + ) + .padding(DesignTokens.Size.badgeStroke * 2) } else { // Indeterminate Animated Ring Circle() @@ -102,6 +111,7 @@ struct DeviceUpgradeBadgeView: View { switch phase { case .updating: return "arrow.down" case .checking: return "magnifyingglass" + case .scheduled: return "clock.badge" default: return "arrow.trianglehead.2.clockwise" } } diff --git a/Shellbee/Features/Home/HomeDevicesCard.swift b/Shellbee/Features/Home/HomeDevicesCard.swift index 5205137..59bbc33 100644 --- a/Shellbee/Features/Home/HomeDevicesCard.swift +++ b/Shellbee/Features/Home/HomeDevicesCard.swift @@ -6,7 +6,11 @@ struct HomeDevicesCard: View { let onFilter: (DeviceQuickFilter) -> Void private var hasAlerts: Bool { - snapshot.devicesWithUpdates > 0 || snapshot.lowBatteryDevices > 0 || snapshot.weakSignalDevices > 0 + snapshot.devicesWithUpdates > 0 + || snapshot.scheduledUpdateDevices > 0 + || snapshot.updatingDevices > 0 + || snapshot.lowBatteryDevices > 0 + || snapshot.weakSignalDevices > 0 } var body: some View { @@ -49,6 +53,22 @@ struct HomeDevicesCard: View { action: { onFilter(.updatesAvailable) } ) } + if snapshot.scheduledUpdateDevices > 0 { + HomeCardAlertRow( + symbol: "calendar.badge.clock", + title: "\(snapshot.scheduledUpdateDevices) scheduled for update", + color: .indigo, + action: { onFilter(.updatesAvailable) } + ) + } + if snapshot.updatingDevices > 0 { + HomeCardAlertRow( + symbol: "arrow.up.circle.fill", + title: "\(snapshot.updatingDevices) updating now", + color: .green, + action: { onFilter(.updatesAvailable) } + ) + } if snapshot.lowBatteryDevices > 0 { HomeCardAlertRow( symbol: "battery.25", diff --git a/Shellbee/Features/Home/HomeSnapshot.swift b/Shellbee/Features/Home/HomeSnapshot.swift index bd0f3a1..8a4de26 100644 --- a/Shellbee/Features/Home/HomeSnapshot.swift +++ b/Shellbee/Features/Home/HomeSnapshot.swift @@ -11,6 +11,8 @@ struct HomeSnapshot: Sendable { let disabledDevices: Int let groupCount: Int let devicesWithUpdates: Int + let scheduledUpdateDevices: Int + let updatingDevices: Int let lowBatteryDevices: Int let weakSignalDevices: Int let interviewingDevices: Int @@ -48,6 +50,7 @@ struct HomeSnapshot: Sendable { devices: [Device], availability: [String: Bool], states: [String: [String: JSONValue]], + otaStatuses: [String: OTAUpdateStatus] = [:], isConnected: Bool, isBridgeOnline: Bool, groupCount: Int, @@ -73,6 +76,15 @@ struct HomeSnapshot: Sendable { devicesWithUpdates = nonCoordinatorDevices.filter { (states[$0.friendlyName] ?? [:]).hasUpdateAvailable }.count + scheduledUpdateDevices = nonCoordinatorDevices.filter { + let phase = otaStatuses[$0.friendlyName]?.phase + ?? OTAUpdateStatus.Phase(rawValue: (states[$0.friendlyName] ?? [:]).otaUpdateState ?? "") + return phase == .scheduled + }.count + updatingDevices = nonCoordinatorDevices.filter { + if otaStatuses[$0.friendlyName]?.phase == .updating { return true } + return (states[$0.friendlyName] ?? [:]).isUpdating + }.count lowBatteryDevices = nonCoordinatorDevices.filter { guard let battery = (states[$0.friendlyName] ?? [:]).battery else { return false } return battery <= DesignTokens.Threshold.lowBattery diff --git a/Shellbee/Features/Home/HomeView.swift b/Shellbee/Features/Home/HomeView.swift index 019a7ec..aaddc86 100644 --- a/Shellbee/Features/Home/HomeView.swift +++ b/Shellbee/Features/Home/HomeView.swift @@ -20,6 +20,7 @@ struct HomeView: View { devices: environment.store.devices, availability: environment.store.deviceAvailability, states: environment.store.deviceStates, + otaStatuses: environment.store.otaUpdates, isConnected: environment.store.isConnected, isBridgeOnline: environment.store.bridgeOnline, groupCount: environment.store.groups.count, diff --git a/Shellbee/LiveActivities/OTAUpdateLiveActivityCoordinator.swift b/Shellbee/LiveActivities/OTAUpdateLiveActivityCoordinator.swift index 441b912..f538f4a 100644 --- a/Shellbee/LiveActivities/OTAUpdateLiveActivityCoordinator.swift +++ b/Shellbee/LiveActivities/OTAUpdateLiveActivityCoordinator.swift @@ -17,13 +17,23 @@ final class OTAUpdateLiveActivityCoordinator { UserDefaults.standard.object(forKey: ConnectionSessionController.otaLiveActivityEnabledKey) as? Bool ?? true } + /// Whether scheduled OTAs (parked, waiting for the device to wake) are + /// surfaced in the OTA Live Activity. Off by default — a scheduled OTA can + /// sit pending for hours or days, and most users don't want a Lock-Screen + /// surface for that. + static var isScheduledEnabled: Bool { + UserDefaults.standard.object(forKey: ConnectionSessionController.otaScheduledLiveActivityEnabledKey) as? Bool ?? false + } + func sync(with statuses: [OTAUpdateStatus], devices: [Device] = []) { guard Self.isEnabled else { if isVisible { clearAll() } return } + let scheduledEnabled = Self.isScheduledEnabled let activeStatuses = statuses .filter(\.isActive) + .filter { scheduledEnabled || $0.phase != .scheduled } .sorted { lhs, rhs in if lhs.sortPriority != rhs.sortPriority { return lhs.sortPriority < rhs.sortPriority From 5b8fdf688dab51c85e63d0d097da31a9aeb5a88c Mon Sep 17 00:00:00 2001 From: tashda Date: Tue, 28 Apr 2026 21:52:59 +0200 Subject: [PATCH 20/24] Connection: reset session state on user-initiated connect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switching from a working server to one with bad/missing auth left hasBeenConnected == true from the prior session, which routed the new failure into the .lost branch — leaving the user on a stale homepage instead of bouncing back to the setup screen. connect() now drops hasBeenConnected, resets the store, and clears isConnected before kicking off the session, so a failure cleanly surfaces as .failed on a fresh attempt. Co-Authored-By: Claude Opus 4.7 (1M context) --- Shellbee/App/ConnectionSessionController.swift | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Shellbee/App/ConnectionSessionController.swift b/Shellbee/App/ConnectionSessionController.swift index 4441c0c..eb9733b 100644 --- a/Shellbee/App/ConnectionSessionController.swift +++ b/Shellbee/App/ConnectionSessionController.swift @@ -107,6 +107,14 @@ final class ConnectionSessionController { } func connect(config: ConnectionConfig) { + // A user-initiated connect is a fresh attempt — drop any prior session + // state so a failure routes the UI back to the setup screen instead of + // leaving the user on a stale homepage. Without this, switching from a + // working server to one with bad/missing auth would leave hasBeenConnected + // == true, sending the failure into the `.lost` branch. + hasBeenConnected = false + store.reset() + store.isConnected = false connectionConfig = config errorMessage = nil startSession(config: config) From 2b483c297d8ec25d01d154b236e6354c7b8e66dc Mon Sep 17 00:00:00 2001 From: tashda Date: Tue, 28 Apr 2026 21:53:14 +0200 Subject: [PATCH 21/24] Fan card: replace sheet drill-ins with NavigationLink, settings rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fan extras section was rendering each Expose with a custom row (leading icon, custom paddings) and presenting indexed groups via a .sheet. Aligns with iOS Settings conventions instead. - New SettingsFormRow renders one Expose as a plain Form row: label on the left, value or control on the right, no leading icon, no chevron. Writable numerics push a detail screen with a slider. - Indexed groups now drill in through a NavigationLink to a FeatureGroupDetailView instead of bouncing through a sheet — matches the rest of the device detail navigation and keeps the back-stack coherent. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Shared/FanControl/FanControlCard.swift | 208 ++++++++++++++---- 1 file changed, 171 insertions(+), 37 deletions(-) diff --git a/Shellbee/Shared/FanControl/FanControlCard.swift b/Shellbee/Shared/FanControl/FanControlCard.swift index 33bb84f..3fdd2f2 100644 --- a/Shellbee/Shared/FanControl/FanControlCard.swift +++ b/Shellbee/Shared/FanControl/FanControlCard.swift @@ -488,10 +488,7 @@ struct FanFeatureSections: View { let mode: CardDisplayMode let onSend: (JSONValue) -> Void - @State private var presentedGroup: IndexedGroup? - private let filterProps: Set = ["replace_filter", "filter_age", "device_age"] - private let rowIconWidth: CGFloat = 22 private var eligibleExtras: [Expose] { let claimed: Set = Set(["pm25", "air_quality"]).union(filterProps) @@ -511,51 +508,188 @@ struct FanFeatureSections: View { } } } - .sheet(item: $presentedGroup) { group in - FeatureDetailSheet(title: group.label) { - ForEach(Array(group.members.enumerated()), id: \.element.property) { idx, e in - if idx > 0 { - Divider().padding(.leading, DesignTokens.Spacing.lg + rowIconWidth + DesignTokens.Spacing.md) - } - FanExtraRow(expose: e, state: context.state, mode: mode, - horizontalPadding: DesignTokens.Spacing.lg, - verticalPadding: DesignTokens.Spacing.md, - iconWidth: rowIconWidth, - onSend: onSend) - } - } - } } @ViewBuilder private func rowFor(_ item: LayoutItem) -> some View { switch item { case .row(let expose): - FanExtraRow(expose: expose, state: context.state, mode: mode, - horizontalPadding: 0, - verticalPadding: 0, - iconWidth: rowIconWidth, - onSend: onSend) + SettingsFormRow(expose: expose, state: context.state, mode: mode, onSend: onSend) case .indexedGroup(let group): - Button { - presentedGroup = group + NavigationLink { + FeatureGroupDetailView(group: group, context: context, mode: mode, onSend: onSend) } label: { - HStack(spacing: DesignTokens.Spacing.md) { - Image(systemName: group.symbol) - .font(.system(size: 16, weight: .medium)) - .symbolRenderingMode(.hierarchical) - .foregroundStyle(.secondary) - .frame(width: rowIconWidth) - Text(group.label).foregroundStyle(.primary) - Spacer() - Text("\(group.members.count)").foregroundStyle(.secondary) - Image(systemName: "chevron.right") - .font(.footnote.weight(.semibold)) - .foregroundStyle(.tertiary) + LabeledContent(group.label) { + Text("\(group.members.count)") + } + } + } + } +} + +// MARK: - Settings-style form row + +/// Renders a single `Expose` as a native iOS Settings-style row: plain label +/// on the left, value or control on the right. No leading icons, no chevrons — +/// the row is whatever SwiftUI gives you in a `Form` / inset-grouped `List`. +/// Writable numerics tap into a detail screen with a slider. +private struct SettingsFormRow: View { + let expose: Expose + let state: [String: JSONValue] + let mode: CardDisplayMode + let onSend: (JSONValue) -> Void + + private var property: String { expose.property ?? expose.name ?? "" } + private var meta: FeatureMeta { FeatureCatalog.meta(for: property, exposeType: expose.type) } + private var label: String { meta.label } + private var stateValue: JSONValue? { state[property] } + + var body: some View { + switch expose.type { + case "binary": binaryRow + case "enum": enumRow + case "numeric": numericRow + default: textRow + } + } + + @ViewBuilder + private var binaryRow: some View { + let isOn = stateValue == expose.valueOn || stateValue?.boolValue == true + if mode == .interactive, expose.isWritable, + let on = expose.valueOn, let off = expose.valueOff { + Toggle(label, isOn: Binding( + get: { isOn }, + set: { v in onSend(.object([property: v ? on : off])) } + )) + } else { + LabeledContent(label) { Text(isOn ? "On" : "Off") } + } + } + + @ViewBuilder + private var enumRow: some View { + let values = expose.values ?? [] + let current = stateValue?.stringValue ?? "" + if mode == .interactive, expose.isWritable, !values.isEmpty { + Picker(label, selection: Binding( + get: { current }, + set: { onSend(.object([property: .string($0)])) } + )) { + ForEach(values, id: \.self) { v in + Text(prettify(v)).tag(v) + } + } + } else { + LabeledContent(label) { Text(prettify(current.isEmpty ? "—" : current)) } + } + } + + @ViewBuilder + private var numericRow: some View { + let current = stateValue?.numberValue ?? 0 + let unit = expose.unit ?? "" + let writable = mode == .interactive && expose.isWritable + && expose.valueMin != nil && expose.valueMax != nil + + if writable { + NavigationLink { + NumericDetailView(expose: expose, state: state, onSend: onSend) + } label: { + LabeledContent(label) { Text(format(current, unit: unit)) } + } + } else { + LabeledContent(label) { Text(format(current, unit: unit)) } + } + } + + @ViewBuilder + private var textRow: some View { + LabeledContent(label) { Text(stateValue?.stringified ?? "—") } + } + + private func prettify(_ s: String) -> String { + s.replacingOccurrences(of: "_", with: " ").capitalized + } + + private func format(_ v: Double, unit: String) -> String { + let s = v.formatted(.number.precision(.fractionLength(0...1))) + return unit.isEmpty ? s : "\(s) \(unit)" + } +} + +// MARK: - Numeric detail (slider) screen + +/// Pushed when tapping a writable numeric row. Hosts the slider in a Form +/// section with the current value, min, and max — same idiom as iOS +/// Settings → Display & Brightness → Text Size. +private struct NumericDetailView: View { + let expose: Expose + let state: [String: JSONValue] + let onSend: (JSONValue) -> Void + + @State private var draft: Double = 0 + + private var property: String { expose.property ?? expose.name ?? "" } + private var meta: FeatureMeta { FeatureCatalog.meta(for: property, exposeType: expose.type) } + private var label: String { meta.label } + private var unit: String { expose.unit ?? "" } + private var current: Double { state[property]?.numberValue ?? 0 } + + var body: some View { + Form { + if let min = expose.valueMin, let max = expose.valueMax { + Section { + LabeledContent("Value") { + Text(format(draft)) + .monospacedDigit() + } + Slider(value: $draft, in: min...max, step: expose.valueStep ?? 1) { editing in + guard !editing else { return } + onSend(.object([property: numericPayload(draft, step: expose.valueStep)])) + } + } footer: { + Text("\(format(min)) – \(format(max))") + } + } + } + .navigationTitle(label) + .navigationBarTitleDisplayMode(.inline) + .onAppear { draft = current } + .onChange(of: current) { _, v in draft = v } + } + + private func numericPayload(_ v: Double, step: Double?) -> JSONValue { + if let step, step.truncatingRemainder(dividingBy: 1) == 0 { + return .int(Int(v.rounded())) + } + return .double(v) + } + + private func format(_ v: Double) -> String { + let s = v.formatted(.number.precision(.fractionLength(0...1))) + return unit.isEmpty ? s : "\(s) \(unit)" + } +} + +// MARK: - Indexed group detail (pushed) + +private struct FeatureGroupDetailView: View { + let group: IndexedGroup + let context: FanControlContext + let mode: CardDisplayMode + let onSend: (JSONValue) -> Void + + var body: some View { + Form { + Section { + ForEach(group.members, id: \.property) { e in + SettingsFormRow(expose: e, state: context.state, mode: mode, onSend: onSend) } } - .buttonStyle(.plain) } + .navigationTitle(group.label) + .navigationBarTitleDisplayMode(.inline) } } From 5458c91086b9761dd05d66debf22e6f65f49b039 Mon Sep 17 00:00:00 2001 From: tashda Date: Tue, 28 Apr 2026 21:53:21 +0200 Subject: [PATCH 22/24] About: explicit "Connect" section header, plain button style on rows Makes the Rate / GitHub group a labelled section instead of an unlabelled one, and applies .buttonStyle(.plain) so the rows don't pick up the default tinted button styling. The GitHub row icon shifts to .darkGray so it reads consistently in dark mode. Co-Authored-By: Claude Opus 4.7 (1M context) --- Shellbee/Features/Settings/AboutView.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Shellbee/Features/Settings/AboutView.swift b/Shellbee/Features/Settings/AboutView.swift index 38cbb38..7843323 100644 --- a/Shellbee/Features/Settings/AboutView.swift +++ b/Shellbee/Features/Settings/AboutView.swift @@ -42,7 +42,7 @@ struct AboutView: View { } private var connectSection: some View { - Section { + Section("Connect") { externalLinkRow( title: "Rate Shellbee", systemImage: "star.fill", @@ -52,7 +52,7 @@ struct AboutView: View { externalLinkRow( title: "View on GitHub", systemImage: "chevron.left.forwardslash.chevron.right", - color: Color(.label), + color: Color(.darkGray), url: Self.githubURL ) } @@ -74,6 +74,7 @@ struct AboutView: View { .foregroundStyle(.tertiary) } } + .buttonStyle(.plain) } @ViewBuilder From 3e0ada2d82d75274610f0a08fe9088ba2311c95c Mon Sep 17 00:00:00 2001 From: tashda Date: Tue, 28 Apr 2026 22:44:44 +0200 Subject: [PATCH 23/24] About: extend tap target to full row width Wrap the row HStack in `.contentShape(Rectangle())` so taps on the empty space between the label and the chevron register, matching the hit-test behaviour of native iOS Settings rows. Co-Authored-By: Claude Opus 4.7 (1M context) --- Shellbee/Features/Settings/AboutView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Shellbee/Features/Settings/AboutView.swift b/Shellbee/Features/Settings/AboutView.swift index 7843323..e8fda72 100644 --- a/Shellbee/Features/Settings/AboutView.swift +++ b/Shellbee/Features/Settings/AboutView.swift @@ -73,6 +73,7 @@ struct AboutView: View { .font(.footnote.weight(.semibold)) .foregroundStyle(.tertiary) } + .contentShape(Rectangle()) } .buttonStyle(.plain) } From 368f93499e1122156ac9ba2395179cbb34d5a61c Mon Sep 17 00:00:00 2001 From: tashda Date: Tue, 28 Apr 2026 22:45:05 +0200 Subject: [PATCH 24/24] Device cards: native iOS Settings sections beneath every category MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hero cards stay exactly as they are; everything they don't bind to a primary control drops down as native iOS Settings `Section`s beneath, grouped via `FeatureLayout` into Behaviour / Indicators / Maintenance / Status / More sections — same taxonomy fans already use. Writable numerics render their slider inline within the same `List` row (label + value on top, slider beneath) instead of pushing a near-empty detail screen with just a slider. `NumericDetailView` is gone. `linkquality`, `battery`, `last_seen`, `update`, `update_available`, and any `identify*` prefix are always hidden — surfaced on the device card or noisy diagnostics, never in a settings list. Light: removes the sunrise (Startup) and ellipsis (More) sheet buttons from inside the card and renders them as native sections beneath. Effects (sparkles) stays in the card — true light-specific control. Startup section sorts Power-On Behavior first, then state / brightness / color-temp / hue+sat / execute_if_off. The Color Temperature row now uses the same swatch + tinted slider as the hero card. `DeviceDetailView` reorganised around a single `heroAndSettingsSections(for:state:)` builder. Shared primitives: `SettingsFormRow`, `DeviceExtras`, `DeviceFeatureSectionRow`, `FeatureGroupDetailView` extracted into `Shared/Components/` so every category dispatches identically. Fixes #31 Fixes #32 Co-Authored-By: Claude Opus 4.7 (1M context) --- Shellbee.xcodeproj/project.pbxproj | 8 + .../Features/Devices/DeviceDetailView.swift | 120 ++++++++++--- .../ClimateFeatureSections.swift | 52 ++++++ Shellbee/Shared/Components/DeviceExtras.swift | 49 +++++ .../Components/DeviceFeatureSectionRow.swift | 26 +++ .../Components/FeatureGroupDetailView.swift | 23 +++ .../Shared/Components/SettingsFormRow.swift | 112 ++++++++++++ .../CoverControl/CoverFeatureSections.swift | 43 +++++ .../Shared/FanControl/FanControlCard.swift | 168 +----------------- .../LightControl/LightAdvancedFeature.swift | 6 + .../LightAdvancedFeatureRow.swift | 27 ++- .../LightControl/LightControlCard.swift | 20 ++- .../LightControl/LightControlContext.swift | 21 ++- .../LightControl/LightFeatureSections.swift | 34 ++++ .../SwitchControl/SwitchFeatureSections.swift | 46 +++++ .../Devices/DeviceDetailUITests.swift | 80 +++++++++ 16 files changed, 630 insertions(+), 205 deletions(-) create mode 100644 Shellbee/Shared/ClimateControl/ClimateFeatureSections.swift create mode 100644 Shellbee/Shared/Components/DeviceExtras.swift create mode 100644 Shellbee/Shared/Components/DeviceFeatureSectionRow.swift create mode 100644 Shellbee/Shared/Components/FeatureGroupDetailView.swift create mode 100644 Shellbee/Shared/Components/SettingsFormRow.swift create mode 100644 Shellbee/Shared/CoverControl/CoverFeatureSections.swift create mode 100644 Shellbee/Shared/LightControl/LightFeatureSections.swift create mode 100644 Shellbee/Shared/SwitchControl/SwitchFeatureSections.swift diff --git a/Shellbee.xcodeproj/project.pbxproj b/Shellbee.xcodeproj/project.pbxproj index 0ee9b4d..ec7c43b 100644 --- a/Shellbee.xcodeproj/project.pbxproj +++ b/Shellbee.xcodeproj/project.pbxproj @@ -232,9 +232,12 @@ Shared/CardDisplayMode.swift, Shared/ClimateControl/ClimateControlCard.swift, Shared/ClimateControl/ClimateControlContext.swift, + Shared/ClimateControl/ClimateFeatureSections.swift, Shared/Components/BeautifulPayloadView.swift, Shared/Components/BeautifulRow.swift, Shared/Components/CopyableRow.swift, + Shared/Components/DeviceExtras.swift, + Shared/Components/DeviceFeatureSectionRow.swift, Shared/Components/DevicePickerRow.swift, Shared/Components/Doc/DefaultDocSectionView.swift, Shared/Components/Doc/DeviceInfoCardView.swift, @@ -249,11 +252,14 @@ Shared/Components/Doc/OptionsSectionView.swift, Shared/Components/Doc/PairingGuideExperienceView.swift, Shared/Components/Doc/PairingSectionView.swift, + Shared/Components/FeatureGroupDetailView.swift, Shared/Components/FilterChip.swift, Shared/Components/MemberAvatarStack.swift, Shared/Components/SafariBrowserView.swift, + Shared/Components/SettingsFormRow.swift, Shared/CoverControl/CoverControlCard.swift, Shared/CoverControl/CoverControlContext.swift, + Shared/CoverControl/CoverFeatureSections.swift, "Shared/DesignTokens+Groups.swift", Shared/ExposeCardView.swift, Shared/FanControl/FanControlCard.swift, @@ -268,6 +274,7 @@ Shared/LightControl/LightControlContext.swift, Shared/LightControl/LightDisplayColor.swift, Shared/LightControl/LightEffectsSheet.swift, + Shared/LightControl/LightFeatureSections.swift, Shared/LightControl/LightTemperatureControl.swift, Shared/LockControl/LockControlCard.swift, Shared/LockControl/LockControlContext.swift, @@ -275,6 +282,7 @@ Shared/SensorCard/SensorCard.swift, Shared/SwitchControl/SwitchControlCard.swift, Shared/SwitchControl/SwitchControlContext.swift, + Shared/SwitchControl/SwitchFeatureSections.swift, ShellbeeApp.swift, ); target = 0A9D45CD2F96232B00DF6DF5 /* ShellbeeWidgetsExtension */; diff --git a/Shellbee/Features/Devices/DeviceDetailView.swift b/Shellbee/Features/Devices/DeviceDetailView.swift index 3257f78..807051d 100644 --- a/Shellbee/Features/Devices/DeviceDetailView.swift +++ b/Shellbee/Features/Devices/DeviceDetailView.swift @@ -32,26 +32,7 @@ struct DeviceDetailView: View { .listRowBackground(Color.clear) .listRowSeparator(.hidden) - if device.category == .fan, let fanCtx = FanControlContext(device: device, state: state) { - Section { - FanControlCard(context: fanCtx, mode: .interactive, onSend: { payload in - environment.sendDeviceState(device.friendlyName, payload: payload) - }, rendersSectionsInline: false) - .listRowInsets(EdgeInsets()) - .listRowBackground(Color.clear) - } - FanFeatureSections(context: fanCtx, mode: .interactive) { payload in - environment.sendDeviceState(device.friendlyName, payload: payload) - } - } else { - Section { - ExposeCardView(device: device, state: state, mode: .interactive) { payload in - environment.sendDeviceState(device.friendlyName, payload: payload) - } - .listRowInsets(EdgeInsets()) - .listRowBackground(Color.clear) - } - } + heroAndSettingsSections(for: device, state: state) if device.definition != nil { Section("Documentation") { @@ -144,6 +125,105 @@ struct DeviceDetailView: View { } } + /// Renders the hero card(s) plus any "leftover" exposes as native iOS + /// Settings-style sections beneath. The cards stay exactly as they are; + /// the sections handle configuration / advanced features that don't fit + /// in the hero (LED, child lock, power-on behaviour, calibration, etc.). + @ViewBuilder + private func heroAndSettingsSections(for device: Device, state: [String: JSONValue]) -> some View { + let send: (JSONValue) -> Void = { payload in + environment.sendDeviceState(device.friendlyName, payload: payload) + } + + switch device.category { + case .fan: + if let ctx = FanControlContext(device: device, state: state) { + Section { + FanControlCard(context: ctx, mode: .interactive, onSend: send, rendersSectionsInline: false) + .listRowInsets(EdgeInsets()) + .listRowBackground(Color.clear) + } + FanFeatureSections(context: ctx, mode: .interactive, onSend: send) + } else { + genericExposeSection(device: device, state: state, send: send) + } + + case .light: + let contexts = LightControlContext.contexts(for: device, state: state) + if !contexts.isEmpty { + Section { + VStack(spacing: DesignTokens.Spacing.lg) { + ForEach(contexts) { ctx in + LightControlCard(context: ctx, mode: .interactive, onSend: send, + rendersAdvancedSheetsInline: false) + } + } + .listRowInsets(EdgeInsets()) + .listRowBackground(Color.clear) + } + ForEach(contexts) { ctx in + LightFeatureSections(context: ctx, onSend: send) + } + } else { + genericExposeSection(device: device, state: state, send: send) + } + + case .switchPlug: + let contexts = SwitchControlContext.contexts(for: device, state: state) + if !contexts.isEmpty { + Section { + ExposeCardView(device: device, state: state, mode: .interactive, onSend: send) + .listRowInsets(EdgeInsets()) + .listRowBackground(Color.clear) + } + if let ctx = contexts.first { + SwitchFeatureSections(device: device, context: ctx, state: state, onSend: send) + } + } else { + genericExposeSection(device: device, state: state, send: send) + } + + case .climate: + if let ctx = ClimateControlContext(device: device, state: state) { + Section { + ClimateControlCard(context: ctx, mode: .interactive, onSend: send) + .listRowInsets(EdgeInsets()) + .listRowBackground(Color.clear) + } + ClimateFeatureSections(device: device, context: ctx, state: state, onSend: send) + } else { + genericExposeSection(device: device, state: state, send: send) + } + + case .cover: + let contexts = CoverControlContext.contexts(for: device, state: state) + if !contexts.isEmpty { + Section { + ExposeCardView(device: device, state: state, mode: .interactive, onSend: send) + .listRowInsets(EdgeInsets()) + .listRowBackground(Color.clear) + } + if let ctx = contexts.first { + CoverFeatureSections(device: device, context: ctx, state: state, onSend: send) + } + } else { + genericExposeSection(device: device, state: state, send: send) + } + + default: + genericExposeSection(device: device, state: state, send: send) + } + } + + @ViewBuilder + private func genericExposeSection(device: Device, state: [String: JSONValue], send: @escaping (JSONValue) -> Void) -> some View { + Section { + ExposeCardView(device: device, state: state, mode: .interactive, onSend: send) + .listRowInsets(EdgeInsets()) + .listRowBackground(Color.clear) + } + } + private static let recentLogLimit = 5 @ViewBuilder diff --git a/Shellbee/Shared/ClimateControl/ClimateFeatureSections.swift b/Shellbee/Shared/ClimateControl/ClimateFeatureSections.swift new file mode 100644 index 0000000..3a21c6e --- /dev/null +++ b/Shellbee/Shared/ClimateControl/ClimateFeatureSections.swift @@ -0,0 +1,52 @@ +import SwiftUI + +/// Renders a thermostat's "leftover" exposes (eco mode, schedule, valve +/// position, calibration, etc.) as native iOS Settings sections beneath the +/// hero `ClimateControlCard`. Fan mode and preset are surfaced here too — +/// the card itself doesn't bind to them today but they're meaningful +/// configuration the user expects to control. Sections are grouped by +/// `FeatureLayout` (Behaviour / Indicators / Maintenance / etc.). +struct ClimateFeatureSections: View { + let device: Device + let context: ClimateControlContext + let state: [String: JSONValue] + let onSend: (JSONValue) -> Void + + private var primaryProps: Set { + var props: Set = [] + if let p = context.temperatureFeature?.property { props.insert(p) } + if let p = context.heatingSetpointFeature?.property { props.insert(p) } + if let p = context.coolingSetpointFeature?.property { props.insert(p) } + if let p = context.systemModeFeature?.property { props.insert(p) } + if let p = context.runningStateFeature?.property { props.insert(p) } + return props + } + + private var extras: [Expose] { + let exposes = device.definition?.exposes ?? [] + let climateBlock = exposes.first(where: { $0.type == "climate" }) + let climateInternal = climateBlock?.features?.flattenedLeaves ?? [] + var internalProps = Set(climateInternal.compactMap { $0.property }) + // Surface fan_mode + preset under Configuration even though they live + // inside the climate composite — they're meaningful user settings. + if let p = context.fanModeFeature?.property { internalProps.remove(p) } + if let p = context.presetFeature?.property { internalProps.remove(p) } + return DeviceExtras.eligibleLeaves( + from: exposes, + primaryProps: primaryProps, + extraExcludedProps: internalProps + ) + } + + private var sections: [LayoutSection] { FeatureLayout.sections(from: extras) } + + var body: some View { + ForEach(sections) { section in + Section(section.title) { + ForEach(section.items, id: \.id) { item in + DeviceFeatureSectionRow(item: item, state: state, mode: .interactive, onSend: onSend) + } + } + } + } +} diff --git a/Shellbee/Shared/Components/DeviceExtras.swift b/Shellbee/Shared/Components/DeviceExtras.swift new file mode 100644 index 0000000..48b5aa8 --- /dev/null +++ b/Shellbee/Shared/Components/DeviceExtras.swift @@ -0,0 +1,49 @@ +import Foundation + +/// Helpers for selecting the "leftover" leaf exposes that a category card does +/// not bind to a primary control. These get surfaced in +/// `…FeatureSections` views rendered as native iOS Settings sections beneath +/// the hero card. +enum DeviceExtras { + /// Properties that must never appear in any feature section. These are + /// either surfaced elsewhere (battery + linkquality on the device card) + /// or noisy diagnostics that don't belong in a settings list. + static let alwaysHiddenProperties: Set = [ + "linkquality", + "battery", + "last_seen", + "update", + "update_available" + ] + + /// Property prefixes we always hide. `identify` is a Zigbee diagnostic + /// trigger that should never surface as a user-facing setting. + static let alwaysHiddenPrefixes: [String] = ["identify"] + + /// Returns the leaf exposes that are eligible for a `…FeatureSections` + /// rendering, after subtracting: + /// - properties already bound to primary controls in the card + /// - the `alwaysHiddenProperties` list + /// - any property starting with `alwaysHiddenPrefixes` + /// - composites with sub-features (those need bundled-payload writes that + /// the leaf renderer can't do; surface them via dedicated sheets) + /// - non-renderable types (anything that isn't binary/enum/numeric/text) + static func eligibleLeaves( + from exposes: [Expose], + primaryProps: Set, + extraExcludedProps: Set = [] + ) -> [Expose] { + exposes.filter { e in + guard let prop = e.property, !prop.isEmpty else { return false } + if primaryProps.contains(prop) { return false } + if extraExcludedProps.contains(prop) { return false } + if alwaysHiddenProperties.contains(prop) { return false } + if alwaysHiddenPrefixes.contains(where: { prop.hasPrefix($0) }) { return false } + if let f = e.features, !f.isEmpty { return false } + switch e.type { + case "binary", "enum", "numeric", "text": return true + default: return false + } + } + } +} diff --git a/Shellbee/Shared/Components/DeviceFeatureSectionRow.swift b/Shellbee/Shared/Components/DeviceFeatureSectionRow.swift new file mode 100644 index 0000000..1adb09b --- /dev/null +++ b/Shellbee/Shared/Components/DeviceFeatureSectionRow.swift @@ -0,0 +1,26 @@ +import SwiftUI + +/// Maps a `LayoutItem` (single row or indexed group) to a native row in any +/// `…FeatureSections` view. Leaf rows render as `SettingsFormRow`; indexed +/// groups push to `FeatureGroupDetailView`. +struct DeviceFeatureSectionRow: View { + let item: LayoutItem + let state: [String: JSONValue] + let mode: CardDisplayMode + let onSend: (JSONValue) -> Void + + var body: some View { + switch item { + case .row(let expose): + SettingsFormRow(expose: expose, state: state, mode: mode, onSend: onSend) + case .indexedGroup(let group): + NavigationLink { + FeatureGroupDetailView(group: group, state: state, mode: mode, onSend: onSend) + } label: { + LabeledContent(group.label) { + Text("\(group.members.count)") + } + } + } + } +} diff --git a/Shellbee/Shared/Components/FeatureGroupDetailView.swift b/Shellbee/Shared/Components/FeatureGroupDetailView.swift new file mode 100644 index 0000000..40070bd --- /dev/null +++ b/Shellbee/Shared/Components/FeatureGroupDetailView.swift @@ -0,0 +1,23 @@ +import SwiftUI + +/// Pushed when a `LayoutItem.indexedGroup` row is tapped in any +/// `…FeatureSections` view. Renders the group's members as native rows so the +/// surface looks identical to the parent settings list. +struct FeatureGroupDetailView: View { + let group: IndexedGroup + let state: [String: JSONValue] + let mode: CardDisplayMode + let onSend: (JSONValue) -> Void + + var body: some View { + Form { + Section { + ForEach(group.members, id: \.property) { e in + SettingsFormRow(expose: e, state: state, mode: mode, onSend: onSend) + } + } + } + .navigationTitle(group.label) + .navigationBarTitleDisplayMode(.inline) + } +} diff --git a/Shellbee/Shared/Components/SettingsFormRow.swift b/Shellbee/Shared/Components/SettingsFormRow.swift new file mode 100644 index 0000000..f83eae5 --- /dev/null +++ b/Shellbee/Shared/Components/SettingsFormRow.swift @@ -0,0 +1,112 @@ +import SwiftUI + +/// Renders a single `Expose` as a native iOS Settings-style row inside a +/// grouped `Form` / inset-grouped `List` section. Plain label on the left, +/// value or control on the right. Writable numerics show their slider +/// **inline** (label + value on top, slider beneath) — never push to a +/// separate detail screen. +/// +/// Used by `FanFeatureSections`, `SwitchFeatureSections`, +/// `ClimateFeatureSections`, `CoverFeatureSections` to surface "leftover" +/// exposes that the category card itself does not bind to a primary control. +struct SettingsFormRow: View { + let expose: Expose + let state: [String: JSONValue] + let mode: CardDisplayMode + let onSend: (JSONValue) -> Void + + @State private var numericDraft: Double = 0 + + private var property: String { expose.property ?? expose.name ?? "" } + private var meta: FeatureMeta { FeatureCatalog.meta(for: property, exposeType: expose.type) } + private var label: String { meta.label } + private var stateValue: JSONValue? { state[property] } + + var body: some View { + switch expose.type { + case "binary": binaryRow + case "enum": enumRow + case "numeric": numericRow + default: textRow + } + } + + @ViewBuilder + private var binaryRow: some View { + let isOn = stateValue == expose.valueOn || stateValue?.boolValue == true + if mode == .interactive, expose.isWritable, + let on = expose.valueOn, let off = expose.valueOff { + Toggle(label, isOn: Binding( + get: { isOn }, + set: { v in onSend(.object([property: v ? on : off])) } + )) + } else { + LabeledContent(label) { Text(isOn ? "On" : "Off") } + } + } + + @ViewBuilder + private var enumRow: some View { + let values = expose.values ?? [] + let current = stateValue?.stringValue ?? "" + if mode == .interactive, expose.isWritable, !values.isEmpty { + Picker(label, selection: Binding( + get: { current }, + set: { onSend(.object([property: .string($0)])) } + )) { + ForEach(values, id: \.self) { v in + Text(prettify(v)).tag(v) + } + } + } else { + LabeledContent(label) { Text(prettify(current.isEmpty ? "—" : current)) } + } + } + + @ViewBuilder + private var numericRow: some View { + let current = stateValue?.numberValue ?? 0 + let unit = expose.unit ?? "" + let writable = mode == .interactive && expose.isWritable + && expose.valueMin != nil && expose.valueMax != nil + + if writable, let min = expose.valueMin, let max = expose.valueMax { + VStack(alignment: .leading, spacing: DesignTokens.Spacing.sm) { + LabeledContent(label) { + Text(format(numericDraft, unit: unit)) + .monospacedDigit() + } + Slider(value: $numericDraft, in: min...max, step: expose.valueStep ?? 1) { editing in + guard !editing else { return } + onSend(.object([property: numericPayload(numericDraft, step: expose.valueStep)])) + } + } + .accessibilityElement(children: .contain) + .onAppear { numericDraft = current } + .onChange(of: current) { _, v in numericDraft = v } + } else { + LabeledContent(label) { Text(format(current, unit: unit)) } + } + } + + @ViewBuilder + private var textRow: some View { + LabeledContent(label) { Text(stateValue?.stringified ?? "—") } + } + + private func numericPayload(_ v: Double, step: Double?) -> JSONValue { + if let step, step.truncatingRemainder(dividingBy: 1) == 0 { + return .int(Int(v.rounded())) + } + return .double(v) + } + + private func prettify(_ s: String) -> String { + s.replacingOccurrences(of: "_", with: " ").capitalized + } + + private func format(_ v: Double, unit: String) -> String { + let s = v.formatted(.number.precision(.fractionLength(0...1))) + return unit.isEmpty ? s : "\(s) \(unit)" + } +} diff --git a/Shellbee/Shared/CoverControl/CoverFeatureSections.swift b/Shellbee/Shared/CoverControl/CoverFeatureSections.swift new file mode 100644 index 0000000..7569b44 --- /dev/null +++ b/Shellbee/Shared/CoverControl/CoverFeatureSections.swift @@ -0,0 +1,43 @@ +import SwiftUI + +/// Renders a cover's "leftover" exposes (calibration, motor speed, child +/// lock, etc.) as native iOS Settings sections beneath the hero +/// `CoverControlCard`. Sections are grouped by `FeatureLayout` so behaviour / +/// indicators / maintenance each get their own header. +struct CoverFeatureSections: View { + let device: Device + let context: CoverControlContext + let state: [String: JSONValue] + let onSend: (JSONValue) -> Void + + private var primaryProps: Set { + var props: Set = [] + if let p = context.stateFeature?.property { props.insert(p) } + if let p = context.positionFeature?.property { props.insert(p) } + if let p = context.tiltFeature?.property { props.insert(p) } + return props + } + + private var extras: [Expose] { + let exposes = device.definition?.exposes ?? [] + let coverInternal = exposes.first(where: { $0.type == "cover" })?.features?.flattenedLeaves ?? [] + let internalProps = Set(coverInternal.compactMap { $0.property }) + return DeviceExtras.eligibleLeaves( + from: exposes, + primaryProps: primaryProps, + extraExcludedProps: internalProps + ) + } + + private var sections: [LayoutSection] { FeatureLayout.sections(from: extras) } + + var body: some View { + ForEach(sections) { section in + Section(section.title) { + ForEach(section.items, id: \.id) { item in + DeviceFeatureSectionRow(item: item, state: state, mode: .interactive, onSend: onSend) + } + } + } + } +} diff --git a/Shellbee/Shared/FanControl/FanControlCard.swift b/Shellbee/Shared/FanControl/FanControlCard.swift index 3fdd2f2..d171e21 100644 --- a/Shellbee/Shared/FanControl/FanControlCard.swift +++ b/Shellbee/Shared/FanControl/FanControlCard.swift @@ -517,7 +517,7 @@ struct FanFeatureSections: View { SettingsFormRow(expose: expose, state: context.state, mode: mode, onSend: onSend) case .indexedGroup(let group): NavigationLink { - FeatureGroupDetailView(group: group, context: context, mode: mode, onSend: onSend) + FeatureGroupDetailView(group: group, state: context.state, mode: mode, onSend: onSend) } label: { LabeledContent(group.label) { Text("\(group.members.count)") @@ -527,172 +527,6 @@ struct FanFeatureSections: View { } } -// MARK: - Settings-style form row - -/// Renders a single `Expose` as a native iOS Settings-style row: plain label -/// on the left, value or control on the right. No leading icons, no chevrons — -/// the row is whatever SwiftUI gives you in a `Form` / inset-grouped `List`. -/// Writable numerics tap into a detail screen with a slider. -private struct SettingsFormRow: View { - let expose: Expose - let state: [String: JSONValue] - let mode: CardDisplayMode - let onSend: (JSONValue) -> Void - - private var property: String { expose.property ?? expose.name ?? "" } - private var meta: FeatureMeta { FeatureCatalog.meta(for: property, exposeType: expose.type) } - private var label: String { meta.label } - private var stateValue: JSONValue? { state[property] } - - var body: some View { - switch expose.type { - case "binary": binaryRow - case "enum": enumRow - case "numeric": numericRow - default: textRow - } - } - - @ViewBuilder - private var binaryRow: some View { - let isOn = stateValue == expose.valueOn || stateValue?.boolValue == true - if mode == .interactive, expose.isWritable, - let on = expose.valueOn, let off = expose.valueOff { - Toggle(label, isOn: Binding( - get: { isOn }, - set: { v in onSend(.object([property: v ? on : off])) } - )) - } else { - LabeledContent(label) { Text(isOn ? "On" : "Off") } - } - } - - @ViewBuilder - private var enumRow: some View { - let values = expose.values ?? [] - let current = stateValue?.stringValue ?? "" - if mode == .interactive, expose.isWritable, !values.isEmpty { - Picker(label, selection: Binding( - get: { current }, - set: { onSend(.object([property: .string($0)])) } - )) { - ForEach(values, id: \.self) { v in - Text(prettify(v)).tag(v) - } - } - } else { - LabeledContent(label) { Text(prettify(current.isEmpty ? "—" : current)) } - } - } - - @ViewBuilder - private var numericRow: some View { - let current = stateValue?.numberValue ?? 0 - let unit = expose.unit ?? "" - let writable = mode == .interactive && expose.isWritable - && expose.valueMin != nil && expose.valueMax != nil - - if writable { - NavigationLink { - NumericDetailView(expose: expose, state: state, onSend: onSend) - } label: { - LabeledContent(label) { Text(format(current, unit: unit)) } - } - } else { - LabeledContent(label) { Text(format(current, unit: unit)) } - } - } - - @ViewBuilder - private var textRow: some View { - LabeledContent(label) { Text(stateValue?.stringified ?? "—") } - } - - private func prettify(_ s: String) -> String { - s.replacingOccurrences(of: "_", with: " ").capitalized - } - - private func format(_ v: Double, unit: String) -> String { - let s = v.formatted(.number.precision(.fractionLength(0...1))) - return unit.isEmpty ? s : "\(s) \(unit)" - } -} - -// MARK: - Numeric detail (slider) screen - -/// Pushed when tapping a writable numeric row. Hosts the slider in a Form -/// section with the current value, min, and max — same idiom as iOS -/// Settings → Display & Brightness → Text Size. -private struct NumericDetailView: View { - let expose: Expose - let state: [String: JSONValue] - let onSend: (JSONValue) -> Void - - @State private var draft: Double = 0 - - private var property: String { expose.property ?? expose.name ?? "" } - private var meta: FeatureMeta { FeatureCatalog.meta(for: property, exposeType: expose.type) } - private var label: String { meta.label } - private var unit: String { expose.unit ?? "" } - private var current: Double { state[property]?.numberValue ?? 0 } - - var body: some View { - Form { - if let min = expose.valueMin, let max = expose.valueMax { - Section { - LabeledContent("Value") { - Text(format(draft)) - .monospacedDigit() - } - Slider(value: $draft, in: min...max, step: expose.valueStep ?? 1) { editing in - guard !editing else { return } - onSend(.object([property: numericPayload(draft, step: expose.valueStep)])) - } - } footer: { - Text("\(format(min)) – \(format(max))") - } - } - } - .navigationTitle(label) - .navigationBarTitleDisplayMode(.inline) - .onAppear { draft = current } - .onChange(of: current) { _, v in draft = v } - } - - private func numericPayload(_ v: Double, step: Double?) -> JSONValue { - if let step, step.truncatingRemainder(dividingBy: 1) == 0 { - return .int(Int(v.rounded())) - } - return .double(v) - } - - private func format(_ v: Double) -> String { - let s = v.formatted(.number.precision(.fractionLength(0...1))) - return unit.isEmpty ? s : "\(s) \(unit)" - } -} - -// MARK: - Indexed group detail (pushed) - -private struct FeatureGroupDetailView: View { - let group: IndexedGroup - let context: FanControlContext - let mode: CardDisplayMode - let onSend: (JSONValue) -> Void - - var body: some View { - Form { - Section { - ForEach(group.members, id: \.property) { e in - SettingsFormRow(expose: e, state: context.state, mode: mode, onSend: onSend) - } - } - } - .navigationTitle(group.label) - .navigationBarTitleDisplayMode(.inline) - } -} - // MARK: - Disclosure row (monochrome, local to fan card) private struct DisclosureRow: View { diff --git a/Shellbee/Shared/LightControl/LightAdvancedFeature.swift b/Shellbee/Shared/LightControl/LightAdvancedFeature.swift index 8bdbb79..5b0dc02 100644 --- a/Shellbee/Shared/LightControl/LightAdvancedFeature.swift +++ b/Shellbee/Shared/LightControl/LightAdvancedFeature.swift @@ -30,6 +30,12 @@ struct LightAdvancedFeature: Equatable, Identifiable { return "Color Temperature" case "current_level_startup": return "Startup Brightness" + case "power_on_behavior": + return "Power-On Behavior" + case "color_power_on_behavior": + return "Color Power-On Behavior" + case "state_startup": + return "Startup State" default: let stripped = last.hasSuffix("_startup") ? String(last.dropLast("_startup".count)) : last return stripped.replacingOccurrences(of: "_", with: " ").capitalized diff --git a/Shellbee/Shared/LightControl/LightAdvancedFeatureRow.swift b/Shellbee/Shared/LightControl/LightAdvancedFeatureRow.swift index e54f70d..fbfa3f6 100644 --- a/Shellbee/Shared/LightControl/LightAdvancedFeatureRow.swift +++ b/Shellbee/Shared/LightControl/LightAdvancedFeatureRow.swift @@ -46,23 +46,22 @@ struct LightAdvancedFeatureRow: View { } } + /// Reuses the same swatch + slider control as the hero light card so the + /// "Color Temperature" startup row reads identically to the live control + /// the user just adjusted in the card. private func temperatureRow(range: ClosedRange?) -> some View { VStack(alignment: .leading, spacing: DesignTokens.Spacing.sm) { - HStack { - Text(feature.displayLabel) - Spacer() - Text("\(Int((1_000_000 / max(numericDraftValue, 1)).rounded()).formatted(.number.grouping(.never)))K") - .foregroundStyle(.secondary) - .monospacedDigit() - } - + Text(feature.displayLabel) if let range { - let kelvinRange = (1_000_000 / range.upperBound)...(1_000_000 / max(range.lowerBound, 1)) - Slider(value: kelvinBinding, in: kelvinRange) { editing in - guard !editing else { return } - onChange(.double(numericDraftValue)) - } - .tint(LightDisplayColor.temperatureColor(mireds: numericDraftValue)) + LightTemperatureControl( + range: range, + value: numericDraftValue, + isInteractive: true, + onChange: { mireds in + numericDraftValue = mireds + onChange(.double(mireds)) + } + ) .onChange(of: feature.value?.numberValue ?? 0) { _, newValue in numericDraftValue = newValue } diff --git a/Shellbee/Shared/LightControl/LightControlCard.swift b/Shellbee/Shared/LightControl/LightControlCard.swift index 9cbceea..6823f59 100644 --- a/Shellbee/Shared/LightControl/LightControlCard.swift +++ b/Shellbee/Shared/LightControl/LightControlCard.swift @@ -10,16 +10,28 @@ struct LightControlCard: View { let context: LightControlContext let mode: CardDisplayMode let onSend: (JSONValue) -> Void + /// When `true` (the default for snapshot contexts like LogDetailView), + /// Startup + Other-advanced configuration is reachable via sheet buttons + /// inside the card. When `false`, those buttons are suppressed because + /// the surrounding screen is rendering them as native iOS Settings + /// sections beneath the card via `LightFeatureSections`. Effects stays + /// inside the card either way — it's a light-specific control, not + /// configuration. + var rendersAdvancedSheetsInline: Bool = true @State private var selectedSurface: Surface @State private var showEffects = false @State private var showStartup = false @State private var showMore = false - init(context: LightControlContext, mode: CardDisplayMode, onSend: @escaping (JSONValue) -> Void = { _ in }) { + init(context: LightControlContext, + mode: CardDisplayMode, + onSend: @escaping (JSONValue) -> Void = { _ in }, + rendersAdvancedSheetsInline: Bool = true) { self.context = context self.mode = mode self.onSend = onSend + self.rendersAdvancedSheetsInline = rendersAdvancedSheetsInline _selectedSurface = State(initialValue: Self.initialSurface(for: context)) } @@ -95,8 +107,10 @@ struct LightControlCard: View { .foregroundStyle(headerTint) Spacer() if context.effectFeature != nil { configButton("sparkles") { showEffects = true } } - if !context.startupFeatures.isEmpty { configButton("sunrise.fill") { showStartup = true } } - if !context.otherAdvancedFeatures.isEmpty { configButton("ellipsis") { showMore = true } } + if rendersAdvancedSheetsInline { + if !context.startupFeatures.isEmpty { configButton("sunrise.fill") { showStartup = true } } + if !context.otherAdvancedFeatures.isEmpty { configButton("ellipsis") { showMore = true } } + } } if let brightness = context.brightness { LightBrightnessArea( diff --git a/Shellbee/Shared/LightControl/LightControlContext.swift b/Shellbee/Shared/LightControl/LightControlContext.swift index 52053c6..5011aa6 100644 --- a/Shellbee/Shared/LightControl/LightControlContext.swift +++ b/Shellbee/Shared/LightControl/LightControlContext.swift @@ -31,7 +31,26 @@ struct LightControlContext: Equatable, Identifiable { } var startupFeatures: [LightAdvancedFeature] { - advancedFeatures.filter { $0.category == .startup } + let filtered = advancedFeatures.filter { $0.category == .startup } + return filtered.sorted { Self.startupSortKey($0) < Self.startupSortKey($1) } + } + + /// Lower keys sort first. Power-On Behavior is the headline setting and + /// must lead; the per-attribute startup defaults follow in a natural + /// "what does it do, then what does it look like" order. + private static func startupSortKey(_ feature: LightAdvancedFeature) -> Int { + guard let last = feature.payloadPath.last else { return 99 } + switch last { + case "power_on_behavior": return 0 + case "color_power_on_behavior": return 1 + case "state_startup": return 2 + case "current_level_startup": return 3 + case "color_temp_startup": return 4 + case "hue_startup", "saturation_startup": return 5 + case "execute_if_off": return 9 + default: + return last.hasSuffix("_startup") ? 6 : 8 + } } var otherAdvancedFeatures: [LightAdvancedFeature] { diff --git a/Shellbee/Shared/LightControl/LightFeatureSections.swift b/Shellbee/Shared/LightControl/LightFeatureSections.swift new file mode 100644 index 0000000..e3e4ab4 --- /dev/null +++ b/Shellbee/Shared/LightControl/LightFeatureSections.swift @@ -0,0 +1,34 @@ +import SwiftUI + +/// Renders a light's "leftover" advanced features as native iOS Settings-style +/// `List` sections beneath the hero `LightControlCard`. Effects stay inside +/// the card (true light-specific control); Startup, Power-on, and other +/// advanced configuration drop down here so they look like native iOS +/// Settings. +/// +/// Place inside a `List` whose `.listStyle` is grouped or inset-grouped. +struct LightFeatureSections: View { + let context: LightControlContext + let onSend: (JSONValue) -> Void + + var body: some View { + if !context.startupFeatures.isEmpty { + Section("Startup") { + ForEach(context.startupFeatures) { feature in + LightAdvancedFeatureRow(feature: feature) { value in + onSend(feature.payload(value)) + } + } + } + } + if !context.otherAdvancedFeatures.isEmpty { + Section("Configuration") { + ForEach(context.otherAdvancedFeatures) { feature in + LightAdvancedFeatureRow(feature: feature) { value in + onSend(feature.payload(value)) + } + } + } + } + } +} diff --git a/Shellbee/Shared/SwitchControl/SwitchFeatureSections.swift b/Shellbee/Shared/SwitchControl/SwitchFeatureSections.swift new file mode 100644 index 0000000..beec50c --- /dev/null +++ b/Shellbee/Shared/SwitchControl/SwitchFeatureSections.swift @@ -0,0 +1,46 @@ +import SwiftUI + +/// Renders a switch's "leftover" exposes (power-on behaviour, child lock, +/// indicator config, timers, etc.) as native iOS Settings sections beneath +/// the hero `SwitchControlCard`. Sections are grouped by `FeatureLayout` so +/// behaviour / indicators / maintenance / etc. each get their own header, +/// matching the fan pattern. +struct SwitchFeatureSections: View { + let device: Device + let context: SwitchControlContext + let state: [String: JSONValue] + let onSend: (JSONValue) -> Void + + private var primaryProps: Set { + var props: Set = [] + if let p = context.stateFeature?.property { props.insert(p) } + if let p = context.powerFeature?.property { props.insert(p) } + if let p = context.energyFeature?.property { props.insert(p) } + if let p = context.voltageFeature?.property { props.insert(p) } + if let p = context.currentFeature?.property { props.insert(p) } + return props + } + + private var extras: [Expose] { + let exposes = device.definition?.exposes ?? [] + let switchInternal = exposes.first(where: { $0.type == "switch" })?.features?.flattenedLeaves ?? [] + let internalProps = Set(switchInternal.compactMap { $0.property }) + return DeviceExtras.eligibleLeaves( + from: exposes, + primaryProps: primaryProps, + extraExcludedProps: internalProps + ) + } + + private var sections: [LayoutSection] { FeatureLayout.sections(from: extras) } + + var body: some View { + ForEach(sections) { section in + Section(section.title) { + ForEach(section.items, id: \.id) { item in + DeviceFeatureSectionRow(item: item, state: state, mode: .interactive, onSend: onSend) + } + } + } + } +} diff --git a/ShellbeeUITests/Devices/DeviceDetailUITests.swift b/ShellbeeUITests/Devices/DeviceDetailUITests.swift index 3e3bbc7..98372f1 100644 --- a/ShellbeeUITests/Devices/DeviceDetailUITests.swift +++ b/ShellbeeUITests/Devices/DeviceDetailUITests.swift @@ -135,6 +135,86 @@ final class DeviceDetailUITests: ShellbeeUITestCase { XCTAssertTrue(app.navigationBars["Bathroom Fan"].waitForExistence(timeout: 5)) } + /// Light hero card surfaces brightness + Effects (sparkles button); Startup + /// / Other-advanced features used to live behind sunrise / ellipsis sheet + /// buttons inside the card. They now drop down as native iOS Settings + /// sections beneath the card. The Effects button stays — it's a true + /// light-specific control, not configuration. + func testLightAdvancedFeaturesRenderAsSettingsSections() { + openDetail(named: "Bedroom Hue") + XCTAssertTrue(app.navigationBars["Bedroom Hue"].waitForExistence(timeout: 5)) + + // Effects button (sparkles) must still exist in the card. + // Sunrise (Startup) and Ellipsis (More) buttons must NOT be there. + // We verify by swiping down to the section area instead — the + // presence of section headers like "Configuration" or rows with + // typical advanced-feature labels signals the new layout. + app.swipeUp() + let configHeader = app.staticTexts.matching( + NSPredicate(format: "label CONTAINS[c] 'Configuration' OR label CONTAINS[c] 'Startup'") + ).firstMatch + XCTAssertTrue( + configHeader.waitForExistence(timeout: 3), + "Light should expose advanced features in a native section beneath the card" + ) + } + + /// `linkquality` and `identify` must never appear in any feature section — + /// they're either surfaced on the device card (linkquality) or are + /// noisy diagnostics (identify). Enforced for every device category. + func testFeatureSectionsHideLinkqualityAndIdentify() { + for name in ["Bedroom Hue", "Bathroom Fan", "Office Inovelli Fan Switch", "Bedroom Curtain"] { + openDetail(named: name) + _ = app.navigationBars[name].waitForExistence(timeout: 5) + app.swipeUp() + app.swipeUp() + XCTAssertFalse( + app.staticTexts["Linkquality"].exists, + "\(name) must not surface 'Linkquality' as a settings row" + ) + XCTAssertFalse( + app.staticTexts["Identify"].exists, + "\(name) must not surface 'Identify' as a settings row" + ) + // Back out for the next iteration. + let backBtn = app.navigationBars.buttons.element(boundBy: 0) + if backBtn.exists { backBtn.tap() } + _ = app.cells.firstMatch.waitForExistence(timeout: 5) + } + } + + /// Writable numeric settings under the fan card render their slider inline + /// — never push to a separate detail screen. Attic Tuya Fan exposes both a + /// hero `speed` slider and a `countdown_hours` writable numeric in its + /// settings sections, so a correctly-rendered detail screen exposes more + /// than one slider without any navigation push. + func testFanWritableNumericRendersInline() { + openDetail(named: "Attic Tuya Fan") + XCTAssertTrue(app.navigationBars["Attic Tuya Fan"].waitForExistence(timeout: 5)) + + let detailNav = app.navigationBars["Attic Tuya Fan"] + XCTAssertTrue(detailNav.waitForExistence(timeout: 5)) + + // Scroll down so the Behaviour section (countdown_hours lives there) + // is rendered and its slider is hit-testable. + app.swipeUp() + app.swipeUp() + + let sliderCount = app.sliders.count + XCTAssertGreaterThan( + sliderCount, 1, + "Expected hero speed slider plus an inline slider for countdown_hours; got \(sliderCount)" + ) + + // No new navigation page (e.g. a dedicated "Countdown Hours" detail) + // should be on screen — the original device detail nav bar must still + // be the active one. + XCTAssertFalse( + app.navigationBars["Countdown Hours"].exists, + "Writable numeric must not push a dedicated detail screen" + ) + } + // MARK: - Remote — "TRADFRI Remote" func testRemoteDetailOpens() {