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

341 lines
12 KiB
Swift

import Foundation
import OSLog
/// Philips-Hue-Bridge-Client. Spricht das offizielle CLIP v2 REST-API
/// (https://developers.meethue.com/develop/hue-api-v2/) lokal über
/// HTTPS gegen die Bridge im LAN.
///
/// **Architektur-Entscheidung:** Komplett client-side. Der App-Key
/// (Bridge-Token) lebt im iOS-Keychain des Geräts, NIE auf einem
/// mana-Server. Das ist die saubere Privacy-Variante kein
/// Vereins-Service speichert deine Lampen-Auth.
///
/// Drawback: Lampen reagieren nur wenn die App offen oder im
/// Background-Run ist. Server-Side-Bridge-Service kommt erst, wenn
/// User-Demand für auch im Hintergrund" auftaucht.
///
/// Bridge-TLS hat ein self-signed Cert mit einer von Signify
/// signierten Bridge-ID wir akzeptieren bewusst alle Certs (siehe
/// `bridgeTrustingURLSession`), weil:
/// 1. Der Discovery liefert Bridge-ID, wir matchen sie gegen das CN
/// (TODO V2).
/// 2. Auth-Token ist Press-Button-erworben, MITM würde nichts ändern.
/// 3. CIE-xy-Color ist nicht sensitiv genug für strikte PKI.
///
/// Wenn Hue Bridge irgendwann publicly-trusted Certs hat (V2-Branch),
/// kippen wir auf URLSession.shared.
struct HueClient: Sendable {
/// Bridge-LAN-Adresse + optionaler App-Key. Beim Pair-Flow ist
/// `appKey == nil`, bei allen anderen Calls Pflicht.
struct Bridge: Codable, Hashable, Sendable {
let id: String // Hex-ID, eindeutig pro Bridge
let host: String // "192.168.178.50" oder mDNS-Name
let internalIPAddress: String?
let modelId: String?
}
struct Light: Codable, Hashable, Sendable, Identifiable {
let id: String // Hue Resource-ID (UUID v4)
let name: String
let isOn: Bool
let supportsColor: Bool
let supportsDimming: Bool
}
struct EntertainmentConfiguration: Codable, Hashable, Sendable, Identifiable {
let id: String
let name: String
let status: String // "active" | "inactive"
let channelCount: Int // Anzahl Channels für DTLS-Frames
}
struct PairResult: Sendable {
let username: String // hue-application-key
let clientKey: String // PSK (hex) für DTLS Entertainment-Stream
}
let bridge: Bridge
let appKey: String?
private let session: URLSession
private let log = Logger(subsystem: "ev.mana.moodlit", category: "hue")
init(bridge: Bridge, appKey: String? = nil) {
self.bridge = bridge
self.appKey = appKey
self.session = Self.bridgeTrustingURLSession()
}
// MARK: - Discovery (Cloud)
/// Fragt Philips' Discovery-Endpoint nach Bridges im selben WAN.
/// Liefert IP-Adressen, mit denen man Pair starten kann.
///
/// Cloud-Pfad ist Default mDNS via NWBrowser kann später als
/// Bonus-Discovery dazukommen (μ-9.4).
static func discoverViaCloud() async throws -> [Bridge] {
let url = URL(string: "https://discovery.meethue.com")!
let (data, response) = try await URLSession.shared.data(from: url)
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
throw HueError.discoveryFailed
}
struct DiscoveryEntry: Decodable {
let id: String
let internalipaddress: String
let port: Int?
}
let entries = try JSONDecoder().decode([DiscoveryEntry].self, from: data)
return entries.map { entry in
Bridge(
id: entry.id,
host: entry.internalipaddress,
internalIPAddress: entry.internalipaddress,
modelId: nil
)
}
}
/// Manuelle Bridge-Eingabe User tippt IP-Adresse, wir
/// fragen `/api/0/config` ab um die Bridge-ID zu verifizieren.
static func probe(host: String) async throws -> Bridge {
let session = bridgeTrustingURLSession()
let url = URL(string: "https://\(host)/api/0/config")!
let (data, response) = try await session.data(from: url)
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
throw HueError.bridgeUnreachable
}
struct Config: Decodable {
let bridgeid: String?
let modelid: String?
}
let cfg = try JSONDecoder().decode(Config.self, from: data)
guard let bridgeId = cfg.bridgeid else {
throw HueError.bridgeUnreachable
}
return Bridge(id: bridgeId, host: host, internalIPAddress: host, modelId: cfg.modelid)
}
// MARK: - Pair (Button-Press)
/// Initialer Pair: User drückt den Knopf auf der Bridge, dann
/// rufen wir `/api` mit `{ devicetype: "moodlit#<bundleid>" }`. Bei
/// Erfolg liefert die Bridge einen App-Key zurück.
///
/// Wenn der Button NICHT gedrückt wurde, gibt die Bridge
/// `{"error":{"type":101,"description":"link button not pressed"}}`.
static func pair(bridge: Bridge, deviceName: String = "moodlit-ios") async throws -> PairResult {
let session = bridgeTrustingURLSession()
let url = URL(string: "https://\(bridge.host)/api")!
var req = URLRequest(url: url)
req.httpMethod = "POST"
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.httpBody = try JSONSerialization.data(
withJSONObject: ["devicetype": deviceName, "generateclientkey": true]
)
let (data, response) = try await session.data(for: req)
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
throw HueError.pairFailed("HTTP \(String(describing: response))")
}
// Antwort ist ein Array entweder Success oder Error.
struct PairResponse: Decodable {
struct Success: Decodable {
let username: String
let clientkey: String? // optional nur wenn generateclientkey:true im Request
}
struct ErrorBody: Decodable {
let type: Int
let description: String
}
let success: Success?
let error: ErrorBody?
}
let entries = try JSONDecoder().decode([PairResponse].self, from: data)
if let success = entries.first?.success {
return PairResult(
username: success.username,
clientKey: success.clientkey ?? ""
)
}
if let err = entries.first?.error {
if err.type == 101 {
throw HueError.buttonNotPressed
}
throw HueError.pairFailed(err.description)
}
throw HueError.pairFailed("Leere Antwort")
}
// MARK: - Entertainment Configurations
func listEntertainmentConfigurations() async throws -> [EntertainmentConfiguration] {
guard let key = appKey else { throw HueError.missingAppKey }
let url = URL(string: "https://\(bridge.host)/clip/v2/resource/entertainment_configuration")!
var req = URLRequest(url: url)
req.setValue(key, forHTTPHeaderField: "hue-application-key")
let (data, response) = try await session.data(for: req)
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
throw HueError.apiError("listEntertainment HTTP \(String(describing: response))")
}
struct EcResp: Decodable {
struct Item: Decodable {
struct Channel: Decodable { let channel_id: Int }
struct MD: Decodable { let name: String }
let id: String
let metadata: MD?
let status: String?
let channels: [Channel]?
}
let data: [Item]
}
let resp = try JSONDecoder().decode(EcResp.self, from: data)
return resp.data.map { item in
EntertainmentConfiguration(
id: item.id,
name: item.metadata?.name ?? "Entertainment Area",
status: item.status ?? "inactive",
channelCount: item.channels?.count ?? 0
)
}
}
/// Aktiviert oder deaktiviert das DTLS-Streaming-Mode für eine
/// Entertainment-Configuration. Vor erstem DTLS-Send mit action:"start"
/// aufrufen, beim Close mit "stop".
func setEntertainmentActive(configId: String, on: Bool) async throws {
guard let key = appKey else { throw HueError.missingAppKey }
let url = URL(string: "https://\(bridge.host)/clip/v2/resource/entertainment_configuration/\(configId)")!
var req = URLRequest(url: url)
req.httpMethod = "PUT"
req.setValue(key, forHTTPHeaderField: "hue-application-key")
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.httpBody = try JSONSerialization.data(
withJSONObject: ["action": on ? "start" : "stop"]
)
let (_, response) = try await session.data(for: req)
guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else {
throw HueEntertainmentError.streamActivationFailed("HTTP \(String(describing: response))")
}
}
// MARK: - Lights
func listLights() async throws -> [Light] {
guard let key = appKey else { throw HueError.missingAppKey }
let url = URL(string: "https://\(bridge.host)/clip/v2/resource/light")!
var req = URLRequest(url: url)
req.setValue(key, forHTTPHeaderField: "hue-application-key")
let (data, response) = try await session.data(for: req)
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
throw HueError.apiError("listLights HTTP \(String(describing: response))")
}
struct LightsResp: Decodable {
struct Item: Decodable {
struct MD: Decodable { let name: String }
struct OnState: Decodable { let on: Bool }
struct Dim: Decodable { let brightness: Double? }
struct Color: Decodable { struct XY: Decodable { let x: Double; let y: Double }
let xy: XY?
}
let id: String
let metadata: MD
let on: OnState
let dimming: Dim?
let color: Color?
}
let data: [Item]
}
let resp = try JSONDecoder().decode(LightsResp.self, from: data)
return resp.data.map { item in
Light(
id: item.id,
name: item.metadata.name,
isOn: item.on.on,
supportsColor: item.color != nil,
supportsDimming: item.dimming != nil
)
}
}
// MARK: - Set Light State
/// Setzt Farbe + Helligkeit + On-State. Alle Parameter optional
/// nur die genannten werden gepatcht.
func setLight(
id: String,
on: Bool? = nil,
hex: String? = nil,
brightnessPct: Int? = nil,
transitionMs: Int = 200
) async throws {
guard let key = appKey else { throw HueError.missingAppKey }
let url = URL(string: "https://\(bridge.host)/clip/v2/resource/light/\(id)")!
var req = URLRequest(url: url)
req.httpMethod = "PUT"
req.setValue(key, forHTTPHeaderField: "hue-application-key")
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
var body: [String: Any] = [:]
if let on { body["on"] = ["on": on] }
if let hex, let conv = HueColor.sRGBHexToXY(hex) {
body["color"] = ["xy": ["x": conv.xy.0, "y": conv.xy.1]]
}
if let brightnessPct {
body["dimming"] = ["brightness": max(1, min(100, brightnessPct))]
}
body["dynamics"] = ["duration": max(0, transitionMs)]
req.httpBody = try JSONSerialization.data(withJSONObject: body)
let (_, response) = try await session.data(for: req)
guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else {
throw HueError.apiError("setLight HTTP \(String(describing: response))")
}
}
// MARK: - TLS-Trust für self-signed Bridge-Certs
private static func bridgeTrustingURLSession() -> URLSession {
let config = URLSessionConfiguration.ephemeral
return URLSession(configuration: config, delegate: BridgeTrustDelegate(), delegateQueue: nil)
}
}
enum HueError: Error, LocalizedError, Sendable {
case discoveryFailed
case bridgeUnreachable
case buttonNotPressed
case pairFailed(String)
case missingAppKey
case apiError(String)
var errorDescription: String? {
switch self {
case .discoveryFailed: return "Hue-Bridge-Suche fehlgeschlagen"
case .bridgeUnreachable: return "Bridge unter dieser IP nicht erreichbar"
case .buttonNotPressed:
return "Drücke den runden Knopf in der Mitte deiner Hue Bridge und versuche es erneut (innerhalb 30 Sekunden)."
case .pairFailed(let msg): return "Pair fehlgeschlagen: \(msg)"
case .missingAppKey: return "Diese Bridge ist noch nicht gepaart"
case .apiError(let msg): return msg
}
}
}
private final class BridgeTrustDelegate: NSObject, URLSessionDelegate {
func urlSession(
_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) {
// Bridges haben self-signed Certs mit Bridge-ID im CN. Wir
// trusten sie pauschal siehe Begründung im HueClient-Doc.
if let trust = challenge.protectionSpace.serverTrust {
completionHandler(.useCredential, URLCredential(trust: trust))
} else {
completionHandler(.performDefaultHandling, nil)
}
}
}