iOS-only: Apple Home-Lampen reagieren auf Mood-Wechsel parallel
zur direkten Hue-Bridge. Trade-off: HomeKit rate-limited (~10Hz),
für Mood-Switches okay, für Beat-Sync zu langsam.
Was:
- Sources/Core/SmartHome/HomeKitController.swift @Observable +
HMHomeManagerDelegate:
- start(): triggert iOS-HomeKit-Permission-Prompt
- LampAccessory-Indexer extrahiert nur Lightbulb-Services, gruppiert
nach Room
- applyMood schreibt Hue/Saturation/Brightness/PowerState pro
Accessory mit RGB→HSV-Konvertierung (HomeKit-Skala: h 0-360,
s/v 0-100)
- Selected-Lamps in App-Group-UserDefaults
- turnOffSelected() beim Player-Close
- Sources/Features/Settings/HomeKitSettingsView.swift: Permission-State-
Anzeige + Lamps gruppiert nach Room mit Multi-Select-Toggle
- SettingsView: NavigationLink zu HomeKitSettingsView (iOS-only)
- MoodPlayerView: pushToHomeKit() + turnOffHomeLights() parallel zu Hue
- project.yml: NSHomeKitUsageDescription + NSLocalNetworkUsageDescription
in Info.plist, com.apple.developer.homekit-Entitlement
(Apple-Dev-Portal-Capability-Aktivierung steht noch aus)
primaryHome → homes.first weil iOS-16.1-Deprecation.
Build iOS+macOS grün, 18/18 Tests grün.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
256 lines
7.7 KiB
Swift
256 lines
7.7 KiB
Swift
#if os(iOS)
|
||
import Foundation
|
||
import HomeKit
|
||
import OSLog
|
||
import SwiftUI
|
||
|
||
/// iOS-HomeKit-Bridge — alternative zu Hue-Direkt-Setup. Funktioniert
|
||
/// mit jeder HomeKit-zertifizierten Lampe (Hue via deren Bridge, LIFX,
|
||
/// Nanoleaf, IKEA Trådfri, Aqara, …).
|
||
///
|
||
/// Trade-off zu Hue-direkt:
|
||
/// + Mehr Vendor-Support, kein Pair-Flow nötig (Apple hat schon
|
||
/// gepaart)
|
||
/// + Funktioniert auch ohne Cloud-Discovery
|
||
/// − Rate-Limit ~10 Hz pro Lampe (HomeKit-Default), für Music-Sync
|
||
/// zu langsam. Für statische Mood-Switches okay.
|
||
/// − iOS-only (HomeKit-Framework ist nicht auf macOS)
|
||
///
|
||
/// **State-Storage:** Selected-Accessory-IDs in App-Group-UserDefaults.
|
||
/// Kein eigener Token — HomeKit-Auth ist System-Permission, die der
|
||
/// User in den iOS-Einstellungen verwaltet.
|
||
@MainActor
|
||
@Observable
|
||
final class HomeKitController: NSObject {
|
||
enum AuthState: Sendable, Equatable {
|
||
case notRequested
|
||
case requesting
|
||
case authorized
|
||
case denied
|
||
case restricted
|
||
}
|
||
|
||
struct LampAccessory: Identifiable, Hashable, Sendable {
|
||
let id: UUID // HMAccessory.uniqueIdentifier
|
||
let name: String
|
||
let room: String?
|
||
let supportsColor: Bool
|
||
let supportsBrightness: Bool
|
||
}
|
||
|
||
private(set) var authState: AuthState = .notRequested
|
||
private(set) var availableLamps: [LampAccessory] = []
|
||
private(set) var selectedLampIds: Set<UUID> = []
|
||
private(set) var lastError: String?
|
||
|
||
private let log = Logger(subsystem: "ev.mana.moodlit", category: "homekit")
|
||
private let appGroup: String?
|
||
private let selectedKey = "moodlit.homekit.selectedLamps.v1"
|
||
|
||
private var manager: HMHomeManager?
|
||
private var lampsByAccessoryId: [UUID: HMAccessory] = [:]
|
||
private var lastApplyTask: Task<Void, Never>?
|
||
|
||
init(appGroup: String? = nil) {
|
||
self.appGroup = appGroup
|
||
super.init()
|
||
if let raw = defaults.array(forKey: selectedKey) as? [String] {
|
||
selectedLampIds = Set(raw.compactMap { UUID(uuidString: $0) })
|
||
}
|
||
}
|
||
|
||
// MARK: - Auth + Discovery
|
||
|
||
/// Erstellt die HMHomeManager-Instanz. Bei erstem Call zeigt iOS
|
||
/// automatisch den System-Permission-Prompt für HomeKit. Status
|
||
/// wird per Delegate-Callback gemeldet.
|
||
func start() {
|
||
guard manager == nil else { return }
|
||
authState = .requesting
|
||
let m = HMHomeManager()
|
||
m.delegate = self
|
||
manager = m
|
||
}
|
||
|
||
// MARK: - Lamp-Selection
|
||
|
||
func toggleSelected(lampId: UUID) {
|
||
if selectedLampIds.contains(lampId) {
|
||
selectedLampIds.remove(lampId)
|
||
} else {
|
||
selectedLampIds.insert(lampId)
|
||
}
|
||
defaults.set(selectedLampIds.map(\.uuidString), forKey: selectedKey)
|
||
}
|
||
|
||
// MARK: - Apply Mood
|
||
|
||
/// Pusht eine Mood-Palette an alle selektierten HomeKit-Lampen.
|
||
/// Mehrere Farben werden zyklisch verteilt (analog HueController).
|
||
func applyMood(colors: [String], brightnessPct: Int) {
|
||
guard authState == .authorized,
|
||
!selectedLampIds.isEmpty,
|
||
!colors.isEmpty
|
||
else { return }
|
||
|
||
lastApplyTask?.cancel()
|
||
let lampIds = Array(selectedLampIds)
|
||
let palette = colors
|
||
|
||
// HMAccessory ist NICHT Sendable — wir bauen die Mapping-Daten
|
||
// auf MainActor, dispatchen dann nur primitive Werte.
|
||
var jobs: [(accessory: HMAccessory, hex: String)] = []
|
||
for (idx, id) in lampIds.enumerated() {
|
||
guard let acc = lampsByAccessoryId[id] else { continue }
|
||
jobs.append((acc, palette[idx % palette.count]))
|
||
}
|
||
|
||
lastApplyTask = Task { @MainActor [weak self] in
|
||
for job in jobs {
|
||
if Task.isCancelled { return }
|
||
await self?.writeLamp(job.accessory, hex: job.hex, brightnessPct: brightnessPct)
|
||
}
|
||
}
|
||
}
|
||
|
||
func turnOffSelected() {
|
||
guard authState == .authorized else { return }
|
||
let lampIds = Array(selectedLampIds)
|
||
Task { @MainActor [weak self] in
|
||
guard let self else { return }
|
||
for id in lampIds {
|
||
guard let acc = self.lampsByAccessoryId[id] else { continue }
|
||
await self.writeOn(acc, on: false)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - HomeKit-IO
|
||
|
||
private func writeLamp(_ accessory: HMAccessory, hex: String, brightnessPct: Int) async {
|
||
guard let conv = HueColor.parseHex(hex) else { return }
|
||
let hsv = rgbToHSV(r: conv.r, g: conv.g, b: conv.b)
|
||
|
||
for service in accessory.services {
|
||
guard service.serviceType == HMServiceTypeLightbulb else { continue }
|
||
for ch in service.characteristics {
|
||
do {
|
||
switch ch.characteristicType {
|
||
case HMCharacteristicTypePowerState:
|
||
try await ch.writeValue(true as NSNumber)
|
||
case HMCharacteristicTypeHue:
|
||
try await ch.writeValue(hsv.h as NSNumber)
|
||
case HMCharacteristicTypeSaturation:
|
||
try await ch.writeValue(hsv.s as NSNumber)
|
||
case HMCharacteristicTypeBrightness:
|
||
try await ch.writeValue(brightnessPct as NSNumber)
|
||
default:
|
||
break
|
||
}
|
||
} catch {
|
||
lastError = error.localizedDescription
|
||
log.warning("HomeKit write failed: \(error.localizedDescription, privacy: .public)")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private func writeOn(_ accessory: HMAccessory, on: Bool) async {
|
||
for service in accessory.services {
|
||
guard service.serviceType == HMServiceTypeLightbulb else { continue }
|
||
for ch in service.characteristics where ch.characteristicType == HMCharacteristicTypePowerState {
|
||
try? await ch.writeValue(on as NSNumber)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Home → LampAccessory-Liste
|
||
|
||
private func indexAccessories(in home: HMHome) {
|
||
var lamps: [LampAccessory] = []
|
||
var map: [UUID: HMAccessory] = [:]
|
||
for acc in home.accessories {
|
||
let lightServices = acc.services.filter { $0.serviceType == HMServiceTypeLightbulb }
|
||
guard !lightServices.isEmpty else { continue }
|
||
|
||
let supportsColor = lightServices.contains { svc in
|
||
svc.characteristics.contains { $0.characteristicType == HMCharacteristicTypeHue }
|
||
}
|
||
let supportsBrightness = lightServices.contains { svc in
|
||
svc.characteristics.contains { $0.characteristicType == HMCharacteristicTypeBrightness }
|
||
}
|
||
|
||
lamps.append(LampAccessory(
|
||
id: acc.uniqueIdentifier,
|
||
name: acc.name,
|
||
room: acc.room?.name,
|
||
supportsColor: supportsColor,
|
||
supportsBrightness: supportsBrightness
|
||
))
|
||
map[acc.uniqueIdentifier] = acc
|
||
}
|
||
// Sortierung: nach Raum, dann nach Name
|
||
lamps.sort { lhs, rhs in
|
||
if lhs.room == rhs.room { return lhs.name < rhs.name }
|
||
return (lhs.room ?? "z") < (rhs.room ?? "z")
|
||
}
|
||
availableLamps = lamps
|
||
lampsByAccessoryId = map
|
||
}
|
||
|
||
private var defaults: UserDefaults {
|
||
appGroup.flatMap { UserDefaults(suiteName: $0) } ?? .standard
|
||
}
|
||
}
|
||
|
||
extension HomeKitController: HMHomeManagerDelegate {
|
||
nonisolated func homeManagerDidUpdateHomes(_ manager: HMHomeManager) {
|
||
Task { @MainActor [weak self] in
|
||
guard let self else { return }
|
||
// HMHomeManager.authorizationStatus ist iOS 13+. Wir mappen
|
||
// auf unseren AuthState.
|
||
let st = manager.authorizationStatus
|
||
if st.contains(.authorized) {
|
||
self.authState = .authorized
|
||
// `primaryHome` ist seit iOS 16.1 deprecated — `homes.first`
|
||
// reicht für UI-Listing.
|
||
if let home = manager.homes.first {
|
||
self.indexAccessories(in: home)
|
||
}
|
||
} else if st.contains(.restricted) {
|
||
self.authState = .restricted
|
||
} else if st.contains(.determined) {
|
||
self.authState = .denied
|
||
} else {
|
||
// `.determined` nicht gesetzt heißt: User hat noch nicht
|
||
// entschieden. Wir warten auf nächsten Callback.
|
||
self.authState = .requesting
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// RGB → HSV-Konvertierung, kompatibel mit HomeKit-Skala:
|
||
/// h: 0-360, s: 0-100, v: 0-100.
|
||
func rgbToHSV(r: Double, g: Double, b: Double) -> (h: Double, s: Double, v: Double) {
|
||
let mx = max(r, g, b)
|
||
let mn = min(r, g, b)
|
||
let delta = mx - mn
|
||
|
||
var h: Double = 0
|
||
if delta > 0 {
|
||
if mx == r {
|
||
h = 60 * ((g - b) / delta).truncatingRemainder(dividingBy: 6)
|
||
} else if mx == g {
|
||
h = 60 * ((b - r) / delta + 2)
|
||
} else {
|
||
h = 60 * ((r - g) / delta + 4)
|
||
}
|
||
}
|
||
if h < 0 { h += 360 }
|
||
|
||
let s = mx == 0 ? 0.0 : (delta / mx) * 100.0
|
||
let v = mx * 100.0
|
||
return (h, s, v)
|
||
}
|
||
#endif
|