diff --git a/Shellbee.xcodeproj/project.pbxproj b/Shellbee.xcodeproj/project.pbxproj index ffc2c26..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, @@ -177,6 +178,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, @@ -844,7 +846,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 +887,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 +927,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 +969,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/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/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/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/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/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/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/Core/Store/AppStore+Events.swift b/Shellbee/Core/Store/AppStore+Events.swift index d8809ea..4491468 100644 --- a/Shellbee/Core/Store/AppStore+Events.swift +++ b/Shellbee/Core/Store/AppStore+Events.swift @@ -162,10 +162,20 @@ 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)) + insertLogEntry(LogMapperEngine.stateChangeEntry(device: name, changes: changes, payload: state)) } } deviceStates[name] = state 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() + } } } 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/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/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()) + } +} 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/Logs/LogDetailView.swift b/Shellbee/Features/Logs/LogDetailView.swift index 19e6c92..21c9c21 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 } @@ -49,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 } @@ -160,10 +176,32 @@ 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 private func singleDeviceSection(_ device: Device) -> some View { + let isOrigin = device.ieeeAddress == originDeviceIEEE Section { ZStack { DeviceCard( @@ -176,8 +214,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) @@ -198,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 } ) @@ -251,7 +299,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) + } + } } } diff --git a/Shellbee/Features/Logs/LogsView.swift b/Shellbee/Features/Logs/LogsView.swift index 5c5bb26..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) { @@ -49,6 +58,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 { 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.") diff --git a/Shellbee/Features/Settings/SettingsView.swift b/Shellbee/Features/Settings/SettingsView.swift index 964803c..6863cb0 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) @@ -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 }, diff --git a/Shellbee/Shared/LightControl/LightControlCard.swift b/Shellbee/Shared/LightControl/LightControlCard.swift index ea278c9..c9f2ae3 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) @@ -230,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", @@ -256,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)) } } } @@ -287,11 +287,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) } } } diff --git a/Shellbee/Shared/LightControl/LightControlContext.swift b/Shellbee/Shared/LightControl/LightControlContext.swift index 5011aa6..081eb4e 100644 --- a/Shellbee/Shared/LightControl/LightControlContext.swift +++ b/Shellbee/Shared/LightControl/LightControlContext.swift @@ -20,6 +20,17 @@ struct LightControlContext: Equatable, Identifiable { let displayColor: Color let endpointLabel: String? + /// 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 { + colorMode == "xy" || colorMode == "hs" + } + var id: String { power?.property ?? brightness?.property ?? endpointLabel ?? "light" } var supportsWhiteControls: Bool { colorTemperature != nil } diff --git a/Shellbee/Shared/LightControl/LightDisplayColor.swift b/Shellbee/Shared/LightControl/LightDisplayColor.swift index 084bbdb..c4c577a 100644 --- a/Shellbee/Shared/LightControl/LightDisplayColor.swift +++ b/Shellbee/Shared/LightControl/LightDisplayColor.swift @@ -2,6 +2,11 @@ import SwiftUI enum LightDisplayColor { static func resolve(colorValue: JSONValue?, colorTemperature: Double?, colorMode: String?) -> Color { + // 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) } @@ -29,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 { 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)