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>
194 lines
5.5 KiB
Swift
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)
|
|
}
|
|
}
|