moodlit-native/Sources/Core/SmartHome/LIFXController.swift
till 78ef922ecb feat(smart-home): μ-9.5 LIFX-Adapter (Cloud-API)
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>
2026-05-19 16:19:28 +02:00

194 lines
5.5 KiB
Swift

import Foundation
import OSLog
/// State + I/O für die LIFX-Cloud-Integration. Parallel zu
/// `HueController`, analoge API.
///
/// Token-Storage: Keychain (analog Hue-AppKey). Light-Selection in
/// App-Group-UserDefaults.
@MainActor
@Observable
final class LIFXController {
enum SetupState: Sendable, Equatable {
case notConfigured
case validating
case configured(lightCount: Int)
case failed(message: String)
}
private(set) var setupState: SetupState = .notConfigured
private(set) var availableLights: [LIFXClient.Light] = []
private(set) var selectedLightIds: Set<String> = []
private(set) var lastError: String?
private let log = Logger(subsystem: "ev.mana.moodlit", category: "lifx-controller")
private let appGroup: String?
private let selectedKey = "moodlit.lifx.selectedLights.v1"
private let tokenKeychainKey = "moodlit.lifx.token.v1"
private var lastApplyTask: Task<Void, Never>?
init(appGroup: String? = nil) {
self.appGroup = appGroup
self.selectedLightIds = Set(defaults.stringArray(forKey: selectedKey) ?? [])
if loadToken() != nil {
setupState = .configured(lightCount: selectedLightIds.count)
}
}
// MARK: - Token-Setup
/// Speichert den Token + validiert ihn gegen LIFX-Cloud + lädt die
/// Lampen-Liste. Bei 401: Setup bleibt notConfigured + Fehler.
func setToken(_ token: String) async {
let trimmed = token.trimmingCharacters(in: .whitespaces)
guard !trimmed.isEmpty else { return }
setupState = .validating
lastError = nil
do {
let client = LIFXClient(token: trimmed)
let lights = try await client.listLights()
saveToken(trimmed)
availableLights = lights
setupState = .configured(lightCount: selectedLightIds.count)
} catch let err as LIFXError {
setupState = .failed(message: err.localizedDescription)
lastError = err.localizedDescription
} catch {
setupState = .failed(message: error.localizedDescription)
lastError = error.localizedDescription
}
}
func refreshLights() async {
guard let token = loadToken() else { return }
do {
availableLights = try await LIFXClient(token: token).listLights()
} catch {
lastError = error.localizedDescription
}
}
func toggleSelected(lightId: String) {
if selectedLightIds.contains(lightId) {
selectedLightIds.remove(lightId)
} else {
selectedLightIds.insert(lightId)
}
defaults.set(Array(selectedLightIds), forKey: selectedKey)
if case .configured = setupState {
setupState = .configured(lightCount: selectedLightIds.count)
}
}
// MARK: - Apply Mood
/// Pusht eine Mood-Palette an alle ausgewählten LIFX-Lampen.
/// Mehrere Farben werden zyklisch verteilt analog HueController.
func applyMood(colors: [String], brightnessPct: Int) {
guard let token = loadToken() else { return }
guard !selectedLightIds.isEmpty, !colors.isEmpty else { return }
lastApplyTask?.cancel()
let lights = Array(selectedLightIds)
let palette = colors
let client = LIFXClient(token: token)
lastApplyTask = Task { [weak self] in
for (idx, lightId) in lights.enumerated() {
if Task.isCancelled { return }
let color = palette[idx % palette.count]
do {
try await client.setState(
selector: "id:\(lightId)",
on: true,
hex: color,
brightnessPct: brightnessPct,
durationSec: 0.6
)
} catch {
await self?.recordError(error)
return
}
}
}
}
func turnOffSelected() {
guard let token = loadToken() else { return }
let lights = Array(selectedLightIds)
let client = LIFXClient(token: token)
Task {
for lightId in lights {
try? await client.setState(
selector: "id:\(lightId)",
on: false,
durationSec: 0.4
)
}
}
}
// MARK: - Reset
func clearConfig() {
removeTokenFromKeychain()
defaults.removeObject(forKey: selectedKey)
selectedLightIds = []
availableLights = []
setupState = .notConfigured
}
// MARK: - Persistence
private var defaults: UserDefaults {
appGroup.flatMap { UserDefaults(suiteName: $0) } ?? .standard
}
@MainActor
private func recordError(_ error: Error) async {
lastError = error.localizedDescription
log.warning("LIFX setState failed: \(error.localizedDescription, privacy: .public)")
}
// MARK: - Keychain
private var keychainService: String { "ev.mana.moodlit.lifx" }
private var keychainAccount: String { tokenKeychainKey }
private func saveToken(_ token: String) {
let data = Data(token.utf8)
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: keychainService,
kSecAttrAccount as String: keychainAccount,
]
SecItemDelete(query as CFDictionary)
var add = query
add[kSecValueData as String] = data
add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
SecItemAdd(add as CFDictionary, nil)
}
private func loadToken() -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: keychainService,
kSecAttrAccount as String: keychainAccount,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
var item: AnyObject?
guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess,
let data = item as? Data,
let str = String(data: data, encoding: .utf8)
else { return nil }
return str
}
private func removeTokenFromKeychain() {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: keychainService,
kSecAttrAccount as String: keychainAccount,
]
SecItemDelete(query as CFDictionary)
}
}