Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
17 changes: 9 additions & 8 deletions Shellbee.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,7 @@
Shared/ClimateControl/ClimateControlCard.swift,
Shared/ClimateControl/ClimateControlContext.swift,
Shared/ClimateControl/ClimateFeatureSections.swift,
Shared/Compat/iOS26Compat.swift,
Shared/Components/BeautifulPayloadView.swift,
Shared/Components/BeautifulRow.swift,
Shared/Components/CopyableRow.swift,
Expand Down Expand Up @@ -824,12 +825,12 @@
INFOPLIST_FILE = Config/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Shellbee;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
IPHONEOS_DEPLOYMENT_TARGET = 17.5;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.4.0;
MARKETING_VERSION = 1.5.0;
PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_ID)";
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
Expand Down Expand Up @@ -865,12 +866,12 @@
INFOPLIST_FILE = Config/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Shellbee;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
IPHONEOS_DEPLOYMENT_TARGET = 17.5;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.4.0;
MARKETING_VERSION = 1.5.0;
PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_ID)";
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
Expand Down Expand Up @@ -904,13 +905,13 @@
INFOPLIST_KEY_CFBundleDisplayName = "Shellbee Widgets";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
INFOPLIST_KEY_NSSupportsLiveActivities = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
IPHONEOS_DEPLOYMENT_TARGET = 17.5;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.4.0;
MARKETING_VERSION = 1.5.0;
PRODUCT_BUNDLE_IDENTIFIER = "$(APP_WIDGET_BUNDLE_ID)";
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
Expand Down Expand Up @@ -946,13 +947,13 @@
INFOPLIST_KEY_CFBundleDisplayName = "Shellbee Widgets";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
INFOPLIST_KEY_NSSupportsLiveActivities = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
IPHONEOS_DEPLOYMENT_TARGET = 17.5;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.4.0;
MARKETING_VERSION = 1.5.0;
PRODUCT_BUNDLE_IDENTIFIER = "$(APP_WIDGET_BUNDLE_ID)";
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
Expand Down
65 changes: 50 additions & 15 deletions Shellbee/App/MainTabView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,21 @@ struct MainTabView: View {
@Environment(AppEnvironment.self) private var environment
@State private var tabSelection: AppTab = .home

var body: some View {
TabView(selection: $tabSelection) {
Tab("Home", systemImage: "house.fill", value: AppTab.home) {
HomeView()
}
Tab("Devices", systemImage: "sensor.tag.radiowaves.forward.fill", value: AppTab.devices) {
DeviceListView()
}
Tab("Groups", systemImage: "square.on.square.fill", value: AppTab.groups) {
GroupListView()
}
Tab("Settings", systemImage: "gearshape.fill", value: AppTab.settings) {
SettingsView()
}
.badge(environment.store.bridgeInfo?.restartRequired == true ? Text("!") : nil)
init() {
// iOS 26 has the new floating glass tab bar from the Tab { } builder,
// which we don't want to disturb. On iOS 17/18 the classic UITabBar
// goes transparent at the scroll edge by default; force opaque so it
// always shows the system fill instead of fading into content.
if #unavailable(iOS 26.0) {
let appearance = UITabBarAppearance()
appearance.configureWithOpaqueBackground()
UITabBar.appearance().standardAppearance = appearance
UITabBar.appearance().scrollEdgeAppearance = appearance
}
}

var body: some View {
tabContent
.overlay(alignment: .bottom) {
InAppNotificationOverlay()
.safeAreaPadding(.bottom)
Expand All @@ -42,6 +41,42 @@ struct MainTabView: View {
}
}

@ViewBuilder
private var tabContent: some View {
if #available(iOS 18.0, *) {
TabView(selection: $tabSelection) {
Tab("Home", systemImage: "house.fill", value: AppTab.home) {
HomeView()
}
Tab("Devices", systemImage: "sensor.tag.radiowaves.forward.fill", value: AppTab.devices) {
DeviceListView()
}
Tab("Groups", systemImage: "square.on.square.fill", value: AppTab.groups) {
GroupListView()
}
Tab("Settings", systemImage: "gearshape.fill", value: AppTab.settings) {
SettingsView()
}
.badge(environment.store.bridgeInfo?.restartRequired == true ? Text("!") : nil)
}
} else {
TabView(selection: $tabSelection) {
HomeView()
.tabItem { Label("Home", systemImage: "house.fill") }
.tag(AppTab.home)
DeviceListView()
.tabItem { Label("Devices", systemImage: "sensor.tag.radiowaves.forward.fill") }
.tag(AppTab.devices)
GroupListView()
.tabItem { Label("Groups", systemImage: "square.on.square.fill") }
.tag(AppTab.groups)
SettingsView()
.tabItem { Label("Settings", systemImage: "gearshape.fill") }
.tag(AppTab.settings)
.badge(environment.store.bridgeInfo?.restartRequired == true ? Text("!") : nil)
}
}
}
}

private struct LogSheetHost: View {
Expand Down
1 change: 1 addition & 0 deletions Shellbee/Core/Log/LogContext.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ struct LogContext: Sendable {

enum LogAction: Sendable {
case mqttPublish
case bridgeResponse
case stateChange
case bindSuccess, bindFailure, unbind
case groupAdd, groupRemove
Expand Down
69 changes: 68 additions & 1 deletion Shellbee/Core/Log/LogMapperEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,19 @@ struct LogMapperEngine {
if let m = message.firstMatch(of: Z2MLogPatterns.mqttPublish) {
var name = String(m.topic)
if name.hasPrefix("zigbee2mqtt/") { name = String(name.dropFirst("zigbee2mqtt/".count)) }
// Bridge responses/events carry the real subject inside the payload.
// Parse `payload '<json>'` from the message to surface it.
if name.hasPrefix("bridge/"),
let resolved = bridgeSubject(in: message, topic: name, knownDevices: knownDevices) {
return oneDevice(resolved, .bridgeResponse)
}
// Sub-topics like "<device>/action" or "<device>/availability" should
// attribute to the parent device so the redundant publish row dedupes
// against the .deviceState event (handled in AppStore+Events).
if let slash = name.firstIndex(of: "/") {
let parent = String(name[..<slash])
if knownDevices.contains(parent) { return oneDevice(parent, .mqttPublish) }
}
return oneDevice(name, .mqttPublish)
}
// Fallback: scan all 'quoted' tokens against known device/group names
Expand All @@ -58,7 +71,7 @@ struct LogMapperEngine {
_ previous: [String: JSONValue],
_ next: [String: JSONValue]
) -> [LogContext.StateChange] {
let excluded: Set<String> = ["last_seen", "update", "update_available", "device"]
let excluded: Set<String> = ["last_seen", "update", "update_available", "device", "elapsed"]
var changes: [LogContext.StateChange] = []
let keys = Set(previous.keys).union(next.keys).subtracting(excluded)

Expand All @@ -69,6 +82,9 @@ struct LogMapperEngine {

// Null-valued entries mean "not active" — not a meaningful change to surface
if case .null = curr ?? .null { continue }
// z2m clears momentary triggers like `action` by publishing an empty
// string — that's not a meaningful change either.
if case .string(let s) = curr, s.isEmpty { continue }

if case .object(let pObj) = prev, case .object(let cObj) = curr {
let subKeys = Set(pObj.keys).union(cObj.keys)
Expand Down Expand Up @@ -144,16 +160,67 @@ struct LogMapperEngine {
if property == "brightness" { return "\(Int((Double(i) / 254.0 * 100).rounded()))%" }
if property == "color_temp" { return "\(Int((1_000_000.0 / Double(i)).rounded()))K" }
if property == "battery" || property == "humidity" { return "\(i)%" }
if Self.minuteDurationProps.contains(property) { return formatMinutesDuration(Double(i)) }
return "\(i)"
case .double(let d):
if property == "temperature" { return String(format: "%.1f°", d) }
if Self.minuteDurationProps.contains(property) { return formatMinutesDuration(d) }
return d.formatted(.number.precision(.fractionLength(0...2)))
default: return value.stringified
}
}

private static let minuteDurationProps: Set<String> = ["filter_age", "device_age"]

private static func formatMinutesDuration(_ minutes: Double) -> String {
let total = Int(minutes.rounded())
if total < 60 { return "\(total) min" }
let hours = total / 60
if hours < 48 { return "\(hours) h" }
let days = hours / 24
if days < 60 { return "\(days) d" }
let months = days / 30
return "\(months) mo"
}

// MARK: - Private

/// Extract the subject of a `bridge/...` MQTT publish from the embedded payload.
/// Examples:
/// bridge/response/device/ota_update/check → payload.data.id
/// bridge/response/device/rename → payload.data.to
/// bridge/event → payload.data.friendly_name
/// On error responses (`status: "error"`) z2m clears `data` and the device name
/// only appears inside the human-readable `error` string, e.g.
/// {"data":{},"error":"Failed ... for 'office_remote' ...","status":"error"}
/// In that case we scan the error string for a quoted token that matches a
/// known device name.
private static func bridgeSubject(
in message: String, topic: String, knownDevices: Set<String>
) -> String? {
guard let payloadStr = LogEntry.extractPayload(from: message) else { return nil }
guard let data = payloadStr.data(using: .utf8),
let payload = try? JSONDecoder().decode([String: JSONValue].self, from: data) else {
return nil
}
if case .object(let inner) = payload["data"] ?? .null {
if topic.hasSuffix("/rename"), case .string(let to) = inner["to"] ?? .null {
return to
}
if case .string(let id) = inner["id"] ?? .null { return id }
if case .string(let fn) = inner["friendly_name"] ?? .null { return fn }
}
if case .string(let fn) = payload["friendly_name"] ?? .null { return fn }
// Error path: scan payload.error for a quoted token matching a known device.
if case .string(let err) = payload["error"] ?? .null {
for m in err.matches(of: Z2MLogPatterns.singleQuoted) {
let token = String(m.name)
if knownDevices.contains(token) { return token }
}
}
return nil
}

private static func oneDevice(_ name: String, _ action: LogContext.LogAction) -> LogContext {
LogContext(devices: [.init(friendlyName: name, role: .subject)], stateChanges: [], action: action)
}
Expand Down
51 changes: 43 additions & 8 deletions Shellbee/Core/Models/LogEntry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -70,19 +70,13 @@ struct LogEntry: Identifiable, Sendable, Hashable {
}

var parsedMessageKind: MessageKind {
// Find the topic and payload within single quotes
// We look for: topic '([^']+)' and payload '([^']*)'
let topicPattern = /topic '([^']+)'/
let payloadPattern = /payload '([^']*)'/

guard let topicMatch = message.firstMatch(of: topicPattern) else { return .simple }
let topic = String(topicMatch.1)
guard let payloadMatch = message.firstMatch(of: payloadPattern) else {

guard let payloadStr = Self.extractPayload(from: message) else {
return .mqttPublish(device: topic, topic: topic, payload: [:])
}

let payloadStr = String(payloadMatch.1)
var payload: [String: JSONValue] = [:]

if let data = payloadStr.data(using: .utf8),
Expand All @@ -105,9 +99,50 @@ struct LogEntry: Identifiable, Sendable, Hashable {
if device.hasPrefix("zigbee2mqtt/") {
device = String(device.dropFirst("zigbee2mqtt/".count))
}
// Bridge responses/events carry the real subject inside the payload.
// Examples:
// bridge/response/device/ota_update/check → payload.data.id
// bridge/response/device/configure → payload.data.id
// bridge/response/device/rename → payload.data.to (post-rename name)
// bridge/event → payload.data.friendly_name
if device.hasPrefix("bridge/") {
if let resolved = Self.resolveBridgeSubject(topic: device, payload: payload) {
device = resolved
}
}
return .mqttPublish(device: device, topic: topic, payload: payload)
}

/// Extract the JSON payload from a z2m log line of the form
/// `... topic '<topic>', payload '<json>'` where the JSON itself can contain
/// single quotes (e.g. `'office_remote'`, `didn't`). The payload always runs
/// from `payload '` to the final single quote in the string.
static func extractPayload(from message: String) -> String? {
guard let range = message.range(of: "payload '") else { return nil }
let afterOpen = range.upperBound
guard let lastQuote = message.lastIndex(of: "'"), lastQuote > afterOpen else {
return nil
}
return String(message[afterOpen..<lastQuote])
}

private static func resolveBridgeSubject(topic: String, payload: [String: JSONValue]) -> String? {
if case .object(let data) = payload["data"] ?? .null {
if topic.hasSuffix("/rename"), case .string(let to) = data["to"] ?? .null {
return to
}
if case .string(let id) = data["id"] ?? .null { return id }
if case .string(let fn) = data["friendly_name"] ?? .null { return fn }
}
if case .string(let fn) = payload["friendly_name"] ?? .null { return fn }
// Error responses: device name only appears quoted inside `error`.
if case .string(let err) = payload["error"] ?? .null {
let pattern = /'([^']+)'/
if let m = err.firstMatch(of: pattern) { return String(m.1) }
}
return nil
}

var summaryTitle: String {
if let name = context?.primaryDevice?.friendlyName { return name }
if let name = deviceName { return name }
Expand Down
10 changes: 10 additions & 0 deletions Shellbee/Core/Store/AppStore+Devices.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,16 @@ extension AppStore {
devices.first { $0.friendlyName == friendlyName }
}

func group(named friendlyName: String) -> Group? {
groups.first { $0.friendlyName == friendlyName }
}

func memberDevices(of group: Group) -> [Device] {
group.members.compactMap { member in
devices.first { $0.ieeeAddress == member.ieeeAddress }
}
}

func state(for friendlyName: String) -> [String: JSONValue] {
deviceStates[friendlyName] ?? [:]
}
Expand Down
2 changes: 2 additions & 0 deletions Shellbee/Core/Store/AppStore+Events.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ extension AppStore {
)
// MQTT publish for a known device/group state topic is redundant — the
// .deviceState event creates a richer stateChange entry for the same update.
// Bridge responses (.bridgeResponse) are *not* redundant — they carry distinct
// payload (status, source URL, etc.) that the device-state event doesn't.
if case .mqttPublish = ctx.action,
let deviceName = ctx.primaryDevice?.friendlyName,
knownNames.contains(deviceName) {
Expand Down
2 changes: 1 addition & 1 deletion Shellbee/Features/Devices/DeviceDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,7 @@ struct DeviceDetailView: View {
}
} else if !otaActive {
Button { checkForUpdate(device) } label: {
Label("Check for Update", systemImage: "arrow.trianglehead.2.clockwise")
Label("Check for Update", systemImage: "arrow.triangle.2.circlepath")
}
if hasUpdateAvailable {
if isBattery {
Expand Down
3 changes: 1 addition & 2 deletions Shellbee/Features/Devices/DeviceFirmwareMenu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ struct DeviceFirmwareMenu: View {
}
environment.otaBulkQueue.enqueue(names, kind: .check)
} label: {
Label("Check All for Updates\(otaCount > 0 ? " (\(otaCount))" : "")", systemImage: "arrow.trianglehead.2.clockwise")
Label("Check All for Updates\(otaCount > 0 ? " (\(otaCount))" : "")", systemImage: "arrow.triangle.2.circlepath")
}
.disabled(otaCount == 0 || bulkActive)

Expand All @@ -68,7 +68,6 @@ struct DeviceFirmwareMenu: View {
ZStack(alignment: .topTrailing) {
if bulkActive {
ProgressView()
.controlSize(.small)
} else {
Image(systemName: "arrow.up.circle")
}
Expand Down
Loading
Loading