moodlit-native/Sources/Core/SmartHome/HueController.swift
till e25982b5cd feat(smart-home): μ-10.6 Hue Entertainment-API DTLS 50Hz
Echtes 50-Hz-Streaming auf Hue-Lampen via DTLS-UDP (Spotify+Hue-
Pfad). Beats kommen damit tight statt 200ms-perceptual-versetzt.

Was:
- Sources/Core/SmartHome/HueEntertainmentClient.swift @MainActor:
  - DTLS-PSK-Verbindung via Network.framework (iOS 13+):
    NWProtocolTLS.Options + sec_protocol_options_add_pre_shared_key +
    append_tls_ciphersuite(TLS_PSK_WITH_AES_128_GCM_SHA256). PSK
    identity=username (Bridge-AppKey), PSK key=hex-decoded clientkey
  - Frame-Format Hue Entertainment v2: 16-byte Header ("HueStream"
    + version 2.0 + sequence) + 36-byte UUID + N×7-byte Channel-
    Records (channel_id + 3×uint16-big-endian RGB)
  - pulseBeat(baselineHex, peakHex): peak-Frame sofort, baseline-
    Frame nach 80ms via Task.sleep
  - ResumeBox @unchecked Sendable als NSLock-Wrapper für nw-callbacks
    aus beliebigen Queues (Swift 6 strict-concurrency-compatible)
- HueClient erweitert:
  - PairResult struct mit username + clientKey (Bridge schickt clientkey
    nur bei generateclientkey:true im Request)
  - EntertainmentConfiguration-Listing via GET /clip/v2/resource/
    entertainment_configuration
  - setEntertainmentActive(configId, on) PUT mit action:"start"|"stop"
- HueController:
  - clientKey im Keychain (separater Slot, kSecAttrAccessibleAfter-
    FirstUnlock analog appKey)
  - selectedEntertainmentId in App-Group-UserDefaults
  - startEntertainment(): aktiviert Bridge-Stream + öffnet DTLS,
    init-Channels = alle schwarz
  - stopEntertainment(): close + deactivate Bridge-Stream
  - pulseBeatViaEntertainmentOrClip: routet DTLS oder CLIP-Fallback
- HueSettingsView: neue Section "Entertainment-Area (50 Hz Beat-Sync)"
  mit Auswahl-Liste + footer-Erklärung
- MoodPlayerView: onAppear startet Entertainment-Stream (no-op wenn
  keine Config gewählt), BeatSubscriber-Callback routet durch
  pulseBeatViaEntertainmentOrClip, onDisappear stop

User-Setup-Pfad: Hue-App → Sync → Entertainment-Area erstellen →
in Moodlit-Settings die Area auswählen → beim nächsten Mood-Play
gehen alle Lampen auf DTLS-Stream statt CLIP-Single-Calls.

Fallback: ohne ausgewählte Entertainment-Config bleibt der CLIP-API-
Pulse-Pfad aktiv (alter μ-10.3-Code), kein Bruch in der UX.

Build iOS+macOS BUILD SUCCEEDED, 18/18 Tests grün.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 16:31:50 +02:00

435 lines
13 KiB
Swift

