Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
68673d1
v1.6.1: Hide Groups by default, trim empty device stats, replace Sett…
tashda May 4, 2026
59600c7
Restart Required notice: long-press → Go to Log
tashda May 4, 2026
7e0dc87
Merge branch 'main' into dev
tashda May 4, 2026
d36ae3f
Fix device-specific settings parity with Z2M frontend (#91)
tashda May 4, 2026
3691cbd
Remove "Go to Log" from Restart Required notice
tashda May 4, 2026
952939e
Don't link Device Card back to itself in log detail
tashda May 4, 2026
1784a41
Move Connect button to toolbar on connect editor
tashda May 4, 2026
bf9cede
Don't fabricate brightness in log state snapshot
tashda May 4, 2026
46ee2d3
Add Logs section to Group Detail
tashda May 4, 2026
7a855d8
Restart: pop to Settings root and clear stale runtime stats
tashda May 4, 2026
9cfef28
Logs tab: register Device/Group navigation destinations
tashda May 4, 2026
3e5b089
Group state changes: log the first arrival, not just subsequent ones
tashda May 4, 2026
cfeebe4
Format 'Failed to ping' warnings into structured fields
tashda May 4, 2026
d1fba1f
Log Detail: render full Light Card from state-change payload
tashda May 4, 2026
eefa021
Log sheet: register Device/Group destinations so card taps navigate
tashda May 4, 2026
ccec111
Multi-bridge Bridges row matches single-bridge Connection card
tashda May 4, 2026
bb92c85
Remove redundant color swatch from snapshot info row
tashda May 4, 2026
01d04a1
Light Card: render real color even when color_mode lies
tashda May 4, 2026
3a5982b
Light Card snapshot: include nested color, trust color_mode, drop swa…
tashda May 4, 2026
498dba3
Color temperature: use Tanner Helland CCT-to-RGB so warm vs cool actu…
tashda May 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions Shellbee.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 = "";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 = "";
Expand Down
10 changes: 9 additions & 1 deletion Shellbee/App/AppEnvironment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(""))
}

Expand Down
6 changes: 6 additions & 0 deletions Shellbee/App/MainTabView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
17 changes: 17 additions & 0 deletions Shellbee/Core/Log/LogContext.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 7 additions & 2 deletions Shellbee/Core/Log/LogMapperEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
187 changes: 187 additions & 0 deletions Shellbee/Core/Log/LogMessageStructure.swift
Original file line number Diff line number Diff line change
@@ -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 '<friendly>' (attempt <n>/<m>, ZCL command <ieee>/<ep> <call>,
// <options-json>) failed (<reason>)
//
// 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..<closeParen])

// Tail after the args paren — typically " failed (<reason>)".
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)
}
}
}
39 changes: 32 additions & 7 deletions Shellbee/Core/Models/BridgeInfo.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down
6 changes: 4 additions & 2 deletions Shellbee/Core/Networking/BridgeScope.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading