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>
435 lines
13 KiB
Swift
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
|
|
}
|
|
}
|