diff --git a/Shellbee.xcodeproj/project.pbxproj b/Shellbee.xcodeproj/project.pbxproj index df85c4a..36e18e9 100644 --- a/Shellbee.xcodeproj/project.pbxproj +++ b/Shellbee.xcodeproj/project.pbxproj @@ -160,6 +160,8 @@ Features/Groups/AddGroupMemberDeviceRow.swift, Features/Groups/AddGroupMembersSheet.swift, Features/Groups/AddGroupSheet.swift, + Features/Groups/GroupAvatarPickerSheet.swift, + Features/Groups/GroupAvatarStore.swift, Features/Groups/GroupCard.swift, Features/Groups/GroupCardFooterBar.swift, Features/Groups/GroupCardHeader.swift, @@ -207,12 +209,17 @@ Features/Notifications/FastTrackBanner.swift, Features/Notifications/InAppNotificationBanner.swift, Features/Notifications/InAppNotificationOverlay.swift, + Features/Onboarding/OnboardingConnectPage.swift, + Features/Onboarding/OnboardingModel.swift, + Features/Onboarding/OnboardingTestPage.swift, + Features/Onboarding/OnboardingView.swift, + Features/Pairing/PairingWizardModel.swift, + Features/Pairing/PairingWizardView.swift, Features/Settings/AboutView.swift, Features/Settings/AcknowledgementsView.swift, Features/Settings/AppGeneralView.swift, Features/Settings/AppLiveActivitiesView.swift, Features/Settings/AppNotificationSettingsView.swift, - Features/Settings/AppPerformanceView.swift, Features/Settings/AvailabilitySettingsView.swift, Features/Settings/Backup/BackupPayload.swift, Features/Settings/Backup/BackupView.swift, @@ -822,7 +829,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.3.1; + MARKETING_VERSION = 1.4.0; PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_ID)"; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -863,7 +870,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.3.1; + MARKETING_VERSION = 1.4.0; PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_ID)"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -903,7 +910,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.3.1; + MARKETING_VERSION = 1.4.0; PRODUCT_BUNDLE_IDENTIFIER = "$(APP_WIDGET_BUNDLE_ID)"; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -945,7 +952,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.3.1; + MARKETING_VERSION = 1.4.0; 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 ae346fd..d114162 100644 --- a/Shellbee/App/AppEnvironment.swift +++ b/Shellbee/App/AppEnvironment.swift @@ -108,6 +108,24 @@ final class AppEnvironment { /// Renames a device with an optimistic local update so the UI changes /// immediately. If the bridge rejects the rename, AppStore reverts the /// change when `bridge/response/device/rename` arrives with status="error". + /// Asks the device to physically identify itself (blink/beep) via the + /// Zigbee Identify cluster. Z2M exposes this as a writable enum property + /// `identify` with values `["identify"]`. Fire-and-forget — there's no + /// `bridge/response/.../identify` to await, so we surface the in-progress + /// state for ~3s in the UI before clearing it. + func identifyDevice(_ friendlyName: String) { + guard !store.identifyInProgress.contains(friendlyName) else { return } + store.identifyInProgress.insert(friendlyName) + sendDeviceState(friendlyName, payload: .object(["identify": .string("identify")])) + + Task { [weak store] in + try? await Task.sleep(for: .seconds(3)) + await MainActor.run { + _ = store?.identifyInProgress.remove(friendlyName) + } + } + } + func renameDevice(from: String, to: String, homeassistantRename: Bool) { let trimmed = to.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty, trimmed != from else { return } diff --git a/Shellbee/App/ConnectionSessionController.swift b/Shellbee/App/ConnectionSessionController.swift index eb9733b..8906fac 100644 --- a/Shellbee/App/ConnectionSessionController.swift +++ b/Shellbee/App/ConnectionSessionController.swift @@ -196,7 +196,7 @@ final class ConnectionSessionController { throw Z2MError.invalidURL } - let events = try await client.connect(url: url) + let events = try await client.connect(url: url, allowInvalidCertificates: config.allowInvalidCertificates) config.save() connectionState = .connected hasBeenConnected = true diff --git a/Shellbee/App/RootView.swift b/Shellbee/App/RootView.swift index 8ae3a70..571de66 100644 --- a/Shellbee/App/RootView.swift +++ b/Shellbee/App/RootView.swift @@ -5,6 +5,8 @@ struct RootView: View { @Environment(\.scenePhase) private var scenePhase @State private var isInitializing = true @State private var pendingCrash: PendingCrash? + @AppStorage(OnboardingStep.completedKey) private var onboardingCompleted: Bool = false + @State private var showOnboarding = false var body: some View { ZStack { @@ -26,6 +28,19 @@ struct RootView: View { onDiscard: { SentryService.shared.discardPending() } ) } + .fullScreenCover(isPresented: $showOnboarding) { + OnboardingView() + .environment(environment) + } + .onChange(of: isInitializing) { _, stillInitializing in + // First-launch only. Defer until splash dismisses so the cover + // doesn't fight the splash transition. + guard !stillInitializing, + !onboardingCompleted, + environment.connectionConfig == nil + else { return } + showOnboarding = true + } .task { // Start the environment (auto-connect if config exists) await environment.start() diff --git a/Shellbee/Core/Config/AppConfig.swift b/Shellbee/Core/Config/AppConfig.swift index be2bfdb..dcaaa4d 100644 --- a/Shellbee/Core/Config/AppConfig.swift +++ b/Shellbee/Core/Config/AppConfig.swift @@ -35,10 +35,28 @@ nonisolated enum AppConfig { /// multiple log lines) reads as one notification, not four. static let notificationCoalesceWindow: TimeInterval = 1.5 - /// How long after a device first joins the network it shows the - /// "Recently Added" badge in the device list. 30 minutes covers - /// most pairing → naming → first-test workflows without lingering - /// on the homepage forever. - static let recentDeviceWindow: TimeInterval = 30 * 60 + /// Default window after a device first joins the network during + /// which it appears in the "Recently Added" section of the device + /// list. User-overridable via Settings → General; this default + /// covers most pairing → naming → first-test workflows without + /// lingering forever. + static let recentDeviceWindow: TimeInterval = recentDeviceWindowDefaultMinutes * 60 + + /// User-facing key + options for the Recently-Added window picker + /// in Settings → General. Stored as minutes. To hide the section + /// entirely the user toggles "Show Recents" off in the device + /// list's Sort menu — that's the single source of truth for + /// visibility, this picker only controls the window length. + static let recentDeviceWindowKey = "DeviceList.recentWindowMinutes" + static let recentDeviceWindowDefaultMinutes: TimeInterval = 30 + static let recentDeviceWindowOptionsMinutes: [Int] = [5, 15, 30, 60, 120, 240, 1440] + + /// Resolves the active window (in seconds) honoring the user's + /// stored preference if any, falling back to the default. + static var configuredRecentDeviceWindow: TimeInterval { + let raw = UserDefaults.standard.object(forKey: recentDeviceWindowKey) as? Int + let minutes = raw.map(TimeInterval.init) ?? recentDeviceWindowDefaultMinutes + return minutes * 60 + } } } diff --git a/Shellbee/Core/Models/BridgeInfo.swift b/Shellbee/Core/Models/BridgeInfo.swift index d0e44c0..08c8977 100644 --- a/Shellbee/Core/Models/BridgeInfo.swift +++ b/Shellbee/Core/Models/BridgeInfo.swift @@ -9,6 +9,12 @@ struct BridgeInfo: Codable, Sendable, Equatable { let permitJoin: Bool let permitJoinTimeout: Int? let permitJoinEnd: Int? + /// Friendly name of the router (or coordinator) the current permit-join + /// session is scoped to. `nil` when the network is open via all devices. + /// Z2M doesn't include this in `bridge/info` — we capture it from the + /// `bridge/event` `permit_join` payload and from our own outbound + /// requests to keep the wizard honest about scope. + let permitJoinTarget: String? let restartRequired: Bool let config: BridgeConfig? @@ -29,6 +35,7 @@ struct BridgeInfo: Codable, Sendable, Equatable { logLevel = try container.decode(String.self, forKey: .logLevel) permitJoin = try container.decode(Bool.self, forKey: .permitJoin) permitJoinTimeout = try container.decodeIfPresent(Int.self, forKey: .permitJoinTimeout) + permitJoinTarget = nil restartRequired = try container.decode(Bool.self, forKey: .restartRequired) config = try container.decodeIfPresent(BridgeConfig.self, forKey: .config) @@ -41,7 +48,7 @@ struct BridgeInfo: Codable, Sendable, Equatable { } // Also need an explicit memberwise init for Previews and AppStore updates - init(version: String, commit: String?, coordinator: CoordinatorInfo, network: NetworkInfo?, logLevel: String, permitJoin: Bool, permitJoinTimeout: Int?, permitJoinEnd: Int?, restartRequired: Bool, config: BridgeConfig?) { + init(version: String, commit: String?, coordinator: CoordinatorInfo, network: NetworkInfo?, logLevel: String, permitJoin: Bool, permitJoinTimeout: Int?, permitJoinEnd: Int?, permitJoinTarget: String? = nil, restartRequired: Bool, config: BridgeConfig?) { self.version = version self.commit = commit self.coordinator = coordinator @@ -50,6 +57,7 @@ struct BridgeInfo: Codable, Sendable, Equatable { self.permitJoin = permitJoin self.permitJoinTimeout = permitJoinTimeout self.permitJoinEnd = permitJoinEnd + self.permitJoinTarget = permitJoinTarget self.restartRequired = restartRequired self.config = config } @@ -64,10 +72,27 @@ struct BridgeInfo: Codable, Sendable, Equatable { permitJoin: permitJoin, permitJoinTimeout: permitJoinTimeout, permitJoinEnd: permitJoinEnd, + permitJoinTarget: permitJoinTarget, restartRequired: restartRequired ?? self.restartRequired, config: config ?? self.config ) } + + func copyUpdatingPermitJoin(enabled: Bool, timeout: Int?, target: String?) -> BridgeInfo { + BridgeInfo( + version: version, + commit: commit, + coordinator: coordinator, + network: network, + logLevel: logLevel, + permitJoin: enabled, + permitJoinTimeout: timeout, + permitJoinEnd: timeout.map { Int(Date().timeIntervalSince1970 * 1000) + ($0 * 1000) }, + permitJoinTarget: target, + restartRequired: restartRequired, + config: config + ) + } } struct BridgeConfig: Codable, Sendable, Equatable { diff --git a/Shellbee/Core/Models/Device.swift b/Shellbee/Core/Models/Device.swift index 4d0b589..16a0f22 100644 --- a/Shellbee/Core/Models/Device.swift +++ b/Shellbee/Core/Models/Device.swift @@ -13,8 +13,8 @@ struct Device: Codable, Identifiable, Sendable, Equatable, Hashable { var powerSource: String? var modelId: String? var manufacturer: String? - let interviewCompleted: Bool - let interviewing: Bool + var interviewCompleted: Bool + var interviewing: Bool var softwareBuildId: String? var dateCode: String? var endpoints: [String: JSONValue]? @@ -28,6 +28,16 @@ struct Device: Codable, Identifiable, Sendable, Equatable, Hashable { return ints.isEmpty ? [1] : ints } + /// Whether the device exposes the Zigbee Identify cluster as a writable + /// property. Z2M renders this as `{ "name": "identify", "type": "enum", + /// "property": "identify", "access": 2, "values": ["identify"] }`. + var supportsIdentify: Bool { + guard let exposes = definition?.exposes else { return false } + return exposes.flattened.contains { expose in + expose.property == "identify" && expose.isWritable + } + } + func hash(into hasher: inout Hasher) { hasher.combine(id) } static func == (lhs: Device, rhs: Device) -> Bool { lhs.ieeeAddress == rhs.ieeeAddress } diff --git a/Shellbee/Core/Networking/ConnectionConfig.swift b/Shellbee/Core/Networking/ConnectionConfig.swift index a8a80d2..ea3a0f1 100644 --- a/Shellbee/Core/Networking/ConnectionConfig.swift +++ b/Shellbee/Core/Networking/ConnectionConfig.swift @@ -11,6 +11,7 @@ struct ConnectionConfig: Codable, Sendable { var basePath: String var authToken: String? var name: String? = nil + var allowInvalidCertificates: Bool = false static let defaultPort = 8080 @@ -27,6 +28,7 @@ extension ConnectionConfig: Equatable, Hashable { && lhs.useTLS == rhs.useTLS && lhs.basePath == rhs.basePath && lhs.authToken == rhs.authToken + && lhs.allowInvalidCertificates == rhs.allowInvalidCertificates } func hash(into hasher: inout Hasher) { @@ -35,6 +37,7 @@ extension ConnectionConfig: Equatable, Hashable { hasher.combine(useTLS) hasher.combine(basePath) hasher.combine(authToken) + hasher.combine(allowInvalidCertificates) } var webSocketURL: URL? { @@ -106,7 +109,14 @@ extension ConnectionConfig { } var persistedSnapshot: PersistedSnapshot { - PersistedSnapshot(host: host, port: port, useTLS: useTLS, basePath: basePath, name: name) + PersistedSnapshot( + host: host, + port: port, + useTLS: useTLS, + basePath: basePath, + name: name, + allowInvalidCertificates: allowInvalidCertificates + ) } static func persistToken(for config: ConnectionConfig) { @@ -147,13 +157,36 @@ extension ConnectionConfig { let useTLS: Bool let basePath: String let name: String? - - init(host: String, port: Int, useTLS: Bool, basePath: String, name: String? = nil) { + let allowInvalidCertificates: Bool + + init( + host: String, + port: Int, + useTLS: Bool, + basePath: String, + name: String? = nil, + allowInvalidCertificates: Bool = false + ) { self.host = host self.port = port self.useTLS = useTLS self.basePath = basePath self.name = name + self.allowInvalidCertificates = allowInvalidCertificates + } + + enum CodingKeys: String, CodingKey { + case host, port, useTLS, basePath, name, allowInvalidCertificates + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + host = try c.decode(String.self, forKey: .host) + port = try c.decode(Int.self, forKey: .port) + useTLS = try c.decode(Bool.self, forKey: .useTLS) + basePath = try c.decode(String.self, forKey: .basePath) + name = try c.decodeIfPresent(String.self, forKey: .name) + allowInvalidCertificates = try c.decodeIfPresent(Bool.self, forKey: .allowInvalidCertificates) ?? false } var connectionConfig: ConnectionConfig { @@ -172,7 +205,8 @@ extension ConnectionConfig { useTLS: useTLS, basePath: basePath, authToken: ConnectionTokenKeychain.shared.token(for: lookup.secretLookupKey), - name: name + name: name, + allowInvalidCertificates: allowInvalidCertificates ) } } diff --git a/Shellbee/Core/Networking/Z2MDiscoveryService.swift b/Shellbee/Core/Networking/Z2MDiscoveryService.swift index 63c9413..8b5d0ed 100644 --- a/Shellbee/Core/Networking/Z2MDiscoveryService.swift +++ b/Shellbee/Core/Networking/Z2MDiscoveryService.swift @@ -2,14 +2,29 @@ import Foundation import Network import Darwin +struct DiscoveredEndpoint: Hashable, Sendable { + let host: String + let port: UInt16 + + var subtitle: String { + switch port { + case 8099: "Home Assistant add-on" + case 8080: "Discovered on your local network" + default: "Port \(port)" + } + } +} + @Observable final class Z2MDiscoveryService { - @MainActor public var discoveredHosts: Set = [] + @MainActor public var discoveredEndpoints: Set = [] @MainActor public private(set) var isScanning: Bool = false @MainActor private var scanTask: Task? - nonisolated private static let z2mPort: UInt16 = 8080 + /// Probed in order. 8080 = standalone Docker default. 8099 = Home Assistant + /// community add-on default (zigbee2mqtt/hassio-zigbee2mqtt). + nonisolated private static let z2mPorts: [UInt16] = [8080, 8099] nonisolated private static let probeTimeout: TimeInterval = AppConfig.Networking.discoveryProbeTimeout nonisolated private static let maxConcurrent = 48 @@ -17,13 +32,13 @@ final class Z2MDiscoveryService { func start() { guard !isScanning else { return } isScanning = true - discoveredHosts.removeAll() + discoveredEndpoints.removeAll() scanTask = Task.detached(priority: .userInitiated) { [weak self] in guard let self else { return } - await Self.scan { host in + await Self.scan { endpoint in Task { @MainActor [weak self] in - self?.discoveredHosts.insert(host) + self?.discoveredEndpoints.insert(endpoint) } } await MainActor.run { self.isScanning = false } @@ -39,7 +54,7 @@ final class Z2MDiscoveryService { // MARK: - Scan - nonisolated private static func scan(onFound: @Sendable @escaping (String) -> Void) async { + nonisolated private static func scan(onFound: @Sendable @escaping (DiscoveredEndpoint) -> Void) async { guard let localIP = localIPv4Address() else { return } let parts = localIP.split(separator: ".") guard parts.count == 4 else { return } @@ -51,16 +66,18 @@ final class Z2MDiscoveryService { for i in 1...254 { let host = "\(prefix).\(i)" if host == localIP { continue } - group.addTask { - await sem.acquire() - if Task.isCancelled { + for port in z2mPorts { + group.addTask { + await sem.acquire() + if Task.isCancelled { + await sem.release() + return + } + let isZ2M = await probe(host: host, port: port) await sem.release() - return - } - let isZ2M = await probe(host: host) - await sem.release() - if isZ2M { - onFound(host) + if isZ2M { + onFound(DiscoveredEndpoint(host: host, port: port)) + } } } } @@ -69,14 +86,14 @@ final class Z2MDiscoveryService { // MARK: - Probe - nonisolated private static func probe(host: String) async -> Bool { + nonisolated private static func probe(host: String, port: UInt16) async -> Bool { await withCheckedContinuation { (continuation: CheckedContinuation) in - guard let port = NWEndpoint.Port(rawValue: z2mPort) else { + guard let nwPort = NWEndpoint.Port(rawValue: port) else { continuation.resume(returning: false) return } - let conn = NWConnection(host: NWEndpoint.Host(host), port: port, using: .tcp) + let conn = NWConnection(host: NWEndpoint.Host(host), port: nwPort, using: .tcp) let state = ProbeState(continuation: continuation, connection: conn) let timeoutItem = DispatchWorkItem { state.finish(false) } @@ -84,10 +101,11 @@ final class Z2MDiscoveryService { DispatchQueue.global().asyncAfter(deadline: .now() + probeTimeout, execute: timeoutItem) let hostCopy = host + let portCopy = port conn.stateUpdateHandler = { newState in switch newState { case .ready: - let request = "GET / HTTP/1.1\r\nHost: \(hostCopy):\(z2mPort)\r\nUser-Agent: Shellbee\r\nConnection: close\r\n\r\n" + let request = "GET / HTTP/1.1\r\nHost: \(hostCopy):\(portCopy)\r\nUser-Agent: Shellbee\r\nConnection: close\r\n\r\n" if let data = request.data(using: .utf8) { conn.send(content: data, completion: .contentProcessed({ _ in })) } diff --git a/Shellbee/Core/Networking/Z2MWebSocketClient.swift b/Shellbee/Core/Networking/Z2MWebSocketClient.swift index 9f1695b..f8a00a6 100644 --- a/Shellbee/Core/Networking/Z2MWebSocketClient.swift +++ b/Shellbee/Core/Networking/Z2MWebSocketClient.swift @@ -40,7 +40,7 @@ actor Z2MWebSocketClient { ) } - func connect(url: URL) async throws -> AsyncStream { + func connect(url: URL, allowInvalidCertificates: Bool = false) async throws -> AsyncStream { // Finish any in-progress stream so old for-await loops exit cleanly. currentContinuation?.finish() currentContinuation = nil @@ -52,7 +52,7 @@ actor Z2MWebSocketClient { let wsTask = session.webSocketTask(with: url) wsTask.maximumMessageSize = Self.maximumFrameSize task = wsTask - delegate.setExpectedTask(wsTask) + delegate.setExpectedTask(wsTask, allowInvalidCertificates: allowInvalidCertificates) wsTask.resume() let firstMessage: URLSessionWebSocketTask.Message diff --git a/Shellbee/Core/Networking/Z2MWebSocketSessionDelegate.swift b/Shellbee/Core/Networking/Z2MWebSocketSessionDelegate.swift index f3f39e2..62c74dd 100644 --- a/Shellbee/Core/Networking/Z2MWebSocketSessionDelegate.swift +++ b/Shellbee/Core/Networking/Z2MWebSocketSessionDelegate.swift @@ -11,11 +11,13 @@ final class Z2MWebSocketSessionDelegate: NSObject, URLSessionWebSocketDelegate, private let lock = NSLock() private var openState: OpenState = .idle private weak var expectedTask: URLSessionWebSocketTask? + private var allowInvalidCertificates: Bool = false - func setExpectedTask(_ task: URLSessionWebSocketTask) { + func setExpectedTask(_ task: URLSessionWebSocketTask, allowInvalidCertificates: Bool) { lock.lock() expectedTask = task openState = .idle + self.allowInvalidCertificates = allowInvalidCertificates lock.unlock() } @@ -109,6 +111,29 @@ final class Z2MWebSocketSessionDelegate: NSObject, URLSessionWebSocketDelegate, resolveOpen(.failure(Z2MError.requestFailed("Connection closed before opening."))) } + func urlSession( + _ session: URLSession, + didReceive challenge: URLAuthenticationChallenge, + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + ) { + guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust, + let trust = challenge.protectionSpace.serverTrust else { + completionHandler(.performDefaultHandling, nil) + return + } + + lock.lock() + let allow = allowInvalidCertificates + lock.unlock() + + guard allow else { + completionHandler(.performDefaultHandling, nil) + return + } + + completionHandler(.useCredential, URLCredential(trust: trust)) + } + func urlSession( _ session: URLSession, task: URLSessionTask, diff --git a/Shellbee/Core/Store/AppStore+Events.swift b/Shellbee/Core/Store/AppStore+Events.swift index cc5f8bf..dffd4de 100644 --- a/Shellbee/Core/Store/AppStore+Events.swift +++ b/Shellbee/Core/Store/AppStore+Events.swift @@ -4,7 +4,33 @@ extension AppStore { func apply(_ event: Z2MEvent) { switch event { case .bridgeInfo(let info): - bridgeInfo = info + // bridge/info recomputes permitJoinEnd from `permit_join_timeout` + // every time it lands. Z2M's timeout doesn't tick down between + // snapshots, so each refresh would either jump the end forward + // (timeout still set) or zero it out (omitted on later snapshots), + // depending on the bridge — both look broken in the UI. When + // permit_join stays on, preserve the existing end and target; + // we only adopt the new ones once the bridge tells us + // permit_join has actually changed. + if info.permitJoin, + let previous = bridgeInfo, + previous.permitJoin { + bridgeInfo = BridgeInfo( + version: info.version, + commit: info.commit, + coordinator: info.coordinator, + network: info.network, + logLevel: info.logLevel, + permitJoin: true, + permitJoinTimeout: previous.permitJoinTimeout, + permitJoinEnd: previous.permitJoinEnd, + permitJoinTarget: previous.permitJoinTarget, + restartRequired: info.restartRequired, + config: info.config + ) + } else { + bridgeInfo = info + } case .bridgeState(let state): bridgeOnline = state == "online" case .devices(let list): @@ -57,6 +83,19 @@ extension AppStore { enqueueNotification(note) } } + // Capture the via-router scope on permit_join events; bridge/info + // doesn't include this, so the wizard would otherwise have no + // way to show "open via Kitchen Relay". + if event.type == "permit_join", let info = bridgeInfo { + let permitted = event.data.object?["permitted"]?.boolValue ?? false + let time = event.data.object?["time"]?.numberValue.map(Int.init) ?? info.permitJoinTimeout + let target = event.data.object?["device"]?.stringValue + bridgeInfo = info.copyUpdatingPermitJoin( + enabled: permitted, + timeout: permitted ? time : nil, + target: permitted ? target : nil + ) + } if let ieee = event.data.object?["ieee_address"]?.stringValue { switch event.type { case "device_joined": @@ -67,6 +106,41 @@ extension AppStore { case "device_interview": let name = event.data.object?["friendly_name"]?.stringValue ?? ieee let status = event.data.object?["status"]?.stringValue + + // Mirror the interview status into our local `Device` + // entry so any view observing `device.interviewing` / + // `device.interviewCompleted` updates immediately — + // without this the row only refreshes when the next + // bridge/devices snapshot lands, which can be many + // seconds later (or never if the wire path is slow). + if let idx = devices.firstIndex(where: { $0.ieeeAddress == ieee }) { + let friendlyName = devices[idx].friendlyName + switch status { + case "started": + devices[idx].interviewing = true + devices[idx].interviewCompleted = false + case "successful": + devices[idx].interviewing = false + devices[idx].interviewCompleted = true + // A device that just finished interviewing is by + // definition online — Z2M only completes interview + // when the device responds. Optimistically reflect + // that so we don't render the row as offline / + // greyed-out while we wait for the + // /availability publish to land. This is + // particularly important after a remove + re-pair + // in the same session, where the prior `false` + // availability would otherwise stick until app + // restart. + deviceAvailability[friendlyName] = true + case "failed": + devices[idx].interviewing = false + devices[idx].interviewCompleted = false + default: + break + } + } + Task { @MainActor in switch status { case "started": @@ -101,17 +175,10 @@ extension AppStore { handleOTACheckResponse(response) case .permitJoinChanged(let enabled, let remaining): if let info = bridgeInfo { - bridgeInfo = BridgeInfo( - version: info.version, - commit: info.commit, - coordinator: info.coordinator, - network: info.network, - logLevel: info.logLevel, - permitJoin: enabled, - permitJoinTimeout: remaining, - permitJoinEnd: remaining.map { Int(Date().timeIntervalSince1970 * 1000) + ($0 * 1000) }, - restartRequired: info.restartRequired, - config: info.config + bridgeInfo = info.copyUpdatingPermitJoin( + enabled: enabled, + timeout: remaining, + target: enabled ? info.permitJoinTarget : nil ) } diff --git a/Shellbee/Core/Store/AppStore.swift b/Shellbee/Core/Store/AppStore.swift index 20a9c27..364806a 100644 --- a/Shellbee/Core/Store/AppStore.swift +++ b/Shellbee/Core/Store/AppStore.swift @@ -31,6 +31,10 @@ final class AppStore { var touchlinkScanInProgress = false var touchlinkIdentifyInProgress = false var touchlinkResetInProgress = false + /// Friendly names of devices currently running an Identify (Zigbee + /// Identify cluster). The action is fire-and-forget, so the row clears + /// itself on a short timer rather than waiting for a response. + var identifyInProgress: Set = [] var pendingNotifications: [InAppNotification] = [] var fastTrackNotifications: [InAppNotification] = [] // Bumped whenever a new (non-coalesced) normal notification is enqueued. @@ -80,8 +84,13 @@ final class AppStore { deviceStates = [:] deviceAvailability = [:] pendingRenames = [] - deviceFirstSeen = [:] - UserDefaults.standard.removeObject(forKey: Self.firstSeenStoreKey) + // `deviceFirstSeen` is intentionally NOT cleared here. It's + // user-visible state ("Recently Added" in the device list) that + // should outlive a connection bounce or app restart, and the + // 30-minute window in DeviceListViewModel already self-prunes + // anything stale. Wiping it on every reconnect was killing the + // section the moment the app launched and re-established its + // session. otaUpdates = [:] logEntries = [] operationErrors = [] @@ -93,6 +102,7 @@ final class AppStore { touchlinkScanInProgress = false touchlinkIdentifyInProgress = false touchlinkResetInProgress = false + identifyInProgress = [] OTAUpdateLiveActivityCoordinator.shared.clearAll() } diff --git a/Shellbee/Features/Connection/ConnectionEditorDraft.swift b/Shellbee/Features/Connection/ConnectionEditorDraft.swift index 0472303..a93aeb0 100644 --- a/Shellbee/Features/Connection/ConnectionEditorDraft.swift +++ b/Shellbee/Features/Connection/ConnectionEditorDraft.swift @@ -7,6 +7,7 @@ struct ConnectionEditorDraft { var useTLS: Bool var basePath: String var authToken: String + var allowInvalidCertificates: Bool init( name: String = "", @@ -14,7 +15,8 @@ struct ConnectionEditorDraft { port: String = "8080", useTLS: Bool = false, basePath: String = "/", - authToken: String = "" + authToken: String = "", + allowInvalidCertificates: Bool = false ) { self.name = name self.host = host @@ -22,6 +24,7 @@ struct ConnectionEditorDraft { self.useTLS = useTLS self.basePath = basePath self.authToken = authToken + self.allowInvalidCertificates = allowInvalidCertificates } var canConnect: Bool { diff --git a/Shellbee/Features/Connection/ConnectionEditorView.swift b/Shellbee/Features/Connection/ConnectionEditorView.swift index a9464df..6f7b068 100644 --- a/Shellbee/Features/Connection/ConnectionEditorView.swift +++ b/Shellbee/Features/Connection/ConnectionEditorView.swift @@ -23,15 +23,20 @@ struct ConnectionEditorView: View { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } - - ToolbarItem(placement: .confirmationAction) { - Button("Connect") { - if viewModel.connect(using: draft) { - dismiss() - } + } + .safeAreaInset(edge: .bottom) { + Button("Connect") { + if viewModel.connect(using: draft) { + dismiss() } - .disabled(!draft.canConnect) } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .fontWeight(.semibold) + .frame(maxWidth: .infinity) + .disabled(!draft.canConnect) + .padding(.horizontal, DesignTokens.Spacing.lg) + .padding(.vertical, DesignTokens.Spacing.md) } } } diff --git a/Shellbee/Features/Connection/ConnectionFormSections.swift b/Shellbee/Features/Connection/ConnectionFormSections.swift index 18e6e22..ec14e23 100644 --- a/Shellbee/Features/Connection/ConnectionFormSections.swift +++ b/Shellbee/Features/Connection/ConnectionFormSections.swift @@ -75,44 +75,45 @@ struct ConnectionDiscoverySection: View { var body: some View { Section("Nearby Servers") { - if viewModel.isScanning { - LabeledContent("Scanning") { - ProgressView() - } - } else if viewModel.discoveredHosts.isEmpty { + // Render any servers found so far first — discovery streams hits + // as it goes, so a match should appear the moment the probe + // resolves rather than at the end of the /24 sweep. + ForEach(viewModel.discoveredEndpoints, id: \.self) { endpoint in Button { - viewModel.startDiscovery() + viewModel.presentNewServer(prefilledHost: endpoint.host, prefilledPort: endpoint.port) } label: { - Label("Scan for Zigbee2MQTT", systemImage: "magnifyingglass") - .foregroundStyle(.primary) - } - } else { - ForEach(viewModel.discoveredHosts, id: \.self) { host in - Button { - viewModel.presentNewServer(prefilledHost: host) - } label: { - HStack { - VStack(alignment: .leading, spacing: DesignTokens.Spacing.xs) { - Text(host) - .foregroundStyle(.primary) - Text("Discovered on your local network") - .font(.caption) - .foregroundStyle(.secondary) - } - Spacer() - Image(systemName: "plus.circle.fill") + HStack { + VStack(alignment: .leading, spacing: DesignTokens.Spacing.xs) { + Text("\(endpoint.host):\(String(endpoint.port))") + .foregroundStyle(.primary) + Text(endpoint.subtitle) + .font(.caption) .foregroundStyle(.secondary) } - .contentShape(Rectangle()) + Spacer() + Image(systemName: "plus.circle.fill") + .foregroundStyle(.secondary) } - .buttonStyle(.plain) + .contentShape(Rectangle()) } + .buttonStyle(.plain) + } + // Scanning indicator stays alongside results so the user knows + // the sweep is still running even after the first match arrives. + if viewModel.isScanning { + LabeledContent("Scanning") { + ProgressView() + } + } else { Button { viewModel.startDiscovery() } label: { - Text("Scan Again") - .foregroundStyle(.primary) + Label( + viewModel.discoveredEndpoints.isEmpty ? "Scan for Zigbee2MQTT" : "Scan Again", + systemImage: "magnifyingglass" + ) + .foregroundStyle(.primary) } } } @@ -153,10 +154,20 @@ struct ConnectionServerSection: View { .textInputAutocapitalization(.never) .autocorrectionDisabled() } + + if draft.useTLS { + Toggle("Allow Self-Signed Certificates", isOn: $draft.allowInvalidCertificates) + } } header: { Text("Server") } footer: { - Text("Shellbee connects to Zigbee2MQTT over WebSocket. Leave Base Path as “/” unless your server is behind a reverse proxy on a subpath.") + VStack(alignment: .leading, spacing: DesignTokens.Spacing.xs) { + Text("Shellbee connects to Zigbee2MQTT over WebSocket. Leave Base Path as “/” unless your server is behind a reverse proxy on a subpath.") + if draft.useTLS && draft.allowInvalidCertificates { + Text("Certificate validation is disabled for this server. The connection is encrypted, but anyone on the network path could impersonate the server. Only use on networks you trust.") + .foregroundStyle(.orange) + } + } } } } diff --git a/Shellbee/Features/Connection/ConnectionViewModel.swift b/Shellbee/Features/Connection/ConnectionViewModel.swift index fb5dc3b..41b84c5 100644 --- a/Shellbee/Features/Connection/ConnectionViewModel.swift +++ b/Shellbee/Features/Connection/ConnectionViewModel.swift @@ -9,9 +9,14 @@ final class ConnectionViewModel { var useTLS = false var basePath = "/" var authToken = "" + var allowInvalidCertificates = false - var discoveredHosts: [String] { - Array(environment.discovery.discoveredHosts).sorted() + var discoveredEndpoints: [DiscoveredEndpoint] { + Array(environment.discovery.discoveredEndpoints) + .sorted { lhs, rhs in + if lhs.host != rhs.host { return lhs.host < rhs.host } + return lhs.port < rhs.port + } } @MainActor @@ -90,14 +95,15 @@ final class ConnectionViewModel { environment.connect(config: config) } - func presentNewServer(prefilledHost: String? = nil) { + func presentNewServer(prefilledHost: String? = nil, prefilledPort: UInt16? = nil) { editingConnection = nil name = "" host = prefilledHost ?? "" - port = "8080" + port = prefilledPort.map(String.init) ?? "8080" useTLS = false basePath = "/" authToken = "" + allowInvalidCertificates = false isEditorPresented = true } @@ -114,7 +120,8 @@ final class ConnectionViewModel { port: port, useTLS: useTLS, basePath: basePath, - authToken: authToken + authToken: authToken, + allowInvalidCertificates: allowInvalidCertificates ) } @@ -148,6 +155,7 @@ final class ConnectionViewModel { useTLS = draft.useTLS basePath = draft.basePath authToken = draft.authToken + allowInvalidCertificates = draft.useTLS ? draft.allowInvalidCertificates : false return connectDraft() } @@ -167,7 +175,8 @@ final class ConnectionViewModel { useTLS: useTLS, basePath: basePath.isEmpty ? "/" : basePath, authToken: authToken.isEmpty ? nil : authToken, - name: trimmedName.isEmpty ? nil : trimmedName + name: trimmedName.isEmpty ? nil : trimmedName, + allowInvalidCertificates: useTLS ? allowInvalidCertificates : false ) } @@ -199,5 +208,6 @@ final class ConnectionViewModel { useTLS = config.useTLS basePath = config.basePath authToken = config.authToken ?? "" + allowInvalidCertificates = config.allowInvalidCertificates } } diff --git a/Shellbee/Features/Devices/DeviceDetailView.swift b/Shellbee/Features/Devices/DeviceDetailView.swift index 807051d..0ccb125 100644 --- a/Shellbee/Features/Devices/DeviceDetailView.swift +++ b/Shellbee/Features/Devices/DeviceDetailView.swift @@ -302,6 +302,16 @@ struct DeviceDetailView: View { } } Divider() + if device.supportsIdentify { + Button { + environment.identifyDevice(device.friendlyName) + } label: { + let identifying = environment.store.identifyInProgress.contains(device.friendlyName) + Label(identifying ? "Identifying" : "Identify", + systemImage: identifying ? "wave.3.right" : "wave.3.right.circle") + } + .disabled(environment.store.identifyInProgress.contains(device.friendlyName)) + } Button { pendingDeviceAlert = .interview(device) } label: { Label("Interview", systemImage: "questionmark.circle") } diff --git a/Shellbee/Features/Devices/DeviceListRow.swift b/Shellbee/Features/Devices/DeviceListRow.swift index 516fb5b..e739dd2 100644 --- a/Shellbee/Features/Devices/DeviceListRow.swift +++ b/Shellbee/Features/Devices/DeviceListRow.swift @@ -10,10 +10,16 @@ struct DeviceListRow: View { let otaStatus: OTAUpdateStatus? var checkResult: AppStore.DeviceCheckResult? = nil var isDeleting: Bool = false + var isIdentifying: Bool = false + /// When `false` the row renders inline (no NavigationLink wrapper, no + /// chevron, no tap highlight). Used in the pairing wizard where there + /// is no device-detail navigation destination registered. + var navigates: Bool = true let onRename: () -> Void let onRemove: () -> Void let onReconfigure: () -> Void let onInterview: () -> Void + let onIdentify: () -> Void let onUpdate: (() -> Void)? let onCheckUpdate: () -> Void let onSchedule: (() -> Void)? @@ -50,17 +56,29 @@ struct DeviceListRow: View { #endif } - var body: some View { - NavigationLink(value: device) { - DeviceRowView( - device: device, - state: state, - isAvailable: isAvailable, - otaStatus: otaStatus, - checkResult: checkResult, - isDeleting: isDeleting - ) + @ViewBuilder + private var rowBody: some View { + DeviceRowView( + device: device, + state: state, + isAvailable: isAvailable, + otaStatus: otaStatus, + checkResult: checkResult, + isDeleting: isDeleting + ) + } + + @ViewBuilder + private var rowContent: some View { + if navigates { + NavigationLink(value: device) { rowBody } + } else { + rowBody } + } + + var body: some View { + rowContent .swipeActions(edge: .leading, allowsFullSwipe: true) { if otaStatus?.phase == .scheduled, let onUnschedule { Button(action: onUnschedule) { @@ -128,8 +146,22 @@ struct DeviceListRow: View { Label("Interview", systemImage: "questionmark.circle") } .tint(.purple) + if device.supportsIdentify { + Button(action: onIdentify) { + Label(isIdentifying ? "Identifying" : "Identify", + systemImage: isIdentifying ? "wave.3.right" : "wave.3.right.circle") + } + .tint(.teal) + .disabled(isIdentifying) + } } .contextMenu { + if device.supportsIdentify { + Button(action: onIdentify) { + Label("Identify", systemImage: "wave.3.right.circle") + } + .disabled(isIdentifying) + } Button(action: onRename) { Label("Rename", systemImage: "pencil") } @@ -200,6 +232,7 @@ struct DeviceListRow: View { onRemove: {}, onReconfigure: {}, onInterview: {}, + onIdentify: {}, onUpdate: {}, onCheckUpdate: {}, onSchedule: {}, diff --git a/Shellbee/Features/Devices/DeviceListView.swift b/Shellbee/Features/Devices/DeviceListView.swift index 9ceb28a..c895dc7 100644 --- a/Shellbee/Features/Devices/DeviceListView.swift +++ b/Shellbee/Features/Devices/DeviceListView.swift @@ -7,6 +7,7 @@ struct DeviceListView: View { @State private var deviceToRename: Device? @State private var deviceToRemove: Device? @State private var pendingDeviceAlert: PendingDeviceAlert? + @State private var showPairingWizard = false private var isGrouped: Bool { viewModel.groupByCategory && !viewModel.hasActiveFilter && viewModel.searchText.isEmpty @@ -31,6 +32,12 @@ struct DeviceListView: View { .searchToolbarBehavior(.minimize) .toolbar { ToolbarItemGroup(placement: .topBarTrailing) { + Button { + showPairingWizard = true + } label: { + Image(systemName: "plus") + } + .accessibilityLabel("Add Device") DeviceFilterMenu(viewModel: viewModel, store: environment.store) DeviceFirmwareMenu() sortMenu @@ -62,6 +69,10 @@ struct DeviceListView: View { pushDeviceResettingPath(device) } } + .sheet(isPresented: $showPairingWizard) { + PairingWizardView() + .environment(environment) + } .sheet(item: $deviceToRename) { device in RenameDeviceSheet(device: device) { newName, updateHA in viewModel.renameDevice(device, to: newName, homeassistantRename: updateHA, environment: environment) @@ -213,10 +224,12 @@ private struct DeviceListContent: View { otaStatus: otaStatus, checkResult: environment.store.deviceCheckResults[device.friendlyName], isDeleting: environment.store.pendingRemovals.contains(device.friendlyName), + isIdentifying: environment.store.identifyInProgress.contains(device.friendlyName), onRename: { onRename(device) }, onRemove: { onRemove(device) }, onReconfigure: { onPendingAlert(.reconfigure(device)) }, onInterview: { onPendingAlert(.interview(device)) }, + onIdentify: { environment.identifyDevice(device.friendlyName) }, onUpdate: state.hasUpdateAvailable ? { viewModel.updateDevice(device, environment: environment) } : nil, diff --git a/Shellbee/Features/Devices/DeviceListViewModel.swift b/Shellbee/Features/Devices/DeviceListViewModel.swift index 546642a..936e34c 100644 --- a/Shellbee/Features/Devices/DeviceListViewModel.swift +++ b/Shellbee/Features/Devices/DeviceListViewModel.swift @@ -101,8 +101,9 @@ final class DeviceListViewModel { /// A device counts as "Recently Added" if it is currently interviewing, or /// if its first-seen timestamp falls within this window. The window - /// survives app close/open via the timestamps persisted in `AppStore`. - static let recentWindow: TimeInterval = AppConfig.UX.recentDeviceWindow + /// survives app close/open via the timestamps persisted in `AppStore`, + /// and is user-configurable in Settings → General. + static var recentWindow: TimeInterval { AppConfig.UX.configuredRecentDeviceWindow } var hasActiveFilter: Bool { categoryFilter != nil || typeFilter != nil || vendorFilter != nil || statusFilter != .all diff --git a/Shellbee/Features/Groups/GroupAvatarPickerSheet.swift b/Shellbee/Features/Groups/GroupAvatarPickerSheet.swift new file mode 100644 index 0000000..590fbfb --- /dev/null +++ b/Shellbee/Features/Groups/GroupAvatarPickerSheet.swift @@ -0,0 +1,96 @@ +import SwiftUI + +struct GroupAvatarPickerSheet: View { + @Environment(\.dismiss) private var dismiss + let group: Group + let memberDevices: [Device] + @Binding var selectedIEEEs: [String] + + var body: some View { + NavigationStack { + Form { + Section { + if memberDevices.isEmpty { + Text("This group has no members yet.") + .foregroundStyle(.secondary) + } else { + ForEach(memberDevices, id: \.ieeeAddress) { device in + Button { + toggle(device) + } label: { + row(for: device) + } + .buttonStyle(.plain) + } + } + } footer: { + Text("Pick up to two members. Selecting a third replaces the earliest pick. Leave both unchecked to fall back to the default.") + } + } + .navigationTitle("Group Avatar") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Reset") { + selectedIEEEs = [] + save() + } + .disabled(selectedIEEEs.isEmpty) + } + ToolbarItem(placement: .confirmationAction) { + Button("Done") { + save() + dismiss() + } + .fontWeight(.semibold) + } + } + } + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + } + + @ViewBuilder + private func row(for device: Device) -> some View { + let isSelected = selectedIEEEs.contains(device.ieeeAddress) + let order = selectedIEEEs.firstIndex(of: device.ieeeAddress).map { $0 + 1 } + HStack(spacing: DesignTokens.Spacing.md) { + DeviceImageView(device: device, isAvailable: true, + size: DesignTokens.Size.deviceActionSheetImage) + VStack(alignment: .leading, spacing: 2) { + Text(device.friendlyName) + .foregroundStyle(.primary) + Text(device.definition?.model ?? device.modelId ?? device.ieeeAddress) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + if let order { + Text("\(order)") + .font(.caption.weight(.semibold)) + .foregroundStyle(.white) + .frame(width: 22, height: 22) + .background(.tint, in: Circle()) + } else if isSelected { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.tint) + } + } + .contentShape(Rectangle()) + } + + private func toggle(_ device: Device) { + if let idx = selectedIEEEs.firstIndex(of: device.ieeeAddress) { + selectedIEEEs.remove(at: idx) + } else { + selectedIEEEs.append(device.ieeeAddress) + if selectedIEEEs.count > 2 { + selectedIEEEs.removeFirst(selectedIEEEs.count - 2) + } + } + } + + private func save() { + GroupAvatarStore.shared.save(selectedIEEEs, for: group) + } +} diff --git a/Shellbee/Features/Groups/GroupAvatarStore.swift b/Shellbee/Features/Groups/GroupAvatarStore.swift new file mode 100644 index 0000000..eb62907 --- /dev/null +++ b/Shellbee/Features/Groups/GroupAvatarStore.swift @@ -0,0 +1,50 @@ +import Foundation + +/// Persisted user choice for which group members appear in the group hero +/// avatar. Values are IEEE addresses; UserDefaults-backed and keyed by +/// group ID. Modeled as an @Observable shared store so SwiftUI views +/// re-render the moment a selection changes — UserDefaults writes alone +/// don't trigger view updates. +@Observable +final class GroupAvatarStore { + static let shared = GroupAvatarStore() + + private var cache: [Int: [String]] = [:] + + private init() {} + + /// Reads the saved selection, lazily caching the UserDefaults value so + /// repeated reads are cheap and observed property accesses funnel + /// through `cache`. + func selection(for group: Group) -> [String] { + if let cached = cache[group.id] { return cached } + let raw = UserDefaults.standard.stringArray(forKey: Self.key(for: group)) ?? [] + cache[group.id] = raw + return raw + } + + func save(_ ieees: [String], for group: Group) { + let trimmed = Array(ieees.prefix(2)) + cache[group.id] = trimmed + if trimmed.isEmpty { + UserDefaults.standard.removeObject(forKey: Self.key(for: group)) + } else { + UserDefaults.standard.set(trimmed, forKey: Self.key(for: group)) + } + } + + /// Resolve the device list for the avatar, given current group + /// membership. Honors the user's selection (filtered to current + /// members) and falls back to the first two devices when no selection + /// or all picks have left the group. + func resolvedDevices(for group: Group, members: [Device]) -> [Device] { + let selected = selection(for: group) + if !selected.isEmpty { + let pick = selected.compactMap { ieee in members.first { $0.ieeeAddress == ieee } } + if !pick.isEmpty { return Array(pick.prefix(2)) } + } + return Array(members.prefix(2)) + } + + private static func key(for group: Group) -> String { "group.avatar.\(group.id)" } +} diff --git a/Shellbee/Features/Groups/GroupCard.swift b/Shellbee/Features/Groups/GroupCard.swift index 2cd0bce..c3b2401 100644 --- a/Shellbee/Features/Groups/GroupCard.swift +++ b/Shellbee/Features/Groups/GroupCard.swift @@ -7,6 +7,22 @@ struct GroupCard: View { var onRenameTapped: (() -> Void)? = nil var displayMode: DeviceIdentityDisplayMode = .prominent + @State private var showAvatarPicker = false + @State private var avatarSelection: [String] = [] + + /// Avatar reflects the @State selection so changes from the picker + /// re-render this view immediately. Falls back to first-two when no + /// selection or none of the stored IEEEs are still members. + private var avatarDevices: [Device] { + if !avatarSelection.isEmpty { + let pick = avatarSelection.compactMap { ieee in + memberDevices.first { $0.ieeeAddress == ieee } + } + if !pick.isEmpty { return Array(pick.prefix(2)) } + } + return Array(memberDevices.prefix(2)) + } + var body: some View { switch displayMode { case .prominent: @@ -28,11 +44,21 @@ struct GroupCard: View { .clipShape(RoundedRectangle(cornerRadius: DesignTokens.CornerRadius.lg, style: .continuous)) .shadow(color: .black.opacity(DesignTokens.Shadow.badgeOpacity), radius: DesignTokens.Spacing.sm, y: DesignTokens.Spacing.xs) + .sheet(isPresented: $showAvatarPicker) { + GroupAvatarPickerSheet( + group: group, + memberDevices: memberDevices, + selectedIEEEs: $avatarSelection + ) + } + .onAppear { + avatarSelection = GroupAvatarStore.shared.selection(for: group) + } } private var compactHeader: some View { HStack(alignment: .center, spacing: DesignTokens.Spacing.lg) { - GroupIconView(memberDevices: memberDevices, size: DesignTokens.Size.deviceCardImage * 0.68) + GroupIconView(memberDevices: avatarDevices, size: DesignTokens.Size.deviceCardImage * 0.68) .clipShape(Circle()) VStack(alignment: .leading, spacing: DesignTokens.Spacing.xs) { @@ -73,7 +99,17 @@ struct GroupCard: View { private var identityRow: some View { HStack(alignment: .center, spacing: DesignTokens.Spacing.lg) { - GroupIconView(memberDevices: memberDevices, size: DesignTokens.Size.deviceCardImage * 0.80) + Button { + let stored = GroupAvatarStore.shared.selection(for: group) + avatarSelection = stored.isEmpty + ? Array(memberDevices.prefix(2).map(\.ieeeAddress)) + : stored + showAvatarPicker = true + } label: { + GroupIconView(memberDevices: avatarDevices, size: DesignTokens.Size.deviceCardImage * 0.80) + } + .buttonStyle(.plain) + .accessibilityLabel("Choose group avatar") VStack(alignment: .leading, spacing: DesignTokens.Spacing.xs) { nameView diff --git a/Shellbee/Features/Groups/GroupCardHeader.swift b/Shellbee/Features/Groups/GroupCardHeader.swift index 0c3ac3e..3b2b0e1 100644 --- a/Shellbee/Features/Groups/GroupCardHeader.swift +++ b/Shellbee/Features/Groups/GroupCardHeader.swift @@ -28,7 +28,10 @@ struct GroupCardHeader: View { } private var avatarArea: some View { - GroupIconView(memberDevices: memberDevices, size: DesignTokens.Size.deviceCardImage) + GroupIconView( + memberDevices: GroupAvatarStore.shared.resolvedDevices(for: group, members: memberDevices), + size: DesignTokens.Size.deviceCardImage + ) } @ViewBuilder diff --git a/Shellbee/Features/Groups/GroupDetailView.swift b/Shellbee/Features/Groups/GroupDetailView.swift index f1a6bbe..a55b460 100644 --- a/Shellbee/Features/Groups/GroupDetailView.swift +++ b/Shellbee/Features/Groups/GroupDetailView.swift @@ -60,7 +60,11 @@ struct GroupDetailView: View { BeautifulPayloadView(payload: groupState) } - GroupMembersSection(group: currentGroup) { memberToRemove = $0 } + GroupMembersSection( + group: currentGroup, + onRemove: { memberToRemove = $0 }, + onAdd: { showAddMembers = true } + ) GroupScenesSection(group: currentGroup, viewModel: viewModel) } diff --git a/Shellbee/Features/Groups/GroupMembersSection.swift b/Shellbee/Features/Groups/GroupMembersSection.swift index 2a410f4..3c4cdbb 100644 --- a/Shellbee/Features/Groups/GroupMembersSection.swift +++ b/Shellbee/Features/Groups/GroupMembersSection.swift @@ -4,40 +4,74 @@ struct GroupMembersSection: View { @Environment(AppEnvironment.self) private var environment let group: Group let onRemove: (GroupMember) -> Void + var onAdd: (() -> Void)? = nil var body: some View { + if group.members.isEmpty { + emptySection + } else { + populatedSection + } + } + + private var emptySection: some View { Section("Members") { - if group.members.isEmpty { - ContentUnavailableView( - "No Members", - systemImage: "person.2", - description: Text("Add devices to this group.") - ) - .listRowInsets(EdgeInsets()) - .listRowBackground(Color.clear) - } else { - ForEach(group.members, id: \.ieeeAddress) { member in - let device = environment.store.devices.first { $0.ieeeAddress == member.ieeeAddress } - SwiftUI.Group { - if let device { - NavigationLink(value: device) { - GroupMemberRow( - member: member, - device: device, - state: environment.store.state(for: device.friendlyName), - isAvailable: environment.store.isAvailable(device.friendlyName) - ) - } - } else { - GroupMemberRow(member: member, device: nil, state: [:], isAvailable: false) - } + VStack(spacing: DesignTokens.Spacing.lg) { + Image(systemName: "person.2") + .font(.system(size: 44, weight: .regular)) + .foregroundStyle(.secondary) + VStack(spacing: DesignTokens.Spacing.xs) { + Text("No Members") + .font(.headline) + Text("Add devices to this group to control them together.") + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, DesignTokens.Spacing.lg) + } + if let onAdd { + Button(action: onAdd) { + Label("Add Members", systemImage: "plus") + .fontWeight(.semibold) + .padding(.horizontal, DesignTokens.Spacing.md) } - .swipeActions(edge: .trailing, allowsFullSwipe: false) { - Button(role: .destructive) { - onRemove(member) - } label: { - Label("Remove", systemImage: "trash") + .buttonStyle(.borderedProminent) + .controlSize(.large) + .buttonBorderShape(.capsule) + .padding(.top, DesignTokens.Spacing.xs) + } + } + .frame(maxWidth: .infinity) + .padding(.vertical, DesignTokens.Spacing.xl) + .listRowInsets(EdgeInsets()) + .listRowBackground(Color.clear) + } + } + + @ViewBuilder + private var populatedSection: some View { + Section("Members") { + ForEach(group.members, id: \.ieeeAddress) { member in + let device = environment.store.devices.first { $0.ieeeAddress == member.ieeeAddress } + SwiftUI.Group { + if let device { + NavigationLink(value: device) { + GroupMemberRow( + member: member, + device: device, + state: environment.store.state(for: device.friendlyName), + isAvailable: environment.store.isAvailable(device.friendlyName) + ) } + } else { + GroupMemberRow(member: member, device: nil, state: [:], isAvailable: false) + } + } + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button(role: .destructive) { + onRemove(member) + } label: { + Label("Remove", systemImage: "trash") } } } diff --git a/Shellbee/Features/Groups/GroupRowView.swift b/Shellbee/Features/Groups/GroupRowView.swift index 1880245..28a295b 100644 --- a/Shellbee/Features/Groups/GroupRowView.swift +++ b/Shellbee/Features/Groups/GroupRowView.swift @@ -31,7 +31,10 @@ struct GroupRowView: View { } private var groupLeadingVisual: some View { - GroupIconView(memberDevices: memberDevices, size: DesignTokens.Size.summaryRowSymbolFrame) + GroupIconView( + memberDevices: GroupAvatarStore.shared.resolvedDevices(for: group, members: memberDevices), + size: DesignTokens.Size.summaryRowSymbolFrame + ) } private var memberSubtitle: String { diff --git a/Shellbee/Features/Home/HomeBridgeCard.swift b/Shellbee/Features/Home/HomeBridgeCard.swift index 3ed974f..44aa38c 100644 --- a/Shellbee/Features/Home/HomeBridgeCard.swift +++ b/Shellbee/Features/Home/HomeBridgeCard.swift @@ -151,8 +151,12 @@ struct HomeBridgeCard: View { ) } if snapshot.isPermitJoinActive { - let label = snapshot.permitJoinRemaining.map { "Permit Join open — \($0)s remaining" } ?? "Permit Join open" - HomeCardAlertRow(symbol: "person.crop.circle.badge.plus", title: label, color: .orange, action: nil) + // The countdown lives in the toolbar's active sheet (TimelineView + // reading from bridgeInfo). Mirroring it here updated only when + // the snapshot recomputed, which made the card look broken — and + // a static "open" badge is the right granularity for an at-a-glance + // status row anyway. + HomeCardAlertRow(symbol: "person.crop.circle.badge.plus", title: "Permit Join open", color: .orange, action: nil) } if let pct = health?.process?.memoryPercent, pct > 30 { HomeCardAlertRow( diff --git a/Shellbee/Features/Home/HomeView.swift b/Shellbee/Features/Home/HomeView.swift index 109cd58..bcea078 100644 --- a/Shellbee/Features/Home/HomeView.swift +++ b/Shellbee/Features/Home/HomeView.swift @@ -6,11 +6,27 @@ struct HomeView: View { @State private var isPermitJoinConfigPresented = false @State private var isPermitJoinActivePresented = false @State private var showingRestartAlert = false - @State private var permitJoinStartTime: Date? - @State private var permitJoinDuration: Int = 0 - @State private var permitJoinTargetName: String? @State private var showingMeshDetail = false + /// Active permit-join state derived from bridgeInfo so the toolbar + /// sheet shows the correct countdown / via-target regardless of where + /// permit-join was started (Home toolbar, Add Devices wizard, an + /// external Z2M client, etc.). + private var permitJoinTotalDuration: Int { + environment.store.bridgeInfo?.permitJoinTimeout ?? 0 + } + + private var permitJoinStartTime: Date? { + guard let end = environment.store.bridgeInfo?.permitJoinEnd, + permitJoinTotalDuration > 0 else { return nil } + let endSeconds = TimeInterval(end) / 1000 + return Date(timeIntervalSince1970: endSeconds - TimeInterval(permitJoinTotalDuration)) + } + + private var permitJoinTargetName: String? { + environment.store.bridgeInfo?.permitJoinTarget + } + @AppStorage(HomeSettings.recentEventsCountKey) private var recentEventsCount: Int = HomeSettings.recentEventsCountDefault @State private var showingAllLogs = false @State private var layout = HomeLayoutStore() @@ -141,7 +157,7 @@ struct HomeView: View { .sheet(isPresented: $isPermitJoinActivePresented) { PermitJoinActiveSheet( startTime: permitJoinStartTime, - totalDuration: permitJoinDuration, + totalDuration: permitJoinTotalDuration, targetName: permitJoinTargetName, onStop: { sendPermitJoin(duration: 0, deviceName: nil) } ) @@ -152,13 +168,6 @@ struct HomeView: View { } message: { Text("Restarting the bridge will apply pending configuration changes and temporarily disconnect all Zigbee devices.") } - .onChange(of: snapshot.isPermitJoinActive) { _, isActive in - if !isActive { - permitJoinStartTime = nil - permitJoinDuration = 0 - permitJoinTargetName = nil - } - } } } @@ -223,18 +232,26 @@ struct HomeView: View { } private func startPermitJoin(duration: Int, deviceName: String?) { - permitJoinStartTime = Date() - permitJoinDuration = duration - permitJoinTargetName = deviceName?.isEmpty == false ? deviceName : nil sendPermitJoin(duration: duration, deviceName: deviceName) } private func sendPermitJoin(duration: Int, deviceName: String?) { - var payload: [String: JSONValue] = ["time": .int(duration)] + var payload: [String: JSONValue] = ["time": .int(duration), "value": .bool(duration > 0)] if let deviceName, !deviceName.isEmpty { payload["device"] = .string(deviceName) } environment.send(topic: Z2MTopics.Request.permitJoin, payload: .object(payload)) + + // Optimistically reflect the request in bridgeInfo so toolbar + // sheets / wizard / etc. update the moment the user taps, + // without waiting for the bridge round-trip. + if let info = environment.store.bridgeInfo { + environment.store.bridgeInfo = info.copyUpdatingPermitJoin( + enabled: duration > 0, + timeout: duration > 0 ? duration : nil, + target: duration > 0 ? deviceName : nil + ) + } } } diff --git a/Shellbee/Features/Onboarding/OnboardingConnectPage.swift b/Shellbee/Features/Onboarding/OnboardingConnectPage.swift new file mode 100644 index 0000000..3b90fd6 --- /dev/null +++ b/Shellbee/Features/Onboarding/OnboardingConnectPage.swift @@ -0,0 +1,70 @@ +import SwiftUI + +struct OnboardingConnectPage: View { + @Environment(AppEnvironment.self) private var environment + @State private var viewModel: ConnectionViewModel? + + var body: some View { + SwiftUI.Group { + if let viewModel { + List { + Section { + Label { + Text("Tap a server below to connect, or use the **+** button to add one manually if your bridge isn't on this network.") + .font(.subheadline) + .foregroundStyle(.secondary) + } icon: { + Image(systemName: "info.circle") + .foregroundStyle(.tint) + } + } + ConnectionHistorySection(viewModel: viewModel) + ConnectionDiscoverySection(viewModel: viewModel) + } + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + viewModel.presentNewServer() + } label: { + Image(systemName: "plus") + } + .accessibilityLabel("Add Server Manually") + } + } + .sheet(isPresented: bindingForEditor(viewModel)) { + NavigationStack { + ConnectionEditorView(viewModel: viewModel) + } + } + .alert("Connection Error", isPresented: errorBinding(viewModel)) { + Button("OK", role: .cancel) {} + } message: { + Text(viewModel.errorMessage ?? "") + } + .onAppear { viewModel.startDiscovery() } + .onDisappear { viewModel.stopDiscovery() } + } else { + ProgressView() + } + } + .onAppear { + if viewModel == nil { + viewModel = ConnectionViewModel(environment: environment) + } + } + } + + private func bindingForEditor(_ viewModel: ConnectionViewModel) -> Binding { + Binding( + get: { viewModel.isEditorPresented }, + set: { viewModel.isEditorPresented = $0 } + ) + } + + private func errorBinding(_ viewModel: ConnectionViewModel) -> Binding { + Binding( + get: { viewModel.errorMessage != nil }, + set: { if !$0 { viewModel.errorMessage = nil } } + ) + } +} diff --git a/Shellbee/Features/Onboarding/OnboardingModel.swift b/Shellbee/Features/Onboarding/OnboardingModel.swift new file mode 100644 index 0000000..7054036 --- /dev/null +++ b/Shellbee/Features/Onboarding/OnboardingModel.swift @@ -0,0 +1,9 @@ +import Foundation + +enum OnboardingStep: Int, CaseIterable, Identifiable { + case welcome, connect, test, done + var id: Int { rawValue } + + static let storedIndexKey = "onboardingPageIndex" + static let completedKey = "onboardingCompleted" +} diff --git a/Shellbee/Features/Onboarding/OnboardingTestPage.swift b/Shellbee/Features/Onboarding/OnboardingTestPage.swift new file mode 100644 index 0000000..cf99409 --- /dev/null +++ b/Shellbee/Features/Onboarding/OnboardingTestPage.swift @@ -0,0 +1,107 @@ +import SwiftUI + +struct OnboardingTestPage: View { + @Environment(AppEnvironment.self) private var environment + let onContinue: () -> Void + let onRetry: () -> Void + + var body: some View { + VStack(spacing: DesignTokens.Spacing.lg) { + Spacer() + + statusIcon + .font(.system(size: 64)) + + VStack(spacing: DesignTokens.Spacing.md) { + Text(statusTitle) + .font(.title2.weight(.semibold)) + if let detail = statusDetail { + Text(detail) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, DesignTokens.Spacing.lg) + } + } + + Spacer() + + actionButton + } + .onChange(of: environment.connectionState) { _, newState in + if case .connected = newState { + Task { @MainActor in + try? await Task.sleep(for: .seconds(0.6)) + onContinue() + } + } + } + } + + @ViewBuilder + private var statusIcon: some View { + switch environment.connectionState { + case .connecting, .reconnecting: + ProgressView() + .controlSize(.large) + case .connected: + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + .symbolEffect(.bounce) + case .failed, .lost: + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.red) + case .idle: + Image(systemName: "circle.dotted") + .foregroundStyle(.secondary) + } + } + + private var statusTitle: String { + switch environment.connectionState { + case .connecting: "Connecting" + case .reconnecting: "Reconnecting" + case .connected: "Connected" + case .failed: "Couldn't connect" + case .lost: "Lost the connection" + case .idle: "Waiting" + } + } + + private var statusDetail: String? { + switch environment.connectionState { + case .failed(let msg), .lost(let msg): + return msg + case .connected: + return "Pulling devices and bridge info." + default: + return nil + } + } + + @ViewBuilder + private var actionButton: some View { + switch environment.connectionState { + case .failed, .lost: + Button(action: onRetry) { + Text("Try Again") + .fontWeight(.semibold) + .frame(minWidth: 140) + } + .buttonStyle(.bordered) + .controlSize(.large) + case .connected: + Button { + onContinue() + } label: { + Text("Continue") + .fontWeight(.semibold) + .frame(minWidth: 140) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + default: + EmptyView() + } + } +} diff --git a/Shellbee/Features/Onboarding/OnboardingView.swift b/Shellbee/Features/Onboarding/OnboardingView.swift new file mode 100644 index 0000000..4680f50 --- /dev/null +++ b/Shellbee/Features/Onboarding/OnboardingView.swift @@ -0,0 +1,163 @@ +import SwiftUI + +struct OnboardingView: View { + @Environment(\.dismiss) private var dismiss + @Environment(AppEnvironment.self) private var environment + @Environment(\.colorScheme) private var colorScheme + @AppStorage(OnboardingStep.completedKey) private var completed: Bool = false + @AppStorage(OnboardingStep.storedIndexKey) private var storedIndex: Int = 0 + @State private var step: OnboardingStep = .welcome + + var body: some View { + NavigationStack { + content + .navigationTitle(title(for: step)) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + // Skip is available on the entry pages where the user + // hasn't committed to anything yet (welcome, test). Hidden + // on .connect (the user must attempt a connection) and + // .done (Get Started is the only sensible action). + if step == .welcome || step == .test { + ToolbarItem(placement: .cancellationAction) { + Button("Skip") { finish() } + } + } + } + .onAppear { + if let restored = OnboardingStep(rawValue: storedIndex) { + step = restored + } + } + .onChange(of: step) { _, newValue in + storedIndex = newValue.rawValue + } + .onChange(of: environment.connectionState) { _, newState in + // When the user kicks off a connection from the connect + // step, advance to the test page so they can watch it + // resolve (the test page auto-advances on success). + guard step == .connect else { return } + switch newState { + case .connecting, .connected, .reconnecting: + step = .test + default: + break + } + } + } + } + + @ViewBuilder + private var content: some View { + switch step { + case .welcome: + WelcomePage(onContinue: { step = .connect }) + case .connect: + OnboardingConnectPage() + case .test: + OnboardingTestPage(onContinue: { step = .done }, onRetry: { step = .connect }) + case .done: + DonePage(onFinish: finish) + } + } + + private func title(for step: OnboardingStep) -> String { + switch step { + case .welcome: "" + case .connect: "Connect" + case .test: "Testing Connection" + case .done: "All Set" + } + } + + private func finish() { + completed = true + storedIndex = 0 + dismiss() + } +} + +// MARK: - Welcome page + +private struct WelcomePage: View { + @Environment(\.colorScheme) private var colorScheme + let onContinue: () -> Void + + var body: some View { + ZStack { + HomeBackgroundGradient() + .ignoresSafeArea() + + VStack { + Spacer() + Image(colorScheme == .dark ? "SplashAppIconDark" : "SplashAppIcon") + .resizable() + .scaledToFit() + .frame(width: DesignTokens.Size.permitJoinQR, height: DesignTokens.Size.permitJoinQR) + .clipShape(RoundedRectangle(cornerRadius: DesignTokens.CornerRadius.lg, style: .continuous)) + + VStack(spacing: DesignTokens.Spacing.sm) { + Text("Welcome to Shellbee") + .font(.largeTitle.weight(.bold)) + Text("Let's get you connected.") + .foregroundStyle(.secondary) + } + .padding(.top, DesignTokens.Spacing.xl) + Spacer() + } + .padding(.horizontal, DesignTokens.Spacing.xl) + } + .safeAreaInset(edge: .bottom) { + Button("Continue", action: onContinue) + .buttonStyle(.borderedProminent) + .controlSize(.large) + .fontWeight(.semibold) + .frame(maxWidth: .infinity) + .padding(.horizontal, DesignTokens.Spacing.lg) + .padding(.vertical, DesignTokens.Spacing.md) + } + } +} + +// MARK: - Done page + +private struct DonePage: View { + @Environment(AppEnvironment.self) private var environment + let onFinish: () -> Void + + var body: some View { + VStack(spacing: DesignTokens.Spacing.lg) { + Spacer() + Image(systemName: "checkmark.seal.fill") + .font(.system(size: 80)) + .foregroundStyle(.green) + .symbolEffect(.bounce) + Text("You're all set") + .font(.largeTitle.weight(.bold)) + let count = environment.store.devices.count + if count > 0 { + Text("Connected — \(count) device\(count == 1 ? "" : "s") detected.") + .foregroundStyle(.secondary) + } else { + Text("Connected to your bridge.") + .foregroundStyle(.secondary) + } + Spacer() + } + .padding(.horizontal, DesignTokens.Spacing.xl) + .safeAreaInset(edge: .bottom) { + Button("Get Started", action: onFinish) + .buttonStyle(.borderedProminent) + .controlSize(.large) + .fontWeight(.semibold) + .frame(maxWidth: .infinity) + .padding(.horizontal, DesignTokens.Spacing.lg) + .padding(.vertical, DesignTokens.Spacing.md) + } + } +} + +#Preview { + OnboardingView() + .environment(AppEnvironment()) +} diff --git a/Shellbee/Features/Pairing/PairingWizardModel.swift b/Shellbee/Features/Pairing/PairingWizardModel.swift new file mode 100644 index 0000000..ad7dcbe --- /dev/null +++ b/Shellbee/Features/Pairing/PairingWizardModel.swift @@ -0,0 +1,55 @@ +import Foundation + +@Observable +final class PairingWizardModel { + /// Stamped when the wizard opens. Drives the "is this device part of + /// THIS pairing session?" filter — anything whose first-seen timestamp + /// predates this is from a previous session and not in scope. + let sessionStart: Date = .now + + /// Allow a small grace window so a device that joined seconds before the + /// user opened the wizard still shows up. Z2M's `device_joined` lands a + /// beat before the user's tap on most networks. + private static let graceWindow: TimeInterval = 30 + + func sessionDevices(in store: AppStore) -> [Device] { + let cutoff = sessionStart.addingTimeInterval(-Self.graceWindow) + return store.devices + .filter { $0.type != .coordinator } + .filter { device in + guard let firstSeen = store.deviceFirstSeen[device.ieeeAddress] else { return false } + return firstSeen >= cutoff + } + .sorted { lhs, rhs in + let l = store.deviceFirstSeen[lhs.ieeeAddress] ?? .distantPast + let r = store.deviceFirstSeen[rhs.ieeeAddress] ?? .distantPast + return l < r + } + } + + func interviewStatus(for device: Device) -> InterviewStatus { + if device.interviewing { return .running } + if device.interviewCompleted { return .completed } + return .pending + } + + enum InterviewStatus { + case pending, running, completed + + var label: String { + switch self { + case .pending: "Waiting" + case .running: "Interviewing" + case .completed: "Ready" + } + } + + var systemImage: String { + switch self { + case .pending: "hourglass" + case .running: "arrow.trianglehead.2.clockwise" + case .completed: "checkmark.circle.fill" + } + } + } +} diff --git a/Shellbee/Features/Pairing/PairingWizardView.swift b/Shellbee/Features/Pairing/PairingWizardView.swift new file mode 100644 index 0000000..34f7fe6 --- /dev/null +++ b/Shellbee/Features/Pairing/PairingWizardView.swift @@ -0,0 +1,303 @@ +import SwiftUI + +struct PairingWizardView: View { + @Environment(\.dismiss) private var dismiss + @Environment(AppEnvironment.self) private var environment + @State private var model = PairingWizardModel() + @State private var showCancelConfirm = false + @State private var deviceToRename: Device? + @State private var deviceToRemove: Device? + @State private var pendingDeviceAlert: PendingDeviceAlert? + + private var isPermitOpen: Bool { + environment.store.bridgeInfo?.permitJoin ?? false + } + + private var sessionDevices: [Device] { + model.sessionDevices(in: environment.store) + } + + var body: some View { + NavigationStack { + List { + permitJoinSection + if !sessionDevices.isEmpty { + Section { + ForEach(sessionDevices, id: \.ieeeAddress) { device in + wizardRow(for: device) + } + } header: { + Text("New Devices") + } footer: { + Text("Swipe a device left or right for actions, or long-press for more options.") + } + } + } + .navigationTitle("Add Devices") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + if isPermitOpen { + showCancelConfirm = true + } else { + dismiss() + } + } + } + if !sessionDevices.isEmpty { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { dismiss() } + .fontWeight(.semibold) + } + } + } + .alert("Network is still open", isPresented: $showCancelConfirm) { + Button("Keep Open") { dismiss() } + Button("Close Network", role: .destructive) { + sendPermitJoin(duration: 0, deviceName: nil) + dismiss() + } + } message: { + Text("Devices can still join until the timer runs out. Close it now or leave it open in the background?") + } + .sheet(item: $deviceToRename) { device in + RenameDeviceSheet(device: device) { newName, updateHA in + environment.renameDevice(from: device.friendlyName, to: newName, homeassistantRename: updateHA) + } + } + .sheet(item: $deviceToRemove) { device in + RemoveDeviceSheet(device: device) { force, block in + environment.send(topic: Z2MTopics.Request.deviceRemove, payload: .object([ + "id": .string(device.friendlyName), + "force": .bool(force), + "block": .bool(block) + ])) + } + } + .alert( + pendingDeviceAlert?.title ?? "", + isPresented: Binding( + get: { pendingDeviceAlert != nil }, + set: { if !$0 { pendingDeviceAlert = nil } } + ), + presenting: pendingDeviceAlert + ) { alert in + Button(alert.confirmTitle, role: alert.role) { + switch alert { + case .reconfigure(let device): + environment.send(topic: Z2MTopics.Request.deviceConfigure, + payload: .object(["id": .string(device.friendlyName)])) + case .interview(let device): + environment.send(topic: Z2MTopics.Request.deviceInterview, + payload: .object(["id": .string(device.friendlyName)])) + } + pendingDeviceAlert = nil + } + Button("Cancel", role: .cancel) { pendingDeviceAlert = nil } + } message: { alert in + Text(alert.message) + } + } + } + + // MARK: - Permit join section + + @ViewBuilder + private var permitJoinSection: some View { + if isPermitOpen { + Section { + NetworkOpenRow( + permitEnd: environment.store.bridgeInfo?.permitJoinEnd, + target: environment.store.bridgeInfo?.permitJoinTarget + ) + } footer: { + if sessionDevices.isEmpty { + networkOpenHint + } + } + } else { + PermitJoinControls(onStart: { duration, target in + sendPermitJoin(duration: duration, deviceName: target) + }) + } + } + + private var networkOpenHint: some View { + VStack(alignment: .leading, spacing: DesignTokens.Spacing.xs) { + Text("Put your device into pairing mode now. New devices appear below as they join.") + NavigationLink { + DocBrowserView() + } label: { + HStack(spacing: DesignTokens.Spacing.xs) { + Image(systemName: "books.vertical") + Text("Not sure how? Browse the device library") + } + .font(.footnote) + } + .padding(.top, DesignTokens.Spacing.xs) + } + } + + // MARK: - Per-device row (reuses the live device list row) + + @ViewBuilder + private func wizardRow(for device: Device) -> some View { + let state = environment.store.state(for: device.friendlyName) + let isAvailable = environment.store.isAvailable(device.friendlyName) + let otaStatus = environment.store.otaStatus(for: device.friendlyName) + DeviceListRow( + device: device, + state: state, + isAvailable: isAvailable, + otaStatus: otaStatus, + checkResult: environment.store.deviceCheckResults[device.friendlyName], + isDeleting: environment.store.pendingRemovals.contains(device.friendlyName), + isIdentifying: environment.store.identifyInProgress.contains(device.friendlyName), + navigates: false, + onRename: { deviceToRename = device }, + onRemove: { deviceToRemove = device }, + onReconfigure: { pendingDeviceAlert = .reconfigure(device) }, + onInterview: { pendingDeviceAlert = .interview(device) }, + onIdentify: { environment.identifyDevice(device.friendlyName) }, + onUpdate: state.hasUpdateAvailable + ? { + environment.store.startOTAUpdate(for: device.friendlyName) + environment.send(topic: Z2MTopics.Request.deviceOTAUpdate, + payload: .object(["id": .string(device.friendlyName)])) + } + : nil, + onCheckUpdate: { + environment.store.startOTACheck(for: device.friendlyName) + environment.send(topic: Z2MTopics.Request.deviceOTACheck, + payload: .object(["id": .string(device.friendlyName)])) + }, + onSchedule: state.hasUpdateAvailable + ? { + environment.store.startOTASchedule(for: device.friendlyName) + environment.send(topic: Z2MTopics.Request.deviceOTASchedule, + payload: .object(["id": .string(device.friendlyName)])) + } + : nil, + onUnschedule: { + environment.store.cancelOTASchedule(for: device.friendlyName) + environment.send(topic: Z2MTopics.Request.deviceOTAUnschedule, + payload: .object(["id": .string(device.friendlyName)])) + } + ) + } + + private func sendPermitJoin(duration: Int, deviceName: String?) { + var payload: [String: JSONValue] = ["time": .int(duration), "value": .bool(duration > 0)] + if let deviceName, !deviceName.isEmpty { payload["device"] = .string(deviceName) } + environment.send(topic: Z2MTopics.Request.permitJoin, payload: .object(payload)) + + // Optimistically reflect the request in bridgeInfo so the wizard row + // updates the moment the user taps — the bridge's `permit_join` + // event will overwrite this with the authoritative state shortly. + if let info = environment.store.bridgeInfo { + environment.store.bridgeInfo = info.copyUpdatingPermitJoin( + enabled: duration > 0, + timeout: duration > 0 ? duration : nil, + target: duration > 0 ? deviceName : nil + ) + } + } +} + +// MARK: - Permit-join controls (network closed) + +private struct PermitJoinControls: View { + @Environment(AppEnvironment.self) private var environment + @State private var duration: Int = 254 + @State private var targetName: String? + let onStart: (Int, String?) -> Void + + var body: some View { + Section { + Picker("Duration", selection: $duration) { + Text("1 min").tag(60) + Text("2 min").tag(120) + Text("3 min").tag(180) + Text("~4 min").tag(254) + } + Picker("Via", selection: $targetName) { + Text("All devices").tag(String?.none) + ForEach(routerTargets, id: \.ieeeAddress) { device in + Text(device.friendlyName).tag(String?.some(device.friendlyName)) + } + } + } header: { + Text("Open the network") + } footer: { + Text("Put the device into pairing mode after you start. Routers can extend coverage to corners the coordinator can't reach.") + } + + Section { + Button { + onStart(duration, targetName) + } label: { + Label("Start Permit Join", systemImage: "dot.radiowaves.up.forward") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .listRowInsets(EdgeInsets()) + .listRowBackground(Color.clear) + } + } + + private var routerTargets: [Device] { + environment.store.devices + .filter { $0.type == .router } + .sorted { $0.friendlyName.localizedCompare($1.friendlyName) == .orderedAscending } + } +} + +// MARK: - Network-open status row + +private struct NetworkOpenRow: View { + let permitEnd: Int? + let target: String? + + var body: some View { + TimelineView(.periodic(from: .now, by: 1)) { ctx in + let remaining = remainingSeconds(at: ctx.date) + HStack(spacing: DesignTokens.Spacing.md) { + Image(systemName: "dot.radiowaves.up.forward") + .foregroundStyle(.white) + .frame(width: DesignTokens.Size.settingsIconFrame, height: DesignTokens.Size.settingsIconFrame) + .background(.green, in: RoundedRectangle(cornerRadius: DesignTokens.CornerRadius.sm, style: .continuous)) + .symbolEffect(.pulse) + VStack(alignment: .leading, spacing: 2) { + if let target, !target.isEmpty { + Text("Network is open via \(target)") + .foregroundStyle(.primary) + } else { + Text("Network is open") + .foregroundStyle(.primary) + } + if let remaining { + Text(String(format: "%d:%02d remaining", remaining / 60, remaining % 60)) + .font(.caption) + .foregroundStyle(.secondary) + .monospacedDigit() + .contentTransition(.numericText(countsDown: true)) + } + } + Spacer() + } + } + } + + private func remainingSeconds(at date: Date) -> Int? { + guard let end = permitEnd else { return nil } + let now = Int(date.timeIntervalSince1970 * 1000) + return max((end - now) / 1000, 0) + } +} + +#Preview { + PairingWizardView() + .environment(AppEnvironment()) +} diff --git a/Shellbee/Features/Settings/AppGeneralView.swift b/Shellbee/Features/Settings/AppGeneralView.swift index 4b11e6f..d6d4247 100644 --- a/Shellbee/Features/Settings/AppGeneralView.swift +++ b/Shellbee/Features/Settings/AppGeneralView.swift @@ -3,6 +3,7 @@ import SwiftUI struct AppGeneralView: View { @AppStorage("appearanceMode") private var appearanceMode: AppearanceMode = .system @AppStorage(HomeSettings.recentEventsCountKey) private var recentEventsCount: Int = HomeSettings.recentEventsCountDefault + @AppStorage(AppConfig.UX.recentDeviceWindowKey) private var recentDeviceWindowMinutes: Int = Int(AppConfig.UX.recentDeviceWindowDefaultMinutes) @AppStorage(ConnectionSessionController.maxReconnectAttemptsKey) private var maxReconnectAttempts: Int = ConnectionSessionController.defaultMaxReconnectAttempts @AppStorage(DeveloperSettings.modeEnabledKey) private var developerModeEnabled: Bool = false @State private var consent = CrashReportingConsent.shared @@ -30,6 +31,18 @@ struct AppGeneralView: View { Text("Number of recent events shown on the Home page.") } + Section { + Picker("Recently Added Window", selection: $recentDeviceWindowMinutes) { + ForEach(AppConfig.UX.recentDeviceWindowOptionsMinutes, id: \.self) { minutes in + Text(label(forMinutes: minutes)).tag(minutes) + } + } + } header: { + Text("Devices") + } footer: { + Text("How long a freshly-paired device stays in the “Recently Added” section of the device list. To hide the section entirely, toggle “Show Recents” off in the Sort menu on the Devices tab.") + } + Section { InlineIntField( "Reconnect Limit", @@ -64,6 +77,19 @@ struct AppGeneralView: View { } .navigationTitle("General") } + + private func label(forMinutes minutes: Int) -> String { + switch minutes { + case 1..<60: return "\(minutes) min" + case 60: return "1 hour" + case 120: return "2 hours" + case 240: return "4 hours" + case 1440: return "1 day" + default: + let hours = minutes / 60 + return hours == 1 ? "1 hour" : "\(hours) hours" + } + } } #Preview { diff --git a/Shellbee/Features/Settings/AppPerformanceView.swift b/Shellbee/Features/Settings/AppPerformanceView.swift deleted file mode 100644 index 5cf1b14..0000000 --- a/Shellbee/Features/Settings/AppPerformanceView.swift +++ /dev/null @@ -1,35 +0,0 @@ -import SwiftUI - -struct AppPerformanceView: View { - @AppStorage(OTABulkOperationQueue.concurrencyKey) private var concurrency: Int = OTABulkOperationQueue.defaultConcurrency - @AppStorage(OTABulkOperationQueue.checkTimeoutKey) private var checkTimeout: Int = OTABulkOperationQueue.defaultCheckTimeoutSeconds - - var body: some View { - Form { - Section { - InlineIntField( - "Concurrency", - value: $concurrency, - unit: "requests", - range: OTABulkOperationQueue.concurrencyRange - ) - InlineIntField( - "Device Timeout", - value: $checkTimeout, - unit: "s", - range: OTABulkOperationQueue.checkTimeoutRange - ) - } footer: { - Text("Controls how Shellbee paces \"Check All for Updates\". Higher concurrency finishes faster but can flood the Zigbee coordinator.") - } - } - .navigationTitle("Bulk OTA") - .navigationBarTitleDisplayMode(.inline) - } -} - -#Preview { - NavigationStack { - AppPerformanceView() - } -} diff --git a/Shellbee/Features/Settings/OTASettingsView.swift b/Shellbee/Features/Settings/OTASettingsView.swift index 68ed930..43b20b9 100644 --- a/Shellbee/Features/Settings/OTASettingsView.swift +++ b/Shellbee/Features/Settings/OTASettingsView.swift @@ -11,6 +11,9 @@ struct OTASettingsView: View { @State private var imageBlockResponseDelay: Int = 250 @State private var defaultMaximumDataSize: Int = 50 + @AppStorage(OTABulkOperationQueue.concurrencyKey) private var bulkConcurrency: Int = OTABulkOperationQueue.defaultConcurrency + @AppStorage(OTABulkOperationQueue.checkTimeoutKey) private var bulkCheckTimeout: Int = OTABulkOperationQueue.defaultCheckTimeoutSeconds + @State private var showingDiscardAlert = false private var hasChanges: Bool { @@ -61,6 +64,25 @@ struct OTASettingsView: View { } footer: { Text("Advanced transfer settings. Request Timeout is how long to wait for each block response (default 150,000 ms). Block Delay adds a pause between blocks to reduce load. Block Size controls how many bytes are sent per block (default 50).") } + + Section { + InlineIntField( + "Concurrency", + value: $bulkConcurrency, + unit: "requests", + range: OTABulkOperationQueue.concurrencyRange + ) + InlineIntField( + "Device Timeout", + value: $bulkCheckTimeout, + unit: "s", + range: OTABulkOperationQueue.checkTimeoutRange + ) + } header: { + Text("Bulk Check") + } footer: { + Text("Controls how Shellbee paces \"Check All for Updates\". Higher concurrency finishes faster but can flood the Zigbee coordinator.") + } } .navigationTitle("OTA Updates") .navigationBarTitleDisplayMode(.inline) diff --git a/Shellbee/Features/Settings/ServerDetailView.swift b/Shellbee/Features/Settings/ServerDetailView.swift index 946d12b..dc09ceb 100644 --- a/Shellbee/Features/Settings/ServerDetailView.swift +++ b/Shellbee/Features/Settings/ServerDetailView.swift @@ -20,6 +20,11 @@ struct ServerDetailView: View { CopyableRow(label: "Port", value: String(config.port)) CopyableRow(label: "URL", value: config.displayURL) CopyableRow(label: "Protocol", value: config.useTLS ? "WSS (TLS)" : "WS (Plain)") + if config.useTLS && config.allowInvalidCertificates { + LabeledContent("Certificate") { + Text("Self-signed allowed").foregroundStyle(.orange) + } + } LabeledContent("Authentication") { if let token = config.authToken, !token.isEmpty { Text(String(repeating: "•", count: min(token.count, 12))) diff --git a/Shellbee/Features/Settings/SettingsView.swift b/Shellbee/Features/Settings/SettingsView.swift index 26f195a..dd68852 100644 --- a/Shellbee/Features/Settings/SettingsView.swift +++ b/Shellbee/Features/Settings/SettingsView.swift @@ -3,8 +3,8 @@ import SwiftUI struct SettingsView: View { @Environment(AppEnvironment.self) private var environment @AppStorage(DeveloperSettings.modeEnabledKey) private var developerModeEnabled: Bool = false - @State private var showingRestartAlert = false @State private var showingDisconnectConfirmation = false + @State private var showingRestartAlert = false var body: some View { NavigationStack { @@ -173,9 +173,6 @@ struct SettingsView: View { NavigationLink { AppNotificationSettingsView() } label: { settingsLabel(title: "Notifications", systemImage: "bell.badge.fill", color: .red) } - NavigationLink { AppPerformanceView() } label: { - settingsLabel(title: "Bulk OTA", systemImage: "arrow.down.circle.dotted", color: .blue) - } NavigationLink { AboutView() } label: { settingsLabel(title: "About", systemImage: "info.circle.fill", color: Color(.systemGray2)) } @@ -196,11 +193,9 @@ struct SettingsView: View { private var dangerSection: some View { Section { - if environment.connectionState.isConnected { - Button("Restart Zigbee2MQTT", role: .destructive) { - showingRestartAlert = true - } - } + // Restart Zigbee2MQTT lives on Settings → Server (`ServerDetailView`), + // alongside the rest of the bridge-level controls. Mirroring it + // here was a duplicate path with no extra surface area. Button("Disconnect", role: .destructive) { showingDisconnectConfirmation = true }