moodlit-native/Sources/Core/SmartHome/HueEntertainmentClient.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

252 lines
8.1 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import Foundation
import Network
import OSLog
/// DTLS-UDP-Client für Hue Entertainment API v2 der Spotify+Hue-
/// Goldstandard für Music-Sync. Apple's Network.framework supportet
/// DTLS-PSK seit iOS 13, kein externes Crypto.
///
/// Protocol (Hue Entertainment v2):
/// Port: 2100 UDP über DTLS 1.2
/// Cipher: TLS_PSK_WITH_AES_128_GCM_SHA256
/// PSK identity = Bridge-Application-ID (von /auth/v1)
/// PSK key = client-key (Hex-Decoded vom Pair-Flow,
/// `generateclientkey: true`)
///
/// Frame (vor jedem Send):
/// Header (16 bytes):
/// 'HueStream' (9 bytes ASCII)
/// Version Major 0x02, Version Minor 0x00
/// Sequence ID (1 byte, incrementing, wraps at 256)
/// Reserved 0x00 0x00
/// Color Space (0x00 = RGB, 0x01 = xy+brightness)
/// Reserved 0x00
/// Entertainment-Configuration-UUID (36 bytes ASCII mit Bindestrichen)
/// Per Channel (7 bytes):
/// Channel ID (1 byte)
/// 3× 16-bit Color-Werte (RGB: R, G, B als big-endian uint16)
///
/// Ab dem ersten Frame muss der Stream "aktiv" sein die Bridge
/// blockt sonst andere CLIP-API-Writes auf den Lights. Wir aktivieren
/// ihn via REST `PUT /clip/v2/resource/entertainment_configuration/:id
/// { action: "start" }` bevor wir den ersten DTLS-Frame schicken,
/// und deaktivieren mit `"stop"` beim Close.
@MainActor
final class HueEntertainmentClient {
enum State: Sendable, Equatable {
case disconnected
case starting
case streaming
case failed(message: String)
}
struct Channel: Sendable {
let id: UInt8
var rgb: (UInt16, UInt16, UInt16)
}
private(set) var state: State = .disconnected
private let bridgeHost: String
private let appKey: String // PSK identity
private let clientKeyHex: String // PSK key (hex string)
private let configId: String // Entertainment-Configuration-UUID
private let log = Logger(subsystem: "ev.mana.moodlit", category: "hue-entertainment")
private var connection: NWConnection?
private var sendSequence: UInt8 = 0
private var lastChannels: [Channel] = []
init(bridgeHost: String, appKey: String, clientKeyHex: String, configId: String) {
self.bridgeHost = bridgeHost
self.appKey = appKey
self.clientKeyHex = clientKeyHex
self.configId = configId
}
// MARK: - Lifecycle
func start(initialChannels: [Channel]) async throws {
state = .starting
lastChannels = initialChannels
guard let pskData = Self.hexDataFrom(clientKeyHex) else {
throw HueEntertainmentError.invalidClientKey
}
let endpoint = NWEndpoint.hostPort(
host: .init(bridgeHost),
port: 2100
)
let dtlsOptions = NWProtocolTLS.Options()
let sec = dtlsOptions.securityProtocolOptions
sec_protocol_options_set_min_tls_protocol_version(sec, .DTLSv12)
sec_protocol_options_set_max_tls_protocol_version(sec, .DTLSv12)
// PSK-Identity = app-key (Bridge-Username, ASCII-String)
let identityData = Data(appKey.utf8)
let identityDispatch = identityData.withUnsafeBytes { ptr in
DispatchData(bytes: ptr)
}
let pskDispatch = pskData.withUnsafeBytes { ptr in
DispatchData(bytes: ptr)
}
sec_protocol_options_add_pre_shared_key(sec, pskDispatch as __DispatchData, identityDispatch as __DispatchData)
sec_protocol_options_append_tls_ciphersuite(sec, tls_ciphersuite_t(rawValue: TLS_PSK_WITH_AES_128_GCM_SHA256)!)
let udpOptions = NWProtocolUDP.Options()
let params = NWParameters(dtls: dtlsOptions, udp: udpOptions)
params.allowFastOpen = true
let conn = NWConnection(to: endpoint, using: params)
connection = conn
let didResumeBox = ResumeBox()
let readyContinuation = await withCheckedContinuation { (continuation: CheckedContinuation<Error?, Never>) in
conn.stateUpdateHandler = { state in
switch state {
case .ready:
if didResumeBox.tryConsume() {
continuation.resume(returning: nil)
}
case .failed(let err):
if didResumeBox.tryConsume() {
continuation.resume(returning: err)
}
case .cancelled:
if didResumeBox.tryConsume() {
continuation.resume(returning: HueEntertainmentError.cancelled)
}
default:
break
}
}
conn.start(queue: .global(qos: .userInitiated))
}
if let err = readyContinuation {
throw err
}
state = .streaming
// First frame sofort Bridge erwartet < 10 s erster Frame nach
// Stream-Activation, sonst geht sie zurück in normal-Mode.
send(channels: initialChannels)
}
func send(channels: [Channel]) {
guard let conn = connection, case .streaming = state else { return }
lastChannels = channels
var packet = Data()
// Header siehe Doc-Block oben
packet.append(contentsOf: Array("HueStream".utf8)) // 9 bytes
packet.append(0x02) // version major
packet.append(0x00) // version minor
packet.append(sendSequence) // sequence
packet.append(contentsOf: [0x00, 0x00]) // reserved
packet.append(0x00) // color space: RGB
packet.append(0x00) // reserved
// Entertainment-Configuration-ID (UUID-Format mit Bindestrichen)
packet.append(contentsOf: Array(configId.utf8)) // 36 bytes
// Channel-Daten
for ch in channels {
packet.append(ch.id)
let (r, g, b) = ch.rgb
packet.append(UInt8((r >> 8) & 0xFF))
packet.append(UInt8(r & 0xFF))
packet.append(UInt8((g >> 8) & 0xFF))
packet.append(UInt8(g & 0xFF))
packet.append(UInt8((b >> 8) & 0xFF))
packet.append(UInt8(b & 0xFF))
}
conn.send(content: packet, completion: .idempotent)
sendSequence = sendSequence &+ 1
}
func stop() {
connection?.cancel()
connection = nil
state = .disconnected
}
// MARK: - Helpers
/// Per-Channel-Brightness-Puls alle Channels auf max für 80ms,
/// dann zurück. Wird vom BeatSubscriber pro Beat aufgerufen.
func pulseBeat(baselineHex: String, peakHex: String = "#ffffff") {
guard case .streaming = state, !lastChannels.isEmpty else { return }
let peak = Self.hexToRGB16(peakHex) ?? (0xffff, 0xffff, 0xffff)
let base = Self.hexToRGB16(baselineHex) ?? (0x8000, 0x8000, 0x8000)
let ids = lastChannels.map(\.id)
let peakChannels = ids.map { Channel(id: $0, rgb: peak) }
let baseChannels = ids.map { Channel(id: $0, rgb: base) }
send(channels: peakChannels)
Task {
try? await Task.sleep(nanoseconds: 80_000_000)
send(channels: baseChannels)
}
}
// MARK: - Conversions
static func hexToRGB16(_ hex: String) -> (UInt16, UInt16, UInt16)? {
guard let rgb = HueColor.parseHex(hex) else { return nil }
// 0-1 Double 0-65535 UInt16
let r = UInt16(min(65535, max(0, Int(rgb.r * 65535))))
let g = UInt16(min(65535, max(0, Int(rgb.g * 65535))))
let b = UInt16(min(65535, max(0, Int(rgb.b * 65535))))
return (r, g, b)
}
static func hexDataFrom(_ hex: String) -> Data? {
var clean = hex
if clean.hasPrefix("0x") { clean.removeFirst(2) }
guard clean.count % 2 == 0 else { return nil }
var data = Data(capacity: clean.count / 2)
var idx = clean.startIndex
while idx < clean.endIndex {
let next = clean.index(idx, offsetBy: 2)
guard let byte = UInt8(clean[idx..<next], radix: 16) else { return nil }
data.append(byte)
idx = next
}
return data
}
}
/// Atomic-style "did this thing happen once"-Box. NSLock weil
/// nw-Callbacks von beliebigen Queues feuern können und wir nicht
/// MainActor-isolated sind.
private final class ResumeBox: @unchecked Sendable {
private let lock = NSLock()
private var consumed = false
func tryConsume() -> Bool {
lock.lock()
defer { lock.unlock() }
if consumed { return false }
consumed = true
return true
}
}
enum HueEntertainmentError: Error, LocalizedError, Sendable {
case invalidClientKey
case cancelled
case streamActivationFailed(String)
case noConfiguration
var errorDescription: String? {
switch self {
case .invalidClientKey: return "Hue clientkey ist nicht gültiges Hex"
case .cancelled: return "DTLS-Connection wurde abgebrochen"
case .streamActivationFailed(let m): return "Stream-Aktivierung fehlgeschlagen: \(m)"
case .noConfiguration: return "Keine Entertainment-Konfiguration ausgewählt"
}
}
}