moodlit-native/Sources/Core/SmartHome/HomeKitController.swift
till b887917d81 feat(smart-home): μ-9.3 HomeKit als zweiter Smart-Home-Treiber
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>
2026-05-19 15:18:50 +02:00

256 lines
7.7 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.

#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