import Foundation
import OSLog
import SwiftUI
/// Top-Level-State für die Hue-Integration. Speichert die gepairete
/// Bridge + App-Key + ausgewählte Lampen-IDs, beantwortet Setup-Fragen
/// für die UI, und exponiert `applyMood()` für den Player-Loop.
///
/// **State-Storage:**
/// - Bridge + Light-Selection: `UserDefaults` mit App-Group-Suite
/// - App-Key: **Keychain** (sensitiv, Hue-Bridge-Lese-/Schreib-Token)
@MainActor
@Observable
final class HueController {
enum SetupState: Sendable, Equatable {
case notConfigured
case pairing(bridgeId: String)
case configured(bridgeId: String, host: String, lightCount: Int)
case failed(message: String)
}
private(set) var setupState: SetupState = .notConfigured
private(set) var discovered: [HueClient.Bridge] = []
private(set) var availableLights: [HueClient.Light] = []
private(set) var selectedLightIds: Set<String> = []
private(set) var lastError: String?
private(set) var entertainmentConfigurations: [HueClient.EntertainmentConfiguration] = []
private(set) var selectedEntertainmentId: String?
private(set) var entertainment: HueEntertainmentClient?
private let log = Logger(subsystem: "ev.mana.moodlit", category: "hue-controller")
private let bridgeStorageKey = "moodlit.hue.bridge.v1"
private let selectedLightsKey = "moodlit.hue.selectedLights.v1"
private let appKeyKeychainKey = "moodlit.hue.appKey.v1"
private let clientKeyKeychainKey = "moodlit.hue.clientKey.v1"
private let selectedEntertainmentKey = "moodlit.hue.selectedEntertainment.v1"
private let appGroup: String?
private var lastApplyTask: Task<Void, Never>?
private var client: HueClient? {
guard let bridge = loadStoredBridge(), let key = loadAppKey() else { return nil }
return HueClient(bridge: bridge, appKey: key)
}
init(appGroup: String? = nil) {
self.appGroup = appGroup
self.selectedLightIds = Set(
defaults.stringArray(forKey: selectedLightsKey) ?? []
)
self.selectedEntertainmentId = defaults.string(forKey: selectedEntertainmentKey)
if let bridge = loadStoredBridge(), loadAppKey() != nil {
setupState = .configured(
bridgeId: bridge.id,
host: bridge.host,
lightCount: selectedLightIds.count
)
}
}
// MARK: - Discovery
func discover() async {
do {
discovered = try await HueClient.discoverViaCloud()
lastError = nil
} catch {
lastError = error.localizedDescription
discovered = []
}
}
func probeManual(host: String) async -> HueClient.Bridge? {
do {
let bridge = try await HueClient.probe(host: host)
if !discovered.contains(where: { $0.id == bridge.id }) {
discovered.append(bridge)
}
lastError = nil
return bridge
} catch {
lastError = error.localizedDescription
return nil
}
}
// MARK: - Pair (Press-Button)
/// Versucht für ~30s alle 2s den Pair-Endpoint. Bricht ab sobald der
/// User den Bridge-Knopf gedrückt hat oder das Timeout abläuft.
func pair(bridge: HueClient.Bridge) async {
setupState = .pairing(bridgeId: bridge.id)
lastError = nil
let deadline = Date().addingTimeInterval(35)
while Date() < deadline {
do {
let result = try await HueClient.pair(bridge: bridge)
saveBridge(bridge)
saveAppKey(result.username)
if !result.clientKey.isEmpty {
saveClientKey(result.clientKey)
}
await refreshLights()
await refreshEntertainmentConfigurations()
setupState = .configured(
bridgeId: bridge.id,
host: bridge.host,
lightCount: selectedLightIds.count
)
return
} catch HueError.buttonNotPressed {
try? await Task.sleep(nanoseconds: 2_000_000_000)
continue
} catch {
setupState = .failed(message: error.localizedDescription)
lastError = error.localizedDescription
return
}
}
setupState = .failed(message: "Pair-Timeout — Knopf nicht innerhalb 30s gedrückt")
lastError = "Pair-Timeout"
}
// MARK: - Entertainment-Configurations
func refreshEntertainmentConfigurations() async {
guard let client else { return }
do {
entertainmentConfigurations = try await client.listEntertainmentConfigurations()
} catch {
lastError = error.localizedDescription
}
}
func selectEntertainment(configId: String?) {
selectedEntertainmentId = configId
if let id = configId {
defaults.set(id, forKey: selectedEntertainmentKey)
} else {
defaults.removeObject(forKey: selectedEntertainmentKey)
}
}
/// Aktiviert den DTLS-Stream-Mode auf der Bridge + öffnet die
/// DTLS-UDP-Connection. Wird vom MoodPlayerView aufgerufen wenn
/// Music-Sync-Mode aktiv ist. No-op wenn kein Entertainment-Config
/// gewählt oder kein clientKey vorhanden.
func startEntertainment() async {
guard let configId = selectedEntertainmentId,
let bridge = loadStoredBridge(),
let appKey = loadAppKey(),
let clientKey = loadClientKey(),
let config = entertainmentConfigurations.first(where: { $0.id == configId }),
let client else {
log.info("Entertainment-Start skip: keine Config/Keys")
return
}
do {
try await client.setEntertainmentActive(configId: configId, on: true)
} catch {
log.warning("Entertainment-Activation failed: \(error.localizedDescription, privacy: .public)")
return
}
let dtls = HueEntertainmentClient(
bridgeHost: bridge.host,
appKey: appKey,
clientKeyHex: clientKey,
configId: configId
)
// Initial-Frames: alle Channels schwarz (dann pulsed der erste Beat)
let initialChannels = (0..<config.channelCount).map { idx in
HueEntertainmentClient.Channel(id: UInt8(idx), rgb: (0, 0, 0))
}
do {
try await dtls.start(initialChannels: initialChannels)
entertainment = dtls
} catch {
log.warning("DTLS-Start failed: \(error.localizedDescription, privacy: .public)")
try? await client.setEntertainmentActive(configId: configId, on: false)
}
}
func stopEntertainment() async {
entertainment?.stop()
entertainment = nil
if let client, let configId = selectedEntertainmentId {
try? await client.setEntertainmentActive(configId: configId, on: false)
}
}
/// Wird vom BeatSubscriber pro Beat aufgerufen. Wenn
/// Entertainment-Stream aktiv: 50Hz-DTLS-Frame. Sonst: CLIP-API
/// pulseBeat (fallback). Caller muss baselineHex liefern damit der
/// Puls auf die Mood-Farbe zurückkehrt.
func pulseBeatViaEntertainmentOrClip(
baselineHex: String,
baselineBrightnessPct: Int
) {
if let dtls = entertainment {
dtls.pulseBeat(baselineHex: baselineHex)
} else {
pulseBeat(baselineBrightnessPct: baselineBrightnessPct)
}
}
// MARK: - Lights
func refreshLights() async {
guard let client else { return }
do {
availableLights = try await client.listLights()
} catch {
lastError = error.localizedDescription
}
}
func toggleSelected(lightId: String) {
if selectedLightIds.contains(lightId) {
selectedLightIds.remove(lightId)
} else {
selectedLightIds.insert(lightId)
}
defaults.set(Array(selectedLightIds), forKey: selectedLightsKey)
updateConfiguredCount()
}
// MARK: - Apply Mood
/// Pusht eine Mood-Palette an alle ausgewählten Lampen. Wenn
/// mehrere Lampen aktiv sind, verteilen wir die Farben zyklisch
/// (1. Lampe = colors[0], 2. = colors[1 % len], ) gibt
/// kaleidoskopisches" Wohnzimmer.
///
/// Wird vom MoodPlayerView aus aufgerufen (lokal oder Mood-
/// Wechsel via Presence). HTTP-Calls laufen sequenziell bei
/// typisch 10 Lampen pro Wohnzimmer ist Parallelität nicht
/// die Latenz-Bottleneck (Hue-Bridge selbst rate-limited auf
/// ~10 req/s pro Light sowieso).
func applyMood(colors: [String], brightnessPct: Int) {
guard let client else { return }
guard !selectedLightIds.isEmpty, !colors.isEmpty else { return }
// Vorigen Task canceln bei schnellem Mood-Wechsel ist nur
// der letzte interessant.
lastApplyTask?.cancel()
let lights = Array(selectedLightIds)
let palette = colors
lastApplyTask = Task { [weak self] in
for (idx, lightId) in lights.enumerated() {
if Task.isCancelled { return }
let color = palette[idx % palette.count]
do {
try await client.setLight(
id: lightId,
on: true,
hex: color,
brightnessPct: brightnessPct,
transitionMs: 600
)
} catch {
await self?.recordError(error)
return
}
}
}
}
/// Kurzer Brightness-Puls auf alle ausgewählten Lampen. Zweck:
/// Beat-Pulse aus dem BeatSubscriber. Lampen springen kurz auf
/// 100% Helligkeit und kehren danach auf `baselineBrightnessPct`
/// zurück gibt das pulsing on the beat"-Gefühl.
///
/// Hue-Rate-Limit: ~10 Calls/s pro Lampe. Bei 180 BPM (3 Beats/s)
/// * 2 Calls (auf+ab) = 6/s okay. Bei deutlich höheren BPM
/// würden wir Beats droppen müssen; aktuell rufen wir trotzdem
/// alle Schedules die Bridge ignoriert dann manche Updates.
func pulseBeat(baselineBrightnessPct: Int) {
guard let client else { return }
guard !selectedLightIds.isEmpty else { return }
let lights = Array(selectedLightIds)
Task { [weak self] in
// Auf 100% kurze Transition (50ms) damit's snappy ist
for lightId in lights {
try? await client.setLight(
id: lightId,
brightnessPct: 100,
transitionMs: 0
)
}
try? await Task.sleep(nanoseconds: 80_000_000) // 80ms
if Task.isCancelled { return }
// Zurück zur Baseline sanfte Transition (200ms) für das
// Atmungs"-Gefühl
for lightId in lights {
try? await client.setLight(
id: lightId,
brightnessPct: baselineBrightnessPct,
transitionMs: 200
)
}
_ = self
}
}
/// Schaltet alle ausgewählten Lampen aus (beim Player-Close).
func turnOffSelected() {
guard let client else { return }
let lights = Array(selectedLightIds)
Task {
for lightId in lights {
try? await client.setLight(id: lightId, on: false, transitionMs: 400)
}
}
}
// MARK: - Reset
func clearConfig() {
defaults.removeObject(forKey: bridgeStorageKey)
defaults.removeObject(forKey: selectedLightsKey)
removeAppKeyFromKeychain()
selectedLightIds = []
availableLights = []
setupState = .notConfigured
}
// MARK: - Persistence
private var defaults: UserDefaults {
appGroup.flatMap { UserDefaults(suiteName: $0) } ?? .standard
}
private func saveBridge(_ bridge: HueClient.Bridge) {
if let data = try? JSONEncoder().encode(bridge) {
defaults.set(data, forKey: bridgeStorageKey)
}
}
private func loadStoredBridge() -> HueClient.Bridge? {
guard let data = defaults.data(forKey: bridgeStorageKey) else { return nil }
return try? JSONDecoder().decode(HueClient.Bridge.self, from: data)
}
private func updateConfiguredCount() {
if case .configured(let id, let host, _) = setupState {
setupState = .configured(
bridgeId: id, host: host, lightCount: selectedLightIds.count
)
}
}
@MainActor
private func recordError(_ error: Error) async {
lastError = error.localizedDescription
log.warning("Hue setLight failed: \(error.localizedDescription, privacy: .public)")
}
// MARK: - Keychain (App-Key)
private var keychainService: String { "ev.mana.moodlit.hue" }
private var keychainAccount: String { appKeyKeychainKey }
private func saveAppKey(_ key: String) {
let data = Data(key.utf8)
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: keychainService,
kSecAttrAccount as String: keychainAccount,
]
SecItemDelete(query as CFDictionary)
var add = query
add[kSecValueData as String] = data
add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
SecItemAdd(add as CFDictionary, nil)
}
private func loadAppKey() -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: keychainService,
kSecAttrAccount as String: keychainAccount,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
var item: AnyObject?
guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess,
let data = item as? Data,
let str = String(data: data, encoding: .utf8)
else { return nil }
return str
}
private func removeAppKeyFromKeychain() {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: keychainService,
kSecAttrAccount as String: keychainAccount,
]
SecItemDelete(query as CFDictionary)
}
// MARK: - ClientKey-Keychain (für DTLS-Entertainment-Stream)
private func saveClientKey(_ key: String) {
let data = Data(key.utf8)
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: keychainService,
kSecAttrAccount as String: clientKeyKeychainKey,
]
SecItemDelete(query as CFDictionary)
var add = query
add[kSecValueData as String] = data
add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
SecItemAdd(add as CFDictionary, nil)
}
private func loadClientKey() -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: keychainService,
kSecAttrAccount as String: clientKeyKeychainKey,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
var item: AnyObject?
guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess,
let data = item as? Data,
let str = String(data: data, encoding: .utf8)
else { return nil }
return str
}
}