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>
251 lines
6.6 KiB
Swift
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
|
|
}
|
|
}
|