From 82c016a78f820b0bfdf87c9841401ce26d83cde8 Mon Sep 17 00:00:00 2001 From: pespinel Date: Sun, 3 May 2026 18:48:48 +0200 Subject: [PATCH 1/2] fix: respect disabled device availability --- Shellbee/App/AppNavigationState.swift | 1 + Shellbee/Core/Models/BridgeInfo.swift | 21 ++++++++++ Shellbee/Core/Models/Device.swift | 8 +++- Shellbee/Core/Models/NetworkAnalysis.swift | 33 +++++++++++++-- Shellbee/Core/Store/AppStore+Devices.swift | 24 ++++++++++- Shellbee/Core/Store/AppStore+Events.swift | 3 +- .../Devices/DeviceListViewModel.swift | 11 ++++- Shellbee/Features/Devices/DeviceRowView.swift | 5 +++ .../Features/Home/HomeCardComponents.swift | 2 + Shellbee/Features/Home/HomeDevicesCard.swift | 2 + Shellbee/Features/Home/HomeSnapshot.swift | 18 +++++--- Shellbee/Shared/Components/DeviceCard.swift | 6 +++ .../Components/DeviceCardFooterBar.swift | 6 ++- ShellbeeTests/Helpers/TestFixtures.swift | 31 ++++++++++++++ ShellbeeTests/Unit/AppStoreTests.swift | 28 +++++++++++++ .../Unit/DeviceListViewModelTests.swift | 21 ++++++++++ ShellbeeTests/Unit/HomeSnapshotTests.swift | 41 ++++++++++++++++++- ShellbeeTests/Unit/NetworkAnalysisTests.swift | 16 ++++++++ 18 files changed, 262 insertions(+), 15 deletions(-) diff --git a/Shellbee/App/AppNavigationState.swift b/Shellbee/App/AppNavigationState.swift index 3e5aa21..2c0b9ed 100644 --- a/Shellbee/App/AppNavigationState.swift +++ b/Shellbee/App/AppNavigationState.swift @@ -8,6 +8,7 @@ enum DeviceQuickFilter: Hashable { case all case online case offline + case availabilityOff case batteryLow case weakSignal case interviewing diff --git a/Shellbee/Core/Models/BridgeInfo.swift b/Shellbee/Core/Models/BridgeInfo.swift index 08c8977..5118d5f 100644 --- a/Shellbee/Core/Models/BridgeInfo.swift +++ b/Shellbee/Core/Models/BridgeInfo.swift @@ -107,6 +107,27 @@ struct BridgeConfig: Codable, Sendable, Equatable { let passlist: [String]? let blocklist: [String]? 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] + ?? devices[device.ieeeAddress.lowercased()] + ?? devices[device.ieeeAddress.uppercased()] + ?? devices[device.friendlyName] + ?? devices.values.first { $0.friendlyName == device.friendlyName } + return config?.availability?.boolValue != false + } + + struct DeviceConfig: Codable, Sendable, Equatable { + let friendlyName: String? + let availability: JSONValue? + + enum CodingKeys: String, CodingKey { + case friendlyName = "friendly_name" + case availability + } + } struct AdvancedConfig: Codable, Sendable, Equatable { let logLevel: String? diff --git a/Shellbee/Core/Models/Device.swift b/Shellbee/Core/Models/Device.swift index 16a0f22..b04b88b 100644 --- a/Shellbee/Core/Models/Device.swift +++ b/Shellbee/Core/Models/Device.swift @@ -19,9 +19,15 @@ struct Device: Codable, Identifiable, Sendable, Equatable, Hashable { var dateCode: String? var endpoints: [String: JSONValue]? var options: [String: JSONValue]? + var availability: JSONValue? = nil var id: String { ieeeAddress } + var availabilityTrackingEnabled: Bool { + availability?.boolValue != false + && options?["availability"]?.boolValue != false + } + var availableEndpoints: [Int] { guard let keys = endpoints?.keys, !keys.isEmpty else { return [1] } let ints = keys.compactMap(Int.init).sorted() @@ -51,7 +57,7 @@ struct Device: Codable, Identifiable, Sendable, Equatable, Hashable { case interviewCompleted = "interview_completed" case softwareBuildId = "software_build_id" case dateCode = "date_code" - case endpoints, options + case endpoints, options, availability } } diff --git a/Shellbee/Core/Models/NetworkAnalysis.swift b/Shellbee/Core/Models/NetworkAnalysis.swift index 7086060..7665c72 100644 --- a/Shellbee/Core/Models/NetworkAnalysis.swift +++ b/Shellbee/Core/Models/NetworkAnalysis.swift @@ -1,9 +1,20 @@ import Foundation +enum DeviceAvailabilityStatus: Sendable, Equatable { + case online + case offline + case untracked + + var isAvailable: Bool { + self != .offline + } +} + enum DeviceCondition: String, CaseIterable, Sendable { case updatesAvailable = "Updates Available" case online = "Online" case offline = "Offline" + case availabilityOff = "Availability off" case batteryLow = "Low Battery" case weakSignal = "Bad Signal" case interviewing = "Interviewing" @@ -14,6 +25,7 @@ enum DeviceCondition: String, CaseIterable, Sendable { case .updatesAvailable: return "arrow.down.circle" case .online: return "wifi" case .offline: return "wifi.slash" + case .availabilityOff: return "minus.circle" case .batteryLow: return "battery.25" case .weakSignal: return "wifi.exclamationmark" case .interviewing: return "waveform.path.ecg" @@ -21,7 +33,12 @@ enum DeviceCondition: String, CaseIterable, Sendable { } } - func matches(device: Device, state: [String: JSONValue], isAvailable: Bool, otaStatus: OTAUpdateStatus? = nil) -> Bool { + func matches( + device: Device, + state: [String: JSONValue], + availabilityStatus: DeviceAvailabilityStatus, + otaStatus: OTAUpdateStatus? = nil + ) -> Bool { switch self { case .updatesAvailable: if state.hasUpdateAvailable || state.isUpdating { return true } @@ -29,12 +46,22 @@ enum DeviceCondition: String, CaseIterable, Sendable { case .scheduled, .requested, .updating: return true default: return false } - case .online: return isAvailable - case .offline: return !isAvailable + case .online: return availabilityStatus == .online + case .offline: return availabilityStatus == .offline + case .availabilityOff: return availabilityStatus == .untracked case .batteryLow: return (state.battery ?? 100) < DesignTokens.Threshold.lowBattery case .weakSignal: return (state.linkQuality ?? 999) < DesignTokens.Threshold.weakSignal case .interviewing: return device.interviewing || !device.interviewCompleted case .unsupported: return !device.supported } } + + func matches(device: Device, state: [String: JSONValue], isAvailable: Bool, otaStatus: OTAUpdateStatus? = nil) -> Bool { + matches( + device: device, + state: state, + availabilityStatus: isAvailable ? .online : .offline, + otaStatus: otaStatus + ) + } } diff --git a/Shellbee/Core/Store/AppStore+Devices.swift b/Shellbee/Core/Store/AppStore+Devices.swift index e501809..e4ce073 100644 --- a/Shellbee/Core/Store/AppStore+Devices.swift +++ b/Shellbee/Core/Store/AppStore+Devices.swift @@ -19,8 +19,30 @@ extension AppStore { deviceStates[friendlyName] ?? [:] } + func availabilityStatus(for friendlyName: String) -> DeviceAvailabilityStatus { + if let device = device(named: friendlyName), + device.availabilityTrackingEnabled == false + || bridgeInfo?.config?.availabilityTrackingEnabled(for: device) == false { + return .untracked + } + return (deviceAvailability[friendlyName] ?? false) ? .online : .offline + } + func isAvailable(_ friendlyName: String) -> Bool { - deviceAvailability[friendlyName] ?? false + availabilityStatus(for: friendlyName).isAvailable + } + + func applyingConfiguredAvailability(to device: Device) -> Device { + guard bridgeInfo?.config?.availabilityTrackingEnabled(for: device) == false else { + return device + } + var updated = device + updated.availability = .bool(false) + return updated + } + + func syncConfiguredAvailability() { + devices = devices.map(applyingConfiguredAvailability) } /// Apply a rename to local state immediately so the UI updates without diff --git a/Shellbee/Core/Store/AppStore+Events.swift b/Shellbee/Core/Store/AppStore+Events.swift index 5b91554..d8809ea 100644 --- a/Shellbee/Core/Store/AppStore+Events.swift +++ b/Shellbee/Core/Store/AppStore+Events.swift @@ -31,6 +31,7 @@ extension AppStore { } else { bridgeInfo = info } + syncConfiguredAvailability() case .bridgeState(let state): bridgeOnline = state == "online" case .devices(let list): @@ -45,7 +46,7 @@ extension AppStore { recordFirstSeen(ieee: device.ieeeAddress) } } - devices = list + devices = list.map(applyingConfiguredAvailability) case .groups(let list): groups = list case .logMessage(let msg): diff --git a/Shellbee/Features/Devices/DeviceListViewModel.swift b/Shellbee/Features/Devices/DeviceListViewModel.swift index 936e34c..dba9211 100644 --- a/Shellbee/Features/Devices/DeviceListViewModel.swift +++ b/Shellbee/Features/Devices/DeviceListViewModel.swift @@ -7,6 +7,7 @@ enum DeviceFilter: Hashable { case category(Device.Category) case updatesAvailable case offline + case availabilityOff case batteryLow case weakSignal case interviewing @@ -18,6 +19,7 @@ enum DeviceFilter: Hashable { case .category(let c): return c.label case .updatesAvailable: return "Updates" case .offline: return "Offline" + case .availabilityOff: return "Availability off" case .batteryLow: return "Low Battery" case .weakSignal: return "Bad Signal" case .interviewing: return "Interviewing" @@ -31,6 +33,7 @@ enum DeviceFilter: Hashable { case .category(let c): return c.systemImage case .updatesAvailable: return "arrow.down.circle.fill" case .offline: return "wifi.slash" + case .availabilityOff: return "minus.circle" case .batteryLow: return "battery.25" case .weakSignal: return "wifi.exclamationmark" case .interviewing: return "waveform.path.ecg" @@ -50,6 +53,7 @@ enum DeviceStatusFilter: String, CaseIterable, Hashable { case all = "All" case online = "Online" case offline = "Offline" + case availabilityOff = "Availability off" case updatesAvailable = "Updates Available" case batteryLow = "Low Battery" case weakSignal = "Bad Signal" @@ -61,6 +65,7 @@ enum DeviceStatusFilter: String, CaseIterable, Hashable { case .all: return "circle.grid.2x2" case .online: return "wifi" case .offline: return "wifi.slash" + case .availabilityOff: return "minus.circle" case .updatesAvailable: return "arrow.down.circle" case .batteryLow: return "battery.25" case .weakSignal: return "wifi.exclamationmark" @@ -74,6 +79,7 @@ enum DeviceStatusFilter: String, CaseIterable, Hashable { case .all: return nil case .online: return .online case .offline: return .offline + case .availabilityOff: return .availabilityOff case .updatesAvailable: return .updatesAvailable case .batteryLow: return .batteryLow case .weakSignal: return .weakSignal @@ -165,7 +171,7 @@ final class DeviceListViewModel { condition.matches( device: $0, state: store.state(for: $0.friendlyName), - isAvailable: store.isAvailable($0.friendlyName), + availabilityStatus: store.availabilityStatus(for: $0.friendlyName), otaStatus: store.otaStatus(for: $0.friendlyName) ) }.count @@ -192,6 +198,7 @@ final class DeviceListViewModel { case .all: break case .online: statusFilter = .online case .offline: statusFilter = .offline + case .availabilityOff: statusFilter = .availabilityOff case .updatesAvailable: statusFilter = .updatesAvailable case .batteryLow: statusFilter = .batteryLow case .weakSignal: statusFilter = .weakSignal @@ -290,7 +297,7 @@ final class DeviceListViewModel { condition.matches( device: $0, state: store.state(for: $0.friendlyName), - isAvailable: store.isAvailable($0.friendlyName), + availabilityStatus: store.availabilityStatus(for: $0.friendlyName), otaStatus: store.otaStatus(for: $0.friendlyName) ) } diff --git a/Shellbee/Features/Devices/DeviceRowView.swift b/Shellbee/Features/Devices/DeviceRowView.swift index 44f630d..3374dc5 100644 --- a/Shellbee/Features/Devices/DeviceRowView.swift +++ b/Shellbee/Features/Devices/DeviceRowView.swift @@ -66,6 +66,11 @@ struct DeviceRowView: View { .foregroundStyle(.blue) } else if let checkResult { checkResultLabel(checkResult) + } else if !device.availabilityTrackingEnabled { + Label("Availability off", systemImage: "minus.circle") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .labelStyle(.titleAndIcon) } else if !isAvailable { Text("Offline") .font(.caption.weight(.medium)) diff --git a/Shellbee/Features/Home/HomeCardComponents.swift b/Shellbee/Features/Home/HomeCardComponents.swift index 6637e4b..20128e2 100644 --- a/Shellbee/Features/Home/HomeCardComponents.swift +++ b/Shellbee/Features/Home/HomeCardComponents.swift @@ -52,6 +52,8 @@ struct HomeStatCell: View { Text(label) .font(.caption) .foregroundStyle(.secondary) + .lineLimit(2) + .minimumScaleFactor(0.85) } .frame(maxWidth: .infinity, alignment: .leading) } diff --git a/Shellbee/Features/Home/HomeDevicesCard.swift b/Shellbee/Features/Home/HomeDevicesCard.swift index 59bbc33..0f777c9 100644 --- a/Shellbee/Features/Home/HomeDevicesCard.swift +++ b/Shellbee/Features/Home/HomeDevicesCard.swift @@ -33,6 +33,8 @@ struct HomeDevicesCard: View { 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: "Availability off", + valueColor: snapshot.availabilityOffDevices > 0 ? .secondary : .primary) } } diff --git a/Shellbee/Features/Home/HomeSnapshot.swift b/Shellbee/Features/Home/HomeSnapshot.swift index 8a4de26..07778bd 100644 --- a/Shellbee/Features/Home/HomeSnapshot.swift +++ b/Shellbee/Features/Home/HomeSnapshot.swift @@ -5,6 +5,8 @@ struct HomeSnapshot: Sendable { let isBridgeOnline: Bool let totalDevices: Int let onlineDevices: Int + let offlineDevices: Int + let availabilityOffDevices: Int let routerCount: Int let endDeviceCount: Int let unsupportedDevices: Int @@ -27,10 +29,6 @@ struct HomeSnapshot: Sendable { let permitJoinRemaining: Int? let restartRequired: Bool - var offlineDevices: Int { - max(totalDevices - onlineDevices, 0) - } - var releaseURL: URL? { guard let bridgeVersion else { return nil } return URL(string: "https://github.com/Koenkk/zigbee2mqtt/releases/tag/\(bridgeVersion)") @@ -67,7 +65,17 @@ struct HomeSnapshot: Sendable { let nonCoordinatorDevices = devices.filter { $0.type != .coordinator } totalDevices = nonCoordinatorDevices.count - onlineDevices = nonCoordinatorDevices.filter { availability[$0.friendlyName] ?? false }.count + onlineDevices = nonCoordinatorDevices.filter { + $0.availabilityTrackingEnabled + && availability[$0.friendlyName] == true + }.count + offlineDevices = nonCoordinatorDevices.filter { + $0.availabilityTrackingEnabled + && availability[$0.friendlyName] != true + }.count + availabilityOffDevices = nonCoordinatorDevices.filter { + !$0.availabilityTrackingEnabled + }.count routerCount = nonCoordinatorDevices.filter { $0.type == .router }.count endDeviceCount = nonCoordinatorDevices.filter { $0.type == .endDevice }.count unsupportedDevices = nonCoordinatorDevices.filter { !$0.supported }.count diff --git a/Shellbee/Shared/Components/DeviceCard.swift b/Shellbee/Shared/Components/DeviceCard.swift index 0e6db22..8f8fc32 100644 --- a/Shellbee/Shared/Components/DeviceCard.swift +++ b/Shellbee/Shared/Components/DeviceCard.swift @@ -285,18 +285,24 @@ struct DeviceCard: View { return "Interviewing" } + if !device.availabilityTrackingEnabled { + return "Availability off" + } + return isAvailable ? "Online" : "Offline" } private var statusColor: Color { if otaStatus?.isActive == true { return .blue } if device.interviewing { return .orange } + if !device.availabilityTrackingEnabled { return .secondary } return isAvailable ? .green : .red } private var statusIcon: String { if otaStatus?.isActive == true { return "arrow.triangle.2.circlepath.circle.fill" } if device.interviewing { return "dot.radiowaves.left.and.right" } + if !device.availabilityTrackingEnabled { return "minus.circle.fill" } return isAvailable ? "checkmark.circle.fill" : "xmark.circle.fill" } diff --git a/Shellbee/Shared/Components/DeviceCardFooterBar.swift b/Shellbee/Shared/Components/DeviceCardFooterBar.swift index 2dad644..65322f6 100644 --- a/Shellbee/Shared/Components/DeviceCardFooterBar.swift +++ b/Shellbee/Shared/Components/DeviceCardFooterBar.swift @@ -85,12 +85,17 @@ struct DeviceCardFooterBar: View { return "Interviewing" } + if !device.availabilityTrackingEnabled { + return "Availability off" + } + return isAvailable ? "Online" : "Offline" } private var statusColor: Color { if otaStatus?.isActive == true { return .blue } if device.interviewing { return .orange } + if !device.availabilityTrackingEnabled { return .secondary } return isAvailable ? .green : .red } @@ -154,4 +159,3 @@ struct DeviceCardFooterBar: View { .padding() .background(Color(.secondarySystemGroupedBackground)) } - diff --git a/ShellbeeTests/Helpers/TestFixtures.swift b/ShellbeeTests/Helpers/TestFixtures.swift index 5ff6016..153d148 100644 --- a/ShellbeeTests/Helpers/TestFixtures.swift +++ b/ShellbeeTests/Helpers/TestFixtures.swift @@ -333,6 +333,37 @@ enum StateFixture { } } +// MARK: - Bridge info builders + +enum BridgeInfoFixture { + @MainActor + static func withDeviceAvailabilityDisabled( + ieee: String = "0x00000000000000f1", + friendlyName: String = "Untracked Remote" + ) -> BridgeInfo { + let json = """ + { + "version": "2.1.0", + "commit": "abc", + "coordinator": {"ieee_address": "0x0000000000000000", "type": "zStack30x", "meta": {}}, + "network": {"channel": 11, "pan_id": 6754, "extended_pan_id": "0xdd"}, + "log_level": "info", + "permit_join": false, + "restart_required": false, + "config": { + "devices": { + "\(ieee)": { + "friendly_name": "\(friendlyName)", + "availability": false + } + } + } + } + """ + return try! JSONDecoder().decode(BridgeInfo.self, from: Data(json.utf8)) + } +} + // MARK: - Z2M WebSocket frame builders enum FrameFixture { diff --git a/ShellbeeTests/Unit/AppStoreTests.swift b/ShellbeeTests/Unit/AppStoreTests.swift index f107072..d2ff01a 100644 --- a/ShellbeeTests/Unit/AppStoreTests.swift +++ b/ShellbeeTests/Unit/AppStoreTests.swift @@ -116,6 +116,34 @@ final class AppStoreTests: XCTestCase, @unchecked Sendable { XCTAssertFalse(store.isAvailable("unknown")) } + @MainActor + func testAvailabilityDisabledDeviceIsUntracked() { + var remote = DeviceFixture.remote(name: "Untracked Remote") + remote.options = ["availability": .bool(false)] + + store.apply(.devices([remote])) + store.apply(.deviceAvailability(friendlyName: remote.friendlyName, available: false)) + + XCTAssertEqual(store.availabilityStatus(for: remote.friendlyName), .untracked) + XCTAssertTrue(store.isAvailable(remote.friendlyName)) + } + + @MainActor + func testAvailabilityDisabledInBridgeConfigIsUntracked() { + let remote = DeviceFixture.remote( + ieee: "0x00000000000000f1", + name: "Untracked Remote" + ) + + store.apply(.devices([remote])) + store.apply(.bridgeInfo(BridgeInfoFixture.withDeviceAvailabilityDisabled())) + store.apply(.deviceAvailability(friendlyName: remote.friendlyName, available: false)) + + XCTAssertEqual(store.availabilityStatus(for: remote.friendlyName), .untracked) + XCTAssertFalse(store.devices[0].availabilityTrackingEnabled) + XCTAssertTrue(store.isAvailable(remote.friendlyName)) + } + // MARK: - logs @MainActor diff --git a/ShellbeeTests/Unit/DeviceListViewModelTests.swift b/ShellbeeTests/Unit/DeviceListViewModelTests.swift index dcb0863..9810445 100644 --- a/ShellbeeTests/Unit/DeviceListViewModelTests.swift +++ b/ShellbeeTests/Unit/DeviceListViewModelTests.swift @@ -58,6 +58,27 @@ final class DeviceListViewModelTests: XCTestCase, @unchecked Sendable { XCTAssertEqual(result[0].friendlyName, "Office Sensor") } + @MainActor + func testAvailabilityDisabledDeviceIsExcludedFromOnlineAndOfflineFilters() { + let remote = DeviceFixture.remote( + ieee: "0x00000000000000f1", + name: "Untracked Remote" + ) + store.apply(.devices([remote])) + store.apply(.bridgeInfo(BridgeInfoFixture.withDeviceAvailabilityDisabled())) + store.apply(.deviceAvailability(friendlyName: remote.friendlyName, available: false)) + + vm.statusFilter = .offline + XCTAssertTrue(vm.filteredDevices(store: store).isEmpty) + + vm.statusFilter = .online + XCTAssertTrue(vm.filteredDevices(store: store).isEmpty) + + vm.statusFilter = .availabilityOff + let result = vm.filteredDevices(store: store) + XCTAssertEqual(result.map(\.friendlyName), [remote.friendlyName]) + } + @MainActor func testFilterUpdatesAvailable() { store.apply(.deviceState( diff --git a/ShellbeeTests/Unit/HomeSnapshotTests.swift b/ShellbeeTests/Unit/HomeSnapshotTests.swift index 3c27eb9..81405cd 100644 --- a/ShellbeeTests/Unit/HomeSnapshotTests.swift +++ b/ShellbeeTests/Unit/HomeSnapshotTests.swift @@ -36,6 +36,44 @@ final class HomeSnapshotTests: XCTestCase { XCTAssertEqual(snapshot.endDeviceCount, 1, "sensor is an end device") } + func testAvailabilityDisabledDeviceDoesNotCountOffline() { + var remote = DeviceFixture.remote(name: "Untracked Remote") + remote.options = ["availability": .bool(false)] + + let snapshot = makeSnapshot( + devices: [remote], + availability: [remote.friendlyName: false], + states: [:] + ) + + XCTAssertEqual(snapshot.totalDevices, 1) + XCTAssertEqual(snapshot.onlineDevices, 0) + XCTAssertEqual(snapshot.offlineDevices, 0) + XCTAssertEqual(snapshot.availabilityOffDevices, 1) + } + + func testAvailabilityDisabledInBridgeConfigDoesNotCountOffline() { + let store = AppStore() + let remote = DeviceFixture.remote( + ieee: "0x00000000000000f1", + name: "Untracked Remote" + ) + store.apply(.devices([remote])) + store.apply(.bridgeInfo(BridgeInfoFixture.withDeviceAvailabilityDisabled())) + store.apply(.deviceAvailability(friendlyName: remote.friendlyName, available: false)) + + let snapshot = makeSnapshot( + devices: store.devices, + availability: store.deviceAvailability, + states: [:] + ) + + XCTAssertEqual(snapshot.totalDevices, 1) + XCTAssertEqual(snapshot.onlineDevices, 0) + XCTAssertEqual(snapshot.offlineDevices, 0) + XCTAssertEqual(snapshot.availabilityOffDevices, 1) + } + // Behavior: averageLinkQuality is the integer mean of linkQuality // values reported across non-coordinator devices, and is nil when // no device reports a linkQuality. This powers Mesh → Average LQI. @@ -115,6 +153,7 @@ final class HomeSnapshotTests: XCTestCase { private func makeSnapshot( devices: [Device], + availability: [String: Bool] = [:], states: [String: [String: JSONValue]], panID: Int? = nil, isPermitJoinActive: Bool = false, @@ -122,7 +161,7 @@ final class HomeSnapshotTests: XCTestCase { ) -> HomeSnapshot { HomeSnapshot( devices: devices, - availability: [:], + availability: availability, states: states, isConnected: true, isBridgeOnline: true, diff --git a/ShellbeeTests/Unit/NetworkAnalysisTests.swift b/ShellbeeTests/Unit/NetworkAnalysisTests.swift index b1abd81..5d92467 100644 --- a/ShellbeeTests/Unit/NetworkAnalysisTests.swift +++ b/ShellbeeTests/Unit/NetworkAnalysisTests.swift @@ -29,6 +29,22 @@ final class NetworkAnalysisTests: XCTestCase { )) } + @MainActor + + func testAvailabilityOffOnlyMatchesUntrackedDevices() { + let device = dev() + + XCTAssertTrue(DeviceCondition.availabilityOff.matches( + device: device, state: [:], availabilityStatus: .untracked + )) + XCTAssertFalse(DeviceCondition.online.matches( + device: device, state: [:], availabilityStatus: .untracked + )) + XCTAssertFalse(DeviceCondition.offline.matches( + device: device, state: [:], availabilityStatus: .untracked + )) + } + // MARK: - batteryLow (threshold = 20) @MainActor From b1c937d8993ecc3f3c290c05bb098c143bab7283 Mon Sep 17 00:00:00 2001 From: pespinel Date: Sun, 3 May 2026 22:56:13 +0200 Subject: [PATCH 2/2] fix: label disabled availability as untracked --- Shellbee/Core/Models/NetworkAnalysis.swift | 2 +- Shellbee/Features/Devices/DeviceListViewModel.swift | 4 ++-- Shellbee/Features/Devices/DeviceRowView.swift | 2 +- Shellbee/Features/Home/HomeDevicesCard.swift | 2 +- Shellbee/Shared/Components/DeviceCard.swift | 2 +- .../Shared/Components/DeviceCardFooterBar.swift | 2 +- ShellbeeTests/Unit/HomeSnapshotTests.swift | 13 +++++-------- 7 files changed, 12 insertions(+), 15 deletions(-) diff --git a/Shellbee/Core/Models/NetworkAnalysis.swift b/Shellbee/Core/Models/NetworkAnalysis.swift index 7665c72..f5f5345 100644 --- a/Shellbee/Core/Models/NetworkAnalysis.swift +++ b/Shellbee/Core/Models/NetworkAnalysis.swift @@ -14,7 +14,7 @@ enum DeviceCondition: String, CaseIterable, Sendable { case updatesAvailable = "Updates Available" case online = "Online" case offline = "Offline" - case availabilityOff = "Availability off" + case availabilityOff = "Untracked" case batteryLow = "Low Battery" case weakSignal = "Bad Signal" case interviewing = "Interviewing" diff --git a/Shellbee/Features/Devices/DeviceListViewModel.swift b/Shellbee/Features/Devices/DeviceListViewModel.swift index dba9211..11f9dbd 100644 --- a/Shellbee/Features/Devices/DeviceListViewModel.swift +++ b/Shellbee/Features/Devices/DeviceListViewModel.swift @@ -19,7 +19,7 @@ enum DeviceFilter: Hashable { case .category(let c): return c.label case .updatesAvailable: return "Updates" case .offline: return "Offline" - case .availabilityOff: return "Availability off" + case .availabilityOff: return "Untracked" case .batteryLow: return "Low Battery" case .weakSignal: return "Bad Signal" case .interviewing: return "Interviewing" @@ -53,7 +53,7 @@ enum DeviceStatusFilter: String, CaseIterable, Hashable { case all = "All" case online = "Online" case offline = "Offline" - case availabilityOff = "Availability off" + case availabilityOff = "Untracked" case updatesAvailable = "Updates Available" case batteryLow = "Low Battery" case weakSignal = "Bad Signal" diff --git a/Shellbee/Features/Devices/DeviceRowView.swift b/Shellbee/Features/Devices/DeviceRowView.swift index 3374dc5..c05615d 100644 --- a/Shellbee/Features/Devices/DeviceRowView.swift +++ b/Shellbee/Features/Devices/DeviceRowView.swift @@ -67,7 +67,7 @@ struct DeviceRowView: View { } else if let checkResult { checkResultLabel(checkResult) } else if !device.availabilityTrackingEnabled { - Label("Availability off", systemImage: "minus.circle") + Label("Untracked", systemImage: "minus.circle") .font(.caption.weight(.semibold)) .foregroundStyle(.secondary) .labelStyle(.titleAndIcon) diff --git a/Shellbee/Features/Home/HomeDevicesCard.swift b/Shellbee/Features/Home/HomeDevicesCard.swift index 0f777c9..f11fecf 100644 --- a/Shellbee/Features/Home/HomeDevicesCard.swift +++ b/Shellbee/Features/Home/HomeDevicesCard.swift @@ -33,7 +33,7 @@ struct HomeDevicesCard: View { 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: "Availability off", + statButton(.availabilityOff, value: "\(snapshot.availabilityOffDevices)", label: "Untracked", valueColor: snapshot.availabilityOffDevices > 0 ? .secondary : .primary) } } diff --git a/Shellbee/Shared/Components/DeviceCard.swift b/Shellbee/Shared/Components/DeviceCard.swift index 8f8fc32..a3b1edf 100644 --- a/Shellbee/Shared/Components/DeviceCard.swift +++ b/Shellbee/Shared/Components/DeviceCard.swift @@ -286,7 +286,7 @@ struct DeviceCard: View { } if !device.availabilityTrackingEnabled { - return "Availability off" + return "Untracked" } return isAvailable ? "Online" : "Offline" diff --git a/Shellbee/Shared/Components/DeviceCardFooterBar.swift b/Shellbee/Shared/Components/DeviceCardFooterBar.swift index 65322f6..103c4b8 100644 --- a/Shellbee/Shared/Components/DeviceCardFooterBar.swift +++ b/Shellbee/Shared/Components/DeviceCardFooterBar.swift @@ -86,7 +86,7 @@ struct DeviceCardFooterBar: View { } if !device.availabilityTrackingEnabled { - return "Availability off" + return "Untracked" } return isAvailable ? "Online" : "Offline" diff --git a/ShellbeeTests/Unit/HomeSnapshotTests.swift b/ShellbeeTests/Unit/HomeSnapshotTests.swift index 81405cd..29f5572 100644 --- a/ShellbeeTests/Unit/HomeSnapshotTests.swift +++ b/ShellbeeTests/Unit/HomeSnapshotTests.swift @@ -52,19 +52,16 @@ final class HomeSnapshotTests: XCTestCase { XCTAssertEqual(snapshot.availabilityOffDevices, 1) } - func testAvailabilityDisabledInBridgeConfigDoesNotCountOffline() { - let store = AppStore() - let remote = DeviceFixture.remote( + func testAvailabilityOffDeviceDoesNotCountOffline() { + var remote = DeviceFixture.remote( ieee: "0x00000000000000f1", name: "Untracked Remote" ) - store.apply(.devices([remote])) - store.apply(.bridgeInfo(BridgeInfoFixture.withDeviceAvailabilityDisabled())) - store.apply(.deviceAvailability(friendlyName: remote.friendlyName, available: false)) + remote.availability = .bool(false) let snapshot = makeSnapshot( - devices: store.devices, - availability: store.deviceAvailability, + devices: [remote], + availability: [remote.friendlyName: false], states: [:] )