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
1 change: 1 addition & 0 deletions Shellbee/App/AppNavigationState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ enum DeviceQuickFilter: Hashable {
case all
case online
case offline
case availabilityOff
case batteryLow
case weakSignal
case interviewing
Expand Down
21 changes: 21 additions & 0 deletions Shellbee/Core/Models/BridgeInfo.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down
8 changes: 7 additions & 1 deletion Shellbee/Core/Models/Device.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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
}
}

Expand Down
33 changes: 30 additions & 3 deletions Shellbee/Core/Models/NetworkAnalysis.swift
Original file line number Diff line number Diff line change
@@ -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 = "Untracked"
case batteryLow = "Low Battery"
case weakSignal = "Bad Signal"
case interviewing = "Interviewing"
Expand All @@ -14,27 +25,43 @@ 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"
case .unsupported: return "exclamationmark.triangle"
}
}

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 }
switch otaStatus?.phase {
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
)
}
}
24 changes: 23 additions & 1 deletion Shellbee/Core/Store/AppStore+Devices.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion Shellbee/Core/Store/AppStore+Events.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ extension AppStore {
} else {
bridgeInfo = info
}
syncConfiguredAvailability()
case .bridgeState(let state):
bridgeOnline = state == "online"
case .devices(let list):
Expand All @@ -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):
Expand Down
11 changes: 9 additions & 2 deletions Shellbee/Features/Devices/DeviceListViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ enum DeviceFilter: Hashable {
case category(Device.Category)
case updatesAvailable
case offline
case availabilityOff
case batteryLow
case weakSignal
case interviewing
Expand All @@ -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 "Untracked"
case .batteryLow: return "Low Battery"
case .weakSignal: return "Bad Signal"
case .interviewing: return "Interviewing"
Expand All @@ -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"
Expand All @@ -50,6 +53,7 @@ enum DeviceStatusFilter: String, CaseIterable, Hashable {
case all = "All"
case online = "Online"
case offline = "Offline"
case availabilityOff = "Untracked"
case updatesAvailable = "Updates Available"
case batteryLow = "Low Battery"
case weakSignal = "Bad Signal"
Expand All @@ -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"
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
)
}
Expand Down
5 changes: 5 additions & 0 deletions Shellbee/Features/Devices/DeviceRowView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,11 @@ struct DeviceRowView: View {
.foregroundStyle(.blue)
} else if let checkResult {
checkResultLabel(checkResult)
} else if !device.availabilityTrackingEnabled {
Label("Untracked", systemImage: "minus.circle")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
.labelStyle(.titleAndIcon)
} else if !isAvailable {
Text("Offline")
.font(.caption.weight(.medium))
Expand Down
2 changes: 2 additions & 0 deletions Shellbee/Features/Home/HomeCardComponents.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ struct HomeStatCell: View {
Text(label)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
.minimumScaleFactor(0.85)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
Expand Down
2 changes: 2 additions & 0 deletions Shellbee/Features/Home/HomeDevicesCard.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: "Untracked",
valueColor: snapshot.availabilityOffDevices > 0 ? .secondary : .primary)
}
}

Expand Down
18 changes: 13 additions & 5 deletions Shellbee/Features/Home/HomeSnapshot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)")
Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions Shellbee/Shared/Components/DeviceCard.swift
Original file line number Diff line number Diff line change
Expand Up @@ -285,18 +285,24 @@ struct DeviceCard: View {
return "Interviewing"
}

if !device.availabilityTrackingEnabled {
return "Untracked"
}

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"
}

Expand Down
6 changes: 5 additions & 1 deletion Shellbee/Shared/Components/DeviceCardFooterBar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -85,12 +85,17 @@ struct DeviceCardFooterBar: View {
return "Interviewing"
}

if !device.availabilityTrackingEnabled {
return "Untracked"
}

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
}

Expand Down Expand Up @@ -154,4 +159,3 @@ struct DeviceCardFooterBar: View {
.padding()
.background(Color(.secondarySystemGroupedBackground))
}

Loading
Loading