moodlit-native/Sources/Core/SmartHome/LIFXClient.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

114 lines
3.6 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 01.0, nicht 0100
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
}
}
}