diff --git a/HomeAssistant.xcodeproj/project.pbxproj b/HomeAssistant.xcodeproj/project.pbxproj index eb63bf78c..485d74df5 100644 --- a/HomeAssistant.xcodeproj/project.pbxproj +++ b/HomeAssistant.xcodeproj/project.pbxproj @@ -1249,10 +1249,12 @@ 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 */; }; + 64CABB447201BA52BF13159B /* KioskCameraDetection.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4EAFCF875FE1A016DCEA9CA /* KioskCameraDetection.test.swift */; }; 651755E378F6F79AB401F05C /* AssistPipelineAddList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07701F2786F6D45E945CC1AA /* AssistPipelineAddList.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 */; }; @@ -1580,6 +1582,9 @@ D87EC7A89E0515C4CAB93220 /* BarometerSensor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1396822195C7FF562AB891F2 /* BarometerSensor.swift */; }; D8B4F2A61E9C73058AF2D49E /* KioskSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A3E91F5B8D42A6E0F13B74 /* KioskSettingsViewModel.swift */; }; D9A6697AF4D05BB8DE822A54 /* Pods_iOS_Extensions_Share.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 33CA7FF55788E7084DA5E4B3 /* Pods_iOS_Extensions_Share.framework */; }; + DA6F4C18D66EDBA5DCEAE833 /* DatabaseMigration.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 892F0EF22A0B9F20AAEE4CCA /* DatabaseMigration.test.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 */; }; DA6F4C18D66EDBA5DCEAE833 /* Database/DatabaseMigration.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 892F0EF22A0B9F20AAEE4CCA /* Database/DatabaseMigration.test.swift */; }; DEFBE1A5E9A005B0A5392D27 /* KioskLocalization.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F4593A60DBF019E6C91AAA7 /* KioskLocalization.test.swift */; }; E92E09E3A93650D56E3C5093 /* KioskScreensaverViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62CDCFDB29D283A7902A3ABE /* KioskScreensaverViewController.swift */; }; @@ -3087,9 +3092,12 @@ 50D9C22ED2834EC9DAAC63AC /* Pods-iOS-Extensions-Intents.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Extensions-Intents.debug.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Extensions-Intents/Pods-iOS-Extensions-Intents.debug.xcconfig"; sourceTree = ""; }; 553A33E097387AA44265DB13 /* Pods-iOS-App-metadata.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "Pods-iOS-App-metadata.plist"; path = "Pods/Pods-iOS-App-metadata.plist"; sourceTree = ""; }; 592EED7A6C2444872F11C17B /* Pods-iOS-Extensions-NotificationService-metadata.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "Pods-iOS-Extensions-NotificationService-metadata.plist"; path = "Pods/Pods-iOS-Extensions-NotificationService-metadata.plist"; 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 = ""; }; 5C50FA39BF16AD0BD782D0D7 /* Database/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 = ""; }; @@ -3492,6 +3500,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 = ""; }; @@ -3703,6 +3712,7 @@ 06D62F8A8D381DAFB70C6B31 /* Kiosk */ = { isa = PBXGroup; children = ( + D4EAFCF875FE1A016DCEA9CA /* KioskCameraDetection.test.swift */, 4F4593A60DBF019E6C91AAA7 /* KioskLocalization.test.swift */, EFD4B475DDA9447E45A9BAD3 /* KioskSettings.test.swift */, ); @@ -6532,12 +6542,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 = ""; @@ -6569,6 +6580,16 @@ path = CommonlyUsedEntities; sourceTree = ""; }; + 98096A4025810F739C3A581B /* Camera */ = { + isa = PBXGroup; + children = ( + 5F06E4C4BCA8BCEFAC2BD8CD /* KioskCameraDetectionManager.swift */, + 596D41DEAF91D00E446816EC /* KioskCameraMotionDetector.swift */, + ); + name = Camera; + path = Camera; + sourceTree = ""; + }; 9C4E5E20229D97FA0044C8EC /* Configuration */ = { isa = PBXGroup; children = ( @@ -9691,6 +9712,8 @@ 19F4C1F65664780F4E721699 /* KioskClockScreensaverView.swift in Sources */, CB4D44CC6DBA5176155E157E /* KioskSecretExitGestureView.swift in Sources */, 12FC58D695EB7AF41673476E /* WebViewController+Kiosk.swift in Sources */, + 5C03030E951BE417C7CB03E7 /* KioskCameraDetectionManager.swift in Sources */, + E72C0153D13C2041C76CE742 /* KioskCameraMotionDetector.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -9774,6 +9797,7 @@ 119C786725CF845800D41734 /* LocalizedStrings.test.swift in Sources */, C574CE3276BCE901743FF8C9 /* KioskSettings.test.swift in Sources */, DEFBE1A5E9A005B0A5392D27 /* KioskLocalization.test.swift in Sources */, + 64CABB447201BA52BF13159B /* KioskCameraDetection.test.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Sources/App/Kiosk/Camera/KioskCameraDetectionManager.swift b/Sources/App/Kiosk/Camera/KioskCameraDetectionManager.swift new file mode 100644 index 000000000..00d6d43f7 --- /dev/null +++ b/Sources/App/Kiosk/Camera/KioskCameraDetectionManager.swift @@ -0,0 +1,118 @@ +import AVFoundation +import Combine +import Foundation +import Shared +import UIKit + +// MARK: - Kiosk Camera Detection Manager + +/// Coordinates camera-based motion 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 + + /// 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)? + + // MARK: - Private + + private var settings: KioskSettings { KioskModeManager.shared.settings } + private let motionDetector = KioskCameraMotionDetector() + private var cancellables = Set() + + // MARK: - Initialization + + private init() { + setupBindings() + checkAuthorizationStatus() + } + + deinit { + cancellables.forEach { $0.cancel() } + cancellables.removeAll() + } + + // MARK: - Public Methods + + /// 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() { + Current.Log.info("Starting camera detection manager") + + if settings.cameraMotionEnabled { + motionDetector.start() + } + } + + /// Stop all camera detection + public func stop() { + Current.Log.info("Stopping camera detection manager") + motionDetector.stop() + } + + /// 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() { + 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 + self?.motionDetected = detected + if detected { + self?.handleMotionDetected() + } + } + .store(in: &cancellables) + + motionDetector.$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?() + } +} diff --git a/Sources/App/Kiosk/Camera/KioskCameraMotionDetector.swift b/Sources/App/Kiosk/Camera/KioskCameraMotionDetector.swift new file mode 100644 index 000000000..6f4ca8c62 --- /dev/null +++ b/Sources/App/Kiosk/Camera/KioskCameraMotionDetector.swift @@ -0,0 +1,260 @@ +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 + + /// 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.motion", qos: .userInitiated) + + // 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 + // Accessed only from processingQueue + private nonisolated(unsafe) let ciContext = CIContext(options: [.workingColorSpace: kCFNull as Any]) + + // 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() + self?.previousFrame = nil + DispatchQueue.main.async { + self?.isActive = false + self?.motionDetected = false + self?.motionLevel = 0 + } + } + + 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 + } + + /// 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 { + previousFrame = ciImage + return + } + + let difference = calculateDifference(current: ciImage, previous: previous) + previousFrame = ciImage + + Task { @MainActor [weak self] in + guard let self else { return } + motionLevel = difference + if difference > motionThreshold, !isInCooldown { + handleMotionDetected() + } + } + } + + private nonisolated 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) + ciContext.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))") + + // 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 } + processFrame(pixelBuffer) + } +} diff --git a/Sources/App/Kiosk/KioskModeManager.swift b/Sources/App/Kiosk/KioskModeManager.swift index d4aca7c70..4fbe4af99 100644 --- a/Sources/App/Kiosk/KioskModeManager.swift +++ b/Sources/App/Kiosk/KioskModeManager.swift @@ -224,6 +224,11 @@ public final class KioskModeManager: ObservableObject { updateKioskModeLockdown(enabled: true) notifyObserversOfModeChange() + + // Start camera detection if enabled + #if !targetEnvironment(macCatalyst) + startCameraDetection() + #endif } /// Disable kiosk mode @@ -259,6 +264,12 @@ public final class KioskModeManager: ObservableObject { hideScreensaver(source: "kiosk_disabled") updateKioskModeLockdown(enabled: false) + + // Stop camera detection + #if !targetEnvironment(macCatalyst) + stopCameraDetection() + #endif + notifyObserversOfModeChange() } @@ -594,6 +605,30 @@ 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.start() + } + + private func stopCameraDetection() { + let cameraManager = KioskCameraDetectionManager.shared + cameraManager.onMotionDetected = nil + cameraManager.stop() + } + + private func restartCameraDetection() { + stopCameraDetection() + startCameraDetection() + } + // MARK: - Settings Persistence private static func loadSettings() -> KioskSettings { @@ -644,6 +679,16 @@ public final class KioskModeManager: ObservableObject { } } + // 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 { + restartCameraDetection() + } + #endif + updateKioskModeLockdown(enabled: true) notifyObserversOfSettingsChange() } diff --git a/Sources/App/Kiosk/KioskSettings.swift b/Sources/App/Kiosk/KioskSettings.swift index 01f6e2da6..bc12967f8 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 @@ -117,6 +120,120 @@ 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 + + // 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 + } + + 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 + 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 + } } // MARK: - Enums @@ -190,6 +307,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 { diff --git a/Sources/App/Kiosk/Settings/KioskSettingsView.swift b/Sources/App/Kiosk/Settings/KioskSettingsView.swift index c72942a27..7c8a76818 100644 --- a/Sources/App/Kiosk/Settings/KioskSettingsView.swift +++ b/Sources/App/Kiosk/Settings/KioskSettingsView.swift @@ -28,6 +28,9 @@ public struct KioskSettingsView: View { coreSettingsSection brightnessSection screensaverSection + #if !targetEnvironment(macCatalyst) + cameraDetectionSection + #endif } .toolbar { ToolbarItem(placement: .confirmationAction) { @@ -54,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 @@ -260,6 +276,35 @@ public struct KioskSettingsView: View { Text(L10n.Kiosk.Screensaver.pixelShiftFooter) } } + + // MARK: - Camera Detection Section + + private var cameraDetectionSection: some View { + Section { + Toggle(isOn: Binding( + get: { viewModel.settings.cameraMotionEnabled }, + set: { viewModel.setCameraMotionEnabled($0) } + )) { + 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) + } + } + } header: { + Text(L10n.Kiosk.Camera.section) + } footer: { + Text(L10n.Kiosk.Camera.footer) + } + } } // MARK: - Preview diff --git a/Sources/App/Kiosk/Settings/KioskSettingsViewModel.swift b/Sources/App/Kiosk/Settings/KioskSettingsViewModel.swift index a3c0cfb7c..5ead4e915 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 59d003f46..6ce639a3e 100644 --- a/Sources/App/Resources/en.lproj/Localizable.strings +++ b/Sources/App/Resources/en.lproj/Localizable.strings @@ -502,6 +502,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.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"; diff --git a/Sources/Shared/Resources/Swiftgen/Strings.swift b/Sources/Shared/Resources/Swiftgen/Strings.swift index ec3726280..11d0ae736 100644 --- a/Sources/Shared/Resources/Swiftgen/Strings.swift +++ b/Sources/Shared/Resources/Swiftgen/Strings.swift @@ -1745,6 +1745,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") } diff --git a/Tests/App/Kiosk/KioskCameraDetection.test.swift b/Tests/App/Kiosk/KioskCameraDetection.test.swift new file mode 100644 index 000000000..d7ebd296a --- /dev/null +++ b/Tests/App/Kiosk/KioskCameraDetection.test.swift @@ -0,0 +1,108 @@ +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) + } + + @Test func cameraSettingsRoundtrip() async throws { + var settings = KioskSettings() + settings.cameraMotionEnabled = true + settings.cameraMotionSensitivity = .high + settings.wakeOnCameraMotion = 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) + } + + @Test func backwardsCompatibility() async throws { + // Simulate settings JSON without camera fields (backwards compatibility) + let pr1JSON = """ + { + "isKioskModeEnabled": true, + "screensaverMode": "clock", + "clockStyle": "large", + "secretExitGestureCorner": "bottomRight", + "secretExitGestureTaps": 3 + } + """.data(using: .utf8)! + + let decoded = try JSONDecoder().decode(KioskSettings.self, from: pr1JSON) + + // Original 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) + } + + @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) + } +}