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>
252 lines
8.1 KiB
Swift
252 lines
8.1 KiB
Swift
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"
|
||
}
|
||
}
|
||
}
|