Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
c6f99ff
Add L10n strings for camera detection settings (PR2)
nstefanelli Apr 9, 2026
a2779e1
Add MotionSensitivity enum and camera detection settings to KioskSett…
nstefanelli Apr 9, 2026
7b193c9
test(kiosk): add unit tests for MotionSensitivity and camera settings
nstefanelli Apr 9, 2026
e2c3c3a
feat(kiosk): add camera motion detector
nstefanelli Apr 9, 2026
003bf22
feat(kiosk): add presence detector with Vision framework
nstefanelli Apr 9, 2026
1ea006d
feat(kiosk): add camera detection manager coordinator
nstefanelli Apr 9, 2026
a93d5b6
feat(kiosk): integrate camera detection into KioskModeManager
nstefanelli Apr 10, 2026
7959b54
feat(kiosk): add camera detection settings section
nstefanelli Apr 10, 2026
cc655cb
chore(kiosk): add camera detection files to Xcode project
nstefanelli Apr 10, 2026
a63b9b1
fix(kiosk): address code review findings for camera detection
nstefanelli Apr 10, 2026
f262028
fix(kiosk): move camera frame processing off main thread
nstefanelli Apr 10, 2026
8eba154
fix(kiosk): add explicit init() to KioskSettings
nstefanelli Apr 10, 2026
9922505
Merge branch 'main' into kiosk-pr2-camera-detection
nstefanelli Apr 13, 2026
abc22b9
feat(kiosk): scope down to motion-only per maintainer request
nstefanelli Apr 26, 2026
f26c83e
chore(swiftgen): regenerate cleanly without unrelated changes
nstefanelli Apr 26, 2026
d4d019a
Merge remote-tracking branch 'upstream/main' into kiosk-pr2-camera-de…
nstefanelli Apr 26, 2026
5af1753
fix(kiosk): address camera detection review feedback
nstefanelli Apr 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 27 additions & 3 deletions HomeAssistant.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -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 */; };
Expand Down Expand Up @@ -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 = "<group>"; };
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 = "<group>"; };
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 = "<group>"; };
596D41DEAF91D00E446816EC /* KioskCameraMotionDetector.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = KioskCameraMotionDetector.swift; sourceTree = "<group>"; };
5C50FA39BF16AD0BD782D0D7 /* GRDB+Initialization.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Database/GRDB+Initialization.test.swift"; sourceTree = "<group>"; };
5C50FA39BF16AD0BD782D0D7 /* Database/GRDB+Initialization.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Database/GRDB+Initialization.test.swift"; sourceTree = "<group>"; };
5D4737412F241342009A70EA /* FolderDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FolderDetailView.swift; sourceTree = "<group>"; };
5E95733B72864AB3B9607B57 /* MockLiveActivityRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockLiveActivityRegistry.swift; sourceTree = "<group>"; };
5F06E4C4BCA8BCEFAC2BD8CD /* KioskCameraDetectionManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = KioskCameraDetectionManager.swift; sourceTree = "<group>"; };
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 = "<group>"; };
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 = "<group>"; };
Expand Down Expand Up @@ -3492,6 +3500,7 @@
D0EEF321214DE56B00D1D360 /* LocationTrigger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationTrigger.swift; sourceTree = "<group>"; };
D0FF79CB20D778B50034574D /* ClientEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientEvent.swift; sourceTree = "<group>"; };
D0FF79CD20D85C3A0034574D /* ClientEventStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientEventStore.swift; sourceTree = "<group>"; };
D4EAFCF875FE1A016DCEA9CA /* KioskCameraDetection.test.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = KioskCameraDetection.test.swift; sourceTree = "<group>"; };
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 = "<group>"; };
D790D5FA5DBB4B5B9DBB2334 /* NotificationsCommandManagerLiveActivityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsCommandManagerLiveActivityTests.swift; sourceTree = "<group>"; };
DEEEA3344F064DB183F46C47 /* WidgetCommonlyUsedEntities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetCommonlyUsedEntities.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -3703,6 +3712,7 @@
06D62F8A8D381DAFB70C6B31 /* Kiosk */ = {
isa = PBXGroup;
children = (
D4EAFCF875FE1A016DCEA9CA /* KioskCameraDetection.test.swift */,
4F4593A60DBF019E6C91AAA7 /* KioskLocalization.test.swift */,
EFD4B475DDA9447E45A9BAD3 /* KioskSettings.test.swift */,
);
Expand Down Expand Up @@ -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 = "<group>";
Expand Down Expand Up @@ -6569,6 +6580,16 @@
path = CommonlyUsedEntities;
sourceTree = "<group>";
};
98096A4025810F739C3A581B /* Camera */ = {
isa = PBXGroup;
children = (
5F06E4C4BCA8BCEFAC2BD8CD /* KioskCameraDetectionManager.swift */,
596D41DEAF91D00E446816EC /* KioskCameraMotionDetector.swift */,
);
name = Camera;
path = Camera;
sourceTree = "<group>";
};
9C4E5E20229D97FA0044C8EC /* Configuration */ = {
isa = PBXGroup;
children = (
Expand Down Expand Up @@ -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;
};
Expand Down Expand Up @@ -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;
};
Expand Down
118 changes: 118 additions & 0 deletions Sources/App/Kiosk/Camera/KioskCameraDetectionManager.swift
Original file line number Diff line number Diff line change
@@ -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<AnyCancellable>()

// 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()
}
}
Comment thread
nstefanelli marked this conversation as resolved.

/// 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?()
}
}
Loading
Loading