From 8036a8d5db58f8b21fd23f5cc404eff2e2c82195 Mon Sep 17 00:00:00 2001 From: Nick Stefanelli Date: Thu, 9 Apr 2026 19:54:56 -0400 Subject: [PATCH 01/15] Add L10n strings for camera detection settings (PR2) Adds 11 kiosk.camera.* keys covering motion detection, presence detection, face detection, sensitivity (low/medium/high), wake triggers, and footer copy. Regenerates SwiftGen output with updated Strings.swift accessors under L10n.Kiosk.Camera and L10n.Kiosk.Camera.Sensitivity. --- .../Resources/en.lproj/Localizable.strings | 11 ++++++ Sources/Shared/Assets/Assets.swift | 30 ++++++++++++++++ .../Resources/Swiftgen/FrontendStrings.swift | 34 +++++++++---------- 3 files changed, 58 insertions(+), 17 deletions(-) diff --git a/Sources/App/Resources/en.lproj/Localizable.strings b/Sources/App/Resources/en.lproj/Localizable.strings index 582f1e6f86..85687e2c03 100644 --- a/Sources/App/Resources/en.lproj/Localizable.strings +++ b/Sources/App/Resources/en.lproj/Localizable.strings @@ -554,6 +554,17 @@ This server requires a client certificate (mTLS) but the operation was cancelled "kiosk.security.section" = "Security & Display"; "kiosk.security.taps_required" = "Taps Required: %li"; "kiosk.title" = "Kiosk Mode"; +"kiosk.camera.section" = "Camera Detection"; +"kiosk.camera.motion_detection" = "Motion Detection"; +"kiosk.camera.sensitivity" = "Sensitivity"; +"kiosk.camera.sensitivity.low" = "Low"; +"kiosk.camera.sensitivity.medium" = "Medium"; +"kiosk.camera.sensitivity.high" = "High"; +"kiosk.camera.wake_on_motion" = "Wake on Motion"; +"kiosk.camera.presence_detection" = "Presence Detection"; +"kiosk.camera.face_detection" = "Face Detection"; +"kiosk.camera.wake_on_presence" = "Wake on Presence"; +"kiosk.camera.footer" = "Uses the front camera to detect motion and human presence. Requires camera permission."; "legacy_actions.disclaimer" = "Legacy iOS Actions are not the recommended way to interact with Home Assistant anymore, please use Scripts, Scenes and Automations directly in your Widgets, Apple Watch and CarPlay."; "live_activity.empty_state" = "No active Live Activities"; "live_activity.end_all.button" = "End All Activities"; diff --git a/Sources/Shared/Assets/Assets.swift b/Sources/Shared/Assets/Assets.swift index 1a3cbd51c6..d7fc57b60c 100644 --- a/Sources/Shared/Assets/Assets.swift +++ b/Sources/Shared/Assets/Assets.swift @@ -8,6 +8,9 @@ #elseif os(tvOS) || os(watchOS) import UIKit #endif +#if canImport(SwiftUI) + import SwiftUI +#endif // Deprecated typealiases @available(*, deprecated, renamed: "ImageAsset.Image", message: "This typealias will be removed in SwiftGen 7.0") @@ -68,6 +71,13 @@ public struct ImageAsset { return result } #endif + + #if canImport(SwiftUI) + @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 10.15, *) + public var swiftUIImage: SwiftUI.Image { + SwiftUI.Image(asset: self) + } + #endif } public extension ImageAsset.Image { @@ -86,6 +96,26 @@ public extension ImageAsset.Image { } } +#if canImport(SwiftUI) +@available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 10.15, *) +public extension SwiftUI.Image { + init(asset: ImageAsset) { + let bundle = BundleToken.bundle + self.init(asset.name, bundle: bundle) + } + + init(asset: ImageAsset, label: Text) { + let bundle = BundleToken.bundle + self.init(asset.name, bundle: bundle, label: label) + } + + init(decorative asset: ImageAsset) { + let bundle = BundleToken.bundle + self.init(decorative: asset.name, bundle: bundle) + } +} +#endif + // swiftlint:disable convenience_type private final class BundleToken { static let bundle: Bundle = { diff --git a/Sources/Shared/Resources/Swiftgen/FrontendStrings.swift b/Sources/Shared/Resources/Swiftgen/FrontendStrings.swift index 193709bb69..544292b0f9 100644 --- a/Sources/Shared/Resources/Swiftgen/FrontendStrings.swift +++ b/Sources/Shared/Resources/Swiftgen/FrontendStrings.swift @@ -3,7 +3,7 @@ import Foundation -// swiftlint:disable superfluous_disable_command file_length implicit_return +// swiftlint:disable superfluous_disable_command file_length implicit_return prefer_self_in_static_references // MARK: - Strings @@ -11,33 +11,33 @@ import Foundation // swiftlint:disable nesting type_body_length type_name vertical_whitespace_opening_braces public enum FrontendStrings { /// Calendar - public static var panelCalendar: String { return FrontendStrings.tr("Frontend", "panel::calendar") } + public static var panelCalendar: String { return FrontendStrings.tr("Frontend", "panel::calendar", fallback: "Calendar") } /// Settings - public static var panelConfig: String { return FrontendStrings.tr("Frontend", "panel::config") } + public static var panelConfig: String { return FrontendStrings.tr("Frontend", "panel::config", fallback: "Settings") } /// Developer tools - public static var panelDeveloperTools: String { return FrontendStrings.tr("Frontend", "panel::developer_tools") } + public static var panelDeveloperTools: String { return FrontendStrings.tr("Frontend", "panel::developer_tools", fallback: "Developer tools") } /// Energy - public static var panelEnergy: String { return FrontendStrings.tr("Frontend", "panel::energy") } + public static var panelEnergy: String { return FrontendStrings.tr("Frontend", "panel::energy", fallback: "Energy") } /// History - public static var panelHistory: String { return FrontendStrings.tr("Frontend", "panel::history") } + public static var panelHistory: String { return FrontendStrings.tr("Frontend", "panel::history", fallback: "History") } /// Activity - public static var panelLogbook: String { return FrontendStrings.tr("Frontend", "panel::logbook") } + public static var panelLogbook: String { return FrontendStrings.tr("Frontend", "panel::logbook", fallback: "Activity") } /// Mailbox - public static var panelMailbox: String { return FrontendStrings.tr("Frontend", "panel::mailbox") } + public static var panelMailbox: String { return FrontendStrings.tr("Frontend", "panel::mailbox", fallback: "Mailbox") } /// Map - public static var panelMap: String { return FrontendStrings.tr("Frontend", "panel::map") } + public static var panelMap: String { return FrontendStrings.tr("Frontend", "panel::map", fallback: "Map") } /// Media - public static var panelMediaBrowser: String { return FrontendStrings.tr("Frontend", "panel::media_browser") } + public static var panelMediaBrowser: String { return FrontendStrings.tr("Frontend", "panel::media_browser", fallback: "Media") } /// Profile - public static var panelProfile: String { return FrontendStrings.tr("Frontend", "panel::profile") } + public static var panelProfile: String { return FrontendStrings.tr("Frontend", "panel::profile", fallback: "Profile") } /// Shopping list - public static var panelShoppingList: String { return FrontendStrings.tr("Frontend", "panel::shopping_list") } + public static var panelShoppingList: String { return FrontendStrings.tr("Frontend", "panel::shopping_list", fallback: "Shopping list") } /// Overview - public static var panelStates: String { return FrontendStrings.tr("Frontend", "panel::states") } + public static var panelStates: String { return FrontendStrings.tr("Frontend", "panel::states", fallback: "Overview") } /// Unavailable - public static var stateDefaultUnavailable: String { return FrontendStrings.tr("Frontend", "state::default::unavailable") } + public static var stateDefaultUnavailable: String { return FrontendStrings.tr("Frontend", "state::default::unavailable", fallback: "Unavailable") } /// Unknown - public static var stateDefaultUnknown: String { return FrontendStrings.tr("Frontend", "state::default::unknown") } + public static var stateDefaultUnknown: String { return FrontendStrings.tr("Frontend", "state::default::unknown", fallback: "Unknown") } } // swiftlint:enable explicit_type_interface function_parameter_count identifier_name line_length // swiftlint:enable nesting type_body_length type_name vertical_whitespace_opening_braces @@ -45,8 +45,8 @@ public enum FrontendStrings { // MARK: - Implementation Details extension FrontendStrings { - private static func tr(_ table: String, _ key: String, _ args: CVarArg...) -> String { - let format = Current.localized.string(key, table) + private static func tr(_ table: String, _ key: String, _ args: CVarArg..., fallback value: String) -> String { + let format = Current.localized.string(key, table, value) return String(format: format, locale: Locale.current, arguments: args) } } From 200c390cc6df1e9f8fe00ad2fcaa40c97f3d0098 Mon Sep 17 00:00:00 2001 From: Nick Stefanelli Date: Thu, 9 Apr 2026 19:55:40 -0400 Subject: [PATCH 02/15] Add MotionSensitivity enum and camera detection settings to KioskSettings Adds MotionSensitivity enum (low/medium/high with threshold values) and 6 camera detection properties to KioskSettings: cameraMotionEnabled, cameraMotionSensitivity, wakeOnCameraMotion, cameraPresenceEnabled, cameraFaceDetectionEnabled, wakeOnCameraPresence. Replaces synthesized Codable with a custom init(from:) for backwards-compatible decoding so existing persisted settings without camera fields decode without error. --- Sources/App/Kiosk/KioskSettings.swift | 160 ++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) diff --git a/Sources/App/Kiosk/KioskSettings.swift b/Sources/App/Kiosk/KioskSettings.swift index 01f6e2da68..92284e1146 100644 --- a/Sources/App/Kiosk/KioskSettings.swift +++ b/Sources/App/Kiosk/KioskSettings.swift @@ -117,6 +117,144 @@ public struct KioskSettings: Codable, Equatable { /// Number of taps required for secret exit gesture public var secretExitGestureTaps: Int = 3 + + // MARK: - Camera Detection + + /// Enable camera-based motion detection + public var cameraMotionEnabled: Bool = false + + /// Motion detection sensitivity + public var cameraMotionSensitivity: MotionSensitivity = .medium + + /// Wake the screen when camera motion is detected + public var wakeOnCameraMotion: Bool = false + + /// Enable camera-based presence (person) detection + public var cameraPresenceEnabled: Bool = false + + /// Enable face detection (requires presence detection) + public var cameraFaceDetectionEnabled: Bool = false + + /// Wake the screen when a person is detected by camera + public var wakeOnCameraPresence: Bool = false + + // MARK: - Codable (backwards-compatible decoding) + + enum CodingKeys: String, CodingKey { + case isKioskModeEnabled + case requireDeviceAuthentication + case hideStatusBar + case preventAutoLock + case brightnessControlEnabled + case manualBrightness + case screensaverEnabled + case screensaverMode + case screensaverTimeout + case screensaverDimLevel + case pixelShiftEnabled + case pixelShiftAmount + case pixelShiftInterval + case clockShowSeconds + case clockShowDate + case clockUse24HourFormat + case clockStyle + case secretExitGestureEnabled + case secretExitGestureCorner + case secretExitGestureTaps + case cameraMotionEnabled + case cameraMotionSensitivity + case wakeOnCameraMotion + case cameraPresenceEnabled + case cameraFaceDetectionEnabled + case wakeOnCameraPresence + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + // Core + self.isKioskModeEnabled = try container.decodeIfPresent(Bool.self, forKey: .isKioskModeEnabled) ?? false + self.requireDeviceAuthentication = try container.decodeIfPresent( + Bool.self, + forKey: .requireDeviceAuthentication + ) ?? false + self.hideStatusBar = try container.decodeIfPresent(Bool.self, forKey: .hideStatusBar) ?? true + self.preventAutoLock = try container.decodeIfPresent(Bool.self, forKey: .preventAutoLock) ?? true + + // Brightness + self.brightnessControlEnabled = try container.decodeIfPresent( + Bool.self, + forKey: .brightnessControlEnabled + ) ?? true + self.manualBrightness = try container.decodeIfPresent(Float.self, forKey: .manualBrightness) ?? 0.8 + + // Screensaver + self.screensaverEnabled = try container.decodeIfPresent(Bool.self, forKey: .screensaverEnabled) ?? true + self.screensaverMode = try container.decodeIfPresent( + ScreensaverMode.self, + forKey: .screensaverMode + ) ?? .clock + self.screensaverTimeout = try container.decodeIfPresent( + TimeInterval.self, + forKey: .screensaverTimeout + ) ?? 300 + self.screensaverDimLevel = try container.decodeIfPresent(Float.self, forKey: .screensaverDimLevel) ?? 0.1 + self.pixelShiftEnabled = try container.decodeIfPresent(Bool.self, forKey: .pixelShiftEnabled) ?? true + self.pixelShiftAmount = try container.decodeIfPresent(CGFloat.self, forKey: .pixelShiftAmount) ?? 10 + self.pixelShiftInterval = try container.decodeIfPresent( + TimeInterval.self, + forKey: .pixelShiftInterval + ) ?? 60 + + // Clock + self.clockShowSeconds = try container.decodeIfPresent(Bool.self, forKey: .clockShowSeconds) ?? false + self.clockShowDate = try container.decodeIfPresent(Bool.self, forKey: .clockShowDate) ?? true + self.clockUse24HourFormat = try container.decodeIfPresent( + Bool.self, + forKey: .clockUse24HourFormat + ) ?? true + self.clockStyle = try container.decodeIfPresent(ClockStyle.self, forKey: .clockStyle) ?? .large + + // Secret Exit Gesture + self.secretExitGestureEnabled = try container.decodeIfPresent( + Bool.self, + forKey: .secretExitGestureEnabled + ) ?? true + self.secretExitGestureCorner = try container.decodeIfPresent( + ScreenCorner.self, + forKey: .secretExitGestureCorner + ) ?? .bottomRight + self.secretExitGestureTaps = try container.decodeIfPresent( + Int.self, + forKey: .secretExitGestureTaps + ) ?? 3 + + // Camera Detection (new in PR2 — all default to false/medium) + self.cameraMotionEnabled = try container.decodeIfPresent( + Bool.self, + forKey: .cameraMotionEnabled + ) ?? false + self.cameraMotionSensitivity = try container.decodeIfPresent( + MotionSensitivity.self, + forKey: .cameraMotionSensitivity + ) ?? .medium + self.wakeOnCameraMotion = try container.decodeIfPresent( + Bool.self, + forKey: .wakeOnCameraMotion + ) ?? false + self.cameraPresenceEnabled = try container.decodeIfPresent( + Bool.self, + forKey: .cameraPresenceEnabled + ) ?? false + self.cameraFaceDetectionEnabled = try container.decodeIfPresent( + Bool.self, + forKey: .cameraFaceDetectionEnabled + ) ?? false + self.wakeOnCameraPresence = try container.decodeIfPresent( + Bool.self, + forKey: .wakeOnCameraPresence + ) ?? false + } } // MARK: - Enums @@ -190,6 +328,28 @@ public enum ClockStyle: String, Codable, CaseIterable { } } +public enum MotionSensitivity: String, Codable, CaseIterable { + case low + case medium + case high + + public var threshold: Float { + switch self { + case .low: return 0.05 + case .medium: return 0.02 + case .high: return 0.008 + } + } + + public var displayName: String { + switch self { + case .low: return L10n.Kiosk.Camera.Sensitivity.low + case .medium: return L10n.Kiosk.Camera.Sensitivity.medium + case .high: return L10n.Kiosk.Camera.Sensitivity.high + } + } +} + // MARK: - Screen State (for sensors) public enum ScreenState: String, Codable { From a74b5cb655c842505462ac6d33f6dfcb0d34d26e Mon Sep 17 00:00:00 2001 From: Nick Stefanelli Date: Thu, 9 Apr 2026 19:57:37 -0400 Subject: [PATCH 03/15] test(kiosk): add unit tests for MotionSensitivity and camera settings --- .../App/Kiosk/KioskCameraDetection.test.swift | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 Tests/App/Kiosk/KioskCameraDetection.test.swift diff --git a/Tests/App/Kiosk/KioskCameraDetection.test.swift b/Tests/App/Kiosk/KioskCameraDetection.test.swift new file mode 100644 index 0000000000..590a22120f --- /dev/null +++ b/Tests/App/Kiosk/KioskCameraDetection.test.swift @@ -0,0 +1,120 @@ +import Foundation +@testable import HomeAssistant +import Shared +import Testing + +// MARK: - MotionSensitivity Tests + +struct MotionSensitivityTests { + @Test func thresholdValues() async throws { + #expect(MotionSensitivity.low.threshold == 0.05) + #expect(MotionSensitivity.medium.threshold == 0.02) + #expect(MotionSensitivity.high.threshold == 0.008) + } + + @Test func thresholdOrdering() async throws { + // Higher sensitivity = lower threshold value + #expect(MotionSensitivity.high.threshold < MotionSensitivity.medium.threshold) + #expect(MotionSensitivity.medium.threshold < MotionSensitivity.low.threshold) + } + + @Test func displayNames() async throws { + #expect(MotionSensitivity.low.displayName == L10n.Kiosk.Camera.Sensitivity.low) + #expect(MotionSensitivity.medium.displayName == L10n.Kiosk.Camera.Sensitivity.medium) + #expect(MotionSensitivity.high.displayName == L10n.Kiosk.Camera.Sensitivity.high) + } + + @Test func caseCount() async throws { + #expect(MotionSensitivity.allCases.count == 3) + } + + @Test func codableRoundtrip() async throws { + for sensitivity in MotionSensitivity.allCases { + let encoded = try JSONEncoder().encode(sensitivity) + let decoded = try JSONDecoder().decode(MotionSensitivity.self, from: encoded) + #expect(decoded == sensitivity) + } + } + + @Test func rawValues() async throws { + #expect(MotionSensitivity.low.rawValue == "low") + #expect(MotionSensitivity.medium.rawValue == "medium") + #expect(MotionSensitivity.high.rawValue == "high") + } +} + +// MARK: - Camera Settings Tests + +struct KioskCameraSettingsTests { + @Test func defaultCameraSettings() async throws { + let settings = KioskSettings() + #expect(settings.cameraMotionEnabled == false) + #expect(settings.cameraMotionSensitivity == .medium) + #expect(settings.wakeOnCameraMotion == false) + #expect(settings.cameraPresenceEnabled == false) + #expect(settings.cameraFaceDetectionEnabled == false) + #expect(settings.wakeOnCameraPresence == false) + } + + @Test func cameraSettingsRoundtrip() async throws { + var settings = KioskSettings() + settings.cameraMotionEnabled = true + settings.cameraMotionSensitivity = .high + settings.wakeOnCameraMotion = true + settings.cameraPresenceEnabled = true + settings.cameraFaceDetectionEnabled = true + settings.wakeOnCameraPresence = true + + let encoded = try JSONEncoder().encode(settings) + let decoded = try JSONDecoder().decode(KioskSettings.self, from: encoded) + + #expect(decoded.cameraMotionEnabled == true) + #expect(decoded.cameraMotionSensitivity == .high) + #expect(decoded.wakeOnCameraMotion == true) + #expect(decoded.cameraPresenceEnabled == true) + #expect(decoded.cameraFaceDetectionEnabled == true) + #expect(decoded.wakeOnCameraPresence == true) + } + + @Test func backwardsCompatibility() async throws { + // Simulate PR1-era settings JSON (no camera fields) + let pr1JSON = """ + { + "isKioskModeEnabled": true, + "screensaverMode": "clock", + "clockStyle": "large", + "secretExitGestureCorner": "bottomRight", + "secretExitGestureTaps": 3 + } + """.data(using: .utf8)! + + let decoded = try JSONDecoder().decode(KioskSettings.self, from: pr1JSON) + + // PR1 fields preserved + #expect(decoded.isKioskModeEnabled == true) + #expect(decoded.screensaverMode == .clock) + + // Camera fields default correctly + #expect(decoded.cameraMotionEnabled == false) + #expect(decoded.cameraMotionSensitivity == .medium) + #expect(decoded.wakeOnCameraMotion == false) + #expect(decoded.cameraPresenceEnabled == false) + #expect(decoded.cameraFaceDetectionEnabled == false) + #expect(decoded.wakeOnCameraPresence == false) + } + + @Test func settingsEqualityWithCameraFields() async throws { + var settings1 = KioskSettings() + settings1.cameraMotionEnabled = true + settings1.cameraMotionSensitivity = .high + + var settings2 = KioskSettings() + settings2.cameraMotionEnabled = true + settings2.cameraMotionSensitivity = .high + + #expect(settings1 == settings2) + + settings2.cameraMotionSensitivity = .low + #expect(settings1 != settings2) + } +} From 74f8a881516e344bf4c905efd901134c90d6ff81 Mon Sep 17 00:00:00 2001 From: Nick Stefanelli Date: Thu, 9 Apr 2026 19:59:53 -0400 Subject: [PATCH 04/15] feat(kiosk): add camera motion detector --- .../Camera/KioskCameraMotionDetector.swift | 269 ++++++++++++++++++ 1 file changed, 269 insertions(+) create mode 100644 Sources/App/Kiosk/Camera/KioskCameraMotionDetector.swift diff --git a/Sources/App/Kiosk/Camera/KioskCameraMotionDetector.swift b/Sources/App/Kiosk/Camera/KioskCameraMotionDetector.swift new file mode 100644 index 0000000000..680410b977 --- /dev/null +++ b/Sources/App/Kiosk/Camera/KioskCameraMotionDetector.swift @@ -0,0 +1,269 @@ +import AVFoundation +import Combine +import CoreImage +import Shared +import UIKit + +// MARK: - Kiosk Camera Motion Detector + +/// Detects motion using the device camera for wake-on-motion functionality +@MainActor +public final class KioskCameraMotionDetector: NSObject, ObservableObject { + // MARK: - Published State + + /// Whether motion detection is currently active + @Published public private(set) var isActive: Bool = false + + /// Whether motion was detected recently + @Published public private(set) var motionDetected: Bool = false + + /// Current motion level (0.0 - 1.0) + @Published public private(set) var motionLevel: Float = 0 + + /// Camera authorization status + @Published public private(set) var authorizationStatus: AVAuthorizationStatus = .notDetermined + + /// Error message if detection failed + @Published public private(set) var errorMessage: String? + + // MARK: - Callbacks + + /// Called when motion is detected + public var onMotionDetected: (() -> Void)? + + // MARK: - Private + + private var captureSession: AVCaptureSession? + private var videoOutput: AVCaptureVideoDataOutput? + private let processingQueue = DispatchQueue(label: "com.home-assistant.kiosk.motion", qos: .userInitiated) + + private var previousFrame: CIImage? + private var motionThreshold: Float = 0.02 + private var cooldownTimer: Timer? + private var isInCooldown: Bool = false + + // MARK: - Initialization + + override init() { + super.init() + checkAuthorizationStatus() + } + + deinit { + captureSession?.stopRunning() + captureSession = nil + cooldownTimer?.invalidate() + cooldownTimer = nil + } + + // MARK: - Public Methods + + /// Start motion detection + public func start() { + guard !isActive else { return } + + checkAuthorizationStatus() + + guard authorizationStatus == .authorized else { + Current.Log.warning("Camera not authorized for motion detection (status: \(authorizationStatus.rawValue))") + return + } + + Current.Log.info("Starting camera motion detection") + + updateSensitivity() + setupCaptureSession() + + processingQueue.async { [weak self] in + self?.captureSession?.startRunning() + DispatchQueue.main.async { + self?.isActive = true + self?.errorMessage = nil + } + } + } + + /// Stop motion detection + public func stop() { + guard isActive else { return } + + Current.Log.info("Stopping camera motion detection") + + processingQueue.async { [weak self] in + self?.captureSession?.stopRunning() + DispatchQueue.main.async { + self?.isActive = false + self?.motionDetected = false + self?.motionLevel = 0 + self?.previousFrame = nil + } + } + + cooldownTimer?.invalidate() + cooldownTimer = nil + } + + /// Request camera authorization + public func requestAuthorization() async -> Bool { + let status = await AVCaptureDevice.requestAccess(for: .video) + authorizationStatus = AVCaptureDevice.authorizationStatus(for: .video) + return status + } + + /// Update sensitivity from current settings + public func updateSensitivity(_ sensitivity: MotionSensitivity) { + motionThreshold = sensitivity.threshold + Current.Log.info("Motion sensitivity set to \(sensitivity.rawValue), threshold: \(motionThreshold)") + } + + // MARK: - Private Methods + + private func updateSensitivity() { + let sensitivity = KioskModeManager.shared.settings.cameraMotionSensitivity + motionThreshold = sensitivity.threshold + } + + private func checkAuthorizationStatus() { + authorizationStatus = AVCaptureDevice.authorizationStatus(for: .video) + } + + private func setupCaptureSession() { + let session = AVCaptureSession() + session.sessionPreset = .low + + guard let camera = AVCaptureDevice.default( + .builtInWideAngleCamera, + for: .video, + position: .front + ) else { + errorMessage = "Front camera not available" + Current.Log.error("Front camera not available for motion detection") + return + } + + do { + let input = try AVCaptureDeviceInput(device: camera) + if session.canAddInput(input) { + session.addInput(input) + } + + // Configure low frame rate to save power (5 fps) + try camera.lockForConfiguration() + camera.activeVideoMinFrameDuration = CMTime(value: 1, timescale: 5) + camera.activeVideoMaxFrameDuration = CMTime(value: 1, timescale: 5) + camera.unlockForConfiguration() + } catch { + errorMessage = "Failed to configure camera: \(error.localizedDescription)" + Current.Log.error("Camera configuration error: \(error)") + return + } + + let output = AVCaptureVideoDataOutput() + output.videoSettings = [ + kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA, + ] + output.alwaysDiscardsLateVideoFrames = true + output.setSampleBufferDelegate(self, queue: processingQueue) + + if session.canAddOutput(output) { + session.addOutput(output) + } + + captureSession = session + videoOutput = output + } + + private func processFrame(_ pixelBuffer: CVPixelBuffer) { + let ciImage = CIImage(cvPixelBuffer: pixelBuffer) + + guard let previous = previousFrame else { + previousFrame = ciImage + return + } + + let difference = calculateDifference(current: ciImage, previous: previous) + + DispatchQueue.main.async { [weak self] in + guard let self else { return } + + motionLevel = difference + + if difference > motionThreshold, !isInCooldown { + handleMotionDetected() + } + } + + previousFrame = ciImage + } + + private func calculateDifference(current: CIImage, previous: CIImage) -> Float { + let differenceFilter = CIFilter(name: "CIDifferenceBlendMode") + differenceFilter?.setValue(current, forKey: kCIInputImageKey) + differenceFilter?.setValue(previous, forKey: kCIInputBackgroundImageKey) + + guard let differenceImage = differenceFilter?.outputImage else { return 0 } + + let extentVector = CIVector( + x: differenceImage.extent.origin.x, + y: differenceImage.extent.origin.y, + z: differenceImage.extent.size.width, + w: differenceImage.extent.size.height + ) + + let averageFilter = CIFilter(name: "CIAreaAverage") + averageFilter?.setValue(differenceImage, forKey: kCIInputImageKey) + averageFilter?.setValue(extentVector, forKey: kCIInputExtentKey) + + guard let outputImage = averageFilter?.outputImage else { return 0 } + + var bitmap = [UInt8](repeating: 0, count: 4) + let context = CIContext(options: [.workingColorSpace: kCFNull as Any]) + context.render( + outputImage, + toBitmap: &bitmap, + rowBytes: 4, + bounds: CGRect(x: 0, y: 0, width: 1, height: 1), + format: .RGBA8, + colorSpace: nil + ) + + let r = Float(bitmap[0]) / 255.0 + let g = Float(bitmap[1]) / 255.0 + let b = Float(bitmap[2]) / 255.0 + + return (r + g + b) / 3.0 + } + + private func handleMotionDetected() { + motionDetected = true + isInCooldown = true + + Current.Log.info("Motion detected (level: \(motionLevel))") + onMotionDetected?() + + // 2-second cooldown to prevent rapid re-triggering + cooldownTimer?.invalidate() + cooldownTimer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { [weak self] _ in + DispatchQueue.main.async { + self?.isInCooldown = false + self?.motionDetected = false + } + } + } +} + +// MARK: - AVCaptureVideoDataOutputSampleBufferDelegate + +extension KioskCameraMotionDetector: AVCaptureVideoDataOutputSampleBufferDelegate { + public nonisolated func captureOutput( + _ output: AVCaptureOutput, + didOutput sampleBuffer: CMSampleBuffer, + from connection: AVCaptureConnection + ) { + guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return } + + Task { @MainActor in + processFrame(pixelBuffer) + } + } +} From 095fd0c170af0b674dc426e07361fa3a87307f1d Mon Sep 17 00:00:00 2001 From: Nick Stefanelli Date: Thu, 9 Apr 2026 19:59:55 -0400 Subject: [PATCH 05/15] feat(kiosk): add presence detector with Vision framework --- .../Kiosk/Camera/KioskPresenceDetector.swift | 323 ++++++++++++++++++ 1 file changed, 323 insertions(+) create mode 100644 Sources/App/Kiosk/Camera/KioskPresenceDetector.swift diff --git a/Sources/App/Kiosk/Camera/KioskPresenceDetector.swift b/Sources/App/Kiosk/Camera/KioskPresenceDetector.swift new file mode 100644 index 0000000000..eb3840dc23 --- /dev/null +++ b/Sources/App/Kiosk/Camera/KioskPresenceDetector.swift @@ -0,0 +1,323 @@ +import AVFoundation +import Combine +import Shared +import UIKit +import Vision + +// MARK: - Kiosk Presence Detector + +/// Detects human presence and faces using Apple's Vision framework +@MainActor +public final class KioskPresenceDetector: NSObject, ObservableObject { + // MARK: - Published State + + /// Whether presence detection is currently active + @Published public private(set) var isActive: Bool = false + + /// Whether a person is currently detected + @Published public private(set) var personDetected: Bool = false + + /// Whether a face is currently detected + @Published public private(set) var faceDetected: Bool = false + + /// Number of faces detected + @Published public private(set) var faceCount: Int = 0 + + /// Last detection timestamp + @Published public private(set) var lastDetectionTime: Date? + + /// Camera authorization status + @Published public private(set) var authorizationStatus: AVAuthorizationStatus = .notDetermined + + /// Error message if detection failed + @Published public private(set) var errorMessage: String? + + // MARK: - Callbacks + + /// Called when presence state changes + public var onPresenceChanged: ((Bool) -> Void)? + + /// Called when face detection state changes + public var onFaceDetectionChanged: ((Bool, Int) -> Void)? + + // MARK: - Private + + private var captureSession: AVCaptureSession? + private var videoOutput: AVCaptureVideoDataOutput? + private let processingQueue = DispatchQueue(label: "com.home-assistant.kiosk.presence", qos: .userInitiated) + + // Vision requests + private var personDetectionRequest: VNDetectHumanRectanglesRequest? + private var faceDetectionRequest: VNDetectFaceRectanglesRequest? + + // State tracking with hysteresis + private var consecutiveDetections: Int = 0 + private var consecutiveMisses: Int = 0 + private let detectionThreshold: Int = 2 // Frames needed to confirm detection + private let missThreshold: Int = 5 // Frames needed to start absence countdown + + private var presenceTimeout: Timer? + private let presenceTimeoutInterval: TimeInterval = 10 // Seconds before marking as absent + + // MARK: - Initialization + + override init() { + super.init() + checkAuthorizationStatus() + setupVisionRequests() + } + + deinit { + captureSession?.stopRunning() + captureSession = nil + presenceTimeout?.invalidate() + presenceTimeout = nil + } + + // MARK: - Public Methods + + /// Start presence detection + public func start(faceDetectionEnabled: Bool) { + guard !isActive else { return } + + checkAuthorizationStatus() + + guard authorizationStatus == .authorized else { + Current.Log.warning( + "Camera not authorized for presence detection (status: \(authorizationStatus.rawValue))" + ) + return + } + + Current.Log.info("Starting presence detection (face detection: \(faceDetectionEnabled))") + + setupCaptureSession(faceDetectionEnabled: faceDetectionEnabled) + + processingQueue.async { [weak self] in + self?.captureSession?.startRunning() + DispatchQueue.main.async { + self?.isActive = true + self?.errorMessage = nil + } + } + } + + /// Stop presence detection + public func stop() { + guard isActive else { return } + + Current.Log.info("Stopping presence detection") + + processingQueue.async { [weak self] in + self?.captureSession?.stopRunning() + DispatchQueue.main.async { + self?.isActive = false + self?.personDetected = false + self?.faceDetected = false + self?.faceCount = 0 + } + } + + presenceTimeout?.invalidate() + presenceTimeout = nil + } + + /// Request camera authorization + public func requestAuthorization() async -> Bool { + let status = await AVCaptureDevice.requestAccess(for: .video) + authorizationStatus = AVCaptureDevice.authorizationStatus(for: .video) + return status + } + + // MARK: - Private Methods + + private func checkAuthorizationStatus() { + authorizationStatus = AVCaptureDevice.authorizationStatus(for: .video) + } + + private func setupVisionRequests() { + personDetectionRequest = VNDetectHumanRectanglesRequest { [weak self] request, error in + if let error { + Current.Log.error("Person detection error: \(error)") + return + } + self?.handlePersonDetectionResults(request.results as? [VNHumanObservation]) + } + personDetectionRequest?.upperBodyOnly = true + + faceDetectionRequest = VNDetectFaceRectanglesRequest { [weak self] request, error in + if let error { + Current.Log.error("Face detection error: \(error)") + return + } + self?.handleFaceDetectionResults(request.results as? [VNFaceObservation]) + } + } + + private func setupCaptureSession(faceDetectionEnabled: Bool) { + let session = AVCaptureSession() + session.sessionPreset = .medium + + guard let camera = AVCaptureDevice.default( + .builtInWideAngleCamera, + for: .video, + position: .front + ) else { + errorMessage = "Front camera not available" + Current.Log.error("Front camera not available for presence detection") + return + } + + do { + let input = try AVCaptureDeviceInput(device: camera) + if session.canAddInput(input) { + session.addInput(input) + } + + // Configure frame rate: 10 fps for face detection, 3 fps for person only + try camera.lockForConfiguration() + let timescale: CMTimeScale = faceDetectionEnabled ? 10 : 3 + camera.activeVideoMinFrameDuration = CMTime(value: 1, timescale: timescale) + camera.unlockForConfiguration() + } catch { + errorMessage = "Failed to configure camera: \(error.localizedDescription)" + Current.Log.error("Camera configuration error: \(error)") + return + } + + let output = AVCaptureVideoDataOutput() + output.videoSettings = [ + kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA, + ] + output.alwaysDiscardsLateVideoFrames = true + output.setSampleBufferDelegate(self, queue: processingQueue) + + if session.canAddOutput(output) { + session.addOutput(output) + } + + captureSession = session + videoOutput = output + } + + private func processFrame( + _ pixelBuffer: CVPixelBuffer, + presenceEnabled: Bool, + faceDetectionEnabled: Bool + ) { + let handler = VNImageRequestHandler(cvPixelBuffer: pixelBuffer, orientation: .up, options: [:]) + + do { + var requests: [VNRequest] = [] + + if presenceEnabled, let personRequest = personDetectionRequest { + requests.append(personRequest) + } + + if faceDetectionEnabled, let faceRequest = faceDetectionRequest { + requests.append(faceRequest) + } + + if !requests.isEmpty { + try handler.perform(requests) + } + } catch { + Current.Log.error("Vision request error: \(error)") + } + } + + private func handlePersonDetectionResults(_ results: [VNHumanObservation]?) { + let detected = !(results?.isEmpty ?? true) + + DispatchQueue.main.async { [weak self] in + guard let self else { return } + updatePresenceState(detected: detected) + } + } + + private func handleFaceDetectionResults(_ results: [VNFaceObservation]?) { + let faces = results ?? [] + let detected = !faces.isEmpty + + DispatchQueue.main.async { [weak self] in + guard let self else { return } + + let previouslyDetected = faceDetected + let previousCount = faceCount + + faceCount = faces.count + faceDetected = detected + + if detected != previouslyDetected || faces.count != previousCount { + onFaceDetectionChanged?(detected, faces.count) + + if detected { + lastDetectionTime = Current.date() + Current.Log.info("Face detected (count: \(faces.count))") + } + } + } + } + + private func updatePresenceState(detected: Bool) { + if detected { + consecutiveDetections += 1 + consecutiveMisses = 0 + + // Reset timeout + presenceTimeout?.invalidate() + presenceTimeout = Timer.scheduledTimer( + withTimeInterval: presenceTimeoutInterval, + repeats: false + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.handlePresenceTimeout() + } + } + + if consecutiveDetections >= detectionThreshold, !personDetected { + personDetected = true + lastDetectionTime = Current.date() + onPresenceChanged?(true) + Current.Log.info("Person presence detected") + } + } else { + consecutiveMisses += 1 + consecutiveDetections = 0 + + // Don't immediately mark as absent; wait for timeout + // This prevents flickering when person moves slightly + } + } + + private func handlePresenceTimeout() { + if personDetected { + personDetected = false + faceDetected = false + faceCount = 0 + onPresenceChanged?(false) + Current.Log.info("Person presence timeout - marking as absent") + } + } +} + +// MARK: - AVCaptureVideoDataOutputSampleBufferDelegate + +extension KioskPresenceDetector: AVCaptureVideoDataOutputSampleBufferDelegate { + public nonisolated func captureOutput( + _ output: AVCaptureOutput, + didOutput sampleBuffer: CMSampleBuffer, + from connection: AVCaptureConnection + ) { + guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return } + + Task { @MainActor in + let settings = KioskModeManager.shared.settings + processFrame( + pixelBuffer, + presenceEnabled: settings.cameraPresenceEnabled, + faceDetectionEnabled: settings.cameraFaceDetectionEnabled + ) + } + } +} From dd9b49833226bea8edb6c26c49023de51ed72444 Mon Sep 17 00:00:00 2001 From: Nick Stefanelli Date: Thu, 9 Apr 2026 19:59:58 -0400 Subject: [PATCH 06/15] feat(kiosk): add camera detection manager coordinator --- .../Camera/KioskCameraDetectionManager.swift | 216 ++++++++++++++++++ 1 file changed, 216 insertions(+) create mode 100644 Sources/App/Kiosk/Camera/KioskCameraDetectionManager.swift diff --git a/Sources/App/Kiosk/Camera/KioskCameraDetectionManager.swift b/Sources/App/Kiosk/Camera/KioskCameraDetectionManager.swift new file mode 100644 index 0000000000..8578c6683d --- /dev/null +++ b/Sources/App/Kiosk/Camera/KioskCameraDetectionManager.swift @@ -0,0 +1,216 @@ +import AVFoundation +import Combine +import Foundation +import Shared +import UIKit + +// MARK: - Kiosk Camera Detection Manager + +/// Coordinates camera-based motion and presence detection for kiosk mode +@MainActor +public final class KioskCameraDetectionManager: ObservableObject { + // MARK: - Singleton + + public static let shared = KioskCameraDetectionManager() + + // MARK: - Published State + + /// Whether any camera detection is currently active + @Published public private(set) var isActive: Bool = false + + /// Current motion detected state + @Published public private(set) var motionDetected: Bool = false + + /// Current presence detected state + @Published public private(set) var presenceDetected: Bool = false + + /// Current face detected state + @Published public private(set) var faceDetected: Bool = false + + /// Number of faces detected + @Published public private(set) var faceCount: Int = 0 + + /// Camera authorization status + @Published public private(set) var authorizationStatus: AVAuthorizationStatus = .notDetermined + + // MARK: - Callbacks + + /// Called when motion is detected (for wake trigger) + public var onMotionDetected: (() -> Void)? + + /// Called when presence state changes + public var onPresenceChanged: ((Bool) -> Void)? + + // MARK: - Private + + private var settings: KioskSettings { KioskModeManager.shared.settings } + private let motionDetector = KioskCameraMotionDetector() + private let presenceDetector = KioskPresenceDetector() + private var cancellables = Set() + + /// Timer for periodic activity updates while presence is detected + private var presenceActivityTimer: Timer? + + /// Interval for presence activity updates (keeps idle timer reset while someone is present) + private let presenceActivityInterval: TimeInterval = 5.0 + + // MARK: - Initialization + + private init() { + setupBindings() + checkAuthorizationStatus() + } + + deinit { + cancellables.forEach { $0.cancel() } + cancellables.removeAll() + } + + // MARK: - Public Methods + + /// Start camera detection based on current settings + public func start() { + guard !isActive else { return } + + Current.Log.info("Starting camera detection manager") + + if settings.cameraMotionEnabled { + motionDetector.start() + } + + if settings.cameraPresenceEnabled || settings.cameraFaceDetectionEnabled { + presenceDetector.start(faceDetectionEnabled: settings.cameraFaceDetectionEnabled) + } + + isActive = settings.cameraMotionEnabled || settings.cameraPresenceEnabled + || settings.cameraFaceDetectionEnabled + } + + /// Stop all camera detection + public func stop() { + guard isActive else { return } + + Current.Log.info("Stopping camera detection manager") + + stopPresenceActivityTimer() + motionDetector.stop() + presenceDetector.stop() + isActive = false + } + + /// Restart detection (e.g., after settings change) + public func restart() { + stop() + start() + } + + /// Request camera authorization + public func requestAuthorization() async -> Bool { + let granted = await motionDetector.requestAuthorization() + checkAuthorizationStatus() + return granted + } + + // MARK: - Private Methods + + private func checkAuthorizationStatus() { + authorizationStatus = AVCaptureDevice.authorizationStatus(for: .video) + } + + private func setupBindings() { + // Bind motion detector state + motionDetector.$motionDetected + .receive(on: DispatchQueue.main) + .sink { [weak self] detected in + self?.motionDetected = detected + if detected { + self?.handleMotionDetected() + } + } + .store(in: &cancellables) + + // Bind presence detector state + presenceDetector.$personDetected + .receive(on: DispatchQueue.main) + .sink { [weak self] detected in + let previousState = self?.presenceDetected ?? false + self?.presenceDetected = detected + if detected != previousState { + self?.handlePresenceChanged(detected) + } + } + .store(in: &cancellables) + + // Bind face detection state + presenceDetector.$faceDetected + .receive(on: DispatchQueue.main) + .sink { [weak self] detected in + self?.faceDetected = detected + } + .store(in: &cancellables) + + presenceDetector.$faceCount + .receive(on: DispatchQueue.main) + .sink { [weak self] count in + self?.faceCount = count + } + .store(in: &cancellables) + + // Bind authorization status from both detectors + Publishers.Merge( + motionDetector.$authorizationStatus, + presenceDetector.$authorizationStatus + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] status in + self?.authorizationStatus = status + } + .store(in: &cancellables) + } + + private func handleMotionDetected() { + Current.Log.info("Camera motion detected") + onMotionDetected?() + } + + private func handlePresenceChanged(_ detected: Bool) { + Current.Log.info("Presence changed: \(detected ? "detected" : "absent")") + onPresenceChanged?(detected) + + if detected { + startPresenceActivityTimer() + } else { + stopPresenceActivityTimer() + } + } + + // MARK: - Presence Activity Timer + + /// Starts a timer that periodically records activity while presence is detected. + /// This prevents the screensaver from triggering while someone is standing in front of the device. + private func startPresenceActivityTimer() { + stopPresenceActivityTimer() + + guard settings.wakeOnCameraPresence else { return } + + presenceActivityTimer = Timer.scheduledTimer( + withTimeInterval: presenceActivityInterval, + repeats: true + ) { [weak self] _ in + Task { @MainActor [weak self] in + guard let self, presenceDetected else { + self?.stopPresenceActivityTimer() + return + } + + Current.Log.verbose("Presence activity tick - keeping screen awake") + KioskModeManager.shared.recordActivity(source: "camera_presence") + } + } + } + + private func stopPresenceActivityTimer() { + presenceActivityTimer?.invalidate() + presenceActivityTimer = nil + } +} From 058184652538195a2f2d3b379f75fd09226769c1 Mon Sep 17 00:00:00 2001 From: Nick Stefanelli Date: Thu, 9 Apr 2026 20:02:01 -0400 Subject: [PATCH 07/15] feat(kiosk): integrate camera detection into KioskModeManager --- Sources/App/Kiosk/KioskModeManager.swift | 47 ++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/Sources/App/Kiosk/KioskModeManager.swift b/Sources/App/Kiosk/KioskModeManager.swift index 46018ae14a..5a36247fd8 100644 --- a/Sources/App/Kiosk/KioskModeManager.swift +++ b/Sources/App/Kiosk/KioskModeManager.swift @@ -234,6 +234,9 @@ public final class KioskModeManager: ObservableObject { updateKioskModeLockdown(enabled: true) notifyObserversOfModeChange() + + // Start camera detection if enabled + startCameraDetection() } /// Disable kiosk mode @@ -273,6 +276,10 @@ public final class KioskModeManager: ObservableObject { tearDownSecretExitGesture() updateKioskModeLockdown(enabled: false) + + // Stop camera detection + stopCameraDetection() + notifyObserversOfModeChange() } @@ -657,6 +664,36 @@ public final class KioskModeManager: ObservableObject { notifyObserversOfPixelShift() } + // MARK: - Camera Detection + + private func startCameraDetection() { + let cameraManager = KioskCameraDetectionManager.shared + + cameraManager.onMotionDetected = { [weak self] in + guard let self, settings.wakeOnCameraMotion else { return } + wakeScreen(source: "camera_motion") + } + + cameraManager.onPresenceChanged = { [weak self] detected in + guard let self, detected, settings.wakeOnCameraPresence else { return } + wakeScreen(source: "camera_presence") + } + + cameraManager.start() + } + + private func stopCameraDetection() { + let cameraManager = KioskCameraDetectionManager.shared + cameraManager.onMotionDetected = nil + cameraManager.onPresenceChanged = nil + cameraManager.stop() + } + + private func restartCameraDetection() { + stopCameraDetection() + startCameraDetection() + } + // MARK: - Settings Persistence private static func loadSettings() -> KioskSettings { @@ -707,6 +744,16 @@ public final class KioskModeManager: ObservableObject { } } + // Restart camera detection if camera settings changed + if oldValue.cameraMotionEnabled != newValue.cameraMotionEnabled + || oldValue.cameraPresenceEnabled != newValue.cameraPresenceEnabled + || oldValue.cameraFaceDetectionEnabled != newValue.cameraFaceDetectionEnabled + || oldValue.cameraMotionSensitivity != newValue.cameraMotionSensitivity + || oldValue.wakeOnCameraMotion != newValue.wakeOnCameraMotion + || oldValue.wakeOnCameraPresence != newValue.wakeOnCameraPresence { + restartCameraDetection() + } + updateKioskModeLockdown(enabled: true) notifyObserversOfSettingsChange() } From 380604929ed852958a6fbf4541a747ec3e7fe990 Mon Sep 17 00:00:00 2001 From: Nick Stefanelli Date: Thu, 9 Apr 2026 20:02:05 -0400 Subject: [PATCH 08/15] feat(kiosk): add camera detection settings section --- .../Kiosk/Settings/KioskSettingsView.swift | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/Sources/App/Kiosk/Settings/KioskSettingsView.swift b/Sources/App/Kiosk/Settings/KioskSettingsView.swift index c72942a277..901b60548f 100644 --- a/Sources/App/Kiosk/Settings/KioskSettingsView.swift +++ b/Sources/App/Kiosk/Settings/KioskSettingsView.swift @@ -28,6 +28,7 @@ public struct KioskSettingsView: View { coreSettingsSection brightnessSection screensaverSection + cameraDetectionSection } .toolbar { ToolbarItem(placement: .confirmationAction) { @@ -260,6 +261,46 @@ public struct KioskSettingsView: View { Text(L10n.Kiosk.Screensaver.pixelShiftFooter) } } + + // MARK: - Camera Detection Section + + private var cameraDetectionSection: some View { + Section { + Toggle(isOn: $viewModel.settings.cameraMotionEnabled) { + Label(L10n.Kiosk.Camera.motionDetection, systemSymbol: .figureWalk) + } + + if viewModel.settings.cameraMotionEnabled { + Picker(L10n.Kiosk.Camera.sensitivity, selection: $viewModel.settings.cameraMotionSensitivity) { + ForEach(MotionSensitivity.allCases, id: \.self) { sensitivity in + Text(sensitivity.displayName).tag(sensitivity) + } + } + + Toggle(isOn: $viewModel.settings.wakeOnCameraMotion) { + Label(L10n.Kiosk.Camera.wakeOnMotion, systemSymbol: .sunMax) + } + } + + Toggle(isOn: $viewModel.settings.cameraPresenceEnabled) { + Label(L10n.Kiosk.Camera.presenceDetection, systemSymbol: .personFill) + } + + if viewModel.settings.cameraPresenceEnabled { + Toggle(isOn: $viewModel.settings.cameraFaceDetectionEnabled) { + Label(L10n.Kiosk.Camera.faceDetection, systemSymbol: .faceSmiling) + } + + Toggle(isOn: $viewModel.settings.wakeOnCameraPresence) { + Label(L10n.Kiosk.Camera.wakeOnPresence, systemSymbol: .sunMax) + } + } + } header: { + Text(L10n.Kiosk.Camera.section) + } footer: { + Text(L10n.Kiosk.Camera.footer) + } + } } // MARK: - Preview From c61eca057761f22c6c89f9524a57bc2b768ba423 Mon Sep 17 00:00:00 2001 From: Nick Stefanelli Date: Thu, 9 Apr 2026 20:06:06 -0400 Subject: [PATCH 09/15] chore(kiosk): add camera detection files to Xcode project --- HomeAssistant.xcodeproj/project.pbxproj | 35 ++++++++++++++++++++++--- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/HomeAssistant.xcodeproj/project.pbxproj b/HomeAssistant.xcodeproj/project.pbxproj index a5d1dbc80e..f6b6aaba80 100644 --- a/HomeAssistant.xcodeproj/project.pbxproj +++ b/HomeAssistant.xcodeproj/project.pbxproj @@ -1246,11 +1246,13 @@ 54B2C38995814098B677135A /* WidgetCommonlyUsedEntities.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEEEA3344F064DB183F46C47 /* WidgetCommonlyUsedEntities.swift */; }; 58213D5B1792311CD4CA261D /* Pods_iOS_Extensions_NotificationService.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F6DA82FEEE2DDC3B2CC20DA3 /* Pods_iOS_Extensions_NotificationService.framework */; }; 5B715903CB3450FE351399BC /* Pods-iOS-Extensions-Share-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = 207E35C8F1554A9AD616FFA2 /* Pods-iOS-Extensions-Share-metadata.plist */; }; + 5C03030E951BE417C7CB03E7 /* KioskCameraDetectionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F06E4C4BCA8BCEFAC2BD8CD /* KioskCameraDetectionManager.swift */; }; 5D4737422F241342009A70EA /* FolderDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D4737412F241342009A70EA /* FolderDetailView.swift */; }; 5F2ECF3C2CA505A59A1CFFAF /* Pods_iOS_SharedTesting.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 65B4DC669522BB7A70C5EED0 /* Pods_iOS_SharedTesting.framework */; }; 61495A70232316478717CF27 /* Pods_iOS_Shared_iOS_Tests_Shared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1FF3A67FB1C2B548C6C7730C /* Pods_iOS_Shared_iOS_Tests_Shared.framework */; }; 618FCE5CA6B34267BB2056F5 /* MockLiveActivityRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E95733B72864AB3B9607B57 /* MockLiveActivityRegistry.swift */; }; 651755E378F6F79AB401F05C /* AssistPipelineAddList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07701F2786F6D45E945CC1AA /* AssistPipelineAddList.swift */; }; + 64CABB447201BA52BF13159B /* KioskCameraDetection.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4EAFCF875FE1A016DCEA9CA /* KioskCameraDetection.test.swift */; }; 65286F3B745551AD4090EE6B /* Pods-iOS-SharedTesting-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = 4053903E4C54A6803204286E /* Pods-iOS-SharedTesting-metadata.plist */; }; 6596FA74E1A501276EA62D86 /* Pods_watchOS_Shared_watchOS.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FD370D44DFFB906B05C3EB3A /* Pods_watchOS_Shared_watchOS.framework */; }; 692BCBBA4EEEABCC76DBBECA /* Database/GRDB+Initialization.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C50FA39BF16AD0BD782D0D7 /* Database/GRDB+Initialization.test.swift */; }; @@ -1539,6 +1541,7 @@ C1AE883A374C598B5BCCAE23 /* CustomWidgetIntentHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A7FF77B68D19A4AD68F8FE3 /* CustomWidgetIntentHelper.swift */; }; C35621B95F7E4548BC8F6D75 /* FolderEditView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BECEB2525564358A124F818 /* FolderEditView.swift */; }; C3EB3740FA097F36D51F525E /* BarometerSensor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1396822195C7FF562AB891F2 /* BarometerSensor.swift */; }; + C3D543FC10EC39A66961A92D /* KioskPresenceDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = C64EAB9B84E4838281F3C36B /* KioskPresenceDetector.swift */; }; C574CE3276BCE901743FF8C9 /* KioskSettings.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFD4B475DDA9447E45A9BAD3 /* KioskSettings.test.swift */; }; C8860D27D848451A887BC441 /* WatchFolderRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DA2D62699FC44A99AB37480 /* WatchFolderRow.swift */; }; CA0CA15000000000000000A2 /* LocationSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA0CA15000000000000000A1 /* LocationSettingsView.swift */; }; @@ -1593,6 +1596,8 @@ DB54626ADCE0C32094C8C0B9 /* LoadingButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BFD0D30836C69840AB63A8A /* LoadingButton.swift */; }; DEFBE1A5E9A005B0A5392D27 /* KioskLocalization.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F4593A60DBF019E6C91AAA7 /* KioskLocalization.test.swift */; }; E3A02409794174F002C8BB4F /* IconSearchPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BC23DE131CA8813C2DBD657 /* IconSearchPicker.swift */; }; + DEFBE1A5E9A005B0A5392D27 /* KioskLocalization.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F4593A60DBF019E6C91AAA7 /* KioskLocalization.test.swift */; }; + E72C0153D13C2041C76CE742 /* KioskCameraMotionDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 596D41DEAF91D00E446816EC /* KioskCameraMotionDetector.swift */; }; E92E09E3A93650D56E3C5093 /* KioskScreensaverViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62CDCFDB29D283A7902A3ABE /* KioskScreensaverViewController.swift */; }; F0D1DD41A8F55F6D767EBF37 /* TemplatePreviewSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 332C060487B468055C487070 /* TemplatePreviewSection.swift */; }; FC8E9421FDB864726918B612 /* Pods-watchOS-WatchExtension-Watch-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = 9249824D575933DFA1530BB2 /* Pods-watchOS-WatchExtension-Watch-metadata.plist */; }; @@ -3098,6 +3103,11 @@ 5D4737412F241342009A70EA /* FolderDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FolderDetailView.swift; sourceTree = ""; }; 5E95733B72864AB3B9607B57 /* MockLiveActivityRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockLiveActivityRegistry.swift; sourceTree = ""; }; 5FF9C3A30E10A8E214623EBB /* ComplicationListView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ComplicationListView.swift; sourceTree = ""; }; + 596D41DEAF91D00E446816EC /* KioskCameraMotionDetector.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = KioskCameraMotionDetector.swift; sourceTree = ""; }; + 5C50FA39BF16AD0BD782D0D7 /* GRDB+Initialization.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Database/GRDB+Initialization.test.swift"; sourceTree = ""; }; + 5D4737412F241342009A70EA /* FolderDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FolderDetailView.swift; sourceTree = ""; }; + 5E95733B72864AB3B9607B57 /* MockLiveActivityRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockLiveActivityRegistry.swift; sourceTree = ""; }; + 5F06E4C4BCA8BCEFAC2BD8CD /* KioskCameraDetectionManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = KioskCameraDetectionManager.swift; sourceTree = ""; }; 608CFDA223EBCDF01B946093 /* Pods_iOS_Extensions_PushProvider.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_iOS_Extensions_PushProvider.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 62CDCFDB29D283A7902A3ABE /* KioskScreensaverViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KioskScreensaverViewController.swift; sourceTree = ""; }; 6563AFB7BDAF57478CA18D9B /* Pods-iOS-Extensions-PushProvider.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Extensions-PushProvider.debug.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Extensions-PushProvider/Pods-iOS-Extensions-PushProvider.debug.xcconfig"; sourceTree = ""; }; @@ -3469,6 +3479,7 @@ C00AE2FDC80CA2FFDFCA2B2B /* KioskModeManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KioskModeManager.swift; sourceTree = ""; }; C1694CFE2AAC39ABD6266B1D /* Pods-iOS-Extensions-Matter.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Extensions-Matter.release.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Extensions-Matter/Pods-iOS-Extensions-Matter.release.xcconfig"; sourceTree = ""; }; C22F619E232C3F0E98A9346D /* Pods_iOS_App.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_iOS_App.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + C64EAB9B84E4838281F3C36B /* KioskPresenceDetector.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = KioskPresenceDetector.swift; sourceTree = ""; }; C663B0750E0318469E7008C3 /* Pods_iOS_Shared_iOS.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_iOS_Shared_iOS.framework; sourceTree = BUILT_PRODUCTS_DIR; }; C700605E488301BAD78A5B27 /* NotificationCategoryEditorView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NotificationCategoryEditorView.swift; sourceTree = ""; }; C7A3E91F5B8D42A6E0F13B74 /* KioskSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KioskSettingsViewModel.swift; sourceTree = ""; }; @@ -3508,6 +3519,7 @@ D0EEF321214DE56B00D1D360 /* LocationTrigger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationTrigger.swift; sourceTree = ""; }; D0FF79CB20D778B50034574D /* ClientEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientEvent.swift; sourceTree = ""; }; D0FF79CD20D85C3A0034574D /* ClientEventStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientEventStore.swift; sourceTree = ""; }; + D4EAFCF875FE1A016DCEA9CA /* KioskCameraDetection.test.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = KioskCameraDetection.test.swift; sourceTree = ""; }; D72C761F65606EF882E2A7B1 /* Pods-iOS-Extensions-Today-metadata.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "Pods-iOS-Extensions-Today-metadata.plist"; path = "Pods/Pods-iOS-Extensions-Today-metadata.plist"; sourceTree = ""; }; D790D5FA5DBB4B5B9DBB2334 /* NotificationsCommandManagerLiveActivityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsCommandManagerLiveActivityTests.swift; sourceTree = ""; }; DEEEA3344F064DB183F46C47 /* WidgetCommonlyUsedEntities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetCommonlyUsedEntities.swift; sourceTree = ""; }; @@ -3720,6 +3732,7 @@ 06D62F8A8D381DAFB70C6B31 /* Kiosk */ = { isa = PBXGroup; children = ( + D4EAFCF875FE1A016DCEA9CA /* KioskCameraDetection.test.swift */, 4F4593A60DBF019E6C91AAA7 /* KioskLocalization.test.swift */, EFD4B475DDA9447E45A9BAD3 /* KioskSettings.test.swift */, 373AE72CB925F044BAE18B62 /* KioskLifecycleBrightness.test.swift */, @@ -6544,12 +6557,13 @@ 5F7F99C4E4A98B841C0969B6 /* Kiosk */ = { isa = PBXGroup; children = ( - 825E1E44BA9ABF1BF53733D3 /* KioskConstants.swift */, - C00AE2FDC80CA2FFDFCA2B2B /* KioskModeManager.swift */, - 402432B9CC897C6278B08A79 /* KioskSettings.swift */, + 98096A4025810F739C3A581B /* Camera */, 82F2464E2BC5B4C6E667087B /* Overlay */, 4C1A049B16335C08AECDAAC2 /* Screensaver */, D6500FB6C2035F49B7421ED9 /* Settings */, + 825E1E44BA9ABF1BF53733D3 /* KioskConstants.swift */, + C00AE2FDC80CA2FFDFCA2B2B /* KioskModeManager.swift */, + 402432B9CC897C6278B08A79 /* KioskSettings.swift */, ); path = Kiosk; sourceTree = ""; @@ -6581,6 +6595,17 @@ path = CommonlyUsedEntities; sourceTree = ""; }; + 98096A4025810F739C3A581B /* Camera */ = { + isa = PBXGroup; + children = ( + 5F06E4C4BCA8BCEFAC2BD8CD /* KioskCameraDetectionManager.swift */, + 596D41DEAF91D00E446816EC /* KioskCameraMotionDetector.swift */, + C64EAB9B84E4838281F3C36B /* KioskPresenceDetector.swift */, + ); + name = Camera; + path = Camera; + sourceTree = ""; + }; 9C4E5E20229D97FA0044C8EC /* Configuration */ = { isa = PBXGroup; children = ( @@ -9747,6 +9772,9 @@ D6F87818FFB55C006563EBBB /* RealmResultsObserver.swift in Sources */, 8411EA28D9F5FEF26D01A39E /* CameraZoomGestureOverlay.swift in Sources */, D9BF1EFF40733A4A1D03B9C8 /* CustomWidgetIntentHelper.swift in Sources */, + 5C03030E951BE417C7CB03E7 /* KioskCameraDetectionManager.swift in Sources */, + E72C0153D13C2041C76CE742 /* KioskCameraMotionDetector.swift in Sources */, + C3D543FC10EC39A66961A92D /* KioskPresenceDetector.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -9832,6 +9860,7 @@ C574CE3276BCE901743FF8C9 /* KioskSettings.test.swift in Sources */, DEFBE1A5E9A005B0A5392D27 /* KioskLocalization.test.swift in Sources */, A1619F1ED93FB8B0E7E53C38 /* KioskLifecycleBrightness.test.swift in Sources */, + 64CABB447201BA52BF13159B /* KioskCameraDetection.test.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; From 42b3cda1177b9ec4a2fc65e76a8e05f20bbca0e4 Mon Sep 17 00:00:00 2001 From: Nick Stefanelli Date: Thu, 9 Apr 2026 20:16:59 -0400 Subject: [PATCH 10/15] fix(kiosk): address code review findings for camera detection --- .../App/Kiosk/Camera/KioskCameraMotionDetector.swift | 10 ++-------- Sources/App/Kiosk/Camera/KioskPresenceDetector.swift | 12 ------------ Sources/App/Kiosk/KioskModeManager.swift | 6 ++++++ Sources/App/Kiosk/KioskSettings.swift | 2 +- Sources/App/Kiosk/Settings/KioskSettingsView.swift | 2 ++ 5 files changed, 11 insertions(+), 21 deletions(-) diff --git a/Sources/App/Kiosk/Camera/KioskCameraMotionDetector.swift b/Sources/App/Kiosk/Camera/KioskCameraMotionDetector.swift index 680410b977..173b294476 100644 --- a/Sources/App/Kiosk/Camera/KioskCameraMotionDetector.swift +++ b/Sources/App/Kiosk/Camera/KioskCameraMotionDetector.swift @@ -26,11 +26,6 @@ public final class KioskCameraMotionDetector: NSObject, ObservableObject { /// Error message if detection failed @Published public private(set) var errorMessage: String? - // MARK: - Callbacks - - /// Called when motion is detected - public var onMotionDetected: (() -> Void)? - // MARK: - Private private var captureSession: AVCaptureSession? @@ -41,6 +36,7 @@ public final class KioskCameraMotionDetector: NSObject, ObservableObject { private var motionThreshold: Float = 0.02 private var cooldownTimer: Timer? private var isInCooldown: Bool = false + private let ciContext = CIContext(options: [.workingColorSpace: kCFNull as Any]) // MARK: - Initialization @@ -217,8 +213,7 @@ public final class KioskCameraMotionDetector: NSObject, ObservableObject { guard let outputImage = averageFilter?.outputImage else { return 0 } var bitmap = [UInt8](repeating: 0, count: 4) - let context = CIContext(options: [.workingColorSpace: kCFNull as Any]) - context.render( + ciContext.render( outputImage, toBitmap: &bitmap, rowBytes: 4, @@ -239,7 +234,6 @@ public final class KioskCameraMotionDetector: NSObject, ObservableObject { isInCooldown = true Current.Log.info("Motion detected (level: \(motionLevel))") - onMotionDetected?() // 2-second cooldown to prevent rapid re-triggering cooldownTimer?.invalidate() diff --git a/Sources/App/Kiosk/Camera/KioskPresenceDetector.swift b/Sources/App/Kiosk/Camera/KioskPresenceDetector.swift index eb3840dc23..edb3d92383 100644 --- a/Sources/App/Kiosk/Camera/KioskPresenceDetector.swift +++ b/Sources/App/Kiosk/Camera/KioskPresenceDetector.swift @@ -32,14 +32,6 @@ public final class KioskPresenceDetector: NSObject, ObservableObject { /// Error message if detection failed @Published public private(set) var errorMessage: String? - // MARK: - Callbacks - - /// Called when presence state changes - public var onPresenceChanged: ((Bool) -> Void)? - - /// Called when face detection state changes - public var onFaceDetectionChanged: ((Bool, Int) -> Void)? - // MARK: - Private private var captureSession: AVCaptureSession? @@ -249,8 +241,6 @@ public final class KioskPresenceDetector: NSObject, ObservableObject { faceDetected = detected if detected != previouslyDetected || faces.count != previousCount { - onFaceDetectionChanged?(detected, faces.count) - if detected { lastDetectionTime = Current.date() Current.Log.info("Face detected (count: \(faces.count))") @@ -278,7 +268,6 @@ public final class KioskPresenceDetector: NSObject, ObservableObject { if consecutiveDetections >= detectionThreshold, !personDetected { personDetected = true lastDetectionTime = Current.date() - onPresenceChanged?(true) Current.Log.info("Person presence detected") } } else { @@ -295,7 +284,6 @@ public final class KioskPresenceDetector: NSObject, ObservableObject { personDetected = false faceDetected = false faceCount = 0 - onPresenceChanged?(false) Current.Log.info("Person presence timeout - marking as absent") } } diff --git a/Sources/App/Kiosk/KioskModeManager.swift b/Sources/App/Kiosk/KioskModeManager.swift index 5a36247fd8..f0c7acd6fc 100644 --- a/Sources/App/Kiosk/KioskModeManager.swift +++ b/Sources/App/Kiosk/KioskModeManager.swift @@ -236,7 +236,9 @@ public final class KioskModeManager: ObservableObject { notifyObserversOfModeChange() // Start camera detection if enabled + #if !targetEnvironment(macCatalyst) startCameraDetection() + #endif } /// Disable kiosk mode @@ -278,7 +280,9 @@ public final class KioskModeManager: ObservableObject { updateKioskModeLockdown(enabled: false) // Stop camera detection + #if !targetEnvironment(macCatalyst) stopCameraDetection() + #endif notifyObserversOfModeChange() } @@ -745,6 +749,7 @@ public final class KioskModeManager: ObservableObject { } // Restart camera detection if camera settings changed + #if !targetEnvironment(macCatalyst) if oldValue.cameraMotionEnabled != newValue.cameraMotionEnabled || oldValue.cameraPresenceEnabled != newValue.cameraPresenceEnabled || oldValue.cameraFaceDetectionEnabled != newValue.cameraFaceDetectionEnabled @@ -753,6 +758,7 @@ public final class KioskModeManager: ObservableObject { || oldValue.wakeOnCameraPresence != newValue.wakeOnCameraPresence { restartCameraDetection() } + #endif updateKioskModeLockdown(enabled: true) notifyObserversOfSettingsChange() diff --git a/Sources/App/Kiosk/KioskSettings.swift b/Sources/App/Kiosk/KioskSettings.swift index 92284e1146..99c209bccd 100644 --- a/Sources/App/Kiosk/KioskSettings.swift +++ b/Sources/App/Kiosk/KioskSettings.swift @@ -229,7 +229,7 @@ public struct KioskSettings: Codable, Equatable { forKey: .secretExitGestureTaps ) ?? 3 - // Camera Detection (new in PR2 — all default to false/medium) + // Camera Detection self.cameraMotionEnabled = try container.decodeIfPresent( Bool.self, forKey: .cameraMotionEnabled diff --git a/Sources/App/Kiosk/Settings/KioskSettingsView.swift b/Sources/App/Kiosk/Settings/KioskSettingsView.swift index 901b60548f..f805e17d27 100644 --- a/Sources/App/Kiosk/Settings/KioskSettingsView.swift +++ b/Sources/App/Kiosk/Settings/KioskSettingsView.swift @@ -28,7 +28,9 @@ public struct KioskSettingsView: View { coreSettingsSection brightnessSection screensaverSection + #if !targetEnvironment(macCatalyst) cameraDetectionSection + #endif } .toolbar { ToolbarItem(placement: .confirmationAction) { From 3b45927c71ed348d13e210372e97edb51b99a7a2 Mon Sep 17 00:00:00 2001 From: Nick Stefanelli Date: Thu, 9 Apr 2026 20:29:54 -0400 Subject: [PATCH 11/15] fix(kiosk): move camera frame processing off main thread - Make processFrame/calculateDifference nonisolated in motion detector so CIFilter and CIContext.render run on processingQueue, not MainActor - Make processFrame nonisolated in presence detector so Vision requests run on processingQueue; settings read on MainActor then dispatched - Mark previousFrame, ciContext, and Vision requests as nonisolated(unsafe) since they are only accessed from their respective processing queues - Remove @Published from errorMessage (internal debugging state, not UI) - Replace PR-specific comments in tests with neutral wording --- .../Camera/KioskCameraMotionDetector.swift | 27 +++++++--------- .../Kiosk/Camera/KioskPresenceDetector.swift | 31 ++++++++++++------- .../App/Kiosk/KioskCameraDetection.test.swift | 4 +-- 3 files changed, 33 insertions(+), 29 deletions(-) diff --git a/Sources/App/Kiosk/Camera/KioskCameraMotionDetector.swift b/Sources/App/Kiosk/Camera/KioskCameraMotionDetector.swift index 173b294476..123d41266e 100644 --- a/Sources/App/Kiosk/Camera/KioskCameraMotionDetector.swift +++ b/Sources/App/Kiosk/Camera/KioskCameraMotionDetector.swift @@ -23,8 +23,8 @@ public final class KioskCameraMotionDetector: NSObject, ObservableObject { /// Camera authorization status @Published public private(set) var authorizationStatus: AVAuthorizationStatus = .notDetermined - /// Error message if detection failed - @Published public private(set) var errorMessage: String? + /// Internal error state for debugging; not displayed in UI + public private(set) var errorMessage: String? // MARK: - Private @@ -32,11 +32,13 @@ public final class KioskCameraMotionDetector: NSObject, ObservableObject { private var videoOutput: AVCaptureVideoDataOutput? private let processingQueue = DispatchQueue(label: "com.home-assistant.kiosk.motion", qos: .userInitiated) - private var previousFrame: CIImage? + // Accessed only from processingQueue + private nonisolated(unsafe) var previousFrame: CIImage? private var motionThreshold: Float = 0.02 private var cooldownTimer: Timer? private var isInCooldown: Bool = false - private let ciContext = CIContext(options: [.workingColorSpace: kCFNull as Any]) + // Accessed only from processingQueue + private nonisolated(unsafe) let ciContext = CIContext(options: [.workingColorSpace: kCFNull as Any]) // MARK: - Initialization @@ -169,7 +171,8 @@ public final class KioskCameraMotionDetector: NSObject, ObservableObject { videoOutput = output } - private func processFrame(_ pixelBuffer: CVPixelBuffer) { + /// Process a video frame for motion detection. Called on processingQueue. + private nonisolated func processFrame(_ pixelBuffer: CVPixelBuffer) { let ciImage = CIImage(cvPixelBuffer: pixelBuffer) guard let previous = previousFrame else { @@ -178,21 +181,18 @@ public final class KioskCameraMotionDetector: NSObject, ObservableObject { } let difference = calculateDifference(current: ciImage, previous: previous) + previousFrame = ciImage - DispatchQueue.main.async { [weak self] in + Task { @MainActor [weak self] in guard let self else { return } - motionLevel = difference - if difference > motionThreshold, !isInCooldown { handleMotionDetected() } } - - previousFrame = ciImage } - private func calculateDifference(current: CIImage, previous: CIImage) -> Float { + private nonisolated func calculateDifference(current: CIImage, previous: CIImage) -> Float { let differenceFilter = CIFilter(name: "CIDifferenceBlendMode") differenceFilter?.setValue(current, forKey: kCIInputImageKey) differenceFilter?.setValue(previous, forKey: kCIInputBackgroundImageKey) @@ -255,9 +255,6 @@ extension KioskCameraMotionDetector: AVCaptureVideoDataOutputSampleBufferDelegat from connection: AVCaptureConnection ) { guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return } - - Task { @MainActor in - processFrame(pixelBuffer) - } + processFrame(pixelBuffer) } } diff --git a/Sources/App/Kiosk/Camera/KioskPresenceDetector.swift b/Sources/App/Kiosk/Camera/KioskPresenceDetector.swift index edb3d92383..dabf5007d4 100644 --- a/Sources/App/Kiosk/Camera/KioskPresenceDetector.swift +++ b/Sources/App/Kiosk/Camera/KioskPresenceDetector.swift @@ -29,8 +29,8 @@ public final class KioskPresenceDetector: NSObject, ObservableObject { /// Camera authorization status @Published public private(set) var authorizationStatus: AVAuthorizationStatus = .notDetermined - /// Error message if detection failed - @Published public private(set) var errorMessage: String? + /// Internal error state for debugging; not displayed in UI + public private(set) var errorMessage: String? // MARK: - Private @@ -38,9 +38,9 @@ public final class KioskPresenceDetector: NSObject, ObservableObject { private var videoOutput: AVCaptureVideoDataOutput? private let processingQueue = DispatchQueue(label: "com.home-assistant.kiosk.presence", qos: .userInitiated) - // Vision requests - private var personDetectionRequest: VNDetectHumanRectanglesRequest? - private var faceDetectionRequest: VNDetectFaceRectanglesRequest? + // Vision requests — set once in init, read from processingQueue + private nonisolated(unsafe) var personDetectionRequest: VNDetectHumanRectanglesRequest? + private nonisolated(unsafe) var faceDetectionRequest: VNDetectFaceRectanglesRequest? // State tracking with hysteresis private var consecutiveDetections: Int = 0 @@ -192,7 +192,8 @@ public final class KioskPresenceDetector: NSObject, ObservableObject { videoOutput = output } - private func processFrame( + /// Process a video frame for presence detection. Called on processingQueue. + private nonisolated func processFrame( _ pixelBuffer: CVPixelBuffer, presenceEnabled: Bool, faceDetectionEnabled: Bool @@ -299,13 +300,19 @@ extension KioskPresenceDetector: AVCaptureVideoDataOutputSampleBufferDelegate { ) { guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return } - Task { @MainActor in + // Read settings on MainActor, then process on processingQueue + Task { @MainActor [weak self] in + guard let self else { return } let settings = KioskModeManager.shared.settings - processFrame( - pixelBuffer, - presenceEnabled: settings.cameraPresenceEnabled, - faceDetectionEnabled: settings.cameraFaceDetectionEnabled - ) + let presenceEnabled = settings.cameraPresenceEnabled + let faceEnabled = settings.cameraFaceDetectionEnabled + processingQueue.async { [weak self] in + self?.processFrame( + pixelBuffer, + presenceEnabled: presenceEnabled, + faceDetectionEnabled: faceEnabled + ) + } } } } diff --git a/Tests/App/Kiosk/KioskCameraDetection.test.swift b/Tests/App/Kiosk/KioskCameraDetection.test.swift index 590a22120f..263dd01865 100644 --- a/Tests/App/Kiosk/KioskCameraDetection.test.swift +++ b/Tests/App/Kiosk/KioskCameraDetection.test.swift @@ -77,7 +77,7 @@ struct KioskCameraSettingsTests { } @Test func backwardsCompatibility() async throws { - // Simulate PR1-era settings JSON (no camera fields) + // Simulate settings JSON without camera fields (backwards compatibility) let pr1JSON = """ { "isKioskModeEnabled": true, @@ -90,7 +90,7 @@ struct KioskCameraSettingsTests { let decoded = try JSONDecoder().decode(KioskSettings.self, from: pr1JSON) - // PR1 fields preserved + // Original fields preserved #expect(decoded.isKioskModeEnabled == true) #expect(decoded.screensaverMode == .clock) From 267e0faf4955bda0a942ec1f237f27bbd93ddbcb Mon Sep 17 00:00:00 2001 From: Nick Stefanelli Date: Thu, 9 Apr 2026 20:55:46 -0400 Subject: [PATCH 12/15] fix(kiosk): add explicit init() to KioskSettings Custom init(from:) suppresses the synthesized default initializer. Add explicit public init() so KioskSettings() continues to work. --- Sources/App/Kiosk/KioskSettings.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Sources/App/Kiosk/KioskSettings.swift b/Sources/App/Kiosk/KioskSettings.swift index 99c209bccd..5d86f54eb7 100644 --- a/Sources/App/Kiosk/KioskSettings.swift +++ b/Sources/App/Kiosk/KioskSettings.swift @@ -48,6 +48,9 @@ public struct KioskSettingsRecord: Codable, FetchableRecord, PersistableRecord { /// Complete settings model for kiosk mode /// All settings are Codable for persistence and HA integration sync public struct KioskSettings: Codable, Equatable { + /// Default initializer with all default values + public init() {} + // MARK: - Core Kiosk Mode /// Whether kiosk mode is currently enabled From 1a084ce2331a51b246d9300a9f46f38a15d2828b Mon Sep 17 00:00:00 2001 From: Nick Stefanelli Date: Sat, 25 Apr 2026 22:23:34 -0400 Subject: [PATCH 13/15] feat(kiosk): scope down to motion-only per maintainer request Per bgoncal review on #4497, ship motion detection first and defer presence/face detection to a follow-up PR for simpler review scope. Drops KioskPresenceDetector and the presence/face settings, UI toggles, manager wiring, and L10n keys. --- HomeAssistant.xcodeproj/project.pbxproj | 3 - .../Camera/KioskCameraDetectionManager.swift | 109 +----- .../Kiosk/Camera/KioskPresenceDetector.swift | 318 ------------------ Sources/App/Kiosk/KioskModeManager.swift | 11 +- Sources/App/Kiosk/KioskSettings.swift | 24 -- .../Kiosk/Settings/KioskSettingsView.swift | 14 - .../Resources/en.lproj/Localizable.strings | 5 +- .../App/Kiosk/KioskCameraDetection.test.swift | 12 - 8 files changed, 7 insertions(+), 489 deletions(-) delete mode 100644 Sources/App/Kiosk/Camera/KioskPresenceDetector.swift diff --git a/HomeAssistant.xcodeproj/project.pbxproj b/HomeAssistant.xcodeproj/project.pbxproj index f6b6aaba80..a974150520 100644 --- a/HomeAssistant.xcodeproj/project.pbxproj +++ b/HomeAssistant.xcodeproj/project.pbxproj @@ -3479,7 +3479,6 @@ C00AE2FDC80CA2FFDFCA2B2B /* KioskModeManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KioskModeManager.swift; sourceTree = ""; }; C1694CFE2AAC39ABD6266B1D /* Pods-iOS-Extensions-Matter.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Extensions-Matter.release.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Extensions-Matter/Pods-iOS-Extensions-Matter.release.xcconfig"; sourceTree = ""; }; C22F619E232C3F0E98A9346D /* Pods_iOS_App.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_iOS_App.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - C64EAB9B84E4838281F3C36B /* KioskPresenceDetector.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = KioskPresenceDetector.swift; sourceTree = ""; }; C663B0750E0318469E7008C3 /* Pods_iOS_Shared_iOS.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_iOS_Shared_iOS.framework; sourceTree = BUILT_PRODUCTS_DIR; }; C700605E488301BAD78A5B27 /* NotificationCategoryEditorView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NotificationCategoryEditorView.swift; sourceTree = ""; }; C7A3E91F5B8D42A6E0F13B74 /* KioskSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KioskSettingsViewModel.swift; sourceTree = ""; }; @@ -6600,7 +6599,6 @@ children = ( 5F06E4C4BCA8BCEFAC2BD8CD /* KioskCameraDetectionManager.swift */, 596D41DEAF91D00E446816EC /* KioskCameraMotionDetector.swift */, - C64EAB9B84E4838281F3C36B /* KioskPresenceDetector.swift */, ); name = Camera; path = Camera; @@ -9774,7 +9772,6 @@ D9BF1EFF40733A4A1D03B9C8 /* CustomWidgetIntentHelper.swift in Sources */, 5C03030E951BE417C7CB03E7 /* KioskCameraDetectionManager.swift in Sources */, E72C0153D13C2041C76CE742 /* KioskCameraMotionDetector.swift in Sources */, - C3D543FC10EC39A66961A92D /* KioskPresenceDetector.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Sources/App/Kiosk/Camera/KioskCameraDetectionManager.swift b/Sources/App/Kiosk/Camera/KioskCameraDetectionManager.swift index 8578c6683d..b8203cdc56 100644 --- a/Sources/App/Kiosk/Camera/KioskCameraDetectionManager.swift +++ b/Sources/App/Kiosk/Camera/KioskCameraDetectionManager.swift @@ -6,7 +6,7 @@ import UIKit // MARK: - Kiosk Camera Detection Manager -/// Coordinates camera-based motion and presence detection for kiosk mode +/// Coordinates camera-based motion detection for kiosk mode @MainActor public final class KioskCameraDetectionManager: ObservableObject { // MARK: - Singleton @@ -21,15 +21,6 @@ public final class KioskCameraDetectionManager: ObservableObject { /// Current motion detected state @Published public private(set) var motionDetected: Bool = false - /// Current presence detected state - @Published public private(set) var presenceDetected: Bool = false - - /// Current face detected state - @Published public private(set) var faceDetected: Bool = false - - /// Number of faces detected - @Published public private(set) var faceCount: Int = 0 - /// Camera authorization status @Published public private(set) var authorizationStatus: AVAuthorizationStatus = .notDetermined @@ -38,22 +29,12 @@ public final class KioskCameraDetectionManager: ObservableObject { /// Called when motion is detected (for wake trigger) public var onMotionDetected: (() -> Void)? - /// Called when presence state changes - public var onPresenceChanged: ((Bool) -> Void)? - // MARK: - Private private var settings: KioskSettings { KioskModeManager.shared.settings } private let motionDetector = KioskCameraMotionDetector() - private let presenceDetector = KioskPresenceDetector() private var cancellables = Set() - /// Timer for periodic activity updates while presence is detected - private var presenceActivityTimer: Timer? - - /// Interval for presence activity updates (keeps idle timer reset while someone is present) - private let presenceActivityInterval: TimeInterval = 5.0 - // MARK: - Initialization private init() { @@ -78,12 +59,7 @@ public final class KioskCameraDetectionManager: ObservableObject { motionDetector.start() } - if settings.cameraPresenceEnabled || settings.cameraFaceDetectionEnabled { - presenceDetector.start(faceDetectionEnabled: settings.cameraFaceDetectionEnabled) - } - - isActive = settings.cameraMotionEnabled || settings.cameraPresenceEnabled - || settings.cameraFaceDetectionEnabled + isActive = settings.cameraMotionEnabled } /// Stop all camera detection @@ -92,9 +68,7 @@ public final class KioskCameraDetectionManager: ObservableObject { Current.Log.info("Stopping camera detection manager") - stopPresenceActivityTimer() motionDetector.stop() - presenceDetector.stop() isActive = false } @@ -118,7 +92,6 @@ public final class KioskCameraDetectionManager: ObservableObject { } private func setupBindings() { - // Bind motion detector state motionDetector.$motionDetected .receive(on: DispatchQueue.main) .sink { [weak self] detected in @@ -129,88 +102,16 @@ public final class KioskCameraDetectionManager: ObservableObject { } .store(in: &cancellables) - // Bind presence detector state - presenceDetector.$personDetected - .receive(on: DispatchQueue.main) - .sink { [weak self] detected in - let previousState = self?.presenceDetected ?? false - self?.presenceDetected = detected - if detected != previousState { - self?.handlePresenceChanged(detected) - } - } - .store(in: &cancellables) - - // Bind face detection state - presenceDetector.$faceDetected - .receive(on: DispatchQueue.main) - .sink { [weak self] detected in - self?.faceDetected = detected - } - .store(in: &cancellables) - - presenceDetector.$faceCount + motionDetector.$authorizationStatus .receive(on: DispatchQueue.main) - .sink { [weak self] count in - self?.faceCount = count + .sink { [weak self] status in + self?.authorizationStatus = status } .store(in: &cancellables) - - // Bind authorization status from both detectors - Publishers.Merge( - motionDetector.$authorizationStatus, - presenceDetector.$authorizationStatus - ) - .receive(on: DispatchQueue.main) - .sink { [weak self] status in - self?.authorizationStatus = status - } - .store(in: &cancellables) } private func handleMotionDetected() { Current.Log.info("Camera motion detected") onMotionDetected?() } - - private func handlePresenceChanged(_ detected: Bool) { - Current.Log.info("Presence changed: \(detected ? "detected" : "absent")") - onPresenceChanged?(detected) - - if detected { - startPresenceActivityTimer() - } else { - stopPresenceActivityTimer() - } - } - - // MARK: - Presence Activity Timer - - /// Starts a timer that periodically records activity while presence is detected. - /// This prevents the screensaver from triggering while someone is standing in front of the device. - private func startPresenceActivityTimer() { - stopPresenceActivityTimer() - - guard settings.wakeOnCameraPresence else { return } - - presenceActivityTimer = Timer.scheduledTimer( - withTimeInterval: presenceActivityInterval, - repeats: true - ) { [weak self] _ in - Task { @MainActor [weak self] in - guard let self, presenceDetected else { - self?.stopPresenceActivityTimer() - return - } - - Current.Log.verbose("Presence activity tick - keeping screen awake") - KioskModeManager.shared.recordActivity(source: "camera_presence") - } - } - } - - private func stopPresenceActivityTimer() { - presenceActivityTimer?.invalidate() - presenceActivityTimer = nil - } } diff --git a/Sources/App/Kiosk/Camera/KioskPresenceDetector.swift b/Sources/App/Kiosk/Camera/KioskPresenceDetector.swift deleted file mode 100644 index dabf5007d4..0000000000 --- a/Sources/App/Kiosk/Camera/KioskPresenceDetector.swift +++ /dev/null @@ -1,318 +0,0 @@ -import AVFoundation -import Combine -import Shared -import UIKit -import Vision - -// MARK: - Kiosk Presence Detector - -/// Detects human presence and faces using Apple's Vision framework -@MainActor -public final class KioskPresenceDetector: NSObject, ObservableObject { - // MARK: - Published State - - /// Whether presence detection is currently active - @Published public private(set) var isActive: Bool = false - - /// Whether a person is currently detected - @Published public private(set) var personDetected: Bool = false - - /// Whether a face is currently detected - @Published public private(set) var faceDetected: Bool = false - - /// Number of faces detected - @Published public private(set) var faceCount: Int = 0 - - /// Last detection timestamp - @Published public private(set) var lastDetectionTime: Date? - - /// Camera authorization status - @Published public private(set) var authorizationStatus: AVAuthorizationStatus = .notDetermined - - /// Internal error state for debugging; not displayed in UI - public private(set) var errorMessage: String? - - // MARK: - Private - - private var captureSession: AVCaptureSession? - private var videoOutput: AVCaptureVideoDataOutput? - private let processingQueue = DispatchQueue(label: "com.home-assistant.kiosk.presence", qos: .userInitiated) - - // Vision requests — set once in init, read from processingQueue - private nonisolated(unsafe) var personDetectionRequest: VNDetectHumanRectanglesRequest? - private nonisolated(unsafe) var faceDetectionRequest: VNDetectFaceRectanglesRequest? - - // State tracking with hysteresis - private var consecutiveDetections: Int = 0 - private var consecutiveMisses: Int = 0 - private let detectionThreshold: Int = 2 // Frames needed to confirm detection - private let missThreshold: Int = 5 // Frames needed to start absence countdown - - private var presenceTimeout: Timer? - private let presenceTimeoutInterval: TimeInterval = 10 // Seconds before marking as absent - - // MARK: - Initialization - - override init() { - super.init() - checkAuthorizationStatus() - setupVisionRequests() - } - - deinit { - captureSession?.stopRunning() - captureSession = nil - presenceTimeout?.invalidate() - presenceTimeout = nil - } - - // MARK: - Public Methods - - /// Start presence detection - public func start(faceDetectionEnabled: Bool) { - guard !isActive else { return } - - checkAuthorizationStatus() - - guard authorizationStatus == .authorized else { - Current.Log.warning( - "Camera not authorized for presence detection (status: \(authorizationStatus.rawValue))" - ) - return - } - - Current.Log.info("Starting presence detection (face detection: \(faceDetectionEnabled))") - - setupCaptureSession(faceDetectionEnabled: faceDetectionEnabled) - - processingQueue.async { [weak self] in - self?.captureSession?.startRunning() - DispatchQueue.main.async { - self?.isActive = true - self?.errorMessage = nil - } - } - } - - /// Stop presence detection - public func stop() { - guard isActive else { return } - - Current.Log.info("Stopping presence detection") - - processingQueue.async { [weak self] in - self?.captureSession?.stopRunning() - DispatchQueue.main.async { - self?.isActive = false - self?.personDetected = false - self?.faceDetected = false - self?.faceCount = 0 - } - } - - presenceTimeout?.invalidate() - presenceTimeout = nil - } - - /// Request camera authorization - public func requestAuthorization() async -> Bool { - let status = await AVCaptureDevice.requestAccess(for: .video) - authorizationStatus = AVCaptureDevice.authorizationStatus(for: .video) - return status - } - - // MARK: - Private Methods - - private func checkAuthorizationStatus() { - authorizationStatus = AVCaptureDevice.authorizationStatus(for: .video) - } - - private func setupVisionRequests() { - personDetectionRequest = VNDetectHumanRectanglesRequest { [weak self] request, error in - if let error { - Current.Log.error("Person detection error: \(error)") - return - } - self?.handlePersonDetectionResults(request.results as? [VNHumanObservation]) - } - personDetectionRequest?.upperBodyOnly = true - - faceDetectionRequest = VNDetectFaceRectanglesRequest { [weak self] request, error in - if let error { - Current.Log.error("Face detection error: \(error)") - return - } - self?.handleFaceDetectionResults(request.results as? [VNFaceObservation]) - } - } - - private func setupCaptureSession(faceDetectionEnabled: Bool) { - let session = AVCaptureSession() - session.sessionPreset = .medium - - guard let camera = AVCaptureDevice.default( - .builtInWideAngleCamera, - for: .video, - position: .front - ) else { - errorMessage = "Front camera not available" - Current.Log.error("Front camera not available for presence detection") - return - } - - do { - let input = try AVCaptureDeviceInput(device: camera) - if session.canAddInput(input) { - session.addInput(input) - } - - // Configure frame rate: 10 fps for face detection, 3 fps for person only - try camera.lockForConfiguration() - let timescale: CMTimeScale = faceDetectionEnabled ? 10 : 3 - camera.activeVideoMinFrameDuration = CMTime(value: 1, timescale: timescale) - camera.unlockForConfiguration() - } catch { - errorMessage = "Failed to configure camera: \(error.localizedDescription)" - Current.Log.error("Camera configuration error: \(error)") - return - } - - let output = AVCaptureVideoDataOutput() - output.videoSettings = [ - kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA, - ] - output.alwaysDiscardsLateVideoFrames = true - output.setSampleBufferDelegate(self, queue: processingQueue) - - if session.canAddOutput(output) { - session.addOutput(output) - } - - captureSession = session - videoOutput = output - } - - /// Process a video frame for presence detection. Called on processingQueue. - private nonisolated func processFrame( - _ pixelBuffer: CVPixelBuffer, - presenceEnabled: Bool, - faceDetectionEnabled: Bool - ) { - let handler = VNImageRequestHandler(cvPixelBuffer: pixelBuffer, orientation: .up, options: [:]) - - do { - var requests: [VNRequest] = [] - - if presenceEnabled, let personRequest = personDetectionRequest { - requests.append(personRequest) - } - - if faceDetectionEnabled, let faceRequest = faceDetectionRequest { - requests.append(faceRequest) - } - - if !requests.isEmpty { - try handler.perform(requests) - } - } catch { - Current.Log.error("Vision request error: \(error)") - } - } - - private func handlePersonDetectionResults(_ results: [VNHumanObservation]?) { - let detected = !(results?.isEmpty ?? true) - - DispatchQueue.main.async { [weak self] in - guard let self else { return } - updatePresenceState(detected: detected) - } - } - - private func handleFaceDetectionResults(_ results: [VNFaceObservation]?) { - let faces = results ?? [] - let detected = !faces.isEmpty - - DispatchQueue.main.async { [weak self] in - guard let self else { return } - - let previouslyDetected = faceDetected - let previousCount = faceCount - - faceCount = faces.count - faceDetected = detected - - if detected != previouslyDetected || faces.count != previousCount { - if detected { - lastDetectionTime = Current.date() - Current.Log.info("Face detected (count: \(faces.count))") - } - } - } - } - - private func updatePresenceState(detected: Bool) { - if detected { - consecutiveDetections += 1 - consecutiveMisses = 0 - - // Reset timeout - presenceTimeout?.invalidate() - presenceTimeout = Timer.scheduledTimer( - withTimeInterval: presenceTimeoutInterval, - repeats: false - ) { [weak self] _ in - Task { @MainActor [weak self] in - self?.handlePresenceTimeout() - } - } - - if consecutiveDetections >= detectionThreshold, !personDetected { - personDetected = true - lastDetectionTime = Current.date() - Current.Log.info("Person presence detected") - } - } else { - consecutiveMisses += 1 - consecutiveDetections = 0 - - // Don't immediately mark as absent; wait for timeout - // This prevents flickering when person moves slightly - } - } - - private func handlePresenceTimeout() { - if personDetected { - personDetected = false - faceDetected = false - faceCount = 0 - Current.Log.info("Person presence timeout - marking as absent") - } - } -} - -// MARK: - AVCaptureVideoDataOutputSampleBufferDelegate - -extension KioskPresenceDetector: AVCaptureVideoDataOutputSampleBufferDelegate { - public nonisolated func captureOutput( - _ output: AVCaptureOutput, - didOutput sampleBuffer: CMSampleBuffer, - from connection: AVCaptureConnection - ) { - guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return } - - // Read settings on MainActor, then process on processingQueue - Task { @MainActor [weak self] in - guard let self else { return } - let settings = KioskModeManager.shared.settings - let presenceEnabled = settings.cameraPresenceEnabled - let faceEnabled = settings.cameraFaceDetectionEnabled - processingQueue.async { [weak self] in - self?.processFrame( - pixelBuffer, - presenceEnabled: presenceEnabled, - faceDetectionEnabled: faceEnabled - ) - } - } - } -} diff --git a/Sources/App/Kiosk/KioskModeManager.swift b/Sources/App/Kiosk/KioskModeManager.swift index f0c7acd6fc..4d602715b8 100644 --- a/Sources/App/Kiosk/KioskModeManager.swift +++ b/Sources/App/Kiosk/KioskModeManager.swift @@ -678,18 +678,12 @@ public final class KioskModeManager: ObservableObject { wakeScreen(source: "camera_motion") } - cameraManager.onPresenceChanged = { [weak self] detected in - guard let self, detected, settings.wakeOnCameraPresence else { return } - wakeScreen(source: "camera_presence") - } - cameraManager.start() } private func stopCameraDetection() { let cameraManager = KioskCameraDetectionManager.shared cameraManager.onMotionDetected = nil - cameraManager.onPresenceChanged = nil cameraManager.stop() } @@ -751,11 +745,8 @@ public final class KioskModeManager: ObservableObject { // Restart camera detection if camera settings changed #if !targetEnvironment(macCatalyst) if oldValue.cameraMotionEnabled != newValue.cameraMotionEnabled - || oldValue.cameraPresenceEnabled != newValue.cameraPresenceEnabled - || oldValue.cameraFaceDetectionEnabled != newValue.cameraFaceDetectionEnabled || oldValue.cameraMotionSensitivity != newValue.cameraMotionSensitivity - || oldValue.wakeOnCameraMotion != newValue.wakeOnCameraMotion - || oldValue.wakeOnCameraPresence != newValue.wakeOnCameraPresence { + || oldValue.wakeOnCameraMotion != newValue.wakeOnCameraMotion { restartCameraDetection() } #endif diff --git a/Sources/App/Kiosk/KioskSettings.swift b/Sources/App/Kiosk/KioskSettings.swift index 5d86f54eb7..bc12967f88 100644 --- a/Sources/App/Kiosk/KioskSettings.swift +++ b/Sources/App/Kiosk/KioskSettings.swift @@ -132,15 +132,6 @@ public struct KioskSettings: Codable, Equatable { /// Wake the screen when camera motion is detected public var wakeOnCameraMotion: Bool = false - /// Enable camera-based presence (person) detection - public var cameraPresenceEnabled: Bool = false - - /// Enable face detection (requires presence detection) - public var cameraFaceDetectionEnabled: Bool = false - - /// Wake the screen when a person is detected by camera - public var wakeOnCameraPresence: Bool = false - // MARK: - Codable (backwards-compatible decoding) enum CodingKeys: String, CodingKey { @@ -167,9 +158,6 @@ public struct KioskSettings: Codable, Equatable { case cameraMotionEnabled case cameraMotionSensitivity case wakeOnCameraMotion - case cameraPresenceEnabled - case cameraFaceDetectionEnabled - case wakeOnCameraPresence } public init(from decoder: Decoder) throws { @@ -245,18 +233,6 @@ public struct KioskSettings: Codable, Equatable { Bool.self, forKey: .wakeOnCameraMotion ) ?? false - self.cameraPresenceEnabled = try container.decodeIfPresent( - Bool.self, - forKey: .cameraPresenceEnabled - ) ?? false - self.cameraFaceDetectionEnabled = try container.decodeIfPresent( - Bool.self, - forKey: .cameraFaceDetectionEnabled - ) ?? false - self.wakeOnCameraPresence = try container.decodeIfPresent( - Bool.self, - forKey: .wakeOnCameraPresence - ) ?? false } } diff --git a/Sources/App/Kiosk/Settings/KioskSettingsView.swift b/Sources/App/Kiosk/Settings/KioskSettingsView.swift index f805e17d27..a05fcb4e18 100644 --- a/Sources/App/Kiosk/Settings/KioskSettingsView.swift +++ b/Sources/App/Kiosk/Settings/KioskSettingsView.swift @@ -283,20 +283,6 @@ public struct KioskSettingsView: View { Label(L10n.Kiosk.Camera.wakeOnMotion, systemSymbol: .sunMax) } } - - Toggle(isOn: $viewModel.settings.cameraPresenceEnabled) { - Label(L10n.Kiosk.Camera.presenceDetection, systemSymbol: .personFill) - } - - if viewModel.settings.cameraPresenceEnabled { - Toggle(isOn: $viewModel.settings.cameraFaceDetectionEnabled) { - Label(L10n.Kiosk.Camera.faceDetection, systemSymbol: .faceSmiling) - } - - Toggle(isOn: $viewModel.settings.wakeOnCameraPresence) { - Label(L10n.Kiosk.Camera.wakeOnPresence, systemSymbol: .sunMax) - } - } } header: { Text(L10n.Kiosk.Camera.section) } footer: { diff --git a/Sources/App/Resources/en.lproj/Localizable.strings b/Sources/App/Resources/en.lproj/Localizable.strings index 85687e2c03..8823699167 100644 --- a/Sources/App/Resources/en.lproj/Localizable.strings +++ b/Sources/App/Resources/en.lproj/Localizable.strings @@ -561,10 +561,7 @@ This server requires a client certificate (mTLS) but the operation was cancelled "kiosk.camera.sensitivity.medium" = "Medium"; "kiosk.camera.sensitivity.high" = "High"; "kiosk.camera.wake_on_motion" = "Wake on Motion"; -"kiosk.camera.presence_detection" = "Presence Detection"; -"kiosk.camera.face_detection" = "Face Detection"; -"kiosk.camera.wake_on_presence" = "Wake on Presence"; -"kiosk.camera.footer" = "Uses the front camera to detect motion and human presence. Requires camera permission."; +"kiosk.camera.footer" = "Uses the front camera to detect motion. Requires camera permission."; "legacy_actions.disclaimer" = "Legacy iOS Actions are not the recommended way to interact with Home Assistant anymore, please use Scripts, Scenes and Automations directly in your Widgets, Apple Watch and CarPlay."; "live_activity.empty_state" = "No active Live Activities"; "live_activity.end_all.button" = "End All Activities"; diff --git a/Tests/App/Kiosk/KioskCameraDetection.test.swift b/Tests/App/Kiosk/KioskCameraDetection.test.swift index 263dd01865..d7ebd296a5 100644 --- a/Tests/App/Kiosk/KioskCameraDetection.test.swift +++ b/Tests/App/Kiosk/KioskCameraDetection.test.swift @@ -51,9 +51,6 @@ struct KioskCameraSettingsTests { #expect(settings.cameraMotionEnabled == false) #expect(settings.cameraMotionSensitivity == .medium) #expect(settings.wakeOnCameraMotion == false) - #expect(settings.cameraPresenceEnabled == false) - #expect(settings.cameraFaceDetectionEnabled == false) - #expect(settings.wakeOnCameraPresence == false) } @Test func cameraSettingsRoundtrip() async throws { @@ -61,9 +58,6 @@ struct KioskCameraSettingsTests { settings.cameraMotionEnabled = true settings.cameraMotionSensitivity = .high settings.wakeOnCameraMotion = true - settings.cameraPresenceEnabled = true - settings.cameraFaceDetectionEnabled = true - settings.wakeOnCameraPresence = true let encoded = try JSONEncoder().encode(settings) let decoded = try JSONDecoder().decode(KioskSettings.self, from: encoded) @@ -71,9 +65,6 @@ struct KioskCameraSettingsTests { #expect(decoded.cameraMotionEnabled == true) #expect(decoded.cameraMotionSensitivity == .high) #expect(decoded.wakeOnCameraMotion == true) - #expect(decoded.cameraPresenceEnabled == true) - #expect(decoded.cameraFaceDetectionEnabled == true) - #expect(decoded.wakeOnCameraPresence == true) } @Test func backwardsCompatibility() async throws { @@ -98,9 +89,6 @@ struct KioskCameraSettingsTests { #expect(decoded.cameraMotionEnabled == false) #expect(decoded.cameraMotionSensitivity == .medium) #expect(decoded.wakeOnCameraMotion == false) - #expect(decoded.cameraPresenceEnabled == false) - #expect(decoded.cameraFaceDetectionEnabled == false) - #expect(decoded.wakeOnCameraPresence == false) } @Test func settingsEqualityWithCameraFields() async throws { From cc2836384b4793a9caa0330d840f68970b3b7c3a Mon Sep 17 00:00:00 2001 From: Nick Stefanelli Date: Sat, 25 Apr 2026 23:13:14 -0400 Subject: [PATCH 14/15] fix(kiosk): address camera detection review feedback - KioskCameraDetectionManager.isActive now mirrors the underlying motion detector instead of being set from settings, so denied camera permission no longer leaves the manager stuck active. - Move previousFrame=nil into the processingQueue.async block to match its 'processingQueue-only' invariant. - Drop wakeOnCameraMotion from the settings-restart comparison; the closure already reads it at fire time, no need to recreate the capture session when only the wake flag toggles. - Toggling cameraMotionEnabled now requests camera permission and surfaces a denial alert that deep-links to iOS Settings, so the motion detector actually has something to start with. --- .../Camera/KioskCameraDetectionManager.swift | 19 +++++------ .../Camera/KioskCameraMotionDetector.swift | 2 +- Sources/App/Kiosk/KioskModeManager.swift | 7 ++-- .../Kiosk/Settings/KioskSettingsView.swift | 18 ++++++++++- .../Settings/KioskSettingsViewModel.swift | 32 +++++++++++++++++++ .../Resources/en.lproj/Localizable.strings | 3 ++ 6 files changed, 67 insertions(+), 14 deletions(-) diff --git a/Sources/App/Kiosk/Camera/KioskCameraDetectionManager.swift b/Sources/App/Kiosk/Camera/KioskCameraDetectionManager.swift index b8203cdc56..00d6d43f7a 100644 --- a/Sources/App/Kiosk/Camera/KioskCameraDetectionManager.swift +++ b/Sources/App/Kiosk/Camera/KioskCameraDetectionManager.swift @@ -49,27 +49,21 @@ public final class KioskCameraDetectionManager: ObservableObject { // MARK: - Public Methods - /// Start camera detection based on current settings + /// Start camera detection based on current settings. + /// `isActive` reflects the underlying detector state (bound in setupBindings), + /// so it stays false if the detector bails out (e.g. camera permission denied). public func start() { - guard !isActive else { return } - Current.Log.info("Starting camera detection manager") if settings.cameraMotionEnabled { motionDetector.start() } - - isActive = settings.cameraMotionEnabled } /// Stop all camera detection public func stop() { - guard isActive else { return } - Current.Log.info("Stopping camera detection manager") - motionDetector.stop() - isActive = false } /// Restart detection (e.g., after settings change) @@ -92,6 +86,13 @@ public final class KioskCameraDetectionManager: ObservableObject { } private func setupBindings() { + motionDetector.$isActive + .receive(on: DispatchQueue.main) + .sink { [weak self] active in + self?.isActive = active + } + .store(in: &cancellables) + motionDetector.$motionDetected .receive(on: DispatchQueue.main) .sink { [weak self] detected in diff --git a/Sources/App/Kiosk/Camera/KioskCameraMotionDetector.swift b/Sources/App/Kiosk/Camera/KioskCameraMotionDetector.swift index 123d41266e..6f4ca8c621 100644 --- a/Sources/App/Kiosk/Camera/KioskCameraMotionDetector.swift +++ b/Sources/App/Kiosk/Camera/KioskCameraMotionDetector.swift @@ -89,11 +89,11 @@ public final class KioskCameraMotionDetector: NSObject, ObservableObject { processingQueue.async { [weak self] in self?.captureSession?.stopRunning() + self?.previousFrame = nil DispatchQueue.main.async { self?.isActive = false self?.motionDetected = false self?.motionLevel = 0 - self?.previousFrame = nil } } diff --git a/Sources/App/Kiosk/KioskModeManager.swift b/Sources/App/Kiosk/KioskModeManager.swift index 4d602715b8..ee547a9b92 100644 --- a/Sources/App/Kiosk/KioskModeManager.swift +++ b/Sources/App/Kiosk/KioskModeManager.swift @@ -742,11 +742,12 @@ public final class KioskModeManager: ObservableObject { } } - // Restart camera detection if camera settings changed + // Restart camera detection only when detector configuration changes. + // wakeOnCameraMotion is read at fire time by the closure, so toggling it + // doesn't require tearing down the capture session. #if !targetEnvironment(macCatalyst) if oldValue.cameraMotionEnabled != newValue.cameraMotionEnabled - || oldValue.cameraMotionSensitivity != newValue.cameraMotionSensitivity - || oldValue.wakeOnCameraMotion != newValue.wakeOnCameraMotion { + || oldValue.cameraMotionSensitivity != newValue.cameraMotionSensitivity { restartCameraDetection() } #endif diff --git a/Sources/App/Kiosk/Settings/KioskSettingsView.swift b/Sources/App/Kiosk/Settings/KioskSettingsView.swift index a05fcb4e18..7c8a76818f 100644 --- a/Sources/App/Kiosk/Settings/KioskSettingsView.swift +++ b/Sources/App/Kiosk/Settings/KioskSettingsView.swift @@ -57,6 +57,19 @@ public struct KioskSettingsView: View { } message: { Text(viewModel.authErrorMessage) } + .alert( + L10n.Kiosk.Camera.PermissionDenied.title, + isPresented: $viewModel.showingCameraPermissionDenied + ) { + Button(L10n.Kiosk.Camera.PermissionDenied.openSettings) { + if let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) + } + } + Button(L10n.cancelLabel, role: .cancel) {} + } message: { + Text(L10n.Kiosk.Camera.PermissionDenied.message) + } } // MARK: - Auth Gate Overlay @@ -268,7 +281,10 @@ public struct KioskSettingsView: View { private var cameraDetectionSection: some View { Section { - Toggle(isOn: $viewModel.settings.cameraMotionEnabled) { + Toggle(isOn: Binding( + get: { viewModel.settings.cameraMotionEnabled }, + set: { viewModel.setCameraMotionEnabled($0) } + )) { Label(L10n.Kiosk.Camera.motionDetection, systemSymbol: .figureWalk) } diff --git a/Sources/App/Kiosk/Settings/KioskSettingsViewModel.swift b/Sources/App/Kiosk/Settings/KioskSettingsViewModel.swift index a3c0cfb7ca..5ead4e9155 100644 --- a/Sources/App/Kiosk/Settings/KioskSettingsViewModel.swift +++ b/Sources/App/Kiosk/Settings/KioskSettingsViewModel.swift @@ -1,3 +1,4 @@ +import AVFoundation import LocalAuthentication import Shared import SwiftUI @@ -8,6 +9,7 @@ public final class KioskSettingsViewModel: ObservableObject { @Published public var isAuthenticated = false @Published public var showingAuthError = false @Published public var authErrorMessage = "" + @Published public var showingCameraPermissionDenied = false private let manager: KioskModeManager private let onDismiss: (() -> Void)? @@ -131,4 +133,34 @@ public final class KioskSettingsViewModel: ObservableObject { dismiss(using: environmentDismiss) } } + + /// Toggle camera motion detection. Turning it on requests camera authorization + /// first so the underlying detector can actually start; on denial we revert + /// the toggle and surface an alert that deep-links to iOS Settings. + func setCameraMotionEnabled(_ enabled: Bool) { + guard enabled else { + settings.cameraMotionEnabled = false + return + } + + switch AVCaptureDevice.authorizationStatus(for: .video) { + case .authorized: + settings.cameraMotionEnabled = true + case .notDetermined: + Task { [weak self] in + let granted = await AVCaptureDevice.requestAccess(for: .video) + await MainActor.run { + if granted { + self?.settings.cameraMotionEnabled = true + } else { + self?.showingCameraPermissionDenied = true + } + } + } + case .denied, .restricted: + showingCameraPermissionDenied = true + @unknown default: + showingCameraPermissionDenied = true + } + } } diff --git a/Sources/App/Resources/en.lproj/Localizable.strings b/Sources/App/Resources/en.lproj/Localizable.strings index 8823699167..ef2e4cbaa6 100644 --- a/Sources/App/Resources/en.lproj/Localizable.strings +++ b/Sources/App/Resources/en.lproj/Localizable.strings @@ -562,6 +562,9 @@ This server requires a client certificate (mTLS) but the operation was cancelled "kiosk.camera.sensitivity.high" = "High"; "kiosk.camera.wake_on_motion" = "Wake on Motion"; "kiosk.camera.footer" = "Uses the front camera to detect motion. Requires camera permission."; +"kiosk.camera.permission_denied.title" = "Camera Access Required"; +"kiosk.camera.permission_denied.message" = "Enable camera access for Home Assistant in Settings to use motion detection."; +"kiosk.camera.permission_denied.open_settings" = "Open Settings"; "legacy_actions.disclaimer" = "Legacy iOS Actions are not the recommended way to interact with Home Assistant anymore, please use Scripts, Scenes and Automations directly in your Widgets, Apple Watch and CarPlay."; "live_activity.empty_state" = "No active Live Activities"; "live_activity.end_all.button" = "End All Activities"; From cf7af84ff9fca0edd6c0bb6ef69a71d4d8ca397b Mon Sep 17 00:00:00 2001 From: Nick Stefanelli Date: Tue, 26 May 2026 10:21:25 -0400 Subject: [PATCH 15/15] chore(swiftgen): regenerate after rebase onto upstream/main --- Sources/Shared/Assets/Assets.swift | 30 ---------------- .../Resources/Swiftgen/FrontendStrings.swift | 34 +++++++++---------- .../Shared/Resources/Swiftgen/Strings.swift | 28 +++++++++++++++ 3 files changed, 45 insertions(+), 47 deletions(-) diff --git a/Sources/Shared/Assets/Assets.swift b/Sources/Shared/Assets/Assets.swift index d7fc57b60c..1a3cbd51c6 100644 --- a/Sources/Shared/Assets/Assets.swift +++ b/Sources/Shared/Assets/Assets.swift @@ -8,9 +8,6 @@ #elseif os(tvOS) || os(watchOS) import UIKit #endif -#if canImport(SwiftUI) - import SwiftUI -#endif // Deprecated typealiases @available(*, deprecated, renamed: "ImageAsset.Image", message: "This typealias will be removed in SwiftGen 7.0") @@ -71,13 +68,6 @@ public struct ImageAsset { return result } #endif - - #if canImport(SwiftUI) - @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 10.15, *) - public var swiftUIImage: SwiftUI.Image { - SwiftUI.Image(asset: self) - } - #endif } public extension ImageAsset.Image { @@ -96,26 +86,6 @@ public extension ImageAsset.Image { } } -#if canImport(SwiftUI) -@available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 10.15, *) -public extension SwiftUI.Image { - init(asset: ImageAsset) { - let bundle = BundleToken.bundle - self.init(asset.name, bundle: bundle) - } - - init(asset: ImageAsset, label: Text) { - let bundle = BundleToken.bundle - self.init(asset.name, bundle: bundle, label: label) - } - - init(decorative asset: ImageAsset) { - let bundle = BundleToken.bundle - self.init(decorative: asset.name, bundle: bundle) - } -} -#endif - // swiftlint:disable convenience_type private final class BundleToken { static let bundle: Bundle = { diff --git a/Sources/Shared/Resources/Swiftgen/FrontendStrings.swift b/Sources/Shared/Resources/Swiftgen/FrontendStrings.swift index 544292b0f9..193709bb69 100644 --- a/Sources/Shared/Resources/Swiftgen/FrontendStrings.swift +++ b/Sources/Shared/Resources/Swiftgen/FrontendStrings.swift @@ -3,7 +3,7 @@ import Foundation -// swiftlint:disable superfluous_disable_command file_length implicit_return prefer_self_in_static_references +// swiftlint:disable superfluous_disable_command file_length implicit_return // MARK: - Strings @@ -11,33 +11,33 @@ import Foundation // swiftlint:disable nesting type_body_length type_name vertical_whitespace_opening_braces public enum FrontendStrings { /// Calendar - public static var panelCalendar: String { return FrontendStrings.tr("Frontend", "panel::calendar", fallback: "Calendar") } + public static var panelCalendar: String { return FrontendStrings.tr("Frontend", "panel::calendar") } /// Settings - public static var panelConfig: String { return FrontendStrings.tr("Frontend", "panel::config", fallback: "Settings") } + public static var panelConfig: String { return FrontendStrings.tr("Frontend", "panel::config") } /// Developer tools - public static var panelDeveloperTools: String { return FrontendStrings.tr("Frontend", "panel::developer_tools", fallback: "Developer tools") } + public static var panelDeveloperTools: String { return FrontendStrings.tr("Frontend", "panel::developer_tools") } /// Energy - public static var panelEnergy: String { return FrontendStrings.tr("Frontend", "panel::energy", fallback: "Energy") } + public static var panelEnergy: String { return FrontendStrings.tr("Frontend", "panel::energy") } /// History - public static var panelHistory: String { return FrontendStrings.tr("Frontend", "panel::history", fallback: "History") } + public static var panelHistory: String { return FrontendStrings.tr("Frontend", "panel::history") } /// Activity - public static var panelLogbook: String { return FrontendStrings.tr("Frontend", "panel::logbook", fallback: "Activity") } + public static var panelLogbook: String { return FrontendStrings.tr("Frontend", "panel::logbook") } /// Mailbox - public static var panelMailbox: String { return FrontendStrings.tr("Frontend", "panel::mailbox", fallback: "Mailbox") } + public static var panelMailbox: String { return FrontendStrings.tr("Frontend", "panel::mailbox") } /// Map - public static var panelMap: String { return FrontendStrings.tr("Frontend", "panel::map", fallback: "Map") } + public static var panelMap: String { return FrontendStrings.tr("Frontend", "panel::map") } /// Media - public static var panelMediaBrowser: String { return FrontendStrings.tr("Frontend", "panel::media_browser", fallback: "Media") } + public static var panelMediaBrowser: String { return FrontendStrings.tr("Frontend", "panel::media_browser") } /// Profile - public static var panelProfile: String { return FrontendStrings.tr("Frontend", "panel::profile", fallback: "Profile") } + public static var panelProfile: String { return FrontendStrings.tr("Frontend", "panel::profile") } /// Shopping list - public static var panelShoppingList: String { return FrontendStrings.tr("Frontend", "panel::shopping_list", fallback: "Shopping list") } + public static var panelShoppingList: String { return FrontendStrings.tr("Frontend", "panel::shopping_list") } /// Overview - public static var panelStates: String { return FrontendStrings.tr("Frontend", "panel::states", fallback: "Overview") } + public static var panelStates: String { return FrontendStrings.tr("Frontend", "panel::states") } /// Unavailable - public static var stateDefaultUnavailable: String { return FrontendStrings.tr("Frontend", "state::default::unavailable", fallback: "Unavailable") } + public static var stateDefaultUnavailable: String { return FrontendStrings.tr("Frontend", "state::default::unavailable") } /// Unknown - public static var stateDefaultUnknown: String { return FrontendStrings.tr("Frontend", "state::default::unknown", fallback: "Unknown") } + public static var stateDefaultUnknown: String { return FrontendStrings.tr("Frontend", "state::default::unknown") } } // swiftlint:enable explicit_type_interface function_parameter_count identifier_name line_length // swiftlint:enable nesting type_body_length type_name vertical_whitespace_opening_braces @@ -45,8 +45,8 @@ public enum FrontendStrings { // MARK: - Implementation Details extension FrontendStrings { - private static func tr(_ table: String, _ key: String, _ args: CVarArg..., fallback value: String) -> String { - let format = Current.localized.string(key, table, value) + private static func tr(_ table: String, _ key: String, _ args: CVarArg...) -> String { + let format = Current.localized.string(key, table) return String(format: format, locale: Locale.current, arguments: args) } } diff --git a/Sources/Shared/Resources/Swiftgen/Strings.swift b/Sources/Shared/Resources/Swiftgen/Strings.swift index abb620ff74..b16f7ab45b 100644 --- a/Sources/Shared/Resources/Swiftgen/Strings.swift +++ b/Sources/Shared/Resources/Swiftgen/Strings.swift @@ -1881,6 +1881,34 @@ public enum L10n { /// Brightness public static var section: String { return L10n.tr("Localizable", "kiosk.brightness.section") } } + public enum Camera { + /// Uses the front camera to detect motion. Requires camera permission. + public static var footer: String { return L10n.tr("Localizable", "kiosk.camera.footer") } + /// Motion Detection + public static var motionDetection: String { return L10n.tr("Localizable", "kiosk.camera.motion_detection") } + /// Camera Detection + public static var section: String { return L10n.tr("Localizable", "kiosk.camera.section") } + /// Sensitivity + public static var sensitivity: String { return L10n.tr("Localizable", "kiosk.camera.sensitivity") } + /// Wake on Motion + public static var wakeOnMotion: String { return L10n.tr("Localizable", "kiosk.camera.wake_on_motion") } + public enum PermissionDenied { + /// Enable camera access for Home Assistant in Settings to use motion detection. + public static var message: String { return L10n.tr("Localizable", "kiosk.camera.permission_denied.message") } + /// Open Settings + public static var openSettings: String { return L10n.tr("Localizable", "kiosk.camera.permission_denied.open_settings") } + /// Camera Access Required + public static var title: String { return L10n.tr("Localizable", "kiosk.camera.permission_denied.title") } + } + public enum Sensitivity { + /// High + public static var high: String { return L10n.tr("Localizable", "kiosk.camera.sensitivity.high") } + /// Low + public static var low: String { return L10n.tr("Localizable", "kiosk.camera.sensitivity.low") } + /// Medium + public static var medium: String { return L10n.tr("Localizable", "kiosk.camera.sensitivity.medium") } + } + } public enum Clock { /// 24-Hour Format public static var _24hour: String { return L10n.tr("Localizable", "kiosk.clock.24hour") }