From 398822f28d8da40a02231d76a26666f379c2f8ab Mon Sep 17 00:00:00 2001 From: Adam Borbas Date: Fri, 17 Jul 2026 21:14:18 +0200 Subject: [PATCH] Support kelvin light color-temp attributes (HA 2026.3+) HA 2026.3 removed the mired light attributes (color_temp, min_mireds, max_mireds) and the color_temp turn_on parameter in favour of the kelvin equivalents, so Hemera's color-temp control silently disappeared for lamps on updated servers. Read path (LightEntity): prefer the legacy mired attributes when present, otherwise derive them from color_temp_kelvin / min_/max_color_temp_kelvin (reciprocal conversion; bounds invert). Write path (LightController): send color_temp_kelvin, accepted by HA since 2022.11 so it covers every version Hemera supports. Both old and new servers now work. Co-Authored-By: Claude Opus 4.8 (1M context) --- Hemera/Entities/Light/LightController.swift | 5 +-- Hemera/Entities/Light/LightEntity.swift | 17 ++++++++++ HemeraTests/Entities/LightEntityTests.swift | 36 +++++++++++++++++++++ 3 files changed, 56 insertions(+), 2 deletions(-) diff --git a/Hemera/Entities/Light/LightController.swift b/Hemera/Entities/Light/LightController.swift index e3b5916..b4dc8f8 100644 --- a/Hemera/Entities/Light/LightController.swift +++ b/Hemera/Entities/Light/LightController.swift @@ -35,11 +35,12 @@ final class LightController: LightControlling, ServiceCallErrorHandling { func setColorTemp(_ id: String, to mireds: Int) async { await performServiceCall("Failed to set color temp for \(id)") { + let kelvin = mireds > 0 ? 1_000_000 / mireds : 0 try await serviceCaller.callService( domain: Keys.domain, service: Keys.turnOn, entityId: id, - extraData: [Keys.colorTemp: mireds] + extraData: [Keys.colorTempKelvin: kelvin] ) } } @@ -61,6 +62,6 @@ private enum Keys { static let turnOn = "turn_on" static let turnOff = "turn_off" static let brightness = "brightness" - static let colorTemp = "color_temp" + static let colorTempKelvin = "color_temp_kelvin" static let hsColor = "hs_color" } diff --git a/Hemera/Entities/Light/LightEntity.swift b/Hemera/Entities/Light/LightEntity.swift index 85e380f..eb972d9 100644 --- a/Hemera/Entities/Light/LightEntity.swift +++ b/Hemera/Entities/Light/LightEntity.swift @@ -78,6 +78,7 @@ final class LightEntity: StoredEntity { brightness = entity.attributes["brightness"] as? Int colorMode = entity.attributes["color_mode"] as? String colorTemp = entity.attributes["color_temp"] as? Int + ?? Self.miredsFromKelvin(entity.attributes["color_temp_kelvin"]) if let hs = entity.attributes["hs_color"] as? [Any] { hsColor = hs.compactMap { ($0 as? NSNumber)?.doubleValue } if hsColor?.count != 2 { hsColor = nil } @@ -85,7 +86,9 @@ final class LightEntity: StoredEntity { hsColor = nil } let rawMinMireds = entity.attributes["min_mireds"] as? Int + ?? Self.miredsFromKelvin(entity.attributes["max_color_temp_kelvin"]) let rawMaxMireds = entity.attributes["max_mireds"] as? Int + ?? Self.miredsFromKelvin(entity.attributes["min_color_temp_kelvin"]) if let lo = rawMinMireds, let hi = rawMaxMireds { minMireds = Swift.min(lo, hi) maxMireds = Swift.max(lo, hi) @@ -101,6 +104,20 @@ final class LightEntity: StoredEntity { } } +// MARK: - Kelvin conversion + +private extension LightEntity { + /** + Converts a HA `*_color_temp_kelvin` attribute to mireds (their reciprocal). + Returns nil when the attribute is absent or non-positive so the caller can + fall back to the legacy mired attributes. + */ + static func miredsFromKelvin(_ value: Any?) -> Int? { + guard let kelvin = value as? Int, kelvin > 0 else { return nil } + return 1_000_000 / kelvin + } +} + // MARK: - StoredEntity extension LightEntity { diff --git a/HemeraTests/Entities/LightEntityTests.swift b/HemeraTests/Entities/LightEntityTests.swift index 31499a0..41b9b10 100644 --- a/HemeraTests/Entities/LightEntityTests.swift +++ b/HemeraTests/Entities/LightEntityTests.swift @@ -52,4 +52,40 @@ struct LightEntityTests { #expect(entity.minMireds == nil) #expect(entity.maxMireds == nil) } + + @Test + func update_withKelvinAttributes_derivesInvertedMireds() throws { + let entity = LightEntity(entityId: "light.test") + // HA 2026.3 drops mireds; only kelvin bounds are sent. min mired ↔ max kelvin. + let haEntity = try makeHAEntity(attributes: [ + "color_temp_kelvin": 4000, + "min_color_temp_kelvin": 2000, + "max_color_temp_kelvin": 6535 + ]) + + entity.update(from: haEntity) + + #expect(entity.colorTemp == 1_000_000 / 4000) // 250 + #expect(entity.minMireds == 1_000_000 / 6535) // 153, from the highest kelvin + #expect(entity.maxMireds == 1_000_000 / 2000) // 500, from the lowest kelvin + } + + @Test + func update_prefersLegacyMiredsOverKelvin() throws { + let entity = LightEntity(entityId: "light.test") + let haEntity = try makeHAEntity(attributes: [ + "color_temp": 300, + "min_mireds": 153, + "max_mireds": 500, + "color_temp_kelvin": 4000, + "min_color_temp_kelvin": 2000, + "max_color_temp_kelvin": 6535 + ]) + + entity.update(from: haEntity) + + #expect(entity.colorTemp == 300) + #expect(entity.minMireds == 153) + #expect(entity.maxMireds == 500) + } }