diff --git a/Shellbee.xcodeproj/project.pbxproj b/Shellbee.xcodeproj/project.pbxproj index 6771385..ec7c43b 100644 --- a/Shellbee.xcodeproj/project.pbxproj +++ b/Shellbee.xcodeproj/project.pbxproj @@ -200,9 +200,16 @@ 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, + Features/Settings/Backup/BackupPayload.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, Features/Settings/DeviceStatisticsView.swift, Features/Settings/DocBrowserDetailView.swift, Features/Settings/DocBrowserView.swift, @@ -225,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, @@ -242,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, @@ -261,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, @@ -268,6 +282,7 @@ Shared/SensorCard/SensorCard.swift, Shared/SwitchControl/SwitchControlCard.swift, Shared/SwitchControl/SwitchControlContext.swift, + Shared/SwitchControl/SwitchFeatureSections.swift, ShellbeeApp.swift, ); target = 0A9D45CD2F96232B00DF6DF5 /* ShellbeeWidgetsExtension */; @@ -794,7 +809,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 +850,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 +890,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 +932,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/App/ConnectionSessionController.swift b/Shellbee/App/ConnectionSessionController.swift index a19d8ab..eb9733b 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() @@ -33,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 @@ -102,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) @@ -212,6 +225,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/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/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/Core/Networking/Z2MTopics.swift b/Shellbee/Core/Networking/Z2MTopics.swift index cef1962..e9dedfe 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" @@ -18,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" @@ -29,6 +32,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/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 diff --git a/Shellbee/Core/Store/AppStore.swift b/Shellbee/Core/Store/AppStore.swift index 41bb8f6..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 @@ -533,6 +549,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/DeviceDetailView.swift b/Shellbee/Features/Devices/DeviceDetailView.swift index ea70082..807051d 100644 --- a/Shellbee/Features/Devices/DeviceDetailView.swift +++ b/Shellbee/Features/Devices/DeviceDetailView.swift @@ -32,13 +32,7 @@ struct DeviceDetailView: View { .listRowBackground(Color.clear) .listRowSeparator(.hidden) - 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") { @@ -131,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 @@ -162,9 +255,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 +272,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 +334,31 @@ 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)]) + ) + // 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)]) + ) + } } #Preview { diff --git a/Shellbee/Features/Devices/DeviceFirmwareMenu.swift b/Shellbee/Features/Devices/DeviceFirmwareMenu.swift index 090a363..fc81fb6 100644 --- a/Shellbee/Features/Devices/DeviceFirmwareMenu.swift +++ b/Shellbee/Features/Devices/DeviceFirmwareMenu.swift @@ -39,6 +39,12 @@ struct DeviceFirmwareMenu: View { } Button { + // 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) @@ -52,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 c4e96ea..516fb5b 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 @@ -55,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) } @@ -65,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) } } } @@ -106,9 +139,43 @@ 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 { + // 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") + } + } + } } } Divider() @@ -134,7 +201,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..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 } @@ -218,6 +219,31 @@ 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)]) + ) + // 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) { environment.renameDevice(from: device.friendlyName, to: newName, homeassistantRename: homeassistantRename) } @@ -263,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/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" 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/Features/Settings/AboutView.swift b/Shellbee/Features/Settings/AboutView.swift index ca2b491..e8fda72 100644 --- a/Shellbee/Features/Settings/AboutView.swift +++ b/Shellbee/Features/Settings/AboutView.swift @@ -6,16 +6,78 @@ 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 ?? "—" + } + + 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 { Form { + shellbeeSection + connectSection 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") + } + } + } + + private var connectSection: some View { + Section("Connect") { + 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(.darkGray), + 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) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + @ViewBuilder private var bridgeSection: some View { Section("Bridge") { @@ -54,16 +116,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") { diff --git a/Shellbee/Features/Settings/AppGeneralView.swift b/Shellbee/Features/Settings/AppGeneralView.swift index 6cab71f..4b11e6f 100644 --- a/Shellbee/Features/Settings/AppGeneralView.swift +++ b/Shellbee/Features/Settings/AppGeneralView.swift @@ -3,9 +3,8 @@ 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 var body: some View { @@ -20,7 +19,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) } @@ -32,10 +31,8 @@ struct AppGeneralView: View { } Section { - Toggle("Connection Live Activity", isOn: $connectionLiveActivityEnabled) - Toggle("OTA Live Activity", isOn: $otaLiveActivityEnabled) InlineIntField( - "Reconnect Attempts", + "Reconnect Limit", value: $maxReconnectAttempts, unit: "attempts", range: ConnectionSessionController.maxReconnectAttemptsRange @@ -43,11 +40,11 @@ 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 { - Toggle("Automatically share crash reports", isOn: Binding( + Toggle("Automatically Share Crash Reports", isOn: Binding( get: { consent.alwaysShare }, set: { consent.alwaysShare = $0 } )) @@ -56,6 +53,14 @@ 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) + } header: { + Text("Advanced") + } 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/AppLiveActivitiesView.swift b/Shellbee/Features/Settings/AppLiveActivitiesView.swift new file mode 100644 index 0000000..863b0c4 --- /dev/null +++ b/Shellbee/Features/Settings/AppLiveActivitiesView.swift @@ -0,0 +1,28 @@ +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) + Toggle("Scheduled OTAs", isOn: $otaScheduledLiveActivityEnabled) + .disabled(!otaLiveActivityEnabled) + } footer: { + 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") + .navigationBarTitleDisplayMode(.inline) + } +} + +#Preview { + NavigationStack { + AppLiveActivitiesView() + } +} diff --git a/Shellbee/Features/Settings/AppPerformanceView.swift b/Shellbee/Features/Settings/AppPerformanceView.swift index 7969dc8..5cf1b14 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 @@ -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/AvailabilitySettingsView.swift b/Shellbee/Features/Settings/AvailabilitySettingsView.swift index b07c486..01c33da 100644 --- a/Shellbee/Features/Settings/AvailabilitySettingsView.swift +++ b/Shellbee/Features/Settings/AvailabilitySettingsView.swift @@ -30,15 +30,15 @@ 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 { 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/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 new file mode 100644 index 0000000..20e465e --- /dev/null +++ b/Shellbee/Features/Settings/Backup/BackupView.swift @@ -0,0 +1,205 @@ +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 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 + case running + case success(size: Int) + case failed(reason: String) + } + + var body: some View { + Form { + Section { + Button { + triggerBackup() + } label: { + HStack { + Text("Create Backup") + Spacer() + if status == .running { + ProgressView() + } + } + } + .disabled(status == .running || !environment.connectionState.isConnected) + + if let url = lastBackupURL, let size = lastBackupSize { + Button { + shareItem = ShareItem(url: url) + } label: { + LabeledContent("Share Backup", value: formatted(size: size)) + } + } + } footer: { + statusFooter + } + + if !history.isEmpty { + Section { + ForEach(history) { entry in + LabeledContent { + Text(formatted(size: entry.size)) + .foregroundStyle(.secondary) + } label: { + Text(entry.timestamp, format: .dateTime.day().month().year().hour().minute()) + } + } + .onDelete { indices in + history.remove(atOffsets: indices) + HistoryEntry.save(history) + } + } header: { + Text("Recent Backups") + } footer: { + Text("Shellbee does not retain backup files — save them to Files or iCloud Drive when prompted.") + } + } + + Section { + Button { + showRestoreGuide = true + } label: { + HStack { + Text("Restore Guide") + .foregroundStyle(Color.primary) + Spacer() + Image(systemName: "chevron.right") + .font(.footnote.weight(.semibold)) + .foregroundStyle(.tertiary) + } + } + } footer: { + 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() { + 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 + lastBackupSize = size + 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 { + 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" + // 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) + do { + try BackupPayload.verifyZip(at: url) + } catch { + try? FileManager.default.removeItem(at: url) + throw error + } + 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) + } + } +} + +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 new file mode 100644 index 0000000..0a58b75 --- /dev/null +++ b/Shellbee/Features/Settings/Backup/RestoreGuideSheet.swift @@ -0,0 +1,103 @@ +import SwiftUI + +struct RestoreGuideSheet: View { + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + 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("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("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") + } + } + + 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) + } + } + .navigationTitle("Restoring a Backup") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { dismiss() } + } + } + } + } + + 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(.footnote).foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + .padding(.vertical, 2) + } + + private func bulletRow(_ text: String) -> some View { + HStack(alignment: .top, spacing: 8) { + Text("\u{2022}") + .foregroundStyle(.secondary) + Text(text) + .fixedSize(horizontal: false, vertical: true) + } + .font(.footnote) + } +} + +#Preview { + RestoreGuideSheet() +} 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..d12f7e0 --- /dev/null +++ b/Shellbee/Features/Settings/Developer/MQTTInspectorView.swift @@ -0,0 +1,411 @@ +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, Hashable { + case subscribe = "Subscribe" + case publish = "Publish" + var id: String { rawValue } + } + + var body: some View { + ZStack { + switch selectedTab { + case .subscribe: + SubscribeView(store: store) + case .publish: + PublishView() + } + } + .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 + Text(tab.rawValue).tag(tab) + } + } + .pickerStyle(.segmented) + .frame(width: 220) + } + } + .onAppear { store.attach(session: environment.session) } + .onDisappear { store.detach(session: environment.session) } + } +} + +// 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 + } + + 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 + 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 + } + + /// 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() } + } label: { + Text(expanded ? "Show less" : "Show more") + .font(.caption.weight(.medium)) + } + .buttonStyle(.borderless) + .padding(.leading, 22) + } + } + .padding(.vertical, 2) + } +} + +// 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? + @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 { + 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 { + Button("Publish") { + if topic.hasPrefix("bridge/request/") { + showWarning = true + } else { + sendNow() + } + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .fontWeight(.semibold) + .frame(maxWidth: .infinity) + .disabled(!isValid) + .listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16)) + .listRowBackground(Color.clear) + } + + if let lastResult { + Section { + Label(lastResult, systemImage: "checkmark.circle.fill") + .font(.footnote) + .foregroundStyle(.green) + } + } + } + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + topic = "" + payload = "" + lastResult = nil + } label: { + Image(systemName: "trash") + } + .accessibilityLabel("Clear form") + .disabled(topic.isEmpty && payload.isEmpty && lastResult == nil) + } + } + .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 at \(Date.now.formatted(date: .omitted, time: .standard))" + Haptics.impact(.light) + } + + 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/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/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/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/OTASettingsView.swift b/Shellbee/Features/Settings/OTASettingsView.swift index 07fa1ab..68ed930 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,13 +53,13 @@ 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") } 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 991321c..93b2f5e 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("Controls the indicator LED on the Zigbee adapter, if supported.") } } .navigationTitle("Adapter") diff --git a/Shellbee/Features/Settings/SettingsView.swift b/Shellbee/Features/Settings/SettingsView.swift index 2a766ac..26f195a 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 } @@ -117,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") } @@ -159,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)) @@ -173,6 +184,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 { 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 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 f7cb757..d171e21 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,56 @@ 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 + + private let filterProps: Set = ["replace_filter", "filter_age", "device_age"] + + 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) + } + } + } + } + + @ViewBuilder + private func rowFor(_ item: LayoutItem) -> some View { + switch item { + case .row(let expose): + SettingsFormRow(expose: expose, state: context.state, mode: mode, onSend: onSend) + case .indexedGroup(let group): + NavigationLink { + FeatureGroupDetailView(group: group, state: context.state, mode: mode, onSend: onSend) + } label: { + LabeledContent(group.label) { + Text("\(group.members.count)") + } + } + } + } +} + // MARK: - Disclosure row (monochrome, local to fan card) private struct DisclosureRow: View { @@ -660,51 +717,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 } 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/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 + } +} 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() { diff --git a/ShellbeeUITests/Settings/SettingsUITests.swift b/ShellbeeUITests/Settings/SettingsUITests.swift index ee42b24..cee2c76 100644 --- a/ShellbeeUITests/Settings/SettingsUITests.swift +++ b/ShellbeeUITests/Settings/SettingsUITests.swift @@ -150,6 +150,144 @@ 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") + } + + // 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: 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"). + 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() {