Skip to content

Commit a4a7c7d

Browse files
committed
Feature: Add Kiosk Mode security managers and audio detection
Part 5 of Kiosk Mode implementation. Depends on PRs home-assistant#4197-home-assistant#4200. Files added (~51K): Security: - BatteryManager.swift: Battery monitoring, low battery alerts - CrashRecoveryManager.swift: Automatic crash recovery and restart - GuidedAccessManager.swift: iOS Guided Access integration - TamperDetectionManager.swift: Motion-based tamper detection Audio: - AmbientAudioDetector.swift: Ambient sound level monitoring for wake triggers - AudioManager.swift: Audio playback and TTS support Features: - Battery level monitoring with critical level alerts - Automatic crash recovery to restore kiosk state - Guided Access API integration for enterprise deployments - Motion-based tamper detection (device moved while in kiosk) - Ambient audio detection for sound-triggered wake - Text-to-speech announcements via notification commands
1 parent 2d30c4f commit a4a7c7d

File tree

7 files changed

+1997
-0
lines changed

7 files changed

+1997
-0
lines changed

HomeAssistant.xcodeproj/project.pbxproj

Lines changed: 33 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
import AVFoundation
2+
import Combine
3+
import Foundation
4+
import Shared
5+
6+
// MARK: - Ambient Audio Detector
7+
8+
/// Detects ambient audio levels using the device microphone
9+
@MainActor
10+
public final class AmbientAudioDetector: ObservableObject {
11+
// MARK: - Singleton
12+
13+
public static let shared = AmbientAudioDetector()
14+
15+
// MARK: - Published State
16+
17+
/// Whether detection is currently active
18+
@Published public private(set) var isActive: Bool = false
19+
20+
/// Current ambient audio level in decibels (normalized 0-1)
21+
@Published public private(set) var audioLevel: Float = 0
22+
23+
/// Current audio level in dB
24+
@Published public private(set) var audioLevelDB: Float = -160
25+
26+
/// Whether loud audio is currently detected
27+
@Published public private(set) var loudAudioDetected: Bool = false
28+
29+
/// Microphone authorization status
30+
@Published public private(set) var authorizationStatus: AVAudioSession.RecordPermission = .undetermined
31+
32+
/// Error message if detection failed
33+
@Published public private(set) var errorMessage: String?
34+
35+
// MARK: - Callbacks
36+
37+
/// Called when loud audio is detected
38+
public var onLoudAudioDetected: (() -> Void)?
39+
40+
/// Called when audio level crosses threshold
41+
public var onThresholdCrossed: ((Bool) -> Void)?
42+
43+
// MARK: - Private
44+
45+
private var settings: KioskSettings { KioskModeManager.shared.settings }
46+
private var audioRecorder: AVAudioRecorder?
47+
private var meteringTimer: Timer?
48+
49+
// Detection settings
50+
private let sampleInterval: TimeInterval = 0.1 // 100ms
51+
private var loudThresholdDB: Float = -20 // Adjustable
52+
private var quietThresholdDB: Float = -50
53+
private var consecutiveLoudSamples: Int = 0
54+
private let loudSampleThreshold: Int = 3 // Samples needed to confirm loud
55+
56+
// MARK: - Initialization
57+
58+
private init() {
59+
checkAuthorizationStatus()
60+
}
61+
62+
deinit {
63+
audioRecorder?.stop()
64+
audioRecorder = nil
65+
meteringTimer?.invalidate()
66+
meteringTimer = nil
67+
}
68+
69+
// MARK: - Public Methods
70+
71+
/// Start ambient audio detection
72+
public func start() {
73+
guard !isActive else { return }
74+
guard authorizationStatus == .granted else {
75+
Current.Log.warning("Microphone not authorized for ambient detection")
76+
return
77+
}
78+
79+
Current.Log.info("Starting ambient audio detection")
80+
81+
setupAudioRecorder()
82+
startMetering()
83+
isActive = true
84+
errorMessage = nil
85+
}
86+
87+
/// Stop ambient audio detection
88+
public func stop() {
89+
guard isActive else { return }
90+
91+
Current.Log.info("Stopping ambient audio detection")
92+
93+
stopMetering()
94+
audioRecorder?.stop()
95+
audioRecorder = nil
96+
isActive = false
97+
audioLevel = 0
98+
audioLevelDB = -160
99+
loudAudioDetected = false
100+
}
101+
102+
/// Request microphone authorization
103+
public func requestAuthorization() async -> Bool {
104+
return await withCheckedContinuation { continuation in
105+
AVAudioSession.sharedInstance().requestRecordPermission { granted in
106+
Task { @MainActor in
107+
self.checkAuthorizationStatus()
108+
continuation.resume(returning: granted)
109+
}
110+
}
111+
}
112+
}
113+
114+
/// Set detection threshold in dB (e.g., -20 for loud, -50 for quiet)
115+
public func setThreshold(loud: Float, quiet: Float) {
116+
loudThresholdDB = loud
117+
quietThresholdDB = quiet
118+
}
119+
120+
// MARK: - Private Methods
121+
122+
private func checkAuthorizationStatus() {
123+
authorizationStatus = AVAudioSession.sharedInstance().recordPermission
124+
}
125+
126+
private func setupAudioRecorder() {
127+
let audioSession = AVAudioSession.sharedInstance()
128+
129+
do {
130+
try audioSession.setCategory(.playAndRecord, mode: .measurement, options: [.mixWithOthers, .defaultToSpeaker])
131+
try audioSession.setActive(true)
132+
133+
// Create temporary file for recording (required by AVAudioRecorder but we don't use it)
134+
let tempDir = FileManager.default.temporaryDirectory
135+
let tempFile = tempDir.appendingPathComponent("ambient_meter.caf")
136+
137+
let settings: [String: Any] = [
138+
AVFormatIDKey: Int(kAudioFormatAppleIMA4),
139+
AVSampleRateKey: 44100.0,
140+
AVNumberOfChannelsKey: 1,
141+
AVEncoderAudioQualityKey: AVAudioQuality.low.rawValue,
142+
]
143+
144+
audioRecorder = try AVAudioRecorder(url: tempFile, settings: settings)
145+
audioRecorder?.isMeteringEnabled = true
146+
audioRecorder?.record()
147+
148+
Current.Log.info("Audio recorder initialized for ambient detection")
149+
} catch {
150+
errorMessage = "Failed to setup audio recorder: \(error.localizedDescription)"
151+
Current.Log.error("Audio recorder setup error: \(error)")
152+
}
153+
}
154+
155+
private func startMetering() {
156+
meteringTimer = Timer.scheduledTimer(withTimeInterval: sampleInterval, repeats: true) { [weak self] _ in
157+
Task { @MainActor in
158+
self?.updateMetering()
159+
}
160+
}
161+
}
162+
163+
private func stopMetering() {
164+
meteringTimer?.invalidate()
165+
meteringTimer = nil
166+
}
167+
168+
private func updateMetering() {
169+
guard let recorder = audioRecorder else { return }
170+
171+
recorder.updateMeters()
172+
173+
// Get average power in dB (-160 to 0)
174+
let averagePower = recorder.averagePower(forChannel: 0)
175+
audioLevelDB = averagePower
176+
177+
// Normalize to 0-1 range
178+
// -160 dB = 0, 0 dB = 1
179+
audioLevel = max(0, min(1, (averagePower + 160) / 160))
180+
181+
// Check for loud audio
182+
if averagePower > loudThresholdDB {
183+
consecutiveLoudSamples += 1
184+
185+
if consecutiveLoudSamples >= loudSampleThreshold && !loudAudioDetected {
186+
loudAudioDetected = true
187+
onLoudAudioDetected?()
188+
onThresholdCrossed?(true)
189+
Current.Log.info("Loud audio detected: \(averagePower) dB")
190+
}
191+
} else if averagePower < quietThresholdDB {
192+
if loudAudioDetected {
193+
loudAudioDetected = false
194+
onThresholdCrossed?(false)
195+
Current.Log.info("Audio returned to quiet: \(averagePower) dB")
196+
}
197+
consecutiveLoudSamples = 0
198+
}
199+
}
200+
}
201+
202+
// MARK: - Sensor State Access
203+
204+
extension AmbientAudioDetector {
205+
/// Get audio level for HA sensor reporting (0-100)
206+
public var audioLevelPercent: Int {
207+
Int(audioLevel * 100)
208+
}
209+
210+
/// Get sensor attributes for HA
211+
public var sensorAttributes: [String: Any] {
212+
[
213+
"level_db": audioLevelDB,
214+
"level_percent": audioLevelPercent,
215+
"loud_detected": loudAudioDetected,
216+
"detection_active": isActive,
217+
"threshold_loud_db": loudThresholdDB,
218+
"threshold_quiet_db": quietThresholdDB,
219+
]
220+
}
221+
}
222+
223+
// MARK: - Use Cases
224+
225+
extension AmbientAudioDetector {
226+
/// Configure for voice activity detection
227+
public func configureForVoiceDetection() {
228+
setThreshold(loud: -30, quiet: -45)
229+
}
230+
231+
/// Configure for loud noise detection (e.g., smoke alarm)
232+
public func configureForLoudNoiseDetection() {
233+
setThreshold(loud: -15, quiet: -30)
234+
}
235+
236+
/// Configure for quiet room detection
237+
public func configureForQuietRoomDetection() {
238+
setThreshold(loud: -40, quiet: -55)
239+
}
240+
}

0 commit comments

Comments
 (0)