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>
341 lines
12 KiB
Swift
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)
|
|
}
|
|
}
|
|
}
|