From 68673d1cb80836c8b1fbc9508ca2fdd0495461b9 Mon Sep 17 00:00:00 2001 From: tashda Date: Mon, 4 May 2026 08:26:39 +0200 Subject: [PATCH 01/19] v1.6.1: Hide Groups by default, trim empty device stats, replace Settings + with More menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - HomeLayout: one-time migration force-hides the Groups card for users upgrading from 1.6.0, matching new-install behavior - HomeDevicesCard: only render Online/Offline/Untracked stats when count > 0 - SettingsView: toolbar + becomes a More (ellipsis) menu — Add Bridge always, Disconnect only in single-bridge mode; removes duplicate Disconnect section at bottom of single-bridge layout Co-Authored-By: Claude Opus 4.7 (1M context) --- Shellbee.xcodeproj/project.pbxproj | 8 ++-- Shellbee/Features/Home/HomeDevicesCard.swift | 15 ++++--- Shellbee/Features/Home/HomeLayout.swift | 12 +++++- Shellbee/Features/Settings/SettingsView.swift | 40 +++++++++---------- 4 files changed, 45 insertions(+), 30 deletions(-) diff --git a/Shellbee.xcodeproj/project.pbxproj b/Shellbee.xcodeproj/project.pbxproj index ffc2c26..327b31e 100644 --- a/Shellbee.xcodeproj/project.pbxproj +++ b/Shellbee.xcodeproj/project.pbxproj @@ -844,7 +844,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.6.0; + MARKETING_VERSION = 1.6.1; PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_ID)"; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -885,7 +885,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.6.0; + MARKETING_VERSION = 1.6.1; PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_ID)"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -925,7 +925,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.6.0; + MARKETING_VERSION = 1.6.1; PRODUCT_BUNDLE_IDENTIFIER = "$(APP_WIDGET_BUNDLE_ID)"; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -967,7 +967,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.6.0; + MARKETING_VERSION = 1.6.1; PRODUCT_BUNDLE_IDENTIFIER = "$(APP_WIDGET_BUNDLE_ID)"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/Shellbee/Features/Home/HomeDevicesCard.swift b/Shellbee/Features/Home/HomeDevicesCard.swift index f11fecf..f39d2bb 100644 --- a/Shellbee/Features/Home/HomeDevicesCard.swift +++ b/Shellbee/Features/Home/HomeDevicesCard.swift @@ -30,11 +30,16 @@ struct HomeDevicesCard: View { private var statsRow: some View { HStack(alignment: .top, spacing: DesignTokens.Spacing.lg) { statButton(.all, value: "\(snapshot.totalDevices)", label: "Total") - statButton(.online, value: "\(snapshot.onlineDevices)", label: "Online") - statButton(.offline, value: "\(snapshot.offlineDevices)", label: "Offline", - valueColor: snapshot.offlineDevices > 0 ? .red : .primary) - statButton(.availabilityOff, value: "\(snapshot.availabilityOffDevices)", label: "Untracked", - valueColor: snapshot.availabilityOffDevices > 0 ? .secondary : .primary) + if snapshot.onlineDevices > 0 { + statButton(.online, value: "\(snapshot.onlineDevices)", label: "Online") + } + if snapshot.offlineDevices > 0 { + statButton(.offline, value: "\(snapshot.offlineDevices)", label: "Offline", valueColor: .red) + } + if snapshot.availabilityOffDevices > 0 { + statButton(.availabilityOff, value: "\(snapshot.availabilityOffDevices)", label: "Untracked", + valueColor: .secondary) + } } } diff --git a/Shellbee/Features/Home/HomeLayout.swift b/Shellbee/Features/Home/HomeLayout.swift index 4b80dda..d89b494 100644 --- a/Shellbee/Features/Home/HomeLayout.swift +++ b/Shellbee/Features/Home/HomeLayout.swift @@ -55,13 +55,14 @@ final class HomeLayoutStore { private static let visibleKey = "homeVisibleOrder" private static let hiddenKey = "homeHiddenCards" private static let initializedKey = "homeLayoutInitialized" + private static let groupsHiddenMigrationKey = "homeGroupsDefaultHiddenMigrationV1" private static let defaultHidden: Set = [.groups] init() { let defaults = UserDefaults.standard let isInitialized = defaults.bool(forKey: Self.initializedKey) - let savedVisible = Self.decode(defaults.string(forKey: Self.visibleKey)) + var savedVisible = Self.decode(defaults.string(forKey: Self.visibleKey)) var savedHidden = Set(Self.decode(defaults.string(forKey: Self.hiddenKey))) if !isInitialized { @@ -69,6 +70,15 @@ final class HomeLayoutStore { defaults.set(true, forKey: Self.initializedKey) } + // One-time migration: 1.6.0 shipped Groups visible by default for any + // user whose layout was already initialized. Force it hidden once so + // upgraders match new-install behavior; users can reveal it via Edit. + if !defaults.bool(forKey: Self.groupsHiddenMigrationKey) { + savedVisible.removeAll { $0 == .groups } + savedHidden.insert(.groups) + defaults.set(true, forKey: Self.groupsHiddenMigrationKey) + } + var visible = savedVisible for card in HomeCardID.allCases where !visible.contains(card) && !savedHidden.contains(card) { visible.append(card) diff --git a/Shellbee/Features/Settings/SettingsView.swift b/Shellbee/Features/Settings/SettingsView.swift index 964803c..c3af93e 100644 --- a/Shellbee/Features/Settings/SettingsView.swift +++ b/Shellbee/Features/Settings/SettingsView.swift @@ -31,6 +31,12 @@ struct SettingsView: View { singleBridgeID.flatMap { environment.scope(for: $0) } } + private var canDisconnectSingleBridge: Bool { + guard let scope = singleBridgeScope else { return false } + return scope.connectionState.isConnected + || (scope.session?.controller.hasBeenConnected ?? false) + } + private var singleBridgeConfig: ConnectionConfig? { if let id = singleBridgeID { return environment.history.connections.first(where: { $0.id == id }) @@ -51,10 +57,21 @@ struct SettingsView: View { .navigationTitle("Settings") .toolbar { ToolbarItem(placement: .topBarTrailing) { - Button { presentNewBridgeEditor() } label: { - Image(systemName: "plus") + Menu { + Button { presentNewBridgeEditor() } label: { + Label("Add Bridge", systemImage: "plus") + } + if !isMultiBridge, canDisconnectSingleBridge { + Button(role: .destructive) { + showingDisconnectConfirmation = true + } label: { + Label("Disconnect", systemImage: "xmark.circle") + } + } + } label: { + Image(systemName: "ellipsis") } - .accessibilityLabel("Add Bridge") + .accessibilityLabel("More") } } .sheet(item: editorBinding) { vm in @@ -186,12 +203,6 @@ struct SettingsView: View { if developerModeEnabled { developerSection } - - let connected = singleBridgeScope?.connectionState.isConnected ?? false - let everConnected = singleBridgeScope?.session?.controller.hasBeenConnected ?? false - if connected || everConnected, let id = singleBridgeID { - dangerSection(bridgeID: id) - } } @ViewBuilder @@ -361,17 +372,6 @@ struct SettingsView: View { } } - private func dangerSection(bridgeID: UUID) -> some View { - // bridgeID is the resolved single-bridge id; the Disconnect alert - // (handled at view-level) targets it via `singleBridgeID`. - _ = bridgeID - return Section { - Button("Disconnect", role: .destructive) { - showingDisconnectConfirmation = true - } - } - } - private func settingsLabel(title: String, systemImage: String, color: Color) -> some View { Label { Text(title) From 59600c76de84cba8166421b9075191c44471f344 Mon Sep 17 00:00:00 2001 From: tashda Date: Mon, 4 May 2026 08:35:14 +0200 Subject: [PATCH 02/19] =?UTF-8?q?Restart=20Required=20notice:=20long-press?= =?UTF-8?q?=20=E2=86=92=20Go=20to=20Log?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Capture the id of the Z2M log entry whose message contains "restart required" when bridgeInfo.restartRequired flips false → true (and clear on the reverse). The Settings and per-bridge "Restart Required" notice gains a context menu with "Go to Log" that deep-links into LogsView filtered to that entry, with a live-scan fallback when no id is captured. Co-Authored-By: Claude Opus 4.7 (1M context) --- Shellbee/Core/Models/NavigationRoutes.swift | 8 ++++++ Shellbee/Core/Store/AppStore+Events.swift | 26 +++++++++++++++++++ Shellbee/Core/Store/AppStore.swift | 6 +++++ .../Settings/BridgeSettingsView.swift | 21 +++++++++++++++ Shellbee/Features/Settings/SettingsView.swift | 21 +++++++++++++++ 5 files changed, 82 insertions(+) diff --git a/Shellbee/Core/Models/NavigationRoutes.swift b/Shellbee/Core/Models/NavigationRoutes.swift index fea74da..c5da9dd 100644 --- a/Shellbee/Core/Models/NavigationRoutes.swift +++ b/Shellbee/Core/Models/NavigationRoutes.swift @@ -27,3 +27,11 @@ struct LogRoute: Hashable { let bridgeID: UUID let entry: LogEntry } + +/// Request to deep-link into `LogsView`. Carries the optional id of the +/// specific entry to focus; when nil, opens the unfiltered log list. +/// Identifiable so it drives `.navigationDestination(item:)`. +struct LogDeepLinkRequest: Hashable, Identifiable { + let entryID: UUID? + var id: UUID { entryID ?? UUID() } +} diff --git a/Shellbee/Core/Store/AppStore+Events.swift b/Shellbee/Core/Store/AppStore+Events.swift index d8809ea..cd741f1 100644 --- a/Shellbee/Core/Store/AppStore+Events.swift +++ b/Shellbee/Core/Store/AppStore+Events.swift @@ -4,6 +4,7 @@ extension AppStore { func apply(_ event: Z2MEvent) { switch event { case .bridgeInfo(let info): + let previousRestartRequired = bridgeInfo?.restartRequired ?? false // bridge/info recomputes permitJoinEnd from `permit_join_timeout` // every time it lands. Z2M's timeout doesn't tick down between // snapshots, so each refresh would either jump the end forward @@ -31,6 +32,7 @@ extension AppStore { } else { bridgeInfo = info } + updateRestartTrigger(previous: previousRestartRequired, current: info.restartRequired) syncConfiguredAvailability() case .bridgeState(let state): bridgeOnline = state == "online" @@ -204,7 +206,9 @@ extension AppStore { guard payload.object?["status"]?.stringValue == "ok" else { break } if let restartRequired = payload.object?["data"]?.object?["restart_required"]?.boolValue, let info = bridgeInfo { + let previous = info.restartRequired bridgeInfo = info.copyUpdating(restartRequired: restartRequired) + updateRestartTrigger(previous: previous, current: restartRequired) } case .bridgeHealth(let health): @@ -342,4 +346,26 @@ extension AppStore { return nil } } + + /// Bridge `restart_required` arrives as a single bool — never a reference + /// to the log line that explains *why*. To deep-link the Settings notice + /// to that log line, scan recent entries on the false → true transition + /// for the matching Z2M log message ("restart required"). False → true + /// without a matching entry leaves the id nil; the UI falls back to + /// opening Logs unfiltered. Clear on the true → false transition so + /// stale ids never deep-link to old entries. + func updateRestartTrigger(previous: Bool, current: Bool) { + guard previous != current else { return } + if current { + restartTriggerLogID = recentRestartRequiredLogID() + } else { + restartTriggerLogID = nil + } + } + + /// Most recent log entry whose message contains "restart required" + /// (case-insensitive). Searches `logEntries` newest-first. + func recentRestartRequiredLogID() -> UUID? { + logEntries.first(where: { $0.message.range(of: "restart required", options: .caseInsensitive) != nil })?.id + } } diff --git a/Shellbee/Core/Store/AppStore.swift b/Shellbee/Core/Store/AppStore.swift index c7482e1..1760c2a 100644 --- a/Shellbee/Core/Store/AppStore.swift +++ b/Shellbee/Core/Store/AppStore.swift @@ -52,6 +52,12 @@ final class AppStore { var otaUpdates: [String: OTAUpdateStatus] = [:] var logEntries: [LogEntry] = [] var rawLogEntries: [LogEntry] = [] + /// Id of the most recent log entry that flagged the bridge as needing + /// a restart (a Z2M log line containing "restart required"). Captured + /// when `bridgeInfo.restartRequired` flips false → true so the + /// "Restart Required" notice in Settings can deep-link to the source + /// log entry on long-press. Cleared when restart_required goes false. + var restartTriggerLogID: UUID? var operationErrors: [Z2MOperationError] = [] var touchlinkDevices: [TouchlinkDevice] = [] var touchlinkScanInProgress = false diff --git a/Shellbee/Features/Settings/BridgeSettingsView.swift b/Shellbee/Features/Settings/BridgeSettingsView.swift index 20cb49e..cec04d0 100644 --- a/Shellbee/Features/Settings/BridgeSettingsView.swift +++ b/Shellbee/Features/Settings/BridgeSettingsView.swift @@ -13,6 +13,7 @@ struct BridgeSettingsView: View { @State private var showingDisconnectConfirmation = false @State private var editorViewModel: ConnectionViewModel? @State private var removeConfirmation: ConnectionConfig? + @State private var logDeepLink: LogDeepLinkRequest? private var scope: BridgeScope { environment.scope(for: bridgeID) } private var session: BridgeSession? { environment.registry.session(for: bridgeID) } @@ -38,6 +39,9 @@ struct BridgeSettingsView: View { } .navigationTitle(displayName) .navigationBarTitleDisplayMode(.inline) + .navigationDestination(item: $logDeepLink) { req in + LogsView(initialEntryFilter: req.entryID.map { [$0] }) + } .sheet(item: editorBinding) { vm in NavigationStack { ConnectionEditorView(viewModel: vm, mode: .save) @@ -232,9 +236,26 @@ struct BridgeSettingsView: View { .padding(.vertical, DesignTokens.Spacing.xs) } .buttonStyle(.plain) + .contextMenu { + Button { + logDeepLink = LogDeepLinkRequest(entryID: restartTriggerLogID) + } label: { + Label("Go to Log", systemImage: "doc.text.magnifyingglass") + } + Button { + showingRestartAlert = true + } label: { + Label("Restart Now", systemImage: "arrow.clockwise") + } + } } } + private var restartTriggerLogID: UUID? { + guard let store = session?.store else { return nil } + return store.restartTriggerLogID ?? store.recentRestartRequiredLogID() + } + private func settingsLabel(title: String, systemImage: String, color: Color) -> some View { Label { Text(title) diff --git a/Shellbee/Features/Settings/SettingsView.swift b/Shellbee/Features/Settings/SettingsView.swift index c3af93e..ec331ca 100644 --- a/Shellbee/Features/Settings/SettingsView.swift +++ b/Shellbee/Features/Settings/SettingsView.swift @@ -9,6 +9,7 @@ struct SettingsView: View { /// mode to add a new saved bridge without leaving Settings. @State private var editorViewModel: ConnectionViewModel? @State private var removeConfirmation: ConnectionConfig? + @State private var logDeepLink: LogDeepLinkRequest? /// Phase 2 multi-bridge: when the user has more than one saved bridge, the /// top-level Settings page swaps to the merged layout — every per-bridge @@ -55,6 +56,9 @@ struct SettingsView: View { } } .navigationTitle("Settings") + .navigationDestination(item: $logDeepLink) { req in + LogsView(initialEntryFilter: req.entryID.map { [$0] }) + } .toolbar { ToolbarItem(placement: .topBarTrailing) { Menu { @@ -406,8 +410,25 @@ struct SettingsView: View { .padding(.vertical, DesignTokens.Spacing.xs) } .buttonStyle(.plain) + .contextMenu { + Button { + logDeepLink = LogDeepLinkRequest(entryID: restartTriggerLogID) + } label: { + Label("Go to Log", systemImage: "doc.text.magnifyingglass") + } + Button { + showingRestartAlert = true + } label: { + Label("Restart Now", systemImage: "arrow.clockwise") + } + } } } + + private var restartTriggerLogID: UUID? { + guard let store = singleBridgeScope?.session?.store else { return nil } + return store.restartTriggerLogID ?? store.recentRestartRequiredLogID() + } } // MARK: - Bridge row (multi-bridge Settings root) From d36ae3f1e51ccd42a7f654fe4b7a2c7f3d865b22 Mon Sep 17 00:00:00 2001 From: tashda Date: Mon, 4 May 2026 09:08:56 +0200 Subject: [PATCH 03/19] Fix device-specific settings parity with Z2M frontend (#91) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-device options (e.g. Hue Native Control, transition, color sync) were either missing, broken, or rendered with custom non-native controls. - Source option values from bridge/info.config.devices[ieee] (real Z2M source of truth); fall back to device.options for the docker seeder. - Render expose.description as a Section footer per option, native row height for each control. - Replace pill-style tristate with native Toggle / Picker. - Title-case labels ("Hue Native Control", "State Action") while preserving multi-uppercase acronyms ("QoS", "RGB"). - Numeric input is Double-aware via value_step, only writes on commit (was auto-publishing every option on screen appear), and shows the unit always — including inferred "s" for transition/throttle/debounce/retention. - Send options keyed by ieee_address (survives mid-flight rename). - Seeder now mirrors options into bridge/info.config.devices to match real Z2M, and seeds hue_native_control + effect_color_mode on Philips color lamp fixtures. Co-authored-by: Claude Opus 4.7 (1M context) --- Shellbee/Core/Models/BridgeInfo.swift | 39 ++- .../Features/Devices/DeviceSettingsView.swift | 242 ++++++++++++++---- docker/seeder/models.json | 57 +++++ docker/seeder/seeder.py | 19 ++ 4 files changed, 299 insertions(+), 58 deletions(-) diff --git a/Shellbee/Core/Models/BridgeInfo.swift b/Shellbee/Core/Models/BridgeInfo.swift index 5118d5f..265be8c 100644 --- a/Shellbee/Core/Models/BridgeInfo.swift +++ b/Shellbee/Core/Models/BridgeInfo.swift @@ -109,23 +109,48 @@ struct BridgeConfig: Codable, Sendable, Equatable { let groups: [String: [String: JSONValue]]? let devices: [String: DeviceConfig]? - func availabilityTrackingEnabled(for device: Device) -> Bool { - guard let devices else { return true } - let config = devices[device.ieeeAddress] + /// Resolve the `bridge/info.config.devices` entry for a device, accepting + /// any of the casings/keys Z2M might use as the map key (ieee, friendly). + func deviceConfig(for device: Device) -> DeviceConfig? { + guard let devices else { return nil } + return devices[device.ieeeAddress] ?? devices[device.ieeeAddress.lowercased()] ?? devices[device.ieeeAddress.uppercased()] ?? devices[device.friendlyName] ?? devices.values.first { $0.friendlyName == device.friendlyName } - return config?.availability?.boolValue != false + } + + func availabilityTrackingEnabled(for device: Device) -> Bool { + guard devices != nil else { return true } + return deviceConfig(for: device)?.availability?.boolValue != false } struct DeviceConfig: Codable, Sendable, Equatable { let friendlyName: String? let availability: JSONValue? + /// Full per-device options map from `bridge/info.config.devices[ieee]`. + /// On a real Z2M bridge this is the only source of truth for option + /// values like `qos`, `throttle`, `hue_native_control`, etc. — they are + /// NOT present on the per-device entries in `bridge/devices`. + let raw: [String: JSONValue] - enum CodingKeys: String, CodingKey { - case friendlyName = "friendly_name" - case availability + init(friendlyName: String?, availability: JSONValue?, raw: [String: JSONValue] = [:]) { + self.friendlyName = friendlyName + self.availability = availability + self.raw = raw + } + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let map = (try? container.decode([String: JSONValue].self)) ?? [:] + raw = map + friendlyName = map["friendly_name"]?.stringValue + availability = map["availability"] + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(raw) } } diff --git a/Shellbee/Features/Devices/DeviceSettingsView.swift b/Shellbee/Features/Devices/DeviceSettingsView.swift index 2edc007..e410765 100644 --- a/Shellbee/Features/Devices/DeviceSettingsView.swift +++ b/Shellbee/Features/Devices/DeviceSettingsView.swift @@ -18,6 +18,17 @@ struct DeviceSettingsView: View { scope.store.devices.first { $0.ieeeAddress == device.ieeeAddress } ?? device } + /// Real Z2M ships per-device option values via `bridge/info.config.devices[ieee]`, + /// not on the device entry in `bridge/devices`. Read both for compatibility + /// (the docker seeder mirrors options onto the device entry too). + private var optionValues: [String: JSONValue] { + var merged = currentDevice.options ?? [:] + if let cfg = scope.store.bridgeInfo?.config?.deviceConfig(for: currentDevice) { + for (k, v) in cfg.raw { merged[k] = v } + } + return merged + } + private var deviceOptions: [Expose] { (currentDevice.definition?.options ?? []).flattenedLeaves } @@ -32,26 +43,33 @@ struct DeviceSettingsView: View { } } - if !deviceOptions.isEmpty { - Section("Device Options") { - ForEach(deviceOptions, id: \.property) { expose in - let key = expose.property ?? expose.name ?? "" - DeviceOptionRow( - expose: expose, - currentValue: currentDevice.options?[key], - onChange: { sendOption(key, value: $0) } - ) + // Each device-specific option gets its own Section so the + // description renders as a proper iOS-style footer beneath a + // standard-height row — matches Settings > General etc. + ForEach(Array(deviceOptions.enumerated()), id: \.offset) { index, expose in + let key = expose.property ?? expose.name ?? "" + Section { + DeviceOptionRow( + expose: expose, + currentValue: optionValues[key], + onChange: { sendOption(key, value: $0) } + ) + } header: { + if index == 0 { Text("Device Options") } + } footer: { + if let desc = expose.description, !desc.isEmpty { + Text(desc) } } } Section { Toggle("Retain", isOn: Binding( - get: { currentDevice.options?["retain"]?.boolValue ?? false }, + get: { optionValues["retain"]?.boolValue ?? false }, set: { sendOption("retain", value: .bool($0)) } )) Picker("QoS", selection: Binding( - get: { currentDevice.options?["qos"]?.intValue ?? -1 }, + get: { optionValues["qos"]?.intValue ?? -1 }, set: { sendOption("qos", value: $0 < 0 ? .null : .int($0)) } )) { Text("Default").tag(-1) @@ -75,11 +93,11 @@ struct DeviceSettingsView: View { Section { Toggle("Optimistic", isOn: Binding( - get: { currentDevice.options?["optimistic"]?.boolValue ?? true }, + get: { optionValues["optimistic"]?.boolValue ?? true }, set: { sendOption("optimistic", value: .bool($0)) } )) Toggle("Disabled", isOn: Binding( - get: { currentDevice.options?["disabled"]?.boolValue ?? currentDevice.disabled }, + get: { optionValues["disabled"]?.boolValue ?? currentDevice.disabled }, set: { sendOption("disabled", value: .bool($0)) } )) InlineIntField("Debounce", value: $debounce, unit: "s", range: 0...60, offLabel: "Off") @@ -125,10 +143,10 @@ struct DeviceSettingsView: View { } private func syncState() { - throttle = currentDevice.options?["throttle"]?.intValue ?? 0 - retention = currentDevice.options?["retention"]?.intValue ?? 0 - debounce = currentDevice.options?["debounce"]?.intValue ?? 0 - haName = currentDevice.options?["homeassistant"]?.object?["name"]?.stringValue ?? "" + throttle = optionValues["throttle"]?.intValue ?? 0 + retention = optionValues["retention"]?.intValue ?? 0 + debounce = optionValues["debounce"]?.intValue ?? 0 + haName = optionValues["homeassistant"]?.object?["name"]?.stringValue ?? "" } private func sendHAName() { @@ -137,10 +155,12 @@ struct DeviceSettingsView: View { } private func sendOption(_ key: String, value: JSONValue) { + // Use ieee_address as the canonical id (Z2M accepts both, but ieee + // survives a friendly-name rename mid-flight). scope.send( topic: Z2MTopics.Request.deviceOptions, payload: .object([ - "id": .string(currentDevice.friendlyName), + "id": .string(currentDevice.ieeeAddress), "options": .object([key: value]) ]) ) @@ -148,72 +168,192 @@ struct DeviceSettingsView: View { } +// MARK: - Row for one definition.options entry + private struct DeviceOptionRow: View { let expose: Expose let currentValue: JSONValue? let onChange: (JSONValue) -> Void - @State private var numericInt: Int = 0 - private var label: String { - let raw = expose.property ?? expose.name ?? "" - return expose.label ?? raw.replacingOccurrences(of: "_", with: " ").capitalized + let base: String = { + if let l = expose.label, !l.isEmpty { return l } + let raw = expose.property ?? expose.name ?? "" + return raw.replacingOccurrences(of: "_", with: " ") + }() + return Self.titleCase(base) } - var body: some View { + @ViewBuilder var body: some View { switch expose.type { - case "binary": binaryRow - case "enum": enumRow - case "numeric": numericRow + case "binary": BinaryOption(expose: expose, label: label, currentValue: currentValue, onChange: onChange) + case "enum": EnumOption(expose: expose, label: label, currentValue: currentValue, onChange: onChange) + case "numeric": NumericOption(expose: expose, label: label, currentValue: currentValue, onChange: onChange) default: textRow } } - @ViewBuilder private var binaryRow: some View { - let isOn = currentValue == expose.valueOn || currentValue?.boolValue == true - if expose.isWritable, let on = expose.valueOn, let off = expose.valueOff { - Toggle(label, isOn: Binding(get: { isOn }, set: { onChange($0 ? on : off) })) + private var textRow: some View { + LabeledContent(label) { + Text(currentValue?.stringified ?? "—").foregroundStyle(.secondary) + } + } + + /// Title-case a label: "Hue native control" → "Hue Native Control". + /// Preserves runs of uppercase (acronyms like "QoS", "RGB", "URL") so + /// they don't get clobbered into "Qos" / "Rgb" / "Url". + fileprivate static func titleCase(_ s: String) -> String { + s.split(separator: " ", omittingEmptySubsequences: false).map { word -> String in + guard let first = word.first else { return "" } + // Word with multiple uppercase letters → leave as-is (RGB, QoS). + let upperCount = word.filter { $0.isUppercase }.count + if upperCount > 1 { return String(word) } + return first.uppercased() + word.dropFirst() + }.joined(separator: " ") + } +} + +private let _knownUnitSeconds: Set = [ + "transition", "throttle", "debounce", "retention", +] + +// MARK: - Binary option (native Toggle; long-press to clear to default) + +private struct BinaryOption: View { + let expose: Expose + let label: String + let currentValue: JSONValue? + let onChange: (JSONValue) -> Void + + @ViewBuilder + var body: some View { + let on = expose.valueOn ?? .bool(true) + let off = expose.valueOff ?? .bool(false) + let isOn = currentValue == on || (expose.valueOn == nil && currentValue?.boolValue == true) + + if !expose.isWritable { + LabeledContent(label) { + Text(isOn ? "On" : "Off").foregroundStyle(.secondary) + } } else { - LabeledContent(label) { Text(isOn ? "On" : "Off").foregroundStyle(.secondary) } + Toggle(label, isOn: Binding( + get: { isOn }, + set: { onChange($0 ? on : off) } + )) } } +} + +// MARK: - Enum option - @ViewBuilder private var enumRow: some View { +private struct EnumOption: View { + let expose: Expose + let label: String + let currentValue: JSONValue? + let onChange: (JSONValue) -> Void + + var body: some View { let values = expose.values ?? [] if expose.isWritable, !values.isEmpty { Picker(label, selection: Binding( - get: { currentValue?.stringValue ?? values.first ?? "" }, - set: { onChange(.string($0)) } + get: { currentValue?.stringValue ?? "" }, + set: { newValue in + if newValue.isEmpty { onChange(.null) } + else { onChange(.string(newValue)) } + } )) { + Text("Default").tag("") ForEach(values, id: \.self) { v in - Text(v.replacingOccurrences(of: "_", with: " ").capitalized).tag(v) + Text(displayValue(v)).tag(v) } } } else { LabeledContent(label) { - Text(currentValue?.stringValue?.replacingOccurrences(of: "_", with: " ").capitalized ?? "—") + Text(currentValue?.stringValue.map(displayValue) ?? "—") .foregroundStyle(.secondary) } } } - @ViewBuilder private var numericRow: some View { - let range: ClosedRange? = { - guard let lo = expose.valueMin, let hi = expose.valueMax else { return nil } - return Int(lo)...Int(hi) - }() - InlineIntField( - label, - value: $numericInt, - unit: expose.unit ?? "", - range: range - ) - .onAppear { numericInt = Int(currentValue?.numberValue ?? expose.valueMin ?? 0) } - .onChange(of: numericInt) { _, v in onChange(.double(Double(v))) } + private func displayValue(_ raw: String) -> String { + DeviceOptionRow.titleCase(raw.replacingOccurrences(of: "_", with: " ")) } +} - private var textRow: some View { - LabeledContent(label) { Text(currentValue?.stringified ?? "—").foregroundStyle(.secondary) } +// MARK: - Numeric option (Double-aware, only writes on user commit) + +private struct NumericOption: View { + let expose: Expose + let label: String + let currentValue: JSONValue? + let onChange: (JSONValue) -> Void + + @State private var text: String = "" + @State private var didLoad: Bool = false + @FocusState private var focused: Bool + + private var isFractional: Bool { + if let step = expose.valueStep, step.truncatingRemainder(dividingBy: 1) != 0 { return true } + return false + } + + /// Resolve a unit to display alongside the value. The bridge sets + /// `expose.unit` for most numerics, but generic options like `transition` + /// ship without one even though their description says "in seconds". + private var resolvedUnit: String? { + if let u = expose.unit, !u.isEmpty { return u } + let key = expose.property ?? expose.name ?? "" + return _knownUnitSeconds.contains(key) ? "s" : nil + } + + var body: some View { + LabeledContent(label) { + HStack(spacing: DesignTokens.Spacing.xs) { + TextField("Default", text: $text) + .keyboardType(isFractional ? .decimalPad : .numberPad) + .multilineTextAlignment(.trailing) + .focused($focused) + .foregroundStyle(.secondary) + if let unit = resolvedUnit { + Text(unit).foregroundStyle(.secondary) + } + } + } + .disabled(!expose.isWritable) + .onAppear { if !didLoad { syncFromValue(); didLoad = true } } + .onChange(of: currentValue) { _, _ in if !focused { syncFromValue() } } + .onChange(of: focused) { _, isFocused in if !isFocused { commit() } } + } + + private func syncFromValue() { + if let n = currentValue?.numberValue { + text = formatNumber(n) + } else { + text = "" + } + } + + private func commit() { + let trimmed = text.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty { + onChange(.null) + return + } + guard let parsed = Double(trimmed.replacingOccurrences(of: ",", with: ".")) else { + syncFromValue() + return + } + var clamped = parsed + if let lo = expose.valueMin { clamped = max(clamped, lo) } + if let hi = expose.valueMax { clamped = min(clamped, hi) } + onChange(.double(clamped)) + text = formatNumber(clamped) + } + + private func formatNumber(_ n: Double) -> String { + n.truncatingRemainder(dividingBy: 1) == 0 + ? String(Int(n)) + : String(n) } } diff --git a/docker/seeder/models.json b/docker/seeder/models.json index 259cc43..1b6d776 100644 --- a/docker/seeder/models.json +++ b/docker/seeder/models.json @@ -16663,6 +16663,25 @@ "description": "State actions will also be published as 'action' when true (default false).", "value_on": true, "value_off": false + }, + { + "name": "hue_native_control", + "label": "Hue native control", + "access": 2, + "type": "binary", + "property": "hue_native_control", + "description": "Control this light using a Philips-specific protocol instead of standard Zigbee commands. When enabled, on/off, brightness, color, and color temperature are combined into single atomic commands. This is required to use the Effect color update mode. When disabled (default), standard Zigbee commands are used, which preserves the usual behavior, including simulating on/off transitions.", + "value_on": true, + "value_off": false + }, + { + "name": "effect_color_mode", + "label": "Effect color mode", + "access": 2, + "type": "enum", + "property": "effect_color_mode", + "description": "Controls what happens when color is changed while an effect is active (requires Hue native control). 'stop' (default): color change stops the effect (Hue app behavior). 'update': color change re-sends the effect with the new color.", + "values": ["stop", "update"] } ], "meta": { @@ -28664,6 +28683,25 @@ "description": "State actions will also be published as 'action' when true (default false).", "value_on": true, "value_off": false + }, + { + "name": "hue_native_control", + "label": "Hue native control", + "access": 2, + "type": "binary", + "property": "hue_native_control", + "description": "Control this light using a Philips-specific protocol instead of standard Zigbee commands. When enabled, on/off, brightness, color, and color temperature are combined into single atomic commands. This is required to use the Effect color update mode. When disabled (default), standard Zigbee commands are used, which preserves the usual behavior, including simulating on/off transitions.", + "value_on": true, + "value_off": false + }, + { + "name": "effect_color_mode", + "label": "Effect color mode", + "access": 2, + "type": "enum", + "property": "effect_color_mode", + "description": "Controls what happens when color is changed while an effect is active (requires Hue native control). 'stop' (default): color change stops the effect (Hue app behavior). 'update': color change re-sends the effect with the new color.", + "values": ["stop", "update"] } ], "meta": { @@ -28907,6 +28945,25 @@ "description": "State actions will also be published as 'action' when true (default false).", "value_on": true, "value_off": false + }, + { + "name": "hue_native_control", + "label": "Hue native control", + "access": 2, + "type": "binary", + "property": "hue_native_control", + "description": "Control this light using a Philips-specific protocol instead of standard Zigbee commands. When enabled, on/off, brightness, color, and color temperature are combined into single atomic commands. This is required to use the Effect color update mode. When disabled (default), standard Zigbee commands are used, which preserves the usual behavior, including simulating on/off transitions.", + "value_on": true, + "value_off": false + }, + { + "name": "effect_color_mode", + "label": "Effect color mode", + "access": 2, + "type": "enum", + "property": "effect_color_mode", + "description": "Controls what happens when color is changed while an effect is active (requires Hue native control). 'stop' (default): color change stops the effect (Hue app behavior). 'update': color change re-sends the effect with the new color.", + "values": ["stop", "update"] } ], "meta": { diff --git a/docker/seeder/seeder.py b/docker/seeder/seeder.py index 438d424..e7bbeee 100644 --- a/docker/seeder/seeder.py +++ b/docker/seeder/seeder.py @@ -361,9 +361,19 @@ def _req_device_options(client, payload): if device is None: raise RequestError(f"Device '{ident}' does not exist") with _lock: + # Real Z2M stores per-device options in `bridge/info.config.devices[ieee]`, + # not on the per-device entry of `bridge/devices`. Mirror that here so the + # mock matches production behavior — and keep the on-device copy so any + # legacy reader still works. device.setdefault("options", {}).update(options) merged = dict(device["options"]) + config = _bridge_info.setdefault("config", {}) + cfg_devices = config.setdefault("devices", {}) + ieee = device.get("ieee_address") or ident + entry = cfg_devices.setdefault(ieee, {"friendly_name": device.get("friendly_name", ident)}) + entry.update(options) _publish_devices(client) + _publish_info(client) return {"id": ident, "from": options, "to": merged, "restart_required": False} @@ -858,6 +868,15 @@ def _init_state() -> None: _groups = copy.deepcopy(GROUPS) _bridge_info = copy.deepcopy(BRIDGE_INFO) _bridge_health = copy.deepcopy(BRIDGE_HEALTH) + # Mirror per-device options into bridge/info.config.devices to match real Z2M. + cfg_devices = _bridge_info.setdefault("config", {}).setdefault("devices", {}) + for d in _devices: + ieee = d.get("ieee_address") + if not ieee: + continue + entry = {"friendly_name": d.get("friendly_name", "")} + entry.update(d.get("options") or {}) + cfg_devices[ieee] = entry _states.clear() for name, state in DEVICE_STATES.items(): _states[name] = copy.deepcopy(state) From 3691cbd54e4a988b5f3841386c130dda22e39b93 Mon Sep 17 00:00:00 2001 From: tashda Date: Mon, 4 May 2026 09:14:09 +0200 Subject: [PATCH 04/19] Remove "Go to Log" from Restart Required notice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Z2M never logs the words "restart required" — the flag is set silently inside settings.apply() and settings.changeEntityOptions(). Our keyword search could never match, so "Go to Log" was just opening the Logs page unfiltered. Match windfront's honest minimalism: tap to restart, stop. Reverts the AppStore tracking, route type, navigation state, and context menu added in #85. Fixes #88 Co-Authored-By: Claude Opus 4.7 (1M context) --- Shellbee/Core/Models/NavigationRoutes.swift | 8 ------ Shellbee/Core/Store/AppStore+Events.swift | 26 ------------------- Shellbee/Core/Store/AppStore.swift | 6 ----- .../Settings/BridgeSettingsView.swift | 21 --------------- Shellbee/Features/Settings/SettingsView.swift | 21 --------------- 5 files changed, 82 deletions(-) diff --git a/Shellbee/Core/Models/NavigationRoutes.swift b/Shellbee/Core/Models/NavigationRoutes.swift index c5da9dd..fea74da 100644 --- a/Shellbee/Core/Models/NavigationRoutes.swift +++ b/Shellbee/Core/Models/NavigationRoutes.swift @@ -27,11 +27,3 @@ struct LogRoute: Hashable { let bridgeID: UUID let entry: LogEntry } - -/// Request to deep-link into `LogsView`. Carries the optional id of the -/// specific entry to focus; when nil, opens the unfiltered log list. -/// Identifiable so it drives `.navigationDestination(item:)`. -struct LogDeepLinkRequest: Hashable, Identifiable { - let entryID: UUID? - var id: UUID { entryID ?? UUID() } -} diff --git a/Shellbee/Core/Store/AppStore+Events.swift b/Shellbee/Core/Store/AppStore+Events.swift index cd741f1..d8809ea 100644 --- a/Shellbee/Core/Store/AppStore+Events.swift +++ b/Shellbee/Core/Store/AppStore+Events.swift @@ -4,7 +4,6 @@ extension AppStore { func apply(_ event: Z2MEvent) { switch event { case .bridgeInfo(let info): - let previousRestartRequired = bridgeInfo?.restartRequired ?? false // bridge/info recomputes permitJoinEnd from `permit_join_timeout` // every time it lands. Z2M's timeout doesn't tick down between // snapshots, so each refresh would either jump the end forward @@ -32,7 +31,6 @@ extension AppStore { } else { bridgeInfo = info } - updateRestartTrigger(previous: previousRestartRequired, current: info.restartRequired) syncConfiguredAvailability() case .bridgeState(let state): bridgeOnline = state == "online" @@ -206,9 +204,7 @@ extension AppStore { guard payload.object?["status"]?.stringValue == "ok" else { break } if let restartRequired = payload.object?["data"]?.object?["restart_required"]?.boolValue, let info = bridgeInfo { - let previous = info.restartRequired bridgeInfo = info.copyUpdating(restartRequired: restartRequired) - updateRestartTrigger(previous: previous, current: restartRequired) } case .bridgeHealth(let health): @@ -346,26 +342,4 @@ extension AppStore { return nil } } - - /// Bridge `restart_required` arrives as a single bool — never a reference - /// to the log line that explains *why*. To deep-link the Settings notice - /// to that log line, scan recent entries on the false → true transition - /// for the matching Z2M log message ("restart required"). False → true - /// without a matching entry leaves the id nil; the UI falls back to - /// opening Logs unfiltered. Clear on the true → false transition so - /// stale ids never deep-link to old entries. - func updateRestartTrigger(previous: Bool, current: Bool) { - guard previous != current else { return } - if current { - restartTriggerLogID = recentRestartRequiredLogID() - } else { - restartTriggerLogID = nil - } - } - - /// Most recent log entry whose message contains "restart required" - /// (case-insensitive). Searches `logEntries` newest-first. - func recentRestartRequiredLogID() -> UUID? { - logEntries.first(where: { $0.message.range(of: "restart required", options: .caseInsensitive) != nil })?.id - } } diff --git a/Shellbee/Core/Store/AppStore.swift b/Shellbee/Core/Store/AppStore.swift index 1760c2a..c7482e1 100644 --- a/Shellbee/Core/Store/AppStore.swift +++ b/Shellbee/Core/Store/AppStore.swift @@ -52,12 +52,6 @@ final class AppStore { var otaUpdates: [String: OTAUpdateStatus] = [:] var logEntries: [LogEntry] = [] var rawLogEntries: [LogEntry] = [] - /// Id of the most recent log entry that flagged the bridge as needing - /// a restart (a Z2M log line containing "restart required"). Captured - /// when `bridgeInfo.restartRequired` flips false → true so the - /// "Restart Required" notice in Settings can deep-link to the source - /// log entry on long-press. Cleared when restart_required goes false. - var restartTriggerLogID: UUID? var operationErrors: [Z2MOperationError] = [] var touchlinkDevices: [TouchlinkDevice] = [] var touchlinkScanInProgress = false diff --git a/Shellbee/Features/Settings/BridgeSettingsView.swift b/Shellbee/Features/Settings/BridgeSettingsView.swift index cec04d0..20cb49e 100644 --- a/Shellbee/Features/Settings/BridgeSettingsView.swift +++ b/Shellbee/Features/Settings/BridgeSettingsView.swift @@ -13,7 +13,6 @@ struct BridgeSettingsView: View { @State private var showingDisconnectConfirmation = false @State private var editorViewModel: ConnectionViewModel? @State private var removeConfirmation: ConnectionConfig? - @State private var logDeepLink: LogDeepLinkRequest? private var scope: BridgeScope { environment.scope(for: bridgeID) } private var session: BridgeSession? { environment.registry.session(for: bridgeID) } @@ -39,9 +38,6 @@ struct BridgeSettingsView: View { } .navigationTitle(displayName) .navigationBarTitleDisplayMode(.inline) - .navigationDestination(item: $logDeepLink) { req in - LogsView(initialEntryFilter: req.entryID.map { [$0] }) - } .sheet(item: editorBinding) { vm in NavigationStack { ConnectionEditorView(viewModel: vm, mode: .save) @@ -236,26 +232,9 @@ struct BridgeSettingsView: View { .padding(.vertical, DesignTokens.Spacing.xs) } .buttonStyle(.plain) - .contextMenu { - Button { - logDeepLink = LogDeepLinkRequest(entryID: restartTriggerLogID) - } label: { - Label("Go to Log", systemImage: "doc.text.magnifyingglass") - } - Button { - showingRestartAlert = true - } label: { - Label("Restart Now", systemImage: "arrow.clockwise") - } - } } } - private var restartTriggerLogID: UUID? { - guard let store = session?.store else { return nil } - return store.restartTriggerLogID ?? store.recentRestartRequiredLogID() - } - private func settingsLabel(title: String, systemImage: String, color: Color) -> some View { Label { Text(title) diff --git a/Shellbee/Features/Settings/SettingsView.swift b/Shellbee/Features/Settings/SettingsView.swift index ec331ca..c3af93e 100644 --- a/Shellbee/Features/Settings/SettingsView.swift +++ b/Shellbee/Features/Settings/SettingsView.swift @@ -9,7 +9,6 @@ struct SettingsView: View { /// mode to add a new saved bridge without leaving Settings. @State private var editorViewModel: ConnectionViewModel? @State private var removeConfirmation: ConnectionConfig? - @State private var logDeepLink: LogDeepLinkRequest? /// Phase 2 multi-bridge: when the user has more than one saved bridge, the /// top-level Settings page swaps to the merged layout — every per-bridge @@ -56,9 +55,6 @@ struct SettingsView: View { } } .navigationTitle("Settings") - .navigationDestination(item: $logDeepLink) { req in - LogsView(initialEntryFilter: req.entryID.map { [$0] }) - } .toolbar { ToolbarItem(placement: .topBarTrailing) { Menu { @@ -410,25 +406,8 @@ struct SettingsView: View { .padding(.vertical, DesignTokens.Spacing.xs) } .buttonStyle(.plain) - .contextMenu { - Button { - logDeepLink = LogDeepLinkRequest(entryID: restartTriggerLogID) - } label: { - Label("Go to Log", systemImage: "doc.text.magnifyingglass") - } - Button { - showingRestartAlert = true - } label: { - Label("Restart Now", systemImage: "arrow.clockwise") - } - } } } - - private var restartTriggerLogID: UUID? { - guard let store = singleBridgeScope?.session?.store else { return nil } - return store.restartTriggerLogID ?? store.recentRestartRequiredLogID() - } } // MARK: - Bridge row (multi-bridge Settings root) From 952939e87d096efc6bda54e2056f554f58478da1 Mon Sep 17 00:00:00 2001 From: tashda Date: Mon, 4 May 2026 09:15:36 +0200 Subject: [PATCH 05/19] Don't link Device Card back to itself in log detail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When LogDetailView is opened from a device's own log feed, the device hero card it renders should not navigate back to that same device — a dead-end interaction. Thread an originDeviceIEEE through both call sites and suppress the NavigationLink overlay when it matches. Fixes #89 Co-Authored-By: Claude Opus 4.7 (1M context) --- Shellbee/Features/Devices/DeviceDetailView.swift | 2 +- Shellbee/Features/Devices/DeviceLogsView.swift | 2 +- Shellbee/Features/Logs/LogDetailView.swift | 15 ++++++++++++--- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/Shellbee/Features/Devices/DeviceDetailView.swift b/Shellbee/Features/Devices/DeviceDetailView.swift index 194b53c..c16736d 100644 --- a/Shellbee/Features/Devices/DeviceDetailView.swift +++ b/Shellbee/Features/Devices/DeviceDetailView.swift @@ -247,7 +247,7 @@ struct DeviceDetailView: View { } else { ForEach(recent) { entry in NavigationLink { - LogDetailView(bridgeID: bridgeID, entry: entry) + LogDetailView(bridgeID: bridgeID, entry: entry, originDeviceIEEE: device.ieeeAddress) } label: { LogRowView(entry: entry, store: scope.store, bridgeID: bridgeID) } diff --git a/Shellbee/Features/Devices/DeviceLogsView.swift b/Shellbee/Features/Devices/DeviceLogsView.swift index 09a9bea..b6dd94c 100644 --- a/Shellbee/Features/Devices/DeviceLogsView.swift +++ b/Shellbee/Features/Devices/DeviceLogsView.swift @@ -22,7 +22,7 @@ struct DeviceLogsView: View { List { ForEach(entries) { entry in NavigationLink { - LogDetailView(bridgeID: bridgeID, entry: entry) + LogDetailView(bridgeID: bridgeID, entry: entry, originDeviceIEEE: device.ieeeAddress) } label: { LogRowView(entry: entry, store: scope.store, bridgeID: bridgeID) } diff --git a/Shellbee/Features/Logs/LogDetailView.swift b/Shellbee/Features/Logs/LogDetailView.swift index 19e6c92..3a1d3cc 100644 --- a/Shellbee/Features/Logs/LogDetailView.swift +++ b/Shellbee/Features/Logs/LogDetailView.swift @@ -8,13 +8,19 @@ struct LogDetailView: View { /// the entry resolve against the right store. let bridgeID: UUID let entry: LogEntry + /// IEEE address of the device whose log feed pushed this view, if any. + /// When set, the device hero card for that same device renders without a + /// NavigationLink — tapping it would push back to the device the user + /// just came from, which is a dead-end interaction. + private let originDeviceIEEE: String? private let doneAction: (() -> Void)? enum ViewMode { case beautiful, json } - init(bridgeID: UUID, entry: LogEntry, doneAction: (() -> Void)? = nil) { + init(bridgeID: UUID, entry: LogEntry, originDeviceIEEE: String? = nil, doneAction: (() -> Void)? = nil) { self.bridgeID = bridgeID self.entry = entry + self.originDeviceIEEE = originDeviceIEEE self.doneAction = doneAction } @@ -164,6 +170,7 @@ struct LogDetailView: View { @ViewBuilder private func singleDeviceSection(_ device: Device) -> some View { + let isOrigin = device.ieeeAddress == originDeviceIEEE Section { ZStack { DeviceCard( @@ -176,8 +183,10 @@ struct LogDetailView: View { lastSeenEnabled: (scope.store.bridgeInfo?.config?.advanced?.lastSeen ?? "disable") != "disable", displayMode: .compact ) - NavigationLink(value: DeviceRoute(bridgeID: bridgeID, device: device)) { EmptyView() } - .opacity(0) + if !isOrigin { + NavigationLink(value: DeviceRoute(bridgeID: bridgeID, device: device)) { EmptyView() } + .opacity(0) + } } .listRowInsets(EdgeInsets()) .listRowBackground(Color.clear) From 1784a414ff429904bc1d924bd4b169fdcb2e4c60 Mon Sep 17 00:00:00 2001 From: tashda Date: Mon, 4 May 2026 09:16:35 +0200 Subject: [PATCH 06/19] Move Connect button to toolbar on connect editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Save flow already lived in the navigation toolbar; the Connect flow floated over the form. Use the same confirmationAction toolbar slot for both modes — same placement, same affordance — and gate the enabled state per mode (connect: canConnect; save: also requires a real edit). Fixes #86 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Connection/ConnectionEditorView.swift | 30 ++++++------------- 1 file changed, 9 insertions(+), 21 deletions(-) diff --git a/Shellbee/Features/Connection/ConnectionEditorView.swift b/Shellbee/Features/Connection/ConnectionEditorView.swift index 00f464e..f23a74c 100644 --- a/Shellbee/Features/Connection/ConnectionEditorView.swift +++ b/Shellbee/Features/Connection/ConnectionEditorView.swift @@ -47,31 +47,14 @@ struct ConnectionEditorView: View { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } - if mode == .save { - ToolbarItem(placement: .confirmationAction) { - Button("Save") { - if viewModel.connect(using: draft) { - dismiss() - } - } - .disabled(!canSaveInToolbar) - } - } - } - .safeAreaInset(edge: .bottom) { - if mode == .connect { + ToolbarItem(placement: .confirmationAction) { Button(actionLabel) { if viewModel.connect(using: draft) { dismiss() } } - .buttonStyle(.borderedProminent) - .controlSize(.large) .fontWeight(.semibold) - .frame(maxWidth: .infinity) - .disabled(!draft.canConnect) - .padding(.horizontal, DesignTokens.Spacing.lg) - .padding(.vertical, DesignTokens.Spacing.md) + .disabled(!isActionEnabled) } } .onAppear { @@ -88,8 +71,13 @@ struct ConnectionEditorView: View { } } - private var canSaveInToolbar: Bool { - draft.canConnect && draft.normalizedForComparison() != initialDraft.normalizedForComparison() + private var isActionEnabled: Bool { + switch mode { + case .connect: + return draft.canConnect + case .save: + return draft.canConnect && draft.normalizedForComparison() != initialDraft.normalizedForComparison() + } } } From bf9cedee71e0149dd8475bee7d53c9d3ed8c1dd8 Mon Sep 17 00:00:00 2001 From: tashda Date: Mon, 4 May 2026 09:18:24 +0200 Subject: [PATCH 07/19] Don't fabricate brightness in log state snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit State-change diffs (and ON/OFF-only publishes) omit brightness when it didn't change between events. The snapshot card was reading context.brightnessPercent — which falls back to range.upperBound (100%) when brightnessValue is nil — and rendering an invented "100%" instead of the actual prior value. Require a real brightnessValue before showing the percent; otherwise show "On"/"Off". Fixes #90 Co-Authored-By: Claude Opus 4.7 (1M context) --- Shellbee/Shared/LightControl/LightControlCard.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Shellbee/Shared/LightControl/LightControlCard.swift b/Shellbee/Shared/LightControl/LightControlCard.swift index ea278c9..4804e36 100644 --- a/Shellbee/Shared/LightControl/LightControlCard.swift +++ b/Shellbee/Shared/LightControl/LightControlCard.swift @@ -191,7 +191,11 @@ struct LightControlCard: View { @ViewBuilder private var snapshotHeroValue: some View { - if context.isOn, context.brightness != nil { + // Snapshot is a frozen view of the payload at log time — never invent + // a brightness value. State-change diffs (and ON/OFF-only publishes) + // omit brightness when it didn't change, so brightnessValue is nil; + // showing brightnessPercent there would fabricate a default (100%). + if context.isOn, context.brightness != nil, context.brightnessValue != nil { HStack(alignment: .firstTextBaseline, spacing: DesignTokens.Spacing.xs) { Text("\(context.brightnessPercent)") .font(DesignTokens.Typography.heroValue) From 46ee2d3ed5da781a77ec6b810a08f2f167b42207 Mon Sep 17 00:00:00 2001 From: tashda Date: Mon, 4 May 2026 09:20:15 +0200 Subject: [PATCH 08/19] Add Logs section to Group Detail Mirror the Device Detail pattern: a Logs section at the bottom showing the most recent entries scoped to the group's friendly name, plus a "See All Logs" navigation link to a dedicated GroupLogsView with search. Fixes #92 Co-Authored-By: Claude Opus 4.7 (1M context) --- Shellbee.xcodeproj/project.pbxproj | 1 + .../Features/Groups/GroupDetailView.swift | 32 +++++++++++ Shellbee/Features/Groups/GroupLogsView.swift | 55 +++++++++++++++++++ 3 files changed, 88 insertions(+) create mode 100644 Shellbee/Features/Groups/GroupLogsView.swift diff --git a/Shellbee.xcodeproj/project.pbxproj b/Shellbee.xcodeproj/project.pbxproj index 327b31e..b483691 100644 --- a/Shellbee.xcodeproj/project.pbxproj +++ b/Shellbee.xcodeproj/project.pbxproj @@ -177,6 +177,7 @@ Features/Groups/GroupListRow.swift, Features/Groups/GroupListView.swift, Features/Groups/GroupListViewModel.swift, + Features/Groups/GroupLogsView.swift, Features/Groups/GroupMemberRow.swift, Features/Groups/GroupMembersSection.swift, Features/Groups/GroupRowView.swift, diff --git a/Shellbee/Features/Groups/GroupDetailView.swift b/Shellbee/Features/Groups/GroupDetailView.swift index a935c14..2edd4d7 100644 --- a/Shellbee/Features/Groups/GroupDetailView.swift +++ b/Shellbee/Features/Groups/GroupDetailView.swift @@ -35,6 +35,36 @@ struct GroupDetailView: View { viewModel.synthesizedState(for: currentGroup, environment: environment, bridgeID: bridgeID) } + private static let recentLogLimit = 5 + + @ViewBuilder + private var logsSection: some View { + let groupEntries = scope.store.logEntries.filter { $0.deviceName == currentGroup.friendlyName } + let recent = Array(groupEntries.prefix(Self.recentLogLimit)) + + Section("Logs") { + if groupEntries.isEmpty { + Text("No logs for this group yet") + .font(.subheadline) + .foregroundStyle(.secondary) + } else { + ForEach(recent) { entry in + NavigationLink { + LogDetailView(bridgeID: bridgeID, entry: entry) + } label: { + LogRowView(entry: entry, store: scope.store, bridgeID: bridgeID) + } + .listRowBackground(BridgeRowLeadingBar(bridgeID: bridgeID)) + } + NavigationLink { + GroupLogsView(bridgeID: bridgeID, group: currentGroup) + } label: { + Label("See All Logs", systemImage: "list.bullet") + } + } + } + } + private var groupLightContext: LightControlContext? { for member in currentGroup.members { guard let device = scope.store.devices.first(where: { $0.ieeeAddress == member.ieeeAddress }) else { continue } @@ -77,6 +107,8 @@ struct GroupDetailView: View { ) GroupScenesSection(bridgeID: bridgeID, group: currentGroup, viewModel: viewModel) + + logsSection } .contentMargins(.top, 0, for: .scrollContent) .toolbarBackground(.automatic, for: .navigationBar) diff --git a/Shellbee/Features/Groups/GroupLogsView.swift b/Shellbee/Features/Groups/GroupLogsView.swift new file mode 100644 index 0000000..2174e38 --- /dev/null +++ b/Shellbee/Features/Groups/GroupLogsView.swift @@ -0,0 +1,55 @@ +import SwiftUI + +struct GroupLogsView: View { + @Environment(AppEnvironment.self) private var environment + let bridgeID: UUID + let group: Group + @State private var searchText = "" + + private var scope: BridgeScope { environment.scope(for: bridgeID) } + + private var allGroupEntries: [LogEntry] { + scope.store.logEntries.filter { $0.deviceName == group.friendlyName } + } + + private var entries: [LogEntry] { + guard !searchText.isEmpty else { return allGroupEntries } + let q = searchText.lowercased() + return allGroupEntries.filter { $0.message.lowercased().contains(q) } + } + + var body: some View { + List { + ForEach(entries) { entry in + NavigationLink { + LogDetailView(bridgeID: bridgeID, entry: entry) + } label: { + LogRowView(entry: entry, store: scope.store, bridgeID: bridgeID) + } + .listRowBackground(BridgeRowLeadingBar(bridgeID: bridgeID)) + } + } + .listStyle(.plain) + .overlay { + if allGroupEntries.isEmpty { + ContentUnavailableView( + "No Logs", + systemImage: "doc.text.magnifyingglass", + description: Text("Log entries for \(group.friendlyName) will appear here as the bridge generates them.") + ) + } else if entries.isEmpty { + ContentUnavailableView.search(text: searchText) + } + } + .searchable(text: $searchText, prompt: "Search logs") + .navigationTitle("Logs") + .navigationBarTitleDisplayMode(.inline) + } +} + +#Preview { + NavigationStack { + GroupLogsView(bridgeID: UUID(), group: .previewWithMembers) + .environment(AppEnvironment()) + } +} From 7a855d8072d1913df3d9b3eff9828c347ad204f6 Mon Sep 17 00:00:00 2001 From: tashda Date: Mon, 4 May 2026 09:23:09 +0200 Subject: [PATCH 09/19] Restart: pop to Settings root and clear stale runtime stats Two paper-cuts after tapping Restart from a deep settings page: - The user stayed on the page they came from, with no signal that the action took. Pop back to Settings root via @Environment(\.dismiss) on ServerDetailView and BridgeSettingsView so the restart has a clear endpoint. - Home tile kept rendering pre-restart uptime / published / received counts because Z2M won't republish bridge/health until reconnect. Optimistically clear bridgeHealth and bridgeOnline in AppEnvironment.restartBridge so those stats vanish immediately; they'll repopulate on the next bridge/health publish. Routed scope.restart() through environment.restartBridge so every restart surface gets the same clear. Fixes #87 Co-Authored-By: Claude Opus 4.7 (1M context) --- Shellbee/App/AppEnvironment.swift | 10 +++++++++- Shellbee/Core/Networking/BridgeScope.swift | 6 ++++-- Shellbee/Features/Settings/BridgeSettingsView.swift | 6 +++++- Shellbee/Features/Settings/ServerDetailView.swift | 6 +++++- 4 files changed, 23 insertions(+), 5 deletions(-) diff --git a/Shellbee/App/AppEnvironment.swift b/Shellbee/App/AppEnvironment.swift index 5c4ffb3..04c0ac0 100644 --- a/Shellbee/App/AppEnvironment.swift +++ b/Shellbee/App/AppEnvironment.swift @@ -244,8 +244,16 @@ final class AppEnvironment { registry.session(for: bridgeID)?.controller.clearErrorMessage() } - /// Restart a specific bridge's Z2M instance. + /// Restart a specific bridge's Z2M instance. Optimistically clears + /// runtime stats (health, online flag) so home/settings surfaces don't + /// keep showing pre-restart uptime/message counts as if they were + /// current — Z2M won't republish those until reconnect, which can take + /// several seconds and makes the user doubt the restart actually fired. func restartBridge(_ bridgeID: UUID) { + if let store = registry.session(for: bridgeID)?.store { + store.bridgeHealth = nil + store.bridgeOnline = false + } send(bridge: bridgeID, topic: Z2MTopics.Request.restart, payload: .string("")) } diff --git a/Shellbee/Core/Networking/BridgeScope.swift b/Shellbee/Core/Networking/BridgeScope.swift index 4c17c83..1d9e28f 100644 --- a/Shellbee/Core/Networking/BridgeScope.swift +++ b/Shellbee/Core/Networking/BridgeScope.swift @@ -58,9 +58,11 @@ struct BridgeScope: Identifiable { send(topic: Z2MTopics.Request.options, payload: .object(["options": .object(options)])) } - /// Restart the scoped bridge. + /// Restart the scoped bridge. Routes through `AppEnvironment.restartBridge` + /// so the optimistic stats clear (health, online flag) fires regardless of + /// which surface initiated the restart. func restart() { - send(topic: Z2MTopics.Request.restart, payload: .string("")) + environment?.restartBridge(bridgeID) } /// Set a device's state on the scoped bridge. diff --git a/Shellbee/Features/Settings/BridgeSettingsView.swift b/Shellbee/Features/Settings/BridgeSettingsView.swift index 20cb49e..9b35fb7 100644 --- a/Shellbee/Features/Settings/BridgeSettingsView.swift +++ b/Shellbee/Features/Settings/BridgeSettingsView.swift @@ -7,6 +7,7 @@ import SwiftUI /// by `bridgeID`. struct BridgeSettingsView: View { @Environment(AppEnvironment.self) private var environment + @Environment(\.dismiss) private var dismiss let bridgeID: UUID @State private var showingRestartAlert = false @@ -55,7 +56,10 @@ struct BridgeSettingsView: View { Text("\(config.displayName) will be disconnected and removed from your saved bridges. Its auth token is deleted from the keychain.") } .alert("Restart Zigbee2MQTT?", isPresented: $showingRestartAlert) { - Button("Restart", role: .destructive) { scope.restart() } + Button("Restart", role: .destructive) { + scope.restart() + dismiss() + } Button("Cancel", role: .cancel) {} } message: { Text("Zigbee2MQTT on \(displayName) will restart. The app will reconnect automatically.") diff --git a/Shellbee/Features/Settings/ServerDetailView.swift b/Shellbee/Features/Settings/ServerDetailView.swift index 1edd301..3c12a07 100644 --- a/Shellbee/Features/Settings/ServerDetailView.swift +++ b/Shellbee/Features/Settings/ServerDetailView.swift @@ -2,6 +2,7 @@ import SwiftUI struct ServerDetailView: View { @Environment(AppEnvironment.self) private var environment + @Environment(\.dismiss) private var dismiss let bridgeID: UUID @State private var showingRestartAlert = false @@ -130,7 +131,10 @@ struct ServerDetailView: View { } } .alert("Restart Zigbee2MQTT?", isPresented: $showingRestartAlert) { - Button("Restart", role: .destructive) { scope.restart() } + Button("Restart", role: .destructive) { + scope.restart() + dismiss() + } Button("Cancel", role: .cancel) {} } message: { Text("Zigbee2MQTT will restart. The app will reconnect automatically.") From 9cfef288a9d1f4bbe489b4bb1ba526fbe379b896 Mon Sep 17 00:00:00 2001 From: tashda Date: Mon, 4 May 2026 09:27:10 +0200 Subject: [PATCH 10/19] Logs tab: register Device/Group navigation destinations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LogDetailView's hero card pushes a DeviceRoute or GroupRoute when tapped. The Devices and Groups tabs each register handlers on their own stacks, but LogsView's NavigationStack didn't — so opening a log via Settings → Logs and tapping the device/group card emitted a SwiftUI runtime warning and silently failed. Register both destinations on the LogsView stack so the link works regardless of entry surface. Co-Authored-By: Claude Opus 4.7 (1M context) --- Shellbee/Features/Logs/LogsView.swift | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/Shellbee/Features/Logs/LogsView.swift b/Shellbee/Features/Logs/LogsView.swift index 5c5bb26..f85b7e8 100644 --- a/Shellbee/Features/Logs/LogsView.swift +++ b/Shellbee/Features/Logs/LogsView.swift @@ -49,6 +49,17 @@ struct LogsView: View { .navigationDestination(item: $autoOpenedEntry) { route in LogDetailView(bridgeID: route.bridgeID, entry: route.entry) } + // LogDetailView's device/group hero card pushes these routes + // when the user taps it. Without handlers on this stack the + // links emit a runtime warning and don't navigate; the device + // and group tabs each register the same destinations on their + // own stacks. + .navigationDestination(for: DeviceRoute.self) { route in + DeviceDetailView(bridgeID: route.bridgeID, device: route.device) + } + .navigationDestination(for: GroupRoute.self) { route in + GroupDetailView(bridgeID: route.bridgeID, group: route.group) + } .minimizeSearchToolbarIfAvailable() .toolbar(.hidden, for: .tabBar) .toolbar { From 3e5b089ade05f1d0165ba2de7b2d7db2ed23ab85 Mon Sep 17 00:00:00 2001 From: tashda Date: Mon, 4 May 2026 09:31:45 +0200 Subject: [PATCH 11/19] Group state changes: log the first arrival, not just subsequent ones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The state-change diff suppressed any "empty → value" transition to avoid flooding logs with retained-state arrivals after MQTT connect. That's correct for devices, but Z2M only publishes group state on an actual change — so the very first arrival for a group IS the user's toggle and was being silently swallowed. Group Detail's Logs section then showed "No logs" until the second toggle made the first one visible. Skip the empty-previous suppression when the entity is a group so the first interaction shows up immediately. Devices still rely on the suppression to keep startup quiet. Co-Authored-By: Claude Opus 4.7 (1M context) --- Shellbee/Core/Store/AppStore+Events.swift | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/Shellbee/Core/Store/AppStore+Events.swift b/Shellbee/Core/Store/AppStore+Events.swift index d8809ea..e5ed174 100644 --- a/Shellbee/Core/Store/AppStore+Events.swift +++ b/Shellbee/Core/Store/AppStore+Events.swift @@ -162,7 +162,17 @@ extension AppStore { } case .deviceState(let name, let state): let previous = deviceStates[name] ?? [:] - if !previous.isEmpty { + // Devices: skip the empty → value transition because retained MQTT + // state floods every device on connect — logging those would + // bury real changes. Subsequent diffs are real user-visible + // changes and do log. + // + // Groups: skip the suppression. Z2M only publishes group state on + // an actual change, so the very first arrival is the user's + // first toggle and should appear in the Group Detail Logs + // section immediately. + let isGroup = groups.contains { $0.friendlyName == name } + if !previous.isEmpty || isGroup { let changes = LogMapperEngine.diff(previous, state) if !changes.isEmpty { insertLogEntry(LogMapperEngine.stateChangeEntry(device: name, changes: changes)) From cfeebe4646af2d41be8bd0705e60954988dae776 Mon Sep 17 00:00:00 2001 From: tashda Date: Mon, 4 May 2026 09:37:11 +0200 Subject: [PATCH 12/19] Format 'Failed to ping' warnings into structured fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Z2M's "Failed to ping" warnings encode device, attempt counter, ZCL command, an options JSON blob, and a nested failure reason — all jammed into one text line. Render them as a labelled summary plus structured CopyableRow fields, with the options blob as a separate grouped section. The raw text remains accessible via the existing { } toolbar toggle. Adds a small parser registry (LogMessageParser) so other recurring z2m warning shapes can be added without touching the view; unrecognized messages fall back to the original raw rendering. Fixes #93 Co-Authored-By: Claude Opus 4.7 (1M context) --- Shellbee.xcodeproj/project.pbxproj | 1 + Shellbee/Core/Log/LogMessageStructure.swift | 187 ++++++++++++++++++++ Shellbee/Features/Logs/LogDetailView.swift | 26 ++- 3 files changed, 213 insertions(+), 1 deletion(-) create mode 100644 Shellbee/Core/Log/LogMessageStructure.swift diff --git a/Shellbee.xcodeproj/project.pbxproj b/Shellbee.xcodeproj/project.pbxproj index b483691..4be5a94 100644 --- a/Shellbee.xcodeproj/project.pbxproj +++ b/Shellbee.xcodeproj/project.pbxproj @@ -90,6 +90,7 @@ Core/CrashReporting/SentryService.swift, Core/Log/LogContext.swift, Core/Log/LogMapperEngine.swift, + Core/Log/LogMessageStructure.swift, Core/Log/Z2MLogPatterns.swift, Core/Models/AppearanceMode.swift, Core/Models/BridgeBound.swift, diff --git a/Shellbee/Core/Log/LogMessageStructure.swift b/Shellbee/Core/Log/LogMessageStructure.swift new file mode 100644 index 0000000..e8fb65b --- /dev/null +++ b/Shellbee/Core/Log/LogMessageStructure.swift @@ -0,0 +1,187 @@ +import Foundation + +/// Structured rendering shape for log messages whose raw text encodes +/// distinct fields (e.g. Z2M's `Failed to ping ...` warnings carry a +/// device id, attempt counter, ZCL command, options blob, and a nested +/// failure reason). LogDetailView renders this as labelled rows under +/// the existing message section, falling back to raw text when no +/// parser matches. +nonisolated struct LogMessageStructure: Sendable { + let summary: String + let fields: [Field] + /// Optional grouped sub-rows rendered under their own header (e.g. + /// the request `options` map on a ping failure). + let groups: [Group] + + struct Field: Identifiable, Sendable { + let id: UUID = UUID() + let label: String + let value: String + } + + struct Group: Identifiable, Sendable { + let id: UUID = UUID() + let title: String + let fields: [Field] + } + + init(summary: String, fields: [Field] = [], groups: [Group] = []) { + self.summary = summary + self.fields = fields + self.groups = groups + } +} + +/// Tiny parser registry — each entry inspects an entry's message and +/// returns a `LogMessageStructure` if it recognizes the shape. Add new +/// shapes by appending a parser; LogDetailView falls back to its raw +/// rendering when none match. +nonisolated enum LogMessageParser { + typealias Parse = @Sendable (_ message: String) -> LogMessageStructure? + + static let parsers: [Parse] = [ + parsePingFailure + ] + + static func structure(for message: String) -> LogMessageStructure? { + for parser in parsers { + if let result = parser(message) { return result } + } + return nil + } + + // MARK: - Ping failure + // + // Z2M emits warnings of the form: + // Failed to ping '' (attempt /, ZCL command / , + // ) failed () + // + // We pull each piece out by hand rather than with one giant regex so a + // shape-shift in any field degrades to "no match" (and the raw fallback) + // instead of producing a wrong-looking parse. + @Sendable private static func parsePingFailure(_ message: String) -> LogMessageStructure? { + let trimmed = stripNamespace(message) + guard trimmed.hasPrefix("Failed to ping ") else { return nil } + + // Device name in single quotes after the prefix. + guard let nameRange = trimmed.range(of: "'([^']+)'", options: .regularExpression) else { + return nil + } + let device = String(trimmed[nameRange]).trimmingCharacters(in: ["'"]) + + // Top-level args inside the first paren after the device name. + let afterName = trimmed[nameRange.upperBound...] + guard let openParen = afterName.firstIndex(of: "(") else { return nil } + let argsStart = afterName.index(after: openParen) + guard let closeParen = matchingCloseParen(in: afterName, openIndexAfter: openParen) else { + return nil + } + let args = String(afterName[argsStart..)". + let tail = String(afterName[afterName.index(after: closeParen)...]) + + var fields: [LogMessageStructure.Field] = [] + + if let attempt = match(args, pattern: #"attempt\s+(\d+\s*/\s*\d+)"#) { + fields.append(.init(label: "Attempt", value: attempt)) + } + + if let zcl = match(args, pattern: #"ZCL command\s+([^,]+?)(?=,\s*\{|$)"#) { + fields.append(.init(label: "ZCL Command", value: zcl.trimmingCharacters(in: .whitespaces))) + } + + if let reason = match(tail, pattern: #"\(([^)]+?)\.?\)"#) { + fields.append(.init(label: "Reason", value: reason)) + } + + // Options JSON object — anything between the first `{` and its + // matching `}` inside args. JSONSerialization for safety. + var groups: [LogMessageStructure.Group] = [] + if let jsonStart = args.firstIndex(of: "{"), + let jsonEnd = matchingCloseBrace(in: args, openIndex: jsonStart) { + let json = String(args[jsonStart...jsonEnd]) + if let data = json.data(using: .utf8), + let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + let optionFields = dict.keys.sorted().map { key -> LogMessageStructure.Field in + let value = dict[key].map { stringify($0) } ?? "" + return .init(label: key, value: value) + } + if !optionFields.isEmpty { + groups.append(.init(title: "Options", fields: optionFields)) + } + } + } + + return LogMessageStructure( + summary: "Failed to ping '\(device)'", + fields: fields, + groups: groups + ) + } + + // MARK: - helpers + + private static func stripNamespace(_ text: String) -> String { + guard text.hasPrefix("z2m:") else { return text } + if let sp = text.range(of: " ") { + return String(text[sp.upperBound...]) + } + return text + } + + private static func match(_ text: String, pattern: String) -> String? { + guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil } + let range = NSRange(text.startIndex..., in: text) + guard let m = regex.firstMatch(in: text, range: range), m.numberOfRanges >= 2, + let r = Range(m.range(at: 1), in: text) else { return nil } + return String(text[r]) + } + + /// Find the index of the `)` that pairs with the `(` immediately preceding + /// `openIndexAfter`. Returns nil if the parens are unbalanced. + private static func matchingCloseParen(in text: Substring, openIndexAfter open: Substring.Index) -> Substring.Index? { + var depth = 1 + var i = text.index(after: open) + while i < text.endIndex { + switch text[i] { + case "(": depth += 1 + case ")": + depth -= 1 + if depth == 0 { return i } + default: break + } + i = text.index(after: i) + } + return nil + } + + private static func matchingCloseBrace(in text: String, openIndex: String.Index) -> String.Index? { + var depth = 0 + var i = openIndex + while i < text.endIndex { + switch text[i] { + case "{": depth += 1 + case "}": + depth -= 1 + if depth == 0 { return i } + default: break + } + i = text.index(after: i) + } + return nil + } + + private static func stringify(_ value: Any) -> String { + switch value { + case let b as Bool: return b ? "true" : "false" + case let n as NSNumber: + // Bool comes through as NSNumber; the `as Bool` cast above wins + // for true booleans, so anything reaching here is numeric. + return n.stringValue + case let s as String: return s + case let arr as [Any]: return arr.map { stringify($0) }.joined(separator: ", ") + default: return String(describing: value) + } + } +} diff --git a/Shellbee/Features/Logs/LogDetailView.swift b/Shellbee/Features/Logs/LogDetailView.swift index 3a1d3cc..fd0a690 100644 --- a/Shellbee/Features/Logs/LogDetailView.swift +++ b/Shellbee/Features/Logs/LogDetailView.swift @@ -260,7 +260,31 @@ struct LogDetailView: View { BeautifulPayloadView(payload: payload, device: displayDevices.first?.device) } if changes.isEmpty && payload.isEmpty { - messageSection + if let structure = LogMessageParser.structure(for: entry.message) { + structuredMessageSections(structure) + } else { + messageSection + } + } + } + + @ViewBuilder + private func structuredMessageSections(_ structure: LogMessageStructure) -> some View { + Section(sectionTitle) { + Text(structure.summary) + .font(.callout) + .textSelection(.enabled) + .padding(.vertical, DesignTokens.Spacing.xs) + ForEach(structure.fields) { field in + CopyableRow(label: field.label, value: field.value) + } + } + ForEach(structure.groups) { group in + Section(group.title) { + ForEach(group.fields) { field in + CopyableRow(label: field.label, value: field.value) + } + } } } From d1fba1fa6f6859f960d57806159db4f76b87c710 Mon Sep 17 00:00:00 2001 From: tashda Date: Mon, 4 May 2026 09:40:15 +0200 Subject: [PATCH 13/19] Log Detail: render full Light Card from state-change payload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Synthesized state-change entries built from `bridge/state` events used to carry only the diff — so the snapshot card collapsed to the single changed property even when the payload had brightness, color_temp, and color all present. Capture the full state on `LogContext.payload` at mapping time and prefer it in `logTimeState` so the device hero card renders the complete light state at log time. Diff-only fallback preserved for older entries. For groups with light-shaped payloads, also render the same Light Card (snapshot mode) keyed off a light member device. Non-light payloads still fall through to the generic field breakdown. Fixes #94 #95 Co-Authored-By: Claude Opus 4.7 (1M context) --- Shellbee/Core/Log/LogContext.swift | 17 +++++++++ Shellbee/Core/Log/LogMapperEngine.swift | 9 +++-- Shellbee/Core/Store/AppStore+Events.swift | 2 +- Shellbee/Features/Logs/LogDetailView.swift | 41 +++++++++++++++++++--- 4 files changed, 61 insertions(+), 8 deletions(-) diff --git a/Shellbee/Core/Log/LogContext.swift b/Shellbee/Core/Log/LogContext.swift index 0a0508b..b560a1c 100644 --- a/Shellbee/Core/Log/LogContext.swift +++ b/Shellbee/Core/Log/LogContext.swift @@ -4,10 +4,27 @@ struct LogContext: Sendable { let devices: [DeviceRef] let stateChanges: [StateChange] let action: LogAction + /// Full state snapshot at the moment the log entry was emitted. Set on + /// synthesized state-change entries so LogDetailView can populate the + /// hero card with every relevant field (brightness, color, color_temp, + /// …) instead of only the changed properties. + let payload: [String: JSONValue]? var primaryDevice: DeviceRef? { devices.first } var hasMultipleDevices: Bool { devices.count > 1 } + init( + devices: [DeviceRef], + stateChanges: [StateChange], + action: LogAction, + payload: [String: JSONValue]? = nil + ) { + self.devices = devices + self.stateChanges = stateChanges + self.action = action + self.payload = payload + } + var inferredCategory: LogCategory { if case .stateChange = action { return .stateChange } return .general diff --git a/Shellbee/Core/Log/LogMapperEngine.swift b/Shellbee/Core/Log/LogMapperEngine.swift index 0d29a55..830b85f 100644 --- a/Shellbee/Core/Log/LogMapperEngine.swift +++ b/Shellbee/Core/Log/LogMapperEngine.swift @@ -116,11 +116,16 @@ struct LogMapperEngine { return changes } - static func stateChangeEntry(device: String, changes: [LogContext.StateChange]) -> LogEntry { + static func stateChangeEntry( + device: String, + changes: [LogContext.StateChange], + payload: [String: JSONValue]? = nil + ) -> LogEntry { let ctx = LogContext( devices: [.init(friendlyName: device, role: .subject)], stateChanges: changes, - action: .stateChange + action: .stateChange, + payload: payload ) let summary = changes.prefix(2).map(\.shortDescription).joined(separator: " · ") return LogEntry( diff --git a/Shellbee/Core/Store/AppStore+Events.swift b/Shellbee/Core/Store/AppStore+Events.swift index e5ed174..4491468 100644 --- a/Shellbee/Core/Store/AppStore+Events.swift +++ b/Shellbee/Core/Store/AppStore+Events.swift @@ -175,7 +175,7 @@ extension AppStore { if !previous.isEmpty || isGroup { let changes = LogMapperEngine.diff(previous, state) if !changes.isEmpty { - insertLogEntry(LogMapperEngine.stateChangeEntry(device: name, changes: changes)) + insertLogEntry(LogMapperEngine.stateChangeEntry(device: name, changes: changes, payload: state)) } } deviceStates[name] = state diff --git a/Shellbee/Features/Logs/LogDetailView.swift b/Shellbee/Features/Logs/LogDetailView.swift index fd0a690..f37296e 100644 --- a/Shellbee/Features/Logs/LogDetailView.swift +++ b/Shellbee/Features/Logs/LogDetailView.swift @@ -55,12 +55,22 @@ struct LogDetailView: View { if case .mqttPublish(_, _, let payload) = entry.parsedMessageKind { return payload.isEmpty ? nil : payload } - if entry.category == .stateChange, let changes = entry.context?.stateChanges { - var state: [String: JSONValue] = [:] - for change in changes where !Self.stateMetadataKeys.contains(change.property) { - state[change.property] = change.to + if entry.category == .stateChange { + // Prefer the full state captured at log time when available — the + // diff alone drops every unchanged field, which collapses the + // Light Card to a single property even when the payload had + // brightness/color_temp/color present. Fall back to the diff + // for older entries that don't carry a payload. + if let payload = entry.context?.payload, !payload.isEmpty { + return payload + } + if let changes = entry.context?.stateChanges { + var state: [String: JSONValue] = [:] + for change in changes where !Self.stateMetadataKeys.contains(change.property) { + state[change.property] = change.to + } + return state.isEmpty ? nil : state } - return state.isEmpty ? nil : state } return nil } @@ -166,6 +176,27 @@ struct LogDetailView: View { .listRowInsets(EdgeInsets()) .listRowBackground(Color.clear) } + if let (member, snapshotState) = lightLikeMemberAndState(in: members) { + Section { + ExposeCardView(device: member, state: snapshotState, mode: .snapshot) + .listRowInsets(EdgeInsets()) + .listRowBackground(Color.clear) + } + } + } + + /// For a group log entry whose payload looks like a light state + /// (`state` plus at least one of brightness/color_temp/color), pick a + /// light member device to drive the snapshot Light Card. Returns nil + /// when the payload isn't light-shaped or no light member exists — + /// callers fall through to the generic field breakdown. + private func lightLikeMemberAndState(in members: [Device]) -> (Device, [String: JSONValue])? { + guard let payload = logTimeState else { return nil } + let lightKeys: Set = ["brightness", "color_temp", "color", "color_xy", "color_hs"] + let hasLightShape = payload["state"] != nil && payload.keys.contains(where: { lightKeys.contains($0) }) + guard hasLightShape else { return nil } + guard let member = members.first(where: { $0.category == .light }) else { return nil } + return (member, payload) } @ViewBuilder From eefa0216e81a2df0e14c1ba1708054afeda518cc Mon Sep 17 00:00:00 2001 From: tashda Date: Mon, 4 May 2026 09:43:17 +0200 Subject: [PATCH 14/19] Log sheet: register Device/Group destinations so card taps navigate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tapping the device or group hero card from a log opened via Home → Recent Events (or a notification) silently failed. The LogSheetHost sheet wraps LogDetailView in its own NavigationStack but never registered handlers for DeviceRoute / GroupRoute, so the push emitted a runtime warning and the user saw the sheet sit unresponsive on top of Home — visually as if the tap "navigated to homepage". Wire the same destinations the in-tab Logs stack already registers; covers both the single-entry and notificationSheetStyle (multi-entry) branches. Co-Authored-By: Claude Opus 4.7 (1M context) --- Shellbee/App/MainTabView.swift | 6 ++++++ Shellbee/Features/Logs/LogsView.swift | 9 +++++++++ 2 files changed, 15 insertions(+) diff --git a/Shellbee/App/MainTabView.swift b/Shellbee/App/MainTabView.swift index 32bef41..3277b04 100644 --- a/Shellbee/App/MainTabView.swift +++ b/Shellbee/App/MainTabView.swift @@ -109,6 +109,12 @@ private struct LogSheetHost: View { if let (bridgeID, entry) = singleResolved { NavigationStack { LogDetailView(bridgeID: bridgeID, entry: entry, doneAction: { dismiss() }) + .navigationDestination(for: DeviceRoute.self) { route in + DeviceDetailView(bridgeID: route.bridgeID, device: route.device) + } + .navigationDestination(for: GroupRoute.self) { route in + GroupDetailView(bridgeID: route.bridgeID, group: route.group) + } } } else { LogsView( diff --git a/Shellbee/Features/Logs/LogsView.swift b/Shellbee/Features/Logs/LogsView.swift index f85b7e8..af9c486 100644 --- a/Shellbee/Features/Logs/LogsView.swift +++ b/Shellbee/Features/Logs/LogsView.swift @@ -32,6 +32,15 @@ struct LogsView: View { .navigationTitle("Logs") .navigationBarTitleDisplayMode(.inline) .onAppear { applyInitialFilter(autoOpenSingle: false) } + // Mirror the in-tab Logs stack so log detail's device / + // group hero card pushes within the sheet instead of + // emitting a runtime warning and silently failing. + .navigationDestination(for: DeviceRoute.self) { route in + DeviceDetailView(bridgeID: route.bridgeID, device: route.device) + } + .navigationDestination(for: GroupRoute.self) { route in + GroupDetailView(bridgeID: route.bridgeID, group: route.group) + } .toolbar { if let onDone { ToolbarItem(placement: .confirmationAction) { From ccec11106a600905618b1d9ef2211b0c7c1a5403 Mon Sep 17 00:00:00 2001 From: tashda Date: Mon, 4 May 2026 09:47:11 +0200 Subject: [PATCH 15/19] Multi-bridge Bridges row matches single-bridge Connection card Replace the status-dot + URL row in the multi-bridge Bridges section with the same BridgeConnectionCardLabel the single-bridge Connection card uses. Same tinted bridge-color icon, same display name, status text in place of the URL. Restart-required badge and connect toggle preserved; status dot dropped (the colored icon + status text already communicate connection state). Fixes #96 Co-Authored-By: Claude Opus 4.7 (1M context) --- Shellbee/Features/Settings/SettingsView.swift | 55 +++++-------------- 1 file changed, 15 insertions(+), 40 deletions(-) diff --git a/Shellbee/Features/Settings/SettingsView.swift b/Shellbee/Features/Settings/SettingsView.swift index c3af93e..6863cb0 100644 --- a/Shellbee/Features/Settings/SettingsView.swift +++ b/Shellbee/Features/Settings/SettingsView.swift @@ -412,11 +412,12 @@ struct SettingsView: View { // MARK: - Bridge row (multi-bridge Settings root) -/// Rich row for the Bridges section: color dot, name, URL, live status -/// subtitle, default star, restart-required indicator, and a Connect / -/// Disconnect toggle. The whole row is a NavigationLink to that bridge's -/// settings page; the Toggle is its own hit-target inside the link, so -/// tapping the toggle never accidentally drills in. +/// Bridges-section row, styled to match the single-bridge Connection +/// card via `BridgeConnectionCardLabel`: tinted bridge-color icon, +/// display name, and live status subtitle. The whole row is a +/// NavigationLink to that bridge's settings page; the Toggle is its own +/// hit-target inside the link, so tapping the toggle never accidentally +/// drills in. private struct BridgeSettingsRow: View { let config: ConnectionConfig let onEdit: () -> Void @@ -443,27 +444,16 @@ private struct BridgeSettingsRow: View { BridgeSettingsView(bridgeID: config.id) } label: { HStack(spacing: DesignTokens.Spacing.md) { - statusDot - VStack(alignment: .leading, spacing: 2) { - HStack(spacing: DesignTokens.Spacing.xs) { - Text(config.displayName) - .font(.body) - .foregroundStyle(.primary) - if restartRequired { - Image(systemName: "exclamationmark.triangle.fill") - .font(.caption2) - .foregroundStyle(.red) - .accessibilityLabel("Restart required") - } - } - Text(config.displayURL) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - Text(stateLabel) + BridgeConnectionCardLabel( + bridgeID: config.id, + displayName: config.displayName, + statusSubtitle: stateLabel + ) + if restartRequired { + Image(systemName: "exclamationmark.triangle.fill") .font(.caption2) - .foregroundStyle(.secondary) - .lineLimit(1) + .foregroundStyle(.red) + .accessibilityLabel("Restart required") } Spacer() connectToggle @@ -489,21 +479,6 @@ private struct BridgeSettingsRow: View { } } - @ViewBuilder - private var statusDot: some View { - let color: Color = { - if isConnected { return .green } - if isConnecting { return .orange } - switch session?.connectionState { - case .failed, .lost: return .red - default: return Color(.systemGray3) - } - }() - Circle() - .fill(color) - .frame(width: DesignTokens.Size.statusDot, height: DesignTokens.Size.statusDot) - } - private var connectToggle: some View { let isOn = Binding( get: { isConnected || isConnecting }, From bb92c8585f54e17a8ac1da3bb923a3c0f43296a5 Mon Sep 17 00:00:00 2001 From: tashda Date: Mon, 4 May 2026 09:49:11 +0200 Subject: [PATCH 16/19] Remove redundant color swatch from snapshot info row The small filled circle next to the snapshot value (e.g. next to "2202 K" on the color-temperature row) duplicated information already conveyed by the card's eyebrow tint and the ON badge. Drop it; the gradient and state pill carry the cue. Co-Authored-By: Claude Opus 4.7 (1M context) --- Shellbee/Shared/LightControl/LightControlCard.swift | 5 ----- 1 file changed, 5 deletions(-) diff --git a/Shellbee/Shared/LightControl/LightControlCard.swift b/Shellbee/Shared/LightControl/LightControlCard.swift index 4804e36..24f32cf 100644 --- a/Shellbee/Shared/LightControl/LightControlCard.swift +++ b/Shellbee/Shared/LightControl/LightControlCard.swift @@ -291,11 +291,6 @@ struct LightControlCard: View { Text(unit) .font(DesignTokens.Typography.snapshotRowUnit) .foregroundStyle(.secondary) - Circle() - .fill(context.displayColor) - .frame(width: DesignTokens.Size.summaryRowSymbol, height: DesignTokens.Size.summaryRowSymbol) - .overlay(Circle().stroke(.separator, lineWidth: DesignTokens.Size.badgeStroke)) - .padding(.leading, DesignTokens.Spacing.xs) } } } From 01d04a149d5f7656237477ff3b20a78703fd3df9 Mon Sep 17 00:00:00 2001 From: tashda Date: Mon, 4 May 2026 09:53:16 +0200 Subject: [PATCH 17/19] Light Card: render real color even when color_mode lies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two intertwined bugs landed the snapshot card on the color-temperature surface during a green color change: - The card compared color_mode against "color_xy"/"color_hs", but Z2M publishes the field as "xy"/"hs"/"color_temp" (no "color_" prefix). Verified against Z2M's own publish.test.ts. The check never matched so any real color change fell through to the temperature branch. - Hue lights with hue_native_control enabled publish color_mode set to "color_temp" alongside a fresh color object during color changes — the bulb reports the equivalent CT and the renderable color in the same payload. Trusting color_mode there shows "Color Temperature 6535 K" with a peach tint while the bulb is rendering green. Replace literal string comparisons with a new LightControlContext.isColorMode that prefers a recognizable color object (x/y, hue/saturation, h/s, or r/g/b) over the color_mode signal. LightDisplayColor.resolve gets the same priority swap so the eyebrow tint follows the actual color. hasColorTemperatureReading is now suppressed when isColorMode wins so the snapshot row matches the card's surface. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../LightControl/LightControlCard.swift | 4 +- .../LightControl/LightControlContext.swift | 43 ++++++++++++++++++- .../LightControl/LightDisplayColor.swift | 17 ++++++-- 3 files changed, 57 insertions(+), 7 deletions(-) diff --git a/Shellbee/Shared/LightControl/LightControlCard.swift b/Shellbee/Shared/LightControl/LightControlCard.swift index 24f32cf..34dfdfe 100644 --- a/Shellbee/Shared/LightControl/LightControlCard.swift +++ b/Shellbee/Shared/LightControl/LightControlCard.swift @@ -234,12 +234,12 @@ struct LightControlCard: View { } private var hasColorOrTempInfo: Bool { - let isColorMode = context.colorMode == "color_xy" || context.colorMode == "color_hs" + let isColorMode = context.isColorMode return isColorMode || context.colorTemperatureValue != nil } @ViewBuilder private var colorSnapshotRow: some View { - let isColorMode = context.colorMode == "color_xy" || context.colorMode == "color_hs" + let isColorMode = context.isColorMode if !isColorMode, let tempMireds = context.colorTemperatureValue { snapshotInfoRow( icon: "thermometer.medium", diff --git a/Shellbee/Shared/LightControl/LightControlContext.swift b/Shellbee/Shared/LightControl/LightControlContext.swift index 5011aa6..d470e7c 100644 --- a/Shellbee/Shared/LightControl/LightControlContext.swift +++ b/Shellbee/Shared/LightControl/LightControlContext.swift @@ -17,9 +17,48 @@ struct LightControlContext: Equatable, Identifiable { let brightnessValue: Double? let colorTemperatureValue: Double? let colorMode: String? + /// Raw `color` object payload (if any). Stored so `isColorMode` can fall + /// back to "is there a real color value" when `color_mode` lies — a known + /// failure mode on Hue lights with `hue_native_control` enabled, where + /// the bulb reports the equivalent color temperature alongside a fresh + /// color object during color changes. + let colorPayload: JSONValue? let displayColor: Color let endpointLabel: String? + /// True when the snapshot should render the color surface (Color row, + /// Color picker, color tint) rather than the color-temperature surface. + /// Authoritative signal in priority order: + /// 1. `color_mode` is explicitly `"xy"` or `"hs"` (z2m's spec values). + /// 2. The `color` payload carries recognizable sub-fields (x/y, h/s, + /// hue/saturation, or r/g/b) — wins even when `color_mode` says + /// `"color_temp"`, because some Hue firmware paths report stale + /// mode while the bulb is actually rendering a color. + var isColorMode: Bool { + if colorMode == "xy" || colorMode == "hs" { return true } + return colorPayload.flatMap(Self.colorObjectIsRecognizable) ?? false + } + + /// True when a usable color-temperature reading exists. Suppressed when + /// `isColorMode` already won — Hue native control publishes a stale + /// `color_temp` alongside a real color and we don't want the snapshot + /// to claim "Color Temperature 6535 K" while the bulb is rendering green. + var hasColorTemperatureReading: Bool { + guard colorTemperatureValue != nil else { return false } + return !isColorMode + } + + private static func colorObjectIsRecognizable(_ value: JSONValue) -> Bool { + guard let object = value.object else { return false } + let xy = object["x"]?.numberValue != nil && object["y"]?.numberValue != nil + let hs = (object["hue"]?.numberValue ?? object["h"]?.numberValue) != nil + && (object["saturation"]?.numberValue ?? object["s"]?.numberValue) != nil + let rgb = object["r"]?.numberValue != nil + && object["g"]?.numberValue != nil + && object["b"]?.numberValue != nil + return xy || hs || rgb + } + var id: String { power?.property ?? brightness?.property ?? endpointLabel ?? "light" } var supportsWhiteControls: Bool { colorTemperature != nil } @@ -97,8 +136,10 @@ struct LightControlContext: Equatable, Identifiable { self.brightnessValue = brightnessValue self.colorTemperatureValue = colorTemperatureValue self.colorMode = colorMode + let colorPayload = state[color?.property ?? "color"] + self.colorPayload = colorPayload self.displayColor = LightDisplayColor.resolve( - colorValue: state[color?.property ?? "color"], + colorValue: colorPayload, colorTemperature: colorTemperatureValue, colorMode: colorMode ) diff --git a/Shellbee/Shared/LightControl/LightDisplayColor.swift b/Shellbee/Shared/LightControl/LightDisplayColor.swift index 084bbdb..75180bd 100644 --- a/Shellbee/Shared/LightControl/LightDisplayColor.swift +++ b/Shellbee/Shared/LightControl/LightDisplayColor.swift @@ -2,10 +2,13 @@ import SwiftUI enum LightDisplayColor { static func resolve(colorValue: JSONValue?, colorTemperature: Double?, colorMode: String?) -> Color { - if colorMode == "color_temp", let colorTemperature { - return temperatureColor(mireds: colorTemperature) - } - + // If the payload carries a real color object (xy / hs / rgb), prefer + // it over color_mode-derived hints. Hue lights with hue_native_control + // enabled publish color_mode="color_temp" alongside a fresh color + // payload during color changes; trusting the mode signal there + // renders a peach-tinted card while the bulb is actually rendering + // green. Color-only lights without color_temp obviously also belong + // here, regardless of any stale color_mode value. if let color = colorValue?.object { if let x = color["x"]?.numberValue, let y = color["y"]?.numberValue { return xyColor(x: x, y: y) @@ -26,6 +29,12 @@ enum LightDisplayColor { return temperatureColor(mireds: colorTemperature) } + // colorMode is currently unused — the color object check above is the + // dominant signal and the temperature fallback covers the rest. + // Keep the parameter so callers stay symmetric with z2m's payload + // shape if we need to gate on it again later. + _ = colorMode + return .accentColor } From 3a5982b4c7383be2bd895b33e639cbcbbd42c196 Mon Sep 17 00:00:00 2001 From: tashda Date: Mon, 4 May 2026 10:01:40 +0200 Subject: [PATCH 18/19] Light Card snapshot: include nested color, trust color_mode, drop swatches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three intertwined fixes for the Log Detail Light Card surface: - exposesScopedState filtered the payload by `flattenedLeaves` of the device's exposes. For the color_xy / color_hs feature whose `property` resolves to "color", that strips the entire nested color object from the state passed to the snapshot card — the leaves are `x` / `y`, but z2m publishes the value at the parent key. Use `flattened` so the parent property survives the filter and the color object reaches LightControlContext intact. This is why every color change still rendered as Color Temperature regardless of any earlier mode detection work. - isColorMode now trusts `color_mode` authoritatively (`"xy"` or `"hs"`). The earlier override that preferred a recognizable color object misread the OFF group case where Hue publishes a stale color object alongside `color_mode: "color_temp"` — the snapshot then wrongly painted a Color row. LightDisplayColor.resolve restored to matching: color_mode == "color_temp" wins for tint, color object next, temperature fallback last. - Drop the redundant color swatch from the COLOR snapshot row to match the earlier removal on the COLOR TEMPERATURE row. The eyebrow tint and ON pill already convey state. Co-Authored-By: Claude Opus 4.7 (1M context) --- Shellbee/Features/Logs/LogDetailView.swift | 10 +++- .../LightControl/LightControlCard.swift | 4 -- .../LightControl/LightControlContext.swift | 48 ++++--------------- .../LightControl/LightDisplayColor.swift | 22 ++++----- 4 files changed, 27 insertions(+), 57 deletions(-) diff --git a/Shellbee/Features/Logs/LogDetailView.swift b/Shellbee/Features/Logs/LogDetailView.swift index f37296e..21c9c21 100644 --- a/Shellbee/Features/Logs/LogDetailView.swift +++ b/Shellbee/Features/Logs/LogDetailView.swift @@ -238,8 +238,16 @@ struct LogDetailView: View { /// match exposes and we render the relevant control card with those values. private func exposesScopedState(for device: Device) -> [String: JSONValue]? { guard let state = logTimeState else { return nil } + // Use `flattened` (every node, parents + leaves) rather than + // `flattenedLeaves`. Z2M publishes nested features (notably the + // `color_xy` / `color_hs` parents whose `property` resolves to + // `"color"`) as a single object under the parent key — not as + // separate top-level `x` / `y` keys. Filtering by leaves alone + // dropped the entire color object, which is why the snapshot + // Light Card never rendered the color surface even when the + // payload carried a perfectly valid `color: {x, y}`. let exposeProps: Set = Set( - (device.definition?.exposes ?? []).flattenedLeaves.compactMap { + (device.definition?.exposes ?? []).flattened.compactMap { $0.property ?? $0.name } ) diff --git a/Shellbee/Shared/LightControl/LightControlCard.swift b/Shellbee/Shared/LightControl/LightControlCard.swift index 34dfdfe..c9f2ae3 100644 --- a/Shellbee/Shared/LightControl/LightControlCard.swift +++ b/Shellbee/Shared/LightControl/LightControlCard.swift @@ -260,10 +260,6 @@ struct LightControlCard: View { } .foregroundStyle(.secondary) Spacer() - Circle() - .fill(context.displayColor) - .frame(width: DesignTokens.Size.cardSymbol, height: DesignTokens.Size.cardSymbol) - .overlay(Circle().stroke(.separator, lineWidth: DesignTokens.Size.badgeStroke)) } } } diff --git a/Shellbee/Shared/LightControl/LightControlContext.swift b/Shellbee/Shared/LightControl/LightControlContext.swift index d470e7c..081eb4e 100644 --- a/Shellbee/Shared/LightControl/LightControlContext.swift +++ b/Shellbee/Shared/LightControl/LightControlContext.swift @@ -17,46 +17,18 @@ struct LightControlContext: Equatable, Identifiable { let brightnessValue: Double? let colorTemperatureValue: Double? let colorMode: String? - /// Raw `color` object payload (if any). Stored so `isColorMode` can fall - /// back to "is there a real color value" when `color_mode` lies — a known - /// failure mode on Hue lights with `hue_native_control` enabled, where - /// the bulb reports the equivalent color temperature alongside a fresh - /// color object during color changes. - let colorPayload: JSONValue? let displayColor: Color let endpointLabel: String? - /// True when the snapshot should render the color surface (Color row, - /// Color picker, color tint) rather than the color-temperature surface. - /// Authoritative signal in priority order: - /// 1. `color_mode` is explicitly `"xy"` or `"hs"` (z2m's spec values). - /// 2. The `color` payload carries recognizable sub-fields (x/y, h/s, - /// hue/saturation, or r/g/b) — wins even when `color_mode` says - /// `"color_temp"`, because some Hue firmware paths report stale - /// mode while the bulb is actually rendering a color. + /// True when the snapshot should render the color surface rather than + /// the color-temperature surface. Z2M sets `color_mode` to one of + /// `"xy"`, `"hs"`, or `"color_temp"` deliberately to describe what the + /// bulb is actively rendering — trust it. (The OFF state of a Hue + /// group, for instance, can carry a stale `color` object alongside + /// `color_mode: "color_temp"`; treating that as a color render + /// mis-reports the surface.) var isColorMode: Bool { - if colorMode == "xy" || colorMode == "hs" { return true } - return colorPayload.flatMap(Self.colorObjectIsRecognizable) ?? false - } - - /// True when a usable color-temperature reading exists. Suppressed when - /// `isColorMode` already won — Hue native control publishes a stale - /// `color_temp` alongside a real color and we don't want the snapshot - /// to claim "Color Temperature 6535 K" while the bulb is rendering green. - var hasColorTemperatureReading: Bool { - guard colorTemperatureValue != nil else { return false } - return !isColorMode - } - - private static func colorObjectIsRecognizable(_ value: JSONValue) -> Bool { - guard let object = value.object else { return false } - let xy = object["x"]?.numberValue != nil && object["y"]?.numberValue != nil - let hs = (object["hue"]?.numberValue ?? object["h"]?.numberValue) != nil - && (object["saturation"]?.numberValue ?? object["s"]?.numberValue) != nil - let rgb = object["r"]?.numberValue != nil - && object["g"]?.numberValue != nil - && object["b"]?.numberValue != nil - return xy || hs || rgb + colorMode == "xy" || colorMode == "hs" } var id: String { power?.property ?? brightness?.property ?? endpointLabel ?? "light" } @@ -136,10 +108,8 @@ struct LightControlContext: Equatable, Identifiable { self.brightnessValue = brightnessValue self.colorTemperatureValue = colorTemperatureValue self.colorMode = colorMode - let colorPayload = state[color?.property ?? "color"] - self.colorPayload = colorPayload self.displayColor = LightDisplayColor.resolve( - colorValue: colorPayload, + colorValue: state[color?.property ?? "color"], colorTemperature: colorTemperatureValue, colorMode: colorMode ) diff --git a/Shellbee/Shared/LightControl/LightDisplayColor.swift b/Shellbee/Shared/LightControl/LightDisplayColor.swift index 75180bd..7c32468 100644 --- a/Shellbee/Shared/LightControl/LightDisplayColor.swift +++ b/Shellbee/Shared/LightControl/LightDisplayColor.swift @@ -2,13 +2,15 @@ import SwiftUI enum LightDisplayColor { static func resolve(colorValue: JSONValue?, colorTemperature: Double?, colorMode: String?) -> Color { - // If the payload carries a real color object (xy / hs / rgb), prefer - // it over color_mode-derived hints. Hue lights with hue_native_control - // enabled publish color_mode="color_temp" alongside a fresh color - // payload during color changes; trusting the mode signal there - // renders a peach-tinted card while the bulb is actually rendering - // green. Color-only lights without color_temp obviously also belong - // here, regardless of any stale color_mode value. + // Trust color_mode as the authoritative signal — z2m sets it + // deliberately to one of "xy", "hs", or "color_temp" to describe + // what the bulb is actively rendering. A bulb in color_temp mode + // can also publish a stale color object (notably Hue with + // hue_native_control), but the true output is the temperature. + if colorMode == "color_temp", let colorTemperature { + return temperatureColor(mireds: colorTemperature) + } + if let color = colorValue?.object { if let x = color["x"]?.numberValue, let y = color["y"]?.numberValue { return xyColor(x: x, y: y) @@ -29,12 +31,6 @@ enum LightDisplayColor { return temperatureColor(mireds: colorTemperature) } - // colorMode is currently unused — the color object check above is the - // dominant signal and the temperature fallback covers the rest. - // Keep the parameter so callers stay symmetric with z2m's payload - // shape if we need to gate on it again later. - _ = colorMode - return .accentColor } From 498dba340621c16a71b9ce315631a550d1d500ad Mon Sep 17 00:00:00 2001 From: tashda Date: Mon, 4 May 2026 10:05:21 +0200 Subject: [PATCH 19/19] Color temperature: use Tanner Helland CCT-to-RGB so warm vs cool actually differ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous formula pinned red at 1.0 and ramped green/blue linearly from 1000–6500K. The result: every white above ~5000K landed on a peach tint that was nearly indistinguishable from neighbouring values, and genuinely cool whites (6500K+) came out pinkish instead of the characteristic blue-white. Replace with the standard Tanner Helland approximation: 2000K reads amber, 2700K tungsten, 4000K neutral, 5500K daylight, 6500K+ cool blue-white. Clamp to 1000–10000K so unusual bulb reports still render usefully. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../LightControl/LightDisplayColor.swift | 51 ++++++++++++++++--- 1 file changed, 44 insertions(+), 7 deletions(-) diff --git a/Shellbee/Shared/LightControl/LightDisplayColor.swift b/Shellbee/Shared/LightControl/LightDisplayColor.swift index 7c32468..c4c577a 100644 --- a/Shellbee/Shared/LightControl/LightDisplayColor.swift +++ b/Shellbee/Shared/LightControl/LightDisplayColor.swift @@ -34,14 +34,51 @@ enum LightDisplayColor { return .accentColor } + /// Convert mireds to a representative RGB color using Tanner Helland's + /// CCT-to-RGB approximation. The previous implementation kept red pinned + /// at 1.0 and ramped blue/green linearly — every white above ~5000K + /// landed as the same peach-pink tint, and warm vs cool whites were + /// nearly indistinguishable. This produces visually correct shifts: + /// 2000K → amber, 2700K → tungsten, 4000K → neutral, 5500K → daylight, + /// 6500K+ → cool blue-white. Clamped to 1000–10000K to keep the + /// rendering usable for both extreme bulb reports and home-class + /// lighting. static func temperatureColor(mireds: Double) -> Color { - let kelvin = max(1000, min(6500, 1_000_000 / max(mireds, 1))) - let normalized = (kelvin - 1000) / 5500 - return Color( - red: 1.0, - green: 0.56 + (0.32 * normalized), - blue: 0.24 + (0.76 * normalized) - ) + let kelvin = max(1000, min(10_000, 1_000_000 / max(mireds, 1))) + let temp = kelvin / 100 + + let red: Double + if temp <= 66 { + red = 255 + } else { + let v = 329.698727446 * pow(temp - 60, -0.1332047592) + red = clamp(v) + } + + let green: Double + if temp <= 66 { + let v = 99.4708025861 * log(max(temp, 1)) - 161.1195681661 + green = clamp(v) + } else { + let v = 288.1221695283 * pow(temp - 60, -0.0755148492) + green = clamp(v) + } + + let blue: Double + if temp >= 66 { + blue = 255 + } else if temp <= 19 { + blue = 0 + } else { + let v = 138.5177312231 * log(temp - 10) - 305.0447927307 + blue = clamp(v) + } + + return Color(red: red / 255, green: green / 255, blue: blue / 255) + } + + private static func clamp(_ value: Double) -> Double { + max(0, min(255, value)) } private static func xyColor(x: Double, y: Double) -> Color {