Dritter Smart-Home-Treiber neben Hue + HomeKit. Cloud-API für V1
(simples Setup ohne Pair-Flow), LAN-UDP-Protocol als μ-9.5.1.
Was:
- Sources/Core/SmartHome/LIFXClient.swift Sendable struct:
- listLights() gegen api.lifx.com/v1/lights/all mit Bearer-Token
- setState(selector, on, hex, brightnessPct, durationSec) PUT
/lights/:selector/state — LIFX-Cloud nimmt #rrggbb direkt + 0-1
brightness float + duration in seconds
- 401 → LIFXError.invalidToken (sauberer UX-Hint im SettingsView)
- LIFXController.swift @Observable @MainActor:
- SetupState (notConfigured/validating/configured/failed)
- Token-Storage im iOS-Keychain (analog Hue-AppKey,
kSecAttrAccessibleAfterFirstUnlock)
- Selected-Light-IDs in App-Group-UserDefaults
- applyMood verteilt Farben zyklisch (analog HueController),
durationSec=0.6 für smooth transitions
- turnOffSelected() beim Player-Close
- LIFXSettingsView mit Token-Paste-Form (monospaced field),
Validate-Button, Lampen-Picker gruppiert per Group, Reset
- SettingsView neuer NavigationLink LIFX in Smart-Home-Section
- MoodPlayerView pushToHue() ruft jetzt parallel hue+lifx+homekit;
turnOffHomeLights analog
Trade-off explizit: ~200ms Cloud-Latenz vs. ~30ms Hue-LAN — Hue
bleibt der Beat-Sync-Pfad. LIFX nur für statische Mood-Wechsel
ausreichend; das ist im InfoSection vermerkt.
Build iOS+macOS BUILD SUCCEEDED, 18/18 Tests grün.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
143 lines
3.7 KiB
Swift
143 lines
3.7 KiB
Swift
import SwiftUI
|
|
|
|
/// Setup-UI für LIFX-Cloud-API. Token-Paste → Validate → Light-Picker.
|
|
public struct LIFXSettingsView: View {
|
|
@Environment(LIFXController.self) private var controller
|
|
@State private var tokenInput = ""
|
|
|
|
public init() {}
|
|
|
|
public var body: some View {
|
|
Form {
|
|
statusSection
|
|
if !isConfigured {
|
|
tokenSection
|
|
}
|
|
if !controller.availableLights.isEmpty {
|
|
lightsSection
|
|
}
|
|
if isConfigured {
|
|
resetSection
|
|
}
|
|
infoSection
|
|
}
|
|
.navigationTitle("LIFX")
|
|
#if os(iOS)
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
#endif
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var statusSection: some View {
|
|
Section {
|
|
switch controller.setupState {
|
|
case .notConfigured:
|
|
Label("Kein Token verbunden", systemImage: "lightbulb.slash")
|
|
.foregroundStyle(MoodlitTheme.mutedForeground)
|
|
case .validating:
|
|
HStack(spacing: 12) {
|
|
ProgressView()
|
|
Text("Token wird geprüft …")
|
|
}
|
|
case .configured(let count):
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Label("Verbunden", systemImage: "checkmark.circle.fill")
|
|
.foregroundStyle(.green)
|
|
Text("\(count) 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 tokenSection: some View {
|
|
Section("Personal Access Token") {
|
|
TextField("LIFX-Token einfügen", text: $tokenInput, axis: .vertical)
|
|
.lineLimit(2...4)
|
|
#if os(iOS)
|
|
.autocapitalization(.none)
|
|
#endif
|
|
.disableAutocorrection(true)
|
|
.font(.system(.body, design: .monospaced))
|
|
Button("Verbinden") {
|
|
Task { await controller.setToken(tokenInput) }
|
|
}
|
|
.disabled(tokenInput.trimmingCharacters(in: .whitespaces).isEmpty)
|
|
Text("Token erstellen auf cloud.lifx.com/settings — er bleibt im iOS-Keychain dieses Geräts, wird nicht an mana-Server geschickt.")
|
|
.font(.caption)
|
|
.foregroundStyle(MoodlitTheme.mutedForeground)
|
|
}
|
|
}
|
|
|
|
@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.label)
|
|
if let group = light.group {
|
|
Text(group.name)
|
|
.font(.caption2)
|
|
.foregroundStyle(MoodlitTheme.mutedForeground)
|
|
}
|
|
}
|
|
Spacer()
|
|
Image(systemName: light.power == "on" ? "lightbulb.fill" : "lightbulb")
|
|
.foregroundStyle(light.power == "on" ? .yellow : MoodlitTheme.mutedForeground)
|
|
}
|
|
}
|
|
.buttonStyle(.plain)
|
|
.contentShape(Rectangle())
|
|
}
|
|
Button("Lampenliste aktualisieren") {
|
|
Task { await controller.refreshLights() }
|
|
}
|
|
.font(.caption)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var resetSection: some View {
|
|
Section {
|
|
Button(role: .destructive) {
|
|
controller.clearConfig()
|
|
tokenInput = ""
|
|
} label: {
|
|
Label("Token entfernen", systemImage: "trash")
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var infoSection: some View {
|
|
Section {
|
|
Text("LIFX-Cloud-API hat ~200 ms Latenz (gegen ~30 ms Hue-LAN). Für Mood-Wechsel okay, für Beat-Sync nutz lieber Hue. LAN-Direkt-Steuerung kommt mit μ-9.5.1.")
|
|
.font(.caption)
|
|
.foregroundStyle(MoodlitTheme.mutedForeground)
|
|
}
|
|
}
|
|
|
|
private var isConfigured: Bool {
|
|
if case .configured = controller.setupState { return true }
|
|
return false
|
|
}
|
|
}
|