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>
114 lines
3.6 KiB
Swift
114 lines
3.6 KiB
Swift
import Foundation
|
||
|
||
/// LIFX-HTTP-Client gegen `api.lifx.com` (Cloud-API v1).
|
||
///
|
||
/// **Architektur-Entscheidung:** Cloud-API für V1, nicht LAN-UDP-
|
||
/// Protocol. Begründung:
|
||
/// + Einfacher Setup (Token aus cloud.lifx.com paste, kein Pair-Flow)
|
||
/// + HTTPS, kein DTLS-Hassle
|
||
/// + Funktioniert auch wenn das Gerät NICHT im selben LAN ist
|
||
/// − Cloud-Trip = ~200ms RTT (vs ~30ms LAN), für statische Mood-
|
||
/// Switches okay, für tight Beat-Sync (μ-10.3) ungeeignet → LIFX
|
||
/// pulsed nicht beim Beat, nur Hue
|
||
/// − User muss bewusst einen Personal-Access-Token erstellen
|
||
///
|
||
/// Spätere Iteration (μ-9.5.1?): LIFX LAN-Protokoll (UDP binary,
|
||
/// Port 56700) für LAN-Direkt-Steuerung mit besserer Latenz.
|
||
struct LIFXClient: Sendable {
|
||
struct Light: Codable, Hashable, Sendable, Identifiable {
|
||
let id: String
|
||
let label: String
|
||
let connected: Bool
|
||
let power: String // "on" | "off"
|
||
let group: Group?
|
||
let location: Location?
|
||
}
|
||
|
||
struct Group: Codable, Hashable, Sendable {
|
||
let id: String
|
||
let name: String
|
||
}
|
||
|
||
struct Location: Codable, Hashable, Sendable {
|
||
let id: String
|
||
let name: String
|
||
}
|
||
|
||
let token: String
|
||
private let baseURL = URL(string: "https://api.lifx.com/v1/")!
|
||
private let session: URLSession
|
||
|
||
init(token: String, session: URLSession = .shared) {
|
||
self.token = token
|
||
self.session = session
|
||
}
|
||
|
||
// MARK: - Auth / List
|
||
|
||
/// Listet alle Lampen, die dem aktuellen Token gehören.
|
||
func listLights() async throws -> [Light] {
|
||
var req = URLRequest(url: baseURL.appendingPathComponent("lights/all"))
|
||
req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||
let (data, response) = try await session.data(for: req)
|
||
guard let http = response as? HTTPURLResponse else {
|
||
throw LIFXError.networkFailure
|
||
}
|
||
switch http.statusCode {
|
||
case 200:
|
||
return try JSONDecoder().decode([Light].self, from: data)
|
||
case 401:
|
||
throw LIFXError.invalidToken
|
||
default:
|
||
throw LIFXError.apiError("HTTP \(http.statusCode)")
|
||
}
|
||
}
|
||
|
||
// MARK: - Set State
|
||
|
||
/// Setzt Farbe + Helligkeit + Power für einen oder mehrere Lights.
|
||
/// `selector` ist die LIFX-Convention: `id:<uuid>`, `label:<name>`,
|
||
/// `group:<name>`, `all`, oder Komma-separiert.
|
||
///
|
||
/// `hex` wird direkt akzeptiert — LIFX Cloud-API supportet
|
||
/// `color:#rrggbb` seit 2018.
|
||
func setState(
|
||
selector: String,
|
||
on: Bool? = nil,
|
||
hex: String? = nil,
|
||
brightnessPct: Int? = nil,
|
||
durationSec: Double = 0.4
|
||
) async throws {
|
||
var req = URLRequest(url: baseURL.appendingPathComponent("lights/\(selector)/state"))
|
||
req.httpMethod = "PUT"
|
||
req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||
|
||
var body: [String: Any] = ["duration": durationSec]
|
||
if let on { body["power"] = on ? "on" : "off" }
|
||
if let hex { body["color"] = hex.hasPrefix("#") ? hex : "#\(hex)" }
|
||
if let brightnessPct {
|
||
// LIFX brightness ist 0–1.0, nicht 0–100
|
||
body["brightness"] = max(0.0, min(1.0, Double(brightnessPct) / 100.0))
|
||
}
|
||
req.httpBody = try JSONSerialization.data(withJSONObject: body)
|
||
|
||
let (_, response) = try await session.data(for: req)
|
||
guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else {
|
||
throw LIFXError.apiError("setState HTTP \(String(describing: response))")
|
||
}
|
||
}
|
||
}
|
||
|
||
enum LIFXError: Error, LocalizedError, Sendable {
|
||
case invalidToken
|
||
case networkFailure
|
||
case apiError(String)
|
||
|
||
var errorDescription: String? {
|
||
switch self {
|
||
case .invalidToken: return "Token ungültig — prüfe ihn auf cloud.lifx.com/settings"
|
||
case .networkFailure: return "LIFX-Cloud nicht erreichbar"
|
||
case .apiError(let m): return m
|
||
}
|
||
}
|
||
}
|