moodlit-native/Sources/Features/Settings/HueSettingsView.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

251 lines
6.6 KiB
Swift

import SwiftUI
/// Setup-UI für Philips Hue Bridge. Discovery Pair Light-Picker.
public struct HueSettingsView: View {
@Environment(HueController.self) private var controller
@State private var manualHost = ""
@State private var isDiscovering = false
public init() {}
public var body: some View {
Form {
statusSection
discoverySection
if !controller.discovered.isEmpty {
bridgesSection
}
if !controller.availableLights.isEmpty {
lightsSection
}
if !controller.entertainmentConfigurations.isEmpty {
entertainmentSection
}
if case .configured = controller.setupState {
resetSection
}
}
.navigationTitle("Philips Hue")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
}
@ViewBuilder
private var statusSection: some View {
Section {
switch controller.setupState {
case .notConfigured:
Label("Keine Bridge verbunden", systemImage: "lightbulb.slash")
.foregroundStyle(MoodlitTheme.mutedForeground)
case .pairing(let id):
HStack(spacing: 12) {
ProgressView()
VStack(alignment: .leading, spacing: 2) {
Text("Pair läuft …")
.font(.body.weight(.medium))
Text("Drücke jetzt den Knopf auf deiner Hue Bridge (Bridge-ID \(id.prefix(8))).")
.font(.caption)
.foregroundStyle(MoodlitTheme.mutedForeground)
}
}
case .configured(let id, let host, let lightCount):
VStack(alignment: .leading, spacing: 4) {
Label("Verbunden", systemImage: "checkmark.circle.fill")
.foregroundStyle(.green)
Text("Bridge \(id.prefix(8)) auf \(host)")
.font(.caption)
.foregroundStyle(MoodlitTheme.mutedForeground)
Text("\(lightCount) Lampe(n) ausgewählt")
.font(.caption)
.foregroundStyle(MoodlitTheme.mutedForeground)
}
case .failed(let msg):
Label(msg, systemImage: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
.font(.callout)
}
}
}
@ViewBuilder
private var discoverySection: some View {
Section("Bridge suchen") {
Button {
Task {
isDiscovering = true
await controller.discover()
isDiscovering = false
}
} label: {
Label(
isDiscovering ? "Suche läuft …" : "Im Netzwerk suchen",
systemImage: "magnifyingglass"
)
}
.disabled(isDiscovering)
HStack {
TextField("Oder IP-Adresse eingeben", text: $manualHost)
.textContentType(.URL)
#if os(iOS)
.keyboardType(.URL)
.autocapitalization(.none)
#endif
.disableAutocorrection(true)
Button("Prüfen") {
Task {
_ = await controller.probeManual(
host: manualHost.trimmingCharacters(in: .whitespaces)
)
}
}
.disabled(manualHost.isEmpty)
}
}
}
@ViewBuilder
private var bridgesSection: some View {
Section("Gefundene Bridges") {
ForEach(controller.discovered, id: \.id) { bridge in
Button {
Task { await controller.pair(bridge: bridge) }
} label: {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(bridge.host)
.font(.body)
Text("Bridge-ID \(bridge.id.prefix(12))")
.font(.caption2)
.foregroundStyle(MoodlitTheme.mutedForeground)
}
Spacer()
if isPairingThis(bridge.id) {
ProgressView()
} else {
Image(systemName: "chevron.right")
.foregroundStyle(MoodlitTheme.mutedForeground)
}
}
}
.disabled(isPairing)
}
}
}
@ViewBuilder
private var lightsSection: some View {
Section("Lampen") {
ForEach(controller.availableLights) { light in
Button {
controller.toggleSelected(lightId: light.id)
} label: {
HStack(spacing: 12) {
Image(systemName: controller.selectedLightIds.contains(light.id)
? "checkmark.circle.fill"
: "circle"
)
.foregroundStyle(
controller.selectedLightIds.contains(light.id)
? MoodlitTheme.primary
: MoodlitTheme.mutedForeground
)
VStack(alignment: .leading, spacing: 2) {
Text(light.name)
if !light.supportsColor {
Text("nur Helligkeit")
.font(.caption2)
.foregroundStyle(MoodlitTheme.mutedForeground)
}
}
Spacer()
Image(systemName: light.isOn ? "lightbulb.fill" : "lightbulb")
.foregroundStyle(light.isOn ? .yellow : MoodlitTheme.mutedForeground)
}
}
.buttonStyle(.plain)
.contentShape(Rectangle())
}
Button("Lampenliste aktualisieren") {
Task { await controller.refreshLights() }
}
.font(.caption)
}
}
@ViewBuilder
private var entertainmentSection: some View {
Section {
Button {
controller.selectEntertainment(configId: nil)
} label: {
HStack {
Image(systemName: controller.selectedEntertainmentId == nil
? "checkmark.circle.fill" : "circle"
)
.foregroundStyle(controller.selectedEntertainmentId == nil
? MoodlitTheme.primary : MoodlitTheme.mutedForeground
)
Text("Aus — CLIP-API-Pulse (Standard)")
}
}
.buttonStyle(.plain)
.contentShape(Rectangle())
ForEach(controller.entertainmentConfigurations) { cfg in
Button {
controller.selectEntertainment(configId: cfg.id)
} label: {
HStack {
Image(systemName: controller.selectedEntertainmentId == cfg.id
? "checkmark.circle.fill" : "circle"
)
.foregroundStyle(controller.selectedEntertainmentId == cfg.id
? MoodlitTheme.primary : MoodlitTheme.mutedForeground
)
VStack(alignment: .leading, spacing: 2) {
Text(cfg.name)
Text("\(cfg.channelCount) Lampe(n) · Status: \(cfg.status)")
.font(.caption2)
.foregroundStyle(MoodlitTheme.mutedForeground)
}
}
}
.buttonStyle(.plain)
.contentShape(Rectangle())
}
Button("Liste aktualisieren") {
Task { await controller.refreshEntertainmentConfigurations() }
}
.font(.caption)
} header: {
Text("Entertainment-Area (50 Hz Beat-Sync)")
} footer: {
Text("Entertainment-Areas legst du in der Philips-Hue-App an (\"Sync\"\"Entertainment-Area erstellen\"). Mit gewählter Area pulsen Lampen über DTLS-UDP statt langsamer CLIP-Calls — wirkliche Beat-Tightness statt nur „in der Nähe vom Beat\".")
.font(.caption)
}
}
@ViewBuilder
private var resetSection: some View {
Section {
Button(role: .destructive) {
controller.clearConfig()
} label: {
Label("Bridge entkoppeln", systemImage: "trash")
}
}
}
private var isPairing: Bool {
if case .pairing = controller.setupState { return true }
return false
}
private func isPairingThis(_ bridgeId: String) -> Bool {
if case .pairing(let id) = controller.setupState { return id == bridgeId }
return false
}
}